From c40d43f103631380f4567230a777038eb2104262 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Thu, 15 Oct 2026 19:23:33 +0300 Subject: [PATCH 01/50] build: scaffold C++23 Qt6/QML tree with CMake, CPM, ccache and QCoro; move Python to legacy/ --- .clang-format | 3 + CMakeLists.txt | 53 + CMakePresets.json | 28 + cmake/CPM.cmake | 1386 +++++++++++++++++ {src => legacy}/pxmodrim/__init__.py | 0 {src => legacy}/pxmodrim/__main__.py | 0 {src => legacy}/pxmodrim/_app.py | 0 {src => legacy}/pxmodrim/core/__init__.py | 0 .../pxmodrim/core/checker/__init__.py | 0 .../pxmodrim/core/checker/checker.py | 0 .../pxmodrim/core/checker/databases.py | 0 .../pxmodrim/core/checker/graph.py | 0 .../pxmodrim/core/checker/issues.py | 0 .../pxmodrim/core/checker/models.py | 0 {src => legacy}/pxmodrim/core/checker/sort.py | 0 {src => legacy}/pxmodrim/core/config.py | 0 {src => legacy}/pxmodrim/core/constants.py | 0 {src => legacy}/pxmodrim/core/context.py | 0 .../pxmodrim/core/downloads/__init__.py | 0 .../pxmodrim/core/downloads/downloader.py | 0 .../pxmodrim/core/downloads/manager.py | 0 .../pxmodrim/core/downloads/steam.py | 0 .../pxmodrim/core/downloads/types.py | 0 {src => legacy}/pxmodrim/core/events.py | 0 {src => legacy}/pxmodrim/core/loading.py | 0 {src => legacy}/pxmodrim/core/migrator.py | 0 {src => legacy}/pxmodrim/core/mod_service.py | 0 .../pxmodrim/core/models/__init__.py | 0 .../pxmodrim/core/models/metadata/__init__.py | 0 .../pxmodrim/core/models/metadata/parsing.py | 0 .../core/models/metadata/structures.py | 0 .../pxmodrim/core/models/view/__init__.py | 0 .../pxmodrim/core/models/view/diagnostics.py | 0 .../pxmodrim/core/models/view/sidebar.py | 0 {src => legacy}/pxmodrim/core/mods_config.py | 0 .../pxmodrim/core/msgspec_hooks.py | 0 .../pxmodrim/core/organizer/__init__.py | 0 {src => legacy}/pxmodrim/core/organizer/db.py | 0 .../pxmodrim/core/organizer/defaults.py | 0 .../pxmodrim/core/organizer/models.py | 0 .../pxmodrim/core/organizer/resolve.py | 0 .../pxmodrim/core/organizer/service.py | 0 {src => legacy}/pxmodrim/core/plugin.py | 0 .../pxmodrim/core/plugin_config.py | 0 {src => legacy}/pxmodrim/core/profiler.py | 0 .../pxmodrim/core/providers/__init__.py | 0 .../pxmodrim/core/providers/base.py | 0 .../pxmodrim/core/providers/core.py | 0 .../pxmodrim/core/providers/local.py | 0 .../pxmodrim/core/services/__init__.py | 0 .../core/services/activation_service.py | 0 .../core/services/diagnostics_service.py | 0 .../pxmodrim/core/services/game_launcher.py | 0 .../pxmodrim/core/services/metadata_cache.py | 0 .../pxmodrim/core/services/mod_discovery.py | 0 .../pxmodrim/core/services/sort_service.py | 0 .../startup_impact_service/__init__.py | 0 .../services/startup_impact_service/db.py | 0 .../services/startup_impact_service/labels.py | 0 .../services/startup_impact_service/models.py | 0 .../services/startup_impact_service/parser.py | 0 .../services/startup_impact_service/phases.py | 0 .../startup_impact_service/service.py | 0 .../pxmodrim/core/services/update_service.py | 0 .../pxmodrim/core/sort/__init__.py | 0 .../pxmodrim/core/sort/community.py | 0 .../pxmodrim/core/sort/community_service.py | 0 {src => legacy}/pxmodrim/core/sort/config.py | 0 .../pxmodrim/core/sort/exceptions.py | 0 {src => legacy}/pxmodrim/core/sort/models.py | 0 .../pxmodrim/core/sort/progress.py | 0 {src => legacy}/pxmodrim/core/sort/tiers.py | 0 {src => legacy}/pxmodrim/core/structures.py | 0 .../pxmodrim/core/support/__init__.py | 0 .../pxmodrim/core/support/report.py | 0 {src => legacy}/pxmodrim/core/utils.py | 0 .../pxmodrim/core/workshop/__init__.py | 0 .../pxmodrim/core/workshop/catalog.py | 0 .../pxmodrim/core/workshop/client.py | 0 .../pxmodrim/core/workshop/favourites.py | 0 .../pxmodrim/core/workshop/types.py | 0 {src => legacy}/pxmodrim/core/xml.py | 0 {src => legacy}/pxmodrim/ui/__init__.py | 0 .../pxmodrim/ui/assets/__init__.py | 0 {src => legacy}/pxmodrim/ui/assets/logo.png | Bin {src => legacy}/pxmodrim/ui/assets/logo.svg | 0 .../pxmodrim/ui/assets/logo_nobg_1.svg | 0 .../pxmodrim/ui/assets/logo_nobg_2.svg | 0 .../pxmodrim/ui/assets/logo_nobg_3.svg | 0 .../pxmodrim/ui/assets/logo_nobg_4.svg | 0 .../pxmodrim/ui/components/FilterSidebar.qml | 0 .../pxmodrim/ui/components/Header.qml | 0 .../pxmodrim/ui/components/IconRail.qml | 0 .../pxmodrim/ui/components/__init__.py | 0 .../pxmodrim/ui/components/banner_widget.py | 0 .../pxmodrim/ui/components/button.py | 0 .../components/controls/DiagnosticBadge.qml | 0 .../ui/components/controls/EmptyState.qml | 0 .../ui/components/controls/ModDescription.qml | 0 .../ui/components/controls/PxBadge.qml | 0 .../ui/components/controls/PxButton.qml | 0 .../ui/components/controls/PxCheckBox.qml | 0 .../components/controls/PxCheckIndicator.qml | 0 .../ui/components/controls/PxComboBox.qml | 0 .../ui/components/controls/PxDialog.qml | 0 .../ui/components/controls/PxDialogFooter.qml | 0 .../ui/components/controls/PxMenu.qml | 0 .../ui/components/controls/PxMenuItem.qml | 0 .../ui/components/controls/PxProgressBar.qml | 0 .../ui/components/controls/PxScrollBar.qml | 0 .../ui/components/controls/PxSpinBox.qml | 0 .../ui/components/controls/PxTextField.qml | 0 .../ui/components/controls/PxToolTip.qml | 0 .../pxmodrim/ui/components/dialog_chrome.py | 0 .../pxmodrim/ui/components/dialogs.py | 0 .../pxmodrim/ui/components/filter_sidebar.py | 0 .../ui/components/header_controller.py | 0 .../pxmodrim/ui/components/header_panel.py | 0 .../pxmodrim/ui/components/icon_button.py | 0 .../pxmodrim/ui/components/icons.py | 0 .../pxmodrim/ui/components/mod_activation.py | 0 .../pxmodrim/ui/components/mod_updates.py | 0 .../pxmodrim/ui/components/popup_parent.py | 0 .../ui/components/procedural_preview.py | 0 .../pxmodrim/ui/components/progress_dialog.py | 0 .../pxmodrim/ui/components/svg_provider.py | 0 .../pxmodrim/ui/components/toast.py | 0 .../pxmodrim/ui/components/unity_rich_text.py | 0 .../pxmodrim/ui/components/view_rail_panel.py | 0 {src => legacy}/pxmodrim/ui/config.py | 0 {src => legacy}/pxmodrim/ui/context.py | 0 {src => legacy}/pxmodrim/ui/mod_selection.py | 0 .../pxmodrim/ui/models/__init__.py | 0 {src => legacy}/pxmodrim/ui/models/impact.py | 0 .../pxmodrim/ui/models/mod_list_model.py | 0 .../ui/models/mod_list_proxy_model.py | 0 .../pxmodrim/ui/models/sidebar_model.py | 0 {src => legacy}/pxmodrim/ui/navigation.py | 0 .../pxmodrim/ui/panels/ModInfo.qml | 0 .../pxmodrim/ui/panels/ModList.qml | 0 .../pxmodrim/ui/panels/Settings.qml | 0 .../pxmodrim/ui/panels/TimeAnalytics.qml | 0 .../pxmodrim/ui/panels/__init__.py | 0 .../pxmodrim/ui/panels/about_panel.py | 0 .../ui/panels/keyboard_shortcuts_dialog.py | 0 .../pxmodrim/ui/panels/mod_info_data.py | 0 .../pxmodrim/ui/panels/mod_info_panel.py | 0 .../pxmodrim/ui/panels/mod_list_panel.py | 0 .../ui/panels/restore_snapshot_dialog.py | 0 .../pxmodrim/ui/panels/settings_panel.py | 0 .../pxmodrim/ui/panels/sidebar_panel.py | 0 .../pxmodrim/ui/panels/startup_impact_data.py | 0 .../ui/panels/time_analytics_panel.py | 0 .../pxmodrim/ui/panels/update_dialog.py | 0 .../ui/panels/upload_report_dialog.py | 0 .../pxmodrim/ui/plugins/__init__.py | 0 .../ui/plugins/downloads/Downloads.qml | 0 .../pxmodrim/ui/plugins/downloads/__init__.py | 0 .../pxmodrim/ui/plugins/downloads/model.py | 0 .../pxmodrim/ui/plugins/downloads/plugin.py | 0 .../pxmodrim/ui/plugins/downloads/view.py | 0 .../ui/plugins/organizer/ErrorBanner.qml | 0 .../pxmodrim/ui/plugins/organizer/ModTree.qml | 0 .../ui/plugins/organizer/RuleEditor.qml | 0 .../ui/plugins/organizer/TagEditor.qml | 0 .../pxmodrim/ui/plugins/organizer/__init__.py | 0 .../pxmodrim/ui/plugins/organizer/dialogs.py | 0 .../ui/plugins/organizer/filter_model.py | 0 .../pxmodrim/ui/plugins/organizer/plugin.py | 0 .../ui/plugins/organizer/tree_model.py | 0 .../pxmodrim/ui/plugins/organizer/view.py | 0 .../steam_downloader/SteamSettings.qml | 0 .../ui/plugins/steam_downloader/__init__.py | 0 .../ui/plugins/steam_downloader/plugin.py | 0 .../steam_workshop/DownloadSidebar.qml | 0 .../plugins/steam_workshop/SteamWorkshop.qml | 0 .../ui/plugins/steam_workshop/__init__.py | 0 .../ui/plugins/steam_workshop/bridge_obj.py | 0 .../steam_workshop/download_queue_model.py | 0 .../steam_workshop/download_sidebar.py | 0 .../ui/plugins/steam_workshop/inject.css | 0 .../ui/plugins/steam_workshop/plugin.py | 0 .../ui/plugins/steam_workshop/ts/api.ts | 0 .../ui/plugins/steam_workshop/ts/badges.ts | 0 .../ui/plugins/steam_workshop/ts/bridge.ts | 0 .../plugins/steam_workshop/ts/collection.ts | 0 .../ui/plugins/steam_workshop/ts/controls.ts | 0 .../ui/plugins/steam_workshop/ts/deps.ts | 0 .../ui/plugins/steam_workshop/ts/detail.ts | 0 .../ui/plugins/steam_workshop/ts/globals.d.ts | 0 .../ui/plugins/steam_workshop/ts/lifecycle.ts | 0 .../ui/plugins/steam_workshop/ts/main.ts | 0 .../plugins/steam_workshop/ts/qwebchannel.js | 0 .../ui/plugins/steam_workshop/ts/rendering.ts | 0 .../plugins/steam_workshop/ts/tsconfig.json | 0 .../ui/plugins/steam_workshop/ts/types.ts | 0 .../ui/plugins/steam_workshop/ts/utils.ts | 0 .../ui/plugins/steam_workshop/view.py | 0 .../ui/plugins/workshop/CatalogCard.qml | 0 .../ui/plugins/workshop/CatalogImage.qml | 0 .../ui/plugins/workshop/CatalogSettings.qml | 0 .../ui/plugins/workshop/CatalogThumb.qml | 0 .../ui/plugins/workshop/CollectionBanner.qml | 0 .../pxmodrim/ui/plugins/workshop/ModRow.qml | 0 .../ui/plugins/workshop/TagPicker.qml | 0 .../pxmodrim/ui/plugins/workshop/Workshop.qml | 0 .../pxmodrim/ui/plugins/workshop/__init__.py | 0 .../pxmodrim/ui/plugins/workshop/details.py | 0 .../pxmodrim/ui/plugins/workshop/installed.py | 0 .../pxmodrim/ui/plugins/workshop/models.py | 0 .../pxmodrim/ui/plugins/workshop/plugin.py | 0 .../pxmodrim/ui/plugins/workshop/thumbs.js | 0 .../pxmodrim/ui/plugins/workshop/view.py | 0 .../pxmodrim/ui/settings_section.py | 0 {src => legacy}/pxmodrim/ui/theme/__init__.py | 0 .../pxmodrim/ui/theme/constants.py | 0 {src => legacy}/pxmodrim/ui/theme/palette.py | 0 .../pxmodrim/ui/theme/qml_theme.py | 0 {src => legacy}/pxmodrim/ui/theme/style.qss | 0 {src => legacy}/pxmodrim/ui/ui_prefs.py | 0 {src => legacy}/pxmodrim/ui/views/__init__.py | 0 {src => legacy}/pxmodrim/ui/views/base.py | 0 .../pxmodrim/ui/views/mods_view.py | 0 .../pxmodrim/ui/window/__init__.py | 0 {src => legacy}/pxmodrim/ui/window/actions.py | 0 .../pxmodrim/ui/window/main_window.py | 0 .../pxmodrim/ui/window/menu_bar.py | 0 {tests => legacy/tests}/__init__.py | 0 .../tests}/benchmarks/bench_discovery.py | 0 .../tests}/benchmarks/bench_sort.py | 0 {tests => legacy/tests}/conftest.py | 0 .../tests}/test_checker/__init__.py | 0 .../tests}/test_checker/test_checker.py | 0 .../tests}/test_checker/test_graph.py | 0 .../tests}/test_checker/test_issues.py | 0 .../tests}/test_compat/test_config.py | 0 .../tests}/test_compat/test_migrator.py | 0 .../tests}/test_downloads/test_manager.py | 0 .../tests}/test_downloads/test_steam.py | 0 {tests => legacy/tests}/test_logging.py | 0 .../tests}/test_metadata/__init__.py | 0 .../data/local_mods/valid_mod/About/About.xml | 0 .../Data/ludeon.rimworld/About/About.xml | 0 .../data/valid_mod/About/About.xml | 0 .../test_metadata/test_metadata_cache.py | 0 .../tests}/test_metadata/test_mods_config.py | 0 .../tests}/test_metadata/test_parsing.py | 0 .../tests}/test_metadata/test_sidebar.py | 0 .../test_metadata/test_startup_impact.py | 0 .../test_metadata/test_startup_impact_db.py | 0 .../test_startup_impact_phases.py | 0 .../tests}/test_metadata/test_structures.py | 0 .../tests}/test_organizer/__init__.py | 0 .../tests}/test_organizer/test_db.py | 0 .../tests}/test_organizer/test_resolve.py | 0 .../tests}/test_organizer/test_service.py | 0 {tests => legacy/tests}/test_packaging.py | 0 {tests => legacy/tests}/test_plugin.py | 0 {tests => legacy/tests}/test_providers.py | 0 .../test_services/test_activation_service.py | 0 .../test_diagnostics_databases.py | 0 .../test_services/test_game_launcher.py | 0 .../tests}/test_services/test_sort_service.py | 0 .../test_services/test_update_service.py | 0 .../tests}/test_sort/test_tier_order.py | 0 .../tests}/test_support/test_report.py | 0 .../tests}/test_ui/test_dialog_chrome.py | 0 .../tests}/test_ui/test_downloads_model.py | 0 .../tests}/test_ui/test_main_window.py | 0 .../tests}/test_ui/test_menu_bar.py | 0 .../tests}/test_ui/test_mod_info_data.py | 0 .../tests}/test_ui/test_mod_list_model.py | 0 .../tests}/test_ui/test_mod_list_panel.py | 0 .../test_ui/test_mod_list_proxy_model.py | 0 .../tests}/test_ui/test_mod_tree_model.py | 0 .../tests}/test_ui/test_mod_updates.py | 0 .../tests}/test_ui/test_navigation.py | 0 .../tests}/test_ui/test_organizer_dialogs.py | 0 .../test_ui/test_organizer_rule_editor.py | 0 .../tests}/test_ui/test_procedural_preview.py | 0 .../test_ui/test_restore_snapshot_dialog.py | 0 .../tests}/test_ui/test_settings_panel.py | 0 .../tests}/test_ui/test_sidebar_model.py | 0 .../test_ui/test_startup_impact_data.py | 0 .../tests}/test_ui/test_toast_and_progress.py | 0 .../tests}/test_ui/test_unity_rich_text.py | 0 {tests => legacy/tests}/test_ui/test_views.py | 0 .../tests}/test_ui/test_workshop_images.py | 0 .../tests}/test_ui/test_workshop_installed.py | 0 .../tests}/test_ui/test_workshop_models.py | 0 .../tests}/test_ui/test_workshop_view.py | 0 .../tests}/test_workshop/conftest.py | 0 .../tests}/test_workshop/test_catalog.py | 0 .../tests}/test_workshop/test_client.py | 0 .../tests}/test_workshop/test_favourites.py | 0 .../tests}/test_workshop/test_types.py | 0 src/app/CMakeLists.txt | 2 + src/app/main.cpp | 18 + src/core/CMakeLists.txt | 7 + src/core/version.cpp | 18 + src/core/version.h | 13 + src/ui/CMakeLists.txt | 9 + src/ui/Main.qml | 14 + src/ui/placeholder.cpp | 1 + tests/CMakeLists.txt | 5 + tests/core/test_version.cpp | 21 + 306 files changed, 1578 insertions(+) create mode 100644 .clang-format create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 cmake/CPM.cmake rename {src => legacy}/pxmodrim/__init__.py (100%) rename {src => legacy}/pxmodrim/__main__.py (100%) rename {src => legacy}/pxmodrim/_app.py (100%) rename {src => legacy}/pxmodrim/core/__init__.py (100%) rename {src => legacy}/pxmodrim/core/checker/__init__.py (100%) rename {src => legacy}/pxmodrim/core/checker/checker.py (100%) rename {src => legacy}/pxmodrim/core/checker/databases.py (100%) rename {src => legacy}/pxmodrim/core/checker/graph.py (100%) rename {src => legacy}/pxmodrim/core/checker/issues.py (100%) rename {src => legacy}/pxmodrim/core/checker/models.py (100%) rename {src => legacy}/pxmodrim/core/checker/sort.py (100%) rename {src => legacy}/pxmodrim/core/config.py (100%) rename {src => legacy}/pxmodrim/core/constants.py (100%) rename {src => legacy}/pxmodrim/core/context.py (100%) rename {src => legacy}/pxmodrim/core/downloads/__init__.py (100%) rename {src => legacy}/pxmodrim/core/downloads/downloader.py (100%) rename {src => legacy}/pxmodrim/core/downloads/manager.py (100%) rename {src => legacy}/pxmodrim/core/downloads/steam.py (100%) rename {src => legacy}/pxmodrim/core/downloads/types.py (100%) rename {src => legacy}/pxmodrim/core/events.py (100%) rename {src => legacy}/pxmodrim/core/loading.py (100%) rename {src => legacy}/pxmodrim/core/migrator.py (100%) rename {src => legacy}/pxmodrim/core/mod_service.py (100%) rename {src => legacy}/pxmodrim/core/models/__init__.py (100%) rename {src => legacy}/pxmodrim/core/models/metadata/__init__.py (100%) rename {src => legacy}/pxmodrim/core/models/metadata/parsing.py (100%) rename {src => legacy}/pxmodrim/core/models/metadata/structures.py (100%) rename {src => legacy}/pxmodrim/core/models/view/__init__.py (100%) rename {src => legacy}/pxmodrim/core/models/view/diagnostics.py (100%) rename {src => legacy}/pxmodrim/core/models/view/sidebar.py (100%) rename {src => legacy}/pxmodrim/core/mods_config.py (100%) rename {src => legacy}/pxmodrim/core/msgspec_hooks.py (100%) rename {src => legacy}/pxmodrim/core/organizer/__init__.py (100%) rename {src => legacy}/pxmodrim/core/organizer/db.py (100%) rename {src => legacy}/pxmodrim/core/organizer/defaults.py (100%) rename {src => legacy}/pxmodrim/core/organizer/models.py (100%) rename {src => legacy}/pxmodrim/core/organizer/resolve.py (100%) rename {src => legacy}/pxmodrim/core/organizer/service.py (100%) rename {src => legacy}/pxmodrim/core/plugin.py (100%) rename {src => legacy}/pxmodrim/core/plugin_config.py (100%) rename {src => legacy}/pxmodrim/core/profiler.py (100%) rename {src => legacy}/pxmodrim/core/providers/__init__.py (100%) rename {src => legacy}/pxmodrim/core/providers/base.py (100%) rename {src => legacy}/pxmodrim/core/providers/core.py (100%) rename {src => legacy}/pxmodrim/core/providers/local.py (100%) rename {src => legacy}/pxmodrim/core/services/__init__.py (100%) rename {src => legacy}/pxmodrim/core/services/activation_service.py (100%) rename {src => legacy}/pxmodrim/core/services/diagnostics_service.py (100%) rename {src => legacy}/pxmodrim/core/services/game_launcher.py (100%) rename {src => legacy}/pxmodrim/core/services/metadata_cache.py (100%) rename {src => legacy}/pxmodrim/core/services/mod_discovery.py (100%) rename {src => legacy}/pxmodrim/core/services/sort_service.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/__init__.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/db.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/labels.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/models.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/parser.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/phases.py (100%) rename {src => legacy}/pxmodrim/core/services/startup_impact_service/service.py (100%) rename {src => legacy}/pxmodrim/core/services/update_service.py (100%) rename {src => legacy}/pxmodrim/core/sort/__init__.py (100%) rename {src => legacy}/pxmodrim/core/sort/community.py (100%) rename {src => legacy}/pxmodrim/core/sort/community_service.py (100%) rename {src => legacy}/pxmodrim/core/sort/config.py (100%) rename {src => legacy}/pxmodrim/core/sort/exceptions.py (100%) rename {src => legacy}/pxmodrim/core/sort/models.py (100%) rename {src => legacy}/pxmodrim/core/sort/progress.py (100%) rename {src => legacy}/pxmodrim/core/sort/tiers.py (100%) rename {src => legacy}/pxmodrim/core/structures.py (100%) rename {src => legacy}/pxmodrim/core/support/__init__.py (100%) rename {src => legacy}/pxmodrim/core/support/report.py (100%) rename {src => legacy}/pxmodrim/core/utils.py (100%) rename {src => legacy}/pxmodrim/core/workshop/__init__.py (100%) rename {src => legacy}/pxmodrim/core/workshop/catalog.py (100%) rename {src => legacy}/pxmodrim/core/workshop/client.py (100%) rename {src => legacy}/pxmodrim/core/workshop/favourites.py (100%) rename {src => legacy}/pxmodrim/core/workshop/types.py (100%) rename {src => legacy}/pxmodrim/core/xml.py (100%) rename {src => legacy}/pxmodrim/ui/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/assets/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/assets/logo.png (100%) rename {src => legacy}/pxmodrim/ui/assets/logo.svg (100%) rename {src => legacy}/pxmodrim/ui/assets/logo_nobg_1.svg (100%) rename {src => legacy}/pxmodrim/ui/assets/logo_nobg_2.svg (100%) rename {src => legacy}/pxmodrim/ui/assets/logo_nobg_3.svg (100%) rename {src => legacy}/pxmodrim/ui/assets/logo_nobg_4.svg (100%) rename {src => legacy}/pxmodrim/ui/components/FilterSidebar.qml (100%) rename {src => legacy}/pxmodrim/ui/components/Header.qml (100%) rename {src => legacy}/pxmodrim/ui/components/IconRail.qml (100%) rename {src => legacy}/pxmodrim/ui/components/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/components/banner_widget.py (100%) rename {src => legacy}/pxmodrim/ui/components/button.py (100%) rename {src => legacy}/pxmodrim/ui/components/controls/DiagnosticBadge.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/EmptyState.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/ModDescription.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxBadge.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxButton.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxCheckBox.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxCheckIndicator.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxComboBox.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxDialog.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxDialogFooter.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxMenu.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxMenuItem.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxProgressBar.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxScrollBar.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxSpinBox.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxTextField.qml (100%) rename {src => legacy}/pxmodrim/ui/components/controls/PxToolTip.qml (100%) rename {src => legacy}/pxmodrim/ui/components/dialog_chrome.py (100%) rename {src => legacy}/pxmodrim/ui/components/dialogs.py (100%) rename {src => legacy}/pxmodrim/ui/components/filter_sidebar.py (100%) rename {src => legacy}/pxmodrim/ui/components/header_controller.py (100%) rename {src => legacy}/pxmodrim/ui/components/header_panel.py (100%) rename {src => legacy}/pxmodrim/ui/components/icon_button.py (100%) rename {src => legacy}/pxmodrim/ui/components/icons.py (100%) rename {src => legacy}/pxmodrim/ui/components/mod_activation.py (100%) rename {src => legacy}/pxmodrim/ui/components/mod_updates.py (100%) rename {src => legacy}/pxmodrim/ui/components/popup_parent.py (100%) rename {src => legacy}/pxmodrim/ui/components/procedural_preview.py (100%) rename {src => legacy}/pxmodrim/ui/components/progress_dialog.py (100%) rename {src => legacy}/pxmodrim/ui/components/svg_provider.py (100%) rename {src => legacy}/pxmodrim/ui/components/toast.py (100%) rename {src => legacy}/pxmodrim/ui/components/unity_rich_text.py (100%) rename {src => legacy}/pxmodrim/ui/components/view_rail_panel.py (100%) rename {src => legacy}/pxmodrim/ui/config.py (100%) rename {src => legacy}/pxmodrim/ui/context.py (100%) rename {src => legacy}/pxmodrim/ui/mod_selection.py (100%) rename {src => legacy}/pxmodrim/ui/models/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/models/impact.py (100%) rename {src => legacy}/pxmodrim/ui/models/mod_list_model.py (100%) rename {src => legacy}/pxmodrim/ui/models/mod_list_proxy_model.py (100%) rename {src => legacy}/pxmodrim/ui/models/sidebar_model.py (100%) rename {src => legacy}/pxmodrim/ui/navigation.py (100%) rename {src => legacy}/pxmodrim/ui/panels/ModInfo.qml (100%) rename {src => legacy}/pxmodrim/ui/panels/ModList.qml (100%) rename {src => legacy}/pxmodrim/ui/panels/Settings.qml (100%) rename {src => legacy}/pxmodrim/ui/panels/TimeAnalytics.qml (100%) rename {src => legacy}/pxmodrim/ui/panels/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/panels/about_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py (100%) rename {src => legacy}/pxmodrim/ui/panels/mod_info_data.py (100%) rename {src => legacy}/pxmodrim/ui/panels/mod_info_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/mod_list_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/restore_snapshot_dialog.py (100%) rename {src => legacy}/pxmodrim/ui/panels/settings_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/sidebar_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/startup_impact_data.py (100%) rename {src => legacy}/pxmodrim/ui/panels/time_analytics_panel.py (100%) rename {src => legacy}/pxmodrim/ui/panels/update_dialog.py (100%) rename {src => legacy}/pxmodrim/ui/panels/upload_report_dialog.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/downloads/Downloads.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/downloads/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/downloads/model.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/downloads/plugin.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/downloads/view.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/ErrorBanner.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/ModTree.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/RuleEditor.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/TagEditor.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/dialogs.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/filter_model.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/plugin.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/tree_model.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/organizer/view.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_downloader/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_downloader/plugin.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/inject.css (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/plugin.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/api.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/main.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/types.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts (100%) rename {src => legacy}/pxmodrim/ui/plugins/steam_workshop/view.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/CatalogCard.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/CatalogImage.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/CatalogSettings.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/CatalogThumb.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/CollectionBanner.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/ModRow.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/TagPicker.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/Workshop.qml (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/details.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/installed.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/models.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/plugin.py (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/thumbs.js (100%) rename {src => legacy}/pxmodrim/ui/plugins/workshop/view.py (100%) rename {src => legacy}/pxmodrim/ui/settings_section.py (100%) rename {src => legacy}/pxmodrim/ui/theme/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/theme/constants.py (100%) rename {src => legacy}/pxmodrim/ui/theme/palette.py (100%) rename {src => legacy}/pxmodrim/ui/theme/qml_theme.py (100%) rename {src => legacy}/pxmodrim/ui/theme/style.qss (100%) rename {src => legacy}/pxmodrim/ui/ui_prefs.py (100%) rename {src => legacy}/pxmodrim/ui/views/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/views/base.py (100%) rename {src => legacy}/pxmodrim/ui/views/mods_view.py (100%) rename {src => legacy}/pxmodrim/ui/window/__init__.py (100%) rename {src => legacy}/pxmodrim/ui/window/actions.py (100%) rename {src => legacy}/pxmodrim/ui/window/main_window.py (100%) rename {src => legacy}/pxmodrim/ui/window/menu_bar.py (100%) rename {tests => legacy/tests}/__init__.py (100%) rename {tests => legacy/tests}/benchmarks/bench_discovery.py (100%) rename {tests => legacy/tests}/benchmarks/bench_sort.py (100%) rename {tests => legacy/tests}/conftest.py (100%) rename {tests => legacy/tests}/test_checker/__init__.py (100%) rename {tests => legacy/tests}/test_checker/test_checker.py (100%) rename {tests => legacy/tests}/test_checker/test_graph.py (100%) rename {tests => legacy/tests}/test_checker/test_issues.py (100%) rename {tests => legacy/tests}/test_compat/test_config.py (100%) rename {tests => legacy/tests}/test_compat/test_migrator.py (100%) rename {tests => legacy/tests}/test_downloads/test_manager.py (100%) rename {tests => legacy/tests}/test_downloads/test_steam.py (100%) rename {tests => legacy/tests}/test_logging.py (100%) rename {tests => legacy/tests}/test_metadata/__init__.py (100%) rename {tests => legacy/tests}/test_metadata/data/local_mods/valid_mod/About/About.xml (100%) rename {tests => legacy/tests}/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml (100%) rename {tests => legacy/tests}/test_metadata/data/valid_mod/About/About.xml (100%) rename {tests => legacy/tests}/test_metadata/test_metadata_cache.py (100%) rename {tests => legacy/tests}/test_metadata/test_mods_config.py (100%) rename {tests => legacy/tests}/test_metadata/test_parsing.py (100%) rename {tests => legacy/tests}/test_metadata/test_sidebar.py (100%) rename {tests => legacy/tests}/test_metadata/test_startup_impact.py (100%) rename {tests => legacy/tests}/test_metadata/test_startup_impact_db.py (100%) rename {tests => legacy/tests}/test_metadata/test_startup_impact_phases.py (100%) rename {tests => legacy/tests}/test_metadata/test_structures.py (100%) rename {tests => legacy/tests}/test_organizer/__init__.py (100%) rename {tests => legacy/tests}/test_organizer/test_db.py (100%) rename {tests => legacy/tests}/test_organizer/test_resolve.py (100%) rename {tests => legacy/tests}/test_organizer/test_service.py (100%) rename {tests => legacy/tests}/test_packaging.py (100%) rename {tests => legacy/tests}/test_plugin.py (100%) rename {tests => legacy/tests}/test_providers.py (100%) rename {tests => legacy/tests}/test_services/test_activation_service.py (100%) rename {tests => legacy/tests}/test_services/test_diagnostics_databases.py (100%) rename {tests => legacy/tests}/test_services/test_game_launcher.py (100%) rename {tests => legacy/tests}/test_services/test_sort_service.py (100%) rename {tests => legacy/tests}/test_services/test_update_service.py (100%) rename {tests => legacy/tests}/test_sort/test_tier_order.py (100%) rename {tests => legacy/tests}/test_support/test_report.py (100%) rename {tests => legacy/tests}/test_ui/test_dialog_chrome.py (100%) rename {tests => legacy/tests}/test_ui/test_downloads_model.py (100%) rename {tests => legacy/tests}/test_ui/test_main_window.py (100%) rename {tests => legacy/tests}/test_ui/test_menu_bar.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_info_data.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_list_model.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_list_panel.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_list_proxy_model.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_tree_model.py (100%) rename {tests => legacy/tests}/test_ui/test_mod_updates.py (100%) rename {tests => legacy/tests}/test_ui/test_navigation.py (100%) rename {tests => legacy/tests}/test_ui/test_organizer_dialogs.py (100%) rename {tests => legacy/tests}/test_ui/test_organizer_rule_editor.py (100%) rename {tests => legacy/tests}/test_ui/test_procedural_preview.py (100%) rename {tests => legacy/tests}/test_ui/test_restore_snapshot_dialog.py (100%) rename {tests => legacy/tests}/test_ui/test_settings_panel.py (100%) rename {tests => legacy/tests}/test_ui/test_sidebar_model.py (100%) rename {tests => legacy/tests}/test_ui/test_startup_impact_data.py (100%) rename {tests => legacy/tests}/test_ui/test_toast_and_progress.py (100%) rename {tests => legacy/tests}/test_ui/test_unity_rich_text.py (100%) rename {tests => legacy/tests}/test_ui/test_views.py (100%) rename {tests => legacy/tests}/test_ui/test_workshop_images.py (100%) rename {tests => legacy/tests}/test_ui/test_workshop_installed.py (100%) rename {tests => legacy/tests}/test_ui/test_workshop_models.py (100%) rename {tests => legacy/tests}/test_ui/test_workshop_view.py (100%) rename {tests => legacy/tests}/test_workshop/conftest.py (100%) rename {tests => legacy/tests}/test_workshop/test_catalog.py (100%) rename {tests => legacy/tests}/test_workshop/test_client.py (100%) rename {tests => legacy/tests}/test_workshop/test_favourites.py (100%) rename {tests => legacy/tests}/test_workshop/test_types.py (100%) create mode 100644 src/app/CMakeLists.txt create mode 100644 src/app/main.cpp create mode 100644 src/core/CMakeLists.txt create mode 100644 src/core/version.cpp create mode 100644 src/core/version.h create mode 100644 src/ui/CMakeLists.txt create mode 100644 src/ui/Main.qml create mode 100644 src/ui/placeholder.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/core/test_version.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..4ca6bcb --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +BasedOnStyle: Google +IndentWidth: 4 +ColumnLimit: 100 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..080ef78 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.24) +project(PxModRim VERSION 0.3.1 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_AUTOMOC ON) + +option(PX_BUILD_TESTS "Build tests" ON) +set(PX_WORKSHOP_VARIANT "SteamWorkshop" CACHE STRING + "NoWorkshop | SteamWorkshop | NativeWorkshop") +set_property(CACHE PX_WORKSHOP_VARIANT PROPERTY STRINGS + NoWorkshop SteamWorkshop NativeWorkshop) + +find_program(PX_CCACHE ccache) +if(PX_CCACHE) + set(CMAKE_CXX_COMPILER_LAUNCHER "${PX_CCACHE}") + message(STATUS "ccache: ${PX_CCACHE}") +endif() + +set(CPM_USE_LOCAL_PACKAGES ON) +set(CPM_SOURCE_CACHE "$ENV{HOME}/.cache/CPM" CACHE PATH "") +include(cmake/CPM.cmake) + +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Network Sql Gui Quick QuickControls2 Svg Widgets) +qt_standard_project_setup(REQUIRES 6.8) +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") + +if(NOT PX_WORKSHOP_VARIANT STREQUAL "NativeWorkshop" AND NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") + find_package(Qt6 6.8 REQUIRED COMPONENTS WebEngineQuick) +endif() +find_package(QCoro6 REQUIRED COMPONENTS Core Network Qml Quick) +qcoro_enable_coroutines() + +find_package(pugixml REQUIRED) +find_package(spdlog REQUIRED) +CPMAddPackage("gh:stephenberry/glaze@5.5.4") +if(PX_BUILD_TESTS) + CPMAddPackage("gh:catchorg/Catch2@3.8.1") + list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") +endif() + +add_compile_options(-Wall -Wextra -Wpedantic) + +add_subdirectory(src/core) +add_subdirectory(src/ui) +add_subdirectory(src/app) + +if(PX_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..28adfe6 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,28 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "default", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "release", + "inherits": "default", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_INTERPROCEDURAL_OPTIMIZATION": "ON" } + } + ], + "buildPresets": [ + { "name": "default", "configurePreset": "default" }, + { "name": "release", "configurePreset": "release" } + ], + "testPresets": [ + { + "name": "default", + "configurePreset": "default", + "environment": { "QT_QPA_PLATFORM": "offscreen" }, + "output": { "outputOnFailure": true } + } + ] +} diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 0000000..b7075fb --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,1386 @@ +# CPM.cmake - CMake's missing package manager +# =========================================== +# See https://github.com/cpm-cmake/CPM.cmake for usage and update instructions. +# +# MIT License +# ----------- +#[[ + Copyright (c) 2019-2023 Lars Melchior and contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +cmake_minimum_required(VERSION 3.14 FATAL_ERROR) + +# Initialize logging prefix +if(NOT CPM_INDENT) + set(CPM_INDENT + "CPM:" + CACHE INTERNAL "" + ) +endif() + +if(NOT COMMAND cpm_message) + function(cpm_message) + message(${ARGV}) + endfunction() +endif() + +if(DEFINED EXTRACTED_CPM_VERSION) + set(CURRENT_CPM_VERSION "${EXTRACTED_CPM_VERSION}${CPM_DEVELOPMENT}") +else() + set(CURRENT_CPM_VERSION 0.43.2) +endif() + +get_filename_component(CPM_CURRENT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" REALPATH) +if(CPM_DIRECTORY) + if(NOT CPM_DIRECTORY STREQUAL CPM_CURRENT_DIRECTORY) + if(CPM_VERSION VERSION_LESS CURRENT_CPM_VERSION) + message( + AUTHOR_WARNING + "${CPM_INDENT} \ +A dependency is using a more recent CPM version (${CURRENT_CPM_VERSION}) than the current project (${CPM_VERSION}). \ +It is recommended to upgrade CPM to the most recent version. \ +See https://github.com/cpm-cmake/CPM.cmake for more information." + ) + endif() + if(${CMAKE_VERSION} VERSION_LESS "3.17.0") + include(FetchContent) + endif() + return() + endif() + + get_property( + CPM_INITIALIZED GLOBAL "" + PROPERTY CPM_INITIALIZED + SET + ) + if(CPM_INITIALIZED) + return() + endif() +endif() + +if(CURRENT_CPM_VERSION MATCHES "development-version") + message( + WARNING "${CPM_INDENT} Your project is using an unstable development version of CPM.cmake. \ +Please update to a recent release if possible. \ +See https://github.com/cpm-cmake/CPM.cmake for details." + ) +endif() + +set_property(GLOBAL PROPERTY CPM_INITIALIZED true) + +macro(cpm_set_policies) + # the policy allows us to change options without caching + cmake_policy(SET CMP0077 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + + # the policy allows us to change set(CACHE) without caching + if(POLICY CMP0126) + cmake_policy(SET CMP0126 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) + endif() + + # The policy uses the download time for timestamp, instead of the timestamp in the archive. This + # allows for proper rebuilds when a projects url changes + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) + endif() + + # treat relative git repository paths as being relative to the parent project's remote + if(POLICY CMP0150) + cmake_policy(SET CMP0150 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0150 NEW) + endif() +endmacro() +cpm_set_policies() + +option(CPM_USE_LOCAL_PACKAGES "Always try to use `find_package` to get dependencies" + $ENV{CPM_USE_LOCAL_PACKAGES} +) +option(CPM_LOCAL_PACKAGES_ONLY "Only use `find_package` to get dependencies" + $ENV{CPM_LOCAL_PACKAGES_ONLY} +) +option(CPM_DOWNLOAD_ALL "Always download dependencies from source" $ENV{CPM_DOWNLOAD_ALL}) +option(CPM_DONT_UPDATE_MODULE_PATH "Don't update the module path to allow using find_package" + $ENV{CPM_DONT_UPDATE_MODULE_PATH} +) +option(CPM_DONT_CREATE_PACKAGE_LOCK "Don't create a package lock file in the binary path" + $ENV{CPM_DONT_CREATE_PACKAGE_LOCK} +) +option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK + "Add all packages added through CPM.cmake to the package lock" + $ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK} +) +option(CPM_USE_NAMED_CACHE_DIRECTORIES + "Use additional directory of package name in cache on the most nested level." + $ENV{CPM_USE_NAMED_CACHE_DIRECTORIES} +) + +set(CPM_VERSION + ${CURRENT_CPM_VERSION} + CACHE INTERNAL "" +) +set(CPM_DIRECTORY + ${CPM_CURRENT_DIRECTORY} + CACHE INTERNAL "" +) +set(CPM_FILE + ${CMAKE_CURRENT_LIST_FILE} + CACHE INTERNAL "" +) +set(CPM_PACKAGES + "" + CACHE INTERNAL "" +) +set(CPM_DRY_RUN + OFF + CACHE INTERNAL "Don't download or configure dependencies (for testing)" +) + +if(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_SOURCE_CACHE_DEFAULT $ENV{CPM_SOURCE_CACHE}) +else() + set(CPM_SOURCE_CACHE_DEFAULT OFF) +endif() + +set(CPM_SOURCE_CACHE + ${CPM_SOURCE_CACHE_DEFAULT} + CACHE PATH "Directory to download CPM dependencies" +) + +if(NOT CPM_DONT_UPDATE_MODULE_PATH AND NOT DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + set(CPM_MODULE_PATH + "${CMAKE_BINARY_DIR}/CPM_modules" + CACHE INTERNAL "" + ) + # remove old modules + file(REMOVE_RECURSE ${CPM_MODULE_PATH}) + file(MAKE_DIRECTORY ${CPM_MODULE_PATH}) + # locally added CPM modules should override global packages + set(CMAKE_MODULE_PATH "${CPM_MODULE_PATH};${CMAKE_MODULE_PATH}") +endif() + +if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + set(CPM_PACKAGE_LOCK_FILE + "${CMAKE_BINARY_DIR}/cpm-package-lock.cmake" + CACHE INTERNAL "" + ) + file(WRITE ${CPM_PACKAGE_LOCK_FILE} + "# CPM Package Lock\n# This file should be committed to version control\n\n" + ) +endif() + +include(FetchContent) + +# Try to infer package name from git repository uri (path or url) +function(cpm_package_name_from_git_uri URI RESULT) + if("${URI}" MATCHES "([^/:]+)/?.git/?$") + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + else() + unset(${RESULT} PARENT_SCOPE) + endif() +endfunction() + +# Find the shortest hash that can be used eg, if origin_hash is +# cccb77ae9609d2768ed80dd42cec54f77b1f1455 the following files will be checked, until one is found +# that is either empty (allowing us to assign origin_hash), or whose contents matches ${origin_hash} +# +# * .../cccb.hash +# * .../cccb77ae.hash +# * .../cccb77ae9609.hash +# * .../cccb77ae9609d276.hash +# * etc +# +# We will be able to use a shorter path with very high probability, but in the (rare) event that the +# first couple characters collide, we will check longer and longer substrings. +function(cpm_get_shortest_hash source_cache_dir origin_hash short_hash_output_var) + # for compatibility with caches populated by a previous version of CPM, check if a directory using + # the full hash already exists + if(EXISTS "${source_cache_dir}/${origin_hash}") + set(${short_hash_output_var} + "${origin_hash}" + PARENT_SCOPE + ) + return() + endif() + + foreach(len RANGE 4 40 4) + string(SUBSTRING "${origin_hash}" 0 ${len} short_hash) + set(hash_lock ${source_cache_dir}/${short_hash}.lock) + set(hash_fp ${source_cache_dir}/${short_hash}.hash) + # Take a lock, so we don't have a race condition with another instance of cmake. We will release + # this lock when we can, however, if there is an error, we want to ensure it gets released on + # it's own on exit from the function. + file(LOCK ${hash_lock} GUARD FUNCTION) + + # Load the contents of .../${short_hash}.hash + file(TOUCH ${hash_fp}) + file(READ ${hash_fp} hash_fp_contents) + + if(hash_fp_contents STREQUAL "") + # Write the origin hash + file(WRITE ${hash_fp} ${origin_hash}) + file(LOCK ${hash_lock} RELEASE) + break() + elseif(hash_fp_contents STREQUAL origin_hash) + file(LOCK ${hash_lock} RELEASE) + break() + else() + file(LOCK ${hash_lock} RELEASE) + endif() + endforeach() + set(${short_hash_output_var} + "${short_hash}" + PARENT_SCOPE + ) +endfunction() + +# Try to infer package name and version from a url +function(cpm_package_name_and_ver_from_url url outName outVer) + if(url MATCHES + "[/\\?]([a-zA-Z0-9_\\.-]+)\\.(tar|tar\\.gz|tar\\.bz2|tar\\.xz|tar\\.zst|zip|ZIP)(\\?|/|$)" + ) + # We matched an archive + set(filename "${CMAKE_MATCH_1}") + + if(filename MATCHES "([a-zA-Z0-9_\\.-]+)[_-]v?(([0-9]+\\.)*[0-9]+[a-zA-Z0-9]*)") + # We matched - (ie foo-1.2.3) + set(${outName} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + set(${outVer} + "${CMAKE_MATCH_2}" + PARENT_SCOPE + ) + elseif(filename MATCHES "(([0-9]+\\.)+[0-9]+[a-zA-Z0-9]*)") + # We couldn't find a name, but we found a version + # + # In many cases (which we don't handle here) the url would look something like + # `irrelevant/ACTUAL_PACKAGE_NAME/irrelevant/1.2.3.zip`. In such a case we can't possibly + # distinguish the package name from the irrelevant bits. Moreover if we try to match the + # package name from the filename, we'd get bogus at best. + unset(${outName} PARENT_SCOPE) + set(${outVer} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + else() + # Boldly assume that the file name is the package name. + # + # Yes, something like `irrelevant/ACTUAL_NAME/irrelevant/download.zip` will ruin our day, but + # such cases should be quite rare. No popular service does this... we think. + set(${outName} + "${filename}" + PARENT_SCOPE + ) + unset(${outVer} PARENT_SCOPE) + endif() + else() + # No ideas yet what to do with non-archives + unset(${outName} PARENT_SCOPE) + unset(${outVer} PARENT_SCOPE) + endif() +endfunction() + +function(cpm_find_package NAME VERSION) + string(REPLACE " " ";" EXTRA_ARGS "${ARGN}") + find_package(${NAME} ${VERSION} ${EXTRA_ARGS} QUIET) + if(${CPM_ARGS_NAME}_FOUND) + if(DEFINED ${CPM_ARGS_NAME}_VERSION) + set(VERSION ${${CPM_ARGS_NAME}_VERSION}) + endif() + cpm_message(STATUS "${CPM_INDENT} Using local package ${CPM_ARGS_NAME}@${VERSION}") + CPMRegisterPackage(${CPM_ARGS_NAME} "${VERSION}") + set(CPM_PACKAGE_FOUND + YES + PARENT_SCOPE + ) + else() + set(CPM_PACKAGE_FOUND + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Create a custom FindXXX.cmake module for a CPM package This prevents `find_package(NAME)` from +# finding the system library +function(cpm_create_module_file Name) + if(NOT CPM_DONT_UPDATE_MODULE_PATH) + if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + # Redirect find_package calls to the CPM package. This is what FetchContent does when you set + # OVERRIDE_FIND_PACKAGE. The CMAKE_FIND_PACKAGE_REDIRECTS_DIR works for find_package in CONFIG + # mode, unlike the Find${Name}.cmake fallback. CMAKE_FIND_PACKAGE_REDIRECTS_DIR is not defined + # in script mode, or in CMake < 3.24. + # https://cmake.org/cmake/help/latest/module/FetchContent.html#fetchcontent-find-package-integration-examples + string(TOLOWER ${Name} NameLower) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config.cmake + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${NameLower}-extra.cmake\" OPTIONAL)\n" + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${Name}Extra.cmake\" OPTIONAL)\n" + ) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config-version.cmake + "set(PACKAGE_VERSION_COMPATIBLE TRUE)\n" "set(PACKAGE_VERSION_EXACT TRUE)\n" + ) + else() + file(WRITE ${CPM_MODULE_PATH}/Find${Name}.cmake + "include(\"${CPM_FILE}\")\n${ARGN}\nset(${Name}_FOUND TRUE)" + ) + endif() + endif() +endfunction() + +# Find a package locally or fallback to CPMAddPackage +function(CPMFindPackage) + set(oneValueArgs NAME VERSION GIT_TAG FIND_PACKAGE_ARGUMENTS) + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "" ${ARGN}) + + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + set(downloadPackage ${CPM_DOWNLOAD_ALL}) + if(DEFINED CPM_DOWNLOAD_${CPM_ARGS_NAME}) + set(downloadPackage ${CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + elseif(DEFINED ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + set(downloadPackage $ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + endif() + if(downloadPackage) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(NOT CPM_PACKAGE_FOUND) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + endif() + +endfunction() + +# checks if a package has been added before +function(cpm_check_if_package_already_added CPM_ARGS_NAME CPM_ARGS_VERSION) + if("${CPM_ARGS_NAME}" IN_LIST CPM_PACKAGES) + CPMGetPackageVersion(${CPM_ARGS_NAME} CPM_PACKAGE_VERSION) + if("${CPM_PACKAGE_VERSION}" VERSION_LESS "${CPM_ARGS_VERSION}") + message( + WARNING + "${CPM_INDENT} Requires a newer version of ${CPM_ARGS_NAME} (${CPM_ARGS_VERSION}) than currently included (${CPM_PACKAGE_VERSION})." + ) + endif() + cpm_get_fetch_properties(${CPM_ARGS_NAME}) + set(${CPM_ARGS_NAME}_ADDED NO) + set(CPM_PACKAGE_ALREADY_ADDED + YES + PARENT_SCOPE + ) + cpm_export_variables(${CPM_ARGS_NAME}) + else() + set(CPM_PACKAGE_ALREADY_ADDED + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Parse the argument of CPMAddPackage in case a single one was provided and convert it to a list of +# arguments which can then be parsed idiomatically. For example gh:foo/bar@1.2.3 will be converted +# to: GITHUB_REPOSITORY;foo/bar;VERSION;1.2.3 +function(cpm_parse_add_package_single_arg arg outArgs) + # Look for a scheme + if("${arg}" MATCHES "^([a-zA-Z]+):(.+)$") + string(TOLOWER "${CMAKE_MATCH_1}" scheme) + set(uri "${CMAKE_MATCH_2}") + + # Check for CPM-specific schemes + if(scheme STREQUAL "gh") + set(out "GITHUB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "gl") + set(out "GITLAB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "bb") + set(out "BITBUCKET_REPOSITORY;${uri}") + set(packageType "git") + # A CPM-specific scheme was not found. Looks like this is a generic URL so try to determine + # type + elseif(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Fall back to a URL + set(out "URL;${arg}") + set(packageType "archive") + + # We could also check for SVN since FetchContent supports it, but SVN is so rare these days. + # We just won't bother with the additional complexity it will induce in this function. SVN is + # done by multi-arg + endif() + else() + if(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Give up + message(FATAL_ERROR "${CPM_INDENT} Can't determine package type of '${arg}'") + endif() + endif() + + # For all packages we interpret @... as version. Only replace the last occurrence. Thus URIs + # containing '@' can be used + string(REGEX REPLACE "@([^@]+)$" ";VERSION;\\1" out "${out}") + + # Parse the rest according to package type + if(packageType STREQUAL "git") + # For git repos we interpret #... as a tag or branch or commit hash + string(REGEX REPLACE "#([^#]+)$" ";GIT_TAG;\\1" out "${out}") + elseif(packageType STREQUAL "archive") + # For archives we interpret #... as a URL hash. + string(REGEX REPLACE "#([^#]+)$" ";URL_HASH;\\1" out "${out}") + # We don't try to parse the version if it's not provided explicitly. cpm_get_version_from_url + # should do this at a later point + else() + # We should never get here. This is an assertion and hitting it means there's a problem with the + # code above. A packageType was set, but not handled by this if-else. + message(FATAL_ERROR "${CPM_INDENT} Unsupported package type '${packageType}' of '${arg}'") + endif() + + set(${outArgs} + ${out} + PARENT_SCOPE + ) +endfunction() + +# Check that the working directory for a git repo is clean +function(cpm_check_git_working_dir_is_clean repoPath gitTag isClean) + + find_package(Git REQUIRED) + + if(NOT GIT_EXECUTABLE) + # No git executable, assume directory is clean + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + # check for uncommitted changes + execute_process( + COMMAND ${GIT_EXECUTABLE} status --porcelain + RESULT_VARIABLE resultGitStatus + OUTPUT_VARIABLE repoStatus + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET + WORKING_DIRECTORY ${repoPath} + ) + if(resultGitStatus) + # not supposed to happen, assume clean anyway + message(WARNING "${CPM_INDENT} Calling git status on folder ${repoPath} failed") + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + if(NOT "${repoStatus}" STREQUAL "") + set(${isClean} + FALSE + PARENT_SCOPE + ) + return() + endif() + + # check for committed changes + execute_process( + COMMAND ${GIT_EXECUTABLE} diff -s --exit-code ${gitTag} + RESULT_VARIABLE resultGitDiff + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_QUIET + WORKING_DIRECTORY ${repoPath} + ) + + if(${resultGitDiff} EQUAL 0) + set(${isClean} + TRUE + PARENT_SCOPE + ) + else() + set(${isClean} + FALSE + PARENT_SCOPE + ) + endif() + +endfunction() + +# Add PATCH_COMMAND to CPM_ARGS_UNPARSED_ARGUMENTS. This method consumes a list of files in ARGN +# then generates a `PATCH_COMMAND` appropriate for `ExternalProject_Add()`. This command is appended +# to the parent scope's `CPM_ARGS_UNPARSED_ARGUMENTS`. +function(cpm_add_patches) + # Return if no patch files are supplied. + if(NOT ARGN) + return() + endif() + + # Find the patch program. + find_program(PATCH_EXECUTABLE patch) + if(CMAKE_HOST_WIN32 AND NOT PATCH_EXECUTABLE) + # The Windows git executable is distributed with patch.exe. Find the path to the executable, if + # it exists, then search `../usr/bin` and `../../usr/bin` for patch.exe. + find_package(Git QUIET) + if(GIT_EXECUTABLE) + get_filename_component(extra_search_path ${GIT_EXECUTABLE} DIRECTORY) + get_filename_component(extra_search_path_1up ${extra_search_path} DIRECTORY) + get_filename_component(extra_search_path_2up ${extra_search_path_1up} DIRECTORY) + find_program( + PATCH_EXECUTABLE patch HINTS "${extra_search_path_1up}/usr/bin" + "${extra_search_path_2up}/usr/bin" + ) + endif() + endif() + if(NOT PATCH_EXECUTABLE) + message(FATAL_ERROR "Couldn't find `patch` executable to use with PATCHES keyword.") + endif() + + # Create a temporary + set(temp_list ${CPM_ARGS_UNPARSED_ARGUMENTS}) + + # Ensure each file exists (or error out) and add it to the list. + set(first_item True) + foreach(PATCH_FILE ${ARGN}) + # Make sure the patch file exists, if we can't find it, try again in the current directory. + if(NOT EXISTS "${PATCH_FILE}") + if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + message(FATAL_ERROR "Couldn't find patch file: '${PATCH_FILE}'") + endif() + set(PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + endif() + + # Convert to absolute path for use with patch file command. + get_filename_component(PATCH_FILE "${PATCH_FILE}" ABSOLUTE) + + # The first patch entry must be preceded by "PATCH_COMMAND" while the following items are + # preceded by "&&". + if(first_item) + set(first_item False) + list(APPEND temp_list "PATCH_COMMAND") + else() + list(APPEND temp_list "&&") + endif() + # Add the patch command to the list + list(APPEND temp_list "${PATCH_EXECUTABLE}" "-p1" "<" "${PATCH_FILE}") + endforeach() + + # Move temp out into parent scope. + set(CPM_ARGS_UNPARSED_ARGUMENTS + ${temp_list} + PARENT_SCOPE + ) + +endfunction() + +# method to overwrite internal FetchContent properties, to allow using CPM.cmake to overload +# FetchContent calls. As these are internal cmake properties, this method should be used carefully +# and may need modification in future CMake versions. Source: +# https://github.com/Kitware/CMake/blob/dc3d0b5a0a7d26d43d6cfeb511e224533b5d188f/Modules/FetchContent.cmake#L1152 +function(cpm_override_fetchcontent contentName) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "SOURCE_DIR;BINARY_DIR" "") + if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "") + message(FATAL_ERROR "${CPM_INDENT} Unsupported arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + string(TOLOWER ${contentName} contentNameLower) + set(prefix "_FetchContent_${contentNameLower}") + + set(propertyName "${prefix}_sourceDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_SOURCE_DIR}") + + set(propertyName "${prefix}_binaryDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_BINARY_DIR}") + + set(propertyName "${prefix}_populated") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} TRUE) +endfunction() + +# Download and add a package from source +function(CPMAddPackage) + cpm_set_policies() + + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + CUSTOM_CACHE_KEY + ) + + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND PATCHES) + + list(LENGTH ARGN argnLength) + + # Parse single shorthand argument + if(argnLength EQUAL 1) + cpm_parse_add_package_single_arg("${ARGN}" ARGN) + + # The shorthand syntax implies EXCLUDE_FROM_ALL and SYSTEM + set(ARGN "${ARGN};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;") + + # Parse URI shorthand argument + elseif(argnLength GREATER 1 AND "${ARGV0}" STREQUAL "URI") + list(REMOVE_AT ARGN 0 1) # remove "URI gh:<...>@version#tag" + cpm_parse_add_package_single_arg("${ARGV1}" ARGV0) + + set(ARGN "${ARGV0};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;${ARGN}") + endif() + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" "${ARGN}") + + # Set default values for arguments + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + if(CPM_ARGS_DOWNLOAD_ONLY) + set(DOWNLOAD_ONLY ${CPM_ARGS_DOWNLOAD_ONLY}) + else() + set(DOWNLOAD_ONLY NO) + endif() + + if(DEFINED CPM_ARGS_GITHUB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://github.com/${CPM_ARGS_GITHUB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_GITLAB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://gitlab.com/${CPM_ARGS_GITLAB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_BITBUCKET_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://bitbucket.org/${CPM_ARGS_BITBUCKET_REPOSITORY}.git") + endif() + + if(DEFINED CPM_ARGS_GIT_REPOSITORY) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_REPOSITORY ${CPM_ARGS_GIT_REPOSITORY}) + if(NOT DEFINED CPM_ARGS_GIT_TAG) + set(CPM_ARGS_GIT_TAG v${CPM_ARGS_VERSION}) + endif() + + # If a name wasn't provided, try to infer it from the git repo + if(NOT DEFINED CPM_ARGS_NAME) + cpm_package_name_from_git_uri(${CPM_ARGS_GIT_REPOSITORY} CPM_ARGS_NAME) + endif() + endif() + + set(CPM_SKIP_FETCH FALSE) + + if(DEFINED CPM_ARGS_GIT_TAG) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_TAG ${CPM_ARGS_GIT_TAG}) + # If GIT_SHALLOW is explicitly specified, honor the value. + if(DEFINED CPM_ARGS_GIT_SHALLOW) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW ${CPM_ARGS_GIT_SHALLOW}) + endif() + endif() + + if(DEFINED CPM_ARGS_URL) + # If a name or version aren't provided, try to infer them from the URL + list(GET CPM_ARGS_URL 0 firstUrl) + cpm_package_name_and_ver_from_url(${firstUrl} nameFromUrl verFromUrl) + # If we fail to obtain name and version from the first URL, we could try other URLs if any. + # However multiple URLs are expected to be quite rare, so for now we won't bother. + + # If the caller provided their own name and version, they trump the inferred ones. + if(NOT DEFINED CPM_ARGS_NAME) + set(CPM_ARGS_NAME ${nameFromUrl}) + endif() + if(NOT DEFINED CPM_ARGS_VERSION) + set(CPM_ARGS_VERSION ${verFromUrl}) + endif() + + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS URL "${CPM_ARGS_URL}") + endif() + + # Check for required arguments + + if(NOT DEFINED CPM_ARGS_NAME) + message( + FATAL_ERROR + "${CPM_INDENT} 'NAME' was not provided and couldn't be automatically inferred for package added with arguments: '${ARGN}'" + ) + endif() + + # Check if package has been added before + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + if(CPM_PACKAGE_ALREADY_ADDED) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(NOT DEFINED CPM_${CPM_ARGS_NAME}_SOURCE AND DEFINED ENV{CPM_${CPM_ARGS_NAME}_SOURCE}) + # Normalize separators to support Windows paths when reading from environment variables. + file(TO_CMAKE_PATH "$ENV{CPM_${CPM_ARGS_NAME}_SOURCE}" CPM_${CPM_ARGS_NAME}_SOURCE) + message(WARNING "${CPM_INDENT} '${CPM_ARGS_NAME}' version overridden by environment variable " + "CPM_${CPM_ARGS_NAME}_SOURCE='${CPM_${CPM_ARGS_NAME}_SOURCE}'" + ) + endif() + + # Check for manual overrides + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_${CPM_ARGS_NAME}_SOURCE}" STREQUAL "") + set(PACKAGE_SOURCE ${CPM_${CPM_ARGS_NAME}_SOURCE}) + set(CPM_${CPM_ARGS_NAME}_SOURCE "") + if(NOT DEFINED ENV{CPM_${CPM_ARGS_NAME}_SOURCE}) + message(WARNING "${CPM_INDENT} '${CPM_ARGS_NAME}' version overridden by CMake variable " + "CPM_${CPM_ARGS_NAME}_SOURCE='${PACKAGE_SOURCE}'" + ) + endif() + # Preserve semicolons in option values across the recursive cpmaddpackage() call. + set(_forwarded_options) + foreach(opt IN LISTS CPM_ARGS_OPTIONS) + string(REPLACE ";" "\\\\;" opt "${opt}") + list(APPEND _forwarded_options "${opt}") + endforeach() + + CPMAddPackage( + NAME "${CPM_ARGS_NAME}" + SOURCE_DIR "${PACKAGE_SOURCE}" + EXCLUDE_FROM_ALL "${CPM_ARGS_EXCLUDE_FROM_ALL}" + SYSTEM "${CPM_ARGS_SYSTEM}" + PATCHES "${CPM_ARGS_PATCHES}" + OPTIONS "${_forwarded_options}" + SOURCE_SUBDIR "${CPM_ARGS_SOURCE_SUBDIR}" + DOWNLOAD_ONLY "${DOWNLOAD_ONLY}" + FORCE True + ) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for available declaration + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_DECLARATION_${CPM_ARGS_NAME}}" STREQUAL "") + set(declaration ${CPM_DECLARATION_${CPM_ARGS_NAME}}) + set(CPM_DECLARATION_${CPM_ARGS_NAME} "") + CPMAddPackage(${declaration}) + cpm_export_variables(${CPM_ARGS_NAME}) + # checking again to ensure version and option compatibility + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + return() + endif() + + if(NOT CPM_ARGS_FORCE) + if(CPM_USE_LOCAL_PACKAGES OR CPM_LOCAL_PACKAGES_ONLY) + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(CPM_PACKAGE_FOUND) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(CPM_LOCAL_PACKAGES_ONLY) + message( + SEND_ERROR + "${CPM_INDENT} ${CPM_ARGS_NAME} not found via find_package(${CPM_ARGS_NAME} ${CPM_ARGS_VERSION})" + ) + endif() + endif() + endif() + + CPMRegisterPackage("${CPM_ARGS_NAME}" "${CPM_ARGS_VERSION}") + + if(DEFINED CPM_ARGS_GIT_TAG) + set(PACKAGE_INFO "${CPM_ARGS_GIT_TAG}") + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + set(PACKAGE_INFO "${CPM_ARGS_SOURCE_DIR}") + else() + set(PACKAGE_INFO "${CPM_ARGS_VERSION}") + endif() + + if(DEFINED FETCHCONTENT_BASE_DIR) + # respect user's FETCHCONTENT_BASE_DIR if set + set(CPM_FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR}) + else() + set(CPM_FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) + endif() + + cpm_add_patches(${CPM_ARGS_PATCHES}) + + if(DEFINED CPM_ARGS_DOWNLOAD_COMMAND) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS DOWNLOAD_COMMAND ${CPM_ARGS_DOWNLOAD_COMMAND}) + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${CPM_ARGS_SOURCE_DIR}) + if(NOT IS_ABSOLUTE ${CPM_ARGS_SOURCE_DIR}) + # Expand `CPM_ARGS_SOURCE_DIR` relative path. This is important because EXISTS doesn't work + # for relative paths. + get_filename_component( + source_directory ${CPM_ARGS_SOURCE_DIR} REALPATH BASE_DIR ${CMAKE_CURRENT_BINARY_DIR} + ) + else() + set(source_directory ${CPM_ARGS_SOURCE_DIR}) + endif() + if(NOT EXISTS ${source_directory}) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild") + endif() + elseif(CPM_SOURCE_CACHE AND NOT CPM_ARGS_NO_CACHE) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + set(origin_parameters ${CPM_ARGS_UNPARSED_ARGUMENTS}) + list(SORT origin_parameters) + if(CPM_ARGS_CUSTOM_CACHE_KEY) + # Application set a custom unique directory name + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${CPM_ARGS_CUSTOM_CACHE_KEY}) + elseif(CPM_USE_NAMED_CACHE_DIRECTORIES) + string(SHA1 origin_hash "${origin_parameters};NEW_CACHE_STRUCTURE_TAG") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}/${CPM_ARGS_NAME}) + else() + string(SHA1 origin_hash "${origin_parameters}") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}) + endif() + # Expand `download_directory` relative path. This is important because EXISTS doesn't work for + # relative paths. + get_filename_component(download_directory ${download_directory} ABSOLUTE) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${download_directory}) + + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock) + endif() + + if(EXISTS ${download_directory}) + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} "${download_directory}" + "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + ) + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + + if(DEFINED CPM_ARGS_GIT_TAG AND NOT (PATCH_COMMAND IN_LIST CPM_ARGS_UNPARSED_ARGUMENTS)) + # warn if cache has been changed since checkout + cpm_check_git_working_dir_is_clean(${download_directory} ${CPM_ARGS_GIT_TAG} IS_CLEAN) + if(NOT ${IS_CLEAN}) + message( + WARNING "${CPM_INDENT} Cache for ${CPM_ARGS_NAME} (${download_directory}) is dirty" + ) + endif() + endif() + + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + set(PACKAGE_INFO "${PACKAGE_INFO} at ${download_directory}") + + # As the source dir is already cached/populated, we override the call to FetchContent. + set(CPM_SKIP_FETCH TRUE) + cpm_override_fetchcontent( + "${lower_case_name}" SOURCE_DIR "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + BINARY_DIR "${${CPM_ARGS_NAME}_BINARY_DIR}" + ) + + else() + # Enable shallow clone when GIT_TAG is not a commit hash. Our guess may not be accurate, but + # it should guarantee no commit hash get mis-detected. + if(NOT DEFINED CPM_ARGS_GIT_SHALLOW) + cpm_is_git_tag_commit_hash("${CPM_ARGS_GIT_TAG}" IS_HASH) + if(NOT ${IS_HASH}) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW TRUE) + endif() + endif() + + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE ${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild) + set(PACKAGE_INFO "${PACKAGE_INFO} to ${download_directory}") + endif() + endif() + + if(NOT "${DOWNLOAD_ONLY}") + cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")") + endif() + + if(CPM_PACKAGE_LOCK_ENABLED) + if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK) + cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + elseif(CPM_ARGS_SOURCE_DIR) + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory") + else() + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + endif() + endif() + + cpm_message( + STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})" + ) + + if(NOT CPM_SKIP_FETCH) + # CMake 3.28 added EXCLUDE, SYSTEM (3.25), and SOURCE_SUBDIR (3.18) to FetchContent_Declare. + # Calling FetchContent_MakeAvailable will then internally forward these options to + # add_subdirectory. Up until these changes, we had to call FetchContent_Populate and + # add_subdirectory separately, which is no longer necessary and has been deprecated as of 3.30. + # A Bug in CMake prevents us to use the non-deprecated functions until 3.30.3. + set(fetchContentDeclareExtraArgs "") + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(${CPM_ARGS_EXCLUDE_FROM_ALL}) + list(APPEND fetchContentDeclareExtraArgs EXCLUDE_FROM_ALL) + endif() + if(${CPM_ARGS_SYSTEM}) + list(APPEND fetchContentDeclareExtraArgs SYSTEM) + endif() + if(DEFINED CPM_ARGS_SOURCE_SUBDIR) + list(APPEND fetchContentDeclareExtraArgs SOURCE_SUBDIR ${CPM_ARGS_SOURCE_SUBDIR}) + endif() + # For CMake version <3.28 OPTIONS are parsed in cpm_add_subdirectory + if(CPM_ARGS_OPTIONS AND NOT DOWNLOAD_ONLY) + foreach(OPTION ${CPM_ARGS_OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + endif() + cpm_declare_fetch( + "${CPM_ARGS_NAME}" ${fetchContentDeclareExtraArgs} "${CPM_ARGS_UNPARSED_ARGUMENTS}" + ) + + cpm_fetch_package("${CPM_ARGS_NAME}" ${DOWNLOAD_ONLY} populated ${CPM_ARGS_UNPARSED_ARGUMENTS}) + if(CPM_SOURCE_CACHE AND download_directory) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + if(${populated} AND ${CMAKE_VERSION} VERSION_LESS "3.30.3") + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + endif() + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + endif() + + set(${CPM_ARGS_NAME}_ADDED YES) + cpm_export_variables("${CPM_ARGS_NAME}") +endfunction() + +# Fetch a previously declared package +macro(CPMGetPackage Name) + if(DEFINED "CPM_DECLARATION_${Name}") + CPMAddPackage(NAME ${Name}) + else() + message(SEND_ERROR "${CPM_INDENT} Cannot retrieve package ${Name}: no declaration available") + endif() +endmacro() + +# export variables available to the caller to the parent scope expects ${CPM_ARGS_NAME} to be set +macro(cpm_export_variables name) + set(${name}_SOURCE_DIR + "${${name}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${name}_BINARY_DIR + "${${name}_BINARY_DIR}" + PARENT_SCOPE + ) + set(${name}_ADDED + "${${name}_ADDED}" + PARENT_SCOPE + ) + set(CPM_LAST_PACKAGE_NAME + "${name}" + PARENT_SCOPE + ) +endmacro() + +# declares a package, so that any call to CPMAddPackage for the package name will use these +# arguments instead. Previous declarations will not be overridden. +macro(CPMDeclarePackage Name) + if(NOT DEFINED "CPM_DECLARATION_${Name}") + set("CPM_DECLARATION_${Name}" "${ARGN}") + endif() +endmacro() + +function(cpm_add_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} "# ${Name}\nCPMDeclarePackage(${Name}\n${PRETTY_ARGN})\n") + endif() +endfunction() + +function(cpm_add_comment_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN true ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} + "# ${Name} (unversioned)\n# CPMDeclarePackage(${Name}\n${PRETTY_ARGN}#)\n" + ) + endif() +endfunction() + +# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to +# update it +macro(CPMUsePackageLock file) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE) + if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + endif() + if(NOT TARGET cpm-update-package-lock) + add_custom_target( + cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE} + ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH} + ) + endif() + set(CPM_PACKAGE_LOCK_ENABLED true) + endif() +endmacro() + +# registers a package that has been added to CPM +function(CPMRegisterPackage PACKAGE VERSION) + list(APPEND CPM_PACKAGES ${PACKAGE}) + set(CPM_PACKAGES + ${CPM_PACKAGES} + CACHE INTERNAL "" + ) + set("CPM_PACKAGE_${PACKAGE}_VERSION" + ${VERSION} + CACHE INTERNAL "" + ) +endfunction() + +# retrieve the current version of the package to ${OUTPUT} +function(CPMGetPackageVersion PACKAGE OUTPUT) + set(${OUTPUT} + "${CPM_PACKAGE_${PACKAGE}_VERSION}" + PARENT_SCOPE + ) +endfunction() + +# declares a package in FetchContent_Declare +function(cpm_declare_fetch PACKAGE) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package not declared (dry run)") + return() + endif() + + FetchContent_Declare(${PACKAGE} ${ARGN}) +endfunction() + +# returns properties for a package previously defined by cpm_declare_fetch +function(cpm_get_fetch_properties PACKAGE) + if(${CPM_DRY_RUN}) + return() + endif() + + set(${PACKAGE}_SOURCE_DIR + "${CPM_PACKAGE_${PACKAGE}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + "${CPM_PACKAGE_${PACKAGE}_BINARY_DIR}" + PARENT_SCOPE + ) +endfunction() + +function(cpm_store_fetch_properties PACKAGE source_dir binary_dir) + if(${CPM_DRY_RUN}) + return() + endif() + + set(CPM_PACKAGE_${PACKAGE}_SOURCE_DIR + "${source_dir}" + CACHE INTERNAL "" + ) + set(CPM_PACKAGE_${PACKAGE}_BINARY_DIR + "${binary_dir}" + CACHE INTERNAL "" + ) +endfunction() + +# adds a package as a subdirectory if viable, according to provided options +function( + cpm_add_subdirectory + PACKAGE + DOWNLOAD_ONLY + SOURCE_DIR + BINARY_DIR + EXCLUDE + SYSTEM + OPTIONS +) + + if(NOT DOWNLOAD_ONLY AND EXISTS ${SOURCE_DIR}/CMakeLists.txt) + set(addSubdirectoryExtraArgs "") + if(EXCLUDE) + list(APPEND addSubdirectoryExtraArgs EXCLUDE_FROM_ALL) + endif() + if("${SYSTEM}" AND "${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.25") + # https://cmake.org/cmake/help/latest/prop_dir/SYSTEM.html#prop_dir:SYSTEM + list(APPEND addSubdirectoryExtraArgs SYSTEM) + endif() + if(OPTIONS) + foreach(OPTION ${OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + set(CPM_OLD_INDENT "${CPM_INDENT}") + set(CPM_INDENT "${CPM_INDENT} ${PACKAGE}:") + add_subdirectory(${SOURCE_DIR} ${BINARY_DIR} ${addSubdirectoryExtraArgs}) + set(CPM_INDENT "${CPM_OLD_INDENT}") + endif() +endfunction() + +# downloads a previously declared package via FetchContent and exports the variables +# `${PACKAGE}_SOURCE_DIR` and `${PACKAGE}_BINARY_DIR` to the parent scope +function(cpm_fetch_package PACKAGE DOWNLOAD_ONLY populated) + set(${populated} + FALSE + PARENT_SCOPE + ) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package ${PACKAGE} not fetched (dry run)") + return() + endif() + + FetchContent_GetProperties(${PACKAGE}) + + string(TOLOWER "${PACKAGE}" lower_case_name) + + if(NOT ${lower_case_name}_POPULATED) + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(DOWNLOAD_ONLY) + # MakeAvailable will call add_subdirectory internally which is not what we want when + # DOWNLOAD_ONLY is set. Populate will only download the dependency without adding it to the + # build + FetchContent_Populate( + ${PACKAGE} + SOURCE_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-src" + BINARY_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + SUBBUILD_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild" + ${ARGN} + ) + else() + FetchContent_MakeAvailable(${PACKAGE}) + endif() + else() + FetchContent_Populate(${PACKAGE}) + endif() + set(${populated} + TRUE + PARENT_SCOPE + ) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} ${${lower_case_name}_SOURCE_DIR} ${${lower_case_name}_BINARY_DIR} + ) + + set(${PACKAGE}_SOURCE_DIR + ${${lower_case_name}_SOURCE_DIR} + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + ${${lower_case_name}_BINARY_DIR} + PARENT_SCOPE + ) +endfunction() + +# splits a package option +function(cpm_parse_option OPTION) + string(REGEX MATCH "^[^ ]+" OPTION_KEY "${OPTION}") + string(LENGTH "${OPTION}" OPTION_LENGTH) + string(LENGTH "${OPTION_KEY}" OPTION_KEY_LENGTH) + if(OPTION_KEY_LENGTH STREQUAL OPTION_LENGTH) + # no value for key provided, assume user wants to set option to "ON" + set(OPTION_VALUE "ON") + else() + math(EXPR OPTION_KEY_LENGTH "${OPTION_KEY_LENGTH}+1") + string(SUBSTRING "${OPTION}" "${OPTION_KEY_LENGTH}" "-1" OPTION_VALUE) + string(STRIP "${OPTION_VALUE}" OPTION_VALUE) + endif() + set(OPTION_KEY + "${OPTION_KEY}" + PARENT_SCOPE + ) + set(OPTION_VALUE + "${OPTION_VALUE}" + PARENT_SCOPE + ) +endfunction() + +# guesses the package version from a git tag +function(cpm_get_version_from_git_tag GIT_TAG RESULT) + string(LENGTH ${GIT_TAG} length) + if(length EQUAL 40) + # GIT_TAG is probably a git hash + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + string(REGEX MATCH "v?([0123456789.]*).*" _ ${GIT_TAG}) + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + endif() +endfunction() + +# guesses if the git tag is a commit hash or an actual tag or a branch name. +function(cpm_is_git_tag_commit_hash GIT_TAG RESULT) + string(LENGTH "${GIT_TAG}" length) + # full hash has 40 characters, and short hash has at least 7 characters. + if(length LESS 7 OR length GREATER 40) + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + if(${GIT_TAG} MATCHES "^[a-fA-F0-9]+$") + set(${RESULT} + 1 + PARENT_SCOPE + ) + else() + set(${RESULT} + 0 + PARENT_SCOPE + ) + endif() + endif() +endfunction() + +function(cpm_prettify_package_arguments OUT_VAR IS_IN_COMMENT) + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + ) + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND) + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + foreach(oneArgName ${oneValueArgs}) + if(DEFINED CPM_ARGS_${oneArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + if(${oneArgName} STREQUAL "SOURCE_DIR") + string(REPLACE ${CMAKE_SOURCE_DIR} "\${CMAKE_SOURCE_DIR}" CPM_ARGS_${oneArgName} + ${CPM_ARGS_${oneArgName}} + ) + endif() + string(APPEND PRETTY_OUT_VAR " ${oneArgName} ${CPM_ARGS_${oneArgName}}\n") + endif() + endforeach() + foreach(multiArgName ${multiValueArgs}) + if(DEFINED CPM_ARGS_${multiArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ${multiArgName}\n") + foreach(singleOption ${CPM_ARGS_${multiArgName}}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " \"${singleOption}\"\n") + endforeach() + endif() + endforeach() + + if(NOT "${CPM_ARGS_UNPARSED_ARGUMENTS}" STREQUAL "") + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ") + foreach(CPM_ARGS_UNPARSED_ARGUMENT ${CPM_ARGS_UNPARSED_ARGUMENTS}) + string(APPEND PRETTY_OUT_VAR " ${CPM_ARGS_UNPARSED_ARGUMENT}") + endforeach() + string(APPEND PRETTY_OUT_VAR "\n") + endif() + + set(${OUT_VAR} + ${PRETTY_OUT_VAR} + PARENT_SCOPE + ) + +endfunction() diff --git a/src/pxmodrim/__init__.py b/legacy/pxmodrim/__init__.py similarity index 100% rename from src/pxmodrim/__init__.py rename to legacy/pxmodrim/__init__.py diff --git a/src/pxmodrim/__main__.py b/legacy/pxmodrim/__main__.py similarity index 100% rename from src/pxmodrim/__main__.py rename to legacy/pxmodrim/__main__.py diff --git a/src/pxmodrim/_app.py b/legacy/pxmodrim/_app.py similarity index 100% rename from src/pxmodrim/_app.py rename to legacy/pxmodrim/_app.py diff --git a/src/pxmodrim/core/__init__.py b/legacy/pxmodrim/core/__init__.py similarity index 100% rename from src/pxmodrim/core/__init__.py rename to legacy/pxmodrim/core/__init__.py diff --git a/src/pxmodrim/core/checker/__init__.py b/legacy/pxmodrim/core/checker/__init__.py similarity index 100% rename from src/pxmodrim/core/checker/__init__.py rename to legacy/pxmodrim/core/checker/__init__.py diff --git a/src/pxmodrim/core/checker/checker.py b/legacy/pxmodrim/core/checker/checker.py similarity index 100% rename from src/pxmodrim/core/checker/checker.py rename to legacy/pxmodrim/core/checker/checker.py diff --git a/src/pxmodrim/core/checker/databases.py b/legacy/pxmodrim/core/checker/databases.py similarity index 100% rename from src/pxmodrim/core/checker/databases.py rename to legacy/pxmodrim/core/checker/databases.py diff --git a/src/pxmodrim/core/checker/graph.py b/legacy/pxmodrim/core/checker/graph.py similarity index 100% rename from src/pxmodrim/core/checker/graph.py rename to legacy/pxmodrim/core/checker/graph.py diff --git a/src/pxmodrim/core/checker/issues.py b/legacy/pxmodrim/core/checker/issues.py similarity index 100% rename from src/pxmodrim/core/checker/issues.py rename to legacy/pxmodrim/core/checker/issues.py diff --git a/src/pxmodrim/core/checker/models.py b/legacy/pxmodrim/core/checker/models.py similarity index 100% rename from src/pxmodrim/core/checker/models.py rename to legacy/pxmodrim/core/checker/models.py diff --git a/src/pxmodrim/core/checker/sort.py b/legacy/pxmodrim/core/checker/sort.py similarity index 100% rename from src/pxmodrim/core/checker/sort.py rename to legacy/pxmodrim/core/checker/sort.py diff --git a/src/pxmodrim/core/config.py b/legacy/pxmodrim/core/config.py similarity index 100% rename from src/pxmodrim/core/config.py rename to legacy/pxmodrim/core/config.py diff --git a/src/pxmodrim/core/constants.py b/legacy/pxmodrim/core/constants.py similarity index 100% rename from src/pxmodrim/core/constants.py rename to legacy/pxmodrim/core/constants.py diff --git a/src/pxmodrim/core/context.py b/legacy/pxmodrim/core/context.py similarity index 100% rename from src/pxmodrim/core/context.py rename to legacy/pxmodrim/core/context.py diff --git a/src/pxmodrim/core/downloads/__init__.py b/legacy/pxmodrim/core/downloads/__init__.py similarity index 100% rename from src/pxmodrim/core/downloads/__init__.py rename to legacy/pxmodrim/core/downloads/__init__.py diff --git a/src/pxmodrim/core/downloads/downloader.py b/legacy/pxmodrim/core/downloads/downloader.py similarity index 100% rename from src/pxmodrim/core/downloads/downloader.py rename to legacy/pxmodrim/core/downloads/downloader.py diff --git a/src/pxmodrim/core/downloads/manager.py b/legacy/pxmodrim/core/downloads/manager.py similarity index 100% rename from src/pxmodrim/core/downloads/manager.py rename to legacy/pxmodrim/core/downloads/manager.py diff --git a/src/pxmodrim/core/downloads/steam.py b/legacy/pxmodrim/core/downloads/steam.py similarity index 100% rename from src/pxmodrim/core/downloads/steam.py rename to legacy/pxmodrim/core/downloads/steam.py diff --git a/src/pxmodrim/core/downloads/types.py b/legacy/pxmodrim/core/downloads/types.py similarity index 100% rename from src/pxmodrim/core/downloads/types.py rename to legacy/pxmodrim/core/downloads/types.py diff --git a/src/pxmodrim/core/events.py b/legacy/pxmodrim/core/events.py similarity index 100% rename from src/pxmodrim/core/events.py rename to legacy/pxmodrim/core/events.py diff --git a/src/pxmodrim/core/loading.py b/legacy/pxmodrim/core/loading.py similarity index 100% rename from src/pxmodrim/core/loading.py rename to legacy/pxmodrim/core/loading.py diff --git a/src/pxmodrim/core/migrator.py b/legacy/pxmodrim/core/migrator.py similarity index 100% rename from src/pxmodrim/core/migrator.py rename to legacy/pxmodrim/core/migrator.py diff --git a/src/pxmodrim/core/mod_service.py b/legacy/pxmodrim/core/mod_service.py similarity index 100% rename from src/pxmodrim/core/mod_service.py rename to legacy/pxmodrim/core/mod_service.py diff --git a/src/pxmodrim/core/models/__init__.py b/legacy/pxmodrim/core/models/__init__.py similarity index 100% rename from src/pxmodrim/core/models/__init__.py rename to legacy/pxmodrim/core/models/__init__.py diff --git a/src/pxmodrim/core/models/metadata/__init__.py b/legacy/pxmodrim/core/models/metadata/__init__.py similarity index 100% rename from src/pxmodrim/core/models/metadata/__init__.py rename to legacy/pxmodrim/core/models/metadata/__init__.py diff --git a/src/pxmodrim/core/models/metadata/parsing.py b/legacy/pxmodrim/core/models/metadata/parsing.py similarity index 100% rename from src/pxmodrim/core/models/metadata/parsing.py rename to legacy/pxmodrim/core/models/metadata/parsing.py diff --git a/src/pxmodrim/core/models/metadata/structures.py b/legacy/pxmodrim/core/models/metadata/structures.py similarity index 100% rename from src/pxmodrim/core/models/metadata/structures.py rename to legacy/pxmodrim/core/models/metadata/structures.py diff --git a/src/pxmodrim/core/models/view/__init__.py b/legacy/pxmodrim/core/models/view/__init__.py similarity index 100% rename from src/pxmodrim/core/models/view/__init__.py rename to legacy/pxmodrim/core/models/view/__init__.py diff --git a/src/pxmodrim/core/models/view/diagnostics.py b/legacy/pxmodrim/core/models/view/diagnostics.py similarity index 100% rename from src/pxmodrim/core/models/view/diagnostics.py rename to legacy/pxmodrim/core/models/view/diagnostics.py diff --git a/src/pxmodrim/core/models/view/sidebar.py b/legacy/pxmodrim/core/models/view/sidebar.py similarity index 100% rename from src/pxmodrim/core/models/view/sidebar.py rename to legacy/pxmodrim/core/models/view/sidebar.py diff --git a/src/pxmodrim/core/mods_config.py b/legacy/pxmodrim/core/mods_config.py similarity index 100% rename from src/pxmodrim/core/mods_config.py rename to legacy/pxmodrim/core/mods_config.py diff --git a/src/pxmodrim/core/msgspec_hooks.py b/legacy/pxmodrim/core/msgspec_hooks.py similarity index 100% rename from src/pxmodrim/core/msgspec_hooks.py rename to legacy/pxmodrim/core/msgspec_hooks.py diff --git a/src/pxmodrim/core/organizer/__init__.py b/legacy/pxmodrim/core/organizer/__init__.py similarity index 100% rename from src/pxmodrim/core/organizer/__init__.py rename to legacy/pxmodrim/core/organizer/__init__.py diff --git a/src/pxmodrim/core/organizer/db.py b/legacy/pxmodrim/core/organizer/db.py similarity index 100% rename from src/pxmodrim/core/organizer/db.py rename to legacy/pxmodrim/core/organizer/db.py diff --git a/src/pxmodrim/core/organizer/defaults.py b/legacy/pxmodrim/core/organizer/defaults.py similarity index 100% rename from src/pxmodrim/core/organizer/defaults.py rename to legacy/pxmodrim/core/organizer/defaults.py diff --git a/src/pxmodrim/core/organizer/models.py b/legacy/pxmodrim/core/organizer/models.py similarity index 100% rename from src/pxmodrim/core/organizer/models.py rename to legacy/pxmodrim/core/organizer/models.py diff --git a/src/pxmodrim/core/organizer/resolve.py b/legacy/pxmodrim/core/organizer/resolve.py similarity index 100% rename from src/pxmodrim/core/organizer/resolve.py rename to legacy/pxmodrim/core/organizer/resolve.py diff --git a/src/pxmodrim/core/organizer/service.py b/legacy/pxmodrim/core/organizer/service.py similarity index 100% rename from src/pxmodrim/core/organizer/service.py rename to legacy/pxmodrim/core/organizer/service.py diff --git a/src/pxmodrim/core/plugin.py b/legacy/pxmodrim/core/plugin.py similarity index 100% rename from src/pxmodrim/core/plugin.py rename to legacy/pxmodrim/core/plugin.py diff --git a/src/pxmodrim/core/plugin_config.py b/legacy/pxmodrim/core/plugin_config.py similarity index 100% rename from src/pxmodrim/core/plugin_config.py rename to legacy/pxmodrim/core/plugin_config.py diff --git a/src/pxmodrim/core/profiler.py b/legacy/pxmodrim/core/profiler.py similarity index 100% rename from src/pxmodrim/core/profiler.py rename to legacy/pxmodrim/core/profiler.py diff --git a/src/pxmodrim/core/providers/__init__.py b/legacy/pxmodrim/core/providers/__init__.py similarity index 100% rename from src/pxmodrim/core/providers/__init__.py rename to legacy/pxmodrim/core/providers/__init__.py diff --git a/src/pxmodrim/core/providers/base.py b/legacy/pxmodrim/core/providers/base.py similarity index 100% rename from src/pxmodrim/core/providers/base.py rename to legacy/pxmodrim/core/providers/base.py diff --git a/src/pxmodrim/core/providers/core.py b/legacy/pxmodrim/core/providers/core.py similarity index 100% rename from src/pxmodrim/core/providers/core.py rename to legacy/pxmodrim/core/providers/core.py diff --git a/src/pxmodrim/core/providers/local.py b/legacy/pxmodrim/core/providers/local.py similarity index 100% rename from src/pxmodrim/core/providers/local.py rename to legacy/pxmodrim/core/providers/local.py diff --git a/src/pxmodrim/core/services/__init__.py b/legacy/pxmodrim/core/services/__init__.py similarity index 100% rename from src/pxmodrim/core/services/__init__.py rename to legacy/pxmodrim/core/services/__init__.py diff --git a/src/pxmodrim/core/services/activation_service.py b/legacy/pxmodrim/core/services/activation_service.py similarity index 100% rename from src/pxmodrim/core/services/activation_service.py rename to legacy/pxmodrim/core/services/activation_service.py diff --git a/src/pxmodrim/core/services/diagnostics_service.py b/legacy/pxmodrim/core/services/diagnostics_service.py similarity index 100% rename from src/pxmodrim/core/services/diagnostics_service.py rename to legacy/pxmodrim/core/services/diagnostics_service.py diff --git a/src/pxmodrim/core/services/game_launcher.py b/legacy/pxmodrim/core/services/game_launcher.py similarity index 100% rename from src/pxmodrim/core/services/game_launcher.py rename to legacy/pxmodrim/core/services/game_launcher.py diff --git a/src/pxmodrim/core/services/metadata_cache.py b/legacy/pxmodrim/core/services/metadata_cache.py similarity index 100% rename from src/pxmodrim/core/services/metadata_cache.py rename to legacy/pxmodrim/core/services/metadata_cache.py diff --git a/src/pxmodrim/core/services/mod_discovery.py b/legacy/pxmodrim/core/services/mod_discovery.py similarity index 100% rename from src/pxmodrim/core/services/mod_discovery.py rename to legacy/pxmodrim/core/services/mod_discovery.py diff --git a/src/pxmodrim/core/services/sort_service.py b/legacy/pxmodrim/core/services/sort_service.py similarity index 100% rename from src/pxmodrim/core/services/sort_service.py rename to legacy/pxmodrim/core/services/sort_service.py diff --git a/src/pxmodrim/core/services/startup_impact_service/__init__.py b/legacy/pxmodrim/core/services/startup_impact_service/__init__.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/__init__.py rename to legacy/pxmodrim/core/services/startup_impact_service/__init__.py diff --git a/src/pxmodrim/core/services/startup_impact_service/db.py b/legacy/pxmodrim/core/services/startup_impact_service/db.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/db.py rename to legacy/pxmodrim/core/services/startup_impact_service/db.py diff --git a/src/pxmodrim/core/services/startup_impact_service/labels.py b/legacy/pxmodrim/core/services/startup_impact_service/labels.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/labels.py rename to legacy/pxmodrim/core/services/startup_impact_service/labels.py diff --git a/src/pxmodrim/core/services/startup_impact_service/models.py b/legacy/pxmodrim/core/services/startup_impact_service/models.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/models.py rename to legacy/pxmodrim/core/services/startup_impact_service/models.py diff --git a/src/pxmodrim/core/services/startup_impact_service/parser.py b/legacy/pxmodrim/core/services/startup_impact_service/parser.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/parser.py rename to legacy/pxmodrim/core/services/startup_impact_service/parser.py diff --git a/src/pxmodrim/core/services/startup_impact_service/phases.py b/legacy/pxmodrim/core/services/startup_impact_service/phases.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/phases.py rename to legacy/pxmodrim/core/services/startup_impact_service/phases.py diff --git a/src/pxmodrim/core/services/startup_impact_service/service.py b/legacy/pxmodrim/core/services/startup_impact_service/service.py similarity index 100% rename from src/pxmodrim/core/services/startup_impact_service/service.py rename to legacy/pxmodrim/core/services/startup_impact_service/service.py diff --git a/src/pxmodrim/core/services/update_service.py b/legacy/pxmodrim/core/services/update_service.py similarity index 100% rename from src/pxmodrim/core/services/update_service.py rename to legacy/pxmodrim/core/services/update_service.py diff --git a/src/pxmodrim/core/sort/__init__.py b/legacy/pxmodrim/core/sort/__init__.py similarity index 100% rename from src/pxmodrim/core/sort/__init__.py rename to legacy/pxmodrim/core/sort/__init__.py diff --git a/src/pxmodrim/core/sort/community.py b/legacy/pxmodrim/core/sort/community.py similarity index 100% rename from src/pxmodrim/core/sort/community.py rename to legacy/pxmodrim/core/sort/community.py diff --git a/src/pxmodrim/core/sort/community_service.py b/legacy/pxmodrim/core/sort/community_service.py similarity index 100% rename from src/pxmodrim/core/sort/community_service.py rename to legacy/pxmodrim/core/sort/community_service.py diff --git a/src/pxmodrim/core/sort/config.py b/legacy/pxmodrim/core/sort/config.py similarity index 100% rename from src/pxmodrim/core/sort/config.py rename to legacy/pxmodrim/core/sort/config.py diff --git a/src/pxmodrim/core/sort/exceptions.py b/legacy/pxmodrim/core/sort/exceptions.py similarity index 100% rename from src/pxmodrim/core/sort/exceptions.py rename to legacy/pxmodrim/core/sort/exceptions.py diff --git a/src/pxmodrim/core/sort/models.py b/legacy/pxmodrim/core/sort/models.py similarity index 100% rename from src/pxmodrim/core/sort/models.py rename to legacy/pxmodrim/core/sort/models.py diff --git a/src/pxmodrim/core/sort/progress.py b/legacy/pxmodrim/core/sort/progress.py similarity index 100% rename from src/pxmodrim/core/sort/progress.py rename to legacy/pxmodrim/core/sort/progress.py diff --git a/src/pxmodrim/core/sort/tiers.py b/legacy/pxmodrim/core/sort/tiers.py similarity index 100% rename from src/pxmodrim/core/sort/tiers.py rename to legacy/pxmodrim/core/sort/tiers.py diff --git a/src/pxmodrim/core/structures.py b/legacy/pxmodrim/core/structures.py similarity index 100% rename from src/pxmodrim/core/structures.py rename to legacy/pxmodrim/core/structures.py diff --git a/src/pxmodrim/core/support/__init__.py b/legacy/pxmodrim/core/support/__init__.py similarity index 100% rename from src/pxmodrim/core/support/__init__.py rename to legacy/pxmodrim/core/support/__init__.py diff --git a/src/pxmodrim/core/support/report.py b/legacy/pxmodrim/core/support/report.py similarity index 100% rename from src/pxmodrim/core/support/report.py rename to legacy/pxmodrim/core/support/report.py diff --git a/src/pxmodrim/core/utils.py b/legacy/pxmodrim/core/utils.py similarity index 100% rename from src/pxmodrim/core/utils.py rename to legacy/pxmodrim/core/utils.py diff --git a/src/pxmodrim/core/workshop/__init__.py b/legacy/pxmodrim/core/workshop/__init__.py similarity index 100% rename from src/pxmodrim/core/workshop/__init__.py rename to legacy/pxmodrim/core/workshop/__init__.py diff --git a/src/pxmodrim/core/workshop/catalog.py b/legacy/pxmodrim/core/workshop/catalog.py similarity index 100% rename from src/pxmodrim/core/workshop/catalog.py rename to legacy/pxmodrim/core/workshop/catalog.py diff --git a/src/pxmodrim/core/workshop/client.py b/legacy/pxmodrim/core/workshop/client.py similarity index 100% rename from src/pxmodrim/core/workshop/client.py rename to legacy/pxmodrim/core/workshop/client.py diff --git a/src/pxmodrim/core/workshop/favourites.py b/legacy/pxmodrim/core/workshop/favourites.py similarity index 100% rename from src/pxmodrim/core/workshop/favourites.py rename to legacy/pxmodrim/core/workshop/favourites.py diff --git a/src/pxmodrim/core/workshop/types.py b/legacy/pxmodrim/core/workshop/types.py similarity index 100% rename from src/pxmodrim/core/workshop/types.py rename to legacy/pxmodrim/core/workshop/types.py diff --git a/src/pxmodrim/core/xml.py b/legacy/pxmodrim/core/xml.py similarity index 100% rename from src/pxmodrim/core/xml.py rename to legacy/pxmodrim/core/xml.py diff --git a/src/pxmodrim/ui/__init__.py b/legacy/pxmodrim/ui/__init__.py similarity index 100% rename from src/pxmodrim/ui/__init__.py rename to legacy/pxmodrim/ui/__init__.py diff --git a/src/pxmodrim/ui/assets/__init__.py b/legacy/pxmodrim/ui/assets/__init__.py similarity index 100% rename from src/pxmodrim/ui/assets/__init__.py rename to legacy/pxmodrim/ui/assets/__init__.py diff --git a/src/pxmodrim/ui/assets/logo.png b/legacy/pxmodrim/ui/assets/logo.png similarity index 100% rename from src/pxmodrim/ui/assets/logo.png rename to legacy/pxmodrim/ui/assets/logo.png diff --git a/src/pxmodrim/ui/assets/logo.svg b/legacy/pxmodrim/ui/assets/logo.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo.svg rename to legacy/pxmodrim/ui/assets/logo.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_1.svg b/legacy/pxmodrim/ui/assets/logo_nobg_1.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_1.svg rename to legacy/pxmodrim/ui/assets/logo_nobg_1.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_2.svg b/legacy/pxmodrim/ui/assets/logo_nobg_2.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_2.svg rename to legacy/pxmodrim/ui/assets/logo_nobg_2.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_3.svg b/legacy/pxmodrim/ui/assets/logo_nobg_3.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_3.svg rename to legacy/pxmodrim/ui/assets/logo_nobg_3.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_4.svg b/legacy/pxmodrim/ui/assets/logo_nobg_4.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_4.svg rename to legacy/pxmodrim/ui/assets/logo_nobg_4.svg diff --git a/src/pxmodrim/ui/components/FilterSidebar.qml b/legacy/pxmodrim/ui/components/FilterSidebar.qml similarity index 100% rename from src/pxmodrim/ui/components/FilterSidebar.qml rename to legacy/pxmodrim/ui/components/FilterSidebar.qml diff --git a/src/pxmodrim/ui/components/Header.qml b/legacy/pxmodrim/ui/components/Header.qml similarity index 100% rename from src/pxmodrim/ui/components/Header.qml rename to legacy/pxmodrim/ui/components/Header.qml diff --git a/src/pxmodrim/ui/components/IconRail.qml b/legacy/pxmodrim/ui/components/IconRail.qml similarity index 100% rename from src/pxmodrim/ui/components/IconRail.qml rename to legacy/pxmodrim/ui/components/IconRail.qml diff --git a/src/pxmodrim/ui/components/__init__.py b/legacy/pxmodrim/ui/components/__init__.py similarity index 100% rename from src/pxmodrim/ui/components/__init__.py rename to legacy/pxmodrim/ui/components/__init__.py diff --git a/src/pxmodrim/ui/components/banner_widget.py b/legacy/pxmodrim/ui/components/banner_widget.py similarity index 100% rename from src/pxmodrim/ui/components/banner_widget.py rename to legacy/pxmodrim/ui/components/banner_widget.py diff --git a/src/pxmodrim/ui/components/button.py b/legacy/pxmodrim/ui/components/button.py similarity index 100% rename from src/pxmodrim/ui/components/button.py rename to legacy/pxmodrim/ui/components/button.py diff --git a/src/pxmodrim/ui/components/controls/DiagnosticBadge.qml b/legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/DiagnosticBadge.qml rename to legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml diff --git a/src/pxmodrim/ui/components/controls/EmptyState.qml b/legacy/pxmodrim/ui/components/controls/EmptyState.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/EmptyState.qml rename to legacy/pxmodrim/ui/components/controls/EmptyState.qml diff --git a/src/pxmodrim/ui/components/controls/ModDescription.qml b/legacy/pxmodrim/ui/components/controls/ModDescription.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/ModDescription.qml rename to legacy/pxmodrim/ui/components/controls/ModDescription.qml diff --git a/src/pxmodrim/ui/components/controls/PxBadge.qml b/legacy/pxmodrim/ui/components/controls/PxBadge.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxBadge.qml rename to legacy/pxmodrim/ui/components/controls/PxBadge.qml diff --git a/src/pxmodrim/ui/components/controls/PxButton.qml b/legacy/pxmodrim/ui/components/controls/PxButton.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxButton.qml rename to legacy/pxmodrim/ui/components/controls/PxButton.qml diff --git a/src/pxmodrim/ui/components/controls/PxCheckBox.qml b/legacy/pxmodrim/ui/components/controls/PxCheckBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxCheckBox.qml rename to legacy/pxmodrim/ui/components/controls/PxCheckBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxCheckIndicator.qml b/legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxCheckIndicator.qml rename to legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml diff --git a/src/pxmodrim/ui/components/controls/PxComboBox.qml b/legacy/pxmodrim/ui/components/controls/PxComboBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxComboBox.qml rename to legacy/pxmodrim/ui/components/controls/PxComboBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxDialog.qml b/legacy/pxmodrim/ui/components/controls/PxDialog.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxDialog.qml rename to legacy/pxmodrim/ui/components/controls/PxDialog.qml diff --git a/src/pxmodrim/ui/components/controls/PxDialogFooter.qml b/legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxDialogFooter.qml rename to legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml diff --git a/src/pxmodrim/ui/components/controls/PxMenu.qml b/legacy/pxmodrim/ui/components/controls/PxMenu.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxMenu.qml rename to legacy/pxmodrim/ui/components/controls/PxMenu.qml diff --git a/src/pxmodrim/ui/components/controls/PxMenuItem.qml b/legacy/pxmodrim/ui/components/controls/PxMenuItem.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxMenuItem.qml rename to legacy/pxmodrim/ui/components/controls/PxMenuItem.qml diff --git a/src/pxmodrim/ui/components/controls/PxProgressBar.qml b/legacy/pxmodrim/ui/components/controls/PxProgressBar.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxProgressBar.qml rename to legacy/pxmodrim/ui/components/controls/PxProgressBar.qml diff --git a/src/pxmodrim/ui/components/controls/PxScrollBar.qml b/legacy/pxmodrim/ui/components/controls/PxScrollBar.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxScrollBar.qml rename to legacy/pxmodrim/ui/components/controls/PxScrollBar.qml diff --git a/src/pxmodrim/ui/components/controls/PxSpinBox.qml b/legacy/pxmodrim/ui/components/controls/PxSpinBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxSpinBox.qml rename to legacy/pxmodrim/ui/components/controls/PxSpinBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxTextField.qml b/legacy/pxmodrim/ui/components/controls/PxTextField.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxTextField.qml rename to legacy/pxmodrim/ui/components/controls/PxTextField.qml diff --git a/src/pxmodrim/ui/components/controls/PxToolTip.qml b/legacy/pxmodrim/ui/components/controls/PxToolTip.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxToolTip.qml rename to legacy/pxmodrim/ui/components/controls/PxToolTip.qml diff --git a/src/pxmodrim/ui/components/dialog_chrome.py b/legacy/pxmodrim/ui/components/dialog_chrome.py similarity index 100% rename from src/pxmodrim/ui/components/dialog_chrome.py rename to legacy/pxmodrim/ui/components/dialog_chrome.py diff --git a/src/pxmodrim/ui/components/dialogs.py b/legacy/pxmodrim/ui/components/dialogs.py similarity index 100% rename from src/pxmodrim/ui/components/dialogs.py rename to legacy/pxmodrim/ui/components/dialogs.py diff --git a/src/pxmodrim/ui/components/filter_sidebar.py b/legacy/pxmodrim/ui/components/filter_sidebar.py similarity index 100% rename from src/pxmodrim/ui/components/filter_sidebar.py rename to legacy/pxmodrim/ui/components/filter_sidebar.py diff --git a/src/pxmodrim/ui/components/header_controller.py b/legacy/pxmodrim/ui/components/header_controller.py similarity index 100% rename from src/pxmodrim/ui/components/header_controller.py rename to legacy/pxmodrim/ui/components/header_controller.py diff --git a/src/pxmodrim/ui/components/header_panel.py b/legacy/pxmodrim/ui/components/header_panel.py similarity index 100% rename from src/pxmodrim/ui/components/header_panel.py rename to legacy/pxmodrim/ui/components/header_panel.py diff --git a/src/pxmodrim/ui/components/icon_button.py b/legacy/pxmodrim/ui/components/icon_button.py similarity index 100% rename from src/pxmodrim/ui/components/icon_button.py rename to legacy/pxmodrim/ui/components/icon_button.py diff --git a/src/pxmodrim/ui/components/icons.py b/legacy/pxmodrim/ui/components/icons.py similarity index 100% rename from src/pxmodrim/ui/components/icons.py rename to legacy/pxmodrim/ui/components/icons.py diff --git a/src/pxmodrim/ui/components/mod_activation.py b/legacy/pxmodrim/ui/components/mod_activation.py similarity index 100% rename from src/pxmodrim/ui/components/mod_activation.py rename to legacy/pxmodrim/ui/components/mod_activation.py diff --git a/src/pxmodrim/ui/components/mod_updates.py b/legacy/pxmodrim/ui/components/mod_updates.py similarity index 100% rename from src/pxmodrim/ui/components/mod_updates.py rename to legacy/pxmodrim/ui/components/mod_updates.py diff --git a/src/pxmodrim/ui/components/popup_parent.py b/legacy/pxmodrim/ui/components/popup_parent.py similarity index 100% rename from src/pxmodrim/ui/components/popup_parent.py rename to legacy/pxmodrim/ui/components/popup_parent.py diff --git a/src/pxmodrim/ui/components/procedural_preview.py b/legacy/pxmodrim/ui/components/procedural_preview.py similarity index 100% rename from src/pxmodrim/ui/components/procedural_preview.py rename to legacy/pxmodrim/ui/components/procedural_preview.py diff --git a/src/pxmodrim/ui/components/progress_dialog.py b/legacy/pxmodrim/ui/components/progress_dialog.py similarity index 100% rename from src/pxmodrim/ui/components/progress_dialog.py rename to legacy/pxmodrim/ui/components/progress_dialog.py diff --git a/src/pxmodrim/ui/components/svg_provider.py b/legacy/pxmodrim/ui/components/svg_provider.py similarity index 100% rename from src/pxmodrim/ui/components/svg_provider.py rename to legacy/pxmodrim/ui/components/svg_provider.py diff --git a/src/pxmodrim/ui/components/toast.py b/legacy/pxmodrim/ui/components/toast.py similarity index 100% rename from src/pxmodrim/ui/components/toast.py rename to legacy/pxmodrim/ui/components/toast.py diff --git a/src/pxmodrim/ui/components/unity_rich_text.py b/legacy/pxmodrim/ui/components/unity_rich_text.py similarity index 100% rename from src/pxmodrim/ui/components/unity_rich_text.py rename to legacy/pxmodrim/ui/components/unity_rich_text.py diff --git a/src/pxmodrim/ui/components/view_rail_panel.py b/legacy/pxmodrim/ui/components/view_rail_panel.py similarity index 100% rename from src/pxmodrim/ui/components/view_rail_panel.py rename to legacy/pxmodrim/ui/components/view_rail_panel.py diff --git a/src/pxmodrim/ui/config.py b/legacy/pxmodrim/ui/config.py similarity index 100% rename from src/pxmodrim/ui/config.py rename to legacy/pxmodrim/ui/config.py diff --git a/src/pxmodrim/ui/context.py b/legacy/pxmodrim/ui/context.py similarity index 100% rename from src/pxmodrim/ui/context.py rename to legacy/pxmodrim/ui/context.py diff --git a/src/pxmodrim/ui/mod_selection.py b/legacy/pxmodrim/ui/mod_selection.py similarity index 100% rename from src/pxmodrim/ui/mod_selection.py rename to legacy/pxmodrim/ui/mod_selection.py diff --git a/src/pxmodrim/ui/models/__init__.py b/legacy/pxmodrim/ui/models/__init__.py similarity index 100% rename from src/pxmodrim/ui/models/__init__.py rename to legacy/pxmodrim/ui/models/__init__.py diff --git a/src/pxmodrim/ui/models/impact.py b/legacy/pxmodrim/ui/models/impact.py similarity index 100% rename from src/pxmodrim/ui/models/impact.py rename to legacy/pxmodrim/ui/models/impact.py diff --git a/src/pxmodrim/ui/models/mod_list_model.py b/legacy/pxmodrim/ui/models/mod_list_model.py similarity index 100% rename from src/pxmodrim/ui/models/mod_list_model.py rename to legacy/pxmodrim/ui/models/mod_list_model.py diff --git a/src/pxmodrim/ui/models/mod_list_proxy_model.py b/legacy/pxmodrim/ui/models/mod_list_proxy_model.py similarity index 100% rename from src/pxmodrim/ui/models/mod_list_proxy_model.py rename to legacy/pxmodrim/ui/models/mod_list_proxy_model.py diff --git a/src/pxmodrim/ui/models/sidebar_model.py b/legacy/pxmodrim/ui/models/sidebar_model.py similarity index 100% rename from src/pxmodrim/ui/models/sidebar_model.py rename to legacy/pxmodrim/ui/models/sidebar_model.py diff --git a/src/pxmodrim/ui/navigation.py b/legacy/pxmodrim/ui/navigation.py similarity index 100% rename from src/pxmodrim/ui/navigation.py rename to legacy/pxmodrim/ui/navigation.py diff --git a/src/pxmodrim/ui/panels/ModInfo.qml b/legacy/pxmodrim/ui/panels/ModInfo.qml similarity index 100% rename from src/pxmodrim/ui/panels/ModInfo.qml rename to legacy/pxmodrim/ui/panels/ModInfo.qml diff --git a/src/pxmodrim/ui/panels/ModList.qml b/legacy/pxmodrim/ui/panels/ModList.qml similarity index 100% rename from src/pxmodrim/ui/panels/ModList.qml rename to legacy/pxmodrim/ui/panels/ModList.qml diff --git a/src/pxmodrim/ui/panels/Settings.qml b/legacy/pxmodrim/ui/panels/Settings.qml similarity index 100% rename from src/pxmodrim/ui/panels/Settings.qml rename to legacy/pxmodrim/ui/panels/Settings.qml diff --git a/src/pxmodrim/ui/panels/TimeAnalytics.qml b/legacy/pxmodrim/ui/panels/TimeAnalytics.qml similarity index 100% rename from src/pxmodrim/ui/panels/TimeAnalytics.qml rename to legacy/pxmodrim/ui/panels/TimeAnalytics.qml diff --git a/src/pxmodrim/ui/panels/__init__.py b/legacy/pxmodrim/ui/panels/__init__.py similarity index 100% rename from src/pxmodrim/ui/panels/__init__.py rename to legacy/pxmodrim/ui/panels/__init__.py diff --git a/src/pxmodrim/ui/panels/about_panel.py b/legacy/pxmodrim/ui/panels/about_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/about_panel.py rename to legacy/pxmodrim/ui/panels/about_panel.py diff --git a/src/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py b/legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py similarity index 100% rename from src/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py rename to legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py diff --git a/src/pxmodrim/ui/panels/mod_info_data.py b/legacy/pxmodrim/ui/panels/mod_info_data.py similarity index 100% rename from src/pxmodrim/ui/panels/mod_info_data.py rename to legacy/pxmodrim/ui/panels/mod_info_data.py diff --git a/src/pxmodrim/ui/panels/mod_info_panel.py b/legacy/pxmodrim/ui/panels/mod_info_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/mod_info_panel.py rename to legacy/pxmodrim/ui/panels/mod_info_panel.py diff --git a/src/pxmodrim/ui/panels/mod_list_panel.py b/legacy/pxmodrim/ui/panels/mod_list_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/mod_list_panel.py rename to legacy/pxmodrim/ui/panels/mod_list_panel.py diff --git a/src/pxmodrim/ui/panels/restore_snapshot_dialog.py b/legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py similarity index 100% rename from src/pxmodrim/ui/panels/restore_snapshot_dialog.py rename to legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/legacy/pxmodrim/ui/panels/settings_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/settings_panel.py rename to legacy/pxmodrim/ui/panels/settings_panel.py diff --git a/src/pxmodrim/ui/panels/sidebar_panel.py b/legacy/pxmodrim/ui/panels/sidebar_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/sidebar_panel.py rename to legacy/pxmodrim/ui/panels/sidebar_panel.py diff --git a/src/pxmodrim/ui/panels/startup_impact_data.py b/legacy/pxmodrim/ui/panels/startup_impact_data.py similarity index 100% rename from src/pxmodrim/ui/panels/startup_impact_data.py rename to legacy/pxmodrim/ui/panels/startup_impact_data.py diff --git a/src/pxmodrim/ui/panels/time_analytics_panel.py b/legacy/pxmodrim/ui/panels/time_analytics_panel.py similarity index 100% rename from src/pxmodrim/ui/panels/time_analytics_panel.py rename to legacy/pxmodrim/ui/panels/time_analytics_panel.py diff --git a/src/pxmodrim/ui/panels/update_dialog.py b/legacy/pxmodrim/ui/panels/update_dialog.py similarity index 100% rename from src/pxmodrim/ui/panels/update_dialog.py rename to legacy/pxmodrim/ui/panels/update_dialog.py diff --git a/src/pxmodrim/ui/panels/upload_report_dialog.py b/legacy/pxmodrim/ui/panels/upload_report_dialog.py similarity index 100% rename from src/pxmodrim/ui/panels/upload_report_dialog.py rename to legacy/pxmodrim/ui/panels/upload_report_dialog.py diff --git a/src/pxmodrim/ui/plugins/__init__.py b/legacy/pxmodrim/ui/plugins/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/__init__.py rename to legacy/pxmodrim/ui/plugins/__init__.py diff --git a/src/pxmodrim/ui/plugins/downloads/Downloads.qml b/legacy/pxmodrim/ui/plugins/downloads/Downloads.qml similarity index 100% rename from src/pxmodrim/ui/plugins/downloads/Downloads.qml rename to legacy/pxmodrim/ui/plugins/downloads/Downloads.qml diff --git a/src/pxmodrim/ui/plugins/downloads/__init__.py b/legacy/pxmodrim/ui/plugins/downloads/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/downloads/__init__.py rename to legacy/pxmodrim/ui/plugins/downloads/__init__.py diff --git a/src/pxmodrim/ui/plugins/downloads/model.py b/legacy/pxmodrim/ui/plugins/downloads/model.py similarity index 100% rename from src/pxmodrim/ui/plugins/downloads/model.py rename to legacy/pxmodrim/ui/plugins/downloads/model.py diff --git a/src/pxmodrim/ui/plugins/downloads/plugin.py b/legacy/pxmodrim/ui/plugins/downloads/plugin.py similarity index 100% rename from src/pxmodrim/ui/plugins/downloads/plugin.py rename to legacy/pxmodrim/ui/plugins/downloads/plugin.py diff --git a/src/pxmodrim/ui/plugins/downloads/view.py b/legacy/pxmodrim/ui/plugins/downloads/view.py similarity index 100% rename from src/pxmodrim/ui/plugins/downloads/view.py rename to legacy/pxmodrim/ui/plugins/downloads/view.py diff --git a/src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml b/legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml rename to legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml diff --git a/src/pxmodrim/ui/plugins/organizer/ModTree.qml b/legacy/pxmodrim/ui/plugins/organizer/ModTree.qml similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/ModTree.qml rename to legacy/pxmodrim/ui/plugins/organizer/ModTree.qml diff --git a/src/pxmodrim/ui/plugins/organizer/RuleEditor.qml b/legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/RuleEditor.qml rename to legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml diff --git a/src/pxmodrim/ui/plugins/organizer/TagEditor.qml b/legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/TagEditor.qml rename to legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml diff --git a/src/pxmodrim/ui/plugins/organizer/__init__.py b/legacy/pxmodrim/ui/plugins/organizer/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/__init__.py rename to legacy/pxmodrim/ui/plugins/organizer/__init__.py diff --git a/src/pxmodrim/ui/plugins/organizer/dialogs.py b/legacy/pxmodrim/ui/plugins/organizer/dialogs.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/dialogs.py rename to legacy/pxmodrim/ui/plugins/organizer/dialogs.py diff --git a/src/pxmodrim/ui/plugins/organizer/filter_model.py b/legacy/pxmodrim/ui/plugins/organizer/filter_model.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/filter_model.py rename to legacy/pxmodrim/ui/plugins/organizer/filter_model.py diff --git a/src/pxmodrim/ui/plugins/organizer/plugin.py b/legacy/pxmodrim/ui/plugins/organizer/plugin.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/plugin.py rename to legacy/pxmodrim/ui/plugins/organizer/plugin.py diff --git a/src/pxmodrim/ui/plugins/organizer/tree_model.py b/legacy/pxmodrim/ui/plugins/organizer/tree_model.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/tree_model.py rename to legacy/pxmodrim/ui/plugins/organizer/tree_model.py diff --git a/src/pxmodrim/ui/plugins/organizer/view.py b/legacy/pxmodrim/ui/plugins/organizer/view.py similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/view.py rename to legacy/pxmodrim/ui/plugins/organizer/view.py diff --git a/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml b/legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml similarity index 100% rename from src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml rename to legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml diff --git a/src/pxmodrim/ui/plugins/steam_downloader/__init__.py b/legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_downloader/__init__.py rename to legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py diff --git a/src/pxmodrim/ui/plugins/steam_downloader/plugin.py b/legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_downloader/plugin.py rename to legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml b/legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml rename to legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml diff --git a/src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml b/legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml rename to legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml diff --git a/src/pxmodrim/ui/plugins/steam_workshop/__init__.py b/legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/__init__.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py b/legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py b/legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py b/legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/inject.css b/legacy/pxmodrim/ui/plugins/steam_workshop/inject.css similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/inject.css rename to legacy/pxmodrim/ui/plugins/steam_workshop/inject.css diff --git a/src/pxmodrim/ui/plugins/steam_workshop/plugin.py b/legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/plugin.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/api.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/api.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/main.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/main.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/types.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/types.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts rename to legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/view.py b/legacy/pxmodrim/ui/plugins/steam_workshop/view.py similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/view.py rename to legacy/pxmodrim/ui/plugins/steam_workshop/view.py diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CatalogCard.qml rename to legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogImage.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CatalogImage.qml rename to legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml rename to legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml rename to legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml rename to legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml diff --git a/src/pxmodrim/ui/plugins/workshop/ModRow.qml b/legacy/pxmodrim/ui/plugins/workshop/ModRow.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/ModRow.qml rename to legacy/pxmodrim/ui/plugins/workshop/ModRow.qml diff --git a/src/pxmodrim/ui/plugins/workshop/TagPicker.qml b/legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/TagPicker.qml rename to legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/legacy/pxmodrim/ui/plugins/workshop/Workshop.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/Workshop.qml rename to legacy/pxmodrim/ui/plugins/workshop/Workshop.qml diff --git a/src/pxmodrim/ui/plugins/workshop/__init__.py b/legacy/pxmodrim/ui/plugins/workshop/__init__.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/__init__.py rename to legacy/pxmodrim/ui/plugins/workshop/__init__.py diff --git a/src/pxmodrim/ui/plugins/workshop/details.py b/legacy/pxmodrim/ui/plugins/workshop/details.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/details.py rename to legacy/pxmodrim/ui/plugins/workshop/details.py diff --git a/src/pxmodrim/ui/plugins/workshop/installed.py b/legacy/pxmodrim/ui/plugins/workshop/installed.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/installed.py rename to legacy/pxmodrim/ui/plugins/workshop/installed.py diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/legacy/pxmodrim/ui/plugins/workshop/models.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/models.py rename to legacy/pxmodrim/ui/plugins/workshop/models.py diff --git a/src/pxmodrim/ui/plugins/workshop/plugin.py b/legacy/pxmodrim/ui/plugins/workshop/plugin.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/plugin.py rename to legacy/pxmodrim/ui/plugins/workshop/plugin.py diff --git a/src/pxmodrim/ui/plugins/workshop/thumbs.js b/legacy/pxmodrim/ui/plugins/workshop/thumbs.js similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/thumbs.js rename to legacy/pxmodrim/ui/plugins/workshop/thumbs.js diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/legacy/pxmodrim/ui/plugins/workshop/view.py similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/view.py rename to legacy/pxmodrim/ui/plugins/workshop/view.py diff --git a/src/pxmodrim/ui/settings_section.py b/legacy/pxmodrim/ui/settings_section.py similarity index 100% rename from src/pxmodrim/ui/settings_section.py rename to legacy/pxmodrim/ui/settings_section.py diff --git a/src/pxmodrim/ui/theme/__init__.py b/legacy/pxmodrim/ui/theme/__init__.py similarity index 100% rename from src/pxmodrim/ui/theme/__init__.py rename to legacy/pxmodrim/ui/theme/__init__.py diff --git a/src/pxmodrim/ui/theme/constants.py b/legacy/pxmodrim/ui/theme/constants.py similarity index 100% rename from src/pxmodrim/ui/theme/constants.py rename to legacy/pxmodrim/ui/theme/constants.py diff --git a/src/pxmodrim/ui/theme/palette.py b/legacy/pxmodrim/ui/theme/palette.py similarity index 100% rename from src/pxmodrim/ui/theme/palette.py rename to legacy/pxmodrim/ui/theme/palette.py diff --git a/src/pxmodrim/ui/theme/qml_theme.py b/legacy/pxmodrim/ui/theme/qml_theme.py similarity index 100% rename from src/pxmodrim/ui/theme/qml_theme.py rename to legacy/pxmodrim/ui/theme/qml_theme.py diff --git a/src/pxmodrim/ui/theme/style.qss b/legacy/pxmodrim/ui/theme/style.qss similarity index 100% rename from src/pxmodrim/ui/theme/style.qss rename to legacy/pxmodrim/ui/theme/style.qss diff --git a/src/pxmodrim/ui/ui_prefs.py b/legacy/pxmodrim/ui/ui_prefs.py similarity index 100% rename from src/pxmodrim/ui/ui_prefs.py rename to legacy/pxmodrim/ui/ui_prefs.py diff --git a/src/pxmodrim/ui/views/__init__.py b/legacy/pxmodrim/ui/views/__init__.py similarity index 100% rename from src/pxmodrim/ui/views/__init__.py rename to legacy/pxmodrim/ui/views/__init__.py diff --git a/src/pxmodrim/ui/views/base.py b/legacy/pxmodrim/ui/views/base.py similarity index 100% rename from src/pxmodrim/ui/views/base.py rename to legacy/pxmodrim/ui/views/base.py diff --git a/src/pxmodrim/ui/views/mods_view.py b/legacy/pxmodrim/ui/views/mods_view.py similarity index 100% rename from src/pxmodrim/ui/views/mods_view.py rename to legacy/pxmodrim/ui/views/mods_view.py diff --git a/src/pxmodrim/ui/window/__init__.py b/legacy/pxmodrim/ui/window/__init__.py similarity index 100% rename from src/pxmodrim/ui/window/__init__.py rename to legacy/pxmodrim/ui/window/__init__.py diff --git a/src/pxmodrim/ui/window/actions.py b/legacy/pxmodrim/ui/window/actions.py similarity index 100% rename from src/pxmodrim/ui/window/actions.py rename to legacy/pxmodrim/ui/window/actions.py diff --git a/src/pxmodrim/ui/window/main_window.py b/legacy/pxmodrim/ui/window/main_window.py similarity index 100% rename from src/pxmodrim/ui/window/main_window.py rename to legacy/pxmodrim/ui/window/main_window.py diff --git a/src/pxmodrim/ui/window/menu_bar.py b/legacy/pxmodrim/ui/window/menu_bar.py similarity index 100% rename from src/pxmodrim/ui/window/menu_bar.py rename to legacy/pxmodrim/ui/window/menu_bar.py diff --git a/tests/__init__.py b/legacy/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to legacy/tests/__init__.py diff --git a/tests/benchmarks/bench_discovery.py b/legacy/tests/benchmarks/bench_discovery.py similarity index 100% rename from tests/benchmarks/bench_discovery.py rename to legacy/tests/benchmarks/bench_discovery.py diff --git a/tests/benchmarks/bench_sort.py b/legacy/tests/benchmarks/bench_sort.py similarity index 100% rename from tests/benchmarks/bench_sort.py rename to legacy/tests/benchmarks/bench_sort.py diff --git a/tests/conftest.py b/legacy/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to legacy/tests/conftest.py diff --git a/tests/test_checker/__init__.py b/legacy/tests/test_checker/__init__.py similarity index 100% rename from tests/test_checker/__init__.py rename to legacy/tests/test_checker/__init__.py diff --git a/tests/test_checker/test_checker.py b/legacy/tests/test_checker/test_checker.py similarity index 100% rename from tests/test_checker/test_checker.py rename to legacy/tests/test_checker/test_checker.py diff --git a/tests/test_checker/test_graph.py b/legacy/tests/test_checker/test_graph.py similarity index 100% rename from tests/test_checker/test_graph.py rename to legacy/tests/test_checker/test_graph.py diff --git a/tests/test_checker/test_issues.py b/legacy/tests/test_checker/test_issues.py similarity index 100% rename from tests/test_checker/test_issues.py rename to legacy/tests/test_checker/test_issues.py diff --git a/tests/test_compat/test_config.py b/legacy/tests/test_compat/test_config.py similarity index 100% rename from tests/test_compat/test_config.py rename to legacy/tests/test_compat/test_config.py diff --git a/tests/test_compat/test_migrator.py b/legacy/tests/test_compat/test_migrator.py similarity index 100% rename from tests/test_compat/test_migrator.py rename to legacy/tests/test_compat/test_migrator.py diff --git a/tests/test_downloads/test_manager.py b/legacy/tests/test_downloads/test_manager.py similarity index 100% rename from tests/test_downloads/test_manager.py rename to legacy/tests/test_downloads/test_manager.py diff --git a/tests/test_downloads/test_steam.py b/legacy/tests/test_downloads/test_steam.py similarity index 100% rename from tests/test_downloads/test_steam.py rename to legacy/tests/test_downloads/test_steam.py diff --git a/tests/test_logging.py b/legacy/tests/test_logging.py similarity index 100% rename from tests/test_logging.py rename to legacy/tests/test_logging.py diff --git a/tests/test_metadata/__init__.py b/legacy/tests/test_metadata/__init__.py similarity index 100% rename from tests/test_metadata/__init__.py rename to legacy/tests/test_metadata/__init__.py diff --git a/tests/test_metadata/data/local_mods/valid_mod/About/About.xml b/legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml similarity index 100% rename from tests/test_metadata/data/local_mods/valid_mod/About/About.xml rename to legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml diff --git a/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml b/legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml similarity index 100% rename from tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml rename to legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml diff --git a/tests/test_metadata/data/valid_mod/About/About.xml b/legacy/tests/test_metadata/data/valid_mod/About/About.xml similarity index 100% rename from tests/test_metadata/data/valid_mod/About/About.xml rename to legacy/tests/test_metadata/data/valid_mod/About/About.xml diff --git a/tests/test_metadata/test_metadata_cache.py b/legacy/tests/test_metadata/test_metadata_cache.py similarity index 100% rename from tests/test_metadata/test_metadata_cache.py rename to legacy/tests/test_metadata/test_metadata_cache.py diff --git a/tests/test_metadata/test_mods_config.py b/legacy/tests/test_metadata/test_mods_config.py similarity index 100% rename from tests/test_metadata/test_mods_config.py rename to legacy/tests/test_metadata/test_mods_config.py diff --git a/tests/test_metadata/test_parsing.py b/legacy/tests/test_metadata/test_parsing.py similarity index 100% rename from tests/test_metadata/test_parsing.py rename to legacy/tests/test_metadata/test_parsing.py diff --git a/tests/test_metadata/test_sidebar.py b/legacy/tests/test_metadata/test_sidebar.py similarity index 100% rename from tests/test_metadata/test_sidebar.py rename to legacy/tests/test_metadata/test_sidebar.py diff --git a/tests/test_metadata/test_startup_impact.py b/legacy/tests/test_metadata/test_startup_impact.py similarity index 100% rename from tests/test_metadata/test_startup_impact.py rename to legacy/tests/test_metadata/test_startup_impact.py diff --git a/tests/test_metadata/test_startup_impact_db.py b/legacy/tests/test_metadata/test_startup_impact_db.py similarity index 100% rename from tests/test_metadata/test_startup_impact_db.py rename to legacy/tests/test_metadata/test_startup_impact_db.py diff --git a/tests/test_metadata/test_startup_impact_phases.py b/legacy/tests/test_metadata/test_startup_impact_phases.py similarity index 100% rename from tests/test_metadata/test_startup_impact_phases.py rename to legacy/tests/test_metadata/test_startup_impact_phases.py diff --git a/tests/test_metadata/test_structures.py b/legacy/tests/test_metadata/test_structures.py similarity index 100% rename from tests/test_metadata/test_structures.py rename to legacy/tests/test_metadata/test_structures.py diff --git a/tests/test_organizer/__init__.py b/legacy/tests/test_organizer/__init__.py similarity index 100% rename from tests/test_organizer/__init__.py rename to legacy/tests/test_organizer/__init__.py diff --git a/tests/test_organizer/test_db.py b/legacy/tests/test_organizer/test_db.py similarity index 100% rename from tests/test_organizer/test_db.py rename to legacy/tests/test_organizer/test_db.py diff --git a/tests/test_organizer/test_resolve.py b/legacy/tests/test_organizer/test_resolve.py similarity index 100% rename from tests/test_organizer/test_resolve.py rename to legacy/tests/test_organizer/test_resolve.py diff --git a/tests/test_organizer/test_service.py b/legacy/tests/test_organizer/test_service.py similarity index 100% rename from tests/test_organizer/test_service.py rename to legacy/tests/test_organizer/test_service.py diff --git a/tests/test_packaging.py b/legacy/tests/test_packaging.py similarity index 100% rename from tests/test_packaging.py rename to legacy/tests/test_packaging.py diff --git a/tests/test_plugin.py b/legacy/tests/test_plugin.py similarity index 100% rename from tests/test_plugin.py rename to legacy/tests/test_plugin.py diff --git a/tests/test_providers.py b/legacy/tests/test_providers.py similarity index 100% rename from tests/test_providers.py rename to legacy/tests/test_providers.py diff --git a/tests/test_services/test_activation_service.py b/legacy/tests/test_services/test_activation_service.py similarity index 100% rename from tests/test_services/test_activation_service.py rename to legacy/tests/test_services/test_activation_service.py diff --git a/tests/test_services/test_diagnostics_databases.py b/legacy/tests/test_services/test_diagnostics_databases.py similarity index 100% rename from tests/test_services/test_diagnostics_databases.py rename to legacy/tests/test_services/test_diagnostics_databases.py diff --git a/tests/test_services/test_game_launcher.py b/legacy/tests/test_services/test_game_launcher.py similarity index 100% rename from tests/test_services/test_game_launcher.py rename to legacy/tests/test_services/test_game_launcher.py diff --git a/tests/test_services/test_sort_service.py b/legacy/tests/test_services/test_sort_service.py similarity index 100% rename from tests/test_services/test_sort_service.py rename to legacy/tests/test_services/test_sort_service.py diff --git a/tests/test_services/test_update_service.py b/legacy/tests/test_services/test_update_service.py similarity index 100% rename from tests/test_services/test_update_service.py rename to legacy/tests/test_services/test_update_service.py diff --git a/tests/test_sort/test_tier_order.py b/legacy/tests/test_sort/test_tier_order.py similarity index 100% rename from tests/test_sort/test_tier_order.py rename to legacy/tests/test_sort/test_tier_order.py diff --git a/tests/test_support/test_report.py b/legacy/tests/test_support/test_report.py similarity index 100% rename from tests/test_support/test_report.py rename to legacy/tests/test_support/test_report.py diff --git a/tests/test_ui/test_dialog_chrome.py b/legacy/tests/test_ui/test_dialog_chrome.py similarity index 100% rename from tests/test_ui/test_dialog_chrome.py rename to legacy/tests/test_ui/test_dialog_chrome.py diff --git a/tests/test_ui/test_downloads_model.py b/legacy/tests/test_ui/test_downloads_model.py similarity index 100% rename from tests/test_ui/test_downloads_model.py rename to legacy/tests/test_ui/test_downloads_model.py diff --git a/tests/test_ui/test_main_window.py b/legacy/tests/test_ui/test_main_window.py similarity index 100% rename from tests/test_ui/test_main_window.py rename to legacy/tests/test_ui/test_main_window.py diff --git a/tests/test_ui/test_menu_bar.py b/legacy/tests/test_ui/test_menu_bar.py similarity index 100% rename from tests/test_ui/test_menu_bar.py rename to legacy/tests/test_ui/test_menu_bar.py diff --git a/tests/test_ui/test_mod_info_data.py b/legacy/tests/test_ui/test_mod_info_data.py similarity index 100% rename from tests/test_ui/test_mod_info_data.py rename to legacy/tests/test_ui/test_mod_info_data.py diff --git a/tests/test_ui/test_mod_list_model.py b/legacy/tests/test_ui/test_mod_list_model.py similarity index 100% rename from tests/test_ui/test_mod_list_model.py rename to legacy/tests/test_ui/test_mod_list_model.py diff --git a/tests/test_ui/test_mod_list_panel.py b/legacy/tests/test_ui/test_mod_list_panel.py similarity index 100% rename from tests/test_ui/test_mod_list_panel.py rename to legacy/tests/test_ui/test_mod_list_panel.py diff --git a/tests/test_ui/test_mod_list_proxy_model.py b/legacy/tests/test_ui/test_mod_list_proxy_model.py similarity index 100% rename from tests/test_ui/test_mod_list_proxy_model.py rename to legacy/tests/test_ui/test_mod_list_proxy_model.py diff --git a/tests/test_ui/test_mod_tree_model.py b/legacy/tests/test_ui/test_mod_tree_model.py similarity index 100% rename from tests/test_ui/test_mod_tree_model.py rename to legacy/tests/test_ui/test_mod_tree_model.py diff --git a/tests/test_ui/test_mod_updates.py b/legacy/tests/test_ui/test_mod_updates.py similarity index 100% rename from tests/test_ui/test_mod_updates.py rename to legacy/tests/test_ui/test_mod_updates.py diff --git a/tests/test_ui/test_navigation.py b/legacy/tests/test_ui/test_navigation.py similarity index 100% rename from tests/test_ui/test_navigation.py rename to legacy/tests/test_ui/test_navigation.py diff --git a/tests/test_ui/test_organizer_dialogs.py b/legacy/tests/test_ui/test_organizer_dialogs.py similarity index 100% rename from tests/test_ui/test_organizer_dialogs.py rename to legacy/tests/test_ui/test_organizer_dialogs.py diff --git a/tests/test_ui/test_organizer_rule_editor.py b/legacy/tests/test_ui/test_organizer_rule_editor.py similarity index 100% rename from tests/test_ui/test_organizer_rule_editor.py rename to legacy/tests/test_ui/test_organizer_rule_editor.py diff --git a/tests/test_ui/test_procedural_preview.py b/legacy/tests/test_ui/test_procedural_preview.py similarity index 100% rename from tests/test_ui/test_procedural_preview.py rename to legacy/tests/test_ui/test_procedural_preview.py diff --git a/tests/test_ui/test_restore_snapshot_dialog.py b/legacy/tests/test_ui/test_restore_snapshot_dialog.py similarity index 100% rename from tests/test_ui/test_restore_snapshot_dialog.py rename to legacy/tests/test_ui/test_restore_snapshot_dialog.py diff --git a/tests/test_ui/test_settings_panel.py b/legacy/tests/test_ui/test_settings_panel.py similarity index 100% rename from tests/test_ui/test_settings_panel.py rename to legacy/tests/test_ui/test_settings_panel.py diff --git a/tests/test_ui/test_sidebar_model.py b/legacy/tests/test_ui/test_sidebar_model.py similarity index 100% rename from tests/test_ui/test_sidebar_model.py rename to legacy/tests/test_ui/test_sidebar_model.py diff --git a/tests/test_ui/test_startup_impact_data.py b/legacy/tests/test_ui/test_startup_impact_data.py similarity index 100% rename from tests/test_ui/test_startup_impact_data.py rename to legacy/tests/test_ui/test_startup_impact_data.py diff --git a/tests/test_ui/test_toast_and_progress.py b/legacy/tests/test_ui/test_toast_and_progress.py similarity index 100% rename from tests/test_ui/test_toast_and_progress.py rename to legacy/tests/test_ui/test_toast_and_progress.py diff --git a/tests/test_ui/test_unity_rich_text.py b/legacy/tests/test_ui/test_unity_rich_text.py similarity index 100% rename from tests/test_ui/test_unity_rich_text.py rename to legacy/tests/test_ui/test_unity_rich_text.py diff --git a/tests/test_ui/test_views.py b/legacy/tests/test_ui/test_views.py similarity index 100% rename from tests/test_ui/test_views.py rename to legacy/tests/test_ui/test_views.py diff --git a/tests/test_ui/test_workshop_images.py b/legacy/tests/test_ui/test_workshop_images.py similarity index 100% rename from tests/test_ui/test_workshop_images.py rename to legacy/tests/test_ui/test_workshop_images.py diff --git a/tests/test_ui/test_workshop_installed.py b/legacy/tests/test_ui/test_workshop_installed.py similarity index 100% rename from tests/test_ui/test_workshop_installed.py rename to legacy/tests/test_ui/test_workshop_installed.py diff --git a/tests/test_ui/test_workshop_models.py b/legacy/tests/test_ui/test_workshop_models.py similarity index 100% rename from tests/test_ui/test_workshop_models.py rename to legacy/tests/test_ui/test_workshop_models.py diff --git a/tests/test_ui/test_workshop_view.py b/legacy/tests/test_ui/test_workshop_view.py similarity index 100% rename from tests/test_ui/test_workshop_view.py rename to legacy/tests/test_ui/test_workshop_view.py diff --git a/tests/test_workshop/conftest.py b/legacy/tests/test_workshop/conftest.py similarity index 100% rename from tests/test_workshop/conftest.py rename to legacy/tests/test_workshop/conftest.py diff --git a/tests/test_workshop/test_catalog.py b/legacy/tests/test_workshop/test_catalog.py similarity index 100% rename from tests/test_workshop/test_catalog.py rename to legacy/tests/test_workshop/test_catalog.py diff --git a/tests/test_workshop/test_client.py b/legacy/tests/test_workshop/test_client.py similarity index 100% rename from tests/test_workshop/test_client.py rename to legacy/tests/test_workshop/test_client.py diff --git a/tests/test_workshop/test_favourites.py b/legacy/tests/test_workshop/test_favourites.py similarity index 100% rename from tests/test_workshop/test_favourites.py rename to legacy/tests/test_workshop/test_favourites.py diff --git a/tests/test_workshop/test_types.py b/legacy/tests/test_workshop/test_types.py similarity index 100% rename from tests/test_workshop/test_types.py rename to legacy/tests/test_workshop/test_types.py diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt new file mode 100644 index 0000000..3e2b02c --- /dev/null +++ b/src/app/CMakeLists.txt @@ -0,0 +1,2 @@ +qt_add_executable(pxmodrim main.cpp) +target_link_libraries(pxmodrim PRIVATE pxui pxuiplugin) diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..8cae79a --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,18 @@ +#include +#include +#include + +#include "core/version.h" + +Q_IMPORT_QML_PLUGIN(PxModRimPlugin) + +int main(int argc, char* argv[]) { + QGuiApplication app(argc, argv); + app.setApplicationName(QStringLiteral("PxModRim")); + app.setApplicationVersion(px::core::appVersion()); + + QQmlApplicationEngine engine; + engine.loadFromModule("PxModRim", "Main"); + if (engine.rootObjects().isEmpty()) return 1; + return app.exec(); +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt new file mode 100644 index 0000000..deab487 --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,7 @@ +add_library(pxcore STATIC) +target_sources(pxcore PRIVATE version.cpp) +target_include_directories(pxcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(pxcore PUBLIC + Qt6::Core Qt6::Network Qt6::Sql + QCoro6::Core QCoro6::Network + pugixml::pugixml spdlog::spdlog glaze::glaze) diff --git a/src/core/version.cpp b/src/core/version.cpp new file mode 100644 index 0000000..dd27030 --- /dev/null +++ b/src/core/version.cpp @@ -0,0 +1,18 @@ +#include "core/version.h" + +#include +#include + +namespace px::core { + +QString appVersion() { return QStringLiteral("0.3.1"); } + +QCoro::Task delayedVersion(int ms) { + QTimer timer; + timer.setSingleShot(true); + timer.start(ms); + co_await timer; + co_return appVersion(); +} + +} // namespace px::core diff --git a/src/core/version.h b/src/core/version.h new file mode 100644 index 0000000..261d228 --- /dev/null +++ b/src/core/version.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace px::core { + +QString appVersion(); + +// Smoke coroutine: proves QCoro runs on the Qt event loop. +QCoro::Task delayedVersion(int ms); + +} // namespace px::core diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt new file mode 100644 index 0000000..77843f7 --- /dev/null +++ b/src/ui/CMakeLists.txt @@ -0,0 +1,9 @@ +qt_add_library(pxui STATIC) +qt_add_qml_module(pxui + URI PxModRim + VERSION 1.0 + QML_FILES Main.qml + SOURCES placeholder.cpp +) +target_include_directories(pxui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(pxui PUBLIC pxcore Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Widgets QCoro6::Qml QCoro6::Quick) diff --git a/src/ui/Main.qml b/src/ui/Main.qml new file mode 100644 index 0000000..e792ecb --- /dev/null +++ b/src/ui/Main.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Controls + +ApplicationWindow { + visible: true + width: 640 + height: 360 + title: "PxModRim" + + Label { + anchors.centerIn: parent + text: "PxModRim" + } +} diff --git a/src/ui/placeholder.cpp b/src/ui/placeholder.cpp new file mode 100644 index 0000000..d6c38d4 --- /dev/null +++ b/src/ui/placeholder.cpp @@ -0,0 +1 @@ +// Keeps the pxui library non-empty until UI slices land. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..1c69a2d --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,5 @@ +include(Catch) + +add_executable(pxcore_tests core/test_version.cpp) +target_link_libraries(pxcore_tests PRIVATE pxcore Catch2::Catch2WithMain) +catch_discover_tests(pxcore_tests) diff --git a/tests/core/test_version.cpp b/tests/core/test_version.cpp new file mode 100644 index 0000000..3b9f9b9 --- /dev/null +++ b/tests/core/test_version.cpp @@ -0,0 +1,21 @@ +#include + +#include +#include + +#include "core/version.h" + +TEST_CASE("coroutine resumes on the Qt event loop") { + int argc = 0; + QCoreApplication app(argc, nullptr); + + QString result; + auto run = [&]() -> QCoro::Task<> { + result = co_await px::core::delayedVersion(5); + app.quit(); + }; + run(); + app.exec(); + + REQUIRE(result == px::core::appVersion()); +} From f2d1e36be804d2cd5538d6dc173eadc92c935121 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Thu, 15 Oct 2026 22:48:11 +0300 Subject: [PATCH 02/50] feat(core): port domain logic, services and CoreContext to C++23 with QCoro --- CMakeLists.txt | 12 +- cmake/Steam.cmake | 12 + src/core/CMakeLists.txt | 13 +- src/core/checker/checker.cpp | 290 ++++++++ src/core/checker/checker.h | 104 +++ src/core/checker/databases.cpp | 424 +++++++++++ src/core/checker/databases.h | 82 ++ src/core/checker/graph.cpp | 330 ++++++++ src/core/checker/graph.h | 111 +++ src/core/checker/issues.cpp | 269 +++++++ src/core/checker/issues.h | 97 +++ src/core/checker/models.cpp | 25 + src/core/checker/models.h | 61 ++ src/core/checker/sort.cpp | 248 ++++++ src/core/checker/sort.h | 25 + src/core/config.cpp | 454 +++++++++++ src/core/config.h | 192 +++++ src/core/constants.h | 82 ++ src/core/context.cpp | 179 +++++ src/core/context.h | 116 +++ src/core/context_fwd.h | 7 + src/core/downloads/downloader.cpp | 31 + src/core/downloads/downloader.h | 68 ++ src/core/downloads/manager.cpp | 152 ++++ src/core/downloads/manager.h | 68 ++ src/core/downloads/steam.cpp | 570 ++++++++++++++ src/core/downloads/steam.h | 197 +++++ src/core/downloads/types.h | 38 + src/core/error.h | 16 + src/core/events.h | 50 ++ src/core/file_io.cpp | 46 ++ src/core/file_io.h | 22 + src/core/loading.cpp | 80 ++ src/core/loading.h | 64 ++ src/core/migrator.cpp | 160 ++++ src/core/migrator.h | 28 + src/core/mod_service.cpp | 270 +++++++ src/core/mod_service.h | 77 ++ src/core/models/metadata/parsing.cpp | 410 ++++++++++ src/core/models/metadata/parsing.h | 30 + src/core/models/metadata/structures.cpp | 221 ++++++ src/core/models/metadata/structures.h | 288 +++++++ src/core/models/view/diagnostics.h | 28 + src/core/models/view/sidebar.cpp | 17 + src/core/models/view/sidebar.h | 26 + src/core/mods_config.cpp | 291 ++++++++ src/core/mods_config.h | 34 + src/core/organizer/db.cpp | 627 ++++++++++++++++ src/core/organizer/db.h | 68 ++ src/core/organizer/defaults.cpp | 86 +++ src/core/organizer/defaults.h | 21 + src/core/organizer/models.h | 117 +++ src/core/organizer/resolve.cpp | 384 ++++++++++ src/core/organizer/resolve.h | 104 +++ src/core/organizer/service.cpp | 607 +++++++++++++++ src/core/organizer/service.h | 123 +++ src/core/plugin.h | 156 ++++ src/core/plugin_config.h | 110 +++ src/core/profiler.cpp | 58 ++ src/core/profiler.h | 70 ++ src/core/providers/base.cpp | 162 ++++ src/core/providers/base.h | 130 ++++ src/core/providers/core.cpp | 40 + src/core/providers/core.h | 18 + src/core/providers/factory.cpp | 25 + src/core/providers/factory.h | 20 + src/core/providers/labels.h | 37 + src/core/providers/local.cpp | 116 +++ src/core/providers/local.h | 40 + src/core/services/activation_service.cpp | 501 +++++++++++++ src/core/services/activation_service.h | 27 + src/core/services/diagnostics_service.cpp | 351 +++++++++ src/core/services/diagnostics_service.h | 103 +++ src/core/services/game_launcher.cpp | 130 ++++ src/core/services/game_launcher.h | 47 ++ src/core/services/game_process.cpp | 453 +++++++++++ src/core/services/game_process.h | 122 +++ src/core/services/metadata_cache.cpp | 624 ++++++++++++++++ src/core/services/metadata_cache.h | 64 ++ src/core/services/mod_discovery.cpp | 61 ++ src/core/services/mod_discovery.h | 17 + src/core/services/sort_service.cpp | 119 +++ src/core/services/sort_service.h | 46 ++ .../services/startup_impact_service/db.cpp | 612 +++++++++++++++ src/core/services/startup_impact_service/db.h | 73 ++ .../startup_impact_service/labels.cpp | 121 +++ .../services/startup_impact_service/labels.h | 14 + .../startup_impact_service/models.cpp | 79 ++ .../services/startup_impact_service/models.h | 56 ++ .../startup_impact_service/parser.cpp | 144 ++++ .../services/startup_impact_service/parser.h | 18 + .../startup_impact_service/phases.cpp | 93 +++ .../services/startup_impact_service/phases.h | 37 + .../startup_impact_service/service.cpp | 109 +++ .../services/startup_impact_service/service.h | 49 ++ src/core/services/update_service.cpp | 166 +++++ src/core/services/update_service.h | 55 ++ src/core/sort/community.cpp | 57 ++ src/core/sort/community.h | 18 + src/core/sort/community_service.cpp | 214 ++++++ src/core/sort/community_service.h | 39 + src/core/sort/config.cpp | 41 + src/core/sort/config.h | 33 + src/core/sort/models.h | 28 + src/core/sort/tiers.cpp | 164 ++++ src/core/sort/tiers.h | 32 + src/core/structures.h | 14 + src/core/support/report.cpp | 368 +++++++++ src/core/support/report.h | 66 ++ src/core/toposort.h | 63 ++ src/core/utils.cpp | 51 ++ src/core/utils.h | 16 + src/core/workshop/catalog.cpp | 341 +++++++++ src/core/workshop/catalog.h | 266 +++++++ src/core/workshop/client.cpp | 288 +++++++ src/core/workshop/client.h | 66 ++ src/core/workshop/favourites.cpp | 60 ++ src/core/workshop/favourites.h | 43 ++ src/core/workshop/types.h | 223 ++++++ src/core/xml.cpp | 206 +++++ src/core/xml.h | 34 + tests/core/checker/test_checker.cpp | 236 ++++++ tests/core/checker/test_databases.cpp | 163 ++++ tests/core/checker/test_graph.cpp | 315 ++++++++ tests/core/checker/test_issues.cpp | 307 ++++++++ tests/core/downloads/test_manager.cpp | 173 +++++ tests/core/downloads/test_steam.cpp | 703 ++++++++++++++++++ tests/core/models/metadata/test_parsing.cpp | 277 +++++++ tests/core/models/metadata/test_schemas.cpp | 87 +++ .../core/models/metadata/test_structures.cpp | 167 +++++ tests/core/organizer/test_db.cpp | 331 +++++++++ tests/core/organizer/test_resolve.cpp | 544 ++++++++++++++ tests/core/organizer/test_service.cpp | 536 +++++++++++++ tests/core/organizer/test_util.h | 38 + tests/core/providers/test_factory.cpp | 44 ++ tests/core/providers/test_providers.cpp | 230 ++++++ tests/core/services/services_support.h | 68 ++ .../startup_impact_service/test_db.cpp | 286 +++++++ .../startup_impact_service/test_phases.cpp | 82 ++ .../test_startup_impact.cpp | 296 ++++++++ .../core/services/test_activation_service.cpp | 365 +++++++++ tests/core/services/test_game_launcher.cpp | 272 +++++++ tests/core/services/test_metadata_cache.cpp | 332 +++++++++ tests/core/services/test_sort_service.cpp | 196 +++++ tests/core/services/test_update_service.cpp | 188 +++++ tests/core/sort/test_community.cpp | 60 ++ tests/core/sort/test_tier_order.cpp | 190 +++++ tests/core/sort/test_tiers.cpp | 148 ++++ tests/core/support/test_report.cpp | 351 +++++++++ tests/core/test_config.cpp | 248 ++++++ tests/core/test_context.cpp | 147 ++++ tests/core/test_loading.cpp | 85 +++ tests/core/test_migrator.cpp | 235 ++++++ tests/core/test_mods_config.cpp | 345 +++++++++ tests/core/test_plugin.cpp | 137 ++++ tests/core/test_plugin_config.cpp | 64 ++ tests/core/test_support.h | 59 ++ tests/core/test_utils.cpp | 63 ++ tests/core/test_xml.cpp | 96 +++ tests/core/workshop/support.h | 222 ++++++ tests/core/workshop/test_catalog.cpp | 468 ++++++++++++ tests/core/workshop/test_client.cpp | 428 +++++++++++ tests/core/workshop/test_favourites.cpp | 78 ++ tests/core/workshop/test_types.cpp | 121 +++ .../data/local_mods/valid_mod/About/About.xml | 14 + .../Data/ludeon.rimworld/About/About.xml | 14 + tests/data/valid_mod/About/About.xml | 14 + 167 files changed, 26372 insertions(+), 7 deletions(-) create mode 100644 cmake/Steam.cmake create mode 100644 src/core/checker/checker.cpp create mode 100644 src/core/checker/checker.h create mode 100644 src/core/checker/databases.cpp create mode 100644 src/core/checker/databases.h create mode 100644 src/core/checker/graph.cpp create mode 100644 src/core/checker/graph.h create mode 100644 src/core/checker/issues.cpp create mode 100644 src/core/checker/issues.h create mode 100644 src/core/checker/models.cpp create mode 100644 src/core/checker/models.h create mode 100644 src/core/checker/sort.cpp create mode 100644 src/core/checker/sort.h create mode 100644 src/core/config.cpp create mode 100644 src/core/config.h create mode 100644 src/core/constants.h create mode 100644 src/core/context.cpp create mode 100644 src/core/context.h create mode 100644 src/core/context_fwd.h create mode 100644 src/core/downloads/downloader.cpp create mode 100644 src/core/downloads/downloader.h create mode 100644 src/core/downloads/manager.cpp create mode 100644 src/core/downloads/manager.h create mode 100644 src/core/downloads/steam.cpp create mode 100644 src/core/downloads/steam.h create mode 100644 src/core/downloads/types.h create mode 100644 src/core/error.h create mode 100644 src/core/events.h create mode 100644 src/core/file_io.cpp create mode 100644 src/core/file_io.h create mode 100644 src/core/loading.cpp create mode 100644 src/core/loading.h create mode 100644 src/core/migrator.cpp create mode 100644 src/core/migrator.h create mode 100644 src/core/mod_service.cpp create mode 100644 src/core/mod_service.h create mode 100644 src/core/models/metadata/parsing.cpp create mode 100644 src/core/models/metadata/parsing.h create mode 100644 src/core/models/metadata/structures.cpp create mode 100644 src/core/models/metadata/structures.h create mode 100644 src/core/models/view/diagnostics.h create mode 100644 src/core/models/view/sidebar.cpp create mode 100644 src/core/models/view/sidebar.h create mode 100644 src/core/mods_config.cpp create mode 100644 src/core/mods_config.h create mode 100644 src/core/organizer/db.cpp create mode 100644 src/core/organizer/db.h create mode 100644 src/core/organizer/defaults.cpp create mode 100644 src/core/organizer/defaults.h create mode 100644 src/core/organizer/models.h create mode 100644 src/core/organizer/resolve.cpp create mode 100644 src/core/organizer/resolve.h create mode 100644 src/core/organizer/service.cpp create mode 100644 src/core/organizer/service.h create mode 100644 src/core/plugin.h create mode 100644 src/core/plugin_config.h create mode 100644 src/core/profiler.cpp create mode 100644 src/core/profiler.h create mode 100644 src/core/providers/base.cpp create mode 100644 src/core/providers/base.h create mode 100644 src/core/providers/core.cpp create mode 100644 src/core/providers/core.h create mode 100644 src/core/providers/factory.cpp create mode 100644 src/core/providers/factory.h create mode 100644 src/core/providers/labels.h create mode 100644 src/core/providers/local.cpp create mode 100644 src/core/providers/local.h create mode 100644 src/core/services/activation_service.cpp create mode 100644 src/core/services/activation_service.h create mode 100644 src/core/services/diagnostics_service.cpp create mode 100644 src/core/services/diagnostics_service.h create mode 100644 src/core/services/game_launcher.cpp create mode 100644 src/core/services/game_launcher.h create mode 100644 src/core/services/game_process.cpp create mode 100644 src/core/services/game_process.h create mode 100644 src/core/services/metadata_cache.cpp create mode 100644 src/core/services/metadata_cache.h create mode 100644 src/core/services/mod_discovery.cpp create mode 100644 src/core/services/mod_discovery.h create mode 100644 src/core/services/sort_service.cpp create mode 100644 src/core/services/sort_service.h create mode 100644 src/core/services/startup_impact_service/db.cpp create mode 100644 src/core/services/startup_impact_service/db.h create mode 100644 src/core/services/startup_impact_service/labels.cpp create mode 100644 src/core/services/startup_impact_service/labels.h create mode 100644 src/core/services/startup_impact_service/models.cpp create mode 100644 src/core/services/startup_impact_service/models.h create mode 100644 src/core/services/startup_impact_service/parser.cpp create mode 100644 src/core/services/startup_impact_service/parser.h create mode 100644 src/core/services/startup_impact_service/phases.cpp create mode 100644 src/core/services/startup_impact_service/phases.h create mode 100644 src/core/services/startup_impact_service/service.cpp create mode 100644 src/core/services/startup_impact_service/service.h create mode 100644 src/core/services/update_service.cpp create mode 100644 src/core/services/update_service.h create mode 100644 src/core/sort/community.cpp create mode 100644 src/core/sort/community.h create mode 100644 src/core/sort/community_service.cpp create mode 100644 src/core/sort/community_service.h create mode 100644 src/core/sort/config.cpp create mode 100644 src/core/sort/config.h create mode 100644 src/core/sort/models.h create mode 100644 src/core/sort/tiers.cpp create mode 100644 src/core/sort/tiers.h create mode 100644 src/core/structures.h create mode 100644 src/core/support/report.cpp create mode 100644 src/core/support/report.h create mode 100644 src/core/toposort.h create mode 100644 src/core/utils.cpp create mode 100644 src/core/utils.h create mode 100644 src/core/workshop/catalog.cpp create mode 100644 src/core/workshop/catalog.h create mode 100644 src/core/workshop/client.cpp create mode 100644 src/core/workshop/client.h create mode 100644 src/core/workshop/favourites.cpp create mode 100644 src/core/workshop/favourites.h create mode 100644 src/core/workshop/types.h create mode 100644 src/core/xml.cpp create mode 100644 src/core/xml.h create mode 100644 tests/core/checker/test_checker.cpp create mode 100644 tests/core/checker/test_databases.cpp create mode 100644 tests/core/checker/test_graph.cpp create mode 100644 tests/core/checker/test_issues.cpp create mode 100644 tests/core/downloads/test_manager.cpp create mode 100644 tests/core/downloads/test_steam.cpp create mode 100644 tests/core/models/metadata/test_parsing.cpp create mode 100644 tests/core/models/metadata/test_schemas.cpp create mode 100644 tests/core/models/metadata/test_structures.cpp create mode 100644 tests/core/organizer/test_db.cpp create mode 100644 tests/core/organizer/test_resolve.cpp create mode 100644 tests/core/organizer/test_service.cpp create mode 100644 tests/core/organizer/test_util.h create mode 100644 tests/core/providers/test_factory.cpp create mode 100644 tests/core/providers/test_providers.cpp create mode 100644 tests/core/services/services_support.h create mode 100644 tests/core/services/startup_impact_service/test_db.cpp create mode 100644 tests/core/services/startup_impact_service/test_phases.cpp create mode 100644 tests/core/services/startup_impact_service/test_startup_impact.cpp create mode 100644 tests/core/services/test_activation_service.cpp create mode 100644 tests/core/services/test_game_launcher.cpp create mode 100644 tests/core/services/test_metadata_cache.cpp create mode 100644 tests/core/services/test_sort_service.cpp create mode 100644 tests/core/services/test_update_service.cpp create mode 100644 tests/core/sort/test_community.cpp create mode 100644 tests/core/sort/test_tier_order.cpp create mode 100644 tests/core/sort/test_tiers.cpp create mode 100644 tests/core/support/test_report.cpp create mode 100644 tests/core/test_config.cpp create mode 100644 tests/core/test_context.cpp create mode 100644 tests/core/test_loading.cpp create mode 100644 tests/core/test_migrator.cpp create mode 100644 tests/core/test_mods_config.cpp create mode 100644 tests/core/test_plugin.cpp create mode 100644 tests/core/test_plugin_config.cpp create mode 100644 tests/core/test_support.h create mode 100644 tests/core/test_utils.cpp create mode 100644 tests/core/test_xml.cpp create mode 100644 tests/core/workshop/support.h create mode 100644 tests/core/workshop/test_catalog.cpp create mode 100644 tests/core/workshop/test_client.cpp create mode 100644 tests/core/workshop/test_favourites.cpp create mode 100644 tests/core/workshop/test_types.cpp create mode 100644 tests/data/local_mods/valid_mod/About/About.xml create mode 100644 tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml create mode 100644 tests/data/valid_mod/About/About.xml diff --git a/CMakeLists.txt b/CMakeLists.txt index 080ef78..6128a0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,7 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_AUTOMOC ON) option(PX_BUILD_TESTS "Build tests" ON) +option(PX_BUILD_UI "Build the Qt Quick UI and the executable" ON) set(PX_WORKSHOP_VARIANT "SteamWorkshop" CACHE STRING "NoWorkshop | SteamWorkshop | NativeWorkshop") set_property(CACHE PX_WORKSHOP_VARIANT PROPERTY STRINGS @@ -23,7 +24,7 @@ set(CPM_USE_LOCAL_PACKAGES ON) set(CPM_SOURCE_CACHE "$ENV{HOME}/.cache/CPM" CACHE PATH "") include(cmake/CPM.cmake) -find_package(Qt6 6.8 REQUIRED COMPONENTS Core Network Sql Gui Quick QuickControls2 Svg Widgets) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets) qt_standard_project_setup(REQUIRES 6.8) set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") @@ -34,6 +35,7 @@ find_package(QCoro6 REQUIRED COMPONENTS Core Network Qml Quick) qcoro_enable_coroutines() find_package(pugixml REQUIRED) +find_package(libzip REQUIRED) find_package(spdlog REQUIRED) CPMAddPackage("gh:stephenberry/glaze@5.5.4") if(PX_BUILD_TESTS) @@ -41,11 +43,15 @@ if(PX_BUILD_TESTS) list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") endif() +include(cmake/Steam.cmake OPTIONAL) + add_compile_options(-Wall -Wextra -Wpedantic) add_subdirectory(src/core) -add_subdirectory(src/ui) -add_subdirectory(src/app) +if(PX_BUILD_UI) + add_subdirectory(src/ui) + add_subdirectory(src/app) +endif() if(PX_BUILD_TESTS) enable_testing() diff --git a/cmake/Steam.cmake b/cmake/Steam.cmake new file mode 100644 index 0000000..787ac02 --- /dev/null +++ b/cmake/Steam.cmake @@ -0,0 +1,12 @@ +# Steam Workshop downloads via PxSteamDL. The root CMakeLists includes this file +# OPTIONALLY: without it the build has no Steam support and downloads/steam.cpp +# is compiled out (PX_HAS_STEAM stays undefined). + +# PxSteamDL builds all of its dependencies from source. Its cacert dependency +# uses a date as VERSION, which CPM rejects as a find_package() name when local +# packages are enabled, so local lookup is switched off for this fetch only. +set(CPM_USE_LOCAL_PACKAGES OFF) +CPMAddPackage("gh:PyXiion/PxSteamDL@0.3.0") +set(CPM_USE_LOCAL_PACKAGES ON) + +set(PX_STEAM_LIBS PxSteamDL::pxsteamdl) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index deab487..0150751 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,7 +1,12 @@ -add_library(pxcore STATIC) -target_sources(pxcore PRIVATE version.cpp) +file(GLOB_RECURSE PX_CORE_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +add_library(pxcore STATIC ${PX_CORE_SOURCES}) target_include_directories(pxcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) target_link_libraries(pxcore PUBLIC - Qt6::Core Qt6::Network Qt6::Sql + Qt6::Core Qt6::Concurrent Qt6::Network Qt6::Sql QCoro6::Core QCoro6::Network - pugixml::pugixml spdlog::spdlog glaze::glaze) + libzip::zip pugixml::pugixml spdlog::spdlog glaze::glaze) + +if(PX_STEAM_LIBS) + target_link_libraries(pxcore PUBLIC ${PX_STEAM_LIBS}) + target_compile_definitions(pxcore PUBLIC PX_HAS_STEAM=1) +endif() diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp new file mode 100644 index 0000000..c3626e4 --- /dev/null +++ b/src/core/checker/checker.cpp @@ -0,0 +1,290 @@ +#include "core/checker/checker.h" + +#include +#include + +#include + +namespace px::core::checker { +namespace { + +ModDiagnostics compose(const std::vector>& per_checker) { + ModDiagnostics diagnostics; + for (const auto& issues : per_checker) { + for (const ModIssue& issue : issues) { + if (issue.severity == IssueSeverity::Error) { + diagnostics.errors.append(issue); + } else { + diagnostics.warnings.append(issue); + } + } + } + return diagnostics; +} + +// Same key set, and every entry is the same mod object (identity, not equality). +bool same_mods(const QHash& a, + const QHash& b) { + if (a.size() != b.size()) { + return false; + } + for (auto it = a.cbegin(); it != a.cend(); ++it) { + const auto other = b.constFind(it.key()); + if (other == b.cend() || other.value() != it.value()) { + return false; + } + } + return true; +} + +} // namespace + +ModChecker::ModChecker(std::vector> checkers, + sort::SortSettings settings, + QString target_version, + DiagnosticsCallback on_diagnostics_changed) + : checkers_(std::move(checkers)), + settings_(std::move(settings)), + target_version_(std::move(target_version)), + on_diagnostics_changed_(std::move(on_diagnostics_changed)) {} + +void ModChecker::set_no_version_warning(QSet pids) { + no_version_warning_ = std::move(pids); + checker_results_.reset(); +} + +void ModChecker::set_use_this_instead(QHash db) { + use_this_instead_ = std::move(db); + checker_results_.reset(); +} + +void ModChecker::set_community_rules(sort::CommunityRules rules) { + community_rules_ = std::move(rules); +} + +void ModChecker::rebuild(const QHash& mods, + const QStringList& ordered_uuids, + Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + { + auto scope = t("collect_active"); + collect_active(mods, ordered_uuids); + } + { + auto scope = t("graph.build"); + graph_.build(active_mods_, ordered_pids_, settings_, &community_rules_); + } + { + auto scope = t("find_cycles"); + cached_cycles_ = graph_.find_cycles(); + } + const CheckContext ctx = build_context(cached_cycles_); + { + auto scope = t("check_mod"); + check_all(ctx); + } + + emit_diagnostics(); +} + +void ModChecker::toggle_mod(const ListedMod& mod, const QStringList& ordered_uuids) { + if (dynamic_cast(&mod) == nullptr) { + emit_diagnostics(); + return; + } + rebuild(all_mods_, ordered_uuids); +} + +void ModChecker::move_mod(const QString& uuid, int old_index, int new_index) { + if (ordered_pids_.isEmpty()) { + return; + } + const auto uuid_it = uuid_to_pid_.constFind(uuid); + if (uuid_it == uuid_to_pid_.cend()) { + return; + } + const PackageId pid = uuid_it.value(); + + ordered_pids_.insert(new_index, ordered_pids_.takeAt(old_index)); + graph_.update_order(ordered_pids_); + + const CheckContext ctx = build_context(cached_cycles_); + // Only neighbours are rechecked, so cached results no longer match the order. + checker_results_.reset(); + + QSet affected{pid}; + affected.unite(graph_.neighbors(pid)); + for (const PackageId& current_pid : affected) { + if (const auto mod = active_mods_.constFind(current_pid); mod != active_mods_.cend()) { + diagnostics_.insert(current_pid, check_mod(*mod.value(), ctx)); + } + } + + emit_diagnostics(); +} + +void ModChecker::reorder(const QStringList& ordered_uuids) { + const QHash previous = active_mods_; + const QHash previous_index = graph_.pid_to_index(); + collect_active(all_mods_, ordered_uuids); + graph_.update_order(ordered_pids_); + + const QList> cycles = graph_.find_cycles(); + const CheckContext ctx = build_context(cycles); + + if (!checker_results_.has_value() || !same_mods(previous, active_mods_)) { + check_all(ctx); + } else { + QSet moved; + for (auto it = ctx.pid_to_index.cbegin(); it != ctx.pid_to_index.cend(); ++it) { + if (previous_index.value(it.key(), -1) != it.value()) { + moved.insert(it.key()); + } + } + + CheckerResults& results = *checker_results_; + QSet changed; + for (std::size_t i = 0; i < checkers_.size(); ++i) { + const ModIssueChecker& checker = *checkers_[i]; + if (!checker.order_dependent()) { + continue; + } + const auto affected = checker.affected_by_moves(ctx, moved); + for (auto it = active_mods_.cbegin(); it != active_mods_.cend(); ++it) { + if (affected && !affected->contains(it.key())) { + continue; + } + QList issues = run_checker(checker, *it.value(), ctx); + auto& stored = results[it.key()][i]; + if (issues != stored) { + stored = std::move(issues); + changed.insert(it.key()); + } + } + } + for (const PackageId& pid : changed) { + diagnostics_.insert(pid, compose(results[pid])); + } + } + + emit_diagnostics(); +} + +const ModDiagnostics* ModChecker::diagnostics_for(const QString& uuid) const { + const auto pid = uuid_to_pid_.constFind(uuid); + if (pid == uuid_to_pid_.cend()) { + return nullptr; + } + const auto diagnostics = diagnostics_.constFind(pid.value()); + return diagnostics == diagnostics_.cend() ? nullptr : &diagnostics.value(); +} + +DiagnosticsByUuid ModChecker::active_mod_diagnostics() const { + DiagnosticsByUuid result; + for (auto it = uuid_to_pid_.cbegin(); it != uuid_to_pid_.cend(); ++it) { + if (const auto diagnostics = diagnostics_.constFind(it.value()); + diagnostics != diagnostics_.cend()) { + result.insert(it.key(), diagnostics.value()); + } + } + return result; +} + +void ModChecker::collect_active(const QHash& mods, + const QStringList& ordered_uuids) { + // `mods` may alias all_mods_ (toggle_mod and reorder pass it back in), so copy first. + all_mods_ = mods; + active_mods_.clear(); + uuid_to_pid_.clear(); + ordered_pids_.clear(); + + for (const QString& uuid : ordered_uuids) { + const auto listed = all_mods_.constFind(uuid); + if (listed == all_mods_.cend()) { + continue; + } + auto about = std::dynamic_pointer_cast(listed.value()); + if (!about) { + continue; + } + const PackageId pid = about->package_id; + active_mods_.insert(pid, about); + uuid_to_pid_.insert(uuid, pid); + ordered_pids_.append(pid); + } +} + +CheckContext ModChecker::build_context(const QList>& cycles) const { + return CheckContext{ + .active_mods = active_mods_, + .ordered_pids = ordered_pids_, + .pid_to_index = graph_.pid_to_index(), + .graph = graph_, + .settings = settings_, + .target_version = target_version_, + .no_version_warning = no_version_warning_, + .use_this_instead = use_this_instead_, + .cycles = cycles, + }; +} + +void ModChecker::check_all(const CheckContext& ctx) { + CheckerResults results; + QHash diagnostics; + for (auto it = active_mods_.cbegin(); it != active_mods_.cend(); ++it) { + std::vector> per_checker; + per_checker.reserve(checkers_.size()); + for (const auto& checker : checkers_) { + per_checker.push_back(run_checker(*checker, *it.value(), ctx)); + } + diagnostics.insert(it.key(), compose(per_checker)); + results.insert(it.key(), std::move(per_checker)); + } + checker_results_ = std::move(results); + diagnostics_ = std::move(diagnostics); +} + +ModDiagnostics ModChecker::check_mod(const AboutXmlMod& mod, const CheckContext& ctx) const { + std::vector> per_checker; + per_checker.reserve(checkers_.size()); + for (const auto& checker : checkers_) { + per_checker.push_back(run_checker(*checker, mod, ctx)); + } + return compose(per_checker); +} + +QList ModChecker::run_checker(const ModIssueChecker& checker, + const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (!checker.should_check(mod, ctx)) { + return {}; + } + // One checker must not abort diagnostics for every other mod and checker. + try { + return checker.check(mod, ctx); + } catch (const std::exception& error) { + spdlog::error("Checker failed on {}: {}", + mod.package_id.str().toStdString(), + error.what()); + return {}; + } +} + +void ModChecker::emit_diagnostics() const { + if (!on_diagnostics_changed_) { + return; + } + DiagnosticsByUuid by_uuid; + for (auto it = uuid_to_pid_.cbegin(); it != uuid_to_pid_.cend(); ++it) { + const auto diagnostics = diagnostics_.constFind(it.value()); + if (diagnostics != diagnostics_.cend() && + (diagnostics->has_errors() || diagnostics->has_warnings())) { + by_uuid.insert(it.key(), diagnostics.value()); + } + } + on_diagnostics_changed_(by_uuid); +} + +} // namespace px::core::checker diff --git a/src/core/checker/checker.h b/src/core/checker/checker.h new file mode 100644 index 0000000..dd4a4a7 --- /dev/null +++ b/src/core/checker/checker.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "core/checker/graph.h" +#include "core/checker/issues.h" +#include "core/checker/models.h" +#include "core/models/metadata/structures.h" +#include "core/profiler.h" +#include "core/sort/config.h" +#include "core/sort/models.h" + +namespace px::core::checker { + +// Diagnostics with at least one issue, keyed by mod uuid. +using DiagnosticsByUuid = QHash; +using DiagnosticsCallback = std::function; + +// Runs every checker over the active mods and keeps their diagnostics up to date as the mod +// list, load order and databases change. +class ModChecker { +public: + ModChecker(std::vector> checkers, + sort::SortSettings settings, + QString target_version = QStringLiteral("1.5"), + DiagnosticsCallback on_diagnostics_changed = {}); + + // ── Database config ─────────────────────────────────────── + + void set_no_version_warning(QSet pids); + void set_use_this_instead(QHash db); + void set_community_rules(sort::CommunityRules rules); + + // ── Rebuild ─────────────────────────────────────────────── + + // Re-scans all mods and regenerates diagnostics from scratch. + void rebuild(const QHash& mods, + const QStringList& ordered_uuids, + Profiler* timer = nullptr); + + // ── Incremental updates ─────────────────────────────────── + + // Rebuilds constraints and diagnostics after toggling `mod`. Non-About.xml mods only emit. + void toggle_mod(const ListedMod& mod, const QStringList& ordered_uuids); + + // Moves a mod to a new position and rechecks it and its graph neighbours. + void move_mod(const QString& uuid, int old_index, int new_index); + + // Reapplies the full active-mod order and regenerates the affected diagnostics. + void reorder(const QStringList& ordered_uuids); + + // ── Query ───────────────────────────────────────────────── + + QHash active_mods() const { return active_mods_; } + const ConstraintGraph& graph() const noexcept { return graph_; } + QList ordered_pids() const { return ordered_pids_; } + const sort::CommunityRules& community_rules() const noexcept { return community_rules_; } + + // Null when `uuid` is not an active mod. The pointer is valid until the next mutation. + const ModDiagnostics* diagnostics_for(const QString& uuid) const; + DiagnosticsByUuid active_mod_diagnostics() const; + +private: + using CheckerResults = QHash>>; + + void collect_active(const QHash& mods, const QStringList& ordered_uuids); + CheckContext build_context(const QList>& cycles) const; + void check_all(const CheckContext& ctx); + ModDiagnostics check_mod(const AboutXmlMod& mod, const CheckContext& ctx) const; + QList run_checker(const ModIssueChecker& checker, + const AboutXmlMod& mod, + const CheckContext& ctx) const; + void emit_diagnostics() const; + + std::vector> checkers_; + sort::SortSettings settings_; + QString target_version_; + DiagnosticsCallback on_diagnostics_changed_; + + ConstraintGraph graph_; + QHash all_mods_; + QHash diagnostics_; + QHash active_mods_; + QHash uuid_to_pid_; + QList ordered_pids_; + + QSet no_version_warning_; + QHash use_this_instead_; + sort::CommunityRules community_rules_; + QList> cached_cycles_; + // Per-mod issues from each checker (checker order), reused by reorder() for + // order-independent checkers; nullopt when stale. + std::optional checker_results_; +}; + +} // namespace px::core::checker diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp new file mode 100644 index 0000000..8b77451 --- /dev/null +++ b/src/core/checker/databases.cpp @@ -0,0 +1,424 @@ +#include "core/checker/databases.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/error.h" +#include "core/file_io.h" +#include "core/xml.h" + +namespace px::core::checker { +namespace { + +const QString kNoVersionWarningUrl = + QStringLiteral("https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip"); +const QString kUseThisInsteadUrl = + QStringLiteral("https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip"); + +const QString kNoVersionWarningXml = QStringLiteral("ModIdsToFix.xml"); +const QString kReplacementsGz = QStringLiteral("replacements.json.gz"); + +constexpr int kDownloadTimeoutMs = 30000; + +std::optional file_stamp(const QString& path) { + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + return FileStamp{info.lastModified().toMSecsSinceEpoch(), info.size()}; +} + +std::optional file_age_s(const QString& path) { + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + const qint64 age_ms = info.lastModified().msecsTo(QDateTime::currentDateTime()); + return std::max(0.0, static_cast(age_ms) / 1000.0); +} + +// Downloads `url`, following redirects. Any transport or HTTP error fails the download. +QCoro::Task> fetch_archive(QNetworkAccessManager& network, const QString& url) { + QNetworkRequest request{QUrl(url)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kDownloadTimeoutMs); + QNetworkReply* reply = network.get(request); + // Released however the coroutine leaves, so a finished reply is never kept around. + struct Release { + QNetworkReply* reply; + ~Release() { reply->deleteLater(); } + } release{reply}; + + co_await qCoro(reply).waitForFinished(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + co_return reply->readAll(); +} + +Error zip_failure(const char* message) { + return Error{QString::fromUtf8(message)}; +} + +// Reads the first archive entry whose file name is `entry_name`, at any depth. Yields nullopt +// when no entry has that name. +Result> read_zip_entry(const QByteArray& archive, + const QString& entry_name) { + zip_error_t error; + zip_error_init(&error); + zip_source_t* source = zip_source_buffer_create(archive.constData(), + static_cast(archive.size()), + 0, + &error); + if (source == nullptr) { + return std::unexpected(Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + zip_t* zip = zip_open_from_source(source, ZIP_RDONLY, &error); + if (zip == nullptr) { + zip_source_free(source); + return std::unexpected(Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + struct Close { + zip_t* zip; + ~Close() { zip_close(zip); } + } close{zip}; + + const zip_int64_t count = zip_get_num_entries(zip, 0); + for (zip_int64_t index = 0; index < count; ++index) { + const auto position = static_cast(index); + const char* name = zip_get_name(zip, position, 0); + if (name == nullptr || + QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entry_name) { + continue; + } + + zip_file_t* file = zip_fopen_index(zip, position, 0); + if (file == nullptr) { + return std::unexpected(zip_failure(zip_strerror(zip))); + } + QByteArray bytes; + QByteArray chunk(64 * 1024, Qt::Uninitialized); + zip_int64_t read = 0; + while ((read = zip_fread(file, chunk.data(), static_cast(chunk.size()))) > 0) { + bytes.append(chunk.constData(), static_cast(read)); + } + const bool failed = read < 0; + zip_fclose(file); + if (failed) { + return std::unexpected(zip_failure(zip_strerror(zip))); + } + return std::optional(std::move(bytes)); + } + return std::optional{}; +} + +// Blocking: writes the archive entry `entry_name` to `target`. Yields false when the archive has +// no such entry, leaving `target` untouched. +Result store_archive_entry(const QByteArray& archive, + const QString& entry_name, + const QString& target) { + auto entry = read_zip_entry(archive, entry_name); + if (!entry) { + return std::unexpected(entry.error()); + } + if (!entry->has_value()) { + return false; + } + const QByteArray& bytes = **entry; + auto written = write_file_atomically( + target, std::string_view(bytes.constData(), static_cast(bytes.size()))); + if (!written) { + return std::unexpected(written.error()); + } + return true; +} + +// Blocking: inflates a gzip stream in memory. +Result gunzip(const std::string& data) { + z_stream stream{}; + // 16 + MAX_WBITS selects the gzip wrapper, header and CRC included. + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + return std::unexpected(Error{QStringLiteral("Cannot start gzip decoding")}); + } + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::array buffer{}; + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = inflate(&stream, Z_NO_FLUSH); + if (status != Z_OK && status != Z_STREAM_END) { + // Z_BUF_ERROR here means the input ended before the stream did. + inflateEnd(&stream); + return std::unexpected(Error{QStringLiteral("Corrupt or truncated gzip data")}); + } + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + + inflateEnd(&stream); + return out; +} + +// The ids under ModIdsToFix/li: a lone
  • is a string, several are an array. Nullopt when the +// document has another shape. +std::optional> no_version_warning_ids(const glz::json_t& root) { + QSet ids; + if (!root.contains("ModIdsToFix")) { + return ids; + } + const glz::json_t& section = root["ModIdsToFix"]; + if (!section.is_object()) { + return std::nullopt; + } + if (!section.contains("li")) { + return ids; + } + const glz::json_t& items = section["li"]; + if (items.is_string()) { + ids.insert(PackageId(QString::fromStdString(items.get_string()))); + return ids; + } + if (!items.is_array()) { + return std::nullopt; + } + for (const glz::json_t& item : items.get_array()) { + if (!item.is_string()) { + return std::nullopt; + } + ids.insert(PackageId(QString::fromStdString(item.get_string()))); + } + return ids; +} + +// Blocking: parses the cached NoVersionWarning XML. A broken file yields an empty set. +QSet parse_no_version_warning(const QString& xml_path) { + const auto data = xmlFileToJson(xml_path); + if (!data) { + spdlog::error("Failed to load No Version Warning DB: {}", data.error().message.toStdString()); + return {}; + } + const auto ids = no_version_warning_ids(*data); + if (!ids) { + spdlog::error("Failed to load No Version Warning DB: unexpected ModIdsToFix layout"); + return {}; + } + spdlog::info("Loaded {} No Version Warning entries", ids->size()); + return *ids; +} + +// Text of a JSON scalar as Python's str() would show it. Other kinds yield an empty string. +QString json_text(const glz::json_t& object, const char* key) { + if (!object.contains(key)) { + return {}; + } + const glz::json_t& value = object[key]; + if (value.is_string()) { + return QString::fromStdString(value.get_string()); + } + if (value.is_number()) { + const double number = value.get_number(); + if (std::floor(number) == number && std::abs(number) < 1e15) { + return QString::number(static_cast(number)); + } + return QString::number(number, 'g', 17); + } + return {}; +} + +QStringList json_string_list(const glz::json_t& object, const char* key) { + QStringList values; + if (!object.contains(key) || !object[key].is_array()) { + return values; + } + for (const glz::json_t& item : object[key].get_array()) { + if (item.is_string()) { + values.append(QString::fromStdString(item.get_string())); + } + } + return values; +} + +// Blocking: parses the cached gzipped UseThisInstead JSON. A broken file yields no entries. +QHash parse_replacements(const QString& json_path) { + const auto bytes = read_file_bytes(json_path); + if (!bytes) { + spdlog::error("Failed to load Use This Instead DB: {}", bytes.error().message.toStdString()); + return {}; + } + const auto text = gunzip(*bytes); + if (!text) { + spdlog::error("Failed to load Use This Instead DB: {}", text.error().message.toStdString()); + return {}; + } + + // Written by a Windows editor in some releases; the UTF-8 BOM is not part of the JSON. + std::string body = *text; + if (body.starts_with("\xEF\xBB\xBF")) { + body.erase(0, 3); + } + glz::json_t raw; + if (auto error = glz::read_json(raw, body)) { + spdlog::error("Failed to load Use This Instead DB: {}", glz::format_error(error, body)); + return {}; + } + + QHash entries; + if (raw.is_object() && raw.contains("rules") && raw["rules"].is_array()) { + for (const glz::json_t& rule : raw["rules"].get_array()) { + if (!rule.is_object() || !rule.contains("oldWorkshopId")) { + continue; + } + ReplacementInfo info; + info.name = json_text(rule, "newName"); + info.author = json_text(rule, "newAuthor"); + info.packageid = json_text(rule, "newPackageId"); + info.pfid = json_text(rule, "newWorkshopId"); + info.supportedversions = json_string_list(rule, "newVersions"); + entries.insert(json_text(rule, "oldWorkshopId"), std::move(info)); + } + } + spdlog::info("Loaded {} Use This Instead entries", entries.size()); + return entries; +} + +} // namespace + +NoVersionWarningService::NoVersionWarningService(const ConfigService& config_service, + QNetworkAccessManager& network) + : network_(network), + cache_dir_(config_service.directory()), + xml_path_(QDir(cache_dir_).filePath(kNoVersionWarningXml)) {} + +QCoro::Task> NoVersionWarningService::ensure(bool force) { + if (!force) { + if (const auto stamp = file_stamp(xml_path_)) { + if (stamp != loaded_stamp_) { + pids_ = co_await QtConcurrent::run(parse_no_version_warning, xml_path_); + loaded_stamp_ = stamp; + } + co_return pids_; + } + } + co_return co_await download(); +} + +std::optional NoVersionWarningService::cache_age_s() const { + return file_age_s(xml_path_); +} + +QSet NoVersionWarningService::load_if_exists() { + if (QFileInfo::exists(xml_path_)) { + pids_ = parse_no_version_warning(xml_path_); + } + return pids_; +} + +QCoro::Task> NoVersionWarningService::download() { + QDir().mkpath(cache_dir_); + + auto archive = co_await fetch_archive(network_, kNoVersionWarningUrl); + if (!archive) { + spdlog::warn("Failed to download No Version Warning DB: {}", + archive.error().message.toStdString()); + co_return QSet{}; + } + auto stored = co_await QtConcurrent::run(store_archive_entry, + std::move(*archive), + kNoVersionWarningXml, + xml_path_); + if (!stored) { + spdlog::warn("Failed to download No Version Warning DB: {}", + stored.error().message.toStdString()); + co_return QSet{}; + } + if (!*stored) { + co_return QSet{}; + } + + pids_ = co_await QtConcurrent::run(parse_no_version_warning, xml_path_); + co_return pids_; +} + +UseThisInsteadService::UseThisInsteadService(const ConfigService& config_service, + QNetworkAccessManager& network) + : network_(network), + cache_dir_(config_service.directory()), + json_path_(QDir(cache_dir_).filePath(kReplacementsGz)) {} + +QCoro::Task> UseThisInsteadService::ensure(bool force) { + if (!force) { + if (const auto stamp = file_stamp(json_path_)) { + if (stamp != loaded_stamp_) { + entries_ = co_await QtConcurrent::run(parse_replacements, json_path_); + loaded_stamp_ = stamp; + } + co_return entries_; + } + } + co_return co_await download(); +} + +std::optional UseThisInsteadService::cache_age_s() const { + return file_age_s(json_path_); +} + +QHash UseThisInsteadService::load_if_exists() { + if (QFileInfo::exists(json_path_)) { + entries_ = parse_replacements(json_path_); + } + return entries_; +} + +QCoro::Task> UseThisInsteadService::download() { + QDir().mkpath(cache_dir_); + + auto archive = co_await fetch_archive(network_, kUseThisInsteadUrl); + if (!archive) { + spdlog::warn("Failed to download Use This Instead DB: {}", + archive.error().message.toStdString()); + co_return QHash{}; + } + auto stored = co_await QtConcurrent::run(store_archive_entry, + std::move(*archive), + kReplacementsGz, + json_path_); + if (!stored) { + spdlog::warn("Failed to download Use This Instead DB: {}", + stored.error().message.toStdString()); + co_return QHash{}; + } + if (!*stored) { + co_return QHash{}; + } + + entries_ = co_await QtConcurrent::run(parse_replacements, json_path_); + co_return entries_; +} + +} // namespace px::core::checker diff --git a/src/core/checker/databases.h b/src/core/checker/databases.h new file mode 100644 index 0000000..1254bea --- /dev/null +++ b/src/core/checker/databases.h @@ -0,0 +1,82 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "core/checker/models.h" +#include "core/models/metadata/structures.h" + +class QNetworkAccessManager; + +namespace px::core { + +class ConfigService; + +namespace checker { + +// Identity of a cache file: changes whenever the file is rewritten. +struct FileStamp { + qint64 modified_ms = 0; + qint64 size = 0; + + bool operator==(const FileStamp&) const = default; +}; + +// Caches the NoVersionWarning package-id list under the config directory and refreshes it from +// GitHub when the cache is missing or a refresh is forced. +class NoVersionWarningService { +public: + NoVersionWarningService(const ConfigService& config_service, QNetworkAccessManager& network); + + // Cached ids, reloaded when the file changed on disk. Downloads when the cache is missing + // or `force` is set. A failed download yields an empty set and keeps the previous ids. + QCoro::Task> ensure(bool force = false); + + // Seconds since the cache file was written, or nullopt when there is no cache. + std::optional cache_age_s() const; + + // Loads the cache synchronously when it exists; otherwise returns the current ids. + QSet load_if_exists(); + +private: + QCoro::Task> download(); + + QNetworkAccessManager& network_; + QString cache_dir_; + QString xml_path_; + QSet pids_; + // Stamp of the file the current `pids_` were parsed from. + std::optional loaded_stamp_; +}; + +// Caches the UseThisInstead replacement database (gzipped JSON) under the config directory and +// refreshes it from GitHub when the cache is missing or a refresh is forced. +class UseThisInsteadService { +public: + UseThisInsteadService(const ConfigService& config_service, QNetworkAccessManager& network); + + // Cached entries, reloaded when the file changed on disk. Downloads when the cache is + // missing or `force` is set. A failed download yields an empty map and keeps the previous + // entries. + QCoro::Task> ensure(bool force = false); + + std::optional cache_age_s() const; + + QHash load_if_exists(); + +private: + QCoro::Task> download(); + + QNetworkAccessManager& network_; + QString cache_dir_; + QString json_path_; + QHash entries_; + std::optional loaded_stamp_; +}; + +} // namespace checker +} // namespace px::core diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp new file mode 100644 index 0000000..06fc738 --- /dev/null +++ b/src/core/checker/graph.cpp @@ -0,0 +1,330 @@ +#include "core/checker/graph.h" + +#include +#include +#include + +namespace px::core::checker { +namespace { + +constexpr std::size_t index_of_type(EdgeType type) { + return static_cast(type); +} + +bool str_less(const PackageId& a, const PackageId& b) { + return a.str() < b.str(); +} + +// Qt hashes are randomised per process, so the Tarjan traversal visits roots and neighbours +// in name order to keep the reported cycles stable from run to run. +QList sorted_pids(const QSet& pids) { + QList sorted(pids.cbegin(), pids.cend()); + std::ranges::sort(sorted, str_less); + return sorted; +} + +QHash build_alt_map(const QHash& active_mods) { + QHash alt_map; + for (const auto& mod : active_mods) { + for (const DependencyMod& dep : mod->about_rules.dependencies) { + if (active_mods.contains(dep.package_id)) { + continue; + } + for (const PackageId& alternative_pid : dep.alternative_package_ids) { + if (active_mods.contains(alternative_pid)) { + alt_map.insert(dep.package_id, alternative_pid); + break; + } + } + } + } + return alt_map; +} + +} // namespace + +QSet ConstraintGraph::outgoing(const PackageId& pid) const { + QSet result; + if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); + } + } + return result; +} + +QSet ConstraintGraph::incoming(const PackageId& pid) const { + QSet result; + if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); + } + } + return result; +} + +QSet ConstraintGraph::neighbors(const PackageId& pid) const { + QSet result; + if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.target); + } + } + } + if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.source); + } + } + } + return result; +} + +QSet ConstraintGraph::edges_of_type(const PackageId& pid, EdgeType type) const { + if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { + return it.value()[index_of_type(type)]; + } + return {}; +} + +QSet ConstraintGraph::incoming_of_type(const PackageId& pid, EdgeType type) const { + if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { + return it.value()[index_of_type(type)]; + } + return {}; +} + +void ConstraintGraph::build(const QHash& active_mods, + const QList& ordered_pids, + const sort::SortSettings& settings, + const sort::CommunityRules* community_rules) { + outgoing_.clear(); + incoming_.clear(); + cycle_components_.reset(); + ordered_pids_ = ordered_pids; + rebuild_index(); + + // Pre-populate nodes so mods without any edges are still tracked. + nodes_ = QSet(ordered_pids.cbegin(), ordered_pids.cend()); + + const Alternatives alternatives = + settings.use_alternative_package_ids ? build_alt_map(active_mods) : Alternatives{}; + + for (auto it = active_mods.cbegin(); it != active_mods.cend(); ++it) { + add_rules(it.key(), it.value()->about_rules, EdgeOrigin::AboutXml, alternatives); + } + + if (settings.use_community_rules && community_rules != nullptr && !community_rules->isEmpty()) { + for (auto it = active_mods.cbegin(); it != active_mods.cend(); ++it) { + const auto rule = community_rules->constFind(it.key()); + if (rule == community_rules->cend()) { + continue; + } + add_load_rules(it.key(), + rule->load_before, + rule->load_after, + rule->incompatible_with, + EdgeOrigin::CommunityRules, + alternatives); + } + } +} + +void ConstraintGraph::remove_mod(const PackageId& pid) { + cycle_components_.reset(); + const EdgesByType outgoing_edges = outgoing_.take(pid); + for (const auto& edges : outgoing_edges) { + for (const auto& edge : edges) { + incoming_[edge.target][index_of_type(edge.type)].remove(edge); + } + } + const EdgesByType incoming_edges = incoming_.take(pid); + for (const auto& edges : incoming_edges) { + for (const auto& edge : edges) { + outgoing_[edge.source][index_of_type(edge.type)].remove(edge); + } + } + nodes_.remove(pid); + ordered_pids_.removeAll(pid); + rebuild_index(); +} + +void ConstraintGraph::update_order(const QList& ordered_pids) { + ordered_pids_ = ordered_pids; + rebuild_index(); +} + +void ConstraintGraph::rebuild_index() { + pid_to_index_.clear(); + for (qsizetype i = 0; i < ordered_pids_.size(); ++i) { + pid_to_index_.insert(ordered_pids_[i], static_cast(i)); + } +} + +QList> ConstraintGraph::find_cycles() { + if (!cycle_components_) { + cycle_components_ = find_cycle_components(); + } + const auto missing = static_cast(pid_to_index_.size()); + QList> result; + result.reserve(cycle_components_->size()); + for (QList component : *cycle_components_) { + std::ranges::stable_sort(component, {}, [&](const PackageId& member) { + return pid_to_index_.value(member, missing); + }); + result.append(std::move(component)); + } + return result; +} + +QList> ConstraintGraph::find_cycle_components() const { + // Nodes without dependency or load-after edges are trivial components that can never be + // part of a cycle, so they are left out of the search. + QHash> adjacency; + for (auto it = outgoing_.cbegin(); it != outgoing_.cend(); ++it) { + QSet targets; + for (const EdgeType type : {EdgeType::Dependency, EdgeType::LoadAfter}) { + for (const auto& edge : it.value()[index_of_type(type)]) { + targets.insert(edge.target); + } + } + if (!targets.isEmpty()) { + adjacency.insert(it.key(), sorted_pids(targets)); + } + } + for (auto& neighbors : adjacency) { + neighbors.removeIf([&](const PackageId& target) { return !adjacency.contains(target); }); + } + + struct Frame { + PackageId pid; + const QList* neighbors; + qsizetype next; + }; + + QHash indices; + QHash lowlinks; + std::vector active_stack; + QSet on_stack; + QList> components; + int next_index = 0; + + for (const PackageId& root : sorted_pids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { + if (indices.contains(root)) { + continue; + } + + indices.insert(root, next_index); + lowlinks.insert(root, next_index); + ++next_index; + active_stack.push_back(root); + on_stack.insert(root); + std::vector dfs_stack{Frame{root, &adjacency[root], 0}}; + + while (!dfs_stack.empty()) { + Frame& top = dfs_stack.back(); + const PackageId pid = top.pid; + const QList& neighbors = *top.neighbors; + + if (top.next == neighbors.size()) { + dfs_stack.pop_back(); + if (lowlinks[pid] == indices[pid]) { + QList component; + while (true) { + PackageId member = active_stack.back(); + active_stack.pop_back(); + on_stack.remove(member); + const bool is_root = member == pid; + component.append(std::move(member)); + if (is_root) { + break; + } + } + if (component.size() > 1 || adjacency[pid].contains(pid)) { + components.append(std::move(component)); + } + } + if (!dfs_stack.empty()) { + const PackageId& parent = dfs_stack.back().pid; + lowlinks[parent] = std::min(lowlinks[parent], lowlinks[pid]); + } + continue; + } + + const PackageId neighbor = neighbors[top.next++]; + if (!indices.contains(neighbor)) { + indices.insert(neighbor, next_index); + lowlinks.insert(neighbor, next_index); + ++next_index; + active_stack.push_back(neighbor); + on_stack.insert(neighbor); + dfs_stack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); + } else if (on_stack.contains(neighbor) && indices[neighbor] < lowlinks[pid]) { + lowlinks[pid] = indices[neighbor]; + } + } + } + + return components; +} + +template +void ConstraintGraph::add_edges(const PackageId& source, + const Targets& targets, + EdgeType type, + EdgeOrigin origin, + const Alternatives& alternatives) { + // Hot path of build(): the per-source edge set is looked up once, not per target. + QSet* out_edges = nullptr; + for (const PackageId& raw_target : targets) { + const PackageId target = alternatives.value(raw_target, raw_target); + if (out_edges == nullptr) { + nodes_.insert(source); + out_edges = &outgoing_[source][index_of_type(type)]; + } + nodes_.insert(target); + const ConstraintEdge edge{source, target, type, origin}; + out_edges->insert(edge); + incoming_[target][index_of_type(type)].insert(edge); + } +} + +void ConstraintGraph::add_rules(const PackageId& pid, + const BaseRules& rules, + EdgeOrigin origin, + const Alternatives& alternatives) { + for (const DependencyMod& dep : rules.dependencies) { + add_edges(pid, std::array{dep.package_id}, EdgeType::Dependency, origin, alternatives); + if (!dep.alternative_package_ids.empty()) { + add_edges(pid, dep.alternative_package_ids, EdgeType::Alternative, origin, {}); + } + } + + add_load_rules(pid, + rules.load_before, + rules.load_after, + rules.incompatible_with, + origin, + alternatives); +} + +void ConstraintGraph::add_load_rules(const PackageId& pid, + const CaseInsensitiveSet& load_before, + const CaseInsensitiveSet& load_after, + const CaseInsensitiveSet& incompatible_with, + EdgeOrigin origin, + const Alternatives& alternatives) { + if (!load_after.empty()) { + add_edges(pid, load_after, EdgeType::LoadAfter, origin, alternatives); + } + if (!load_before.empty()) { + add_edges(pid, load_before, EdgeType::LoadBefore, origin, alternatives); + } + if (!incompatible_with.empty()) { + add_edges(pid, incompatible_with, EdgeType::Incompatibility, origin, alternatives); + } +} + +} // namespace px::core::checker diff --git a/src/core/checker/graph.h b/src/core/checker/graph.h new file mode 100644 index 0000000..dcd44f6 --- /dev/null +++ b/src/core/checker/graph.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "core/checker/models.h" +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" +#include "core/sort/models.h" + +namespace px::core::checker { + +enum class EdgeType : std::uint8_t { + Dependency, + LoadAfter, + LoadBefore, + Incompatibility, + Alternative, +}; + +inline constexpr std::size_t kEdgeTypeCount = 5; + +enum class EdgeOrigin : std::uint8_t { + AboutXml, + CommunityRules, +}; + +struct ConstraintEdge { + PackageId source; + PackageId target; + EdgeType type; + EdgeOrigin origin; + + bool operator==(const ConstraintEdge&) const = default; + + friend size_t qHash(const ConstraintEdge& edge, size_t seed = 0) noexcept { + return qHashMulti(seed, edge.source, edge.target, edge.type, edge.origin); + } +}; + +// Directed graph of mod dependencies, load-order rules and incompatibilities. Nodes are the +// ordered mods plus any package id a rule points at, whether or not it is active. +class ConstraintGraph { +public: + using Alternatives = QHash; + + const QSet& nodes() const noexcept { return nodes_; } + bool has_node(const PackageId& pid) const { return nodes_.contains(pid); } + + // Union over all edge types. Returned by value: Qt containers are implicitly shared. + QSet outgoing(const PackageId& pid) const; + QSet incoming(const PackageId& pid) const; + QSet neighbors(const PackageId& pid) const; + QSet edges_of_type(const PackageId& pid, EdgeType type) const; + QSet incoming_of_type(const PackageId& pid, EdgeType type) const; + + int index_of(const PackageId& pid) const { return pid_to_index_.value(pid, -1); } + const QHash& pid_to_index() const noexcept { return pid_to_index_; } + + // Replaces all edges. A null or empty `community_rules` means no community rules. + void build(const QHash& active_mods, + const QList& ordered_pids, + const sort::SortSettings& settings, + const sort::CommunityRules* community_rules = nullptr); + + void remove_mod(const PackageId& pid); + + // Replaces the mod ordering without touching edges. + void update_order(const QList& ordered_pids); + + // Strongly connected components that contain a dependency or load-after cycle. Members are + // sorted by load index. Cached until the edges change. + QList> find_cycles(); + +private: + using EdgesByType = std::array, kEdgeTypeCount>; + + template + void add_edges(const PackageId& source, + const Targets& targets, + EdgeType type, + EdgeOrigin origin, + const Alternatives& alternatives); + void add_rules(const PackageId& pid, + const BaseRules& rules, + EdgeOrigin origin, + const Alternatives& alternatives); + void add_load_rules(const PackageId& pid, + const CaseInsensitiveSet& load_before, + const CaseInsensitiveSet& load_after, + const CaseInsensitiveSet& incompatible_with, + EdgeOrigin origin, + const Alternatives& alternatives); + void rebuild_index(); + QList> find_cycle_components() const; + + QHash outgoing_; + QHash incoming_; + QSet nodes_; + QHash pid_to_index_; + QList ordered_pids_; + // Cycle membership depends only on edges, so it survives reorders. + std::optional>> cycle_components_; +}; + +} // namespace px::core::checker diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp new file mode 100644 index 0000000..eafd4d4 --- /dev/null +++ b/src/core/checker/issues.cpp @@ -0,0 +1,269 @@ +#include "core/checker/issues.h" + +#include + +#include "core/checker/graph.h" + +namespace px::core::checker { + +bool DependencyIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext&) const { + return !mod.about_rules.dependencies.isEmpty(); +} + +QList DependencyIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (!ctx.settings.check_missing_dependencies) { + return {}; + } + + QList issues; + const bool consider_alternatives = ctx.settings.use_alternative_package_ids; + + for (const DependencyMod& dep : mod.about_rules.dependencies) { + bool satisfied = ctx.active_mods.contains(dep.package_id); + if (!satisfied && consider_alternatives) { + satisfied = std::ranges::any_of(dep.alternative_package_ids, [&](const PackageId& alt) { + return ctx.active_mods.contains(alt); + }); + } + if (satisfied) { + continue; + } + + QString alt_note; + if (consider_alternatives && !dep.alternative_package_ids.empty()) { + QStringList candidates; + for (const PackageId& alt : dep.alternative_package_ids) { + candidates.append(alt.str()); + } + alt_note = QStringLiteral(" (alternatives: %1)").arg(candidates.join(QStringLiteral(", "))); + } + + issues.append(ModIssue{ + .category = QStringLiteral("missing_dependency"), + .severity = IssueSeverity::Error, + .short_message = QStringLiteral("Missing dependencies"), + .category_display_name = category_display_name(), + .detail_message = + QStringLiteral("Dependency not satisfied: %1%2").arg(dep.package_id.str(), alt_note), + .related_package_ids = {dep.package_id}, + }); + } + + return issues; +} + +bool IncompatibilityIssueChecker::should_check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.package_id; + return !mod.about_rules.incompatible_with.empty() || + !ctx.graph.edges_of_type(pid, EdgeType::Incompatibility).isEmpty() || + !ctx.graph.incoming_of_type(pid, EdgeType::Incompatibility).isEmpty(); +} + +QList IncompatibilityIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + QList issues; + const PackageId& pid = mod.package_id; + + for (const PackageId& incomp : mod.about_rules.incompatible_with) { + if (!ctx.active_mods.contains(incomp)) { + continue; + } + const AboutXmlModPtr other = ctx.active_mods.value(incomp); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::Error, + .short_message = QStringLiteral("Incompatibilities detected"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Mod declares incompatibility with: %1 (%2)") + .arg(other->name, incomp.str()), + .related_package_ids = {incomp}, + }); + } + + // Each declaring mod is reported once, even when it is seen both in our own list and as an + // incoming graph edge (about.xml and community rules can both declare the same pair). + QSet reported_sources(mod.about_rules.incompatible_with.begin(), + mod.about_rules.incompatible_with.end()); + for (const ConstraintEdge& edge : ctx.graph.incoming_of_type(pid, EdgeType::Incompatibility)) { + if (!ctx.active_mods.contains(edge.source) || edge.source == pid || + reported_sources.contains(edge.source)) { + continue; + } + reported_sources.insert(edge.source); + const AboutXmlModPtr other = ctx.active_mods.value(edge.source); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::Error, + .short_message = QStringLiteral("Incompatibilities detected"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Declared incompatible by: %1 (%2)") + .arg(other->name, edge.source.str()), + .related_package_ids = {edge.source}, + }); + } + + return issues; +} + +bool LoadOrderIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext& ctx) const { + const PackageId& pid = mod.package_id; + return !ctx.graph.edges_of_type(pid, EdgeType::LoadBefore).isEmpty() || + !ctx.graph.edges_of_type(pid, EdgeType::LoadAfter).isEmpty(); +} + +std::optional> LoadOrderIssueChecker::affected_by_moves( + const CheckContext& ctx, const QSet& moved) const { + // Results only read the indices of the mod itself and its edge targets. + QSet affected = moved; + for (const PackageId& pid : moved) { + for (const EdgeType type : {EdgeType::LoadBefore, EdgeType::LoadAfter}) { + for (const ConstraintEdge& edge : ctx.graph.incoming_of_type(pid, type)) { + affected.insert(edge.source); + } + } + } + return affected; +} + +QList LoadOrderIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + QList issues; + const PackageId& pid = mod.package_id; + const int idx = ctx.pid_to_index.value(pid, -1); + if (idx < 0) { + return issues; + } + + for (const ConstraintEdge& edge : ctx.graph.edges_of_type(pid, EdgeType::LoadBefore)) { + const int other_idx = ctx.pid_to_index.value(edge.target, -1); + if (other_idx >= 0 && idx >= other_idx) { + const AboutXmlModPtr other = ctx.active_mods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::Warning, + .short_message = QStringLiteral("Load order violated"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Should load before: %1 (%2), but appears after " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(other_idx), + .related_package_ids = {edge.target}, + }); + } + } + + for (const ConstraintEdge& edge : ctx.graph.edges_of_type(pid, EdgeType::LoadAfter)) { + const int other_idx = ctx.pid_to_index.value(edge.target, -1); + if (other_idx >= 0 && idx <= other_idx) { + const AboutXmlModPtr other = ctx.active_mods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::Warning, + .short_message = QStringLiteral("Load order violated"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Should load after: %1 (%2), but appears before " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(other_idx), + .related_package_ids = {edge.target}, + }); + } + } + + return issues; +} + +QList CycleIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { + const PackageId& pid = mod.package_id; + QList issues; + + for (const QList& cycle : ctx.cycles) { + if (!cycle.contains(pid)) { + continue; + } + QStringList members; + QList related; + for (const PackageId& member : cycle) { + members.append(member.str()); + if (member != pid) { + related.append(member); + } + } + issues.append(ModIssue{ + .category = QStringLiteral("cycle"), + .severity = IssueSeverity::Warning, + .short_message = QStringLiteral("Dependency cycle detected"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Participates in a dependency cycle involving: ") + + members.join(QStringLiteral(", ")), + .related_package_ids = std::move(related), + }); + } + + return issues; +} + +bool GameVersionIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext&) const { + return !mod.supported_versions.isEmpty(); +} + +QList GameVersionIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (mod.supported_versions.isEmpty()) { + return {}; + } + if (ctx.no_version_warning.contains(mod.package_id)) { + return {}; + } + + // Only major.minor is compared, so a patch-level target still matches "1.5" support. + const QStringList target_parts = ctx.target_version.split(QLatin1Char('.')).mid(0, 2); + const QString target_major_minor = target_parts.join(QLatin1Char('.')); + if (mod.supported_versions.contains(target_major_minor)) { + return {}; + } + + QStringList supported(mod.supported_versions.cbegin(), mod.supported_versions.cend()); + std::ranges::sort(supported); + return {ModIssue{ + .category = QStringLiteral("version_mismatch"), + .severity = IssueSeverity::Warning, + .short_message = QStringLiteral("Version mismatch"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Mod supports %1, but game is on %2") + .arg(supported.join(QStringLiteral(", ")), target_major_minor), + .related_package_ids = {}, + }}; +} + +QList ReplacementIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const std::optional pfid = mod.published_file_id(); + if (!pfid || pfid->isEmpty()) { + return {}; + } + + const auto replacement = ctx.use_this_instead.constFind(*pfid); + if (replacement == ctx.use_this_instead.cend()) { + return {}; + } + const ReplacementInfo& info = replacement.value(); + + return {ModIssue{ + .category = QStringLiteral("replacement_available"), + .severity = IssueSeverity::Warning, + .short_message = QStringLiteral("Replacement available"), + .category_display_name = category_display_name(), + .detail_message = QStringLiteral("Recommended replacement: %1 (%2) by %3") + .arg(info.name, info.packageid, info.author), + .related_package_ids = {PackageId(info.packageid)}, + }}; +} + +} // namespace px::core::checker diff --git a/src/core/checker/issues.h b/src/core/checker/issues.h new file mode 100644 index 0000000..ec4115f --- /dev/null +++ b/src/core/checker/issues.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "core/checker/models.h" + +namespace px::core::checker { + +class ModIssueChecker { +public: + virtual ~ModIssueChecker() = default; + + const QString& category_display_name() const noexcept { return category_display_name_; } + + // Order-independent results are reused when only the load order changes. + bool order_dependent() const noexcept { return order_dependent_; } + + virtual bool should_check(const AboutXmlMod&, const CheckContext&) const { return true; } + + // Mods whose results may change when `moved` changed index; nullopt means all mods. + virtual std::optional> affected_by_moves(const CheckContext&, + const QSet&) const { + return std::nullopt; + } + + virtual QList check(const AboutXmlMod& mod, const CheckContext& ctx) const = 0; + +protected: + ModIssueChecker(QString category_display_name, bool order_dependent) + : category_display_name_(std::move(category_display_name)), + order_dependent_(order_dependent) {} + +private: + QString category_display_name_; + bool order_dependent_; +}; + +// Missing or unsatisfied mod dependencies, honouring alternative package ids when enabled. +class DependencyIssueChecker final : public ModIssueChecker { +public: + DependencyIssueChecker() : ModIssueChecker(QStringLiteral("Missing Dependency"), false) {} + + bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +// Incompatibilities declared by the mod itself, and declared by other active mods against it. +class IncompatibilityIssueChecker final : public ModIssueChecker { +public: + IncompatibilityIssueChecker() : ModIssueChecker(QStringLiteral("Incompatibility"), false) {} + + bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +// Load-before and load-after constraints that the current order violates. +class LoadOrderIssueChecker final : public ModIssueChecker { +public: + LoadOrderIssueChecker() : ModIssueChecker(QStringLiteral("Load Order"), true) {} + + bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + std::optional> affected_by_moves(const CheckContext& ctx, + const QSet& moved) const override; + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +// Membership in a dependency cycle reported by the graph. +class CycleIssueChecker final : public ModIssueChecker { +public: + CycleIssueChecker() : ModIssueChecker(QStringLiteral("Cycle"), true) {} + + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +// Supported game versions that lack the target version. +class GameVersionIssueChecker final : public ModIssueChecker { +public: + GameVersionIssueChecker() : ModIssueChecker(QStringLiteral("Version Mismatch"), false) {} + + bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +// A known replacement for the mod's Workshop file id. +class ReplacementIssueChecker final : public ModIssueChecker { +public: + ReplacementIssueChecker() : ModIssueChecker(QStringLiteral("Replacement Available"), false) {} + + QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; +}; + +} // namespace px::core::checker diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp new file mode 100644 index 0000000..51fd85b --- /dev/null +++ b/src/core/checker/models.cpp @@ -0,0 +1,25 @@ +#include "core/checker/models.h" + +namespace px::core::checker { + +QString ModDiagnostics::error_tooltip() const { + if (errors.isEmpty()) { + return {}; + } + if (errors.size() == 1) { + return errors.front().short_message; + } + return QStringLiteral("%1 errors").arg(errors.size()); +} + +QString ModDiagnostics::warning_tooltip() const { + if (warnings.isEmpty()) { + return {}; + } + if (warnings.size() == 1) { + return warnings.front().short_message; + } + return QStringLiteral("%1 warnings").arg(warnings.size()); +} + +} // namespace px::core::checker diff --git a/src/core/checker/models.h b/src/core/checker/models.h new file mode 100644 index 0000000..b32ac57 --- /dev/null +++ b/src/core/checker/models.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +namespace px::core::checker { + +using PackageId = CaseInsensitiveStr; +using AboutXmlModPtr = std::shared_ptr; + +class ConstraintGraph; + +enum class IssueSeverity : std::uint8_t { Error, Warning }; + +struct ModIssue { + QString category; + IssueSeverity severity; + QString short_message; + QString category_display_name; + QString detail_message; + QList related_package_ids; + + bool operator==(const ModIssue&) const = default; +}; + +struct ModDiagnostics { + QList errors; + QList warnings; + + bool has_errors() const noexcept { return !errors.isEmpty(); } + bool has_warnings() const noexcept { return !warnings.isEmpty(); } + // The single issue's message, or a count once there is more than one. + QString error_tooltip() const; + QString warning_tooltip() const; + + bool operator==(const ModDiagnostics&) const = default; +}; + +// Everything a checker may read while checking one mod. Callers own the referenced data and +// keep it alive for the duration of the check. +struct CheckContext { + const QHash& active_mods; + const QList& ordered_pids; + const QHash& pid_to_index; + const ConstraintGraph& graph; + const sort::SortSettings& settings; + QString target_version = QStringLiteral("1.5"); + const QSet& no_version_warning; + const QHash& use_this_instead; + const QList>& cycles; +}; + +} // namespace px::core::checker diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp new file mode 100644 index 0000000..0b3535c --- /dev/null +++ b/src/core/checker/sort.cpp @@ -0,0 +1,248 @@ +#include "core/checker/sort.h" + +#include +#include +#include +#include +#include + +#include "core/sort/tiers.h" + +namespace px::core::checker { +namespace { + +using sort::PackageGraph; +using sort::Tier; + +// Dependency edges point from a mod to the mods that must load before it; load-before edges are +// reversed so both shapes end up in the same map. +std::pair build_deps_and_rev_deps(const ConstraintGraph& graph, + const QSet& all_pids) { + PackageGraph deps; + PackageGraph rev_deps; + for (const PackageId& pid : all_pids) { + deps.insert(pid, {}); + rev_deps.insert(pid, {}); + } + + for (const PackageId& pid : graph.nodes()) { + if (!all_pids.contains(pid)) { + continue; + } + for (const ConstraintEdge& edge : graph.outgoing(pid)) { + if (!all_pids.contains(edge.target)) { + continue; + } + switch (edge.type) { + case EdgeType::LoadBefore: + deps[edge.target].insert(pid); + rev_deps[pid].insert(edge.target); + break; + case EdgeType::LoadAfter: + case EdgeType::Dependency: + deps[pid].insert(edge.target); + rev_deps[edge.target].insert(pid); + break; + case EdgeType::Incompatibility: + case EdgeType::Alternative: + break; + } + } + } + + return {std::move(deps), std::move(rev_deps)}; +} + +// Mods that community rules flag through `flag`, or none when community rules are disabled. +QSet community_flagged(const sort::CommunityRules* rules, + const QSet& all_pids, + const sort::SortSettings& settings, + bool sort::CommunityRule::*flag) { + QSet flagged; + if (!settings.use_community_rules || rules == nullptr || rules->isEmpty()) { + return flagged; + } + for (const PackageId& pid : all_pids) { + const auto rule = rules->constFind(pid); + if (rule != rules->cend() && rule.value().*flag) { + flagged.insert(pid); + } + } + return flagged; +} + +// Position of each package id in the tier config, so the user's explicit picks sort first. +QHash build_config_priority(const sort::TierConfig& tier_config) { + QHash priority; + for (std::size_t i = 0; i < tier_config.tier_0.size(); ++i) { + priority.insert(tier_config.tier_0[i], static_cast(i)); + } + // Tier 1 and tier 3 only fill in ids that tier 0 did not already place, and they are + // numbered after the earlier lists. + int base = static_cast(tier_config.tier_0.size()); + for (const auto* list : {&tier_config.tier_1, &tier_config.tier_3}) { + for (std::size_t i = 0; i < list->size(); ++i) { + const PackageId& pid = (*list)[i]; + if (!priority.contains(pid)) { + priority.insert(pid, base + static_cast(i)); + } + } + base += static_cast(list->size()); + } + return priority; +} + +struct SortKey { + int priority; + QString name; + PackageId pid; +}; + +SortKey make_key(const PackageId& pid, const QHash& config_priority) { + return SortKey{config_priority.value(pid, static_cast(config_priority.size())), + pid.str(), + pid}; +} + +bool key_less(const SortKey& a, const SortKey& b) { + if (a.priority != b.priority) { + return a.priority < b.priority; + } + return a.name < b.name; +} + +// Heap order that pops the smallest key first. +bool key_greater(const SortKey& a, const SortKey& b) { + return key_less(b, a); +} + +} // namespace + +QList topological_sort(const QHash>& active_mods, + const ConstraintGraph& graph, + const sort::SortSettings& settings, + const sort::CommunityRules* community_rules, + Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + const QSet all_pids(active_mods.keyBegin(), active_mods.keyEnd()); + if (all_pids.isEmpty()) { + return {}; + } + + PackageGraph deps; + PackageGraph rev_deps; + { + auto scope = t("build_deps"); + std::tie(deps, rev_deps) = build_deps_and_rev_deps(graph, all_pids); + } + QSet load_first; + { + auto scope = t("build_load_first"); + load_first = community_flagged(community_rules, + all_pids, + settings, + &sort::CommunityRule::load_first); + } + QSet load_last; + { + auto scope = t("build_load_last"); + load_last = community_flagged(community_rules, + all_pids, + settings, + &sort::CommunityRule::load_last); + } + + QHash tiers; + { + auto scope = t("assign_tiers"); + tiers = sort::assign_tiers(all_pids, + deps, + rev_deps, + load_first, + load_last, + settings.tier_config); + } + + constexpr std::array, 4> tier_passes{{ + {Tier::Tier0, "kahn_tier_0"}, + {Tier::Tier1, "kahn_tier_1"}, + {Tier::Tier2, "kahn_tier_2"}, + {Tier::Tier3, "kahn_tier_3"}, + }}; + const QHash config_priority = build_config_priority(settings.tier_config); + + QList order; + QSet ordered; + for (const auto& [tier, label] : tier_passes) { + QSet tier_pids; + for (const PackageId& pid : all_pids) { + if (ordered.contains(pid)) { + continue; + } + const auto assigned = tiers.constFind(pid); + if (assigned != tiers.cend() && assigned.value() == tier) { + tier_pids.insert(pid); + } + } + if (tier_pids.isEmpty()) { + continue; + } + + auto scope = t(label); + + // Dependencies are restricted to this tier: earlier tiers are already placed, and + // later tiers must not hold up this one. + QHash indegree; + QHash> rev; + for (const PackageId& pid : tier_pids) { + QSet sub_deps = deps.value(pid); + sub_deps.intersect(tier_pids); + indegree.insert(pid, static_cast(sub_deps.size())); + for (const PackageId& dep : sub_deps) { + rev[dep].insert(pid); + } + } + + std::vector queue; + for (const PackageId& pid : tier_pids) { + if (indegree.value(pid) == 0) { + queue.push_back(make_key(pid, config_priority)); + } + } + std::ranges::make_heap(queue, key_greater); + + while (!queue.empty()) { + std::ranges::pop_heap(queue, key_greater); + const PackageId pid = queue.back().pid; + queue.pop_back(); + order.append(pid); + ordered.insert(pid); + + for (const PackageId& dependent : rev.value(pid)) { + if (--indegree[dependent] == 0) { + queue.push_back(make_key(dependent, config_priority)); + std::ranges::push_heap(queue, key_greater); + } + } + } + + // Whatever is left sits on a dependency cycle; keep it deterministic at the tier's end. + std::vector remaining; + for (const PackageId& pid : tier_pids) { + if (!ordered.contains(pid)) { + remaining.push_back(make_key(pid, config_priority)); + } + } + std::ranges::sort(remaining, key_less); + for (const SortKey& key : remaining) { + order.append(key.pid); + ordered.insert(key.pid); + } + } + + return order; +} + +} // namespace px::core::checker diff --git a/src/core/checker/sort.h b/src/core/checker/sort.h new file mode 100644 index 0000000..b5521fa --- /dev/null +++ b/src/core/checker/sort.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include +#include + +#include "core/checker/graph.h" +#include "core/checker/models.h" +#include "core/profiler.h" +#include "core/sort/config.h" +#include "core/sort/models.h" + +namespace px::core::checker { + +// Orders active mods by tier, then by dependencies and load-order constraints, breaking ties by +// the tier config priority and then by package id. Mods caught in a cycle are appended at the +// end of their tier in the same key order. +QList topological_sort(const QHash>& active_mods, + const ConstraintGraph& graph, + const sort::SortSettings& settings, + const sort::CommunityRules* community_rules = nullptr, + Profiler* timer = nullptr); + +} // namespace px::core::checker diff --git a/src/core/config.cpp b/src/core/config.cpp new file mode 100644 index 0000000..4140557 --- /dev/null +++ b/src/core/config.cpp @@ -0,0 +1,454 @@ +#include "core/config.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/constants.h" + +namespace px::core { + +namespace { + +const QString kConfigFile = QStringLiteral("config.json"); +const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); + +// Schema versions above this are far beyond any real file; clamping keeps them +// representable while still being rejected as unsupported. +constexpr double kMaxSchemaVersion = 1e15; + +QString steam_app_id() { return QString(RIMWORLD_STEAM_APP_ID); } + +QString join_path(QString path, std::initializer_list parts) { + for (const auto& part : parts) { + path.append(u'/').append(part); + } + return QDir::cleanPath(path); +} + +bool is_dir(const QString& path) { return QFileInfo(path).isDir(); } + +QString find_rimworld_in_steam_root(const QString& root, const QString& steam_id); + +// Parses a libraryfolders.vdf for an alternate library holding RimWorld. +QString vdf_find_rimworld(const QString& vdf_path, const QString& steam_id) { + QFile file(vdf_path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::warn("Failed to parse libraryfolders.vdf"); + return {}; + } + const QString text = QString::fromUtf8(file.readAll()); + + // Matches `"path" ""` and `"" {` lines of libraryfolders.vdf. + static const QRegularExpression path_pattern(QStringLiteral(R"re("path"\s+"(.+?)")re")); + const QRegularExpression app_pattern(QStringLiteral("\"") + QRegularExpression::escape(steam_id) + + QStringLiteral(R"re("\s*\{)re")); + + QString current_path; + for (const auto& raw_line : text.split(u'\n')) { + const QString line = raw_line.trimmed(); + if (const auto match = path_pattern.match(line); match.hasMatch()) { + current_path = match.captured(1); + continue; + } + if (!current_path.isEmpty() && app_pattern.match(line).hasMatch()) { + const QString game = join_path(current_path, {QStringLiteral("steamapps"), + QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (is_dir(game)) { + return game; + } + } + } + return {}; +} + +QString find_rimworld_in_steam_root(const QString& root, const QString& steam_id) { + const QString game = join_path(root, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (is_dir(game)) { + return game; + } + const QString config_vdf = join_path(root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(config_vdf)) { + return vdf_find_rimworld(config_vdf, steam_id); + } + const QString steamapps_vdf = + join_path(root, {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(steamapps_vdf)) { + return vdf_find_rimworld(steamapps_vdf, steam_id); + } + return {}; +} + +// Workshop content lives beside the `common` folder that holds the game. +QString workshop_path_from_game(const QString& game, const QString& steam_root, const QString& steam_id) { + const QString normalized = QDir::fromNativeSeparators(game); + const QStringList parts = normalized.split(u'/', Qt::SkipEmptyParts); + const qsizetype common = parts.indexOf(QStringLiteral("common")); + if (common >= 0) { + QString base = parts.mid(0, common).join(u'/'); + if (normalized.startsWith(u'/')) { + base.prepend(u'/'); + } + const QString workshop = + join_path(base, {QStringLiteral("workshop"), QStringLiteral("content"), steam_id}); + if (is_dir(workshop)) { + return workshop; + } + } + return join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steam_id}); +} + +struct GamePaths { + QString game; + QString local; + QString workshop; +}; + +GamePaths game_paths_for(const QString& game, const QString& workshop) { + return GamePaths{ + .game = game, + .local = join_path(game, {QStringLiteral("Mods")}), + .workshop = workshop, + }; +} + +#if defined(Q_OS_LINUX) +GamePaths find_game_paths(QString& found_root) { + const QString steam_id = steam_app_id(); + const QString home = QDir::homePath(); + const QStringList candidates = { + join_path(home, {QStringLiteral(".steam"), QStringLiteral("debian-installation")}), + join_path(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), + join_path(home, {QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), + join_path(home, {QStringLiteral(".var"), QStringLiteral("app"), QStringLiteral("com.valvesoftware.Steam"), + QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), + join_path(home, {QStringLiteral("snap"), QStringLiteral("steam"), QStringLiteral("common"), + QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), + }; + for (const auto& root : candidates) { + if (!is_dir(root)) { + continue; + } + if (const QString game = find_rimworld_in_steam_root(root, steam_id); !game.isEmpty()) { + found_root = root; + return game_paths_for(game, workshop_path_from_game(game, root, steam_id)); + } + } + + const QString fallback_root = join_path(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); + const QString fallback = + join_path(fallback_root, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); + if (is_dir(fallback)) { + found_root = is_dir(fallback_root) ? fallback_root : QString(); + return game_paths_for(fallback, join_path(fallback_root, {QStringLiteral("steamapps"), + QStringLiteral("workshop"), + QStringLiteral("content"), steam_id})); + } + return {}; +} +#elif defined(Q_OS_MACOS) +QString find_mac_app(const QString& rimworld_dir) { + if (!is_dir(rimworld_dir)) { + return {}; + } + const QStringList apps = QDir(rimworld_dir) + .entryList({QStringLiteral("*.app")}, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + return apps.isEmpty() ? QString() : join_path(rimworld_dir, {apps.first()}); +} + +GamePaths find_game_paths(QString& found_root) { + const QString steam_id = steam_app_id(); + const QString steam_root = + join_path(QDir::homePath(), {QStringLiteral("Library"), QStringLiteral("Application Support"), + QStringLiteral("Steam")}); + found_root = is_dir(steam_root) ? steam_root : QString(); + if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); !game.isEmpty()) { + return game_paths_for(game, workshop_path_from_game(game, steam_root, steam_id)); + } + const QString fallback = + join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); + if (const QString app_bundle = find_mac_app(fallback); !app_bundle.isEmpty()) { + return GamePaths{ + .game = app_bundle, + .local = join_path(fallback, {QStringLiteral("Mods")}), + .workshop = join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steam_id}), + }; + } + return {}; +} +#elif defined(Q_OS_WIN) +// Steam's install location is recorded in the registry; the 32-bit view is +// checked first, as the legacy detection did. +GamePaths find_game_paths(QString& found_root) { + const QString steam_id = steam_app_id(); + const QStringList registry_keys = { + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam)"), + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam)"), + }; + for (const auto& key : registry_keys) { + const QString steam_root = QSettings(key, QSettings::NativeFormat).value(QStringLiteral("InstallPath")).toString(); + if (steam_root.isEmpty()) { + spdlog::debug("Unable to inspect Windows Steam registry key {}", key.toStdString()); + continue; + } + if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); !game.isEmpty()) { + return game_paths_for(game, workshop_path_from_game(game, steam_root, steam_id)); + } + } + + const QString fallback = QStringLiteral("C:/Program Files (x86)/Steam"); + const QString game = + join_path(fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); + if (is_dir(game)) { + return game_paths_for(game, join_path(fallback, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steam_id})); + } + return {}; +} +#else +GamePaths find_game_paths(QString&) { return {}; } +#endif + +// Locates the RimWorld Config folder, which the game writes beside its save data. +QString detect_config_folder([[maybe_unused]] const QString& steam_root) { +#if defined(Q_OS_LINUX) + const QString steam_id = steam_app_id(); + if (!steam_root.isEmpty()) { + const QString proton = + join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), steam_id, + QStringLiteral("pfx"), QStringLiteral("drive_c"), QStringLiteral("users"), + QStringLiteral("steamuser"), QStringLiteral("AppData"), + QStringLiteral("LocalLow"), QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); + if (is_dir(proton)) { + spdlog::debug("Detected Proton config folder: {}", proton.toStdString()); + return proton; + } + } + const QString native = join_path(QDir::homePath(), {QStringLiteral(".config"), QStringLiteral("unity3d"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), + QStringLiteral("Config")}); + if (is_dir(native)) { + spdlog::debug("Detected native config folder: {}", native.toStdString()); + return native; + } +#elif defined(Q_OS_MACOS) + const QString config = join_path(QDir::homePath(), {QStringLiteral("Library"), + QStringLiteral("Application Support"), + QStringLiteral("Rimworld"), QStringLiteral("Config")}); + if (is_dir(config)) { + spdlog::debug("Detected macOS config folder: {}", config.toStdString()); + return config; + } +#elif defined(Q_OS_WIN) + const QString config = join_path(QDir::homePath(), {QStringLiteral("AppData"), QStringLiteral("LocalLow"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), + QStringLiteral("Config")}); + if (is_dir(config)) { + spdlog::debug("Detected Windows config folder: {}", config.toStdString()); + return config; + } +#endif + return {}; +} + +} // namespace + +ConfigService::ConfigService(QString config_dir, JsonMigrations migrations) + : dir_(std::move(config_dir)), migrations_(std::move(migrations)) {} + +const QString& ConfigService::directory() const noexcept { return dir_; } + +QString ConfigService::file_path(const QString& filename) const { + return QDir(dir_).filePath(filename); +} + +bool ConfigService::is_versioned(const QString& filename) { + return filename == kConfigFile || filename == kUiPrefsFile; +} + +std::string ConfigService::with_schema_marker(std::string pretty_object) { + // Pretty output ends with the object's closing brace; the marker goes after the + // last non-space character before it, which is the last member's value or the opening brace. + const auto close = pretty_object.rfind('}'); + if (close == std::string::npos) { + return pretty_object; + } + auto last = pretty_object.find_last_not_of(" \t\r\n", close - 1); + if (last == std::string::npos) { + return pretty_object; + } + const bool empty_object = pretty_object[last] == '{'; + const std::string marker = std::string("\"") + std::string(kSchemaVersionKey) + "\": " + + std::to_string(kCurrentConfigSchemaVersion); + pretty_object.insert(last + 1, empty_object ? "\n " + marker + "\n" : ",\n " + marker); + return pretty_object; +} + +JsonObject ConfigService::load_raw(const QString& filename) const { + auto bytes = read_file_bytes(file_path(filename)); + if (!bytes) { + return {}; + } + glz::json_t document; + if (glz::read_json(document, *bytes) || !document.is_object()) { + return {}; + } + return document.get_object(); +} + +Result> ConfigService::load_versioned(const QString& filename) const { + const QString path = file_path(filename); + auto bytes = read_file_bytes(path); + if (!bytes) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), bytes.error().message.toStdString()); + return std::optional{}; + } + + glz::json_t document; + if (auto error = glz::read_json(document, *bytes)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), glz::format_error(error, *bytes)); + return std::optional{}; + } + if (!document.is_object()) { + spdlog::warn("Failed to load {}: top-level value is not an object", filename.toStdString()); + return std::optional{}; + } + JsonObject raw = document.get_object(); + + // A missing marker or an explicit null means schema version 0. + std::int64_t version = 0; + if (auto marker = raw.find(std::string(kSchemaVersionKey)); marker != raw.end()) { + const auto& value = marker->second; + if (!value.is_null()) { + const bool valid = value.is_number() && value.get() >= 0 && + std::trunc(value.get()) == value.get(); + if (!valid) { + spdlog::warn("Failed to load {}: Invalid JSON schema version", filename.toStdString()); + return std::optional{}; + } + version = static_cast(std::min(value.get(), kMaxSchemaVersion)); + } + raw.erase(marker); + } + + if (auto migrated = migrate(raw, version); !migrated) { + return std::unexpected(migrated.error()); + } + if (version == kCurrentConfigSchemaVersion) { + return std::optional(std::move(raw)); + } + + // Keep the pre-migration file as a backup before the rewritten one replaces it. + const QString backup = path + QStringLiteral(".bak.") + QString::number(QDateTime::currentSecsSinceEpoch()); + if (auto copied = copy_file_replacing(path, backup); !copied) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), copied.error().message.toStdString()); + return std::optional{}; + } + raw[std::string(kSchemaVersionKey)] = static_cast(kCurrentConfigSchemaVersion); + auto text = glz::write(raw); + if (!text) { + spdlog::warn("Failed to load {}: cannot encode migrated document", filename.toStdString()); + return std::optional{}; + } + if (auto written = write_file_atomically(path, *text); !written) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), written.error().message.toStdString()); + return std::optional{}; + } + return std::optional(std::move(raw)); +} + +Result ConfigService::migrate(JsonObject& data, std::int64_t current) const { + if (current > kCurrentConfigSchemaVersion) { + return std::unexpected(Error{QStringLiteral("Unsupported JSON schema version %1; latest is %2") + .arg(current) + .arg(kCurrentConfigSchemaVersion)}); + } + for (std::int64_t target = current + 1; target <= kCurrentConfigSchemaVersion; ++target) { + const auto migration = migrations_.find(static_cast(target)); + if (migration == migrations_.end()) { + return std::unexpected( + Error{QStringLiteral("Missing JSON migration step for target version %1").arg(target)}); + } + migration->second(data); + } + return {}; +} + +QString config_dir() { + const QString base = qEnvironmentVariable("XDG_CONFIG_HOME", QDir::homePath() + QStringLiteral("/.config")); + return QDir(base).filePath(QStringLiteral("pxmodrim")); +} + +QString community_rules_file() { + return QDir(config_dir()).filePath(QStringLiteral("communityRules.json")); +} + +std::optional read_game_version(const QString& game_path) { + const QString path = QDir(game_path).filePath(QStringLiteral("Version.txt")); + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + auto bytes = read_file_bytes(path); + if (!bytes) { + spdlog::warn("Failed to read game version from {}", path.toStdString()); + return std::nullopt; + } + return QString::fromUtf8(bytes->data(), static_cast(bytes->size())).trimmed(); +} + +PathConfig detect_game_paths() { + QString found_root; + const GamePaths found = find_game_paths(found_root); + + PathConfig result; + result.game = found.game.toStdString(); + result.local = found.local.toStdString(); + result.workshop = found.workshop.toStdString(); + result.config_folder = detect_config_folder(found_root).toStdString(); + result.community_rules_file = community_rules_file().toStdString(); + return result; +} + +AppConfigService::AppConfigService(ConfigService& svc) : svc_(svc) {} + +QCoro::Task> AppConfigService::setup() { + auto loaded = svc_.load(kConfigFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + cfg_ = std::move(*loaded); + co_return {}; +} + +const AppConfig& AppConfigService::cfg() const { + Q_ASSERT(cfg_.has_value()); + return *cfg_; +} + +Result AppConfigService::save_cfg() const { + if (!cfg_) { + return {}; + } + return svc_.save(kConfigFile, *cfg_); +} + +} // namespace px::core diff --git a/src/core/config.h b/src/core/config.h new file mode 100644 index 0000000..f26b9b6 --- /dev/null +++ b/src/core/config.h @@ -0,0 +1,192 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/constants.h" +#include "core/error.h" +#include "core/file_io.h" +#include "core/sort/config.h" + +namespace px::core { + +inline constexpr int kCurrentConfigSchemaVersion = 1; +inline constexpr std::string_view kSchemaVersionKey = "schema_version"; + +// Unknown keys are skipped so files written by newer or older builds still load. +inline constexpr glz::opts kJsonReadOpts{.error_on_unknown_keys = false}; +inline constexpr glz::opts kJsonPrettyOpts{.prettify = true, .indentation_width = 2}; + +using JsonObject = glz::json_t::object_t; + +// Rewrites a raw JSON object from schema version N-1 to N. +using JsonMigration = std::function; +using JsonMigrations = std::map; + +// Version 1 is the first versioned layout, so there is nothing to rewrite yet. +inline JsonMigrations default_json_migrations() { + return {{1, [](JsonObject&) {}}}; +} + +// glaze does not serialise QString, so serialised fields use std::string. +struct PathConfig { + std::string game; + std::string local; + std::string workshop; + std::string config_folder; + std::string community_rules_file; + std::string no_version_warning_file; + std::string use_this_instead_file; + + bool operator==(const PathConfig&) const = default; +}; + +struct AppConfig { + PathConfig paths; + sort::SortSettings sort; + int max_snapshots = 10; + bool compact_mod_list = false; + std::string log_upload_endpoint = "https://paste.rs/"; + std::string launch_args; + std::string launch_wrapper; + AfterLaunch after_launch = AfterLaunch::Stay; + bool launch_confirm_errors = true; + bool launch_confirm_unsaved = true; + bool launch_confirm_running = true; + + bool operator==(const AppConfig&) const = default; +}; + +// File-based repository for typed JSON config files in one directory. +// Versioned files (config.json, ui_prefs.json) carry a schema_version key and +// are migrated and rewritten on load when older. A missing, unreadable, or +// corrupt file loads as the struct defaults, with a warning. +class ConfigService { +public: + explicit ConfigService(QString config_dir, JsonMigrations migrations = default_json_migrations()); + + const QString& directory() const noexcept; + + // Fails only when a versioned file cannot be migrated. + template + Result load(const QString& filename) const; + + // Like `load`, but nullopt when the file does not exist yet. + template + Result> load_existing(const QString& filename) const; + + template + Result save(const QString& filename, const T& data) const; + + // The undecoded top-level object of a file, or {} when it cannot be read. + JsonObject load_raw(const QString& filename) const; + +private: + QString file_path(const QString& filename) const; + static bool is_versioned(const QString& filename); + // Appends the schema marker after the struct's own members, where the legacy writer put it. + static std::string with_schema_marker(std::string pretty_object); + Result> load_versioned(const QString& filename) const; + Result migrate(JsonObject& data, std::int64_t current) const; + + QString dir_; + JsonMigrations migrations_; +}; + +template +Result ConfigService::load(const QString& filename) const { + const QString path = file_path(filename); + if (!QFileInfo::exists(path)) { + return T{}; + } + + std::string text; + if (is_versioned(filename)) { + auto document = load_versioned(filename); + if (!document) { + return std::unexpected(document.error()); + } + if (!document->has_value()) { + return T{}; + } + auto encoded = glz::write_json(**document); + if (!encoded) { + return T{}; + } + text = std::move(*encoded); + } else { + auto bytes = read_file_bytes(path); + if (!bytes) { + return T{}; + } + text = std::move(*bytes); + } + + T value{}; + if (auto error = glz::read(value, text)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), glz::format_error(error, text)); + return T{}; + } + return value; +} + +template +Result> ConfigService::load_existing(const QString& filename) const { + if (!QFileInfo::exists(file_path(filename))) { + return std::optional{}; + } + auto loaded = load(filename); + if (!loaded) { + return std::unexpected(loaded.error()); + } + return std::optional(std::move(*loaded)); +} + +template +Result ConfigService::save(const QString& filename, const T& data) const { + auto text = glz::write(data); + if (!text) { + return std::unexpected(Error{QStringLiteral("Cannot encode %1").arg(filename)}); + } + if (is_versioned(filename)) { + *text = with_schema_marker(std::move(*text)); + } + return write_file_atomically(file_path(filename), *text); +} + +QString config_dir(); +QString community_rules_file(); + +// The game version string from Version.txt, or nullopt when it is missing or unreadable. +std::optional read_game_version(const QString& game_path); + +PathConfig detect_game_paths(); + +// Owns the AppConfig lifecycle: load on setup, save on request. +class AppConfigService { +public: + explicit AppConfigService(ConfigService& svc); + + QCoro::Task> setup(); + + // Valid only after a successful setup(). + const AppConfig& cfg() const; + + Result save_cfg() const; + +private: + ConfigService& svc_; + std::optional cfg_; +}; + +} // namespace px::core diff --git a/src/core/constants.h b/src/core/constants.h new file mode 100644 index 0000000..00c8047 --- /dev/null +++ b/src/core/constants.h @@ -0,0 +1,82 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace px::core { + +inline constexpr QLatin1StringView RIMWORLD_STEAM_APP_ID{"294100"}; + +enum class LaunchStrategy : int { + Direct = 0, + Steam = 1, +}; + +enum class AfterLaunch : int { + Stay = 0, + Minimize = 1, + Close = 2, +}; + +inline constexpr QLatin1StringView DEFAULT_MISSING_PACKAGEID{"missing.packageid"}; + +struct RimWorldDlc { + QLatin1StringView appId; + QLatin1StringView packageId; + QLatin1StringView name; + QLatin1StringView steamUrl; + QLatin1StringView description; +}; + +// Ordered by Steam app id, matching the legacy dict's iteration order. +inline constexpr std::array RIMWORLD_DLC_METADATA{{ + {QLatin1StringView{"294100"}, QLatin1StringView{"ludeon.rimworld"}, + QLatin1StringView{"RimWorld"}, + QLatin1StringView{"https://store.steampowered.com/app/294100/RimWorld"}, + QLatin1StringView{"Base game"}}, + {QLatin1StringView{"1149640"}, QLatin1StringView{"ludeon.rimworld.royalty"}, + QLatin1StringView{"RimWorld - Royalty"}, + QLatin1StringView{"https://store.steampowered.com/app/1149640/RimWorld__Royalty"}, + QLatin1StringView{"DLC #1"}}, + {QLatin1StringView{"1392840"}, QLatin1StringView{"ludeon.rimworld.ideology"}, + QLatin1StringView{"RimWorld - Ideology"}, + QLatin1StringView{"https://store.steampowered.com/app/1392840/RimWorld__Ideology"}, + QLatin1StringView{"DLC #2"}}, + {QLatin1StringView{"1826140"}, QLatin1StringView{"ludeon.rimworld.biotech"}, + QLatin1StringView{"RimWorld - Biotech"}, + QLatin1StringView{"https://store.steampowered.com/app/1826140/RimWorld__Biotech"}, + QLatin1StringView{"DLC #3"}}, + {QLatin1StringView{"2380740"}, QLatin1StringView{"ludeon.rimworld.anomaly"}, + QLatin1StringView{"RimWorld - Anomaly"}, + QLatin1StringView{"https://store.steampowered.com/app/2380740/RimWorld__Anomaly/"}, + QLatin1StringView{"DLC #4"}}, + {QLatin1StringView{"3022790"}, QLatin1StringView{"ludeon.rimworld.odyssey"}, + QLatin1StringView{"RimWorld - Odyssey"}, + QLatin1StringView{"https://store.steampowered.com/app/3022790/RimWorld__Odyssey/"}, + QLatin1StringView{"DLC #5"}}, +}}; + +// Ordered package ids of RIMWORLD_DLC_METADATA. +inline const QStringList RIMWORLD_PACKAGE_IDS = [] { + QStringList ids; + for (const RimWorldDlc& dlc : RIMWORLD_DLC_METADATA) { + ids.append(QString(dlc.packageId)); + } + return ids; +}(); + +// Exact (case-sensitive) lookup; callers pass lowercase ids as produced by CaseInsensitiveStr. +inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { + for (const RimWorldDlc& dlc : RIMWORLD_DLC_METADATA) { + if (packageId == QLatin1StringView(dlc.packageId)) { + return &dlc; + } + } + return nullptr; +} + +} // namespace px::core diff --git a/src/core/context.cpp b/src/core/context.cpp new file mode 100644 index 0000000..4b0f343 --- /dev/null +++ b/src/core/context.cpp @@ -0,0 +1,179 @@ +#include "core/context.h" + +#include +#include + +#include +#include + +#include "core/downloads/downloader.h" +#include "core/downloads/manager.h" +#include "core/mod_service.h" +#include "core/profiler.h" +#include "core/providers/factory.h" +#include "core/services/activation_service.h" +#include "core/services/diagnostics_service.h" +#include "core/services/game_launcher.h" +#include "core/services/sort_service.h" + +namespace px::core { + +namespace { + +// Minimum valid version string shape: "X.Y" — reject anything shorter. +constexpr qsizetype kVersionMinParts = 2; + +} // namespace + +CoreContext::CoreContext(AppConfig cfg, ConfigService* config_service) + : cfg_(std::move(cfg)), config_service_(config_service) { + refresh_game_version(); + plugins_.register_plugin(std::make_unique()); +} + +CoreContext::~CoreContext() = default; + +std::unique_ptr CoreContext::create(AppConfig cfg, ConfigService* config_service) { + std::unique_ptr ctx(new CoreContext(std::move(cfg), config_service)); + ProfileSession session(QStringLiteral("services.create")); + Profiler& timer = session.timer(); + { + auto scope = timer(QStringLiteral("create_providers")); + ctx->mod_service_ = std::make_unique( + *ctx, providers::create_providers(ctx->cfg_.paths, &ctx->pool_)); + } + { + auto scope = timer(QStringLiteral("diagnostics_service")); + ctx->diagnostics_service_ = std::make_unique(*ctx, ctx->network_); + } + { + auto scope = timer(QStringLiteral("sort_service")); + ctx->sort_service_ = std::make_unique(*ctx, *ctx->diagnostics_service_); + } + { + auto scope = timer(QStringLiteral("game_launcher")); + ctx->game_launcher_ = std::make_unique(*ctx); + } + { + auto scope = timer(QStringLiteral("activation")); + ctx->activation_ = std::make_unique(*ctx); + } + return ctx; +} + +void CoreContext::load(QHash mods, QStringList active_uuids) { + mods_ = std::move(mods); + loaded_active_uuids_ = active_uuids; + active_uuids_ = std::move(active_uuids); +} + +void CoreContext::setActive(QStringList uuids) { + const QSet old_set(active_uuids_.cbegin(), active_uuids_.cend()); + const QSet new_set(uuids.cbegin(), uuids.cend()); + if (old_set == new_set && active_uuids_ == uuids) { + return; + } + const bool set_changed = old_set != new_set; + active_uuids_ = std::move(uuids); + if (set_changed) { + diagnostics_service_->rebuild(active_uuids_); + } else { + diagnostics_service_->reorder(active_uuids_); + } + active_state_changed_.notify(active_uuids_); +} + +QCoro::Task> CoreContext::autoSort() { + const QSet active(active_uuids_.cbegin(), active_uuids_.cend()); + const QStringList deps = sort_service_->resolve_missing_dependencies(active); + if (!deps.isEmpty()) { + active_uuids_.append(deps); + diagnostics_service_->rebuild(active_uuids_); + active_state_changed_.notify(active_uuids_); + spdlog::info("auto-sort: enabling {} missing dependencies", deps.size()); + } + + const auto started = std::chrono::steady_clock::now(); + active_uuids_ = co_await sort_service_->sort_active_mods(); + const double elapsed_ms = + std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + diagnostics_service_->reorder(active_uuids_); + active_state_changed_.notify(active_uuids_); + const int count = static_cast(active_uuids_.size()); + spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsed_ms); + co_return std::pair{count, elapsed_ms}; +} + +void CoreContext::updateConfig(AppConfig cfg) { + cfg_ = std::move(cfg); + refresh_game_version(); + config_changed_.notify(cfg_); +} + +void CoreContext::resetProviders(const PathConfig& paths) { + mod_service_->reset_providers(providers::create_providers(paths, &pool_)); +} + +void CoreContext::refresh_game_version() { + if (cfg_.paths.game.empty()) { + game_version_ = QStringLiteral("Unknown"); + return; + } + const auto version = read_game_version(QString::fromStdString(cfg_.paths.game)); + game_version_ = version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); +} + +QString CoreContext::targetVersion() const { + const QStringList parts = game_version_.split(QLatin1Char('.')); + if (parts.size() >= kVersionMinParts) { + return QStringLiteral("%1.%2").arg(parts[0], parts[1]); + } + return QStringLiteral("Unknown"); +} + +CollectionStats CoreContext::computeStats(const std::optional& active_ids) const { + const QStringList& ids = active_ids ? *active_ids : active_uuids_; + CollectionStats stats; + stats.total = mods_.size(); + stats.active = ids.size(); + stats.inactive = stats.total - stats.active; + for (const auto& mod : mods_) { + if (!mod->valid) { + ++stats.errors; + } + } + return stats; +} + +ConfigService& CoreContext::configService() const { + Q_ASSERT(config_service_ != nullptr); + return *config_service_; +} + +DownloadManager& CoreContext::downloads() const { + auto* manager = dynamic_cast(plugins_.get(kDownloadsManagerName)); + Q_ASSERT(manager != nullptr); + return *manager; +} + +QCoro::Task> CoreContext::initialize() { + co_return co_await mod_service_->initialize(); +} + +QCoro::Task CoreContext::close() { + pool_.waitForDone(); + co_await mod_service_->startup_impact().close_connection(); + co_await mod_service_->close(); +} + +void CoreContext::closeSync() { + pool_.waitForDone(); + mod_service_->startup_impact().close_connection_sync(); + mod_service_->close_sync(); +} + +void CoreContext::registerPlugin(std::unique_ptr plugin) { + plugins_.register_plugin(std::move(plugin)); +} + +} // namespace px::core diff --git a/src/core/context.h b/src/core/context.h new file mode 100644 index 0000000..f371c64 --- /dev/null +++ b/src/core/context.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/error.h" +#include "core/events.h" +#include "core/models/metadata/structures.h" +#include "core/plugin.h" +#include "core/structures.h" + +namespace px::core { + +class ActivationService; +class ConfigService; +class DiagnosticsService; +class DownloadManager; +class ModService; +class SortService; + +namespace services { +class GameLauncher; +} + +// Centralised application state: mods, active load order, config and game version, plus the +// services that act on them. Services hold a reference back to the context, so it is built only +// through create() and never moved. +class CoreContext { +public: + // `config_service` is not owned and must outlive the context. + static std::unique_ptr create(AppConfig cfg, + ConfigService* config_service = nullptr); + + ~CoreContext(); + CoreContext(const CoreContext&) = delete; + CoreContext& operator=(const CoreContext&) = delete; + CoreContext(CoreContext&&) = delete; + CoreContext& operator=(CoreContext&&) = delete; + + // Replaces all mods and the active list, taking ownership of the data. + void load(QHash mods, QStringList active_uuids); + void setActive(QStringList uuids); + // Enables missing dependencies, then sorts the active mods. Resolves to the number of active + // mods and the time the sort took in milliseconds. + QCoro::Task> autoSort(); + // Replaces the live config and refreshes derived values (game version). + void updateConfig(AppConfig cfg); + // Rebuilds the providers for new paths. + void resetProviders(const PathConfig& paths); + CollectionStats computeStats(const std::optional& active_ids = std::nullopt) const; + + QCoro::Task> initialize(); + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void closeSync(); + + void registerPlugin(std::unique_ptr plugin); + + const AppConfig& config() const noexcept { return cfg_; } + const QHash& allMods() const noexcept { return mods_; } + const QStringList& activeUuids() const noexcept { return active_uuids_; } + // Active UUIDs as last loaded from disk, unaffected by later edits. + const QStringList& loadedActiveUuids() const noexcept { return loaded_active_uuids_; } + // "Unknown" when the game is not configured or its version cannot be read. + const QString& gameVersion() const noexcept { return game_version_; } + // The "X.Y" part of the game version, or "Unknown". + QString targetVersion() const; + + Event& activeStateChanged() noexcept { return active_state_changed_; } + Event& configChanged() noexcept { return config_changed_; } + + bool hasConfigService() const noexcept { return config_service_ != nullptr; } + ConfigService& configService() const; + QNetworkAccessManager& network() noexcept { return network_; } + + ModService& modService() noexcept { return *mod_service_; } + DiagnosticsService& diagnosticsService() noexcept { return *diagnostics_service_; } + SortService& sortService() noexcept { return *sort_service_; } + ActivationService& activation() noexcept { return *activation_; } + services::GameLauncher& gameLauncher() noexcept { return *game_launcher_; } + DownloadManager& downloads() const; + PluginRegistry& plugins() noexcept { return plugins_; } + +private: + CoreContext(AppConfig cfg, ConfigService* config_service); + void refresh_game_version(); + + AppConfig cfg_; + ConfigService* config_service_; + QHash mods_; + QStringList active_uuids_; + QStringList loaded_active_uuids_; + QString game_version_; + Event active_state_changed_; + Event config_changed_; + // Shared by providers and the network; declared before the services so they outlive them. + QThreadPool pool_; + QNetworkAccessManager network_; + PluginRegistry plugins_; + // Services are declared last, so they are destroyed before what they use. + std::unique_ptr mod_service_; + std::unique_ptr diagnostics_service_; + std::unique_ptr sort_service_; + std::unique_ptr game_launcher_; + std::unique_ptr activation_; +}; + +} // namespace px::core diff --git a/src/core/context_fwd.h b/src/core/context_fwd.h new file mode 100644 index 0000000..41eee2c --- /dev/null +++ b/src/core/context_fwd.h @@ -0,0 +1,7 @@ +#pragma once + +namespace px::core { + +class CoreContext; + +} // namespace px::core diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp new file mode 100644 index 0000000..a45c75d --- /dev/null +++ b/src/core/downloads/downloader.cpp @@ -0,0 +1,31 @@ +#include "core/downloads/downloader.h" + +#include + +#include "core/downloads/manager.h" + +namespace px::core { + +Downloader::Downloader(QString name, QString label, DownloadManager& manager) + : Plugin(std::move(name), QStringList{kDownloadsManagerName}), + label_(std::move(label)), + manager_(manager) {} + +const QString& Downloader::label() const noexcept { return label_; } + +void Downloader::setup(CoreContext&) { manager_.register_downloader(*this); } + +QStringList Downloader::updatable_ids(const QList& mods) const { + QStringList ids; + QSet seen; + for (const auto& mod : mods) { + auto id = updatable_id(*mod); + if (id && !seen.contains(*id)) { + seen.insert(*id); + ids.append(std::move(*id)); + } + } + return ids; +} + +} // namespace px::core diff --git a/src/core/downloads/downloader.h b/src/core/downloads/downloader.h new file mode 100644 index 0000000..b6916d0 --- /dev/null +++ b/src/core/downloads/downloader.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "core/downloads/types.h" +#include "core/error.h" +#include "core/events.h" +#include "core/models/metadata/structures.h" +#include "core/plugin.h" + +namespace px::core { + +inline const QString kDownloadsManagerName = QStringLiteral("downloads"); + +class DownloadManager; + +// A source that downloads and updates mods into the local mods folder. +// Constructing a downloader hands it to `manager`; `setup` registers it, so the +// UI only talks to the manager and a downloader can be left out freely. +class Downloader : public Plugin { +public: + // `label` is the human-readable source name shown in the UI, e.g. "Steam Workshop". + Downloader(QString name, QString label, DownloadManager& manager); + + const QString& label() const noexcept; + + void setup(CoreContext& ctx) override; + + // Whether a batch is currently downloading. + virtual bool is_downloading() const = 0; + // Ids of the batch currently downloading. + virtual QSet active_ids() const = 0; + // Whether `mod_id` belongs to this source. + virtual bool accepts(const QString& mod_id) const = 0; + // Id of `mod` in this source if PxModRim downloaded it from here. + virtual std::optional updatable_id(const ListedMod& mod) const = 0; + // Unix time of the last successful sync of `mod`, if known. + virtual std::optional last_synced(const ListedMod& mod) const = 0; + // Downloads or updates `mod_ids`. Per-item failures are reported in the result; + // an error means the whole batch was refused. + virtual QCoro::Task> download_mods(QStringList mod_ids) = 0; + virtual void cancel() = 0; + + // Distinct updatable ids among `mods`, in first-seen order. + QStringList updatable_ids(const QList& mods) const; + + Event status_message_changed; + Event download_progress; + Event download_item_status_changed; + Event download_item_titled; + // "login" -> "query" (item details) -> "run" (all items resolved). + Event download_phase_changed; + Event download_finished; + Event busy_changed; + Event batch_started; + +private: + QString label_; + DownloadManager& manager_; +}; + +} // namespace px::core diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp new file mode 100644 index 0000000..355185c --- /dev/null +++ b/src/core/downloads/manager.cpp @@ -0,0 +1,152 @@ +#include "core/downloads/manager.h" + +#include +#include +#include + +namespace px::core { + +DownloadManager::DownloadManager() : Plugin(kDownloadsManagerName) {} + +void DownloadManager::register_downloader(Downloader& downloader) { + downloaders_.push_back(&downloader); + downloader.status_message_changed.connect([this](const QString& v) { status_message_changed.notify(v); }); + downloader.download_progress.connect([this](const DownloadProgress& v) { download_progress.notify(v); }); + downloader.download_item_status_changed.connect( + [this](const DownloadItemStatus& v) { download_item_status_changed.notify(v); }); + downloader.download_item_titled.connect([this](const DownloadItemTitle& v) { download_item_titled.notify(v); }); + downloader.download_phase_changed.connect([this](const QString& v) { download_phase_changed.notify(v); }); + downloader.download_finished.connect([this](const DownloadResult& v) { download_finished.notify(v); }); + downloader.batch_started.connect([this](const QStringList& v) { batch_started.notify(v); }); + downloader.busy_changed.connect([this](bool busy) { on_busy(busy); }); +} + +void DownloadManager::on_busy(bool busy) { + if (busy) { + cancelled_ = false; + } + busy_changed.notify(is_downloading()); +} + +const std::vector& DownloadManager::downloaders() const noexcept { return downloaders_; } + +bool DownloadManager::available() const noexcept { return !downloaders_.empty(); } + +bool DownloadManager::is_downloading() const { + return std::ranges::any_of(downloaders_, [](const Downloader* d) { return d->is_downloading(); }); +} + +QSet DownloadManager::active_ids() const { + QSet ids; + for (const auto* downloader : downloaders_) { + ids |= downloader->active_ids(); + } + return ids; +} + +Downloader* DownloadManager::source_of(const ListedMod& mod) const { + for (auto* downloader : downloaders_) { + if (downloader->updatable_id(mod)) { + return downloader; + } + } + return nullptr; +} + +std::optional DownloadManager::updatable_id(const ListedMod& mod) const { + const Downloader* source = source_of(mod); + return source ? source->updatable_id(mod) : std::nullopt; +} + +QStringList DownloadManager::updatable_ids(const QList& mods) const { + QStringList ids; + QSet seen; + for (const auto* downloader : downloaders_) { + for (const auto& id : downloader->updatable_ids(mods)) { + if (!seen.contains(id)) { + seen.insert(id); + ids.append(id); + } + } + } + return ids; +} + +QHash DownloadManager::names_by_id(const QList& mods) const { + QHash names; + for (const auto& mod : mods) { + if (auto id = updatable_id(*mod)) { + names.insert(*id, mod->name); + } + } + return names; +} + +std::optional DownloadManager::last_synced(const ListedMod& mod) const { + const Downloader* source = source_of(mod); + return source ? source->last_synced(mod) : std::nullopt; +} + +QCoro::Task> DownloadManager::download_mods(QStringList mod_ids) { + // Ids are grouped by owning downloader in first-seen order; duplicates are dropped. + std::vector> groups; + QSet seen; + for (const auto& mod_id : mod_ids) { + if (seen.contains(mod_id)) { + continue; + } + seen.insert(mod_id); + + Downloader* owner = nullptr; + for (auto* downloader : downloaders_) { + if (downloader->accepts(mod_id)) { + owner = downloader; + break; + } + } + if (!owner) { + co_return std::unexpected(Error{QStringLiteral("No download source handles '%1'.").arg(mod_id)}); + } + auto group = std::ranges::find(groups, owner, &std::pair::first); + if (group == groups.end()) { + groups.emplace_back(owner, QStringList{}); + group = std::prev(groups.end()); + } + group->second.append(mod_id); + } + if (groups.empty()) { + co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); + } + if (is_downloading()) { + co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); + } + + cancelled_ = false; + DownloadResult total; + for (auto& [downloader, group] : groups) { + if (cancelled_) { + break; + } + auto result = co_await downloader->download_mods(group); + if (!result) { + co_return std::unexpected(result.error()); + } + total.succeeded += result->succeeded; + total.failed += result->failed; + total.changed += result->changed; + } + co_return total; +} + +bool DownloadManager::cancelled() const noexcept { return cancelled_; } + +void DownloadManager::cancel() { + cancelled_ = true; + for (auto* downloader : downloaders_) { + if (downloader->is_downloading()) { + downloader->cancel(); + } + } +} + +} // namespace px::core diff --git a/src/core/downloads/manager.h b/src/core/downloads/manager.h new file mode 100644 index 0000000..cac8e0d --- /dev/null +++ b/src/core/downloads/manager.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "core/downloads/downloader.h" +#include "core/downloads/types.h" +#include "core/error.h" +#include "core/events.h" +#include "core/models/metadata/structures.h" +#include "core/plugin.h" + +namespace px::core { + +// The one download entry point for the UI; routes to registered downloaders. +// With no downloader registered nothing is updatable and `available()` is false, +// which is how the UI hides every download control. +class DownloadManager : public Plugin { +public: + DownloadManager(); + + // Called by each downloader from its `setup`. The downloader must outlive the manager's use of it. + void register_downloader(Downloader& downloader); + + const std::vector& downloaders() const noexcept; + bool available() const noexcept; + bool is_downloading() const; + QSet active_ids() const; + + // The downloader that can update `mod`, or nullptr. + Downloader* source_of(const ListedMod& mod) const; + std::optional updatable_id(const ListedMod& mod) const; + QStringList updatable_ids(const QList& mods) const; + // Display names of installed `mods`, keyed by their source id. + QHash names_by_id(const QList& mods) const; + std::optional last_synced(const ListedMod& mod) const; + + // Downloads `mod_ids`, one downloader after another. Errors before anything starts + // (unclaimed id, nothing selected, already running) are returned without downloading. + QCoro::Task> download_mods(QStringList mod_ids); + + bool cancelled() const noexcept; + void cancel(); + + Event status_message_changed; + Event download_progress; + Event download_item_status_changed; + Event download_item_titled; + Event download_phase_changed; + Event download_finished; + Event busy_changed; + Event batch_started; + +private: + void on_busy(bool busy); + + std::vector downloaders_; + bool cancelled_ = false; +}; + +} // namespace px::core diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp new file mode 100644 index 0000000..42f85b6 --- /dev/null +++ b/src/core/downloads/steam.cpp @@ -0,0 +1,570 @@ +#ifdef PX_HAS_STEAM + +#include "core/downloads/steam.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/utils.h" + +namespace px::core { +namespace { + +constexpr auto kProgressLogInterval = std::chrono::seconds(15); +constexpr auto kProgressEmitInterval = std::chrono::milliseconds(100); + +// Steam item ids are uint64; anything larger is not an item. +std::optional parse_item_id(const QString& text) { + if (!isAsciiDigits(text)) { + return std::nullopt; + } + const std::string utf8 = text.toStdString(); + std::uint64_t value = 0; + const char* const last = utf8.data() + utf8.size(); + const auto [end, ec] = std::from_chars(utf8.data(), last, value); + if (ec != std::errc{} || end != last) { + return std::nullopt; + } + return value; +} + +QString item_id_text(std::uint64_t id) { return QString::number(static_cast(id)); } + +double unix_now() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +// Settings used to live in config.json under `workshop_` keys. +QHash legacy_settings_keys() { + QHash keys; + for (const char* field : {"auto_update_hours", "parallel_items", "threads_per_item", "proxy", + "connect_timeout", "stall_timeout"}) { + keys.insert(QStringLiteral("workshop_") + QString::fromLatin1(field), QString::fromLatin1(field)); + } + return keys; +} + +// The real client; pxsteamdl::Client is thread-safe, so the batch worker may use it directly. +class PxSteamDlClient final : public WorkshopClient { +public: + explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) : client_(options) {} + + std::vector download(std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + return client_.download(ids, root, options); + } + +private: + pxsteamdl::Client client_; +}; + +} // namespace + +QCoro::Task>> login_with_pxsteamdl(LoginOptions options) { + co_return co_await QtConcurrent::run( + [options = std::move(options)]() -> Result> { + pxsteamdl::ClientOptions native; + native.proxy = options.proxy; + native.connect_timeout = std::chrono::seconds(options.connect_timeout); + native.stall_timeout = std::chrono::seconds(options.stall_timeout); + try { + return std::shared_ptr(std::make_shared(native)); + } catch (const std::exception& e) { + return std::unexpected(Error{QString::fromUtf8(e.what())}); + } + }); +} + +SteamBatch::SteamBatch(int total_items) + : total(total_items), started(std::chrono::steady_clock::now()), last_logged(started) {} + +QStringList SteamBatch::changed() const { + QStringList result; + for (const auto& id : succeeded) { + if (bytes.value(id, {0, 0}).second > 0) { + result.append(id); + } + } + return result; +} + +DownloadProgress SteamBatch::progress() const { + // Items that report all their bytes (or 0/0 for an up-to-date copy) count as done + // before their Result arrives. + QSet finished(succeeded.begin(), succeeded.end()); + finished.unite(QSet(failed.begin(), failed.end())); + qint64 bytes_done = 0; + qint64 bytes_total = 0; + for (auto it = bytes.cbegin(); it != bytes.cend(); ++it) { + const auto [done, size] = it.value(); + if (done >= size) { + finished.insert(it.key()); + } + bytes_done += done; + bytes_total += size; + } + return DownloadProgress{ + .total = total, + .completed = static_cast(finished.size()), + .bytes_done = bytes_done, + .bytes_total = bytes_total, + }; +} + +SteamDownloader::SteamDownloader(SteamDownloaderDeps deps) + : Downloader("steam_downloader", "Steam Workshop", deps.manager), + config_(deps.config), + local_mods_path_(std::move(deps.local_mods_path)), + installed_mods_(std::move(deps.installed_mods)), + client_factory_(std::move(deps.client_factory)), + settings_(&deps.config, QStringLiteral("steam_downloader"), legacy_settings_keys()) { + auto_timer_.setSingleShot(true); + QObject::connect(&auto_timer_, &QTimer::timeout, &auto_timer_, [this] { run_scheduled_auto_update(); }); +} + +QCoro::Task SteamDownloader::load_sync_state() { + ConfigService& config = config_; + auto loaded = co_await QtConcurrent::run([&config] { return config.load(kWorkshopSyncFile); }); + if (loaded) { + sync_ = std::move(*loaded); + } else { + spdlog::warn("[workshop] cannot read sync times: {}", loaded.error().message.toStdString()); + } +} + +QCoro::Task SteamDownloader::init(CoreContext&) { + co_await load_sync_state(); + arm_auto_update(kAutoUpdateStartupDelay); +} + +QCoro::Task SteamDownloader::shutdown() { + stopping_ = true; + auto_timer_.stop(); + cancel(); + if (running_.isValid()) { + QFuture pending = running_; + co_await pending; + } +} + +bool SteamDownloader::is_downloading() const { return stop_.has_value(); } + +QSet SteamDownloader::active_ids() const { return active_ids_; } + +bool SteamDownloader::accepts(const QString& mod_id) const { return isAsciiDigits(mod_id); } + +std::optional SteamDownloader::updatable_id(const ListedMod& mod) const { + const QString local = local_path(); + if (local.isEmpty()) { + return std::nullopt; + } + const auto& path = mod.mod_path(); + if (!path) { + return std::nullopt; + } + const QFileInfo info(*path); + if (QDir::cleanPath(info.path()) != QDir::cleanPath(local)) { + return std::nullopt; + } + const auto pfid = mod.published_file_id(); + if (!pfid || *pfid != info.fileName()) { + return std::nullopt; + } + return pfid; +} + +std::optional SteamDownloader::last_synced(const ListedMod& mod) const { + const auto id = updatable_id(mod); + return id ? synced_at(*id) : std::nullopt; +} + +QStringList SteamDownloader::stale_ids(double max_age_s, double now) const { + const double cutoff = now - max_age_s; + const auto synced_or_zero = [this](const QString& id) { return synced_at(id).value_or(0.0); }; + QStringList stale; + for (const auto& id : updatable_ids(installed_mods_())) { + if (synced_or_zero(id) < cutoff) { + stale.append(id); + } + } + std::stable_sort(stale.begin(), stale.end(), [&](const QString& a, const QString& b) { + return synced_or_zero(a) < synced_or_zero(b); + }); + return stale; +} + +QCoro::Task> SteamDownloader::download_mods(QStringList mod_ids) { + if (mod_ids.isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); + } + for (const auto& id : mod_ids) { + if (!parse_item_id(id)) { + co_return std::unexpected( + Error{QStringLiteral("Published file IDs must be ASCII decimal numbers within uint64.")}); + } + } + const QString root = local_path(); + if (root.isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Local mods path is not configured.")}); + } + if (is_downloading()) { + co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); + } + + QStringList ids; + QSet seen; + for (const auto& id : mod_ids) { + if (!seen.contains(id)) { + seen.insert(id); + ids.append(id); + } + } + + auto batch = std::make_shared(static_cast(ids.size())); + stop_.emplace(); + active_ids_ = QSet(ids.begin(), ids.end()); + busy_changed.notify(true); + batch_started.notify(ids); + spdlog::info("[workshop] downloading {} items into {} (first ids: {})", ids.size(), root.toStdString(), + ids.mid(0, 5).join(QStringLiteral(", ")).toStdString()); + + try { + co_await run_batch(ids, root, batch); + } catch (...) { + finish_batch(batch); + throw; + } + co_return finish_batch(batch); +} + +void SteamDownloader::cancel() { + if (stop_) { + spdlog::debug("[workshop] cancel requested"); + stop_->request_stop(); + } +} + +QString SteamDownloader::local_path() const { return local_mods_path_(); } + +std::optional SteamDownloader::synced_at(const QString& mod_id) const { + const auto it = sync_.synced.find(mod_id.toStdString()); + if (it == sync_.synced.end()) { + return std::nullopt; + } + return it->second; +} + +LoginOptions SteamDownloader::wanted_options() const { + const auto& cfg = settings_.value(); + LoginOptions options; + const QString proxy = QString::fromStdString(cfg.proxy).trimmed(); + if (!proxy.isEmpty()) { + options.proxy = proxy.toStdString(); + } + options.connect_timeout = std::max(1, cfg.connect_timeout); + options.stall_timeout = std::max(1, cfg.stall_timeout); + return options; +} + +QCoro::Task>> SteamDownloader::ensure_client() { + const LoginOptions options = wanted_options(); + if (client_ && options != client_options_) { + client_.reset(); + } + if (!client_) { + status_message_changed.notify(QStringLiteral(u"Logging in to Steam\u2026")); + auto logged_in = co_await client_factory_(options); + if (!logged_in) { + co_return std::unexpected(logged_in.error()); + } + client_ = std::move(*logged_in); + client_options_ = options; + } + auto result = client_; + co_return result; +} + +QCoro::Task SteamDownloader::run_batch(QStringList ids, QString root, std::shared_ptr batch) { + const std::filesystem::path root_path(root.toStdWString()); + const std::error_code mkdir_error = co_await QtConcurrent::run([root_path] { + std::error_code ec; + std::filesystem::create_directories(root_path, ec); + return ec; + }); + if (mkdir_error) { + const QString message = + QStringLiteral("Cannot create %1: %2").arg(root, QString::fromStdString(mkdir_error.message())); + spdlog::error("[workshop] {}", message.toStdString()); + status_message_changed.notify(message); + for (const auto& id : ids) { + finish_item(*batch, id, message); + } + co_return; + } + + if (!client_) { + download_phase_changed.notify(QStringLiteral("login")); + } + const auto login_started = std::chrono::steady_clock::now(); + auto login = co_await ensure_client(); + if (!login) { + const QString message = QStringLiteral("Steam login failed: %1").arg(login.error().message); + spdlog::error("[workshop] {}", message.toStdString()); + status_message_changed.notify(message); + for (const auto& id : ids) { + finish_item(*batch, id, message); + } + co_return; + } + spdlog::debug("[workshop] steam session ready in {:.1f}s", + std::chrono::duration(std::chrono::steady_clock::now() - login_started).count()); + const std::shared_ptr client = *login; + + const auto& cfg = settings_.value(); + const int parallel_items = std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); + const int threads_per_item = std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); + spdlog::debug("[workshop] client.download start: {} items, {} parallel x {} threads", ids.size(), parallel_items, + threads_per_item); + + std::vector item_ids; + item_ids.reserve(ids.size()); + for (const auto& id : ids) { + item_ids.push_back(*parse_item_id(id)); // validated by download_mods + } + + // PxSteamDL calls these on its worker threads. Their state lives on the main thread, + // so each call is posted there. + QObject* const target = &dispatch_; + pxsteamdl::Options options; + options.parallel_items = static_cast(parallel_items); + options.threads_per_item = static_cast(threads_per_item); + options.stop = stop_->get_token(); + options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { + QMetaObject::invokeMethod( + target, + [this, batch, id = item_id_text(info.item_id), title = QString::fromStdString(info.title)] { + on_resolved(*batch, id, title); + }, + Qt::QueuedConnection); + }; + options.on_progress = [this, target, batch](const pxsteamdl::Progress& p) { + QMetaObject::invokeMethod( + target, + [this, batch, id = item_id_text(p.item_id), done = static_cast(p.unpacked_bytes), + total = static_cast(p.unpacked_total)] { on_progress(*batch, id, done, total); }, + Qt::QueuedConnection); + }; + + download_phase_changed.notify(QStringLiteral("query")); + QFuture call = QtConcurrent::run( + [client, item_ids = std::move(item_ids), root_path, options]() -> Outcome { + try { + return client->download(item_ids, root_path, options); + } catch (const std::runtime_error& e) { + // Anything else propagates to the caller, as in the Python version. + const std::string what = e.what(); + return std::unexpected(what.empty() ? std::string("download failed") : what); + } + }); + running_ = call; + Outcome outcome; + try { + outcome = co_await QFuture(call); + } catch (...) { + running_ = {}; + throw; + } + running_ = {}; + + if (!outcome) { + // A failed call leaves the Steam session suspect; log in again next time. + client_.reset(); + const QString message = QString::fromStdString(outcome.error()); + for (const auto& id : ids) { + finish_item(*batch, id, message); + } + co_return; + } + + const auto& results = *outcome; + // A network failure leaves the Steam session suspect; log in again next time. + if (std::ranges::any_of(results, + [](const pxsteamdl::Result& r) { return r.error_kind == pxsteamdl::ErrorKind::kNetwork; })) { + client_.reset(); + } + + // Progress posted by the worker is queued before the completion that resumes this + // coroutine, so the item callbacks have already been handled here. + QHash by_id; + for (const auto& r : results) { + by_id.insert(item_id_text(r.item_id), &r); + } + for (const auto& id : ids) { + const pxsteamdl::Result* result = by_id.value(id, nullptr); + if (!result) { + finish_item(*batch, id, QStringLiteral("no result")); + } else if (!result->cancelled()) { + finish_item(*batch, id, QString::fromStdString(result->error)); + } + } +} + +DownloadResult SteamDownloader::finish_batch(const std::shared_ptr& batch) { + stop_.reset(); + active_ids_.clear(); + busy_changed.notify(false); + + DownloadResult result{ + .succeeded = batch->succeeded, + .failed = batch->failed, + .changed = batch->changed(), + }; + spdlog::info("[workshop] finished in {:.1f}s: {} ok, {} failed{}", + std::chrono::duration(std::chrono::steady_clock::now() - batch->started).count(), + result.succeeded.size(), result.failed.size(), + result.failed.isEmpty() + ? std::string() + : " (failed ids: " + result.failed.mid(0, 20).join(QStringLiteral(", ")).toStdString() + ")"); + record_synced(result.succeeded); + download_finished.notify(result); + return result; +} + +void SteamDownloader::finish_item(SteamBatch& batch, const QString& mod_id, const QString& error) { + const qint64 total = batch.bytes.value(mod_id, {0, 0}).second; + DownloadItemStatus status; + if (!error.isEmpty()) { + spdlog::warn("[workshop] {} failed: {}", mod_id.toStdString(), error.toStdString()); + batch.failed.append(mod_id); + status = DownloadItemStatus{ + .mod_id = mod_id, + .status = QStringLiteral("error"), + .bytes_done = 0, + .bytes_total = 0, + .error = error, + }; + } else { + batch.succeeded.append(mod_id); + batch.bytes.insert(mod_id, {total, total}); + status = DownloadItemStatus{ + .mod_id = mod_id, + .status = QStringLiteral("success"), + .bytes_done = total, + .bytes_total = total, + .error = {}, + }; + } + download_item_status_changed.notify(status); + download_progress.notify(batch.progress()); +} + +void SteamDownloader::record_synced(const QStringList& ids) { + if (ids.isEmpty()) { + return; + } + const double now = unix_now(); + for (const auto& id : ids) { + sync_.synced[id.toStdString()] = now; + } + if (auto saved = config_.save(kWorkshopSyncFile, sync_); !saved) { + spdlog::warn("[workshop] cannot save sync times: {}", saved.error().message.toStdString()); + } +} + +void SteamDownloader::on_resolved(SteamBatch& batch, const QString& mod_id, const QString& title) { + ++batch.resolved; + if (!title.isEmpty()) { + download_item_titled.notify(DownloadItemTitle{.mod_id = mod_id, .title = title}); + } + if (batch.resolved >= batch.total) { + download_phase_changed.notify(QStringLiteral("run")); + } +} + +void SteamDownloader::on_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total) { + if (batch.succeeded.contains(mod_id) || batch.failed.contains(mod_id)) { + return; + } + if (!batch.bytes.contains(mod_id)) { + ++batch.seen; + spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", mod_id.toStdString(), done, + total, batch.seen, batch.total); + } + batch.bytes.insert(mod_id, {done, total}); + + // PxSteamDL reports per network chunk, thousands of times a second; the UI needs far fewer. + const auto now = std::chrono::steady_clock::now(); + if (done >= total || now - batch.last_emitted >= kProgressEmitInterval) { + batch.last_emitted = now; + emit_item_progress(batch, mod_id, done, total); + } + if (now - batch.last_logged >= kProgressLogInterval) { + batch.last_logged = now; + const DownloadProgress progress = batch.progress(); + spdlog::info("[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", progress.completed, + progress.total, static_cast(progress.bytes_done) / 1048576.0, + std::chrono::duration(now - batch.started).count()); + } +} + +void SteamDownloader::emit_item_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total) { + download_item_status_changed.notify(DownloadItemStatus{ + .mod_id = mod_id, + .status = QStringLiteral("downloading"), + .bytes_done = done, + .bytes_total = total, + .error = {}, + }); + download_progress.notify(batch.progress()); +} + +QCoro::Task SteamDownloader::auto_update_once() { + if (is_downloading()) { + co_return; + } + const int hours = settings_.value().auto_update_hours; + if (hours <= 0) { + co_return; + } + const QStringList ids = stale_ids(hours * 3600.0, unix_now()); + if (ids.isEmpty()) { + co_return; + } + spdlog::info("[workshop] auto-update: {} mods older than {}h", ids.size(), hours); + const auto result = co_await download_mods(ids); + if (!result) { + spdlog::warn("[workshop] auto-update not started: {}", result.error().message.toStdString()); + } +} + +void SteamDownloader::arm_auto_update(std::chrono::milliseconds delay) { + if (!stopping_) { + auto_timer_.start(delay); + } +} + +QCoro::Task SteamDownloader::run_scheduled_auto_update() { + try { + co_await auto_update_once(); + } catch (const std::exception& e) { + spdlog::error("[workshop] auto-update iteration failed: {}", e.what()); + } + arm_auto_update(kAutoUpdateCheckInterval); +} + +} // namespace px::core + +#endif // PX_HAS_STEAM diff --git a/src/core/downloads/steam.h b/src/core/downloads/steam.h new file mode 100644 index 0000000..f226db5 --- /dev/null +++ b/src/core/downloads/steam.h @@ -0,0 +1,197 @@ +#pragma once + +// Everything here talks to PxSteamDL, so the whole file exists only when Steam support is built. +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/downloads/downloader.h" +#include "core/downloads/types.h" +#include "core/error.h" +#include "core/models/metadata/structures.h" +#include "core/plugin_config.h" + +namespace px::core { + +// PxSteamDL runs one shared pool of parallel_items * threads_per_item workers. +inline constexpr int kSteamMaxParallelItems = 8; +inline constexpr int kSteamMaxThreadsPerItem = 16; + +inline constexpr std::chrono::seconds kAutoUpdateStartupDelay{45}; +inline constexpr std::chrono::seconds kAutoUpdateCheckInterval{600}; + +// In the config folder; records when each Workshop item was last synced. +inline const QString kWorkshopSyncFile = QStringLiteral("workshop_sync.json"); + +struct SteamSettings { + int auto_update_hours = 0; + int parallel_items = 8; + int threads_per_item = 1; + std::string proxy; + int connect_timeout = 10; + int stall_timeout = 30; + + bool operator==(const SteamSettings&) const = default; +}; + +// Unix seconds of the last sync, keyed by Workshop id. +struct WorkshopSyncState { + std::map synced; +}; + +// Network settings of one Steam login. An unset proxy means libcurl's environment. +struct LoginOptions { + std::optional proxy; + int connect_timeout = 10; + int stall_timeout = 30; + + bool operator==(const LoginOptions&) const = default; +}; + +// The part of pxsteamdl::Client that the downloader uses. A seam: tests substitute a fake. +class WorkshopClient { +public: + virtual ~WorkshopClient() = default; + + virtual std::vector download(std::span ids, + const std::filesystem::path& root, + const pxsteamdl::Options& options) = 0; +}; + +using ClientFactory = std::function>>(LoginOptions)>; + +// Logs in anonymously with PxSteamDL on a worker thread. +QCoro::Task>> login_with_pxsteamdl(LoginOptions options); + +// Progress of one download batch. An item counts as complete once all its bytes are in, +// which can happen before PxSteamDL returns its Result. +struct SteamBatch { + explicit SteamBatch(int total_items); + + int total = 0; + std::chrono::steady_clock::time_point started; + std::chrono::steady_clock::time_point last_logged; + std::chrono::steady_clock::time_point last_emitted; + int seen = 0; + int resolved = 0; + // Item id -> (bytes done, bytes total). + QHash> bytes; + QStringList succeeded; + QStringList failed; + + // Succeeded items that transferred data; up-to-date items are excluded. + QStringList changed() const; + DownloadProgress progress() const; +}; + +// What the downloader reads from the application. Passed in rather than taken from a +// CoreContext so the downloader can be built and tested without the whole application. +struct SteamDownloaderDeps { + DownloadManager& manager; + ConfigService& config; + // The configured local mods folder; empty when not set. + std::function local_mods_path; + // Mods the application knows about; auto-update picks the stale ones among them. + std::function()> installed_mods; + ClientFactory client_factory = login_with_pxsteamdl; +}; + +// Downloads Steam Workshop items into the local mods folder via PxSteamDL. +class SteamDownloader : public Downloader { +public: + explicit SteamDownloader(SteamDownloaderDeps deps); + + QCoro::Task init(CoreContext& ctx) override; + QCoro::Task shutdown() override; + + bool is_downloading() const override; + QSet active_ids() const override; + bool accepts(const QString& mod_id) const override; + std::optional updatable_id(const ListedMod& mod) const override; + std::optional last_synced(const ListedMod& mod) const override; + QCoro::Task> download_mods(QStringList mod_ids) override; + void cancel() override; + + PluginConfig& settings() noexcept { return settings_; } + const PluginConfig& settings() const noexcept { return settings_; } + + // Loads workshop_sync.json off the UI thread; called by init(), exposed for tests. + QCoro::Task load_sync_state(); + + // Ids never synced, or last synced more than `max_age_s` before `now` (unix seconds), + // oldest first. + QStringList stale_ids(double max_age_s, double now) const; + + // One auto-update pass: downloads the stale mods when auto-update is enabled and nothing + // is running. The timer started by init() runs this. + QCoro::Task auto_update_once(); + +private: + // The per-item results of one PxSteamDL call, or the error text if the call itself failed. + using Outcome = std::expected, std::string>; + + QString local_path() const; + std::optional synced_at(const QString& mod_id) const; + LoginOptions wanted_options() const; + QCoro::Task>> ensure_client(); + + QCoro::Task run_batch(QStringList ids, QString root, std::shared_ptr batch); + DownloadResult finish_batch(const std::shared_ptr& batch); + void finish_item(SteamBatch& batch, const QString& mod_id, const QString& error); + void record_synced(const QStringList& ids); + + void on_resolved(SteamBatch& batch, const QString& mod_id, const QString& title); + void on_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total); + void emit_item_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total); + + void arm_auto_update(std::chrono::milliseconds delay); + QCoro::Task run_scheduled_auto_update(); + + ConfigService& config_; + std::function local_mods_path_; + std::function()> installed_mods_; + ClientFactory client_factory_; + PluginConfig settings_; + WorkshopSyncState sync_; + + std::shared_ptr client_; + LoginOptions client_options_; + + // Set while a batch is downloading. + std::optional stop_; + QSet active_ids_; + // The PxSteamDL call of the running batch, so shutdown() can wait for it. + QFuture running_; + + // Worker-thread callbacks are posted to this object, which lives on the main thread. + QObject dispatch_; + QTimer auto_timer_; + bool stopping_ = false; +}; + +} // namespace px::core + +#endif // PX_HAS_STEAM diff --git a/src/core/downloads/types.h b/src/core/downloads/types.h new file mode 100644 index 0000000..4931ef5 --- /dev/null +++ b/src/core/downloads/types.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace px::core { + +struct DownloadProgress { + int total = 0; + int completed = 0; + qint64 bytes_done = 0; + qint64 bytes_total = 0; +}; + +// `status` is "downloading", "success" or "error". +struct DownloadItemStatus { + QString mod_id; + QString status; + qint64 bytes_done = 0; + qint64 bytes_total = 0; + QString error; +}; + +struct DownloadItemTitle { + QString mod_id; + QString title; +}; + +struct DownloadResult { + QStringList succeeded; + QStringList failed; + // Succeeded items that actually transferred data (others were already current). + QStringList changed; + + bool operator==(const DownloadResult&) const = default; +}; + +} // namespace px::core diff --git a/src/core/error.h b/src/core/error.h new file mode 100644 index 0000000..56047d9 --- /dev/null +++ b/src/core/error.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include + +namespace px::core { + +struct Error { + QString message; +}; + +template +using Result = std::expected; + +} // namespace px::core diff --git a/src/core/events.h b/src/core/events.h new file mode 100644 index 0000000..ac6f762 --- /dev/null +++ b/src/core/events.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::core { + +// Synchronous, single-threaded callback list. `connect` returns an id that +// `disconnect` takes back: std::function is not comparable, so the legacy +// identity check on connect is replaced by ids. `emit` runs over a snapshot, +// so handlers may connect or disconnect while an emit is in progress. +template +class Event { +public: + using Handler = std::function; + using Id = std::uint64_t; + + Id connect(Handler handler) { + const Id id = next_id_++; + handlers_.emplace_back(id, std::move(handler)); + return id; + } + + // Returns false when `id` is not connected. + bool disconnect(Id id) { + for (auto it = handlers_.begin(); it != handlers_.end(); ++it) { + if (it->first == id) { + handlers_.erase(it); + return true; + } + } + return false; + } + + // Named `notify`: Qt defines `emit` as a macro, which would swallow a member named so. + void notify(const T& data) const { + const auto snapshot = handlers_; + for (const auto& [id, handler] : snapshot) { + handler(data); + } + } + +private: + std::vector> handlers_; + Id next_id_ = 1; +}; + +} // namespace px::core diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp new file mode 100644 index 0000000..5d09e65 --- /dev/null +++ b/src/core/file_io.cpp @@ -0,0 +1,46 @@ +#include "core/file_io.h" + +#include +#include +#include +#include + +namespace px::core { + +Result read_file_bytes(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + const QByteArray bytes = file.readAll(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +Result write_file_atomically(const QString& path, std::string_view data) { + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected(Error{QStringLiteral("Cannot create directory for %1").arg(path)}); + } + // QSaveFile writes to a temporary file and renames it over the target on commit; + // it discards the temporary file when the commit does not happen. + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (file.write(data.data(), static_cast(data.size())) != static_cast(data.size())) { + return std::unexpected(Error{file.errorString()}); + } + if (!file.commit()) { + return std::unexpected(Error{file.errorString()}); + } + return {}; +} + +Result copy_file_replacing(const QString& source, const QString& target) { + QFile::remove(target); + if (!QFile::copy(source, target)) { + return std::unexpected(Error{QStringLiteral("Cannot copy %1 to %2").arg(source, target)}); + } + return {}; +} + +} // namespace px::core diff --git a/src/core/file_io.h b/src/core/file_io.h new file mode 100644 index 0000000..cdb97d4 --- /dev/null +++ b/src/core/file_io.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +#include "core/error.h" + +namespace px::core { + +Result read_file_bytes(const QString& path); + +// Writes `data` to a temporary file beside `path`, syncs it, then renames it +// over `path`, so readers see either the old or the new contents. Missing +// parent directories are created. +Result write_file_atomically(const QString& path, std::string_view data); + +// Copies `source` to `target`, replacing `target` if it exists. +Result copy_file_replacing(const QString& source, const QString& target); + +} // namespace px::core diff --git a/src/core/loading.cpp b/src/core/loading.cpp new file mode 100644 index 0000000..62622b7 --- /dev/null +++ b/src/core/loading.cpp @@ -0,0 +1,80 @@ +#include "core/loading.h" + +#include + +namespace px::core { + +LoadingState::LoadingState(QObject* parent) : QObject(parent) {} + +QStringList LoadingState::statuses() const { + QStringList result; + result.reserve(static_cast(stack_.size())); + for (const auto& frame : stack_) { + result.append(frame.status); + } + return result; +} + +void LoadingState::push(const QString& status, int total_steps) { + stack_.push_back(Frame{.status = status, .current_step = 0, .total_steps = total_steps}); + emit_changed(); +} + +void LoadingState::pop() { + if (!stack_.empty()) { + stack_.pop_back(); + } + if (stack_.empty()) { + Q_EMIT finished(); + } else { + emit_changed(); + } +} + +void LoadingState::step(int amount) { + if (stack_.empty()) { + return; + } + auto& frame = stack_.back(); + frame.current_step = std::min(frame.total_steps, frame.current_step + amount); + emit_changed(); +} + +void LoadingState::set_total(int total) { + if (stack_.empty()) { + return; + } + stack_.back().total_steps = std::max(1, total); + emit_changed(); +} + +void LoadingState::set_progress(int value) { + if (stack_.empty()) { + return; + } + auto& frame = stack_.back(); + frame.current_step = std::max(0, std::min(frame.total_steps, value)); + emit_changed(); +} + +LoadingState::TaskScope LoadingState::task(const QString& status, int total_steps) { + push(status, total_steps); + return TaskScope(*this); +} + +void LoadingState::emit_changed() { + if (stack_.empty()) { + return; + } + const auto& frame = stack_.back(); + // Truncated through double, as the legacy float division does: 29/100 * 100 + // is 28.999... and must still report 28. + const int pct = frame.total_steps != 0 + ? static_cast((static_cast(frame.current_step) / + static_cast(frame.total_steps)) * + 100.0) + : 0; + Q_EMIT changed(frame.status, pct, frame.current_step, frame.total_steps); +} + +} // namespace px::core diff --git a/src/core/loading.h b/src/core/loading.h new file mode 100644 index 0000000..3acd20f --- /dev/null +++ b/src/core/loading.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace px::core { + +// Stack of progress frames for one async operation. Each frame is a subtask; +// signals describe the innermost (top) frame. +class LoadingState : public QObject { + Q_OBJECT + +public: + // Pops its frame on destruction. + class TaskScope { + public: + explicit TaskScope(LoadingState& state) noexcept : state_(&state) {} + TaskScope(TaskScope&& other) noexcept : state_(std::exchange(other.state_, nullptr)) {} + TaskScope(const TaskScope&) = delete; + TaskScope& operator=(const TaskScope&) = delete; + TaskScope& operator=(TaskScope&&) = delete; + ~TaskScope() { + if (state_) { + state_->pop(); + } + } + + private: + LoadingState* state_; + }; + + explicit LoadingState(QObject* parent = nullptr); + + QStringList statuses() const; + + void push(const QString& status, int total_steps = 100); + void pop(); + void step(int amount = 1); + void set_total(int total); + void set_progress(int value); + + [[nodiscard]] TaskScope task(const QString& status, int total_steps = 100); + +Q_SIGNALS: + void changed(const QString& status, int pct, int current, int total); + void finished(); + +private: + struct Frame { + QString status; + int current_step = 0; + int total_steps = 100; + }; + + void emit_changed(); + + std::vector stack_; +}; + +} // namespace px::core diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp new file mode 100644 index 0000000..356267f --- /dev/null +++ b/src/core/migrator.cpp @@ -0,0 +1,160 @@ +#include "core/migrator.h" + +#include +#include +#include +#include +#include +#include + +#include "core/file_io.h" + +namespace px::core { + +namespace { + +Result execute(QSqlDatabase& db, const QString& sql) { + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; +} + +// QSqlQuery runs one statement per exec, so a schema script is split first. +// Semicolons inside string literals, quoted identifiers, and comments do not split. +// CREATE TRIGGER bodies are not recognised: their inner semicolons split and fail. +QStringList split_statements(const QString& script) { + QStringList statements; + QString current; + QChar quote; + bool in_line_comment = false; + bool in_block_comment = false; + + const auto flush = [&statements](QString& text) { + if (const QString statement = text.trimmed(); !statement.isEmpty()) { + statements.append(statement); + } + text.clear(); + }; + + for (qsizetype i = 0; i < script.size(); ++i) { + const QChar c = script[i]; + const QChar next = i + 1 < script.size() ? script[i + 1] : QChar(); + current.append(c); + if (in_line_comment) { + in_line_comment = c != u'\n'; + } else if (in_block_comment) { + if (c == u'*' && next == u'/') { + in_block_comment = false; + current.append(next); + ++i; + } + } else if (!quote.isNull()) { + if (c == quote) { + if (next == quote) { + current.append(next); + ++i; + } else { + quote = QChar(); + } + } + } else if (c == u'-' && next == u'-') { + in_line_comment = true; + } else if (c == u'/' && next == u'*') { + in_block_comment = true; + current.append(next); + ++i; + } else if (c == u'\'' || c == u'"' || c == u'`') { + quote = c; + } else if (c == u';') { + flush(current); + } + } + flush(current); + return statements; +} + +Result execute_script(QSqlDatabase& db, const QString& script) { + for (const auto& statement : split_statements(script)) { + if (auto applied = execute(db, statement); !applied) { + return applied; + } + } + return {}; +} + +Result user_version(QSqlDatabase& db) { + QSqlQuery query(db); + if (!query.exec(QStringLiteral("PRAGMA user_version"))) { + return std::unexpected(Error{query.lastError().text()}); + } + return query.next() ? query.value(0).toInt() : 0; +} + +// `path` with its last suffix replaced, as Python's Path.with_suffix does. +QString with_suffix(const QString& path, const QString& suffix) { + const QFileInfo info(path); + const QString name = info.fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + const QString stem = dot > 0 ? name.left(dot) : name; + return QDir(info.absolutePath()).filePath(stem + suffix); +} + +} // namespace + +Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int schema_version, + const MigrationSteps& steps, const std::optional& backup_path, + const QString& label) { + auto current_version = user_version(db); + if (!current_version) { + return std::unexpected(current_version.error()); + } + const int current = *current_version; + if (current >= schema_version) { + return {}; + } + + const auto stamp = [&db](int version) { + return execute(db, QStringLiteral("PRAGMA user_version = %1").arg(version)); + }; + + if (current == 0) { + spdlog::info("Initializing {} (version {})", label.toStdString(), schema_version); + if (auto initialized = execute_script(db, schema_sql); !initialized) { + return initialized; + } + return stamp(schema_version); + } + + spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, schema_version); + if (backup_path) { + // Fold the WAL into the main file so the copy below is complete. + if (auto checkpointed = execute(db, QStringLiteral("PRAGMA wal_checkpoint(FULL)")); !checkpointed) { + return checkpointed; + } + const QString backup = with_suffix( + *backup_path, QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); + if (auto copied = copy_file_replacing(*backup_path, backup); !copied) { + return copied; + } + } + + for (int target = current + 1; target <= schema_version; ++target) { + if (!steps.contains(target)) { + return std::unexpected( + Error{QStringLiteral("Missing migration step for target version %1").arg(target)}); + } + } + for (int target = current + 1; target <= schema_version; ++target) { + if (auto applied = steps.at(target)(); !applied) { + return applied; + } + if (auto stamped = stamp(target); !stamped) { + return stamped; + } + } + return {}; +} + +} // namespace px::core diff --git a/src/core/migrator.h b/src/core/migrator.h new file mode 100644 index 0000000..f5d8edf --- /dev/null +++ b/src/core/migrator.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "core/error.h" + +namespace px::core { + +// Applies one schema step, leaving the database at the step's version. +using MigrationStep = std::function()>; + +// Keyed by target schema version. +using MigrationSteps = std::map; + +// Brings the `PRAGMA user_version` of `db` up to `schema_version`. A fresh database +// (version 0) gets `schema_sql`; an older one runs the steps in order, and a file +// copy of `backup_path` is taken first. Runs synchronously: call it on the thread +// that owns `db`. +Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int schema_version, + const MigrationSteps& steps, const std::optional& backup_path = std::nullopt, + const QString& label = QStringLiteral("schema")); + +} // namespace px::core diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp new file mode 100644 index 0000000..af16577 --- /dev/null +++ b/src/core/mod_service.cpp @@ -0,0 +1,270 @@ +#include "core/mod_service.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "core/context.h" +#include "core/mods_config.h" +#include "core/services/diagnostics_service.h" +#include "core/services/mod_discovery.h" + +namespace px::core { + +namespace { + +QString config_folder_of(const CoreContext& ctx) { + return QString::fromStdString(ctx.config().paths.config_folder); +} + +} // namespace + +ModService::ModService(CoreContext& ctx, providers::ProviderList providers, + std::unique_ptr metadata_cache) + : ctx_(ctx), + providers_(std::move(providers)), + metadata_cache_(std::move(metadata_cache)), + startup_impact_(ctx) { + if (!metadata_cache_ && ctx.hasConfigService()) { + metadata_cache_ = std::make_unique( + services::metadata_cache_path(ctx.configService().directory())); + } +} + +void ModService::reset_providers(providers::ProviderList providers) { + providers_ = std::move(providers); +} + +providers::BaseModProvider* ModService::get_provider(const QString& provider_id) const { + for (const auto& provider : providers_) { + if (provider->provider_id() == provider_id) { + return provider.get(); + } + } + return nullptr; +} + +QHash ModService::provider_colors() const { + QHash colors; + for (const auto& provider : providers_) { + colors.insert(provider->provider_id(), provider->color()); + } + return colors; +} + +QCoro::Task> ModService::discover_one(providers::BaseModProvider& provider, + Profiler& timer, bool force_reparse) { + auto scope = timer(QStringLiteral("provider.%1").arg(provider.provider_id())); + co_return co_await provider.discover(ctx_.targetVersion(), &timer, metadata_cache_.get(), + force_reparse); +} + +QCoro::Task> ModService::discover(Profiler* timer, bool force_reparse) { + // Each provider profiles into its own tree, since their discoveries interleave. + std::unique_ptr own_timer; + if (!timer) { + own_timer = std::make_unique(); + timer = own_timer.get(); + } + auto scope = (*timer)(QStringLiteral("discover")); + + std::vector> child_timers; + std::vector>> tasks; + child_timers.reserve(providers_.size()); + tasks.reserve(providers_.size()); + for (const auto& provider : providers_) { + child_timers.push_back(std::make_unique()); + tasks.push_back(discover_one(*provider, *child_timers.back(), force_reparse)); + } + + providers::ModMap all_mods; + std::optional failure; + for (std::size_t i = 0; i < tasks.size(); ++i) { + auto result = co_await tasks[i]; + timer->adopt(*child_timers[i]); + if (!result) { + if (!failure) { + failure = result.error(); + } + continue; + } + for (auto it = result->cbegin(); it != result->cend(); ++it) { + all_mods.insert(it.key(), it.value()); + } + } + if (failure) { + co_return std::unexpected(*failure); + } + + if (metadata_cache_) { + auto prune = (*timer)(QStringLiteral("cache_prune")); + QStringList mod_paths; + for (const auto& mod : all_mods) { + if (mod->mod_path()) { + mod_paths.append(*mod->mod_path()); + } + } + const QStringList live_paths = co_await providers::runOnPool( + nullptr, [mod_paths] { return services::normalize_paths(mod_paths); }); + co_await metadata_cache_->prune_disappeared(QSet(live_paths.cbegin(), live_paths.cend())); + } + + { + auto resolve = (*timer)(QStringLiteral("resolve_active")); + const QStringList active = services::resolve_active_uuids(all_mods, config_folder_of(ctx_)); + ctx_.load(std::move(all_mods), active); + } + + if (own_timer) { + spdlog::debug("Profile report:\n{}", own_timer->render().toStdString()); + } + co_return {}; +} + +QCoro::Task> ModService::run_refresh_pipeline(const QString& label, bool force_reparse) { + Profiler timer; + auto scope = timer(QStringLiteral("mod_service.%1").arg(label)); + + Profiler discover_timer; + Profiler diagnostics_timer; + Profiler impact_timer; + auto discovery = discover(&discover_timer, force_reparse); + auto diagnostics = ctx_.diagnosticsService().initialize(&diagnostics_timer); + auto impact = startup_impact_.load(); + + const auto discovered = co_await discovery; + const auto initialized = co_await diagnostics; + const auto loaded = co_await impact; + timer.adopt(discover_timer); + timer.adopt(diagnostics_timer); + timer.adopt(impact_timer); + + if (!discovered) { + co_return std::unexpected(discovered.error()); + } + if (!initialized) { + co_return std::unexpected(initialized.error()); + } + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + ctx_.diagnosticsService().rebuild(ctx_.activeUuids(), &timer); + spdlog::debug("Profile report:\n{}", timer.render().toStdString()); + mods_changed.notify(std::monostate{}); + co_return {}; +} + +std::optional ModService::snapshots_dir() const { + if (!ctx_.hasConfigService()) { + return std::nullopt; + } + return QDir(ctx_.configService().directory()).filePath(QStringLiteral("snapshots")); +} + +void ModService::snapshot_current_config() { + const QString config_folder = config_folder_of(ctx_); + const auto snapshots = snapshots_dir(); + if (config_folder.isEmpty() || !snapshots) { + return; + } + const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); + if (!QFileInfo(config_path).isFile()) { + return; + } + if (auto created = create_snapshot(config_path, *snapshots, ctx_.config().max_snapshots); !created) { + spdlog::warn("Failed to snapshot ModsConfig.xml: {}", created.error().message.toStdString()); + } +} + +QCoro::Task> ModService::initialize() { + snapshot_current_config(); + co_return co_await run_refresh_pipeline(QStringLiteral("initialize"), false); +} + +QCoro::Task> ModService::reload(bool full) { + co_return co_await run_refresh_pipeline(QStringLiteral("reload"), full); +} + +QStringList ModService::get_snapshots() const { + const auto snapshots = snapshots_dir(); + return snapshots ? px::core::get_snapshots(*snapshots) : QStringList{}; +} + +QCoro::Task ModService::restore_snapshot(QString snapshot_path) { + const QString config_folder = config_folder_of(ctx_); + const auto snapshots = snapshots_dir(); + if (config_folder.isEmpty() || !snapshots) { + spdlog::warn("Cannot restore snapshot: configuration is incomplete"); + co_return false; + } + + const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); + auto restored = px::core::restore_snapshot(snapshot_path, config_path, *snapshots, + ctx_.config().max_snapshots); + if (!restored) { + spdlog::warn("Failed to restore snapshot: {}", restored.error().message.toStdString()); + co_return false; + } + auto reloaded = co_await reload(); + if (!reloaded) { + spdlog::error("Reload after snapshot restore failed: {}", + reloaded.error().message.toStdString()); + co_return false; + } + co_return true; +} + +QCoro::Task ModService::save_active_layout(QStringList active_ids) { + const QString config_folder = config_folder_of(ctx_); + const auto snapshots = snapshots_dir(); + if (config_folder.isEmpty() || !snapshots) { + co_return false; + } + + const auto& mods = ctx_.allMods(); + QList package_ids; + for (const QString& uuid : active_ids) { + if (auto about = std::dynamic_pointer_cast(mods.value(uuid))) { + package_ids.append(about->package_id); + } + } + const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); + const QString target_version = ctx_.targetVersion(); + const int max_snapshots = ctx_.config().max_snapshots; + const QString snapshots_path = *snapshots; + + auto written = co_await providers::runOnPool>( + nullptr, [this, config_path, snapshots_path, target_version, max_snapshots, + package_ids = std::move(package_ids)] { + QMutexLocker lock(&save_mutex_); + const auto existing = parse_mods_config(config_path); + ModsConfig data; + data.version = target_version; + data.activeMods = package_ids; + data.knownExpansions = existing ? existing->knownExpansions : QList{}; + return write_mods_config(config_path, data, snapshots_path, max_snapshots); + }); + if (!written) { + spdlog::warn("Failed to save ModsConfig.xml: {}", written.error().message.toStdString()); + co_return false; + } + co_return true; +} + +QCoro::Task ModService::close() { + if (metadata_cache_) { + co_await metadata_cache_->close(); + } +} + +void ModService::close_sync() { + // Destroying the cache closes its connection and joins its worker thread. + metadata_cache_.reset(); +} + +} // namespace px::core diff --git a/src/core/mod_service.h b/src/core/mod_service.h new file mode 100644 index 0000000..859c838 --- /dev/null +++ b/src/core/mod_service.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/error.h" +#include "core/events.h" +#include "core/profiler.h" +#include "core/providers/factory.h" +#include "core/services/metadata_cache.h" +#include "core/services/startup_impact_service/service.h" + +namespace px::core { + +class CoreContext; + +// Orchestrates mod discovery and lifecycle across a set of providers. +class ModService { +public: + // `ctx` is not owned and must outlive the service. Without a `metadata_cache`, one is created + // in the config folder when the context has a config service. + ModService(CoreContext& ctx, providers::ProviderList providers, + std::unique_ptr metadata_cache = nullptr); + + startup_impact::StartupImpactService& startup_impact() noexcept { return startup_impact_; } + + // Replaces the provider set. + void reset_providers(providers::ProviderList providers); + // The provider with `provider_id`, or nullptr. + providers::BaseModProvider* get_provider(const QString& provider_id) const; + QHash provider_colors() const; + + // Runs all providers in parallel and loads the results into the context. `timer` is optional; + // without one, the profile report goes to the debug log. + QCoro::Task> discover(Profiler* timer = nullptr, bool force_reparse = false); + + // One-time startup work: snapshot the mod list, discover, initialize diagnostics, load impact. + QCoro::Task> initialize(); + // Reparses About.xml files when `full` is set. + QCoro::Task> reload(bool full = false); + + // Available mod-list snapshots, newest first. + QStringList get_snapshots() const; + // Restores a snapshot to ModsConfig.xml and reloads live state. + QCoro::Task restore_snapshot(QString snapshot_path); + // Persists the given active UUIDs as ModsConfig.xml. + QCoro::Task save_active_layout(QStringList active_ids); + + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void close_sync(); + + Event mods_changed; + +private: + QCoro::Task> discover_one(providers::BaseModProvider& provider, + Profiler& timer, bool force_reparse); + QCoro::Task> run_refresh_pipeline(const QString& label, bool force_reparse); + void snapshot_current_config(); + std::optional snapshots_dir() const; + + CoreContext& ctx_; + providers::ProviderList providers_; + std::unique_ptr metadata_cache_; + startup_impact::StartupImpactService startup_impact_; + // Serializes ModsConfig.xml writes; they run on the thread pool. + mutable QMutex save_mutex_; +}; + +} // namespace px::core diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp new file mode 100644 index 0000000..5f3341e --- /dev/null +++ b/src/core/models/metadata/parsing.cpp @@ -0,0 +1,410 @@ +#include "core/models/metadata/parsing.h" + +#include + +#include +#include +#include + +#include "core/constants.h" +#include "core/utils.h" +#include "core/xml.h" + +namespace px::core { + +namespace { + +// Either the stripped text of an element or the stripped texts of its
  • children. +using ElementValue = std::variant; + +std::string utf8(const QString& text) { return text.toStdString(); } + +void setInvalid(ListedMod& mod, const QString& message) { + mod.valid = false; + spdlog::warn("{}", utf8(message)); +} + +CaseInsensitiveStr missingPackageId() { + return CaseInsensitiveStr(QString(DEFAULT_MISSING_PACKAGEID)); +} + +// Text or
  • list of an element; nullopt when the element is absent or empty. +std::optional elementValue(pugi::xml_node element) { + QStringList items; + bool hasItems = false; + for (pugi::xml_node li : element.children("li")) { + hasItems = true; + const QString text = rawText(li).trimmed(); + if (!text.isEmpty()) { + items.append(text); + } + } + if (hasItems) { + return ElementValue{items}; + } + const QString text = rawText(element).trimmed(); + if (!text.isEmpty()) { + return ElementValue{text}; + } + return std::nullopt; +} + +std::optional asText(const std::optional& value) { + if (value && std::holds_alternative(*value)) { + return std::get(*value); + } + return std::nullopt; +} + +std::optional asList(const std::optional& value) { + if (value && std::holds_alternative(*value)) { + return std::get(*value); + } + return std::nullopt; +} + +// The `` child matching the target game version, or a null node when none +// matches or the version has no major.minor part. +pugi::xml_node matchVersionedChild(pugi::xml_node parent, const QString& targetVersion) { + const QStringList parts = targetVersion.split(QLatin1Char('.')); + if (parts.size() < 2) { + return {}; + } + const QString majorMinor = parts[0] + QLatin1Char('.') + parts[1]; + const std::string prefixed = utf8(QLatin1Char('v') + majorMinor); + return parent.child(prefixed.c_str()); +} + +QString officialModName(const QString& packageId, const QString& name) { + if (!name.isEmpty() && name != QStringLiteral("Unknown Mod Name") && name != packageId) { + return name; + } + if (packageId == QLatin1StringView("ludeon.rimworld")) { + return QStringLiteral("Core"); + } + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId)) { + const QString dlcName(dlc->name); + const QString prefix = QStringLiteral("RimWorld - "); + return dlcName.startsWith(prefix) ? dlcName.mid(prefix.size()) : dlcName; + } + return packageId; +} + +DependencyMod dependencyFromLi(pugi::xml_node li) { + DependencyMod dep; + if (const auto packageId = childText(li, "packageId")) { + dep.package_id = CaseInsensitiveStr(*packageId); + } + if (const auto name = childText(li, "displayName")) { + dep.name = *name; + } + if (const auto url = childText(li, "workshopUrl")) { + dep.workshop_url = *url; + } + if (pugi::xml_node alternatives = li.child("alternativePackageIds")) { + for (pugi::xml_node alternative : alternatives.children("li")) { + const QString text = rawText(alternative).trimmed(); + if (!text.isEmpty()) { + dep.alternative_package_ids.add(text); + } + } + } + return dep; +} + +// lxml's `len(li)` counts comments as well as elements, but not text. +bool hasChildNodes(pugi::xml_node node) { + for (pugi::xml_node child = node.first_child(); child; child = child.next_sibling()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { + return true; + } + } + return false; +} + +std::shared_ptr aboutModWithoutXml() { + auto mod = std::make_shared(); + mod->valid = false; + return mod; +} + +template +Result> withModPath(std::shared_ptr mod, const QString& path) { + if (auto set = mod->set_mod_path(path); !set) { + return std::unexpected(set.error()); + } + return mod; +} + +Result invalidListedMod(const QString& path) { + auto mod = std::make_shared(); + mod->valid = false; + return withModPath(std::move(mod), path); +} + +// Mirrors the legacy `_create_about_mod_from_xml`: unreadable or malformed files still yield an +// invalid AboutXmlMod that carries the mod path, so the caller can cache and report it. +Result> createAboutModFromXml(const QString& basePath, + const QString& aboutXmlPath, + const QString& targetVersion, + bool preferVersioned) { + pugi::xml_document doc; + if (auto loaded = loadXmlFile(doc, aboutXmlPath); !loaded) { + spdlog::error("Unable to parse {}: {}", utf8(aboutXmlPath), utf8(loaded.error().message)); + return withModPath(aboutModWithoutXml(), basePath); + } + + auto mod = createAboutModFromElement(doc.document_element(), targetVersion, preferVersioned); + auto withPath = withModPath(std::move(mod), basePath); + if (!withPath) { + return withPath; + } + const QFileInfo info(aboutXmlPath); + (*withPath)->mtime = info.exists() ? info.lastModified().toMSecsSinceEpoch() / 1000.0 : 0.0; + return withPath; +} + +} // namespace + +std::shared_ptr createAboutModFromElement(pugi::xml_node root, + const QString& targetVersion, + bool preferVersioned) { + auto mod = std::make_shared(); + bool hasPackageId = false; + + pugi::xml_node depsEl; + bool depsByVersion = false; + QStringList loadBeforeLi; + QStringList loadAfterLi; + QStringList incompatLi; + QStringList forceBeforeLi; + QStringList forceAfterLi; + pugi::xml_node loadBeforeByVersionEl; + pugi::xml_node loadAfterByVersionEl; + pugi::xml_node incompatByVersionEl; + + for (pugi::xml_node child = root.first_child(); child; child = child.next_sibling()) { + if (child.type() != pugi::node_element) { + continue; + } + const std::string_view tag = child.name(); + + if (tag == "packageId") { + hasPackageId = true; + const auto text = asText(elementValue(child)); + if (text && !text->isEmpty()) { + mod->package_id = CaseInsensitiveStr(*text); + } else { + setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel " + "'%1'.") + .arg(QString(DEFAULT_MISSING_PACKAGEID))); + mod->package_id = missingPackageId(); + } + } else if (tag == "steamAppId") { + const auto text = asText(elementValue(child)); + if (text && isAsciiDigits(*text)) { + bool ok = false; + const qint64 appId = text->toLongLong(&ok); + if (ok) { + mod->steam_app_id = appId; + } + } + } else if (tag == "name") { + if (const auto text = asText(elementValue(child))) { + mod->name = *text; + } + } else if (tag == "description") { + if (const auto text = asText(elementValue(child))) { + mod->description = *text; + } + } else if (tag == "author") { + if (const auto text = asText(elementValue(child))) { + mod->authors.append(*text); + } + } else if (tag == "authors") { + if (const auto list = asList(elementValue(child))) { + mod->authors.append(*list); + } + } else if (tag == "supportedVersions") { + if (const auto list = asList(elementValue(child))) { + mod->supported_versions = QSet(list->cbegin(), list->cend()); + } + } else if (tag == "modVersion") { + if (const auto text = asText(elementValue(child))) { + mod->mod_version = *text; + } + } else if (tag == "modIconPath") { + if (const auto text = asText(elementValue(child))) { + mod->mod_icon_path = *text; + } + } else if (tag == "url") { + if (const auto text = asText(elementValue(child))) { + mod->url = *text; + } + } else if (tag == "modDependencies") { + if (!depsByVersion) { + depsEl = child; + } + } else if (tag == "modDependenciesByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { + depsEl = matched; + depsByVersion = true; + } + } + } else if (tag == "loadBefore") { + if (const auto list = asList(elementValue(child))) { + loadBeforeLi = *list; + } + } else if (tag == "loadBeforeByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { + loadBeforeByVersionEl = matched; + } + } + } else if (tag == "loadAfter") { + if (const auto list = asList(elementValue(child))) { + loadAfterLi = *list; + } + } else if (tag == "loadAfterByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { + loadAfterByVersionEl = matched; + } + } + } else if (tag == "forceLoadBefore") { + if (const auto list = asList(elementValue(child))) { + forceBeforeLi = *list; + } + } else if (tag == "forceLoadAfter") { + if (const auto list = asList(elementValue(child))) { + forceAfterLi = *list; + } + } else if (tag == "incompatibleWith") { + if (const auto list = asList(elementValue(child))) { + incompatLi = *list; + } + } else if (tag == "incompatibleWithByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { + incompatByVersionEl = matched; + } + } + } else if (tag == "descriptionsByVersion") { + if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { + const QString text = rawText(matched).trimmed(); + if (!text.isEmpty()) { + mod->description = text; + } + } + } + } + + if (!hasPackageId) { + setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel '%1'.") + .arg(QString(DEFAULT_MISSING_PACKAGEID))); + mod->package_id = missingPackageId(); + } + + // Versioned overrides replace the unversioned lists they shadow. + if (loadBeforeByVersionEl) { + if (const auto list = asList(elementValue(loadBeforeByVersionEl))) { + loadBeforeLi = *list; + } + } + if (loadAfterByVersionEl) { + if (const auto list = asList(elementValue(loadAfterByVersionEl))) { + loadAfterLi = *list; + } + } + if (incompatByVersionEl) { + if (const auto list = asList(elementValue(incompatByVersionEl))) { + incompatLi = *list; + } + } + + loadBeforeLi.append(forceBeforeLi); + loadAfterLi.append(forceAfterLi); + + BaseRules rules; + rules.load_before = CaseInsensitiveSet(loadBeforeLi); + rules.load_after = CaseInsensitiveSet(loadAfterLi); + rules.incompatible_with = CaseInsensitiveSet(incompatLi); + + if (depsEl) { + for (pugi::xml_node li = depsEl.first_child(); li; li = li.next_sibling()) { + if (li.type() != pugi::node_element || std::string_view(li.name()) != "li") { + continue; + } + if (std::string_view(li.attribute("isNull").value()) == "True") { + continue; + } + if (!li.first_attribute() && !hasChildNodes(li)) { + continue; + } + DependencyMod dep = dependencyFromLi(li); + if (rules.find_dependency(dep.package_id)) { + spdlog::warn("Duplicate dependency found: {}. Skipping.", + utf8(dep.package_id.str())); + } else { + rules.dependencies.append(std::move(dep)); + } + } + } + + const QString packageId = mod->package_id.str(); + mod->name = officialModName(packageId, mod->name); + const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId); + if (dlc && mod->description.isEmpty()) { + mod->description = QString(dlc->description); + } + // DLC cannot load without the base game, so it always depends on RimWorld. + if (dlc && QLatin1StringView(dlc->appId) != RIMWORLD_STEAM_APP_ID) { + const CaseInsensitiveStr rimworldPid(QStringLiteral("ludeon.rimworld")); + if (!rules.has_dependency(rimworldPid)) { + DependencyMod rimworld; + rimworld.name = QStringLiteral("RimWorld"); + rimworld.package_id = rimworldPid; + rimworld.workshop_url = QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); + rules.dependencies.append(std::move(rimworld)); + } + } + + mod->about_rules = std::move(rules); + return mod; +} + +Result createListedModFromPath(const QString& path, const QString& targetVersion, + bool preferVersioned, + std::optional aboutXmlPath) { + if (!QFileInfo(path).isDir()) { + return std::unexpected(Error{QStringLiteral("Path must be a directory: %1").arg(path)}); + } + if (!aboutXmlPath) { + aboutXmlPath = findAboutXml(path); + } + if (aboutXmlPath) { + auto mod = createAboutModFromXml(path, *aboutXmlPath, targetVersion, preferVersioned); + if (!mod) { + return std::unexpected(mod.error()); + } + return ListedModPtr(std::move(*mod)); + } + + const QStringList rscFiles = QDir(path).entryList( + {QStringLiteral("*.rsc")}, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::CaseSensitive); + if (rscFiles.size() > 1) { + spdlog::warn("Multiple .rsc files found in {}. Aborting.", utf8(path)); + return invalidListedMod(path); + } + if (rscFiles.size() == 1) { + // Scenario .rsc files are not parsed yet. + spdlog::warn("Scenario .rsc files not yet supported: {}", utf8(path)); + return invalidListedMod(path); + } + spdlog::warn("No About.xml found in directory: {}", utf8(path)); + return invalidListedMod(path); +} + +} // namespace px::core diff --git a/src/core/models/metadata/parsing.h b/src/core/models/metadata/parsing.h new file mode 100644 index 0000000..a7b7562 --- /dev/null +++ b/src/core/models/metadata/parsing.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include +#include + +#include "core/error.h" +#include "core/models/metadata/structures.h" + +namespace px::core { + +// Builds a mod for the directory `path`, parsing its About.xml (found automatically, or the +// explicit `aboutXmlPath`). Returns Err only when `path` is not a directory. A mod whose metadata +// is unusable is still returned, with `valid == false`. +// +// `targetVersion` selects `*ByVersion` entries: "1.5" matches ``. When +// `preferVersioned` is false those entries are ignored. +Result createListedModFromPath(const QString& path, const QString& targetVersion, + bool preferVersioned = true, + std::optional aboutXmlPath = std::nullopt); + +// Builds an AboutXmlMod from the `` root element of an About.xml. The result's +// `valid` flag reports whether the metadata is usable; no mod path is set. +std::shared_ptr createAboutModFromElement(pugi::xml_node root, + const QString& targetVersion, + bool preferVersioned = true); + +} // namespace px::core diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp new file mode 100644 index 0000000..a4f36d7 --- /dev/null +++ b/src/core/models/metadata/structures.cpp @@ -0,0 +1,221 @@ +#include "core/models/metadata/structures.h" + +#include +#include +#include +#include + +#include "core/constants.h" +#include "core/utils.h" + +namespace px::core { + +namespace { + +// Python's Path.stem / Path.suffix rule: a leading dot does not start an extension. +qsizetype lastExtensionDot(const QString& name) { + const qsizetype dot = name.lastIndexOf(QLatin1Char('.')); + return (dot > 0 && dot < name.size() - 1) ? dot : -1; +} + +QString pathStem(const QString& name) { + const qsizetype dot = lastExtensionDot(name); + return dot < 0 ? name : name.left(dot); +} + +QString pathSuffix(const QString& name) { + const qsizetype dot = lastExtensionDot(name); + return dot < 0 ? QString() : name.mid(dot); +} + +bool hasEntries(const QString& dirPath) { + return !QDir(dirPath) + .entryList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot) + .isEmpty(); +} + +} // namespace + +CaseInsensitiveSet::CaseInsensitiveSet(const QStringList& values) { + for (const QString& value : values) { + data_.insert(CaseInsensitiveStr(value)); + } +} + +CaseInsensitiveSet::CaseInsensitiveSet(const QList& values) { + for (const CaseInsensitiveStr& value : values) { + data_.insert(value); + } +} + +bool CaseInsensitiveSet::contains(QStringView value) const { + return data_.contains(CaseInsensitiveStr(value)); +} + +bool CaseInsensitiveSet::contains(const CaseInsensitiveStr& value) const { + return data_.contains(value); +} + +void CaseInsensitiveSet::add(QStringView value) { data_.insert(CaseInsensitiveStr(value)); } + +void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { data_.insert(value); } + +void CaseInsensitiveSet::discard(QStringView value) { data_.remove(CaseInsensitiveStr(value)); } + +void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { data_.remove(value); } + +CaseInsensitiveSet CaseInsensitiveSet::operator|(const CaseInsensitiveSet& other) const { + CaseInsensitiveSet result = *this; + result.data_.unite(other.data_); + return result; +} + +bool ModsConfig::check_active_duplicates() const { + const QSet unique(activeMods.cbegin(), activeMods.cend()); + return activeMods.size() != unique.size(); +} + +bool ModsConfig::check_expansions_duplicates() const { + const QSet unique(knownExpansions.cbegin(), knownExpansions.cend()); + return knownExpansions.size() != unique.size(); +} + +const DependencyMod* BaseRules::find_dependency(const CaseInsensitiveStr& package_id) const { + for (const DependencyMod& dependency : dependencies) { + if (dependency.package_id == package_id) { + return &dependency; + } + } + return nullptr; +} + +void BaseRules::set_dependency(DependencyMod dependency) { + for (DependencyMod& existing : dependencies) { + if (existing.package_id == dependency.package_id) { + existing = std::move(dependency); + return; + } + } + dependencies.append(std::move(dependency)); +} + +QString ListedMod::newUuid() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } + +QString ListedMod::uuid() const { + if (mod_path_) { + return *mod_path_; + } + return uuid_; +} + +Result ListedMod::set_mod_path(const QString& path) { + if (mod_path_) { + return std::unexpected(Error{QStringLiteral("Mod path already set. Cannot override.")}); + } + mod_path_ = path; + published_file_id_cache_.reset(); + return {}; +} + +std::optional ListedMod::mod_folder() const { + if (!mod_path_) { + return std::nullopt; + } + return pathStem(QFileInfo(QDir::cleanPath(*mod_path_)).fileName()); +} + +qint64 ListedMod::internal_time_touched() const { + if (!mod_path_ || !QFileInfo::exists(*mod_path_)) { + return -1; + } + return QFileInfo(*mod_path_).lastModified().toSecsSinceEpoch(); +} + +std::optional ListedMod::published_file_id() const { + if (!published_file_id_cache_) { + published_file_id_cache_ = [this]() -> std::optional { + if (!mod_path_) { + return std::nullopt; + } + const QString pfidPath = + QDir(*mod_path_).filePath(QStringLiteral("About/PublishedFileId.txt")); + if (QFileInfo::exists(pfidPath)) { + QFile file(pfidPath); + if (!file.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + // utf-8-sig: drop a leading byte-order mark before trimming. + QString content = QString::fromUtf8(file.readAll()); + if (content.startsWith(QChar(0xFEFF))) { + content.remove(0, 1); + } + content = content.trimmed(); + if (isAsciiDigits(content)) { + return content; + } + } + const std::optional folder = mod_folder(); + if (folder && isAsciiDigits(*folder)) { + return folder; + } + return std::nullopt; + }(); + } + return *published_file_id_cache_; +} + +bool ListedMod::c_sharp_mod() const { + if (!c_sharp_mod_cache_) { + c_sharp_mod_cache_ = [this] { + if (!mod_path_) { + return false; + } + const QString assemblies = QDir(*mod_path_).filePath(QStringLiteral("Assemblies")); + return QFileInfo(assemblies).isDir() && hasEntries(assemblies); + }(); + } + return *c_sharp_mod_cache_; +} + +bool ListedMod::xml_patch_mod() const { + if (!xml_patch_mod_cache_) { + xml_patch_mod_cache_ = [this] { + if (!mod_path_) { + return false; + } + const QString patches = QDir(*mod_path_).filePath(QStringLiteral("Patches")); + if (!QFileInfo(patches).isDir()) { + return false; + } + const QFileInfoList entries = QDir(patches).entryInfoList( + QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (pathSuffix(entry.fileName()) == QStringLiteral(".xml")) { + return true; + } + } + return false; + }(); + } + return *xml_patch_mod_cache_; +} + +std::optional ListedMod::preview_img_path() const { + if (!mod_path_) { + return std::nullopt; + } + const QString candidate = QDir(*mod_path_).filePath(QStringLiteral("About/Preview.png")); + if (QFileInfo::exists(candidate)) { + return candidate; + } + return std::nullopt; +} + +std::optional AboutXmlMod::get_dlc_name() const { + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(package_id.str())) { + return QString(dlc->name); + } + return std::nullopt; +} + +} // namespace px::core diff --git a/src/core/models/metadata/structures.h b/src/core/models/metadata/structures.h new file mode 100644 index 0000000..ec919ea --- /dev/null +++ b/src/core/models/metadata/structures.h @@ -0,0 +1,288 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/error.h" + +namespace px::core { + +// Package id that compares and hashes without regard to case. Stored lowercased. +class CaseInsensitiveStr { +public: + CaseInsensitiveStr() = default; + explicit CaseInsensitiveStr(QStringView pid) : value_(pid.toString().toLower()) {} + + const QString& str() const noexcept { return value_; } + + friend bool operator==(const CaseInsensitiveStr&, const CaseInsensitiveStr&) = default; + friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { + return qHash(key.value_, seed); + } + +private: + QString value_; +}; + +// Set of CaseInsensitiveStr with string-friendly membership tests. +class CaseInsensitiveSet { +public: + CaseInsensitiveSet() = default; + explicit CaseInsensitiveSet(const QStringList& values); + explicit CaseInsensitiveSet(const QList& values); + + bool contains(QStringView value) const; + bool contains(const CaseInsensitiveStr& value) const; + void add(QStringView value); + void add(const CaseInsensitiveStr& value); + void discard(QStringView value); + void discard(const CaseInsensitiveStr& value); + + qsizetype size() const noexcept { return data_.size(); } + bool empty() const noexcept { return data_.isEmpty(); } + + // Set union; the result keeps the elements of both operands. + CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; + + auto begin() const { return data_.cbegin(); } + auto end() const { return data_.cend(); } + + friend bool operator==(const CaseInsensitiveSet&, const CaseInsensitiveSet&) = default; + +private: + QSet data_; +}; + +// Parsed ModsConfig.xml data with active mods and known expansions. +struct ModsConfig { + QString version; + QList activeMods; + QList knownExpansions; + + bool check_active_duplicates() const; + bool check_expansions_duplicates() const; + void clear_active_mods() { activeMods.clear(); } + void clear_all() { + activeMods.clear(); + knownExpansions.clear(); + } +}; + +struct ReplacementInfo { + QString name; + QString author; + QString packageid; + QString pfid; + QStringList supportedversions; + QString source = QStringLiteral("database"); +}; + +// Dependency declared by a mod's About.xml (or synthesized, e.g. the RimWorld base game for DLC). +struct DependencyMod { + QString name; + CaseInsensitiveStr package_id{QStringLiteral("invalid.mod")}; + QString workshop_url; + CaseInsensitiveSet alternative_package_ids; +}; + +// Load-order rules and dependencies. Dependencies keep declaration order. +struct BaseRules { + CaseInsensitiveSet load_after; + CaseInsensitiveSet load_before; + CaseInsensitiveSet incompatible_with; + QList dependencies; + + const DependencyMod* find_dependency(const CaseInsensitiveStr& package_id) const; + bool has_dependency(const CaseInsensitiveStr& package_id) const { + return find_dependency(package_id) != nullptr; + } + // Inserts, or replaces the dependency with the same package id in place. + void set_dependency(DependencyMod dependency); +}; + +// Mod discovered by any provider. Polymorphic so providers can hand out AboutXmlMod (and +// ScenarioMod) through a shared base pointer. +class ListedMod { +public: + ListedMod() = default; + ListedMod(const ListedMod&) = default; + ListedMod& operator=(const ListedMod&) = default; + virtual ~ListedMod() = default; + + QString name = QStringLiteral("Unknown Mod Name"); + bool valid = true; + QSet supported_versions; + QString description = QStringLiteral( + "This mod is considered invalid by PxModRim (and the RimWorld game).\n\n" + "This mod does NOT contain an ./About/About.xml and is likely leftover from previous " + "usage."); + QString provider_id; + bool obsolete = false; + bool db_builder_no_name = false; + + // Identity: the mod path once it is known, otherwise a random id assigned at construction. + virtual QString uuid() const; + + const std::optional& mod_path() const noexcept { return mod_path_; } + // Fails if a path was already set; a mod's directory is fixed after discovery. + Result set_mod_path(const QString& path); + + // Folder name without its last extension (Python's Path.stem); nullopt without a path. + std::optional mod_folder() const; + // Modification time of the mod folder in seconds since the epoch, or -1 if unavailable. + qint64 internal_time_touched() const; + + // Published file id from About/PublishedFileId.txt or a numeric folder name. Cached on first + // use; this touches the disk, so callers may warm it off the UI thread. + std::optional published_file_id() const; + bool c_sharp_mod() const; + bool xml_patch_mod() const; + std::optional preview_img_path() const; + +private: + static QString newUuid(); + + QString uuid_ = newUuid(); + std::optional mod_path_; + mutable std::optional> published_file_id_cache_; + mutable std::optional c_sharp_mod_cache_; + mutable std::optional xml_patch_mod_cache_; +}; + +class ScenarioMod : public ListedMod { +public: + QString summary; +}; + +// A mod with an About.xml: authors, version, rules, and DLC metadata. +class AboutXmlMod : public ListedMod { +public: + CaseInsensitiveStr package_id{QStringLiteral("invalid.mod")}; + QStringList authors; + QString mod_version; + std::optional mod_icon_path; + qint64 steam_app_id = -1; + QString url; + double mtime = 0.0; + BaseRules about_rules; + + // Display name of the RimWorld DLC this package id belongs to, if any. + std::optional get_dlc_name() const; +}; + +using ListedModPtr = std::shared_ptr; + +// Pre-computed dependency graphs and tier classification for sorting. +struct CompiledDependencyData { + QHash> deps_graph; + QHash> rev_deps_graph; + QSet tier_zero_mods; + QSet tier_one_mods; + QSet tier_three_mods; + QHash> incompatibilities; + QHash> declared_incompatibilities; +}; + +// Glaze schemas for community rules and the Steam Workshop database. Fields the upstream JSON +// may write either as a single value or as a list use std::variant. + +struct SubExternalRule { + std::variant, std::string> name = std::string{}; + std::variant, std::string> comment = std::string{}; +}; + +struct SubExternalBoolRule { + bool value = false; + std::variant, std::string> comment = std::string{}; +}; + +struct ExternalRule { + std::map loadAfter; + std::map loadBefore; + SubExternalBoolRule loadTop; + SubExternalBoolRule loadBottom; +}; + +struct ExternalRulesSchema { + std::int64_t timestamp = 0; + std::map rules; +}; + +struct SteamDbEntryDependency { + std::string name; + std::string url; +}; + +struct SteamDbEntryBlacklist { + bool value = false; + std::string comment; +}; + +struct SteamDbEntry { + bool unpublished = false; + std::string url; + std::string packageId; + std::variant>, std::string, std::nullptr_t> + gameVersions = std::vector>{}; + std::string steamName; + std::string name; + std::variant, std::string, std::nullptr_t> authors = std::string{}; + std::map, SteamDbEntryDependency>> + dependencies; + SteamDbEntryBlacklist blacklist; + std::vector> tags; +}; + +struct SteamDbSchema { + std::int64_t version = 0; + std::map database; +}; + +enum class WorkshopUpdateStatus { + Success, + NoWorkshopMods, + Partial, + Failed, +}; + +struct WorkshopUpdateResult { + WorkshopUpdateStatus status = WorkshopUpdateStatus::Failed; + int mods_checked = 0; + int mods_updated = 0; + QStringList failed_pfids; + QStringList errors; +}; + +} // namespace px::core + +// Package ids are JSON strings on the wire. +template <> +struct glz::to { + template + static void op(const px::core::CaseInsensitiveStr& value, auto&& ctx, auto&& b, auto&& ix) { + glz::to::template op(value.str().toStdString(), ctx, b, ix); + } +}; + +template <> +struct glz::from { + template + static void op(px::core::CaseInsensitiveStr& value, auto&& ctx, auto&& it, auto&& end) { + std::string text; + glz::from::template op(text, ctx, it, end); + value = px::core::CaseInsensitiveStr(QString::fromStdString(text)); + } +}; diff --git a/src/core/models/view/diagnostics.h b/src/core/models/view/diagnostics.h new file mode 100644 index 0000000..ef29b9f --- /dev/null +++ b/src/core/models/view/diagnostics.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +namespace px::core::view { + +// UI-agnostic diagnostics summary for one mod. +struct ModDiagnosticsView { + bool has_errors = false; + bool has_warnings = false; + QString error_tooltip; + QString warning_tooltip; + + bool operator==(const ModDiagnosticsView&) const = default; +}; + +struct ModIssueView { + QString category; + QString category_display_name; + std::optional detail; + bool is_error = false; + + bool operator==(const ModIssueView&) const = default; +}; + +} // namespace px::core::view diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp new file mode 100644 index 0000000..a9c06b2 --- /dev/null +++ b/src/core/models/view/sidebar.cpp @@ -0,0 +1,17 @@ +#include "core/models/view/sidebar.h" + +#include + +namespace px::core::view { + +QString provider_label(const QString& provider_id) { + static const QHash labels{ + {QStringLiteral("local"), QStringLiteral("Local")}, + {QStringLiteral("steam"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("downloaded"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("core"), QStringLiteral("System / Core")}, + }; + return labels.value(provider_id, provider_id); +} + +} // namespace px::core::view diff --git a/src/core/models/view/sidebar.h b/src/core/models/view/sidebar.h new file mode 100644 index 0000000..bf34381 --- /dev/null +++ b/src/core/models/view/sidebar.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include + +namespace px::core::view { + +// Display name of a mod provider; an unknown provider is shown by its id. +QString provider_label(const QString& provider_id); + +// A sidebar filter and the mods it shows. `count` is derived from `visible_uuids`. +struct SidebarEntry { + enum class Kind : std::uint8_t { All, Active, Inactive, WithErrors, WithWarnings, Provider }; + + Kind kind = Kind::All; + QString label; + QSet visible_uuids; + // Only for Kind::Provider: two providers can share a label, so the id tells them apart. + QString provider_id; + + int count() const noexcept { return static_cast(visible_uuids.size()); } +}; + +} // namespace px::core::view diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp new file mode 100644 index 0000000..01be8b8 --- /dev/null +++ b/src/core/mods_config.cpp @@ -0,0 +1,291 @@ +#include "core/mods_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/constants.h" +#include "core/file_io.h" +#include "core/xml.h" + +namespace px::core { + +namespace { + +constexpr auto kRootTag = "ModsConfigData"; +constexpr auto kDefaultVersion = "1.5"; +constexpr unsigned kParseFlags = pugi::parse_default | pugi::parse_comments | pugi::parse_pi; + +std::filesystem::path to_fs(const QString& path) { return std::filesystem::path(path.toStdU16String()); } + +QString from_fs(const std::filesystem::path& path) { return QString::fromStdU16String(path.u16string()); } + +// Packages listed in RIMWORLD_DLC_METADATA; used when a file declares no known expansions. +QList dlc_package_ids() { + QList ids; + for (const auto& id : RIMWORLD_PACKAGE_IDS) { + ids.append(CaseInsensitiveStr(id)); + } + return ids; +} + +std::optional parse_mods_config_bytes(std::string_view data, const QString& source) { + pugi::xml_document doc; + if (const auto result = doc.load_buffer(data.data(), data.size(), kParseFlags); !result) { + spdlog::error("Failed to parse ModsConfig.xml at {}: {}", source.toStdString(), result.description()); + return std::nullopt; + } + + const pugi::xml_node root = doc.document_element(); + const pugi::xml_node version_node = root.child("version"); + const pugi::xml_node active_mods_node = root.child("activeMods"); + if (std::string_view(root.name()) != kRootTag || !version_node || !active_mods_node) { + spdlog::error("Invalid ModsConfig.xml structure at {}", source.toStdString()); + return std::nullopt; + } + + // lxml's `text or "1.5"`: only a missing or empty text falls back to the default. + const QString raw_version = rawText(version_node); + ModsConfig config; + config.version = raw_version.isEmpty() ? QString::fromLatin1(kDefaultVersion) : raw_version.trimmed(); + + for (const pugi::xml_node li : active_mods_node.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.activeMods.append(CaseInsensitiveStr(text)); + } + } + for (const pugi::xml_node expansions : root.children("knownExpansions")) { + for (const pugi::xml_node li : expansions.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.knownExpansions.append(CaseInsensitiveStr(text)); + } + } + } + if (config.knownExpansions.isEmpty()) { + config.knownExpansions = dlc_package_ids(); + } + return config; +} + +struct SnapshotFile { + QString path; + std::int64_t mtime_ns = 0; + QString name; +}; + +// Snapshots ordered by (mtime, name), newest first. Nanosecond mtimes match the +// legacy ordering; names break ties because they embed the creation time. +QStringList snapshot_files(const QString& snapshots_dir) { + std::vector found; + std::error_code ec; + std::filesystem::directory_iterator it(to_fs(snapshots_dir), ec); + const std::filesystem::directory_iterator end; + for (; !ec && it != end; it.increment(ec)) { + const auto& entry = *it; + const QString name = from_fs(entry.path().filename()); + std::error_code entry_ec; + if (!QDir::match(QStringLiteral("ModsConfig_*.xml"), name) || + !std::filesystem::is_regular_file(entry.path(), entry_ec)) { + continue; + } + const auto mtime = std::filesystem::last_write_time(entry.path(), entry_ec); + if (entry_ec) { + continue; + } + found.push_back(SnapshotFile{ + .path = from_fs(entry.path()), + .mtime_ns = std::chrono::duration_cast(mtime.time_since_epoch()).count(), + .name = name, + }); + } + + std::ranges::sort(found, [](const SnapshotFile& a, const SnapshotFile& b) { + return std::tie(a.mtime_ns, a.name) > std::tie(b.mtime_ns, b.name); + }); + QStringList paths; + for (const auto& snapshot : found) { + paths.append(snapshot.path); + } + return paths; +} + +// Removes the oldest snapshots beyond `max_snapshots` (at least one is always kept), +// never removing `preserved`. +void prune_snapshots(const QString& snapshots_dir, int max_snapshots, const QString& preserved = {}) { + const QStringList snapshots = get_snapshots(snapshots_dir); + qsizetype remaining = snapshots.size() - std::max(1, max_snapshots); + for (auto it = snapshots.crbegin(); it != snapshots.crend() && remaining > 0; ++it) { + if (!preserved.isEmpty() && QDir::cleanPath(*it) == QDir::cleanPath(preserved)) { + continue; + } + --remaining; + if (!QFile::remove(*it)) { + spdlog::warn("Failed to remove old snapshot {}", it->toStdString()); + } + } +} + +// UTC timestamp with microseconds, e.g. 20260922_100000_123456. +std::string snapshot_timestamp() { + using namespace std::chrono; + const auto micros = duration_cast(system_clock::now().time_since_epoch()); + const auto seconds = floor(micros); + const auto fraction = (micros - seconds).count(); + return std::format("{:%Y%m%d_%H%M%S}_{:06}", sys_seconds{seconds}, fraction); +} + +// Replaces the children named `name` with one `li` per item. +void replace_list_items(pugi::xml_node root, const char* name, const QList& items) { + pugi::xml_node node = root.child(name); + if (!node) { + node = root.append_child(name); + } + for (pugi::xml_node li = node.child("li"); li;) { + const pugi::xml_node next = li.next_sibling("li"); + node.remove_child(li); + li = next; + } + for (const auto& item : items) { + node.append_child("li").text().set(item.str().toUtf8().constData()); + } +} + +} // namespace + +std::optional parse_mods_config(const QString& path) { + auto data = read_file_bytes(path); + if (!data) { + spdlog::warn("Failed to read ModsConfig.xml at {}: {}", path.toStdString(), + data.error().message.toStdString()); + return std::nullopt; + } + return parse_mods_config_bytes(*data, path); +} + +QStringList get_snapshots(const QString& snapshots_dir) { + if (!QFileInfo::exists(snapshots_dir)) { + return {}; + } + return snapshot_files(snapshots_dir); +} + +Result> create_snapshot(const QString& config_path, const QString& snapshots_dir, + int max_snapshots) { + if (!QFileInfo(config_path).isFile()) { + return std::optional{}; + } + auto config_data = read_file_bytes(config_path); + if (!config_data) { + spdlog::warn("Failed to read {} for snapshot: {}", config_path.toStdString(), + config_data.error().message.toStdString()); + return std::optional{}; + } + + const QStringList snapshots = get_snapshots(snapshots_dir); + if (!snapshots.isEmpty()) { + if (auto newest = read_file_bytes(snapshots.first()); newest && *newest == *config_data) { + prune_snapshots(snapshots_dir, max_snapshots); + return std::optional(snapshots.first()); + } + } + + const QString timestamp = QString::fromStdString(snapshot_timestamp()); + QString snapshot_path = QDir(snapshots_dir).filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); + for (int counter = 1; QFileInfo::exists(snapshot_path); ++counter) { + snapshot_path = QDir(snapshots_dir) + .filePath(QStringLiteral("ModsConfig_%1_%2.xml") + .arg(timestamp) + .arg(counter, 3, 10, QLatin1Char('0'))); + } + + if (auto written = write_file_atomically(snapshot_path, *config_data); !written) { + return std::unexpected(written.error()); + } + prune_snapshots(snapshots_dir, max_snapshots); + spdlog::info("Snapshot created at {}", snapshot_path.toStdString()); + return std::optional(snapshot_path); +} + +Result restore_snapshot(const QString& snapshot_path, const QString& target_path, + const QString& snapshots_dir, int max_snapshots) { + auto snapshot_data = read_file_bytes(snapshot_path); + if (!snapshot_data) { + return std::unexpected(Error{ + QStringLiteral("Failed to read snapshot %1: %2").arg(snapshot_path, snapshot_data.error().message)}); + } + if (!parse_mods_config_bytes(*snapshot_data, snapshot_path)) { + return std::unexpected(Error{QStringLiteral("Snapshot is invalid or corrupted: %1").arg(snapshot_path)}); + } + + if (QFileInfo::exists(target_path)) { + const int limit = std::max(1, max_snapshots); + if (auto kept = create_snapshot(target_path, snapshots_dir, limit + 1); !kept) { + return std::unexpected(kept.error()); + } + prune_snapshots(snapshots_dir, limit, snapshot_path); + } + if (auto written = write_file_atomically(target_path, *snapshot_data); !written) { + return std::unexpected(written.error()); + } + spdlog::info("Restored snapshot {} to {}", snapshot_path.toStdString(), target_path.toStdString()); + return {}; +} + +Result write_mods_config(const QString& path, const ModsConfig& data, const QString& snapshots_dir, + int max_snapshots) { + // An existing ModsConfigData document is updated in place so unknown XML survives; + // anything else starts a fresh document. + pugi::xml_document doc; + bool reusable = false; + if (auto existing = read_file_bytes(path); existing) { + reusable = doc.load_buffer(existing->data(), existing->size(), kParseFlags) && + std::string_view(doc.document_element().name()) == kRootTag; + } + if (!reusable) { + doc.reset(); + doc.append_child(kRootTag); + } + pugi::xml_node root = doc.document_element(); + + pugi::xml_node version_node = root.child("version"); + if (!version_node) { + version_node = root.append_child("version"); + } + version_node.text().set(data.version.toUtf8().constData()); + replace_list_items(root, "activeMods", data.activeMods); + replace_list_items(root, "knownExpansions", data.knownExpansions); + + pugi::xml_node declaration = doc.prepend_child(pugi::node_declaration); + declaration.append_attribute("version") = "1.0"; + declaration.append_attribute("encoding") = "utf-8"; + + std::ostringstream out; + doc.save(out, " ", pugi::format_default, pugi::encoding_utf8); + + if (QFileInfo::exists(path)) { + if (auto kept = create_snapshot(path, snapshots_dir, max_snapshots); !kept) { + return std::unexpected(kept.error()); + } + } + if (auto written = write_file_atomically(path, out.str()); !written) { + return std::unexpected(written.error()); + } + spdlog::info("ModsConfig.xml written to {}", path.toStdString()); + return {}; +} + +} // namespace px::core diff --git a/src/core/mods_config.h b/src/core/mods_config.h new file mode 100644 index 0000000..26b57bd --- /dev/null +++ b/src/core/mods_config.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include + +#include "core/error.h" +#include "core/models/metadata/structures.h" + +namespace px::core { + +// Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not a ModsConfig document. +std::optional parse_mods_config(const QString& path); + +// Snapshot files in `snapshots_dir`, newest first. +QStringList get_snapshots(const QString& snapshots_dir); + +// Keeps a copy of `config_path` in `snapshots_dir`. Yields the snapshot that holds the +// current contents (an existing identical one, or a new one), or nullopt when `config_path` +// is not a readable file. +Result> create_snapshot(const QString& config_path, const QString& snapshots_dir, + int max_snapshots = 10); + +// Replaces `target_path` with a valid snapshot, keeping the replaced file as a snapshot. +Result restore_snapshot(const QString& snapshot_path, const QString& target_path, + const QString& snapshots_dir, int max_snapshots = 10); + +// Writes `data` into `path`, keeping unknown XML in an existing ModsConfigData document. +// The previous file is kept as a snapshot first. +Result write_mods_config(const QString& path, const ModsConfig& data, const QString& snapshots_dir, + int max_snapshots = 10); + +} // namespace px::core diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp new file mode 100644 index 0000000..b978d5a --- /dev/null +++ b/src/core/organizer/db.cpp @@ -0,0 +1,627 @@ +#include "core/organizer/db.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/migrator.h" + +namespace px::core::organizer { + +namespace { + +// Byte-for-byte the legacy schema: existing user databases carry this exact version-1 layout. +const QString kSchema = QStringLiteral(R"sql( +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, + collapsed INTEGER NOT NULL DEFAULT 0, + CHECK ((id = 1) = (parent_id IS NULL)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_parent_name + ON folders (parent_id, name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS placements ( + package_id TEXT PRIMARY KEY COLLATE NOCASE, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mod_tags ( + package_id TEXT NOT NULL COLLATE NOCASE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (package_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + position INTEGER NOT NULL, + field TEXT NOT NULL CHECK (field IN ('package_id', 'name', 'author')), + op TEXT NOT NULL CHECK (op IN ('prefix', 'contains', 'equals')), + pattern TEXT NOT NULL, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO folders (id, parent_id, name, collapsed) +VALUES (1, NULL, 'Root', 0); +)sql"); + +constexpr int kSchemaVersion = 1; + +const QString kPlaceSql = QStringLiteral( + "INSERT INTO placements (package_id, folder_id) VALUES (?, ?) " + "ON CONFLICT (package_id) DO UPDATE SET folder_id = excluded.folder_id"); +const QString kInsertRuleSql = QStringLiteral( + "INSERT INTO rules (position, field, op, pattern, folder_id) VALUES (?, ?, ?, ?, ?)"); +// Re-parents placements of the whole subtree to the root before the folders are deleted. +const QString kMoveSubtreeSql = QStringLiteral( + "WITH RECURSIVE subtree(id) AS (" + " SELECT ?" + " UNION ALL" + " SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id" + ") " + "UPDATE placements SET folder_id = ? WHERE folder_id IN (SELECT id FROM subtree)"); + +Error error_of(const QSqlQuery& query) { + return Error{query.lastError().text()}; +} + +Result run_statement(QSqlDatabase& db, const QString& sql, const QVariantList& params = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(error_of(query)); + } + for (qsizetype i = 0; i < params.size(); ++i) { + query.bindValue(static_cast(i), params[i]); + } + if (!query.exec()) { + return std::unexpected(error_of(query)); + } + return query; +} + +// Prepares `sql` once and runs it for each row. Positional values are rebound every time. +Result run_batch(QSqlDatabase& db, const QString& sql, const QList& rows) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(error_of(query)); + } + for (const QVariantList& row : rows) { + for (qsizetype i = 0; i < row.size(); ++i) { + query.bindValue(static_cast(i), row[i]); + } + if (!query.exec()) { + return std::unexpected(error_of(query)); + } + } + return {}; +} + +// Visits each row of a SELECT. A failure part-way through is an error, not the end of the rows. +template +Result select_rows(QSqlDatabase& db, const QString& sql, const QVariantList& params, Visit&& visit) { + auto query = run_statement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + while (query->next()) { + visit(*query); + } + if (query->lastError().isValid()) { + return std::unexpected(error_of(*query)); + } + return {}; +} + +// Runs `body` inside one transaction: commits when it succeeds, rolls back otherwise. +template +auto in_transaction(QSqlDatabase& db, F&& body) { + using R = std::invoke_result_t; + if (!db.transaction()) { + return R(std::unexpected(Error{db.lastError().text()})); + } + R result = body(); + if (!result) { + db.rollback(); + return result; + } + if (!db.commit()) { + Error error{db.lastError().text()}; + db.rollback(); + return R(std::unexpected(std::move(error))); + } + return result; +} + +// Runs an UPDATE or DELETE that must touch a row; `missing` is the error when none matched. +Result run_expecting_row(QSqlDatabase& db, const QString& sql, const QVariantList& params, + const QString& missing) { + auto query = run_statement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + if (query->numRowsAffected() == 0) { + return std::unexpected(Error{missing}); + } + return {}; +} + +QString folder_missing(qint64 folder_id) { + return QStringLiteral("Folder with id %1 does not exist.").arg(folder_id); +} + +QString tag_missing(qint64 tag_id) { + return QStringLiteral("Tag with id %1 does not exist.").arg(tag_id); +} + +Result load_state(QSqlDatabase& db) { + OrganizerState state; + + auto folders = select_rows(db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), {}, + [&state](QSqlQuery& row) { + Folder folder; + folder.id = row.value(0).toLongLong(); + if (!row.value(1).isNull()) { + folder.parent_id = row.value(1).toLongLong(); + } + folder.name = row.value(2).toString(); + folder.collapsed = row.value(3).toLongLong() != 0; + state.folders.insert(folder.id, folder); + }); + if (!folders) { + return std::unexpected(folders.error()); + } + + auto placements = select_rows(db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, + [&state](QSqlQuery& row) { + state.placements[row.value(0).toString().toLower()] = + row.value(1).toLongLong(); + }); + if (!placements) { + return std::unexpected(placements.error()); + } + + auto tags = select_rows(db, QStringLiteral("SELECT id, name, color FROM tags"), {}, [&state](QSqlQuery& row) { + const Tag tag{.id = row.value(0).toLongLong(), + .name = row.value(1).toString(), + .color = row.value(2).toString()}; + state.tags.insert(tag.id, tag); + }); + if (!tags) { + return std::unexpected(tags.error()); + } + + auto mod_tags = select_rows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), {}, + [&state](QSqlQuery& row) { + state.mod_tags[row.value(0).toString().toLower()].insert( + row.value(1).toLongLong()); + }); + if (!mod_tags) { + return std::unexpected(mod_tags.error()); + } + + bool corrupt_rule = false; + auto rules = select_rows( + db, + QStringLiteral("SELECT id, position, field, op, pattern, folder_id FROM rules " + "ORDER BY position ASC, id ASC"), + {}, [&state, &corrupt_rule](QSqlQuery& row) { + const auto field = parse_rule_field(row.value(2).toString()); + const auto op = parse_rule_op(row.value(3).toString()); + if (!field || !op) { + corrupt_rule = true; + return; + } + state.rules.append(Rule{.id = row.value(0).toLongLong(), + .position = row.value(1).toLongLong(), + .field = *field, + .op = *op, + .pattern = row.value(4).toString(), + .folder_id = row.value(5).toLongLong()}); + }); + if (!rules) { + return std::unexpected(rules.error()); + } + if (corrupt_rule) { + return std::unexpected(Error{QStringLiteral("The organizer database holds an unknown rule field or operator.")}); + } + return state; +} + +Result insert_folder(QSqlDatabase& db, qint64 parent_id, const QString& name) { + auto inserted = run_statement(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + {parent_id, name}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Folder{.id = inserted->lastInsertId().toLongLong(), + .parent_id = parent_id, + .name = name, + .collapsed = false}; +} + +Result upsert_placements(QSqlDatabase& db, const QStringList& package_ids, qint64 folder_id) { + QList rows; + for (const QString& pid : package_ids) { + rows.append({pid, folder_id}); + } + return run_batch(db, kPlaceSql, rows); +} + +Result insert_tag(QSqlDatabase& db, const QString& name, const QString& color) { + auto inserted = run_statement(db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), {name, color}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Tag{.id = inserted->lastInsertId().toLongLong(), .name = name, .color = color}; +} + +Result delete_folder_rows(QSqlDatabase& db, qint64 folder_id) { + bool exists = false; + auto found = select_rows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), {folder_id}, + [&exists](QSqlQuery&) { exists = true; }); + if (!found) { + return std::unexpected(found.error()); + } + if (!exists) { + return std::unexpected(Error{folder_missing(folder_id)}); + } + auto moved = run_statement(db, kMoveSubtreeSql, {folder_id, ROOT_ID}); + if (!moved) { + return std::unexpected(moved.error()); + } + auto deleted = run_statement(db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folder_id}); + if (!deleted) { + return std::unexpected(deleted.error()); + } + return {}; +} + +Result set_mod_tag_rows(QSqlDatabase& db, const QStringList& package_ids, const QSet& add, + const QSet& remove) { + if (!remove.isEmpty()) { + QList rows; + for (const QString& pid : package_ids) { + for (qint64 tag_id : remove) { + rows.append({pid, tag_id}); + } + } + if (auto removed = run_batch(db, QStringLiteral("DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), rows); + !removed) { + return removed; + } + } + if (!add.isEmpty()) { + QList rows; + for (const QString& pid : package_ids) { + for (qint64 tag_id : add) { + rows.append({pid, tag_id}); + } + } + if (auto added = run_batch(db, QStringLiteral("INSERT OR IGNORE INTO mod_tags (package_id, tag_id) VALUES (?, ?)"), rows); + !added) { + return added; + } + } + return {}; +} + +Result> replace_rule_rows(QSqlDatabase& db, const QList& specs) { + if (auto deleted = run_statement(db, QStringLiteral("DELETE FROM rules")); !deleted) { + return std::unexpected(deleted.error()); + } + QList rules; + for (qsizetype position = 0; position < specs.size(); ++position) { + const RuleSpec& spec = specs[position]; + auto inserted = run_statement(db, kInsertRuleSql, + {static_cast(position), rule_field_name(spec.field).toString(), + rule_op_name(spec.op).toString(), spec.pattern, spec.folder_id}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + rules.append(Rule{.id = inserted->lastInsertId().toLongLong(), + .position = static_cast(position), + .field = spec.field, + .op = spec.op, + .pattern = spec.pattern, + .folder_id = spec.folder_id}); + } + return rules; +} + +Result append_named_rule_rows(QSqlDatabase& db, qint64 parent_id, const QStringList& new_folders, + const QList& rules) { + QList folder_rows; + for (const QString& name : new_folders) { + folder_rows.append({parent_id, name}); + } + if (auto created = run_batch(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), folder_rows); + !created) { + return created; + } + + QHash folder_ids; + if (auto found = select_rows(db, QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), + {parent_id}, [&folder_ids](QSqlQuery& row) { + folder_ids[row.value(1).toString().toCaseFolded()] = row.value(0).toLongLong(); + }); + !found) { + return found; + } + + qint64 start = 0; + if (auto next = select_rows(db, QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), {}, + [&start](QSqlQuery& row) { start = row.value(0).toLongLong(); }); + !next) { + return next; + } + + QList rule_rows; + for (qsizetype offset = 0; offset < rules.size(); ++offset) { + const StandardRule& rule = rules[offset]; + const auto folder = folder_ids.constFind(rule.folder.toCaseFolded()); + if (folder == folder_ids.cend()) { + return std::unexpected(Error{QStringLiteral("Folder \"%1\" does not exist.").arg(rule.folder)}); + } + rule_rows.append({start + static_cast(offset), rule_field_name(rule.field).toString(), + rule_op_name(rule.op).toString(), rule.pattern, folder.value()}); + } + return run_batch(db, kInsertRuleSql, rule_rows); +} + +// Opens the connection named `name`, creating the file and schema on first use. Runs on the +// worker thread only. +Result configure_connection(QSqlDatabase& db, const QString& path) { + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // WAL keeps readers from blocking the writer; the busy timeout absorbs brief contention. + for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}) { + if (auto applied = run_statement(db, QString::fromLatin1(pragma)); !applied) { + return std::unexpected(applied.error()); + } + } + return ensure_schema(db, kSchema, kSchemaVersion, {}, path, QStringLiteral("organizer DB")); +} + +Result open_connection(const QString& name, const QString& path) { + if (QSqlDatabase::contains(name)) { + return QSqlDatabase::database(name, false); + } + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected(Error{QStringLiteral("Cannot create the directory for %1").arg(path)}); + } + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(path); + if (auto ready = configure_connection(db, path); !ready) { + // Drop the handle before removing the connection, so a retry can open it again. + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(name); + return std::unexpected(ready.error()); + } + return db; +} + +void discard_connection(const QString& name) { + if (!QSqlDatabase::contains(name)) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(name, false); + db.close(); + } + QSqlDatabase::removeDatabase(name); +} + +} // namespace + +QString db_path(const QString& config_dir) { + return QDir(config_dir).filePath(QStringLiteral("organizer.db")); +} + +OrganizerDb::OrganizerDb(QString path) + : path_(std::move(path)), + connection_name_(QStringLiteral("organizer-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + pool_.setMaxThreadCount(1); + pool_.setExpiryTimeout(-1); +} + +OrganizerDb::~OrganizerDb() { + // Blocks until the worker has released the connection; the pool cannot stop before that. + QtConcurrent::run(&pool_, [name = connection_name_] { discard_connection(name); }).waitForFinished(); +} + +template +auto OrganizerDb::submit(F fn) { + using R = std::invoke_result_t; + return QtConcurrent::run(&pool_, [name = connection_name_, path = path_, fn = std::move(fn)]() -> R { + auto db = open_connection(name, path); + if (!db) { + return std::unexpected(db.error()); + } + return fn(*db); + }); +} + +QCoro::Task> OrganizerDb::load() { + co_return co_await submit([](QSqlDatabase& db) -> Result { return load_state(db); }); +} + +QCoro::Task> OrganizerDb::create_folder(qint64 parent_id, QString name) { + co_return co_await submit([parent_id, name](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { return insert_folder(db, parent_id, name); }); + }); +} + +QCoro::Task> OrganizerDb::create_folder_with(qint64 parent_id, QString name, + QStringList package_ids) { + co_return co_await submit([parent_id, name, pids = clean_package_ids(package_ids)](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + auto folder = insert_folder(db, parent_id, name); + if (!folder) { + return folder; + } + if (auto placed = upsert_placements(db, pids, folder->id); !placed) { + return std::unexpected(placed.error()); + } + return folder; + }); + }); +} + +QCoro::Task> OrganizerDb::rename_folder(qint64 folder_id, QString name) { + if (folder_id == ROOT_ID) { + co_return std::unexpected(Error{QStringLiteral("The root folder cannot be renamed.")}); + } + co_return co_await submit([folder_id, name](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return run_expecting_row(db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), + {name, folder_id}, folder_missing(folder_id)); + }); + }); +} + +QCoro::Task> OrganizerDb::set_parent(qint64 folder_id, qint64 parent_id) { + if (folder_id == ROOT_ID) { + co_return std::unexpected(Error{QStringLiteral("The root folder cannot be moved.")}); + } + if (folder_id == parent_id) { + co_return std::unexpected(Error{QStringLiteral("A folder cannot be its own parent.")}); + } + co_return co_await submit([folder_id, parent_id](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return run_expecting_row(db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), + {parent_id, folder_id}, folder_missing(folder_id)); + }); + }); +} + +QCoro::Task> OrganizerDb::set_collapsed(qint64 folder_id, bool collapsed) { + co_return co_await submit([folder_id, collapsed](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return run_expecting_row(db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), + {collapsed ? 1 : 0, folder_id}, folder_missing(folder_id)); + }); + }); +} + +QCoro::Task> OrganizerDb::set_all_collapsed(bool collapsed) { + co_return co_await submit([collapsed](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + auto updated = run_statement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), + {collapsed ? 1 : 0}); + if (!updated) { + return std::unexpected(updated.error()); + } + return {}; + }); + }); +} + +QCoro::Task> OrganizerDb::delete_folder(qint64 folder_id) { + if (folder_id == ROOT_ID) { + co_return std::unexpected(Error{QStringLiteral("The root folder cannot be deleted.")}); + } + co_return co_await submit([folder_id](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { return delete_folder_rows(db, folder_id); }); + }); +} + +QCoro::Task> OrganizerDb::place(QStringList package_ids, qint64 folder_id) { + const QStringList pids = clean_package_ids(package_ids); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids, folder_id](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { return upsert_placements(db, pids, folder_id); }); + }); +} + +QCoro::Task> OrganizerDb::unplace(QStringList package_ids) { + const QStringList pids = clean_package_ids(package_ids); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + QList rows; + for (const QString& pid : pids) { + rows.append({pid}); + } + return run_batch(db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), rows); + }); + }); +} + +QCoro::Task> OrganizerDb::create_tag(QString name, QString color) { + co_return co_await submit([name, color](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { return insert_tag(db, name, color); }); + }); +} + +QCoro::Task> OrganizerDb::update_tag(qint64 tag_id, QString name, QString color) { + co_return co_await submit([tag_id, name, color](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return run_expecting_row(db, QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), + {name, color, tag_id}, tag_missing(tag_id)); + }); + }); +} + +QCoro::Task> OrganizerDb::delete_tag(qint64 tag_id) { + co_return co_await submit([tag_id](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return run_expecting_row(db, QStringLiteral("DELETE FROM tags WHERE id = ?"), {tag_id}, + tag_missing(tag_id)); + }); + }); +} + +QCoro::Task> OrganizerDb::set_mod_tags(QStringList package_ids, QSet add, + QSet remove) { + const QStringList pids = clean_package_ids(package_ids); + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + co_return co_await submit([pids, add, remove](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { return set_mod_tag_rows(db, pids, add, remove); }); + }); +} + +QCoro::Task>> OrganizerDb::replace_rules(QList specs) { + co_return co_await submit([specs](QSqlDatabase& db) -> Result> { + return in_transaction(db, [&]() -> Result> { return replace_rule_rows(db, specs); }); + }); +} + +QCoro::Task> OrganizerDb::append_named_rules(qint64 parent_id, QStringList new_folders, + QList rules) { + co_return co_await submit([parent_id, new_folders, rules](QSqlDatabase& db) -> Result { + return in_transaction(db, [&]() -> Result { + return append_named_rule_rows(db, parent_id, new_folders, rules); + }); + }); +} + +QCoro::Task OrganizerDb::close() { + co_await QtConcurrent::run(&pool_, [name = connection_name_] { discard_connection(name); }); +} + +} // namespace px::core::organizer diff --git a/src/core/organizer/db.h b/src/core/organizer/db.h new file mode 100644 index 0000000..28698ba --- /dev/null +++ b/src/core/organizer/db.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/error.h" +#include "core/organizer/defaults.h" +#include "core/organizer/models.h" + +namespace px::core::organizer { + +// The organizer database file inside the config directory. +QString db_path(const QString& config_dir); + +// SQLite store for the organizer. Every operation runs on one worker thread that owns the +// connection, so operations are serialized in submission order and each is atomic. +// The connection opens on first use and closes on close() or destruction. +class OrganizerDb { +public: + explicit OrganizerDb(QString path); + ~OrganizerDb(); + + OrganizerDb(const OrganizerDb&) = delete; + OrganizerDb& operator=(const OrganizerDb&) = delete; + + QCoro::Task> load(); + + QCoro::Task> create_folder(qint64 parent_id, QString name); + QCoro::Task> create_folder_with(qint64 parent_id, QString name, + QStringList package_ids); + QCoro::Task> rename_folder(qint64 folder_id, QString name); + QCoro::Task> set_parent(qint64 folder_id, qint64 parent_id); + QCoro::Task> set_collapsed(qint64 folder_id, bool collapsed); + QCoro::Task> set_all_collapsed(bool collapsed); + QCoro::Task> delete_folder(qint64 folder_id); + + QCoro::Task> place(QStringList package_ids, qint64 folder_id); + QCoro::Task> unplace(QStringList package_ids); + + QCoro::Task> create_tag(QString name, QString color); + QCoro::Task> update_tag(qint64 tag_id, QString name, QString color); + QCoro::Task> delete_tag(qint64 tag_id); + QCoro::Task> set_mod_tags(QStringList package_ids, QSet add, + QSet remove); + + QCoro::Task>> replace_rules(QList specs); + // Creates `new_folders` under `parent_id`, then appends `rules` after the existing ones, + // resolving each rule's folder by name among the children of `parent_id`. + QCoro::Task> append_named_rules(qint64 parent_id, QStringList new_folders, + QList rules); + + QCoro::Task close(); + +private: + // Runs `fn(QSqlDatabase&)` on the worker, opening the connection first if needed. + template + auto submit(F fn); + + QString path_; + QString connection_name_; + // One thread, never expiring: the connection stays on the thread that created it. + QThreadPool pool_; +}; + +} // namespace px::core::organizer diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp new file mode 100644 index 0000000..1bc2940 --- /dev/null +++ b/src/core/organizer/defaults.cpp @@ -0,0 +1,86 @@ +#include "core/organizer/defaults.h" + +#include + +namespace px::core::organizer { + +namespace { + +// Package ids come from each mod's own About.xml. Multi-category authors (brrainz, +// unlimitedhugs, bs, mlie) are matched by exact id only, and name heuristics such as +// "framework" or "performance" are avoided: they catch translations and unrelated +// content mods. Order matters (first match wins): libraries and performance precede +// the family prefixes that would swallow VE Framework and Dubs Performance Analyzer. + +QList exact(const QString& folder, std::initializer_list ids) { + QList rules; + for (const char* id : ids) { + rules.append({folder, RuleField::PackageId, RuleOp::Equals, QString::fromLatin1(id)}); + } + return rules; +} + +QList prefixes(const QString& folder, std::initializer_list prefixes) { + QList rules; + for (const char* prefix : prefixes) { + rules.append({folder, RuleField::PackageId, RuleOp::Prefix, QString::fromLatin1(prefix)}); + } + return rules; +} + +QList build_standard_rules() { + QList rules; + const auto append = [&rules](const QList& more) { rules.append(more); }; + const auto add = [&rules](StandardRule rule) { rules.append(std::move(rule)); }; + + append(prefixes("Official", {"ludeon.rimworld"})); + append(exact("Frameworks & Libraries", { + "brrainz.harmony", + "unlimitedhugs.hugslib", + "erdelf.humanoidalienraces", + "oskarpotocki.vanillafactionsexpanded.core", + "imranfish.xmlextensions", + "zetrith.prepatcher", + "jecrell.jecstools", + "bs.fishery", + "smashphil.vehicleframework", + })); + append(exact("Performance", { + "bs.performance", + "krkr.rocketman", + "taranchuk.performanceoptimizer", + })); + append(prefixes("Performance", {"dubwise.dubsperformanceanalyzer"})); + append(exact("Quality of Life", { + "brrainz.achtung", + "brrainz.cameraplus", + "unlimitedhugs.allowtool", + "jaxe.rimhud", + "mehni.pickupandhaul", + "avilmask.commonsense", + "dhultgren.smarterconstruction", + "roolo.searchanddestroy", + })); + append(prefixes("Vanilla Expanded", { + "vanillaexpanded.", + "oskarpotocki.", + "vanillaracesexpanded.", + "vanillaquestsexpanded.", + "vanillastorytellersexpanded.", + })); + add({"Vanilla Expanded", RuleField::Author, RuleOp::Contains, "oskar potocki"}); + append(prefixes("Alpha Mods", {"sarg."})); + append(prefixes("Combat Extended", {"ceteam."})); + add({"Combat Extended", RuleField::Name, RuleOp::Contains, "combat extended"}); + append(prefixes("Dubs Mods", {"dubwise."})); + return rules; +} + +} // namespace + +const QList& standard_rules() { + static const QList rules = build_standard_rules(); + return rules; +} + +} // namespace px::core::organizer diff --git a/src/core/organizer/defaults.h b/src/core/organizer/defaults.h new file mode 100644 index 0000000..c07d90e --- /dev/null +++ b/src/core/organizer/defaults.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "core/organizer/models.h" + +namespace px::core::organizer { + +// A built-in auto-folder rule targeting a top-level folder by name. +struct StandardRule { + QString folder; + RuleField field = RuleField::PackageId; + RuleOp op = RuleOp::Equals; + QString pattern; +}; + +// Built-in rules in priority order: the first match wins. +const QList& standard_rules(); + +} // namespace px::core::organizer diff --git a/src/core/organizer/models.h b/src/core/organizer/models.h new file mode 100644 index 0000000..53029c4 --- /dev/null +++ b/src/core/organizer/models.h @@ -0,0 +1,117 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace px::core::organizer { + +inline constexpr qint64 ROOT_ID = 1; +inline constexpr int MAX_DEPTH = 3; + +// Names match the SQL values stored in the rules table's CHECK constraints. +enum class RuleField { PackageId, Name, Author }; +enum class RuleOp { Prefix, Contains, Equals }; + +inline QLatin1StringView rule_field_name(RuleField field) { + switch (field) { + case RuleField::PackageId: return QLatin1StringView("package_id"); + case RuleField::Name: return QLatin1StringView("name"); + case RuleField::Author: return QLatin1StringView("author"); + } + return {}; +} + +inline QLatin1StringView rule_op_name(RuleOp op) { + switch (op) { + case RuleOp::Prefix: return QLatin1StringView("prefix"); + case RuleOp::Contains: return QLatin1StringView("contains"); + case RuleOp::Equals: return QLatin1StringView("equals"); + } + return {}; +} + +inline std::optional parse_rule_field(const QString& name) { + if (name == QLatin1StringView("package_id")) return RuleField::PackageId; + if (name == QLatin1StringView("name")) return RuleField::Name; + if (name == QLatin1StringView("author")) return RuleField::Author; + return std::nullopt; +} + +inline std::optional parse_rule_op(const QString& name) { + if (name == QLatin1StringView("prefix")) return RuleOp::Prefix; + if (name == QLatin1StringView("contains")) return RuleOp::Contains; + if (name == QLatin1StringView("equals")) return RuleOp::Equals; + return std::nullopt; +} + +struct Folder { + qint64 id = 0; + std::optional parent_id; // empty only for ROOT_ID + QString name; + bool collapsed = false; + + friend bool operator==(const Folder&, const Folder&) = default; +}; + +struct Tag { + qint64 id = 0; + QString name; + QString color; + + friend bool operator==(const Tag&, const Tag&) = default; +}; + +struct RuleSpec { + RuleField field = RuleField::Name; + RuleOp op = RuleOp::Equals; + QString pattern; + qint64 folder_id = 0; + + friend bool operator==(const RuleSpec&, const RuleSpec&) = default; +}; + +struct Rule { + qint64 id = 0; + qint64 position = 0; + RuleField field = RuleField::Name; + RuleOp op = RuleOp::Equals; + QString pattern; + qint64 folder_id = 0; + + friend bool operator==(const Rule&, const Rule&) = default; +}; + +// Everything the organizer keeps in memory, as loaded from organizer.db. +// Package ids are lowercase keys. +struct OrganizerState { + QHash folders; + QHash placements; + QHash tags; + QHash> mod_tags; + QList rules; + + friend bool operator==(const OrganizerState&, const OrganizerState&) = default; +}; + +// Lowercased, trimmed, de-duplicated package ids in first-seen order; blanks are dropped. +inline QStringList clean_package_ids(const QStringList& package_ids) { + QStringList cleaned; + QSet seen; + for (const QString& raw : package_ids) { + const QString pid = raw.trimmed().toLower(); + if (!pid.isEmpty() && !seen.contains(pid)) { + seen.insert(pid); + cleaned.append(pid); + } + } + return cleaned; +} + +} // namespace px::core::organizer diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp new file mode 100644 index 0000000..0ba1cbd --- /dev/null +++ b/src/core/organizer/resolve.cpp @@ -0,0 +1,384 @@ +#include "core/organizer/resolve.h" + +#include +#include + +#include "core/providers/labels.h" + +namespace px::core::organizer { + +namespace { + +struct Matcher { + RuleOp op = RuleOp::Equals; + QString pattern; // casefolded + + bool matches(const QString& value) const { + switch (op) { + case RuleOp::Prefix: return value.startsWith(pattern); + case RuleOp::Contains: return value.contains(pattern); + case RuleOp::Equals: return value == pattern; + } + return false; + } +}; + +Matcher make_matcher(RuleOp op, const QString& pattern) { + return Matcher{op, pattern.toCaseFolded()}; +} + +struct FieldRule { + RuleField field = RuleField::Name; + Matcher matcher; + qint64 folder_id = ROOT_ID; +}; + +// The casefolded value a rule field is matched against. +QString field_value(RuleField field, const QString& pid, const QString& name, const QString& author) { + switch (field) { + case RuleField::PackageId: return pid.toCaseFolded(); + case RuleField::Name: return name.toCaseFolded(); + case RuleField::Author: return author.toCaseFolded(); + } + return {}; +} + +// Manual placements win over rules; the first matching rule by position wins. +class Resolver { +public: + explicit Resolver(const OrganizerState& state) : placements_(state.placements) { + for (const Rule& rule : state.rules) { + if (state.folders.contains(rule.folder_id) && rule.folder_id != ROOT_ID) { + rules_.append(FieldRule{rule.field, make_matcher(rule.op, rule.pattern), rule.folder_id}); + } + } + } + + Resolution resolve(const std::optional& package_id, const QString& name, + const QString& author) const { + if (!package_id) { + return {ROOT_ID, Placement::None}; + } + const QString pid = package_id->toLower(); + if (const auto manual = placements_.constFind(pid); manual != placements_.cend()) { + return {manual.value(), Placement::Manual}; + } + for (const FieldRule& rule : rules_) { + if (rule.matcher.matches(field_value(rule.field, pid, name, author))) { + return {rule.folder_id, Placement::Rule}; + } + } + return {ROOT_ID, Placement::None}; + } + +private: + const QHash& placements_; + QList rules_; +}; + +bool matches_query(const ModLeaf& leaf, const QString& provider_id, const TreeQuery& query) { + if (query.status == StatusFilter::Active && !leaf.enabled) { + return false; + } + if (query.status == StatusFilter::Inactive && leaf.enabled) { + return false; + } + if (!query.provider_ids.isEmpty() && !query.provider_ids.contains(provider_id)) { + return false; + } + for (qint64 tag_id : query.tag_ids) { + if (!leaf.tag_ids.contains(tag_id)) { + return false; + } + } + const QString text = query.text.trimmed().toCaseFolded(); + if (text.isEmpty()) { + return true; + } + return leaf.name.trimmed().toCaseFolded().contains(text) + || (leaf.package_id && leaf.package_id->contains(text)) + || leaf.author.toCaseFolded().contains(text); +} + +// A folder with no mods counts as off, not on: "on" needs at least one enabled mod. +CheckState check_state(int total, int enabled) { + if (total != 0 && enabled == total) { + return CheckState::On; + } + if (enabled == 0) { + return CheckState::Off; + } + return CheckState::Partial; +} + +bool mod_less(const ModLeaf& a, const ModLeaf& b) { + return std::pair(a.name.toCaseFolded(), a.uuid) < std::pair(b.name.toCaseFolded(), b.uuid); +} + +bool folder_less(const Folder& a, const Folder& b) { + return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); +} + +// Builds the folder tree bottom-up. `visited` is shared across the whole walk, so a folder +// reachable twice is attached once. +struct TreeBuilder { + const OrganizerState& state; + const QHash>& children_of; + const QHash>& all_by_folder; + const QHash>& visible_by_folder; + const QSet& ruled; + bool filtering; + QSet visited; + + FolderNode build(const Folder& folder, int depth) { + visited.insert(folder.id); + FolderNode node; + node.folder = folder; + node.depth = depth; + node.has_rules = ruled.contains(folder.id); + + QList children = children_of.value(folder.id); + std::sort(children.begin(), children.end(), folder_less); + for (const Folder& child : children) { + if (visited.contains(child.id)) { + continue; + } + FolderNode sub = build(child, depth + 1); + node.total += sub.total; + node.enabled += sub.enabled; + node.visible_total += sub.visible_total; + if (!filtering || sub.visible_total != 0) { + node.children.push_back(std::move(sub)); + } + } + + const QList own = all_by_folder.value(folder.id); + QList visible = visible_by_folder.value(folder.id); + std::sort(visible.begin(), visible.end(), mod_less); + int own_enabled = 0; + for (const ModLeaf& leaf : own) { + own_enabled += leaf.enabled ? 1 : 0; + } + node.own_total = static_cast(own.size()); + node.own_enabled = own_enabled; + node.total += node.own_total; + node.enabled += own_enabled; + node.visible_total += static_cast(visible.size()); + node.check = check_state(node.total, node.enabled); + node.mods = std::move(visible); + return node; + } +}; + +} // namespace + +bool TreeQuery::is_empty() const { + return text.trimmed().isEmpty() && tag_ids.isEmpty() && status == StatusFilter::All && provider_ids.isEmpty(); +} + +TreeQuery TreeFilter::query(const QString& text) const { + TreeQuery query; + query.text = text; + query.status = status; + if (provider_id) { + query.provider_ids.insert(*provider_id); + } + if (tag_id) { + query.tag_ids.insert(*tag_id); + } + return query; +} + +CheckState FolderNode::own_check() const { + return check_state(own_total, own_enabled); +} + +QList FolderNode::walk() const { + QList nodes{this}; + for (const FolderNode& child : children) { + nodes += child.walk(); + } + return nodes; +} + +QList FolderNode::all_mods() const { + QList mods_out; + for (const FolderNode* node : walk()) { + mods_out += node->mods; + } + return mods_out; +} + +std::optional mod_package_id(const ListedMod& mod) { + if (const auto* about = dynamic_cast(&mod)) { + return about->package_id.str(); + } + return std::nullopt; +} + +QString mod_author(const ListedMod& mod) { + if (const auto* about = dynamic_cast(&mod)) { + return about->authors.join(QStringLiteral(", ")); + } + return {}; +} + +Resolution folder_for(const OrganizerState& state, const std::optional& package_id, + const QString& name, const QString& author) { + return Resolver(state).resolve(package_id, name, author); +} + +RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& mods, + const QList& specs) { + const Resolver current(state); + // A spec with a blank pattern, an unknown folder or the root matches nothing. The + // matcher still uses the raw pattern, as the legacy preview did. + std::vector> candidates; + candidates.reserve(static_cast(specs.size())); + for (const RuleSpec& spec : specs) { + const bool usable = !spec.pattern.trimmed().isEmpty() && state.folders.contains(spec.folder_id) && + spec.folder_id != ROOT_ID; + candidates.push_back(usable ? std::optional(FieldRule{spec.field, make_matcher(spec.op, spec.pattern), + spec.folder_id}) + : std::nullopt); + } + + RulePreview preview; + preview.counts.assign(specs.size(), 0); + for (const ListedModPtr& mod : mods) { + const auto pid = mod_package_id(*mod); + if (!pid) { + continue; + } + const QString author = mod_author(*mod); + for (qsizetype index = 0; index < specs.size(); ++index) { + const std::optional& candidate = candidates[static_cast(index)]; + if (!candidate || !candidate->matcher.matches(field_value(candidate->field, *pid, mod->name, author))) { + continue; + } + preview.counts[index] += 1; + if (!state.placements.contains(*pid) && current.resolve(pid, mod->name, author).folder_id == ROOT_ID) { + preview.assignable += 1; + } + break; + } + } + return preview; +} + +QList tree_filters(const ModsByUuid& mods, const QSet& active, + const OrganizerState* state) { + const int total = static_cast(mods.size()); + int enabled = 0; + QHash per_provider; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + enabled += active.contains(it.key()) ? 1 : 0; + per_provider[it.value()->provider_id] += 1; + } + + const auto make = [](QString key, QString label, int count) { + TreeFilter filter; + filter.key = std::move(key); + filter.label = std::move(label); + filter.count = count; + return filter; + }; + + TreeFilter status_active = make(QStringLiteral("active"), QStringLiteral("Active"), enabled); + status_active.status = StatusFilter::Active; + TreeFilter status_inactive = make(QStringLiteral("inactive"), QStringLiteral("Inactive"), total - enabled); + status_inactive.status = StatusFilter::Inactive; + QList filters{make(QStringLiteral("all"), QStringLiteral("All"), total), std::move(status_active), + std::move(status_inactive)}; + + std::vector> labelled; // (label, provider id) + for (auto it = per_provider.cbegin(); it != per_provider.cend(); ++it) { + labelled.emplace_back(providerLabel(it.key()), it.key()); + } + std::sort(labelled.begin(), labelled.end()); + for (const auto& [label, pid] : labelled) { + TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, per_provider.value(pid)); + provider.provider_id = pid; + filters.append(std::move(provider)); + } + + if (state && !state->tags.isEmpty()) { + QHash tag_counts; + for (const auto& [id, tag] : state->tags.asKeyValueRange()) { + tag_counts.insert(id, 0); + } + for (const ListedModPtr& mod : mods) { + const auto pid = mod_package_id(*mod); + if (!pid) { + continue; + } + for (qint64 tag_id : state->mod_tags.value(*pid)) { + if (tag_counts.contains(tag_id)) { + tag_counts[tag_id] += 1; + } + } + } + + std::vector sorted_tags; + for (const Tag& tag : state->tags) { + sorted_tags.push_back(tag); + } + std::sort(sorted_tags.begin(), sorted_tags.end(), [](const Tag& a, const Tag& b) { + return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); + }); + for (const Tag& tag : sorted_tags) { + TreeFilter tag_filter = make(QStringLiteral("tag:%1").arg(tag.id), tag.name, tag_counts.value(tag.id)); + tag_filter.tag_id = tag.id; + filters.append(std::move(tag_filter)); + } + } + return filters; +} + +FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, + const std::optional& query) { + const bool filtering = query && !query->is_empty(); + const Resolver resolver(state); + + QHash> all_by_folder; + QHash> visible_by_folder; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + const ListedMod& mod = *it.value(); + const QString& uuid = it.key(); + const auto pid = mod_package_id(mod); + const QString author = mod_author(mod); + Resolution where = resolver.resolve(pid, mod.name, author); + if (!state.folders.contains(where.folder_id)) { + where = Resolution{ROOT_ID, Placement::None}; + } + const ModLeaf leaf{ + .uuid = uuid, + .package_id = pid, + .name = mod.name, + .author = author, + .enabled = active.contains(uuid), + .tag_ids = pid ? state.mod_tags.value(*pid) : QSet{}, + .placement = where.placement, + }; + all_by_folder[where.folder_id].append(leaf); + if (!filtering || matches_query(leaf, mod.provider_id, *query)) { + visible_by_folder[where.folder_id].append(leaf); + } + } + + QHash> children_of; + for (const Folder& folder : state.folders) { + if (folder.parent_id) { + children_of[*folder.parent_id].append(folder); + } + } + QSet ruled; + for (const Rule& rule : state.rules) { + ruled.insert(rule.folder_id); + } + + TreeBuilder builder{state, children_of, all_by_folder, visible_by_folder, ruled, filtering, {}}; + return builder.build(state.folders.value(ROOT_ID), 0); +} + +} // namespace px::core::organizer diff --git a/src/core/organizer/resolve.h b/src/core/organizer/resolve.h new file mode 100644 index 0000000..cd5e570 --- /dev/null +++ b/src/core/organizer/resolve.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/organizer/models.h" + +namespace px::core::organizer { + +// Discovered mods keyed by uuid. +using ModsByUuid = QHash; + +// Where a mod's folder came from: a manual placement, an auto-folder rule, or nowhere (root). +enum class Placement { Manual, Rule, None }; +enum class CheckState { On, Off, Partial }; +enum class StatusFilter { All, Active, Inactive }; + +struct Resolution { + qint64 folder_id = ROOT_ID; + Placement placement = Placement::None; +}; + +struct TreeQuery { + QString text; + QSet tag_ids; + StatusFilter status = StatusFilter::All; + QSet provider_ids; + + bool is_empty() const; +}; + +// A sidebar preset (status, provider, or tag) and the mods it selects. +struct TreeFilter { + QString key; + QString label; + int count = 0; + StatusFilter status = StatusFilter::All; + std::optional provider_id; + std::optional tag_id; + + TreeQuery query(const QString& text = {}) const; +}; + +struct ModLeaf { + QString uuid; + std::optional package_id; + QString name; + QString author; + bool enabled = false; + QSet tag_ids; + Placement placement = Placement::None; +}; + +struct FolderNode { + Folder folder; + int depth = 0; + // std::vector: QList cannot hold its own incomplete element type. + std::vector children; + QList mods; + int total = 0; + int enabled = 0; + CheckState check = CheckState::Off; + int visible_total = 0; + int own_total = 0; + int own_enabled = 0; + bool has_rules = false; + + // Check state of the folder's direct mods only (the root's Ungrouped). + CheckState own_check() const; + // This node followed by all descendants, in pre-order. + QList walk() const; + QList all_mods() const; +}; + +// First-match counts for a draft rule list, and the ungrouped mods it would assign. +struct RulePreview { + QList counts; + int assignable = 0; +}; + +// Lowercased package id for About.xml mods; nullopt for mods without one. +std::optional mod_package_id(const ListedMod& mod); +QString mod_author(const ListedMod& mod); + +Resolution folder_for(const OrganizerState& state, const std::optional& package_id, + const QString& name, const QString& author); + +RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& mods, + const QList& specs); + +// `state` may be null, in which case no tag filters are produced. +QList tree_filters(const ModsByUuid& mods, const QSet& active, + const OrganizerState* state); + +FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, + const std::optional& query = std::nullopt); + +} // namespace px::core::organizer diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp new file mode 100644 index 0000000..6f26ebf --- /dev/null +++ b/src/core/organizer/service.cpp @@ -0,0 +1,607 @@ +#include "core/organizer/service.h" + +#include +#include +#include +#include + +#include +#include + +#include "core/organizer/defaults.h" + +namespace px::core::organizer { +namespace { + +const QHash kEmptyMods; +const QStringList kEmptyActive; + +struct ContextSeam { + std::function&(const CoreContext&)> all_mods = + [](const CoreContext&) -> const QHash& { return kEmptyMods; }; + std::function active_uuids = + [](const CoreContext&) -> const QStringList& { return kEmptyActive; }; +}; + +ContextSeam& global_context_seam() { + static ContextSeam seam; + return seam; +} + +Result clean_name(const QString& name, const QString& what) { + const QString cleaned = name.trimmed(); + if (cleaned.isEmpty()) { + return std::unexpected(Error{QStringLiteral("%1 name cannot be empty.").arg(what)}); + } + return cleaned; +} + +} // namespace + +void register_core_context_seam( + std::function&(const CoreContext&)> all_mods, + std::function active_uuids) { + global_context_seam().all_mods = std::move(all_mods); + global_context_seam().active_uuids = std::move(active_uuids); +} + +OrganizerService::OrganizerService(CoreContext& ctx, OrganizerDb& db) + : Plugin(QStringLiteral("organizer")), ctx_(&ctx), db_(db) {} + +OrganizerService::OrganizerService(AllModsFn all_mods, ActiveUuidsFn active_uuids, OrganizerDb& db) + : Plugin(QStringLiteral("organizer")), + all_mods_fn_(std::move(all_mods)), + active_uuids_fn_(std::move(active_uuids)), + db_(db) {} + +const QHash& OrganizerService::all_mods() const { + if (all_mods_fn_) { + return all_mods_fn_(); + } + if (ctx_) { + return global_context_seam().all_mods(*ctx_); + } + return kEmptyMods; +} + +const QStringList& OrganizerService::active_uuids() const { + if (active_uuids_fn_) { + return active_uuids_fn_(); + } + if (ctx_) { + return global_context_seam().active_uuids(*ctx_); + } + return kEmptyActive; +} + +QCoro::Task OrganizerService::init() { + auto loaded = co_await db_.load(); + if (!loaded) { + // Plugin::init cannot report failure, so the service stays not ready and says why. + spdlog::error("Cannot load organizer state: {}", loaded.error().message.toStdString()); + co_return; + } + state_ = std::move(*loaded); + changed.notify({}); +} + +QCoro::Task OrganizerService::init(CoreContext&) { + co_await init(); +} + +QCoro::Task OrganizerService::shutdown() { + co_await db_.close(); +} + +bool OrganizerService::ready() const noexcept { + return state_.has_value(); +} + +const OrganizerState& OrganizerService::state() const { + Q_ASSERT(state_.has_value()); + return *state_; +} + +QCoro::Task> OrganizerService::commit() { + auto loaded = co_await db_.load(); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + state_ = std::move(*loaded); + changed.notify({}); + co_return {}; +} + +// ── Validation helpers ─────────────────────────────────────────────────────── + +Result OrganizerService::find_folder(qint64 folder_id) const { + const auto& folders = state().folders; + const auto it = folders.constFind(folder_id); + if (it == folders.cend()) { + return std::unexpected(Error{QStringLiteral("That folder no longer exists.")}); + } + return it.value(); +} + +Result OrganizerService::find_non_root(qint64 folder_id) const { + if (folder_id == ROOT_ID) { + return std::unexpected(Error{QStringLiteral("The root folder cannot be changed.")}); + } + return find_folder(folder_id); +} + +Result OrganizerService::find_tag(qint64 tag_id) const { + const auto& tags = state().tags; + const auto it = tags.constFind(tag_id); + if (it == tags.cend()) { + return std::unexpected(Error{QStringLiteral("That tag no longer exists.")}); + } + return it.value(); +} + +int OrganizerService::depth(qint64 folder_id) const { + const auto& folders = state().folders; + int count = 0; + Folder current = folders.value(folder_id); + while (current.parent_id) { + ++count; + current = folders.value(*current.parent_id); + } + return count; +} + +QList OrganizerService::children(qint64 folder_id) const { + QList result; + for (const Folder& folder : state().folders) { + if (folder.parent_id == folder_id) { + result.append(folder); + } + } + return result; +} + +QSet OrganizerService::subtree_ids(qint64 folder_id) const { + QSet ids{folder_id}; + QList pending{folder_id}; + while (!pending.isEmpty()) { + const qint64 current = pending.takeLast(); + for (const Folder& child : children(current)) { + if (!ids.contains(child.id)) { + ids.insert(child.id); + pending.append(child.id); + } + } + } + return ids; +} + +int OrganizerService::height(qint64 folder_id) const { + int tallest = 0; + for (const Folder& child : children(folder_id)) { + tallest = std::max(tallest, height(child.id)); + } + return 1 + tallest; +} + +Result OrganizerService::check_depth(qint64 parent_id, int tree_height) const { + if (depth(parent_id) + tree_height > MAX_DEPTH) { + return std::unexpected( + Error{QStringLiteral("Folders can be nested at most %1 levels deep.").arg(MAX_DEPTH)}); + } + return {}; +} + +Result OrganizerService::check_unique_folder_name(qint64 parent_id, const QString& name, + std::optional exclude) const { + const QString key = name.toCaseFolded(); + for (const Folder& sibling : children(parent_id)) { + if (exclude && sibling.id == *exclude) { + continue; + } + if (sibling.name.toCaseFolded() == key) { + return std::unexpected(Error{QStringLiteral("A folder named \"%1\" already exists here.").arg(name)}); + } + } + return {}; +} + +Result OrganizerService::check_tag(const QString& name, const QString& color, + std::optional exclude) const { + auto cleaned = clean_name(name, QStringLiteral("Tag")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + const QString key = cleaned->toCaseFolded(); + for (const Tag& tag : state().tags) { + if (exclude && tag.id == *exclude) { + continue; + } + if (tag.name.toCaseFolded() == key) { + return std::unexpected(Error{QStringLiteral("A tag named \"%1\" already exists.").arg(*cleaned)}); + } + } + static const QRegularExpression color_pattern(QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); + if (!color_pattern.match(color).hasMatch()) { + return std::unexpected(Error{QStringLiteral("\"%1\" is not a valid color (use #RRGGBB).").arg(color)}); + } + return *cleaned; +} + +Result OrganizerService::validate_new_folder(const QString& name, qint64 parent_id) const { + auto cleaned = clean_name(name, QStringLiteral("Folder")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + if (auto parent = find_folder(parent_id); !parent) { + return std::unexpected(parent.error()); + } + if (auto fits = check_depth(parent_id, 1); !fits) { + return std::unexpected(fits.error()); + } + if (auto unique = check_unique_folder_name(parent_id, *cleaned); !unique) { + return std::unexpected(unique.error()); + } + return *cleaned; +} + +QSet OrganizerService::active_set() const { + const QStringList& active = active_uuids(); + return QSet(active.cbegin(), active.cend()); +} + +// ── Folders ────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::create_folder(QString name, qint64 parent_id) { + auto cleaned = validate_new_folder(name, parent_id); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await db_.create_folder(parent_id, *cleaned); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::create_folder_from(QString name, QStringList package_ids, + qint64 parent_id) { + auto cleaned = validate_new_folder(name, parent_id); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await db_.create_folder_with(parent_id, *cleaned, clean_package_ids(package_ids)); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::rename_folder(qint64 folder_id, QString name) { + auto existing = find_non_root(folder_id); + if (!existing) { + co_return std::unexpected(existing.error()); + } + auto cleaned = clean_name(name, QStringLiteral("Folder")); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (*cleaned == existing->name) { + co_return {}; + } + const qint64 parent_id = existing->parent_id.value_or(ROOT_ID); + if (auto unique = check_unique_folder_name(parent_id, *cleaned, folder_id); !unique) { + co_return std::unexpected(unique.error()); + } + if (auto renamed = co_await db_.rename_folder(folder_id, *cleaned); !renamed) { + co_return std::unexpected(renamed.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::move_folder(qint64 folder_id, qint64 parent_id) { + auto folder = find_non_root(folder_id); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (auto target = find_folder(parent_id); !target) { + co_return std::unexpected(target.error()); + } + if (folder->parent_id == parent_id) { + co_return {}; + } + if (subtree_ids(folder_id).contains(parent_id)) { + co_return std::unexpected(Error{QStringLiteral("A folder cannot be moved into itself.")}); + } + // The whole subtree moves, so its height counts against the depth limit. + if (auto fits = check_depth(parent_id, height(folder_id)); !fits) { + co_return std::unexpected(fits.error()); + } + if (auto unique = check_unique_folder_name(parent_id, folder->name, folder_id); !unique) { + co_return std::unexpected(unique.error()); + } + if (auto moved = co_await db_.set_parent(folder_id, parent_id); !moved) { + co_return std::unexpected(moved.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::set_collapsed(qint64 folder_id, bool collapsed) { + auto folder = find_folder(folder_id); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (folder->collapsed == collapsed) { + co_return {}; + } + if (auto updated = co_await db_.set_collapsed(folder_id, collapsed); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::set_all_collapsed(bool collapsed) { + const bool unchanged = std::all_of(state().folders.cbegin(), state().folders.cend(), + [collapsed](const Folder& folder) { return folder.collapsed == collapsed; }); + if (unchanged) { + co_return {}; + } + if (auto updated = co_await db_.set_all_collapsed(collapsed); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::delete_folder(qint64 folder_id) { + if (auto folder = find_non_root(folder_id); !folder) { + co_return std::unexpected(folder.error()); + } + if (auto deleted = co_await db_.delete_folder(folder_id); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); +} + +// ── Placement ──────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::place(QStringList package_ids, qint64 folder_id) { + if (auto folder = find_folder(folder_id); !folder) { + co_return std::unexpected(folder.error()); + } + const QStringList pids = clean_package_ids(package_ids); + if (pids.isEmpty()) { + co_return {}; + } + if (auto placed = co_await db_.place(pids, folder_id); !placed) { + co_return std::unexpected(placed.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::ungroup(QStringList package_ids) { + co_return co_await place(std::move(package_ids), ROOT_ID); +} + +QCoro::Task> OrganizerService::reset_to_rules(QStringList package_ids) { + const QStringList pids = clean_package_ids(package_ids); + if (pids.isEmpty()) { + co_return {}; + } + if (auto unplaced = co_await db_.unplace(pids); !unplaced) { + co_return std::unexpected(unplaced.error()); + } + co_return co_await commit(); +} + +// ── Tags ───────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::create_tag(QString name, QString color) { + auto cleaned = check_tag(name, color); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await db_.create_tag(*cleaned, color); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::update_tag(qint64 tag_id, QString name, QString color) { + if (auto tag = find_tag(tag_id); !tag) { + co_return std::unexpected(tag.error()); + } + auto cleaned = check_tag(name, color, tag_id); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (auto updated = co_await db_.update_tag(tag_id, *cleaned, color); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::delete_tag(qint64 tag_id) { + if (auto tag = find_tag(tag_id); !tag) { + co_return std::unexpected(tag.error()); + } + if (auto deleted = co_await db_.delete_tag(tag_id); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::set_mod_tags(QStringList package_ids, QSet add, + QSet remove) { + const QStringList pids = clean_package_ids(package_ids); + if (add.intersects(remove)) { + co_return std::unexpected(Error{QStringLiteral("A tag cannot be added and removed at once.")}); + } + for (qint64 tag_id : add | remove) { + if (auto tag = find_tag(tag_id); !tag) { + co_return std::unexpected(tag.error()); + } + } + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + if (auto updated = co_await db_.set_mod_tags(pids, add, remove); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +// ── Rules ──────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::set_rules(QList specs) { + QList cleaned; + cleaned.reserve(specs.size()); + for (RuleSpec spec : specs) { + spec.pattern = spec.pattern.trimmed(); + if (spec.pattern.isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Rule pattern cannot be empty.")}); + } + if (spec.folder_id == ROOT_ID) { + co_return std::unexpected(Error{QStringLiteral("Rules must target a folder, not the root.")}); + } + if (auto target = find_folder(spec.folder_id); !target) { + co_return std::unexpected(target.error()); + } + cleaned.append(std::move(spec)); + } + if (auto replaced = co_await db_.replace_rules(cleaned); !replaced) { + co_return std::unexpected(replaced.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::add_standard_rules() { + // (field, op, casefolded pattern) identifies a rule regardless of folder or position. + using RuleKey = std::tuple; + std::set present; + for (const Rule& rule : state().rules) { + present.emplace(static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()); + } + + QSet existing; + for (const Folder& folder : children(ROOT_ID)) { + existing.insert(folder.name.toCaseFolded()); + } + + QList rules; + QStringList new_folders; + QSet new_folder_keys; + for (const StandardRule& rule : standard_rules()) { + const RuleKey key{static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()}; + if (present.contains(key)) { + continue; + } + present.insert(key); + rules.append(rule); + // The first spelling of a missing top-level folder names it, as in the legacy service. + const QString folder_key = rule.folder.toCaseFolded(); + if (!existing.contains(folder_key) && !new_folder_keys.contains(folder_key)) { + new_folder_keys.insert(folder_key); + new_folders.append(rule.folder); + } + } + if (rules.isEmpty()) { + co_return 0; + } + if (auto appended = co_await db_.append_named_rules(ROOT_ID, new_folders, rules); !appended) { + co_return std::unexpected(appended.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return static_cast(rules.size()); +} + +// ── Read helpers ───────────────────────────────────────────────────────────── + +FolderNode OrganizerService::tree(const std::optional& query) const { + return build_tree(state(), all_mods(), active_set(), query); +} + +Result OrganizerService::folder_mod_uuids(qint64 folder_id, bool recursive) const { + if (auto folder = find_folder(folder_id); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folder_id) { + continue; + } + const QList leaves = recursive ? node->all_mods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + return uuids; + } + return QStringList{}; +} + +Result OrganizerService::folder_toggle(qint64 folder_id, bool recursive) const { + if (auto folder = find_folder(folder_id); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folder_id) { + continue; + } + const QList leaves = recursive ? node->all_mods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + const CheckState check = recursive ? node->check : node->own_check(); + if (check == CheckState::On) { + return FolderToggle{QStringList{}, uuids}; + } + return FolderToggle{uuids, QStringList{}}; + } + return FolderToggle{}; +} + +Result> OrganizerService::folder_move_targets(qint64 folder_id) const { + auto folder = find_non_root(folder_id); + if (!folder) { + return std::unexpected(folder.error()); + } + const QSet subtree = subtree_ids(folder_id); + const int tree_height = height(folder_id); + const QString key = folder->name.toCaseFolded(); + + QSet targets; + for (const Folder& candidate : state().folders) { + if (subtree.contains(candidate.id) || candidate.id == folder->parent_id) { + continue; + } + if (depth(candidate.id) + tree_height > MAX_DEPTH) { + continue; + } + const QList siblings = children(candidate.id); + const bool clash = std::any_of(siblings.cbegin(), siblings.cend(), + [&key](const Folder& child) { return child.name.toCaseFolded() == key; }); + if (!clash) { + targets.insert(candidate.id); + } + } + return targets; +} + +QList OrganizerService::tree_filters() const { + return organizer::tree_filters(all_mods(), active_set(), &state()); +} + +} // namespace px::core::organizer diff --git a/src/core/organizer/service.h b/src/core/organizer/service.h new file mode 100644 index 0000000..706b8e9 --- /dev/null +++ b/src/core/organizer/service.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/context_fwd.h" +#include "core/error.h" +#include "core/events.h" +#include "core/organizer/db.h" +#include "core/organizer/models.h" +#include "core/organizer/resolve.h" +#include "core/plugin.h" + +namespace px::core { +class CoreContext; +} + +namespace px::core::organizer { + +struct FolderToggle { + QStringList enable; + QStringList disable; +}; + +// Virtual folder tree, tags and auto-folder rules. Never touches load order. +// Reads mods and the active list from the context; each mutation persists, reloads the +// state from the database and then emits `changed`. +class OrganizerService : public Plugin { +public: + using AllModsFn = std::function&()>; + using ActiveUuidsFn = std::function; + + OrganizerService(CoreContext& ctx, OrganizerDb& db); + OrganizerService(AllModsFn all_mods, ActiveUuidsFn active_uuids, OrganizerDb& db); + + Event changed; + + QCoro::Task init(); + QCoro::Task init(CoreContext&) override; + QCoro::Task shutdown() override; + + // Whether init() has loaded state; views built earlier must wait. + bool ready() const noexcept; + // Requires ready(): callers check before reading. + const OrganizerState& state() const; + + // ── Folders ────────────────────────────────────────────────────────────── + QCoro::Task> create_folder(QString name, qint64 parent_id = ROOT_ID); + QCoro::Task> create_folder_from(QString name, QStringList package_ids, + qint64 parent_id = ROOT_ID); + QCoro::Task> rename_folder(qint64 folder_id, QString name); + QCoro::Task> move_folder(qint64 folder_id, qint64 parent_id); + QCoro::Task> set_collapsed(qint64 folder_id, bool collapsed); + QCoro::Task> set_all_collapsed(bool collapsed); + QCoro::Task> delete_folder(qint64 folder_id); + + // ── Placement ──────────────────────────────────────────────────────────── + QCoro::Task> place(QStringList package_ids, qint64 folder_id); + QCoro::Task> ungroup(QStringList package_ids); + QCoro::Task> reset_to_rules(QStringList package_ids); + + // ── Tags ───────────────────────────────────────────────────────────────── + QCoro::Task> create_tag(QString name, QString color); + QCoro::Task> update_tag(qint64 tag_id, QString name, QString color); + QCoro::Task> delete_tag(qint64 tag_id); + QCoro::Task> set_mod_tags(QStringList package_ids, QSet add, QSet remove); + + // ── Rules ──────────────────────────────────────────────────────────────── + QCoro::Task> set_rules(QList specs); + // Appends the built-in rules not yet present. Returns how many were added. + QCoro::Task> add_standard_rules(); + + // ── Read helpers ───────────────────────────────────────────────────────── + FolderNode tree(const std::optional& query = std::nullopt) const; + // Mods under `folder_id`; `recursive = false` keeps only its direct mods. + Result folder_mod_uuids(qint64 folder_id, bool recursive = true) const; + // (enable, disable) for a folder checkbox click: a fully enabled folder disables + // everything; partial or off enables all. + Result folder_toggle(qint64 folder_id, bool recursive = true) const; + // Legal destinations, excluding the folder's current parent. + Result> folder_move_targets(qint64 folder_id) const; + QList tree_filters() const; + +private: + QCoro::Task> commit(); + + Result validate_new_folder(const QString& name, qint64 parent_id) const; + Result find_folder(qint64 folder_id) const; + Result find_non_root(qint64 folder_id) const; + Result find_tag(qint64 tag_id) const; + Result check_tag(const QString& name, const QString& color, + std::optional exclude = std::nullopt) const; + Result check_unique_folder_name(qint64 parent_id, const QString& name, + std::optional exclude = std::nullopt) const; + Result check_depth(qint64 parent_id, int height) const; + int depth(qint64 folder_id) const; + int height(qint64 folder_id) const; + QList children(qint64 folder_id) const; + QSet subtree_ids(qint64 folder_id) const; + + const QHash& all_mods() const; + const QStringList& active_uuids() const; + QSet active_set() const; + + CoreContext* ctx_ = nullptr; + AllModsFn all_mods_fn_; + ActiveUuidsFn active_uuids_fn_; + OrganizerDb& db_; + std::optional state_; +}; + +// Wires the bridge between CoreContext and OrganizerService when CoreContext is available. +void register_core_context_seam( + std::function&(const CoreContext&)> all_mods, + std::function active_uuids); + +} // namespace px::core::organizer diff --git a/src/core/plugin.h b/src/core/plugin.h new file mode 100644 index 0000000..60c0c7e --- /dev/null +++ b/src/core/plugin.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/context_fwd.h" +#include "core/error.h" +#include "core/toposort.h" + +namespace px::core { + +// `setup` runs synchronously in dependency order; `init` is awaited right after +// setup, for I/O or async start-up. `shutdown` runs in reverse order. Templated over +// the context type so the UI layer can register plugins against its own context. +template +class BasicPlugin { +public: + BasicPlugin(QString name, QStringList dependencies = {}) + : name_(std::move(name)), dependencies_(std::move(dependencies)) {} + virtual ~BasicPlugin() = default; + + BasicPlugin(const BasicPlugin&) = delete; + BasicPlugin& operator=(const BasicPlugin&) = delete; + + const QString& name() const noexcept { return name_; } + const QStringList& dependencies() const noexcept { return dependencies_; } + + virtual void setup(Ctx&) {} + virtual QCoro::Task init(Ctx&) { co_return; } + virtual QCoro::Task shutdown() { co_return; } + +private: + QString name_; + QStringList dependencies_; +}; + +template +class BasicPluginRegistry { +public: + using Plugin = BasicPlugin; + + // Replaces a plugin registered under the same name, keeping its position. + void register_plugin(std::unique_ptr plugin) { + spdlog::debug("plugin registered: {}", plugin->name().toStdString()); + for (auto& existing : plugins_) { + if (existing->name() == plugin->name()) { + existing = std::move(plugin); + return; + } + } + plugins_.push_back(std::move(plugin)); + } + + Plugin* get(const QString& name) const { + for (const auto& plugin : plugins_) { + if (plugin->name() == name) { + return plugin.get(); + } + } + return nullptr; + } + + QSet names() const { + QSet result; + for (const auto& plugin : plugins_) { + result.insert(plugin->name()); + } + return result; + } + + // `extra_deps` are dependencies satisfied outside the registry. + Result setup_all(Ctx& ctx, const QSet& extra_deps = {}) { + for (const auto& plugin : plugins_) { + for (const auto& dep : plugin->dependencies()) { + if (!get(dep) && !extra_deps.contains(dep)) { + return std::unexpected(Error{QStringLiteral("Plugin '%1' depends on '%2' which is not registered") + .arg(plugin->name(), dep)}); + } + } + } + + spdlog::info("setting up {} plugins...", plugins_.size()); + auto order = dependency_order(); + if (!order) { + return std::unexpected(order.error()); + } + for (Plugin* plugin : *order) { + plugin->setup(ctx); + } + spdlog::info("all plugins set up"); + return {}; + } + + QCoro::Task> init_all(Ctx& ctx) { + spdlog::info("initializing {} plugins...", plugins_.size()); + auto order = dependency_order(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (Plugin* plugin : *order) { + co_await plugin->init(ctx); + } + spdlog::info("all plugins initialized"); + co_return {}; + } + + QCoro::Task> shutdown_all() { + spdlog::info("shutting down {} plugins...", plugins_.size()); + auto order = dependency_order(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (auto it = order->rbegin(); it != order->rend(); ++it) { + co_await (*it)->shutdown(); + } + spdlog::info("all plugins shut down"); + co_return {}; + } + +private: + Result> dependency_order() const { + std::map> graph; + for (const auto& plugin : plugins_) { + const auto& deps = plugin->dependencies(); + graph.emplace(plugin->name(), QSet(deps.cbegin(), deps.cend())); + } + + auto levels = toposort_levels(graph); + if (!levels) { + return std::unexpected(levels.error()); + } + std::vector order; + order.reserve(plugins_.size()); + for (const auto& level : *levels) { + for (const auto& name : level) { + order.push_back(get(name)); + } + } + return order; + } + + std::vector> plugins_; +}; + +using Plugin = BasicPlugin; +using PluginRegistry = BasicPluginRegistry; + +} // namespace px::core diff --git a/src/core/plugin_config.h b/src/core/plugin_config.h new file mode 100644 index 0000000..20d9ba9 --- /dev/null +++ b/src/core/plugin_config.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/events.h" + +namespace px::core { + +// A plugin's own settings, kept in plugins/.json. `legacy` maps keys of +// the old shared config.json to fields of T; they seed the file the first time +// it is created. Without a service the settings live in memory only. +template + requires std::equality_comparable && std::default_initializable +class PluginConfig { +public: + PluginConfig(ConfigService* service, const QString& name, const QHash& legacy = {}); + + const T& value() const noexcept { return value_; } + + void update(T value); + + Event changed; + +private: + T seed(const QHash& legacy) const; + void store(const T& value) const; + + ConfigService* service_; + QString filename_; + T value_{}; +}; + +template + requires std::equality_comparable && std::default_initializable +PluginConfig::PluginConfig(ConfigService* service, const QString& name, + const QHash& legacy) + : service_(service), filename_(QStringLiteral("plugins/%1.json").arg(name)) { + std::optional stored; + if (service_) { + // plugins/*.json is unversioned, so loading cannot fail with a migration error. + auto loaded = service_->load_existing(filename_); + Q_ASSERT(loaded.has_value()); + stored = loaded.value_or(std::nullopt); + } + value_ = stored ? std::move(*stored) : seed(legacy); +} + +template + requires std::equality_comparable && std::default_initializable +T PluginConfig::seed(const QHash& legacy) const { + if (!service_ || legacy.isEmpty()) { + return T{}; + } + const auto raw = service_->load_raw("config.json"); + glz::json_t::object_t fields; + for (auto it = legacy.cbegin(); it != legacy.cend(); ++it) { + if (auto found = raw.find(it.key().toStdString()); found != raw.end()) { + fields[it.value().toStdString()] = found->second; + } + } + + const auto text = glz::write_json(fields); + if (!text) { + spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", filename_.toStdString()); + return T{}; + } + T value{}; + if (auto error = glz::read(value, *text)) { + spdlog::warn("Ignoring old settings for {}: {}", filename_.toStdString(), + glz::format_error(error, *text)); + return T{}; + } + if (!fields.empty()) { + store(value); + } + return value; +} + +template + requires std::equality_comparable && std::default_initializable +void PluginConfig::update(T value) { + if (value == value_) { + return; + } + value_ = std::move(value); + store(value_); + changed.notify(value_); +} + +template + requires std::equality_comparable && std::default_initializable +void PluginConfig::store(const T& value) const { + if (!service_) { + return; + } + if (auto saved = service_->save(filename_, value); !saved) { + spdlog::warn("Cannot save {}: {}", filename_.toStdString(), saved.error().message.toStdString()); + } +} + +} // namespace px::core diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp new file mode 100644 index 0000000..80eddb9 --- /dev/null +++ b/src/core/profiler.cpp @@ -0,0 +1,58 @@ +#include "core/profiler.h" + +#include + +namespace px::core { + +namespace { + +void renderNode(const Profiler::Node& node, int depth, QString& out) { + using namespace std::chrono; + const double ms = duration(node.elapsed).count(); + out += QString(depth * 2, QChar(' ')) + node.name + QStringLiteral(": ") + + QString::number(ms, 'f', 3) + QStringLiteral(" ms\n"); + for (const auto& child : node.children) { + renderNode(*child, depth + 1, out); + } +} + +} // namespace + +Profiler::Scope::Scope(Profiler& owner, QAnyStringView name) + : owner_(owner), parent_(owner.current_), start_(std::chrono::steady_clock::now()) { + auto node = std::make_unique(name.toString()); + node_ = node.get(); + parent_->children.push_back(std::move(node)); + owner_.current_ = node_; +} + +Profiler::Scope::~Scope() { + node_->elapsed = std::chrono::steady_clock::now() - start_; + owner_.current_ = parent_; +} + +void Profiler::adopt(Profiler& child) { + for (auto& node : child.root_.children) { + current_->children.push_back(std::move(node)); + } + child.root_.children.clear(); +} + +QString Profiler::render() const { + QString out; + for (const auto& child : root_.children) { + renderNode(*child, 0, out); + } + return out; +} + +ProfileSession::ProfileSession(QAnyStringView label) + : scope_(std::make_unique(timer_, label)) {} + +ProfileSession::~ProfileSession() { + // Close the top-level scope before rendering so its elapsed time is included. + scope_.reset(); + spdlog::debug("Profile report:\n{}", timer_.render().toStdString()); +} + +} // namespace px::core diff --git a/src/core/profiler.h b/src/core/profiler.h new file mode 100644 index 0000000..d8141d5 --- /dev/null +++ b/src/core/profiler.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace px::core { + +// Hierarchical wall-clock timer. Scopes nest according to their lifetimes; `render()` prints +// the resulting tree. +class Profiler { +public: + struct Node { + explicit Node(QString nodeName) : name(std::move(nodeName)) {} + + QString name; + std::chrono::steady_clock::duration elapsed{}; + std::vector> children; + }; + + class Scope { + public: + Scope(Profiler& owner, QAnyStringView name); + ~Scope(); + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; + + private: + Profiler& owner_; + Node* node_; + Node* parent_; + std::chrono::steady_clock::time_point start_; + }; + + Profiler() = default; + + [[nodiscard]] Scope operator()(QAnyStringView name) { return Scope(*this, name); } + + // Moves the root timings of `child` under the currently open scope of this profiler, so a + // sub-profile collected elsewhere shows up in this report. `child` is left empty. + void adopt(Profiler& child); + + [[nodiscard]] QString render() const; + +private: + Node root_{QStringLiteral("")}; + Node* current_ = &root_; +}; + +// Logs the hierarchical report of a block at debug level when it ends. +class ProfileSession { +public: + explicit ProfileSession(QAnyStringView label); + ~ProfileSession(); + ProfileSession(const ProfileSession&) = delete; + ProfileSession& operator=(const ProfileSession&) = delete; + + [[nodiscard]] Profiler& timer() { return timer_; } + +private: + Profiler timer_; + std::unique_ptr scope_; +}; + +} // namespace px::core diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp new file mode 100644 index 0000000..38fe125 --- /dev/null +++ b/src/core/providers/base.cpp @@ -0,0 +1,162 @@ +#include "core/providers/base.h" + +#include +#include +#include + +#include "core/models/metadata/parsing.h" +#include "core/utils.h" + +namespace px::core::providers { + +namespace { + +QList statCandidates(const QList& dirs) { + QList candidates; + candidates.reserve(dirs.size()); + for (const ModDirectory& dir : dirs) { + const QFileInfo info(dir.aboutXmlPath); + if (!info.exists()) { + continue; + } + candidates.append(ModCandidate{dir.modPath, dir.aboutXmlPath, + info.lastModified().toMSecsSinceEpoch() / 1000.0, + info.size()}); + } + return candidates; +} + +Result parseOne(const ModCandidate& candidate, const QString& targetVersion) { + auto mod = createListedModFromPath(candidate.modPath, targetVersion, true, + candidate.aboutXmlPath); + if (!mod) { + return std::unexpected(mod.error()); + } + return ParsedMod{std::move(*mod), candidate}; +} + +} // namespace + +QList scanModDirectory(const QString& modsPath) { + const QFileInfo root(modsPath); + if (!root.exists() || !root.isDir()) { + spdlog::warn("Mod directory not found: {}", modsPath.toStdString()); + return {}; + } + + QList found; + const QDir dir(modsPath); + const QFileInfoList entries = + dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + const QString modPath = dir.filePath(entry.fileName()); + if (const auto aboutXml = findAboutXml(modPath)) { + found.append(ModDirectory{modPath, *aboutXml}); + } + } + return found; +} + +BaseModProvider::BaseModProvider(QString provider_id, QString color, QString path, + QThreadPool* pool, MetadataCacheStore* metadata_cache) + : provider_id_(std::move(provider_id)), + color_(std::move(color)), + path_(std::move(path)), + pool_(pool), + metadata_cache_(metadata_cache) {} + +QCoro::Task> BaseModProvider::load_mods(QList dirs, + QString target_version, Profiler& timer, + MetadataCacheStore* metadata_cache, + bool force_reparse) { + MetadataCacheStore* cache = metadata_cache ? metadata_cache : metadata_cache_; + + const QList candidates = co_await runOnPool>( + nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); + + ModMap result; + QList missing; + if (force_reparse) { + missing = candidates; + } else if (cache) { + auto scope = timer("cache_lookup"); + CacheLookup lookup = co_await cache->get_mods(candidates, target_version); + for (ListedModPtr& mod : lookup.cached) { + mod->provider_id = provider_id_; + result.insert(mod->uuid(), mod); + } + missing = std::move(lookup.missing); + } else { + missing = candidates; + } + + if (!missing.isEmpty()) { + auto scope = timer("parse_xml"); + Result> parsed = co_await parse_candidates(missing, target_version); + if (!parsed) { + co_return std::unexpected(parsed.error()); + } + if (cache) { + QList cacheItems; + for (const ParsedMod& item : *parsed) { + if (auto about = std::dynamic_pointer_cast(item.mod)) { + cacheItems.append(CachedMod{std::move(about), item.candidate}); + } + } + if (!cacheItems.isEmpty()) { + co_await cache->put_mods(std::move(cacheItems), target_version); + } + } + for (ParsedMod& item : *parsed) { + item.mod->provider_id = provider_id_; + result.insert(item.mod->uuid(), item.mod); + } + } + + // Warm the disk-backed published-file-id cache off the calling thread. + const QList mods = result.values(); + co_await runOnPool(nullptr, [mods] { + for (const ListedModPtr& mod : mods) { + mod->published_file_id(); + } + }); + co_return result; +} + +QCoro::Task>> BaseModProvider::parse_candidates( + QList missing, QString target_version) { + if (pool_ == nullptr || missing.size() <= 1) { + co_return co_await runOnPool>>( + nullptr, [missing = std::move(missing), target_version = std::move(target_version)] { + QList parsed; + parsed.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + Result item = parseOne(candidate, target_version); + if (!item) { + return Result>(std::unexpected(item.error())); + } + parsed.append(std::move(*item)); + } + return Result>(std::move(parsed)); + }); + } + + QList>> pending; + pending.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + pending.append(runOnPool>( + pool_, [candidate, target_version] { return parseOne(candidate, target_version); })); + } + QList parsed; + parsed.reserve(missing.size()); + for (QFuture>& future : pending) { + Result item = co_await future; + if (!item) { + co_return std::unexpected(item.error()); + } + parsed.append(std::move(*item)); + } + co_return parsed; +} + +} // namespace px::core::providers diff --git a/src/core/providers/base.h b/src/core/providers/base.h new file mode 100644 index 0000000..a5a804e --- /dev/null +++ b/src/core/providers/base.h @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/error.h" +#include "core/models/metadata/structures.h" +#include "core/profiler.h" + +namespace px::core::providers { + +// Runs `fn` on `pool`, or on the global pool when `pool` is null, and returns a future for its +// result. Awaiting the future from a coroutine resumes it on the calling thread. +template +QFuture runOnPool(QThreadPool* pool, F&& fn) { + auto promise = std::make_shared>(); + QFuture future = promise->future(); + QThreadPool* target = pool ? pool : QThreadPool::globalInstance(); + target->start(QRunnable::create([promise, fn = std::forward(fn)]() mutable { + promise->start(); + if constexpr (std::is_void_v) { + fn(); + } else { + promise->addResult(fn()); + } + promise->finish(); + })); + return future; +} + +// A directory that holds a mod, with the About.xml that describes it. +struct ModDirectory { + QString modPath; + QString aboutXmlPath; +}; + +// A ModDirectory plus the About.xml stat data that cache entries are validated against. +struct ModCandidate { + QString modPath; + QString aboutXmlPath; + double mtime = 0.0; + qint64 size = 0; +}; + +// A freshly parsed mod and the candidate it came from. +struct ParsedMod { + ListedModPtr mod; + ModCandidate candidate; +}; + +// An AboutXmlMod ready to be stored by a metadata cache. +struct CachedMod { + std::shared_ptr mod; + ModCandidate candidate; +}; + +struct CacheLookup { + QList cached; + QList missing; +}; + +// Persistent store of parsed About.xml metadata, keyed by About.xml path and validated by its +// mtime and size. Implemented by the cache service; providers only depend on this interface. +class MetadataCacheStore { +public: + MetadataCacheStore() = default; + MetadataCacheStore(const MetadataCacheStore&) = delete; + MetadataCacheStore& operator=(const MetadataCacheStore&) = delete; + virtual ~MetadataCacheStore() = default; + + // Returns the entries that are still valid for `target_version`; everything else is missing. + virtual QCoro::Task get_mods(QList candidates, + QString target_version) = 0; + virtual QCoro::Task put_mods(QList entries, QString target_version) = 0; +}; + +// Discovered mods keyed by uuid (the mod path for mods on disk). +using ModMap = QHash; + +// Lists the subdirectories of `modsPath` that contain an About/About.xml. +QList scanModDirectory(const QString& modsPath); + +class BaseModProvider { +public: + // `path` is the root this provider scans. `pool` and `metadata_cache` are optional and not + // owned; they must outlive the provider. + BaseModProvider(QString provider_id, QString color, QString path, + QThreadPool* pool = nullptr, MetadataCacheStore* metadata_cache = nullptr); + BaseModProvider(const BaseModProvider&) = delete; + BaseModProvider& operator=(const BaseModProvider&) = delete; + virtual ~BaseModProvider() = default; + + const QString& provider_id() const noexcept { return provider_id_; } + const QString& color() const noexcept { return color_; } + const QString& path() const noexcept { return path_; } + + // `metadata_cache` overrides the cache given at construction for this call only. + virtual QCoro::Task> discover(QString target_version, + Profiler* timer = nullptr, + MetadataCacheStore* metadata_cache = nullptr, + bool force_reparse = false) = 0; + +protected: + // Loads metadata for `dirs`, using the cache where entries are still valid. + QCoro::Task> load_mods(QList dirs, QString target_version, + Profiler& timer, MetadataCacheStore* metadata_cache, + bool force_reparse); + +private: + QCoro::Task>> parse_candidates(QList missing, + QString target_version); + + QString provider_id_; + QString color_; + QString path_; + QThreadPool* pool_; + MetadataCacheStore* metadata_cache_; +}; + +} // namespace px::core::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp new file mode 100644 index 0000000..3943774 --- /dev/null +++ b/src/core/providers/core.cpp @@ -0,0 +1,40 @@ +#include "core/providers/core.h" + +#include +#include +#include + +namespace px::core::providers { + +CoreModProvider::CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache) + : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), std::move(gamePath), + nullptr, metadataCache) {} + +QCoro::Task> CoreModProvider::discover(QString target_version, Profiler* timer, + MetadataCacheStore* metadata_cache, + bool force_reparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + const QString dataDir = QDir(path()).filePath(QStringLiteral("Data")); + spdlog::debug("CoreModProvider scanning: {}", dataDir.toStdString()); + if (!QFileInfo::exists(dataDir)) { + co_return ModMap{}; + } + + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [dataDir] { return scanModDirectory(dataDir); }); + } + + auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, + metadata_cache, force_reparse); + if (discovered) { + spdlog::info("CoreModProvider discovered {} mods", discovered->size()); + } + co_return discovered; +} + +} // namespace px::core::providers diff --git a/src/core/providers/core.h b/src/core/providers/core.h new file mode 100644 index 0000000..0fc08d1 --- /dev/null +++ b/src/core/providers/core.h @@ -0,0 +1,18 @@ +#pragma once + +#include "core/providers/base.h" + +namespace px::core::providers { + +// Base-game and DLC mods under the game's `Data/` directory. +class CoreModProvider final : public BaseModProvider { +public: + // `gamePath` is the RimWorld install root; `Data/` is resolved inside it. + explicit CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, + MetadataCacheStore* metadata_cache = nullptr, + bool force_reparse = false) override; +}; + +} // namespace px::core::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp new file mode 100644 index 0000000..1229f3c --- /dev/null +++ b/src/core/providers/factory.cpp @@ -0,0 +1,25 @@ +#include "core/providers/factory.h" + +#include "core/providers/core.h" +#include "core/providers/local.h" + +namespace px::core::providers { + +ProviderList create_providers(const PathConfig& paths, QThreadPool* pool) { + ProviderList providers; + if (!paths.game.empty()) { + providers.push_back(std::make_unique(QString::fromStdString(paths.game))); + } + if (!paths.local.empty()) { + const QString local = QString::fromStdString(paths.local); + providers.push_back(std::make_unique(local, pool)); + providers.push_back(std::make_unique(local, pool)); + } + if (!paths.workshop.empty()) { + providers.push_back(std::make_unique( + QString::fromStdString(paths.workshop), pool)); + } + return providers; +} + +} // namespace px::core::providers diff --git a/src/core/providers/factory.h b/src/core/providers/factory.h new file mode 100644 index 0000000..85b280c --- /dev/null +++ b/src/core/providers/factory.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include + +#include "core/config.h" +#include "core/providers/base.h" + +namespace px::core::providers { + +using ProviderList = std::vector>; + +// The default providers for the configured paths: base game, local, downloaded and Workshop. +// Paths that are not set produce no provider. `pool` is not owned and must outlive the providers; +// null uses the global pool. +ProviderList create_providers(const PathConfig& paths, QThreadPool* pool = nullptr); + +} // namespace px::core::providers diff --git a/src/core/providers/labels.h b/src/core/providers/labels.h new file mode 100644 index 0000000..b948f4c --- /dev/null +++ b/src/core/providers/labels.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +namespace px::core { + +// Display name for a mod provider, mirroring the legacy PROVIDER_LABELS table. Unknown ids +// fall back to the id with underscores as spaces and each word title-cased, as Python's +// str.title() does: a letter starts a new word only after an uncased character. +inline QString providerLabel(const QString& providerId) { + if (providerId == QLatin1StringView("local")) { + return QStringLiteral("Local"); + } + if (providerId == QLatin1StringView("steam") || providerId == QLatin1StringView("downloaded")) { + return QStringLiteral("Steam Workshop"); + } + if (providerId == QLatin1StringView("core")) { + return QStringLiteral("System / Core"); + } + + QString label = providerId; + label.replace(u'_', u' '); + bool afterLetter = false; + for (QChar& c : label) { + if (c.isLetter()) { + c = afterLetter ? c.toLower() : c.toUpper(); + afterLetter = true; + } else { + afterLetter = false; + } + } + return label; +} + +} // namespace px::core diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp new file mode 100644 index 0000000..ed4cb40 --- /dev/null +++ b/src/core/providers/local.cpp @@ -0,0 +1,116 @@ +#include "core/providers/local.h" + +#include +#include +#include + +namespace px::core::providers { + +namespace { + +struct ScannedDirectory { + ModDirectory dir; + bool hasPublishedFileId = false; +}; + +// Runs off the UI thread: one directory listing plus a published-file-id probe per mod. +QList scanWithPublishedIds(const QString& root) { + QList scanned; + for (const ModDirectory& dir : scanModDirectory(root)) { + const bool hasId = QFileInfo::exists( + QDir(dir.modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); + scanned.append(ScannedDirectory{dir, hasId}); + } + return scanned; +} + +} // namespace + +LocalModProvider::LocalModProvider(QString localPath, QThreadPool* pool, + MetadataCacheStore* metadataCache) + : BaseModProvider(QStringLiteral("local"), QStringLiteral("#2ecc71"), std::move(localPath), + pool, metadataCache) {} + +QCoro::Task> LocalModProvider::discover(QString target_version, Profiler* timer, + MetadataCacheStore* metadata_cache, + bool force_reparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("LocalModProvider path does not exist: {}", path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("LocalModProvider scanning: {}", path().toStdString()); + + QList scanned; + { + auto scope = tm("scan_dir"); + scanned = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root); }); + } + QList dirs; + for (const ScannedDirectory& item : scanned) { + if (!item.hasPublishedFileId) { + dirs.append(item.dir); + } + } + + auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, + metadata_cache, force_reparse); + if (discovered) { + spdlog::info("LocalModProvider discovered {} mods", discovered->size()); + } + co_return discovered; +} + +DownloadedModProvider::DownloadedModProvider(QString localPath, QThreadPool* pool, + MetadataCacheStore* metadataCache) + : DownloadedModProvider(QStringLiteral("downloaded"), std::move(localPath), pool, + metadataCache) {} + +DownloadedModProvider::DownloadedModProvider(QString providerId, QString path, + QThreadPool* pool, MetadataCacheStore* metadataCache) + : BaseModProvider(std::move(providerId), QStringLiteral("#3498db"), std::move(path), pool, + metadataCache) {} + +QCoro::Task> DownloadedModProvider::discover(QString target_version, + Profiler* timer, + MetadataCacheStore* metadata_cache, + bool force_reparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("DownloadedModProvider path does not exist: {}", path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("DownloadedModProvider scanning: {}", path().toStdString()); + + QList scanned; + { + auto scope = tm("scan_dir"); + scanned = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root); }); + } + QList dirs; + for (const ScannedDirectory& item : scanned) { + if (item.hasPublishedFileId) { + dirs.append(item.dir); + } + } + + auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, + metadata_cache, force_reparse); + if (discovered) { + spdlog::info("{} discovered {} mods", provider_id().toStdString(), discovered->size()); + } + co_return discovered; +} + +SteamWorkshopModProvider::SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool, + MetadataCacheStore* metadataCache) + : DownloadedModProvider(QStringLiteral("steam"), std::move(workshopPath), pool, + metadataCache) {} + +} // namespace px::core::providers diff --git a/src/core/providers/local.h b/src/core/providers/local.h new file mode 100644 index 0000000..5b1c1cf --- /dev/null +++ b/src/core/providers/local.h @@ -0,0 +1,40 @@ +#pragma once + +#include "core/providers/base.h" + +namespace px::core::providers { + +// Mods the user keeps in the local mods folder: directories without a published file id. +class LocalModProvider final : public BaseModProvider { +public: + LocalModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, + MetadataCacheStore* metadata_cache = nullptr, + bool force_reparse = false) override; +}; + +// Mods downloaded by the app into the local folder: directories that carry a published file id. +class DownloadedModProvider : public BaseModProvider { +public: + DownloadedModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, + MetadataCacheStore* metadata_cache = nullptr, + bool force_reparse = false) override; + +protected: + DownloadedModProvider(QString providerId, QString path, QThreadPool* pool, + MetadataCacheStore* metadataCache); +}; + +// Steam Workshop subscriptions: same layout as downloaded mods, under the workshop root. +class SteamWorkshopModProvider final : public DownloadedModProvider { +public: + SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); +}; + +} // namespace px::core::providers diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp new file mode 100644 index 0000000..ef604f6 --- /dev/null +++ b/src/core/services/activation_service.cpp @@ -0,0 +1,501 @@ +#include "core/services/activation_service.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/checker/graph.h" +#include "core/context.h" +#include "core/models/metadata/structures.h" +#include "core/services/diagnostics_service.h" +#include "core/sort/config.h" +#include "core/sort/models.h" + +namespace px::core { +namespace { + +using checker::ConstraintEdge; +using checker::ConstraintGraph; +using checker::EdgeType; +using checker::PackageId; +using ModMap = QHash; +using Edge = std::pair; + +// The About.xml mod behind `uuid`, or null when it is unknown or has no About.xml. +checker::AboutXmlModPtr about_mod(const QHash& all_mods, const QString& uuid) { + const auto it = all_mods.constFind(uuid); + return it == all_mods.cend() ? nullptr : std::dynamic_pointer_cast(it.value()); +} + +QStringList enable_candidates(const QHash& all_mods, + const QStringList& enable, + const QSet& disable_set, + const QSet& active_set) { + QStringList candidates; + QSet seen; + for (const QString& uuid : enable) { + if (seen.contains(uuid) || disable_set.contains(uuid)) { + continue; + } + seen.insert(uuid); + if (all_mods.contains(uuid) && !active_set.contains(uuid)) { + candidates.append(uuid); + } + } + return candidates; +} + +// (before, after) pairs between mods touching any of `candidates`. Edges through ids outside +// `mods` (uninstalled optional mods) are dropped so they cannot chain unrelated mods together. +QList build_edges(const ModMap& mods, + const QList& order, + const QList& candidates, + const sort::SortSettings& settings, + const sort::CommunityRules& community_rules) { + ConstraintGraph graph; + graph.build(mods, order, settings, &community_rules); + // Placement ignores active-to-active edges, so only edges incident to a candidate matter; + // the graph still resolves the full alternative map. + QSet incident; + for (const PackageId& pid : candidates) { + incident.unite(graph.outgoing(pid)); + incident.unite(graph.incoming(pid)); + } + + QList edges; + for (const ConstraintEdge& edge : incident) { + PackageId before; + PackageId after; + switch (edge.type) { + case EdgeType::LoadBefore: + before = edge.source; + after = edge.target; + break; + case EdgeType::Dependency: + case EdgeType::LoadAfter: + before = edge.target; + after = edge.source; + break; + default: + continue; + } + if (before != after && mods.contains(before) && mods.contains(after)) { + edges.append({before, after}); + } + } + return edges; +} + +// Iterative Tarjan SCC. Each component lists its members in `nodes` order. +QList strongly_connected(const QStringList& nodes, + const QHash>& adjacent) { + struct Frame { + QString node; + QStringList children; + qsizetype next = 0; + }; + + QHash index_of; + QHash lowlink; + QStringList stack; + QSet on_stack; + QList components; + QList work; + + const auto visit = [&](const QString& node) { + const int index = static_cast(index_of.size()); + index_of.insert(node, index); + lowlink.insert(node, index); + stack.append(node); + on_stack.insert(node); + const QSet successors = adjacent.value(node); + work.append(Frame{node, QStringList(successors.cbegin(), successors.cend()), 0}); + }; + + for (const QString& root : nodes) { + if (index_of.contains(root)) { + continue; + } + visit(root); + while (!work.isEmpty()) { + Frame& frame = work.last(); + if (frame.next < frame.children.size()) { + const QString child = frame.children[frame.next++]; + if (!index_of.contains(child)) { + // Invalidates `frame`; the loop re-reads work.last(). + visit(child); + } else if (on_stack.contains(child)) { + lowlink[frame.node] = std::min(lowlink.value(frame.node), index_of.value(child)); + } + continue; + } + + // Every child is done: close this node. + const QString node = frame.node; + work.removeLast(); + if (!work.isEmpty()) { + const QString parent = work.last().node; + lowlink[parent] = std::min(lowlink.value(parent), lowlink.value(node)); + } + if (lowlink.value(node) == index_of.value(node)) { + QStringList component; + QString member; + do { + member = stack.takeLast(); + on_stack.remove(member); + component.append(member); + } while (member != node); + components.append(component); + } + } + } + return components; +} + +// Slots enable candidates into gaps of a fixed active order. +// +// Gap i means "just before new_active[i]"; gap new_active.size() is the end. Bounds come only from +// direct edges to active mods, propagated through batch candidates, because the active order +// itself may already violate constraints and must not be re-derived transitively. +class Placement { +public: + Placement(QStringList new_active, + QStringList candidates, + const QHash& all_mods, + const sort::SortSettings& settings, + const sort::CommunityRules& community_rules); + + QStringList assemble() const; + +private: + void add_edge(const PackageId& before, const PackageId& after); + std::pair> order_batch() const; + QHash propagated_ceilings(const QStringList& ordered, + const QSet& cyclic) const; + int gap_for(const QString& uuid, + const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const; + + QStringList new_active_; + QStringList candidates_; + int end_; + + QHash first_pos_; + QHash last_pos_; + QHash uuids_by_pid_; + + // Batch-internal adjacency plus direct bounds against active mods. + QHash> after_; + QHash> before_; + QHash floor_; + QHash ceiling_; +}; + +Placement::Placement(QStringList new_active, + QStringList candidates, + const QHash& all_mods, + const sort::SortSettings& settings, + const sort::CommunityRules& community_rules) + : new_active_(std::move(new_active)), + candidates_(std::move(candidates)), + end_(static_cast(new_active_.size())) { + ModMap mods; + QList order; + for (qsizetype index = 0; index < new_active_.size(); ++index) { + const checker::AboutXmlModPtr mod = about_mod(all_mods, new_active_[index]); + if (!mod) { + continue; + } + const PackageId& pid = mod->package_id; + if (!first_pos_.contains(pid)) { + first_pos_.insert(pid, static_cast(index)); + } + last_pos_.insert(pid, static_cast(index)); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); + } + } + + for (const QString& uuid : candidates_) { + after_.insert(uuid, {}); + before_.insert(uuid, {}); + const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); + if (!mod) { + continue; + } + const PackageId& pid = mod->package_id; + uuids_by_pid_[pid].append(uuid); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); + } + } + + if (!uuids_by_pid_.isEmpty()) { + for (const Edge& edge : + build_edges(mods, order, uuids_by_pid_.keys(), settings, community_rules)) { + add_edge(edge.first, edge.second); + } + } +} + +void Placement::add_edge(const PackageId& before, const PackageId& after) { + const QStringList before_uuids = uuids_by_pid_.value(before); + const QStringList after_uuids = uuids_by_pid_.value(after); + for (const QString& b : before_uuids) { + for (const QString& a : after_uuids) { + if (a != b) { + after_[b].insert(a); + before_[a].insert(b); + } + } + if (const auto first = first_pos_.constFind(after); first != first_pos_.cend()) { + ceiling_[b] = std::min(ceiling_.value(b, first.value()), first.value()); + } + } + if (const auto last = last_pos_.constFind(before); last != last_pos_.cend()) { + for (const QString& a : after_uuids) { + floor_[a] = std::max(floor_.value(a, last.value() + 1), last.value() + 1); + } + } +} + +QStringList Placement::assemble() const { + const auto [ordered, cyclic] = order_batch(); + const QHash ceilings = propagated_ceilings(ordered, cyclic); + QList buckets(end_ + 1); + QHash gaps; + for (const QString& uuid : ordered) { + const int gap = gap_for(uuid, cyclic, ceilings, gaps); + gaps.insert(uuid, gap); + buckets[gap].append(uuid); + } + + QStringList result; + for (qsizetype index = 0; index < new_active_.size(); ++index) { + result.append(buckets[index]); + result.append(new_active_[index]); + } + result.append(buckets[end_]); + return result; +} + +int Placement::gap_for(const QString& uuid, + const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const { + if (cyclic.contains(uuid)) { + return end_; + } + // Befores of an acyclic candidate sit in earlier components, so their gaps are final. + std::optional earliest; + const auto raise_floor = [&earliest](int floor) { + earliest = earliest ? std::max(*earliest, floor) : floor; + }; + for (const QString& b : before_.value(uuid)) { + raise_floor(gaps.value(b)); + } + if (const auto floor = floor_.constFind(uuid); floor != floor_.cend()) { + raise_floor(floor.value()); + } + + std::optional ceiling; + if (const auto found = ceilings.constFind(uuid); found != ceilings.cend()) { + ceiling = found.value(); + } + if (earliest) { + if (ceiling && *earliest > *ceiling) { + return end_; + } + return *earliest; + } + return ceiling ? *ceiling : end_; +} + +QHash Placement::propagated_ceilings(const QStringList& ordered, + const QSet& cyclic) const { + // Earliest active position each candidate must precede, via batch chains. + QHash ceilings; + for (auto it = ordered.crbegin(); it != ordered.crend(); ++it) { + if (cyclic.contains(*it)) { + continue; + } + std::optional bound; + const auto tighten = [&bound](int value) { + bound = bound ? std::min(*bound, value) : value; + }; + for (const QString& a : after_.value(*it)) { + if (const auto found = ceilings.constFind(a); found != ceilings.cend()) { + tighten(found.value()); + } + } + if (const auto own = ceiling_.constFind(*it); own != ceiling_.cend()) { + tighten(own.value()); + } + if (bound) { + ceilings.insert(*it, *bound); + } + } + return ceilings; +} + +// Topologically orders candidates by input order; cycle members are placed as a unit. +std::pair> Placement::order_batch() const { + auto components = strongly_connected(candidates_, after_); + + QHash input_index; + for (qsizetype index = 0; index < candidates_.size(); ++index) { + input_index.insert(candidates_[index], static_cast(index)); + } + + QHash component_of; + QSet cyclic; + for (int index = 0; index < components.size(); ++index) { + QStringList& members = components[index]; + std::sort(members.begin(), members.end(), [&input_index](const QString& a, const QString& b) { + return input_index.value(a) < input_index.value(b); + }); + for (const QString& member : members) { + component_of.insert(member, index); + } + if (members.size() > 1) { + cyclic.unite(QSet(members.cbegin(), members.cend())); + } + } + + QList> successors(components.size()); + QList indegree(components.size(), 0); + for (auto it = after_.cbegin(); it != after_.cend(); ++it) { + const int source = component_of.value(it.key()); + for (const QString& successor : it.value()) { + const int target = component_of.value(successor); + if (target != source && !successors[source].contains(target)) { + successors[source].insert(target); + ++indegree[target]; + } + } + } + + // Ready components pop in input order of their first member. + using Ready = std::pair; + std::priority_queue, std::greater<>> ready; + for (int index = 0; index < components.size(); ++index) { + if (indegree[index] == 0) { + ready.emplace(input_index.value(components[index].front()), index); + } + } + QStringList ordered; + while (!ready.empty()) { + const int index = ready.top().second; + ready.pop(); + ordered.append(components[index]); + for (const int target : successors[index]) { + if (--indegree[target] == 0) { + ready.emplace(input_index.value(components[target].front()), target); + } + } + } + return {std::move(ordered), std::move(cyclic)}; +} + +} // namespace + +ActivationService::ActivationService(CoreContext& ctx) : ctx_(ctx) {} + +QStringList ActivationService::dependents_of(const QStringList& uuids, + const QStringList& enable) const { + const QSet excluded(uuids.cbegin(), uuids.cend()); + const QHash& all_mods = ctx_.allMods(); + const QStringList& active = ctx_.activeUuids(); + + QSet remaining; + const auto keep = [&](const QString& uuid) { + if (excluded.contains(uuid)) { + return; + } + if (const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid)) { + remaining.insert(mod->package_id); + } + }; + for (const QString& uuid : active) { + keep(uuid); + } + for (const QString& uuid : enable) { + keep(uuid); + } + + QSet roots; + for (const QString& uuid : excluded) { + const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); + if (mod && !remaining.contains(mod->package_id)) { + roots.insert(mod->package_id); + } + } + if (roots.isEmpty()) { + return {}; + } + + const ConstraintGraph& graph = ctx_.diagnosticsService().constraint_graph(); + QSet dependents; + QList pending(roots.cbegin(), roots.cend()); + while (!pending.isEmpty()) { + const PackageId target = pending.takeLast(); + const auto edges = graph.incoming_of_type(target, EdgeType::Dependency); + for (const ConstraintEdge& edge : edges) { + if (!roots.contains(edge.source) && !dependents.contains(edge.source)) { + dependents.insert(edge.source); + pending.append(edge.source); + } + } + } + + QStringList result; + for (const QString& uuid : active) { + if (excluded.contains(uuid)) { + continue; + } + const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); + if (mod && dependents.contains(mod->package_id)) { + result.append(uuid); + } + } + return result; +} + +bool ActivationService::apply(const QStringList& enable, const QStringList& disable) { + const QStringList& active = ctx_.activeUuids(); + const QSet disable_set(disable.cbegin(), disable.cend()); + QStringList new_active; + for (const QString& uuid : active) { + if (!disable_set.contains(uuid)) { + new_active.append(uuid); + } + } + const QSet active_set(active.cbegin(), active.cend()); + QStringList candidates = + enable_candidates(ctx_.allMods(), enable, disable_set, active_set); + + const Placement placement(std::move(new_active), + std::move(candidates), + ctx_.allMods(), + ctx_.config().sort, + ctx_.diagnosticsService().community_rules()); + const QStringList result = placement.assemble(); + if (result == active) { + return false; + } + + ctx_.setActive(result); + return true; +} + +} // namespace px::core diff --git a/src/core/services/activation_service.h b/src/core/services/activation_service.h new file mode 100644 index 0000000..9837b79 --- /dev/null +++ b/src/core/services/activation_service.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace px::core { + +class CoreContext; + +// Enables and disables mods without reordering the rest of the active list. +class ActivationService { +public: + // `ctx` is not owned and must outlive the service. + explicit ActivationService(CoreContext& ctx); + + // Active mods that depend on packages lost once `uuids` are disabled and `enable` is + // enabled. The inputs themselves are excluded; order follows the active list. + QStringList dependents_of(const QStringList& uuids, const QStringList& enable = {}) const; + + // Applies a batch of enable/disable changes and returns whether the active state changed. + // A uuid in both lists is disabled. + bool apply(const QStringList& enable = {}, const QStringList& disable = {}); + +private: + CoreContext& ctx_; +}; + +} // namespace px::core diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp new file mode 100644 index 0000000..05f9d8a --- /dev/null +++ b/src/core/services/diagnostics_service.cpp @@ -0,0 +1,351 @@ +#include "core/services/diagnostics_service.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/sort/community.h" + +namespace px::core { +namespace { + +// Cached databases older than this are refreshed in the background. +constexpr double kDatabaseMaxAgeS = 24 * 3600; + +bool needs_refresh(bool loaded, const std::optional& age) { + return !loaded || !age || *age > kDatabaseMaxAgeS; +} + +std::vector> default_checkers() { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; +} + +view::SidebarEntry make_entry(view::SidebarEntry::Kind kind, + QString label, + QSet visible_uuids, + QString provider_id = {}) { + view::SidebarEntry entry; + entry.kind = kind; + entry.label = std::move(label); + entry.visible_uuids = std::move(visible_uuids); + entry.provider_id = std::move(provider_id); + return entry; +} + +} // namespace + +DiagnosticsService::DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network) + : ctx_(ctx), + no_version_warning_service_(ctx.configService(), network), + use_this_instead_service_(ctx.configService(), network), + checker_(default_checkers(), + ctx.config().sort, + ctx.targetVersion(), + [this](const checker::DiagnosticsByUuid& diagnostics) { + on_checker_diagnostics_changed(diagnostics); + }) {} + +// ── Lifecycle ────────────────────────────────────────────── + +QCoro::Task> DiagnosticsService::initialize(Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto init_scope = t("diagnostics.init"); + { + auto scope = t("ensure_databases"); + co_await ensure_databases(); + } + + std::optional rules; + { + auto scope = t("load_community_rules"); + if (const auto path = community_rules_path()) { + auto loaded = co_await QtConcurrent::run( + [p = *path] { return sort::load_community_rules(p); }); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + rules = std::move(*loaded); + } + } + if (rules && !rules->isEmpty()) { + checker_.set_community_rules(std::move(*rules)); + } + co_return {}; +} + +QCoro::Task DiagnosticsService::ensure_databases() { + // The cache files are read off the UI thread; the service is only touched from here. + auto nvw = co_await QtConcurrent::run( + [this] { return no_version_warning_service_.load_if_exists(); }); + const bool nvw_loaded = !nvw.isEmpty(); + if (nvw_loaded) { + checker_.set_no_version_warning(std::move(nvw)); + } + auto uti = co_await QtConcurrent::run( + [this] { return use_this_instead_service_.load_if_exists(); }); + const bool uti_loaded = !uti.isEmpty(); + if (uti_loaded) { + checker_.set_use_this_instead(std::move(uti)); + } + + const bool refresh_nvw = + needs_refresh(nvw_loaded, no_version_warning_service_.cache_age_s()); + const bool refresh_uti = needs_refresh(uti_loaded, use_this_instead_service_.cache_age_s()); + if ((refresh_nvw || refresh_uti) && (!db_task_ || db_task_->isReady())) { + db_task_ = download_databases(refresh_nvw, refresh_uti); + } +} + +QCoro::Task DiagnosticsService::download_databases(bool nvw, bool uti) { + background_task_changed.notify(QStringLiteral(u"Downloading rule databases\u2026")); + bool changed = false; + if (nvw) { + auto pids = co_await no_version_warning_service_.ensure(true); + if (!pids.isEmpty()) { + checker_.set_no_version_warning(std::move(pids)); + changed = true; + } + } + if (uti) { + auto entries = co_await use_this_instead_service_.ensure(true); + if (!entries.isEmpty()) { + checker_.set_use_this_instead(std::move(entries)); + changed = true; + } + } + if (changed) { + rebuild(); + } + background_task_changed.notify(QString{}); +} + +std::optional DiagnosticsService::community_rules_path() const { + const AppConfig& config = ctx_.config(); + if (!config.sort.use_community_rules) { + return std::nullopt; + } + QString path = QString::fromStdString(config.paths.community_rules_file); + if (path.isEmpty()) { + path = QDir(ctx_.configService().directory()).filePath(QStringLiteral("communityRules.json")); + } + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + return path; +} + +// ── Mutations ────────────────────────────────────────────── + +void DiagnosticsService::rebuild(Profiler* timer) { + rebuild(ctx_.activeUuids(), timer); +} + +void DiagnosticsService::rebuild(const QStringList& active_uuids, Profiler* timer) { + last_active_uuids_ = active_uuids; + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto scope = t("diagnostics.rebuild"); + checker_.rebuild(ctx_.allMods(), active_uuids, &t); +} + +void DiagnosticsService::reorder(const QStringList& active_uuids) { + last_active_uuids_ = active_uuids; + checker_.reorder(active_uuids); +} + +// ── Queries ──────────────────────────────────────────────── + +QHash DiagnosticsService::active_mods_by_pid() const { + return checker_.active_mods(); +} + +const checker::ConstraintGraph& DiagnosticsService::constraint_graph() const { + return checker_.graph(); +} + +QList DiagnosticsService::active_ordered_pids() const { + return checker_.ordered_pids(); +} + +const sort::CommunityRules& DiagnosticsService::community_rules() const { + return checker_.community_rules(); +} + +QList DiagnosticsService::issues_for(const QString& uuid) const { + const checker::ModDiagnostics* diag = checker_.diagnostics_for(uuid); + return diag != nullptr ? to_issue_views(*diag) : QList{}; +} + +std::optional DiagnosticsService::summary_for(const QString& uuid) const { + const auto it = last_summary_.constFind(uuid); + if (it == last_summary_.cend()) { + return std::nullopt; + } + return it.value(); +} + +int DiagnosticsService::active_error_count() const { + const auto diagnostics = checker_.active_mod_diagnostics(); + int count = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + count += diag.has_errors() ? 1 : 0; + } + return count; +} + +// ── Conversions ──────────────────────────────────────────── + +QList DiagnosticsService::to_issue_views(const checker::ModDiagnostics& diag) { + QList views; + const auto append = [&views](const QList& issues, bool is_error) { + for (const checker::ModIssue& issue : issues) { + view::ModIssueView view; + view.category = issue.category; + view.category_display_name = + issue.category_display_name.isEmpty() ? issue.category : issue.category_display_name; + if (!issue.detail_message.isEmpty()) { + view.detail = issue.detail_message; + } + view.is_error = is_error; + views.append(std::move(view)); + } + }; + append(diag.errors, true); + append(diag.warnings, false); + return views; +} + +view::ModDiagnosticsView DiagnosticsService::to_view(const checker::ModDiagnostics& diag) { + view::ModDiagnosticsView view; + view.has_errors = diag.has_errors(); + view.has_warnings = diag.has_warnings(); + view.error_tooltip = diag.error_tooltip(); + view.warning_tooltip = diag.warning_tooltip(); + return view; +} + +// ── Checker callback ─────────────────────────────────────── + +void DiagnosticsService::on_checker_diagnostics_changed( + const checker::DiagnosticsByUuid& diagnostics) { + QHash summary; + summary.reserve(diagnostics.size()); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + summary.insert(it.key(), to_view(it.value())); + } + last_summary_ = std::move(summary); + // Sidebar first: the summary handler re-applies the current sidebar filter and must see the + // entries for this state, not the previous one. + sidebar_entries_changed.notify(build_sidebar_entries()); + diagnostics_summary_changed.notify(last_summary_); + status_message_changed.notify(format_status()); +} + +// ── Status ───────────────────────────────────────────────── + +QString DiagnosticsService::format_status() const { + const auto diagnostics = checker_.active_mod_diagnostics(); + int err_count = 0; + int warn_count = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + err_count += diag.has_errors() ? 1 : 0; + warn_count += diag.has_warnings() ? 1 : 0; + } + + QStringList parts{QString::number(last_active_uuids_.size()) + QStringLiteral(" active")}; + if (err_count > 0) { + parts.append(QString::number(err_count) + QStringLiteral(" with errors")); + } + if (warn_count > 0) { + parts.append(QString::number(warn_count) + QStringLiteral(" with warnings")); + } + return parts.join(QStringLiteral(" | ")); +} + +// ── Sidebar ──────────────────────────────────────────────── + +QList DiagnosticsService::build_sidebar_entries() { + const QHash& mods = ctx_.allMods(); + if (!sidebar_base_ || !sidebar_base_->matches(mods)) { + sidebar_base_.emplace(mods); + } + const SidebarBase& base = *sidebar_base_; + + QSet errors = base.invalid; + QSet warnings; + const auto diagnostics = checker_.active_mod_diagnostics(); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + if (!mods.contains(it.key())) { + continue; + } + if (it.value().has_errors()) { + errors.insert(it.key()); + } + if (it.value().has_warnings()) { + warnings.insert(it.key()); + } + } + + const QSet active(last_active_uuids_.cbegin(), last_active_uuids_.cend()); + QSet inactive = base.all_uuids; + inactive.subtract(active); + + using Kind = view::SidebarEntry::Kind; + QList entries{ + make_entry(Kind::All, QStringLiteral("All"), base.all_uuids), + make_entry(Kind::Active, QStringLiteral("Active"), active), + make_entry(Kind::Inactive, QStringLiteral("Inactive"), std::move(inactive)), + make_entry(Kind::WithErrors, QStringLiteral("With errors"), std::move(errors)), + make_entry(Kind::WithWarnings, QStringLiteral("With warnings"), std::move(warnings)), + }; + for (auto it = base.by_provider.cbegin(); it != base.by_provider.cend(); ++it) { + entries.append(make_entry(Kind::Provider, + view::provider_label(it.key()), + it.value(), + it.key())); + } + return entries; +} + +DiagnosticsService::SidebarBase::SidebarBase(const QHash& current) + : mods(current) { + for (auto it = current.cbegin(); it != current.cend(); ++it) { + all_uuids.insert(it.key()); + by_provider[it.value()->provider_id].insert(it.key()); + if (!it.value()->valid) { + invalid.insert(it.key()); + } + } +} + +// Compares by mod identity: the same uuids mapping to the same objects. +bool DiagnosticsService::SidebarBase::matches(const QHash& current) const { + if (mods.size() != current.size()) { + return false; + } + for (auto it = current.cbegin(); it != current.cend(); ++it) { + const auto cached = mods.constFind(it.key()); + if (cached == mods.cend() || cached.value() != it.value()) { + return false; + } + } + return true; +} + +} // namespace px::core diff --git a/src/core/services/diagnostics_service.h b/src/core/services/diagnostics_service.h new file mode 100644 index 0000000..ba50dee --- /dev/null +++ b/src/core/services/diagnostics_service.h @@ -0,0 +1,103 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/checker/checker.h" +#include "core/checker/databases.h" +#include "core/checker/graph.h" +#include "core/checker/models.h" +#include "core/error.h" +#include "core/events.h" +#include "core/models/view/diagnostics.h" +#include "core/models/view/sidebar.h" +#include "core/profiler.h" +#include "core/sort/models.h" + +class QNetworkAccessManager; + +namespace px::core { + +class CoreContext; + +// Keeps the checker in step with the active mods and the rule databases, and publishes the +// diagnostics summary, the sidebar filters and the status line derived from it. +class DiagnosticsService { +public: + // `network` is not owned and must outlive the service. + DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network); + + Event> diagnostics_summary_changed; + Event status_message_changed; + Event background_task_changed; + Event> sidebar_entries_changed; + + // ── Lifecycle ───────────────────────────────────────────── + + // Applies cached databases and community rules, and starts a background refresh of stale + // databases. Fails when the community rules file is malformed. + QCoro::Task> initialize(Profiler* timer = nullptr); + + // ── Mutations ───────────────────────────────────────────── + + // Rebuilds the checker for the context's active mods, or for `active_uuids`. + void rebuild(Profiler* timer = nullptr); + void rebuild(const QStringList& active_uuids, Profiler* timer = nullptr); + + // Notifies the checker of a new active mod order. + void reorder(const QStringList& active_uuids); + + // ── Queries ─────────────────────────────────────────────── + + QHash active_mods_by_pid() const; + const checker::ConstraintGraph& constraint_graph() const; + QList active_ordered_pids() const; + const sort::CommunityRules& community_rules() const; + + // Views of every issue on a mod; empty when the mod is not active. + QList issues_for(const QString& uuid) const; + std::optional summary_for(const QString& uuid) const; + int active_error_count() const; + +private: + // Partitions that do not depend on diagnostics; reused until the mod list changes. + struct SidebarBase { + QHash mods; + QSet all_uuids; + QSet invalid; + QMap> by_provider; + + explicit SidebarBase(const QHash& current); + bool matches(const QHash& current) const; + }; + + QCoro::Task ensure_databases(); + QCoro::Task download_databases(bool nvw, bool uti); + // The community rules file to load, or nullopt when rules are disabled or no file exists. + std::optional community_rules_path() const; + + static QList to_issue_views(const checker::ModDiagnostics& diag); + static view::ModDiagnosticsView to_view(const checker::ModDiagnostics& diag); + + void on_checker_diagnostics_changed(const checker::DiagnosticsByUuid& diagnostics); + QString format_status() const; + QList build_sidebar_entries(); + + CoreContext& ctx_; + checker::NoVersionWarningService no_version_warning_service_; + checker::UseThisInsteadService use_this_instead_service_; + checker::ModChecker checker_; + std::optional> db_task_; + QStringList last_active_uuids_; + QHash last_summary_; + std::optional sidebar_base_; +}; + +} // namespace px::core diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp new file mode 100644 index 0000000..3312258 --- /dev/null +++ b/src/core/services/game_launcher.cpp @@ -0,0 +1,130 @@ +#include "core/services/game_launcher.h" + +#include +#include + +#include +#include +#include +#include + +#include "core/context.h" +#include "core/providers/base.h" + +#if defined(Q_OS_UNIX) +#include +#endif + +namespace px::core::services { + +namespace { + +Result direct_argv(const QString& exe, const AppConfig& cfg, QHash& env) { + const QString args = QString::fromStdString(cfg.launch_args); + if (current_platform() == Platform::MacOS) { + auto split = split_arguments(args); + if (!split) { + return std::unexpected(split.error()); + } + return QStringList{QStringLiteral("open"), exe, QStringLiteral("--args")} + *split; + } + auto command = build_direct_command(exe, QString::fromStdString(cfg.launch_wrapper), args); + if (!command) { + return std::unexpected(command.error()); + } + env = std::move(command->env); + return std::move(command->argv); +} + +} // namespace + +GameLauncher::GameLauncher(CoreContext& ctx, ProcessLister lister, MonitorTiming timing) + : ctx_(ctx), lister_(lister), monitor_(std::move(lister), timing) {} + +QCoro::Task> GameLauncher::launch(LaunchStrategy strategy) { + if (ctx_.config().paths.game.empty()) { + spdlog::warn("Launch aborted — game path not configured"); + co_return std::unexpected(Error{QStringLiteral("Game path not configured")}); + } + + spdlog::info("Launching game with strategy: {}", + strategy == LaunchStrategy::Direct ? "Direct" : "Steam"); + co_await monitor_.capture_baseline(); + if (strategy == LaunchStrategy::Direct) { + co_return co_await launch_direct(); + } + co_return co_await launch_steam(); +} + +bool GameLauncher::is_running() const { + return is_game_running(lister_); +} + +QCoro::Task GameLauncher::wait_for_start(std::chrono::milliseconds timeout) { + return monitor_.wait_for_start(timeout); +} + +QCoro::Task> GameLauncher::wait_for_exit() { + return monitor_.wait_for_exit(); +} + +QCoro::Task> GameLauncher::launch_direct() { + const QString game = QString::fromStdString(ctx_.config().paths.game); + const auto exe = find_executable(game); + if (!exe) { + spdlog::warn("Direct launch failed — executable not found in {}", game.toStdString()); + co_return std::unexpected(Error{QStringLiteral("Game executable not found")}); + } + + ensure_steam_appid(game); + + QHash env; + auto argv = direct_argv(*exe, ctx_.config(), env); + if (!argv) { + spdlog::warn("Invalid launch arguments: {}", argv.error().message.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Invalid launch arguments: %1").arg(argv.error().message)}); + } + + spdlog::info("Spawning process: {} (cwd: {})", argv->join(QLatin1Char(' ')).toStdString(), + game.toStdString()); + auto proc = std::make_unique(); + proc->setProgram(argv->front()); + proc->setArguments(argv->mid(1)); + proc->setWorkingDirectory(game); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + for (auto it = env.cbegin(); it != env.cend(); ++it) { + environment.insert(it.key(), it.value()); + } + proc->setProcessEnvironment(environment); + proc->setStandardOutputFile(QProcess::nullDevice()); + proc->setStandardErrorFile(QProcess::nullDevice()); +#if defined(Q_OS_UNIX) + // The game gets its own session, as it did under start_new_session=True. + proc->setChildProcessModifier([] { ::setsid(); }); +#endif + proc->start(); + if (!proc->waitForStarted()) { + const QString reason = proc->errorString(); + spdlog::warn("Direct launch failed: {}", reason.toStdString()); + co_return std::unexpected(Error{QStringLiteral("Failed to launch: %1").arg(reason)}); + } + + monitor_.attach_process(std::move(proc)); + co_return QStringLiteral("Game launched"); +} + +QCoro::Task> GameLauncher::launch_steam() { + const QString args = QString::fromStdString(ctx_.config().launch_args); + auto opened = co_await providers::runOnPool>(nullptr, [args] { + return open_steam_url(args); + }); + if (!opened) { + spdlog::warn("Steam launch failed: {}", opened.error().message.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Steam launch failed: %1").arg(opened.error().message)}); + } + co_return QStringLiteral("Launching via Steam..."); +} + +} // namespace px::core::services diff --git a/src/core/services/game_launcher.h b/src/core/services/game_launcher.h new file mode 100644 index 0000000..f9d805d --- /dev/null +++ b/src/core/services/game_launcher.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include +#include + +#include "core/constants.h" +#include "core/error.h" +#include "core/services/game_process.h" + +namespace px::core { +class CoreContext; +} + +namespace px::core::services { + +// Starts RimWorld directly or through Steam, and tracks the game until it exits. +class GameLauncher { +public: + // `ctx` is not owned and must outlive the launcher. + GameLauncher(CoreContext& ctx, ProcessLister lister = host_process_lister, + MonitorTiming timing = {}); + + // Resolves to a status message when the launch was started, or to the reason it was not. + QCoro::Task> launch(LaunchStrategy strategy); + + // Whether a game process is running right now. Blocks while listing processes. + bool is_running() const; + + QCoro::Task wait_for_start(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + + // Resolves to the spawned process's exit code, or nullopt when it was not spawned here or + // has not exited. + QCoro::Task> wait_for_exit(); + +private: + QCoro::Task> launch_direct(); + QCoro::Task> launch_steam(); + + CoreContext& ctx_; + ProcessLister lister_; + GameProcessMonitor monitor_; +}; + +} // namespace px::core::services diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp new file mode 100644 index 0000000..35f4375 --- /dev/null +++ b/src/core/services/game_process.cpp @@ -0,0 +1,453 @@ +#include "core/services/game_process.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/constants.h" +#include "core/file_io.h" +#include "core/providers/base.h" + +#if defined(Q_OS_LINUX) +#include +#elif defined(Q_OS_WIN) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#elif defined(Q_OS_MACOS) +#include +#include +#include +#include +#endif + +namespace px::core::services { + +namespace { + +const QSet kProcessNames = { + QStringLiteral("RimWorld"), + QStringLiteral("RimWorldLinux"), + QStringLiteral("RimWorldWin64.exe"), + QStringLiteral("RimWorldWin.exe"), + QStringLiteral("RimWorld.exe"), + QStringLiteral("RimWorld by Ludeon Studios"), +}; + +// Simple POSIX shlex-style tokenizer. Handles single/double quotes and backslash escapes. +Result split_posix(const QString& text) { + QStringList tokens; + QString current; + enum State { Space, Word, SingleQuote, DoubleQuote } state = Space; + bool escaped = false; + + for (const QChar ch : text) { + if (escaped) { + if (state == DoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && ch != u'`') { + current.append(u'\\'); + } + current.append(ch); + escaped = false; + continue; + } + + switch (state) { + case Space: + if (ch.isSpace()) { + continue; + } + if (ch == u'\\') { + escaped = true; + state = Word; + } else if (ch == u'\'') { + state = SingleQuote; + } else if (ch == u'"') { + state = DoubleQuote; + } else { + current.append(ch); + state = Word; + } + break; + + case Word: + if (ch.isSpace()) { + tokens.append(current); + current.clear(); + state = Space; + } else if (ch == u'\\') { + escaped = true; + } else if (ch == u'\'') { + state = SingleQuote; + } else if (ch == u'"') { + state = DoubleQuote; + } else { + current.append(ch); + } + break; + + case SingleQuote: + if (ch == u'\'') { + state = Word; + } else { + current.append(ch); + } + break; + + case DoubleQuote: + if (ch == u'\\') { + escaped = true; + } else if (ch == u'"') { + state = Word; + } else { + current.append(ch); + } + break; + } + } + + if (escaped || state == SingleQuote || state == DoubleQuote) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (state == Word || !current.isEmpty()) { + tokens.append(current); + } + return tokens; +} + +// Python's shlex.split(posix=False), which the Windows launch path uses: quotes are kept in the +// token and there are no escapes, so whitespace inside quotes does not split. +Result split_windows(const QString& text) { + QStringList tokens; + QString current; + bool inToken = false; + QChar quote; + for (const QChar ch : text) { + if (!quote.isNull()) { + current.append(ch); + if (ch == quote) { + quote = QChar(); + } + continue; + } + if (ch == u'"' || ch == u'\'') { + quote = ch; + current.append(ch); + inToken = true; + } else if (ch.isSpace()) { + if (inToken) { + tokens.append(current); + current.clear(); + inToken = false; + } + } else { + current.append(ch); + inToken = true; + } + } + if (!quote.isNull()) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (inToken) { + tokens.append(current); + } + return tokens; +} + +bool is_env_assignment(const QString& token) { + static const QRegularExpression pattern(QStringLiteral("^[A-Za-z_][A-Za-z0-9_]*=")); + return pattern.match(token).hasMatch(); +} + +} // namespace + +Platform current_platform() { +#if defined(Q_OS_MACOS) + return Platform::MacOS; +#elif defined(Q_OS_WIN) + return Platform::Windows; +#else + return Platform::Linux; +#endif +} + +Result split_arguments(const QString& text, Platform platform) { + return platform == Platform::Windows ? split_windows(text) : split_posix(text); +} + +Result build_direct_command(const QString& exe, const QString& wrapper, + const QString& args, Platform platform) { + auto splitArgs = split_arguments(args, platform); + if (!splitArgs) { + return std::unexpected(splitArgs.error()); + } + auto splitWrapper = split_arguments(wrapper, platform); + if (!splitWrapper) { + return std::unexpected(splitWrapper.error()); + } + + QStringList game; + game.append(exe); + game.append(*splitArgs); + + QStringList tokens = *splitWrapper; + DirectCommand result; + while (!tokens.isEmpty() && is_env_assignment(tokens.front())) { + const QString token = tokens.takeFirst(); + const qsizetype eq = token.indexOf(u'='); + result.env.insert(token.left(eq), token.mid(eq + 1)); + } + + const qsizetype placeholder = tokens.indexOf(QStringLiteral("%command%")); + if (placeholder >= 0) { + result.argv = tokens.mid(0, placeholder); + result.argv.append(game); + result.argv.append(tokens.mid(placeholder + 1)); + } else { + result.argv = tokens; + result.argv.append(game); + } + return result; +} + +std::optional find_executable(const QString& game_path, Platform platform) { + const QDir dir(game_path); + if (platform == Platform::Linux) { + for (const char* name : {"RimWorld", "RimWorldLinux"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + const QFileInfo info(candidate); + if (info.isFile() && info.isExecutable()) { + return candidate; + } + } + return std::nullopt; + } + if (platform == Platform::MacOS) { + const QString primary = dir.filePath(QStringLiteral("RimWorld.app")); + if (QFileInfo(primary).isDir()) { + return primary; + } + QStringList apps = dir.entryList(QStringList{QStringLiteral("*.app")}, QDir::Dirs); + apps.sort(); + if (!apps.isEmpty()) { + return dir.filePath(apps.first()); + } + return std::nullopt; + } + for (const char* name : {"RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + } + return std::nullopt; +} + +void ensure_steam_appid(const QString& game_path, Platform platform) { + const QString targetDir = + platform == Platform::MacOS ? QFileInfo(game_path).dir().absolutePath() : game_path; + const QString path = QDir(targetDir).filePath(QStringLiteral("steam_appid.txt")); + if (QFileInfo::exists(path)) { + return; + } + const std::string_view appId(RIMWORLD_STEAM_APP_ID.data(), RIMWORLD_STEAM_APP_ID.size()); + if (auto written = write_file_atomically(path, appId); !written) { + spdlog::warn("Failed to create steam_appid.txt: {}", written.error().message.toStdString()); + } else { + spdlog::debug("Created {}", path.toStdString()); + } +} + +QString steam_run_url(const QString& launch_args) { + const QString trimmed = launch_args.trimmed(); + if (trimmed.isEmpty()) { + return QStringLiteral("steam://rungameid/%1").arg(QString(RIMWORLD_STEAM_APP_ID)); + } + const QByteArray encoded = QUrl::toPercentEncoding(trimmed); + return QStringLiteral("steam://run/%1//%2/") + .arg(QString(RIMWORLD_STEAM_APP_ID), QString::fromUtf8(encoded)); +} + +Result open_steam_url(const QString& launch_args) { + const QString url = steam_run_url(launch_args); + spdlog::info("Opening Steam URL: {}", url.toStdString()); +#if defined(Q_OS_WIN) + const bool started = QProcess::startDetached( + QStringLiteral("cmd"), + {QStringLiteral("/c"), QStringLiteral("start"), QString(), url}); +#elif defined(Q_OS_MACOS) + const bool started = QProcess::startDetached(QStringLiteral("open"), {url}); +#else + const bool started = QProcess::startDetached(QStringLiteral("xdg-open"), {url}); +#endif + if (!started) { + return std::unexpected(Error{QStringLiteral("could not start the system URL opener")}); + } + return {}; +} + +std::vector host_process_lister() { + std::vector procs; +#if defined(Q_OS_LINUX) + DIR* proc = opendir("/proc"); + if (!proc) { + return procs; + } + while (struct dirent* entry = readdir(proc)) { + if (entry->d_type != DT_DIR) { + continue; + } + bool ok = false; + const qint64 pid = QString::fromLatin1(entry->d_name).toLongLong(&ok); + if (!ok || pid <= 0) { + continue; + } + QFile file(QStringLiteral("/proc/%1/stat").arg(pid)); + if (!file.open(QIODevice::ReadOnly)) { + continue; + } + const QByteArray stat = file.readAll(); + const qsizetype openParen = stat.indexOf('('); + const qsizetype closeParen = stat.lastIndexOf(')'); + if (openParen < 0 || closeParen <= openParen) { + continue; + } + const QString comm = QString::fromUtf8(stat.mid(openParen + 1, closeParen - openParen - 1)); + const QByteArray rest = stat.mid(closeParen + 2); + const bool zombie = !rest.isEmpty() && rest.at(0) == 'Z'; + procs.push_back(ProcessInfo{pid, comm, zombie}); + } + closedir(proc); +#elif defined(Q_OS_WIN) + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return procs; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + for (BOOL more = Process32FirstW(snapshot, &entry); more; more = Process32NextW(snapshot, &entry)) { + // The toolhelp snapshot does not expose process state, so zombies are never seen. + procs.push_back(ProcessInfo{static_cast(entry.th32ProcessID), + QString::fromWCharArray(entry.szExeFile), false}); + } + CloseHandle(snapshot); +#elif defined(Q_OS_MACOS) + // proc_listallpids takes a buffer size in bytes and returns the number of pids written. + std::vector pids(static_cast(proc_listallpids(nullptr, 0)) + 64); + const int written = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); + // SZOMBIE from , which is not usable from user code. + constexpr std::uint32_t kZombieStatus = 5; + for (int i = 0; i < written; ++i) { + const pid_t pid = pids[static_cast(i)]; + char name[256] = {}; + if (proc_name(pid, name, sizeof(name)) <= 0) { + continue; + } + proc_bsdinfo info{}; + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != static_cast(sizeof(info))) { + continue; + } + procs.push_back(ProcessInfo{pid, QString::fromUtf8(name), info.pbi_status == kZombieStatus}); + } +#endif + return procs; +} + +QSet live_game_pids(const ProcessLister& lister) { + QSet pids; + for (const ProcessInfo& proc : lister()) { + if (!proc.zombie && kProcessNames.contains(proc.name)) { + pids.insert(proc.pid); + } + } + return pids; +} + +bool is_game_running(const ProcessLister& lister) { + return !live_game_pids(lister).isEmpty(); +} + +GameProcessMonitor::GameProcessMonitor(ProcessLister lister, MonitorTiming timing) + : lister_(std::move(lister)), timing_(timing) {} + +GameProcessMonitor::~GameProcessMonitor() { + drop_process(); +} + +QCoro::Task GameProcessMonitor::capture_baseline() { + baseline_ = co_await providers::runOnPool>(nullptr, [this] { + return live_game_pids(lister_); + }); +} + +void GameProcessMonitor::attach_process(std::unique_ptr proc) { + drop_process(); + proc_ = std::move(proc); +} + +bool GameProcessMonitor::launched_alive() const { + const QSet live = live_game_pids(lister_); + return !(live - baseline_).isEmpty(); +} + +void GameProcessMonitor::drop_process() { + if (proc_ && proc_->state() != QProcess::NotRunning) { + // Intentionally leaked: the child keeps running after the monitor is gone, as it did + // under Popen, and destroying a running QProcess would kill it. + static_cast(proc_.release()); + return; + } + proc_.reset(); +} + +QCoro::Task GameProcessMonitor::wait_for_start(std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + const bool alive = co_await providers::runOnPool(nullptr, [this] { + return launched_alive(); + }); + if (alive) { + co_return true; + } + if (proc_ && proc_->state() == QProcess::NotRunning) { + co_return false; + } + if (timing_.poll_start.count() > 0) { + co_await QCoro::sleepFor(timing_.poll_start); + } + } + co_return false; +} + +QCoro::Task> GameProcessMonitor::wait_for_exit() { + while (co_await providers::runOnPool(nullptr, [this] { return launched_alive(); })) { + if (timing_.poll_exit.count() > 0) { + co_await QCoro::sleepFor(timing_.poll_exit); + } + } + if (!proc_) { + co_return std::nullopt; + } + std::optional code; + if (proc_->state() == QProcess::NotRunning) { + code = proc_->exitCode(); + } + drop_process(); + co_return code; +} + +} // namespace px::core::services diff --git a/src/core/services/game_process.h b/src/core/services/game_process.h new file mode 100644 index 0000000..095a2b2 --- /dev/null +++ b/src/core/services/game_process.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "core/error.h" + +namespace px::core::services { + +enum class Platform { + Linux, + Windows, + MacOS, +}; + +// Returns the host's platform. +Platform current_platform(); + +// Splits a command line the way the platform's launcher does: POSIX shell quoting, or the +// Windows command-line rules. Fails on unbalanced quotes. +Result split_arguments(const QString& text, Platform platform = current_platform()); + +struct DirectCommand { + QStringList argv; + QHash env; +}; + +// Expands wrapper and arguments into argv plus extra environment variables. Leading KEY=VALUE +// tokens in the wrapper set environment variables. %command% marks where the game goes; +// without it the wrapper is a prefix. Fails on unbalanced quotes. +Result build_direct_command(const QString& exe, const QString& wrapper, + const QString& args, + Platform platform = current_platform()); + +// Locates the RimWorld executable inside `game_path`, preferring modern Waylith binaries over +// leftover Unity files on disk. +std::optional find_executable(const QString& game_path, + Platform platform = current_platform()); + +// Writes steam_appid.txt containing the RimWorld Steam app id into the game folder, unless it +// already exists. On macOS the file lives in the game directory's parent. +void ensure_steam_appid(const QString& game_path, Platform platform = current_platform()); + +// The steam:// URL that launches RimWorld with the given launch arguments. +QString steam_run_url(const QString& launch_args); + +// Hands the steam:// URL to the desktop's handler. Fails when no opener could be started. +Result open_steam_url(const QString& launch_args); + +// A running process seen on the host. +struct ProcessInfo { + qint64 pid = 0; + QString name; + bool zombie = false; +}; + +// Enumerates processes; an injectable double replaces this in tests. +using ProcessLister = std::function()>; + +// Reads /proc on Linux, Toolhelp32Snapshot on Windows, libproc on macOS. +std::vector host_process_lister(); + +// Returns true when a non-zombie RimWorld game process is currently running. +bool is_game_running(const ProcessLister& lister = host_process_lister); + +// Returns the set of non-zombie RimWorld PIDs found by `lister`. +QSet live_game_pids(const ProcessLister& lister = host_process_lister); + +struct MonitorTiming { + std::chrono::milliseconds poll_start{500}; + std::chrono::milliseconds poll_exit{2000}; +}; + +// Tracks the game process lifecycle: detects when a newly spawned game process starts, and when +// all game processes have finished. Process listing runs on the thread pool, so awaiting the +// methods never blocks the event loop. +class GameProcessMonitor { +public: + explicit GameProcessMonitor(ProcessLister lister = host_process_lister, + MonitorTiming timing = {}); + ~GameProcessMonitor(); + + GameProcessMonitor(const GameProcessMonitor&) = delete; + GameProcessMonitor& operator=(const GameProcessMonitor&) = delete; + + // Records the game PIDs already running, so they are not counted as newly started. + QCoro::Task capture_baseline(); + + // Takes ownership of the spawned process; it must already be started. + void attach_process(std::unique_ptr proc); + + // Waits until a non-baseline game process appears. Returns false if the timeout expires or + // the spawned process dies before a game process appears. + QCoro::Task wait_for_start(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + + // Waits until every non-baseline game process exits. Returns the spawned process's exit code + // if one was attached and has exited, or nullopt otherwise. + QCoro::Task> wait_for_exit(); + +private: + bool launched_alive() const; + // Forgets the spawned process. A child still running is released rather than destroyed, + // because destroying a QProcess kills its child and the game must outlive the manager. + void drop_process(); + + ProcessLister lister_; + MonitorTiming timing_; + QSet baseline_; + std::unique_ptr proc_; +}; + +} // namespace px::core::services diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp new file mode 100644 index 0000000..31c3340 --- /dev/null +++ b/src/core/services/metadata_cache.cpp @@ -0,0 +1,624 @@ +#include "core/services/metadata_cache.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/migrator.h" +#include "core/models/metadata/structures.h" + +namespace px::core::services { + +using providers::CacheLookup; +using providers::CachedMod; +using providers::ModCandidate; + +namespace { + +constexpr int kSchemaVersion = 2; +constexpr qsizetype kFetchChunk = 500; +constexpr double kMtimeTolerance = 1e-4; +constexpr int kMaxSymlinkHops = 40; + +constexpr char kSchema[] = R"sql( + CREATE TABLE IF NOT EXISTS mod_metadata ( + mod_path TEXT PRIMARY KEY, + about_path TEXT NOT NULL, + mtime REAL NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + target_version TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mm_about + ON mod_metadata(about_path); +)sql"; + +Result exec(QSqlDatabase& db, const QString& sql) { + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; +} + +// ---- Path normalization ------------------------------------------------------------------- + +QString join_path(const QString& dir, const QString& name) { + return dir.endsWith(u'/') ? dir + name : dir + u'/' + name; +} + +// "/" for POSIX paths, "C:/" for drive paths. +QString root_of(const QString& absolute) { + if (absolute.size() >= 3 && absolute[0].isLetter() && absolute[1] == u':' && + absolute[2] == u'/') { + return absolute.left(3); + } + return QStringLiteral("/"); +} + +QString parent_of(const QString& resolved, const QString& root) { + if (resolved.size() <= root.size()) { + return root; + } + const QString parent = resolved.left(resolved.lastIndexOf(u'/')); + return parent.size() < root.size() ? root : parent; +} + +// Resolves symlinks component by component, consuming ".." after the link target has been +// followed, as Python's posixpath.realpath does. +QString resolve_path(const QString& path) { + QString absolute = QDir::fromNativeSeparators(path); + if (!QDir::isAbsolutePath(absolute)) { + absolute = join_path(QDir::currentPath(), absolute); + } + const QString root = root_of(absolute); + QStringList pending = absolute.mid(root.size()).split(u'/', Qt::SkipEmptyParts); + QString resolved = root; + int hops = 0; + while (!pending.isEmpty()) { + const QString name = pending.takeFirst(); + if (name == QLatin1String(".")) { + continue; + } + if (name == QLatin1String("..")) { + resolved = parent_of(resolved, root); + continue; + } + const QString candidate = join_path(resolved, name); + if (!QFileInfo(candidate).isSymLink()) { + resolved = candidate; + continue; + } + if (++hops > kMaxSymlinkHops) { + return candidate; + } + const QString target = QDir::fromNativeSeparators(QFile::symLinkTarget(candidate)); + if (QDir::isAbsolutePath(target)) { + resolved = root; + pending = target.mid(root_of(target).size()).split(u'/', Qt::SkipEmptyParts) + pending; + } else { + pending = target.split(u'/', Qt::SkipEmptyParts) + pending; + } + } + return resolved; +} + +// ---- Payload (de)serialization ------------------------------------------------------------ + +QJsonArray sorted_array(const QStringList& values) { + QStringList sorted = values; + sorted.sort(); + return QJsonArray::fromStringList(sorted); +} + +template +QStringList sorted_strings(const Set& set) { + QStringList values; + values.reserve(set.size()); + for (const auto& item : set) { + values.append(item.str()); + } + values.sort(); + return values; +} + +QJsonObject serialize_mod(const AboutXmlMod& mod) { + QJsonObject object; + object[QStringLiteral("name")] = mod.name; + object[QStringLiteral("package_id")] = mod.package_id.str(); + object[QStringLiteral("authors")] = QJsonArray::fromStringList(mod.authors); + object[QStringLiteral("mod_version")] = mod.mod_version; + object[QStringLiteral("mod_icon_path")] = + mod.mod_icon_path ? QJsonValue(*mod.mod_icon_path) : QJsonValue(QJsonValue::Null); + object[QStringLiteral("steam_app_id")] = QJsonValue(static_cast(mod.steam_app_id)); + object[QStringLiteral("url")] = mod.url; + object[QStringLiteral("description")] = mod.description; + QStringList versions = mod.supported_versions.values(); + versions.sort(); + object[QStringLiteral("supported_versions")] = QJsonArray::fromStringList(versions); + object[QStringLiteral("valid")] = mod.valid; + object[QStringLiteral("mtime")] = mod.mtime; + + // Declaration order matters to the checker, which QJsonObject would sort away, so + // dependencies are an array here. deserialize_mod still reads the legacy object form. + QJsonArray dependencies; + for (const DependencyMod& dep : mod.about_rules.dependencies) { + QJsonObject entry; + entry[QStringLiteral("name")] = dep.name; + entry[QStringLiteral("package_id")] = dep.package_id.str(); + entry[QStringLiteral("workshop_url")] = dep.workshop_url; + entry[QStringLiteral("alternative_package_ids")] = + sorted_array(sorted_strings(dep.alternative_package_ids)); + dependencies.append(entry); + } + QJsonObject rules; + rules[QStringLiteral("load_before")] = sorted_array(sorted_strings(mod.about_rules.load_before)); + rules[QStringLiteral("load_after")] = sorted_array(sorted_strings(mod.about_rules.load_after)); + rules[QStringLiteral("incompatible_with")] = + sorted_array(sorted_strings(mod.about_rules.incompatible_with)); + rules[QStringLiteral("dependencies")] = dependencies; + object[QStringLiteral("about_rules")] = rules; + return object; +} + +QString string_or(const QJsonObject& object, const char* key, const QString& fallback) { + const QJsonValue value = object.value(QLatin1StringView(key)); + return value.isString() ? value.toString() : fallback; +} + +QStringList string_list(const QJsonValue& value) { + QStringList values; + for (const QJsonValue& item : value.toArray()) { + if (item.isString()) { + values.append(item.toString()); + } + } + return values; +} + +void add_dependency(BaseRules& rules, const QJsonObject& data, const QString& fallback_pid) { + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(string_or(data, "package_id", fallback_pid)); + dep.name = string_or(data, "name", QString()); + dep.workshop_url = string_or(data, "workshop_url", QString()); + dep.alternative_package_ids = CaseInsensitiveSet(string_list(data.value(QLatin1StringView("alternative_package_ids")))); + rules.set_dependency(std::move(dep)); +} + +void read_dependencies(BaseRules& rules, const QJsonValue& value) { + if (value.isArray()) { + for (const QJsonValue& item : value.toArray()) { + if (item.isObject()) { + add_dependency(rules, item.toObject(), QStringLiteral("invalid.mod")); + } + } + } else if (value.isObject()) { + const QJsonObject legacy = value.toObject(); + for (auto it = legacy.constBegin(); it != legacy.constEnd(); ++it) { + if (it.value().isObject()) { + add_dependency(rules, it.value().toObject(), it.key()); + } + } + } +} + +// The payload as a fresh AboutXmlMod, without a mod path; the caller sets it. +Result> deserialize_mod(const QString& payload) { + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8()); + if (document.isNull() || !document.isObject()) { + return std::unexpected(Error{QStringLiteral("payload is not a JSON object")}); + } + const QJsonObject data = document.object(); + + auto mod = std::make_shared(); + mod->name = string_or(data, "name", QStringLiteral("Unknown Mod Name")); + mod->package_id = CaseInsensitiveStr(string_or(data, "package_id", QStringLiteral("invalid.mod"))); + mod->authors = string_list(data.value(QLatin1StringView("authors"))); + mod->mod_version = string_or(data, "mod_version", QString()); + const QString icon = string_or(data, "mod_icon_path", QString()); + if (!icon.isEmpty()) { + mod->mod_icon_path = icon; + } + mod->steam_app_id = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); + mod->url = string_or(data, "url", QString()); + mod->description = string_or(data, "description", QString()); + const QStringList versions = string_list(data.value(QLatin1StringView("supported_versions"))); + mod->supported_versions = QSet(versions.begin(), versions.end()); + mod->valid = data.value(QLatin1StringView("valid")).toBool(true); + mod->mtime = data.value(QLatin1StringView("mtime")).toDouble(0.0); + + const QJsonValue rules_value = data.value(QLatin1StringView("about_rules")); + const QJsonObject rules_data = rules_value.isObject() ? rules_value.toObject() : QJsonObject{}; + mod->about_rules.load_before = CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("load_before")))); + mod->about_rules.load_after = CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("load_after")))); + mod->about_rules.incompatible_with = + CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("incompatible_with")))); + read_dependencies(mod->about_rules, rules_data.value(QLatin1StringView("dependencies"))); + return mod; +} + +// ---- Batch bookkeeping -------------------------------------------------------------------- + +// Candidates keyed by normalized mod path. Like Python's dict, a repeated key keeps its first +// position and takes the later value. +struct NormalizedCandidates { + QList> entries; + QHash index; + + void insert(QString key, const ModCandidate& candidate) { + if (const auto found = index.constFind(key); found != index.constEnd()) { + entries[*found].second = candidate; + return; + } + index.insert(key, entries.size()); + entries.append({std::move(key), candidate}); + } +}; + +NormalizedCandidates normalize_candidates(const QList& candidates) { + QStringList mod_paths; + mod_paths.reserve(candidates.size()); + for (const ModCandidate& candidate : candidates) { + mod_paths.append(candidate.modPath); + } + const QStringList normalized = normalize_paths(mod_paths); + NormalizedCandidates result; + for (qsizetype i = 0; i < candidates.size(); ++i) { + result.insert(normalized[i], candidates[i]); + } + return result; +} + +struct StoredRow { + QString mod_path; + double mtime = 0.0; + qint64 file_size = 0; + QString target_version; + QString payload; +}; + +Result> fetch_rows(QSqlDatabase& db, const QStringList& norm_paths) { + QList rows; + for (qsizetype start = 0; start < norm_paths.size(); start += kFetchChunk) { + const qsizetype count = std::min(kFetchChunk, norm_paths.size() - start); + QStringList placeholders(count, QStringLiteral("?")); + QSqlQuery query(db); + const QString sql = QStringLiteral("SELECT mod_path, about_path, mtime, file_size, " + "target_version, payload_json FROM mod_metadata " + "WHERE mod_path IN (%1)") + .arg(placeholders.join(QStringLiteral(","))); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (qsizetype i = 0; i < count; ++i) { + query.addBindValue(norm_paths[start + i]); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + while (query.next()) { + rows.append(StoredRow{query.value(0).toString(), query.value(2).toDouble(), + query.value(3).toLongLong(), query.value(4).toString(), + query.value(5).toString()}); + } + } + return rows; +} + +} // namespace + +QString metadata_cache_path(const QString& config_dir) { + return QDir(config_dir).filePath(QStringLiteral("metadata-cache.db")); +} + +QString normalize_path(const QString& path) { + return resolve_path(path); +} + +QStringList normalize_paths(const QStringList& paths) { + QHash resolved_parents; + QStringList out; + out.reserve(paths.size()); + for (const QString& raw : paths) { + const QString path = QDir::fromNativeSeparators(raw); + const qsizetype slash = path.lastIndexOf(u'/'); + const QString tail = slash < 0 ? QString() : path.mid(slash + 1); + if (slash < 0 || tail.isEmpty() || tail == QLatin1String(".") || tail == QLatin1String("..") || + QFileInfo(raw).isSymLink()) { + out.append(resolve_path(path)); + continue; + } + const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); + auto memo = resolved_parents.constFind(head); + if (memo == resolved_parents.constEnd()) { + memo = resolved_parents.insert(head, resolve_path(head)); + } + out.append(join_path(*memo, tail)); + } + return out; +} + +MetadataCache::MetadataCache(QString path) + : path_(std::move(path)), + connection_name_(QStringLiteral("px-metadata-cache-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + worker_.setMaxThreadCount(1); + // Keeps the single worker alive between calls, so the connection stays on one thread. + worker_.setExpiryTimeout(-1); +} + +MetadataCache::~MetadataCache() { + providers::runOnPool(&worker_, [this] { close_connection(); }).waitForFinished(); +} + +QCoro::Task MetadataCache::get_mods(QList candidates, + QString target_version) { + if (candidates.isEmpty()) { + co_return CacheLookup{}; + } + co_return co_await providers::runOnPool( + &worker_, [this, candidates = std::move(candidates), + target_version = std::move(target_version)] { + return lookup(candidates, target_version); + }); +} + +QCoro::Task MetadataCache::put_mods(QList entries, QString target_version) { + if (entries.isEmpty()) { + co_return; + } + co_await providers::runOnPool( + &worker_, [this, entries = std::move(entries), target_version = std::move(target_version)] { + store(entries, target_version); + }); +} + +QCoro::Task MetadataCache::prune_disappeared(QSet live_mod_paths) { + co_await providers::runOnPool( + &worker_, [this, live = std::move(live_mod_paths)] { prune(live); }); +} + +QCoro::Task MetadataCache::close() { + co_await providers::runOnPool(&worker_, [this] { close_connection(); }); +} + +void MetadataCache::close_connection() { + if (connection_) { + connection_->close(); + connection_.reset(); + QSqlDatabase::removeDatabase(connection_name_); + } +} + +QSqlDatabase* MetadataCache::open_connection() { + if (disabled_) { + return nullptr; + } + if (connection_) { + return &*connection_; + } + + if (!QDir().mkpath(QFileInfo(path_).absolutePath())) { + spdlog::warn("Metadata cache unavailable at {}: cannot create its directory. Disabling cache.", + path_.toStdString()); + disabled_ = true; + return nullptr; + } + if (auto opened = open_database(); !opened) { + spdlog::warn("Corrupt metadata cache database at {}: {}. Attempting recovery.", + path_.toStdString(), opened.error().message.toStdString()); + remove_database_files(); + if (auto reopened = open_database(); !reopened) { + spdlog::error("Failed to re-initialize metadata cache at {}: {}. Disabling cache.", + path_.toStdString(), reopened.error().message.toStdString()); + disabled_ = true; + return nullptr; + } + } + return &*connection_; +} + +Result MetadataCache::open_database() { + Result ready; + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connection_name_); + db.setDatabaseName(path_); + if (db.open()) { + ready = Result{}; + } else { + ready = std::unexpected(Error{db.lastError().text()}); + } + for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (!ready) { + break; + } + ready = exec(db, QString::fromLatin1(pragma)); + } + if (ready) { + MigrationSteps steps; + steps[kSchemaVersion] = [&db] { return exec(db, QStringLiteral("DELETE FROM mod_metadata")); }; + ready = ensure_schema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, path_, + QStringLiteral("metadata cache schema")); + } + if (ready) { + connection_ = db; + return {}; + } + db.close(); + } + // Dropped only once no handle to the connection remains. + QSqlDatabase::removeDatabase(connection_name_); + return ready; +} + +void MetadataCache::remove_database_files() const { + for (const QString& file : {path_, path_ + QStringLiteral("-wal"), path_ + QStringLiteral("-shm")}) { + QFile::remove(file); + } +} + +CacheLookup MetadataCache::lookup(const QList& candidates, + const QString& target_version) { + QSqlDatabase* db = open_connection(); + if (!db) { + return CacheLookup{{}, candidates}; + } + + const NormalizedCandidates normalized = normalize_candidates(candidates); + QStringList keys; + keys.reserve(normalized.entries.size()); + for (const auto& entry : normalized.entries) { + keys.append(entry.first); + } + + auto rows = fetch_rows(*db, keys); + if (!rows) { + spdlog::warn("Error reading metadata cache: {}", rows.error().message.toStdString()); + return CacheLookup{{}, candidates}; + } + + CacheLookup result; + QSet found; + for (const StoredRow& row : *rows) { + const auto index = normalized.index.constFind(row.mod_path); + if (index == normalized.index.constEnd()) { + continue; + } + const ModCandidate& candidate = normalized.entries[*index].second; + if (std::abs(row.mtime - candidate.mtime) >= kMtimeTolerance || + row.file_size != candidate.size || row.target_version != target_version) { + continue; + } + auto mod = deserialize_mod(row.payload); + if (!mod) { + spdlog::warn("Corrupted metadata cache payload for {}: {}", row.mod_path.toStdString(), + mod.error().message.toStdString()); + continue; + } + if (auto set = (*mod)->set_mod_path(candidate.modPath); !set) { + continue; + } + result.cached.append(*mod); + found.insert(row.mod_path); + } + for (const auto& entry : normalized.entries) { + if (!found.contains(entry.first)) { + result.missing.append(entry.second); + } + } + return result; +} + +void MetadataCache::store(const QList& entries, const QString& target_version) { + QSqlDatabase* db = open_connection(); + if (!db) { + return; + } + + struct Row { + QString mod_path; + QString about_path; + double mtime; + qint64 file_size; + QString payload; + }; + QList rows; + for (const CachedMod& entry : entries) { + if (!entry.mod->mod_path()) { + continue; + } + const QJsonDocument document(serialize_mod(*entry.mod)); + rows.append(Row{normalize_path(*entry.mod->mod_path()), + normalize_path(entry.candidate.aboutXmlPath), entry.candidate.mtime, + entry.candidate.size, QString::fromUtf8(document.toJson(QJsonDocument::Compact))}); + } + + QSqlQuery query(*db); + if (!query.prepare(QStringLiteral("INSERT OR REPLACE INTO mod_metadata " + "(mod_path, about_path, mtime, file_size, target_version, " + "payload_json) VALUES (?, ?, ?, ?, ?, ?)"))) { + spdlog::warn("Error writing to metadata cache: {}", query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const Row& row : rows) { + query.addBindValue(row.mod_path); + query.addBindValue(row.about_path); + query.addBindValue(row.mtime); + query.addBindValue(row.file_size); + query.addBindValue(target_version); + query.addBindValue(row.payload); + if (!query.exec()) { + spdlog::warn("Error writing to metadata cache: {}", query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error writing to metadata cache: {}", db->lastError().text().toStdString()); + } +} + +void MetadataCache::prune(const QSet& live_mod_paths) { + QSqlDatabase* db = open_connection(); + if (!db) { + return; + } + + QStringList cached; + { + QSqlQuery query(*db); + if (!query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata"))) { + spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); + return; + } + while (query.next()) { + cached.append(query.value(0).toString()); + } + } + + QStringList disappeared; + for (const QString& path : cached) { + if (!live_mod_paths.contains(path)) { + disappeared.append(path); + } + } + if (disappeared.isEmpty()) { + return; + } + spdlog::debug("Pruning {} disappeared mods from metadata cache", disappeared.size()); + + QSqlQuery query(*db); + if (!query.prepare(QStringLiteral("DELETE FROM mod_metadata WHERE mod_path = ?"))) { + spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const QString& path : disappeared) { + query.addBindValue(path); + if (!query.exec()) { + spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error pruning metadata cache: {}", db->lastError().text().toStdString()); + } +} + +} // namespace px::core::services diff --git a/src/core/services/metadata_cache.h b/src/core/services/metadata_cache.h new file mode 100644 index 0000000..f3a1ee9 --- /dev/null +++ b/src/core/services/metadata_cache.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "core/providers/base.h" + +namespace px::core::services { + +// The cache database inside the config directory. +QString metadata_cache_path(const QString& config_dir); + +// The symlink-resolved absolute form of `path`, as Python's os.path.realpath gives it. The path +// need not exist: components that do not exist are kept as written. +QString normalize_path(const QString& path); + +// normalize_path for many paths; each shared parent directory is resolved once. +QStringList normalize_paths(const QStringList& paths); + +// Persistent SQLite cache of parsed About.xml metadata (metadata-cache.db). All database work runs +// on one private worker thread, which owns the connection, so the connection is never used from two +// threads at once. A database that cannot be opened is recovered by deleting it; if that fails too, +// the cache disables itself and every lookup reports a miss. +class MetadataCache final : public providers::MetadataCacheStore { +public: + explicit MetadataCache(QString path); + ~MetadataCache() override; + + QCoro::Task get_mods(QList candidates, + QString target_version) override; + QCoro::Task put_mods(QList entries, + QString target_version) override; + + // Removes entries whose normalized mod path is not in `live_mod_paths`. + QCoro::Task prune_disappeared(QSet live_mod_paths); + + // Closes the connection; the next operation reopens it. + QCoro::Task close(); + +private: + void close_connection(); + QSqlDatabase* open_connection(); + Result open_database(); + void remove_database_files() const; + + providers::CacheLookup lookup(const QList& candidates, + const QString& target_version); + void store(const QList& entries, const QString& target_version); + void prune(const QSet& live_mod_paths); + + QString path_; + QString connection_name_; + QThreadPool worker_; + std::optional connection_; // worker thread only + bool disabled_ = false; // worker thread only +}; + +} // namespace px::core::services diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp new file mode 100644 index 0000000..a449ca2 --- /dev/null +++ b/src/core/services/mod_discovery.cpp @@ -0,0 +1,61 @@ +#include "core/services/mod_discovery.h" + +#include +#include + +#include +#include + +#include "core/mods_config.h" + +namespace px::core::services { + +namespace { + +QString strip_steam(QString value) { + if (value.endsWith(QLatin1String("_steam"), Qt::CaseInsensitive)) { + value.chop(6); + } + return value.toLower(); +} + +} // namespace + +QStringList resolve_active_uuids(const QHash& all_mods, + const QString& config_folder) { + if (config_folder.isEmpty()) { + return {}; + } + const QString path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); + const auto data = parse_mods_config(path); + if (!data) { + return {}; + } + + // Sort UUIDs so the first-match tiebreaker is deterministic across hash iterations. + QStringList uuids = all_mods.keys(); + std::ranges::sort(uuids); + + QHash pid_to_uuid; + for (const QString& uuid : uuids) { + const auto mod = std::dynamic_pointer_cast(all_mods.value(uuid)); + if (!mod) { + continue; + } + const QString pid = strip_steam(mod->package_id.str()); + if (!pid_to_uuid.contains(pid)) { + pid_to_uuid.insert(pid, uuid); + } + } + + QStringList active; + for (const CaseInsensitiveStr& pid : data->activeMods) { + const QString normalized = strip_steam(pid.str()); + if (const auto found = pid_to_uuid.constFind(normalized); found != pid_to_uuid.constEnd()) { + active.append(*found); + } + } + return active; +} + +} // namespace px::core::services diff --git a/src/core/services/mod_discovery.h b/src/core/services/mod_discovery.h new file mode 100644 index 0000000..edc1d19 --- /dev/null +++ b/src/core/services/mod_discovery.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +#include "core/models/metadata/structures.h" + +namespace px::core::services { + +// Parses ModsConfig.xml inside `config_folder` and returns the ordered list of UUIDs matching +// active mods. If multiple discovered mods share a package id, the first match (in sorted UUID +// order) is chosen. The "_steam" suffix on package ids is ignored. +QStringList resolve_active_uuids(const QHash& all_mods, + const QString& config_folder); + +} // namespace px::core::services diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp new file mode 100644 index 0000000..088c361 --- /dev/null +++ b/src/core/services/sort_service.cpp @@ -0,0 +1,119 @@ +#include "core/services/sort_service.h" + +#include +#include + +#include +#include + +#include "core/checker/sort.h" +#include "core/context.h" +#include "core/profiler.h" +#include "core/services/diagnostics_service.h" + +namespace px::core { + +namespace { + +std::shared_ptr about_xml(const ListedModPtr& mod) { + return std::dynamic_pointer_cast(mod); +} + +} // namespace + +SortService::SortService(CoreContext& ctx, DiagnosticsService& diagnostics) + : ctx_(ctx), diagnostics_(diagnostics) {} + +// Python: asyncio.to_thread(topological_sort, ...) -> QtConcurrent::run + co_await. The inputs are +// copies or borrowed from the diagnostics service, which is not mutated while awaiting. +QCoro::Task SortService::sort_active_mods() { + ProfileSession session(QStringLiteral("sort")); + Profiler& timer = session.timer(); + const auto active_mods = diagnostics_.active_mods_by_pid(); + const auto& graph = diagnostics_.constraint_graph(); + const sort::SortSettings settings = ctx_.config().sort; + const sort::CommunityRules* rules = &diagnostics_.community_rules(); + + const QList sorted = co_await QtConcurrent::run([&] { + return checker::topological_sort(active_mods, graph, settings, rules, &timer); + }); + co_return order_uuids(sorted, ctx_.allMods(), ctx_.activeUuids()); +} + +QStringList SortService::resolve_missing_dependencies(const QSet& active_uuids) const { + return missing_dependency_uuids(ctx_.allMods(), active_uuids); +} + +QStringList SortService::order_uuids(const QList& sorted_pids, + const QHash& all_mods, + const QStringList& active_uuids) { + QHash pid_to_uuid; + for (const auto& uuid : active_uuids) { + if (auto about = about_xml(all_mods.value(uuid))) { + pid_to_uuid.insert(about->package_id, uuid); + } + } + + QStringList ordered; + for (const auto& pid : sorted_pids) { + if (auto it = pid_to_uuid.constFind(pid); it != pid_to_uuid.constEnd()) { + ordered.append(it.value()); + } + } + return ordered; +} + +QStringList SortService::missing_dependency_uuids(const QHash& all_mods, + const QSet& active_uuids) { + // Visiting uuids in sorted order keeps the choice between duplicate inactive copies stable. + QStringList all_uuids = all_mods.keys(); + std::ranges::sort(all_uuids); + QHash pid_to_uuid; + for (const auto& uuid : all_uuids) { + if (auto about = about_xml(all_mods.value(uuid))) { + pid_to_uuid.insert(about->package_id.str(), uuid); + } + } + + QSet active_pids; + for (const auto& uuid : active_uuids) { + if (auto about = about_xml(all_mods.value(uuid))) { + active_pids.insert(about->package_id.str()); + } + } + + QStringList missing; + for (const auto& uuid : active_uuids) { + auto about = about_xml(all_mods.value(uuid)); + if (!about) { + continue; + } + for (const auto& dep : about->about_rules.dependencies) { + const QString dep_pid = dep.package_id.str(); + if (active_pids.contains(dep_pid)) { + continue; + } + const bool satisfied_by_alternative = std::ranges::any_of( + dep.alternative_package_ids, [&](const CaseInsensitiveStr& alt) { + return active_pids.contains(alt.str()); + }); + if (satisfied_by_alternative) { + continue; + } + auto dep_uuid = pid_to_uuid.constFind(dep_pid); + if (dep_uuid != pid_to_uuid.constEnd() && !active_uuids.contains(dep_uuid.value())) { + missing.append(dep_uuid.value()); + } + } + } + + QStringList unique; + for (const auto& uuid : missing) { + if (!unique.contains(uuid)) { + unique.append(uuid); + } + } + return unique; +} + +} // namespace px::core diff --git a/src/core/services/sort_service.h b/src/core/services/sort_service.h new file mode 100644 index 0000000..09326f9 --- /dev/null +++ b/src/core/services/sort_service.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +namespace px::core { + +class CoreContext; +class DiagnosticsService; + +class SortService { +public: + // Neither argument is owned; both must outlive the service. + SortService(CoreContext& ctx, DiagnosticsService& diagnostics); + + // Orders the active mods by tier, dependencies and load-order rules. Resolves to their uuids + // in that order. + QCoro::Task sort_active_mods(); + + // Uuids of inactive mods that the active mods need and that are not already covered. + QStringList resolve_missing_dependencies(const QSet& active_uuids) const; + + // Maps topologically sorted package ids back to the uuids of the active mods that declare them. + // Only the active uuid of a package id is considered, so an inactive duplicate never stands in + // for an active mod. + static QStringList order_uuids(const QList& sorted_pids, + const QHash& all_mods, + const QStringList& active_uuids); + + // Uuids of inactive mods that satisfy a dependency of an active mod which no active mod (or + // alternative package id) already covers. Duplicates are removed, keeping first occurrence. + static QStringList missing_dependency_uuids(const QHash& all_mods, + const QSet& active_uuids); + +private: + CoreContext& ctx_; + DiagnosticsService& diagnostics_; +}; + +} // namespace px::core diff --git a/src/core/services/startup_impact_service/db.cpp b/src/core/services/startup_impact_service/db.cpp new file mode 100644 index 0000000..822aee4 --- /dev/null +++ b/src/core/services/startup_impact_service/db.cpp @@ -0,0 +1,612 @@ +#include "core/services/startup_impact_service/db.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/migrator.h" + +namespace px::core::startup_impact { + +namespace { + +// Recency weights: the newest session weighs WEIGHT_SPREAD, the next WEIGHT_SPREAD-1, and so +// on down to 1. Sessions beyond HISTORY_DEPTH are not kept. +constexpr int HISTORY_DEPTH = 5; +constexpr int WEIGHT_SPREAD = 10; + +constexpr int SCHEMA_VERSION = 1; + +// Above this size the file is rebuilt after a store, since freed pages are never returned +// otherwise. +constexpr qint64 VACUUM_THRESHOLD_BYTES = 10 * 1024 * 1024; + +// Schema text is identical to the legacy app's, so both can open the same cache.db. +constexpr QLatin1StringView SCHEMA{R"sql( + CREATE TABLE IF NOT EXISTS startup_impact_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at REAL NOT NULL, + loading_time_s REAL NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS startup_impact_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + package_id TEXT NOT NULL, + mod_name TEXT NOT NULL DEFAULT '', + total_impact_s REAL NOT NULL DEFAULT 0, + metrics_json TEXT NOT NULL DEFAULT '{}', + off_thread_metrics_json TEXT NOT NULL DEFAULT '{}', + off_thread_total_s REAL NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) + REFERENCES startup_impact_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sie_session + ON startup_impact_entries(session_id); + CREATE INDEX IF NOT EXISTS idx_sie_pid + ON startup_impact_entries(package_id); +)sql"}; + +// Ranks the newest HISTORY_DEPTH sessions and assigns each a recency weight. +// Binds, in order: HISTORY_DEPTH, WEIGHT_SPREAD. Exposes columns session_id and weight. +constexpr QLatin1StringView WEIGHTED_SESSIONS_CTE{R"sql( + ranked AS ( + SELECT id AS session_id, + ROW_NUMBER() OVER (ORDER BY id DESC) AS rn + FROM startup_impact_sessions + ORDER BY id DESC + LIMIT ? + ), + w AS ( + SELECT session_id, + MAX(? - rn + 1, 1) AS weight + FROM ranked + ) +)sql"}; + +// Wraps a SELECT that joins the weighted sessions as `w`. +QString with_weights(const QString& select_sql) { + return QStringLiteral("WITH ") + QString(WEIGHTED_SESSIONS_CTE) + select_sql; +} + +// The CTE's two parameters come first, followed by the query's own. +QVariantList with_cte_binds(const QVariantList& rest = {}) { + QVariantList binds{HISTORY_DEPTH, WEIGHT_SPREAD}; + binds.append(rest); + return binds; +} + +QVariantList to_variants(const QStringList& values) { + QVariantList binds; + binds.reserve(values.size()); + for (const QString& value : values) { + binds.append(value); + } + return binds; +} + +QString placeholder_list(qsizetype count) { + QStringList marks(count, QStringLiteral("?")); + return marks.join(QLatin1Char(',')); +} + +// Prepares and runs one statement. The returned query stays positioned on the first row. +Result exec(QSqlDatabase& db, const QString& sql, const QVariantList& binds = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (const QVariant& value : binds) { + query.addBindValue(value); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + return query; +} + +Result run(QSqlDatabase& db, const QString& sql, const QVariantList& binds = {}) { + auto executed = exec(db, sql, binds); + if (!executed) { + return std::unexpected(executed.error()); + } + return {}; +} + +// Stored metric maps hold seconds already. Non-numeric values are skipped. +Result> load_metric_map(const QString& json_text) { + const QByteArray bytes = json_text.isEmpty() ? QByteArrayLiteral("{}") : json_text.toUtf8(); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return std::unexpected(Error{QStringLiteral("Invalid stored metrics: %1") + .arg(error.errorString())}); + } + QMap values; + const QJsonObject object = document.object(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (it.value().isDouble()) { + values.insert(it.key(), it.value().toDouble()); + } + } + return values; +} + +QString metrics_to_json(const QMap& metrics) { + QJsonObject object; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + object.insert(it.key(), it.value()); + } + return QString::fromUtf8(QJsonDocument(object).toJson(QJsonDocument::Compact)); +} + +Result row_to_mod(const QSqlQuery& row) { + const QString package_id = row.value(0).toString(); + const QString mod_name = row.value(1).toString(); + // A report without a package id stores the mod name in its place. Read it back as absent. + std::optional pid = package_id; + if (package_id == mod_name && package_id != BASE_GAME_PID) { + pid = std::nullopt; + } + + auto metrics = load_metric_map(row.value(3).toString()); + if (!metrics) { + return std::unexpected(metrics.error()); + } + auto off_thread = load_metric_map(row.value(4).toString()); + if (!off_thread) { + return std::unexpected(off_thread.error()); + } + + return StartupImpactMod{ + .mod_name = mod_name, + .package_id = pid, + .total_impact_s = row.value(2).toDouble(), + .metrics = std::move(*metrics), + .off_thread_metrics = std::move(*off_thread), + .off_thread_total_impact_s = row.value(5).toDouble(), + }; +} + +Result> load_mods(QSqlDatabase& db, qlonglong session_id) { + auto query = exec(db, + "SELECT package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s" + " FROM startup_impact_entries WHERE session_id = ?", + {session_id}); + if (!query) { + return std::unexpected(query.error()); + } + QList mods; + while (query->next()) { + auto mod = row_to_mod(*query); + if (!mod) { + return std::unexpected(mod.error()); + } + mods.append(std::move(*mod)); + } + return mods; +} + +void close_connection(StartupImpactDb::Connection& conn) { + if (!conn.open) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(conn.name, false); + db.close(); + } + // removeDatabase requires that no QSqlDatabase handle for the connection remains. + QSqlDatabase::removeDatabase(conn.name); + conn.open = false; + conn.path.clear(); +} + +// Opens the connection on the worker, applying the legacy pragmas and schema. Reuses the open +// connection when the path has not changed. +Result open_connection(StartupImpactDb::Connection& conn, const QString& path) { + if (conn.open && conn.path == path) { + return QSqlDatabase::database(conn.name, false); + } + close_connection(conn); + + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), conn.name); + db.setDatabaseName(path); + + const auto prepare = [&db, &path]() -> Result { + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // Order matters: auto_vacuum only takes effect before the first table is created. + for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (auto applied = run(db, QString::fromLatin1(pragma)); !applied) { + return applied; + } + } + return ensure_schema(db, QString(SCHEMA), SCHEMA_VERSION, {}, path, + QStringLiteral("DB schema")); + }; + + if (auto ready = prepare(); !ready) { + const Error error = ready.error(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(conn.name); + return std::unexpected(error); + } + conn.path = path; + conn.open = true; + return db; +} + +// Read paths return empty results for a missing file rather than creating one. +bool file_exists(const QString& path) { return QFileInfo::exists(path); } + +Result ensure_parent_dir(const QString& path) { + if (file_exists(path)) { + return {}; + } + const QString dir = QFileInfo(path).absolutePath(); + if (!QDir().mkpath(dir)) { + return std::unexpected(Error{QStringLiteral("Cannot create directory %1").arg(dir)}); + } + return {}; +} + +Result insert_report(QSqlDatabase& db, const StartupImpactReport& report) { + const QString timestamp = report.timestamp.isNull() ? QStringLiteral("") : report.timestamp; + auto session = exec(db, + "INSERT INTO startup_impact_sessions" + " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", + {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loading_time_s, + timestamp}); + if (!session) { + return std::unexpected(session.error()); + } + const qlonglong session_id = session->lastInsertId().toLongLong(); + + for (const StartupImpactMod& mod : report.mods) { + const bool has_package_id = mod.package_id && !mod.package_id->isEmpty(); + const QString name = mod.mod_name.isNull() ? QStringLiteral("") : mod.mod_name; + const QString pid = has_package_id ? *mod.package_id : name; + auto entry = run(db, + "INSERT INTO startup_impact_entries" + " (session_id, package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + {session_id, pid, name, mod.total_impact_s, + metrics_to_json(mod.metrics), metrics_to_json(mod.off_thread_metrics), + mod.off_thread_total_impact_s}); + if (!entry) { + return entry; + } + } + + return run(db, + "DELETE FROM startup_impact_sessions WHERE id NOT IN" + " (SELECT id FROM startup_impact_sessions ORDER BY id DESC LIMIT ?)", + {HISTORY_DEPTH}); +} + +Result compact(QSqlDatabase& db, const QString& path) { + // Checkpoint the WAL, then reclaim free pages. The file is only rebuilt when it has grown. + if (auto checkpointed = run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); !checkpointed) { + return checkpointed; + } + if (auto vacuumed = run(db, "PRAGMA incremental_vacuum"); !vacuumed) { + return vacuumed; + } + if (QFileInfo(path).size() > VACUUM_THRESHOLD_BYTES) { + return run(db, "VACUUM"); + } + return {}; +} + +// Runs `work` inside one transaction, rolling back when it fails. +template +Result in_transaction(QSqlDatabase& db, Work&& work) { + if (!db.transaction()) { + return std::unexpected(Error{db.lastError().text()}); + } + if (auto done = work(); !done) { + db.rollback(); + return done; + } + if (!db.commit()) { + const Error error{db.lastError().text()}; + db.rollback(); + return std::unexpected(error); + } + return {}; +} + +Result store_report_sync(StartupImpactDb::Connection& conn, const QString& path, + const StartupImpactReport& report) { + if (auto dir = ensure_parent_dir(path); !dir) { + return dir; + } + auto opened = open_connection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto stored = in_transaction(db, [&] { return insert_report(db, report); }); !stored) { + return stored; + } + return compact(db, path); +} + +Result> get_latest_report_sync(StartupImpactDb::Connection& conn, + const QString& path) { + if (!file_exists(path)) { + return std::nullopt; + } + auto opened = open_connection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto session = exec(db, + "SELECT id, loading_time_s, timestamp" + " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1"); + if (!session) { + return std::unexpected(session.error()); + } + if (!session->next()) { + return std::nullopt; + } + const qlonglong session_id = session->value(0).toLongLong(); + StartupImpactReport report; + report.loading_time_s = session->value(1).toDouble(); + report.timestamp = session->value(2).toString(); + + auto mods = load_mods(db, session_id); + if (!mods) { + return std::unexpected(mods.error()); + } + report.mods = std::move(*mods); + return report; +} + +Result average_for(QSqlDatabase& db, const QString& package_id) { + auto query = exec(db, + with_weights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" + " / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id = ?"), + with_cte_binds({package_id})); + if (!query) { + return std::unexpected(query.error()); + } + return query->next() ? query->value(0).toDouble() : 0.0; +} + +Result> totals_for(QSqlDatabase& db, const QStringList& package_ids) { + auto query = exec(db, + with_weights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)," + " COALESCE(SUM(e.off_thread_total_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id IN (" + + placeholder_list(package_ids.size()) + + ") GROUP BY e.package_id"), + with_cte_binds(to_variants(package_ids))); + if (!query) { + return std::unexpected(query.error()); + } + QHash totals; + while (query->next()) { + totals.insert(query->value(0).toString(), + ThreadAverages{query->value(1).toDouble(), query->value(2).toDouble()}); + } + return totals; +} + +Result> all_averages_for(QSqlDatabase& db) { + auto query = exec(db, + with_weights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " GROUP BY e.package_id"), + with_cte_binds()); + if (!query) { + return std::unexpected(query.error()); + } + QHash averages; + while (query->next()) { + averages.insert(query->value(0).toString(), query->value(1).toDouble()); + } + return averages; +} + +Result clear_sync(StartupImpactDb::Connection& conn, const QString& path) { + if (!file_exists(path)) { + return {}; + } + auto opened = open_connection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto cleared = in_transaction(db, [&]() -> Result { + if (auto entries = run(db, "DELETE FROM startup_impact_entries"); !entries) { + return entries; + } + return run(db, "DELETE FROM startup_impact_sessions"); + }); + !cleared) { + return cleared; + } + if (auto vacuumed = run(db, "VACUUM"); !vacuumed) { + return vacuumed; + } + return run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); +} + +} // namespace + +QString db_path(const QString& config_dir) { + return QDir(config_dir).filePath(QStringLiteral("cache.db")); +} + +StartupImpactDb::StartupImpactDb() { + // A single thread keeps the connection on one thread. Idle threads must not expire, since + // the connection is bound to the thread that opened it. + pool_.setMaxThreadCount(1); + pool_.setExpiryTimeout(-1); + conn_.name = QStringLiteral("startup_impact_%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); +} + +StartupImpactDb::~StartupImpactDb() { close_sync(); } + +QCoro::Task> StartupImpactDb::store_report(QString path, StartupImpactReport report) { + co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path), + report = std::move(report)] { + return store_report_sync(conn_, path, report); + }); +} + +QCoro::Task>> StartupImpactDb::get_latest_report( + QString path) { + co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path)] { + return get_latest_report_sync(conn_, path); + }); +} + +QCoro::Task> StartupImpactDb::get_average(QString path, QString package_id) { + co_return co_await QtConcurrent::run( + &pool_, [this, path = std::move(path), package_id = std::move(package_id)]() -> Result { + if (!file_exists(path)) { + return 0.0; + } + auto opened = open_connection(conn_, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return average_for(db, package_id); + }); +} + +QCoro::Task>> StartupImpactDb::get_totals_for( + QString path, QStringList package_ids) { + co_return co_await QtConcurrent::run( + &pool_, [this, path = std::move(path), package_ids = std::move(package_ids)]() + -> Result> { + // Ids without history are left out, so callers treat them as zero. + if (package_ids.isEmpty() || !file_exists(path)) { + return QHash{}; + } + auto opened = open_connection(conn_, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return totals_for(db, package_ids); + }); +} + +QCoro::Task>> StartupImpactDb::get_all_averages(QString path) { + co_return co_await QtConcurrent::run( + &pool_, [this, path = std::move(path)]() -> Result> { + if (!file_exists(path)) { + return QHash{}; + } + auto opened = open_connection(conn_, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return all_averages_for(db); + }); +} + +QCoro::Task> StartupImpactDb::get_latest_with_averages( + QString path, QStringList active_pids, std::optional selected_pid) { + co_return co_await QtConcurrent::run( + &pool_, [this, path = std::move(path), active_pids = std::move(active_pids), + selected_pid = std::move(selected_pid)]() -> Result { + LatestWithAverages result; + for (const QString& pid : active_pids) { + result.totals.insert(pid, ThreadAverages{}); + } + if (!file_exists(path)) { + return result; + } + + auto latest = get_latest_report_sync(conn_, path); + if (!latest) { + return std::unexpected(latest.error()); + } + result.report = std::move(*latest); + + auto opened = open_connection(conn_, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto base = average_for(db, QString(BASE_GAME_PID)); + if (!base) { + return std::unexpected(base.error()); + } + result.base_game_avg = *base; + + if (!active_pids.isEmpty()) { + auto totals = totals_for(db, active_pids); + if (!totals) { + return std::unexpected(totals.error()); + } + for (auto it = totals->cbegin(); it != totals->cend(); ++it) { + result.totals.insert(it.key(), it.value()); + } + } + + if (selected_pid) { + auto selected = average_for(db, *selected_pid); + if (!selected) { + return std::unexpected(selected.error()); + } + result.selected_avg = *selected; + } + return result; + }); +} + +QCoro::Task> StartupImpactDb::clear(QString path) { + co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path)] { + return clear_sync(conn_, path); + }); +} + +QCoro::Task StartupImpactDb::close() { + co_await QtConcurrent::run(&pool_, [this] { close_connection(conn_); }); +} + +void StartupImpactDb::close_sync() { + QtConcurrent::run(&pool_, [this] { close_connection(conn_); }).waitForFinished(); +} + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/db.h b/src/core/services/startup_impact_service/db.h new file mode 100644 index 0000000..5094f9c --- /dev/null +++ b/src/core/services/startup_impact_service/db.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "core/error.h" +#include "core/services/startup_impact_service/models.h" + +namespace px::core::startup_impact { + +// Average on-thread and off-thread seconds for one package id. +struct ThreadAverages { + double on_thread = 0.0; + double off_thread = 0.0; +}; + +struct LatestWithAverages { + std::optional report; + double base_game_avg = 0.0; + // Keyed by every requested package id; ids without history map to zeros. + QHash totals; + double selected_avg = 0.0; +}; + +// `cache.db` in the config directory. Shared with the metadata cache, which owns other tables. +QString db_path(const QString& config_dir); + +// Startup impact history in SQLite, kept to the most recent sessions. Averages weight newer +// sessions more heavily. The schema matches the legacy Python app so either can read the file. +// +// All database work runs on one worker thread, which owns the single connection. Calls may +// therefore be made from the Qt event loop without blocking it, and need no further locking. +class StartupImpactDb { +public: + // The open connection, identified by a name unique to this instance. Only the worker + // thread reads or writes it. + struct Connection { + QString name; + QString path; + bool open = false; + }; + + StartupImpactDb(); + ~StartupImpactDb(); + StartupImpactDb(const StartupImpactDb&) = delete; + StartupImpactDb& operator=(const StartupImpactDb&) = delete; + + QCoro::Task> store_report(QString path, StartupImpactReport report); + QCoro::Task>> get_latest_report(QString path); + QCoro::Task> get_average(QString path, QString package_id); + QCoro::Task>> get_totals_for(QString path, + QStringList package_ids); + QCoro::Task>> get_all_averages(QString path); + QCoro::Task> get_latest_with_averages( + QString path, QStringList active_pids, std::optional selected_pid); + QCoro::Task> clear(QString path); + + QCoro::Task close(); + // For shutdown paths that cannot await. Blocks until the connection is closed. + void close_sync(); + +private: + // The destructor drains the pool through close_sync() before these members are destroyed. + QThreadPool pool_; + Connection conn_; +}; + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/labels.cpp b/src/core/services/startup_impact_service/labels.cpp new file mode 100644 index 0000000..ecd178f --- /dev/null +++ b/src/core/services/startup_impact_service/labels.cpp @@ -0,0 +1,121 @@ +#include "core/services/startup_impact_service/labels.h" + +#include +#include + +namespace px::core::startup_impact { + +namespace { + +constexpr QLatin1StringView kPrefix{"LoadingProgress.StartupImpact."}; + +const QHash& metric_labels() { + static const QHash labels{ + {QStringLiteral("ModConstructor"), QStringLiteral("Running mod constructor")}, + {QStringLiteral("LoadDefs"), QStringLiteral("Loading defs")}, + {QStringLiteral("CombineXml"), QStringLiteral("Combining XML into a single XML document")}, + {QStringLiteral("TKeySystemParse"), QStringLiteral("Translation key system parsing")}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), + QStringLiteral("Parsing and processing XML")}, + {QStringLiteral("ErrorCheckPatches"), QStringLiteral("Error checking patches")}, + {QStringLiteral("LoadPatches"), QStringLiteral("Loading patches")}, + {QStringLiteral("ApplyPatches"), QStringLiteral("Applying patches")}, + {QStringLiteral("RegisterXmlInheritance"), QStringLiteral("Registering XML inheritance")}, + {QStringLiteral("ResolveXmlInheritance"), QStringLiteral("Resolving XML inheritance")}, + {QStringLiteral("ClearCachedPatches"), QStringLiteral("Clearing cached patches")}, + {QStringLiteral("ClearCachedXmlInheritance"), + QStringLiteral("Clearing cached XML inheritance")}, + {QStringLiteral("LanguageDatabaseInitAllMetadata"), + QStringLiteral("Loading language metadata")}, + {QStringLiteral("DefDatabaseAddAllInMods"), + QStringLiteral("Copying all Defs from mods to global databases")}, + {QStringLiteral("ResolveAllWantedCrossReferences"), + QStringLiteral("Resolving cross-references")}, + {QStringLiteral("ResolveAllWantedCrossReferences.NonImplied"), + QStringLiteral("Resolving cross-references between non-implied Defs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs"), QStringLiteral("Rebinding DefOfs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Early"), + QStringLiteral("Rebinding DefOfs (early)")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Final"), + QStringLiteral("Rebinding DefOfs (final)")}, + {QStringLiteral("TKeySystemBuildMappings"), QStringLiteral("Translation key system mapping")}, + {QStringLiteral("BackStoryTranslationUtilityLoadAndInjectBackstoryData"), + QStringLiteral("Loading and injecting legacy backstory translations")}, + {QStringLiteral("ModContentPackReloadContentInt"), QStringLiteral("Loading content")}, + {QStringLiteral("ModContentPackReloadContentInt.AudioClips"), + QStringLiteral("Loading audio clips")}, + {QStringLiteral("ModContentPackReloadContentInt.Textures"), + QStringLiteral("Loading textures")}, + {QStringLiteral("ModContentPackReloadContentInt.Strings"), QStringLiteral("Loading strings")}, + {QStringLiteral("ModContentPackReloadContentInt.AssetBundles"), + QStringLiteral("Loading asset bundles")}, + {QStringLiteral("LoadedLanguageInjectIntoDataBeforeImpliedDefs"), + QStringLiteral("Injecting language data (early pass)")}, + {QStringLiteral("ColoredTextResetStaticData"), + QStringLiteral("Running global operations (early pass)")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPreResolve"), + QStringLiteral("Generating implied Defs (pre-resolve)")}, + {QStringLiteral("ResolveAllWantedCrossReferences.Implied"), + QStringLiteral("Resolving cross-references between implied Defs")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPre"), + QStringLiteral("Global operations (pre-resolve)")}, + {QStringLiteral("ResolveReferences"), QStringLiteral("Resolving references")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPostResolve"), + QStringLiteral("Generating implied Defs (post-resolve)")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPost"), + QStringLiteral("Global operations (post-resolve)")}, + {QStringLiteral("ErrorCheckAllDefs"), QStringLiteral("Error checking all defs")}, + {QStringLiteral("KeyPrefsInit"), QStringLiteral("Loading keyboard preferences")}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), QStringLiteral("Short hash giving")}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), + QStringLiteral("Running delayed initialization task")}, + {QStringLiteral("SolidBioDatabaseLoadAllBios"), QStringLiteral("Loading all bios")}, + {QStringLiteral("LoadedLanguageInjectIntoDataAfterImpliedDefs"), + QStringLiteral("Injecting language data")}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), + QStringLiteral("Running static constructors")}, + {QStringLiteral("FloatMenuMakerMapInit"), QStringLiteral("Initializing float menu maker map")}, + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), + QStringLiteral("Texture atlas baking")}, + {QStringLiteral("AbstractFilesystemClearAllCache"), + QStringLiteral("Clearing filesystem cache")}, + {QStringLiteral("LoadModXml"), QStringLiteral("Loading and parsing mod XML")}, + }; + return labels; +} + +// The exact key, else its last dot-separated segment, else the key itself. +QString base_label(const QString& key) { + const auto& labels = metric_labels(); + if (const auto found = labels.constFind(key); found != labels.cend()) { + return *found; + } + const QString tail = key.mid(key.lastIndexOf(u'.') + 1); + if (const auto found = labels.constFind(tail); found != labels.cend()) { + return *found; + } + return key; +} + +} // namespace + +QString strip_metric_prefix(const QString& key) { + return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; +} + +QString metric_label(const QString& key) { + const QString stripped = strip_metric_prefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString base = bar < 0 ? stripped : stripped.left(bar); + const QString target = bar < 0 ? QString() : stripped.mid(bar + 1); + const QString label = base_label(base); + if (target.isEmpty()) { + return label; + } + // Deferred tasks name the method they run after " -> "; only the target type is shown. + const qsizetype arrow = target.indexOf(QStringLiteral(" -> ")); + const QString shown = arrow < 0 ? target : target.left(arrow); + return QStringLiteral("%1 (%2)").arg(label, shown); +} + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/labels.h b/src/core/services/startup_impact_service/labels.h new file mode 100644 index 0000000..48ef93b --- /dev/null +++ b/src/core/services/startup_impact_service/labels.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace px::core::startup_impact { + +// Drops the "LoadingProgress.StartupImpact." prefix the game writes before every metric key. +QString strip_metric_prefix(const QString& key); + +// Human-readable name for a metric key. A "|Target" suffix, as written for deferred tasks, +// is shown in parentheses after the label. +QString metric_label(const QString& key); + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp new file mode 100644 index 0000000..62159d1 --- /dev/null +++ b/src/core/services/startup_impact_service/models.cpp @@ -0,0 +1,79 @@ +#include "core/services/startup_impact_service/models.h" + +#include + +namespace px::core::startup_impact { + +namespace { + +template +double sum_values(const Map& map) { + double total = 0.0; + for (const double value : map) { + total += value; + } + return total; +} + +bool is_set(const std::optional& value) { return value && !value->isEmpty(); } + +} // namespace + +double StartupImpactReport::total_time() const { + double total = 0.0; + for (const StartupImpactMod& mod : mods) { + total += mod.total_impact_s; + } + return total; +} + +double StartupImpactReport::off_thread_total_time() const { + double total = 0.0; + for (const StartupImpactMod& mod : mods) { + total += mod.off_thread_total_impact_s; + } + return total; +} + +double StartupImpactReport::mods_total_time() const { + return total_time() + off_thread_total_time(); +} + +double StartupImpactReport::base_game_time() const { return sum_values(metrics); } + +const StartupImpactMod* StartupImpactReport::find(const std::optional& package_id, + const std::optional& mod_name) const { + if (is_set(package_id)) { + const QString normalized = normalize_package_id(*package_id); + for (const StartupImpactMod& mod : mods) { + if (mod.package_id == normalized) { + return &mod; + } + } + } + if (is_set(mod_name)) { + const QString name_lower = mod_name->toLower(); + for (const StartupImpactMod& mod : mods) { + if (mod.mod_name.toLower() == name_lower) { + return &mod; + } + } + } + return nullptr; +} + +QString normalize_package_id(QStringView package_id) { + QString normalized = package_id.toString().toLower(); + constexpr QLatin1StringView steam_suffix{"_steam"}; + if (normalized.endsWith(steam_suffix)) { + normalized.chop(steam_suffix.size()); + } + return normalized; +} + +QString format_impact(double seconds) { + const auto ms = static_cast(std::nearbyint(seconds * 1000.0)); + return QStringLiteral("%1ms").arg(ms); +} + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/models.h b/src/core/services/startup_impact_service/models.h new file mode 100644 index 0000000..da5cf1c --- /dev/null +++ b/src/core/services/startup_impact_service/models.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace px::core::startup_impact { + +inline constexpr double IMPACT_WARN_THRESHOLD_S = 1.0; +inline constexpr double IMPACT_HIGH_THRESHOLD_S = 5.0; + +// Stored as the mod's package id for the RimWorld base game's own profiler metrics. +inline constexpr QLatin1StringView BASE_GAME_PID{"__base_game__"}; + +// Impact durations are in seconds. Metric maps are keyed by profiler metric name and +// ordered by name, so iteration is deterministic. +struct StartupImpactMod { + QString mod_name; + std::optional package_id; + double total_impact_s = 0.0; + QMap metrics; + QMap off_thread_metrics; + double off_thread_total_impact_s = 0.0; +}; + +struct StartupImpactReport { + QString path; + double loading_time_s = 0.0; + QList mods; + QString timestamp; + QMap metrics; + double total_impact = 0.0; + QMap off_thread_metrics; + double off_thread_total_impact = 0.0; + + double total_time() const; + double off_thread_total_time() const; + double mods_total_time() const; + double base_game_time() const; + + // Matches by normalized package id first, then by case-insensitive mod name. + const StartupImpactMod* find(const std::optional& package_id, + const std::optional& mod_name) const; +}; + +// Lowercases and drops the "_steam" suffix that Steam Workshop folders add to package ids. +QString normalize_package_id(QStringView package_id); + +// Rounds half to even, as Python's round() does, so displayed values match the legacy app. +QString format_impact(double seconds); + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp new file mode 100644 index 0000000..dbe7a5a --- /dev/null +++ b/src/core/services/startup_impact_service/parser.cpp @@ -0,0 +1,144 @@ +#include "core/services/startup_impact_service/parser.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::core::startup_impact { + +namespace { + +constexpr QLatin1StringView kReportFilename{"StartupImpactData.json"}; + +// The game writes milliseconds; the rest of the app works in seconds. +double ms_to_s(double ms) { return ms / 1000.0; } + +// Python's bool is an int subclass, so the legacy parser accepts true/false as 1/0 ms. +bool is_numeric(const QJsonValue& value) { return value.isDouble() || value.isBool(); } + +double numeric_ms(const QJsonValue& value) { + return value.isBool() ? static_cast(value.toBool()) : value.toDouble(); +} + +double as_seconds(const QJsonValue& value) { + if (is_numeric(value)) { + return ms_to_s(numeric_ms(value)); + } + if (value.isString()) { + bool ok = false; + const double ms = value.toString().toDouble(&ok); + return ok ? ms_to_s(ms) : 0.0; + } + return 0.0; +} + +// Non-numeric values are skipped, as the legacy parser skips them. +QMap as_seconds_map(const QJsonValue& value) { + QMap result; + if (!value.isObject()) { + return result; + } + const QJsonObject object = value.toObject(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (is_numeric(it.value())) { + result.insert(it.key(), ms_to_s(numeric_ms(it.value()))); + } + } + return result; +} + +std::optional parse_mod(const QJsonValue& value) { + if (!value.isObject()) { + return std::nullopt; + } + const QJsonObject entry = value.toObject(); + const QJsonValue mod_name = entry.value(QStringLiteral("modName")); + if (!mod_name.isString() || mod_name.toString().isEmpty()) { + return std::nullopt; + } + + const QJsonValue raw_package_id = entry.value(QStringLiteral("modPackageId")); + std::optional package_id; + if (raw_package_id.isString() && !raw_package_id.toString().isEmpty()) { + package_id = normalize_package_id(raw_package_id.toString()); + } + + return StartupImpactMod{ + .mod_name = mod_name.toString(), + .package_id = package_id, + .total_impact_s = as_seconds(entry.value(QStringLiteral("totalImpact"))), + .metrics = as_seconds_map(entry.value(QStringLiteral("metrics"))), + .off_thread_metrics = as_seconds_map(entry.value(QStringLiteral("offThreadMetrics"))), + .off_thread_total_impact_s = + as_seconds(entry.value(QStringLiteral("offThreadTotalImpact"))), + }; +} + +} // namespace + +QString get_startup_impact_path(const QString& config_folder) { + // Mirrors Path(config_folder).parent: a trailing separator is ignored and a bare name + // has the current directory as its parent. + const QString clean = QDir::cleanPath(config_folder); + const qsizetype slash = clean.lastIndexOf(u'/'); + const QString parent = slash < 0 ? QStringLiteral(".") + : slash == 0 ? QStringLiteral("/") + : clean.left(slash); + return QDir::cleanPath(QDir(parent).filePath(kReportFilename.toString())); +} + +std::optional parse_startup_impact(const QString& path) { + if (!QFileInfo::exists(path)) { + spdlog::debug("Startup impact report not found at {}", path.toStdString()); + return std::nullopt; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::warn("Failed to parse startup impact report at {}: {}", path.toStdString(), + file.errorString().toStdString()); + return std::nullopt; + } + + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + spdlog::warn("Failed to parse startup impact report at {}: {}", path.toStdString(), + error.errorString().toStdString()); + return std::nullopt; + } + if (!document.isObject()) { + spdlog::warn("Startup impact report is not a JSON object: {}", path.toStdString()); + return std::nullopt; + } + + const QJsonObject data = document.object(); + StartupImpactReport report; + report.path = QDir::cleanPath(path); + report.loading_time_s = as_seconds(data.value(QStringLiteral("loadingTime"))); + if (const QJsonValue timestamp = data.value(QStringLiteral("timestamp")); + timestamp.isString()) { + report.timestamp = timestamp.toString(); + } + report.metrics = as_seconds_map(data.value(QStringLiteral("metrics"))); + report.total_impact = as_seconds(data.value(QStringLiteral("totalImpact"))); + report.off_thread_metrics = as_seconds_map(data.value(QStringLiteral("offThreadMetrics"))); + report.off_thread_total_impact = + as_seconds(data.value(QStringLiteral("offThreadTotalImpact"))); + + // A non-array "mods" yields no entries, as iterating the legacy value did. + for (const QJsonValue& entry : data.value(QStringLiteral("mods")).toArray()) { + if (auto mod = parse_mod(entry)) { + report.mods.append(std::move(*mod)); + } + } + return report; +} + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/parser.h b/src/core/services/startup_impact_service/parser.h new file mode 100644 index 0000000..2235af8 --- /dev/null +++ b/src/core/services/startup_impact_service/parser.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +#include "core/services/startup_impact_service/models.h" + +namespace px::core::startup_impact { + +// The report lives next to the game's config folder, not inside it. +QString get_startup_impact_path(const QString& config_folder); + +// Reads a StartupImpactData.json report. Returns nullopt when the file is missing, unreadable, +// or not a JSON object. Malformed entries are skipped rather than failing the whole report. +std::optional parse_startup_impact(const QString& path); + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp new file mode 100644 index 0000000..38be7a1 --- /dev/null +++ b/src/core/services/startup_impact_service/phases.cpp @@ -0,0 +1,93 @@ +#include "core/services/startup_impact_service/phases.h" + +#include +#include + +#include +#include + +#include "core/services/startup_impact_service/labels.h" + +namespace px::core::startup_impact { + +namespace { + +const QHash& exact_phases() { + static const QHash phases{ + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), Phase::Content}, + {QStringLiteral("ModConstructor"), Phase::ModConstructors}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), Phase::StaticConstructors}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::Deferred}, + {QStringLiteral("LoadDefs"), Phase::Defs}, + {QStringLiteral("LoadModXml"), Phase::Defs}, + {QStringLiteral("CombineXml"), Phase::Defs}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::Defs}, + {QStringLiteral("RegisterXmlInheritance"), Phase::Defs}, + {QStringLiteral("ResolveXmlInheritance"), Phase::Defs}, + {QStringLiteral("ClearCachedXmlInheritance"), Phase::Defs}, + {QStringLiteral("DefDatabaseAddAllInMods"), Phase::Defs}, + {QStringLiteral("ResolveReferences"), Phase::Defs}, + {QStringLiteral("ErrorCheckAllDefs"), Phase::Defs}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::Defs}, + }; + return phases; +} + +// Checked in order after the exact names; the first matching prefix wins. +constexpr std::array, 4> kPrefixPhases{{ + {QLatin1StringView{"ModContentPackReloadContentInt"}, Phase::Content}, + {QLatin1StringView{"ResolveAllWantedCrossReferences"}, Phase::Defs}, + {QLatin1StringView{"DefGeneratorGenerateImpliedDefs"}, Phase::Defs}, + {QLatin1StringView{"DefOfHelperRebindAllDefOfs"}, Phase::Defs}, +}}; + +} // namespace + +QString phase_label(Phase phase) { + switch (phase) { + case Phase::Content: + return QStringLiteral("Textures & audio"); + case Phase::ModConstructors: + return QStringLiteral("Mod constructors"); + case Phase::StaticConstructors: + return QStringLiteral("Static constructors"); + case Phase::Patches: + return QStringLiteral("Patches"); + case Phase::Defs: + return QStringLiteral("Defs & XML"); + case Phase::Deferred: + return QStringLiteral("Deferred init"); + case Phase::Other: + return QStringLiteral("Other"); + } + return QString(); +} + +Phase metric_phase(const QString& key) { + const QString stripped = strip_metric_prefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString name = bar < 0 ? stripped : stripped.left(bar); + + if (const auto found = exact_phases().constFind(name); found != exact_phases().cend()) { + return *found; + } + if (name.endsWith(QLatin1StringView{"Patches"})) { + return Phase::Patches; + } + for (const auto& [prefix, phase] : kPrefixPhases) { + if (name.startsWith(prefix)) { + return phase; + } + } + return Phase::Other; +} + +PhaseTotals phase_totals(const QMap& metrics) { + PhaseTotals totals{}; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + totals[phase_index(metric_phase(it.key()))] += it.value(); + } + return totals; +} + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/phases.h b/src/core/services/startup_impact_service/phases.h new file mode 100644 index 0000000..4f12a61 --- /dev/null +++ b/src/core/services/startup_impact_service/phases.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace px::core::startup_impact { + +enum class Phase : std::uint8_t { + Content, + ModConstructors, + StaticConstructors, + Patches, + Defs, + Deferred, + Other, +}; + +inline constexpr std::size_t PHASE_COUNT = 7; + +// Seconds per phase, indexed by `phase_index`. +using PhaseTotals = std::array; + +constexpr std::size_t phase_index(Phase phase) { return static_cast(phase); } + +QString phase_label(Phase phase); + +// Phase a profiler metric key belongs to. Unknown keys fall into Other. +Phase metric_phase(const QString& key); + +// Sums metric seconds per phase. Every phase is present, even when it has no metrics. +PhaseTotals phase_totals(const QMap& metrics); + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp new file mode 100644 index 0000000..18ad336 --- /dev/null +++ b/src/core/services/startup_impact_service/service.cpp @@ -0,0 +1,109 @@ +#include "core/services/startup_impact_service/service.h" + +#include +#include + +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/services/startup_impact_service/parser.h" + +namespace px::core::startup_impact { + +namespace { + +constexpr double kMinBaseGameTimeS = 0.001; + +QString dbPathFor(CoreContext& ctx) { return db_path(ctx.configService().directory()); } + +QStringList normalized(const QStringList& pids) { + QStringList out; + out.reserve(pids.size()); + for (const QString& pid : pids) out.push_back(normalize_package_id(pid)); + return out; +} + +} // namespace + +StartupImpactService::StartupImpactService(CoreContext& ctx) : ctx_(ctx) {} + +QCoro::Task>> StartupImpactService::load() { + const std::string& configFolder = ctx_.config().paths.config_folder; + if (configFolder.empty()) co_return std::optional{}; + + const QString dbPath = dbPathFor(ctx_); + const QString jsonPath = get_startup_impact_path(QString::fromStdString(configFolder)); + spdlog::debug("Loading startup impact from {}", jsonPath.toStdString()); + + std::optional report = parse_startup_impact(jsonPath); + if (report) { + const double base = std::max(0.0, report->base_game_time()); + QList entries = report->mods; + if (base > kMinBaseGameTimeS) { + StartupImpactMod baseGame; + baseGame.mod_name = QString(BASE_GAME_PID); + baseGame.package_id = QString(BASE_GAME_PID); + baseGame.total_impact_s = base; + baseGame.metrics = report->metrics; + baseGame.off_thread_metrics = report->off_thread_metrics; + baseGame.off_thread_total_impact_s = + std::accumulate(report->off_thread_metrics.cbegin(), + report->off_thread_metrics.cend(), 0.0); + entries.push_back(std::move(baseGame)); + } + StartupImpactReport full; + full.path = report->path; + full.loading_time_s = report->loading_time_s; + full.mods = std::move(entries); + full.timestamp = report->timestamp; + auto stored = co_await db_.store_report(dbPath, std::move(full)); + if (!stored) co_return std::unexpected(stored.error()); + } + co_return report; +} + +QCoro::Task>> StartupImpactService::get_latest_report() { + co_return co_await db_.get_latest_report(dbPathFor(ctx_)); +} + +QCoro::Task> StartupImpactService::estimated_impact_for(QString package_id) { + co_return co_await db_.get_average(dbPathFor(ctx_), normalize_package_id(package_id)); +} + +QCoro::Task> StartupImpactService::estimated_total(QStringList active_pids) { + const QString dbPath = dbPathFor(ctx_); + auto base = co_await db_.get_average(dbPath, QString(BASE_GAME_PID)); + if (!base) co_return std::unexpected(base.error()); + auto totals = co_await db_.get_totals_for(dbPath, normalized(active_pids)); + if (!totals) co_return std::unexpected(totals.error()); + double modSum = 0.0; + for (const ThreadAverages& t : *totals) modSum += t.on_thread + t.off_thread; + co_return *base + modSum; +} + +QCoro::Task>> StartupImpactService::get_all_averages() { + co_return co_await db_.get_all_averages(dbPathFor(ctx_)); +} + +QCoro::Task> StartupImpactService::base_game_average() { + co_return co_await db_.get_average(dbPathFor(ctx_), QString(BASE_GAME_PID)); +} + +QCoro::Task> StartupImpactService::snapshot( + QStringList active_pids, std::optional selected_pid) { + std::optional selected; + if (selected_pid && !selected_pid->isEmpty()) selected = normalize_package_id(*selected_pid); + co_return co_await db_.get_latest_with_averages(dbPathFor(ctx_), normalized(active_pids), + std::move(selected)); +} + +QCoro::Task> StartupImpactService::clear() { + co_return co_await db_.clear(dbPathFor(ctx_)); +} + +QCoro::Task StartupImpactService::close_connection() { co_await db_.close(); } + +void StartupImpactService::close_connection_sync() { db_.close_sync(); } + +} // namespace px::core::startup_impact diff --git a/src/core/services/startup_impact_service/service.h b/src/core/services/startup_impact_service/service.h new file mode 100644 index 0000000..6df1ea3 --- /dev/null +++ b/src/core/services/startup_impact_service/service.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "core/error.h" +#include "core/services/startup_impact_service/db.h" +#include "core/services/startup_impact_service/models.h" + +namespace px::core { +class CoreContext; +} + +namespace px::core::startup_impact { + +// Loads the game's startup impact report into the history database and answers queries about +// historical averages and per-session breakdowns. +class StartupImpactService { +public: + // `ctx` is not owned and must outlive the service. + explicit StartupImpactService(CoreContext& ctx); + + // Imports the report from the game's config folder and records it in history. Resolves to + // nullopt when no config folder is set or no report can be read. + QCoro::Task>> load(); + + QCoro::Task>> get_latest_report(); + QCoro::Task> estimated_impact_for(QString package_id); + // Base-game average plus the on-thread and off-thread averages of each active mod. + QCoro::Task> estimated_total(QStringList active_pids); + QCoro::Task>> get_all_averages(); + QCoro::Task> base_game_average(); + QCoro::Task> snapshot(QStringList active_pids, + std::optional selected_pid = {}); + QCoro::Task> clear(); + + QCoro::Task close_connection(); + void close_connection_sync(); + +private: + CoreContext& ctx_; + StartupImpactDb db_; +}; + +} // namespace px::core::startup_impact diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp new file mode 100644 index 0000000..34b1000 --- /dev/null +++ b/src/core/services/update_service.cpp @@ -0,0 +1,166 @@ +#include "core/services/update_service.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::core::services { + +namespace { + +constexpr int kTransferTimeoutMs = 10'000; + +struct ParsedVersion { + std::vector numbers; + QString suffix; +}; + +// Python's str.strip("-._ ") on both ends. +QString strip_separators(QString text) { + constexpr QStringView separators = u"-._ "; + while (!text.isEmpty() && separators.contains(text.front())) { + text.removeFirst(); + } + while (!text.isEmpty() && separators.contains(text.back())) { + text.chop(1); + } + return text; +} + +std::optional split_version(const QString& text) { + static const QRegularExpression pattern(QStringLiteral(R"(^v?([0-9]+(?:\.[0-9]+)*)([^+]*))")); + const QRegularExpressionMatch match = pattern.match(text.trimmed()); + if (!match.hasMatch()) { + return std::nullopt; + } + ParsedVersion parsed; + for (const QString& part : match.captured(1).split(u'.')) { + parsed.numbers.push_back(part.toLongLong()); + } + parsed.suffix = strip_separators(match.captured(2)).toLower(); + return parsed; +} + +// Orders two versions the way the legacy key did: padded numbers, then release over +// pre-release, then the suffix text. +int compare_versions(const ParsedVersion& a, const ParsedVersion& b) { + const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); + auto padded = [width](std::vector numbers) { + numbers.resize(width, 0); + return numbers; + }; + const std::vector left = padded(a.numbers); + const std::vector right = padded(b.numbers); + if (left != right) { + return left < right ? -1 : 1; + } + const bool leftRelease = a.suffix.isEmpty(); + const bool rightRelease = b.suffix.isEmpty(); + if (leftRelease != rightRelease) { + return leftRelease ? 1 : -1; + } + if (a.suffix != b.suffix) { + return a.suffix < b.suffix ? -1 : 1; + } + return 0; +} + +struct ReplyDeleter { + void operator()(QNetworkReply* reply) const { reply->deleteLater(); } +}; + +QString string_field(const QJsonObject& object, const char* key) { + const QJsonValue value = object.value(QLatin1StringView(key)); + if (value.isString()) { + return value.toString(); + } + if (value.isDouble()) { + return QString::number(value.toDouble()); + } + return {}; +} + +} // namespace + +std::optional> parse_version(const QString& text) { + const auto parsed = split_version(text); + if (!parsed) { + return std::nullopt; + } + return parsed->numbers; +} + +bool is_newer(const QString& tag, const QString& current) { + const auto latest = split_version(tag); + const auto running = split_version(current); + if (!latest || !running) { + return false; + } + return compare_versions(*latest, *running) > 0; +} + +UpdateService::UpdateService(QString current_version, QNetworkAccessManager& network, + QString release_url) + : current_(std::move(current_version)), + network_(network), + release_url_(std::move(release_url)) {} + +QCoro::Task>> UpdateService::check() { + QNetworkRequest request{QUrl(release_url_)}; + request.setRawHeader("Accept", "application/vnd.github+json"); + request.setRawHeader("User-Agent", QStringLiteral("PxModRim/%1").arg(current_).toUtf8()); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply* pending = network_.get(request); + std::unique_ptr reply(pending); + co_await pending; + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + if (status < 200 || status >= 300) { + co_return std::unexpected(Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(release_url_)}); + } + + const QByteArray body = reply->readAll(); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError) { + co_return std::unexpected(Error{parseError.errorString()}); + } + if (!document.isObject()) { + co_return std::unexpected(Error{QStringLiteral("Latest release response is not a JSON object")}); + } + + const QJsonObject data = document.object(); + const QString tag = string_field(data, "tag_name"); + const QString url = string_field(data, "html_url"); + if (tag.isEmpty() || url.isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Latest release response lacks tag_name/html_url")}); + } + if (!is_newer(tag, current_)) { + spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), current_.toStdString()); + co_return std::optional{}; + } + + spdlog::info("Update available: {} (running {})", tag.toStdString(), current_.toStdString()); + QString name = string_field(data, "name"); + if (name.isEmpty()) { + name = tag; + } + co_return std::optional( + ReleaseInfo{tag, name, string_field(data, "body"), url}); +} + +} // namespace px::core::services diff --git a/src/core/services/update_service.h b/src/core/services/update_service.h new file mode 100644 index 0000000..bfd12d5 --- /dev/null +++ b/src/core/services/update_service.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "core/error.h" + +class QNetworkAccessManager; + +namespace px::core::services { + +inline constexpr QLatin1StringView LATEST_RELEASE_URL{ + "https://api.github.com/repos/PyXiion/PxModRim/releases/latest"}; + +struct ReleaseInfo { + QString tag; + QString name; + QString notes; + QString url; +}; + +// The numeric components of `text` ("v1.2.3" -> 1, 2, 3), ignoring any suffix; nullopt when the +// text does not start with a version. +std::optional> parse_version(const QString& text); + +// True when `tag` is a strictly higher release than `current`. A pre-release suffix ("-rc1") +// sorts below the plain release with the same numbers. Versions that do not parse are never +// reported as outdated. +bool is_newer(const QString& tag, const QString& current); + +// Looks up the newest published GitHub release. +class UpdateService { +public: + // `network` is not owned and must outlive the service. `release_url` is the latest-release + // endpoint; the default is the project's own. + UpdateService(QString current_version, QNetworkAccessManager& network, + QString release_url = QString(LATEST_RELEASE_URL)); + + const QString& current_version() const noexcept { return current_; } + + // The latest release when it is newer than the running app, nullopt otherwise. Fails on a + // network error, a non-success HTTP status or a malformed response. + QCoro::Task>> check(); + +private: + QString current_; + QNetworkAccessManager& network_; + QString release_url_; +}; + +} // namespace px::core::services diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp new file mode 100644 index 0000000..5cfcf35 --- /dev/null +++ b/src/core/sort/community.cpp @@ -0,0 +1,57 @@ +#include "core/sort/community.h" + +#include + +#include + +#include "core/config.h" +#include "core/file_io.h" + +namespace px::core::sort { + +namespace { + +CaseInsensitiveSet to_package_set(const std::map& rules) { + CaseInsensitiveSet result; + for (const auto& [pid, _] : rules) { + result.add(QString::fromStdString(pid)); + } + return result; +} + +} // namespace + +CommunityRules to_community_rules(const ExternalRulesSchema& schema) { + CommunityRules result; + for (const auto& [pid_str, rule] : schema.rules) { + CommunityRule community; + community.package_id = PackageId(QString::fromStdString(pid_str)); + community.load_after = to_package_set(rule.loadAfter); + community.load_before = to_package_set(rule.loadBefore); + community.load_first = rule.loadTop.value; + community.load_last = rule.loadBottom.value; + result.insert(community.package_id, std::move(community)); + } + return result; +} + +Result load_community_rules(const QString& json_path) { + if (!QFileInfo::exists(json_path)) { + return CommunityRules{}; + } + + auto bytes = read_file_bytes(json_path); + if (!bytes) { + return std::unexpected(bytes.error()); + } + + ExternalRulesSchema schema; + if (auto error = glz::read(schema, *bytes)) { + return std::unexpected(Error{QStringLiteral("Failed to parse community rules %1: %2") + .arg(json_path, QString::fromStdString( + glz::format_error(error, *bytes)))}); + } + return to_community_rules(schema); +} + +} // namespace px::core::sort diff --git a/src/core/sort/community.h b/src/core/sort/community.h new file mode 100644 index 0000000..8e30167 --- /dev/null +++ b/src/core/sort/community.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "core/error.h" +#include "core/models/metadata/structures.h" +#include "core/sort/models.h" + +namespace px::core::sort { + +// Converts the upstream schema into rules keyed by package id. Only the keys of loadAfter and +// loadBefore matter; their values are ignored. +CommunityRules to_community_rules(const ExternalRulesSchema& schema); + +// Reads the community rules file. A missing file means no rules; a malformed one is an error. +Result load_community_rules(const QString& json_path); + +} // namespace px::core::sort diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp new file mode 100644 index 0000000..5d7a59a --- /dev/null +++ b/src/core/sort/community_service.cpp @@ -0,0 +1,214 @@ +#include "core/sort/community_service.h" + +#include "core/sort/community.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/file_io.h" + +namespace px::core::sort { + +namespace { + +const QString kRulesFilename = QStringLiteral("communityRules.json"); +const QString kRulesUrl = QStringLiteral( + "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/main.zip"); + +// Matches the httpx timeouts of the original client: a stalled transfer fails after these. +constexpr int kHeadTransferTimeoutMs = 10'000; +constexpr int kGetTransferTimeoutMs = 30'000; +constexpr int kExtractChunkBytes = 64 * 1024; + +Error network_error(const QNetworkReply& reply) { + return Error{reply.errorString()}; +} + +// Writes one archive entry under `dest`. Entries whose names would escape `dest` are skipped, +// as the Python zipfile sanitiser also refuses to write outside the target directory. +Result extract_entry(zip_t* archive, zip_uint64_t index, const QDir& dest) { + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || !(stat.valid & ZIP_STAT_NAME)) { + return std::unexpected(Error{QStringLiteral("Unreadable community rules archive entry")}); + } + + const QString name = QString::fromUtf8(stat.name); + const QString relative = QDir::cleanPath(name); + const bool escapes = relative == QLatin1String("..") || relative.startsWith(QLatin1String("../")) || + QDir::isAbsolutePath(relative); + if (escapes || relative == QLatin1String(".") || relative.isEmpty()) { + return {}; + } + + if (name.endsWith(u'/')) { + if (!dest.mkpath(relative)) { + return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(dest.filePath(relative))}); + } + return {}; + } + + if (!dest.mkpath(QFileInfo(relative).path())) { + return std::unexpected(Error{QStringLiteral("Cannot create directory for %1").arg(relative)}); + } + zip_file_t* entry = zip_fopen_index(archive, index, 0); + if (!entry) { + return std::unexpected(Error{QStringLiteral("Cannot read %1 from archive").arg(name)}); + } + auto close_entry = qScopeGuard([entry] { zip_fclose(entry); }); + + const QString target = dest.filePath(relative); + QFile out(target); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(target)}); + } + + QByteArray buffer(kExtractChunkBytes, Qt::Uninitialized); + for (;;) { + const zip_int64_t read = zip_fread(entry, buffer.data(), static_cast(buffer.size())); + if (read < 0) { + return std::unexpected(Error{QStringLiteral("Corrupt archive entry %1").arg(name)}); + } + if (read == 0) { + break; + } + if (out.write(buffer.constData(), read) != read) { + return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(target)}); + } + } + return {}; +} + +Result extract_zip(const QString& zip_path, const QString& dest_dir) { + int error_code = 0; + zip_t* archive = zip_open(QFile::encodeName(zip_path).constData(), ZIP_RDONLY, &error_code); + if (!archive) { + return std::unexpected( + Error{QStringLiteral("Cannot open %1 (libzip error %2)").arg(zip_path).arg(error_code)}); + } + auto close_archive = qScopeGuard([archive] { zip_close(archive); }); + + if (!QDir().mkpath(dest_dir)) { + return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(dest_dir)}); + } + const QDir dest(dest_dir); + const zip_int64_t count = zip_get_num_entries(archive, 0); + for (zip_int64_t i = 0; i < count; ++i) { + if (auto extracted = extract_entry(archive, static_cast(i), dest); !extracted) { + return extracted; + } + } + return {}; +} + +} // namespace + +CommunityRulesService::CommunityRulesService(const ConfigService& config_service, + QNetworkAccessManager& network) + : cache_dir_(config_service.directory()), + json_path_(QDir(cache_dir_).filePath(kRulesFilename)), + network_(network) {} + +QCoro::Task> CommunityRulesService::ensure_rules(LoadingState& loading, + bool force) { + if (QFileInfo::exists(json_path_) && !force) { + co_return json_path_; + } + + auto result = co_await download_rules(loading); + if (!result) { + spdlog::warn("Failed to download community rules: {}", result.error().message.toStdString()); + co_return std::nullopt; + } + co_return *result; +} + +QCoro::Task>> CommunityRulesService::download_rules(LoadingState& loading) { + if (!QDir().mkpath(cache_dir_)) { + co_return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(cache_dir_)}); + } + const QDir cache(cache_dir_); + const QString zip_path = cache.filePath(QStringLiteral("community_rules.zip")); + const QString extract_dir = cache.filePath(QStringLiteral("community_rules_extracted")); + + QNetworkRequest request{QUrl(kRulesUrl)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + // The HEAD request only sizes the progress bar; a missing Content-Length falls back to 100 + // steps, which is what the original did. + request.setTransferTimeout(kHeadTransferTimeoutMs); + QNetworkReply* head = network_.head(request); + auto release_head = qScopeGuard([head] { head->deleteLater(); }); + co_await qCoro(head).waitForFinished(); + if (head->error() != QNetworkReply::NoError) { + co_return std::unexpected(network_error(*head)); + } + const qint64 content_length = head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + const int total_size = content_length > 0 + ? static_cast(std::min(content_length, + std::numeric_limits::max())) + : 100; + + request.setTransferTimeout(kGetTransferTimeoutMs); + QNetworkReply* get = network_.get(request); + auto release_get = qScopeGuard([get] { get->deleteLater(); }); + + QFile zip_file(zip_path); + if (!zip_file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + co_return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(zip_path)}); + } + { + auto download = loading.task(QStringLiteral("Downloading community rules..."), total_size); + QObject::connect(get, &QNetworkReply::readyRead, get, [get, &zip_file, &loading] { + const QByteArray chunk = get->readAll(); + zip_file.write(chunk); + loading.step(static_cast(chunk.size())); + }); + co_await qCoro(get).waitForFinished(); + if (get->error() != QNetworkReply::NoError) { + co_return std::unexpected(network_error(*get)); + } + zip_file.write(get->readAll()); + if (zip_file.error() != QFileDevice::NoError) { + co_return std::unexpected(Error{zip_file.errorString()}); + } + zip_file.close(); + } + + { + auto extract = loading.task(QStringLiteral("Extracting..."), 100); + if (auto extracted = extract_zip(zip_path, extract_dir); !extracted) { + co_return std::unexpected(extracted.error()); + } + loading.step(50); + + QDirIterator rules(extract_dir, {kRulesFilename}, QDir::Files, QDirIterator::Subdirectories); + if (!rules.hasNext()) { + // An archive without the rules file is not an error: there is simply nothing to cache. + co_return std::optional{}; + } + if (auto copied = copy_file_replacing(rules.next(), json_path_); !copied) { + co_return std::unexpected(copied.error()); + } + loading.step(50); + } + co_return std::optional(json_path_); +} + +Result CommunityRulesService::load_rules() const { + return load_community_rules(json_path_); +} + +} // namespace px::core::sort diff --git a/src/core/sort/community_service.h b/src/core/sort/community_service.h new file mode 100644 index 0000000..4a9a3f4 --- /dev/null +++ b/src/core/sort/community_service.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include + +#include "core/config.h" +#include "core/error.h" +#include "core/loading.h" +#include "core/sort/models.h" + +class QNetworkAccessManager; + +namespace px::core::sort { + +// Keeps the community rules file in the config directory, downloading the Community-Rules-Database +// archive when the file is missing. +class CommunityRulesService { +public: + // `network` is not owned and must outlive the service. + CommunityRulesService(const ConfigService& config_service, QNetworkAccessManager& network); + + // Returns the rules file path, downloading first when it is missing or `force` is set. + // nullopt when the download or extraction failed, or the archive has no rules file; the + // failure is logged, not returned. + QCoro::Task> ensure_rules(LoadingState& loading, bool force = false); + + Result load_rules() const; + +private: + QCoro::Task>> download_rules(LoadingState& loading); + + QString cache_dir_; + QString json_path_; + QNetworkAccessManager& network_; +}; + +} // namespace px::core::sort diff --git a/src/core/sort/config.cpp b/src/core/sort/config.cpp new file mode 100644 index 0000000..ebaa99e --- /dev/null +++ b/src/core/sort/config.cpp @@ -0,0 +1,41 @@ +#include "core/sort/config.h" + +#include + +namespace px::core::sort { + +TierConfig TierConfig::defaults() { + return TierConfig{ + .tier_0 = + { + PackageId(QStringLiteral("zetrith.prepatcher")), + PackageId(QStringLiteral("brrainz.harmony")), + PackageId(QStringLiteral("brrainz.visualexceptions")), + PackageId(QStringLiteral("ludeon.rimworld")), + PackageId(QStringLiteral("ludeon.rimworld.royalty")), + PackageId(QStringLiteral("ludeon.rimworld.ideology")), + PackageId(QStringLiteral("ludeon.rimworld.biotech")), + PackageId(QStringLiteral("ludeon.rimworld.anomaly")), + PackageId(QStringLiteral("ludeon.rimworld.odyssey")), + }, + .tier_1 = + { + PackageId(QStringLiteral("adaptive.storage.framework")), + PackageId(QStringLiteral("aoba.framework")), + PackageId(QStringLiteral("aoba.exosuit.framework")), + PackageId(QStringLiteral("ebsg.framework")), + PackageId(QStringLiteral("imranfish.xmlextensions")), + PackageId(QStringLiteral("thesepeople.ritualattachableoutcomes")), + PackageId(QStringLiteral("ohno.asf.ab.local")), + PackageId(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")), + PackageId(QStringLiteral("owlchemist.cherrypicker")), + PackageId(QStringLiteral("redmattis.betterprerequisites")), + PackageId(QStringLiteral("smashphil.vehicleframework")), + PackageId(QStringLiteral("unlimitedhugs.hugslib")), + PackageId(QStringLiteral("vanillaexpanded.backgrounds")), + }, + .tier_3 = {}, + }; +} + +} // namespace px::core::sort diff --git a/src/core/sort/config.h b/src/core/sort/config.h new file mode 100644 index 0000000..e5a8dd4 --- /dev/null +++ b/src/core/sort/config.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "core/models/metadata/structures.h" + +namespace px::core::sort { + +using PackageId = CaseInsensitiveStr; + +enum class Tier : int { Tier0 = 0, Tier1 = 1, Tier2 = 2, Tier3 = 3 }; + +// Serialised by glaze into config.json: member names are the JSON keys. +struct TierConfig { + std::vector tier_0; + std::vector tier_1; + std::vector tier_3; + + static TierConfig defaults(); + + bool operator==(const TierConfig&) const = default; +}; + +struct SortSettings { + bool use_alternative_package_ids = true; + bool check_missing_dependencies = true; + bool use_community_rules = true; + TierConfig tier_config = TierConfig::defaults(); + + bool operator==(const SortSettings&) const = default; +}; + +} // namespace px::core::sort diff --git a/src/core/sort/models.h b/src/core/sort/models.h new file mode 100644 index 0000000..4dc84a6 --- /dev/null +++ b/src/core/sort/models.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +namespace px::core::sort { + +// Load-order rule from the community database for one package id. +struct CommunityRule { + PackageId package_id; + CaseInsensitiveSet load_after; + CaseInsensitiveSet load_before; + bool load_first = false; + bool load_last = false; + CaseInsensitiveSet incompatible_with; + + bool operator==(const CommunityRule&) const = default; +}; + +using CommunityRules = QHash; + +// Adjacency map over package ids: a node's dependencies or reverse dependencies. +using PackageGraph = QHash>; + +} // namespace px::core::sort diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp new file mode 100644 index 0000000..54cbaab --- /dev/null +++ b/src/core/sort/tiers.cpp @@ -0,0 +1,164 @@ +#include "core/sort/tiers.h" + +#include +#include + +namespace px::core::sort { + +namespace { + +// Every node reachable from `start` by at least one edge. `start` is included only when a cycle +// leads back to it. +QSet reachable(const PackageId& start, const PackageGraph& graph) { + QSet result; + QSet visited{start}; + std::vector stack{start}; + while (!stack.empty()) { + const PackageId pid = stack.back(); + stack.pop_back(); + for (const auto& next : graph.value(pid)) { + result.insert(next); + if (!visited.contains(next)) { + visited.insert(next); + stack.push_back(next); + } + } + } + return result; +} + +// Adds `ids` to `tiers` as `tier`, skipping ids that are not canonical (keys of `tiers`). +void assign_present(QHash& tiers, const QSet& ids, Tier tier) { + for (const auto& pid : ids) { + if (auto it = tiers.find(pid); it != tiers.end()) { + *it = tier; + } + } +} + +} // namespace + +QHash assign_tiers(const QSet& canonical_ids, + const PackageGraph& deps, + const PackageGraph& rev_deps, + const QSet& load_first, + const QSet& load_last, + const TierConfig& tier_config) { + QHash tiers; + for (const auto& pid : canonical_ids) { + tiers.insert(pid, Tier::Tier2); + } + + // Tier 3 is the bottom of the load order, so dependents of late mods also sink there. + QSet tier3_set = load_last; + for (const auto& pid : load_last) { + tier3_set |= reachable(pid, rev_deps); + } + for (const auto& pid : tier_config.tier_3) { + if (canonical_ids.contains(pid)) { + tier3_set.insert(pid); + tier3_set |= reachable(pid, rev_deps); + } + } + assign_present(tiers, tier3_set, Tier::Tier3); + + // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a tier-3 mark. + QSet tier0_set; + for (const auto& pid : tier_config.tier_0) { + if (canonical_ids.contains(pid)) { + tier0_set.insert(pid); + tier0_set |= reachable(pid, deps); + } + } + assign_present(tiers, tier0_set, Tier::Tier0); + + // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. + QSet tier1_set; + for (const auto& pid : tier_config.tier_1) { + if (canonical_ids.contains(pid)) { + tier1_set.insert(pid); + tier1_set |= reachable(pid, deps); + } + } + for (const auto& pid : load_first) { + if (canonical_ids.contains(pid)) { + tier1_set.insert(pid); + tier1_set |= reachable(pid, deps); + } + } + for (const auto& pid : tier1_set) { + if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::Tier2) { + *it = Tier::Tier1; + } + } + + return tiers; +} + +QSet get_deps_recursive(const PackageId& start, const PackageGraph& deps) { + return reachable(start, deps); +} + +QSet get_reverse_deps_recursive(const PackageId& start, const PackageGraph& rev_deps) { + return reachable(start, rev_deps); +} + +std::optional> find_cycle(const QSet& nodes, const PackageGraph& deps) { + // One frame per node on the DFS stack; `next` indexes the node's outgoing edges. + struct Frame { + PackageId pid; + QList edges; + qsizetype next = 0; + }; + + QSet visited; + QSet rec_stack; + QHash parent; + + for (const auto& root : nodes) { + if (visited.contains(root)) { + continue; + } + + parent.insert(root, root); + visited.insert(root); + rec_stack.insert(root); + std::vector stack{Frame{root, deps.value(root).values()}}; + while (!stack.empty()) { + Frame& top = stack.back(); + if (top.next == top.edges.size()) { + rec_stack.remove(top.pid); + stack.pop_back(); + continue; + } + + const PackageId pid = top.pid; + const PackageId dep = top.edges[top.next++]; + if (!nodes.contains(dep)) { + continue; + } + if (!visited.contains(dep)) { + parent.insert(dep, pid); + visited.insert(dep); + rec_stack.insert(dep); + // `top` is invalid after this push. + stack.push_back(Frame{dep, deps.value(dep).values()}); + } else if (rec_stack.contains(dep)) { + // Walk parents from the current node back to `dep`, which is on the DFS path. + QList cycle{dep}; + PackageId current = pid; + while (current != dep) { + cycle.append(current); + current = parent.value(current); + } + cycle.append(dep); + std::ranges::reverse(cycle); + return cycle; + } + } + } + + return std::nullopt; +} + +} // namespace px::core::sort diff --git a/src/core/sort/tiers.h b/src/core/sort/tiers.h new file mode 100644 index 0000000..d7688dc --- /dev/null +++ b/src/core/sort/tiers.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +#include "core/sort/config.h" +#include "core/sort/models.h" + +namespace px::core::sort { + +// Assigns each canonical id to a Tier from load order and dependency constraints. Ids outside +// `canonical_ids` are never returned. +QHash assign_tiers(const QSet& canonical_ids, + const PackageGraph& deps, + const PackageGraph& rev_deps, + const QSet& load_first, + const QSet& load_last, + const TierConfig& tier_config); + +// Transitive closure over `deps`, excluding `start` itself unless it is reachable from itself. +QSet get_deps_recursive(const PackageId& start, const PackageGraph& deps); + +// Transitive closure over `rev_deps`, excluding `start` itself unless it is reachable from itself. +QSet get_reverse_deps_recursive(const PackageId& start, const PackageGraph& rev_deps); + +// Returns one cycle among `nodes` as a closed path (first == last), or nullopt when the nodes +// form a DAG. Edges leaving `nodes` are ignored. +std::optional> find_cycle(const QSet& nodes, const PackageGraph& deps); + +} // namespace px::core::sort diff --git a/src/core/structures.h b/src/core/structures.h new file mode 100644 index 0000000..4e79a40 --- /dev/null +++ b/src/core/structures.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace px::core { + +struct CollectionStats { + std::int64_t total = 0; + std::int64_t active = 0; + std::int64_t inactive = 0; + std::int64_t errors = 0; +}; + +} // namespace px::core diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp new file mode 100644 index 0000000..87f0b3e --- /dev/null +++ b/src/core/support/report.cpp @@ -0,0 +1,368 @@ +#include "core/support/report.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/version.h" + +#if defined(Q_OS_UNIX) +#include +#include +#endif + +namespace px::core::support { + +namespace { + +constexpr qint64 MIB = 1024 * 1024; +constexpr qint64 KIB = 1024; +constexpr qint64 MIN_READ_WINDOW = 10 * MIB; +// Upload responses are shown back to the user, so only a short prefix is echoed. +constexpr qsizetype MAX_ECHOED_BODY = 100; + +// A path component may not run into adjacent name characters, so "ludeon.rimworld" is never +// mistaken for a path that contains the username "rimworld". +constexpr QLatin1StringView COMPONENT_START{R"re((?pw_name); + } +#endif + return QString(); +} + +QString format_size(qint64 bytes) { + if (bytes >= MIB) { + return QString::number(static_cast(bytes) / static_cast(MIB), 'f', 1) + + QStringLiteral(" MiB"); + } + if (bytes >= KIB) { + return QString::number(static_cast(bytes) / static_cast(KIB), 'f', 1) + + QStringLiteral(" KiB"); + } + return QString::number(bytes) + QStringLiteral(" B"); +} + +// Splits the way Python's str.splitlines() does: several characters end a line, and a trailing +// terminator does not start an empty line. +QStringList split_lines(const QString& text) { + QStringList lines; + qsizetype start = 0; + for (qsizetype i = 0; i < text.size(); ++i) { + const char16_t c = text[i].unicode(); + const bool boundary = c == u'\n' || c == u'\r' || c == u'\v' || c == u'\f' || + c == 0x1c || c == 0x1d || c == 0x1e || c == 0x85 || c == 0x2028 || + c == 0x2029; + if (!boundary) { + continue; + } + lines.append(text.mid(start, i - start)); + if (c == u'\r' && i + 1 < text.size() && text[i + 1] == u'\n') { + ++i; + } + start = i + 1; + } + if (start < text.size()) { + lines.append(text.mid(start)); + } + return lines; +} + +// loguru writes each record as a JSON object whose "text" holds the formatted line. Lines +// that are not such records pass through unchanged. +QString log_line_text(const QString& line) { + const QString trimmed = line.trimmed(); + if (!trimmed.startsWith(u'{') || !trimmed.endsWith(u'}')) { + return line; + } + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(trimmed.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return line; + } + const QJsonValue text = document.object().value(QStringLiteral("text")); + if (!text.isString()) { + return line; + } + QString result = text.toString(); + while (result.endsWith(u'\r') || result.endsWith(u'\n')) { + result.chop(1); + } + return result; +} + +QString parse_log_text(const QString& raw_text) { + QStringList lines = split_lines(raw_text); + for (QString& line : lines) { + line = log_line_text(line); + } + QString result = lines.join(QLatin1Char('\n')); + if (raw_text.endsWith(u'\n') && !result.isEmpty()) { + result += u'\n'; + } + return result; +} + +// Drops the partial line at the start of a tail slice. +QByteArray after_first_newline(const QByteArray& data) { + const qsizetype newline = data.indexOf('\n'); + return newline < 0 ? data : data.mid(newline + 1); +} + +Result read_raw_tail(QFile& file, qint64 size, qint64 window) { + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (size <= window) { + return file.readAll(); + } + file.seek(size - window); + return after_first_newline(file.read(window)); +} + +// Python's repr() of a string, for quoting part of a server response in an error message. +QString py_repr(const QString& text) { + const QChar quote = text.contains(u'\'') && !text.contains(u'"') ? QChar(u'"') : QChar(u'\''); + QString out(quote); + for (const QChar c : text) { + switch (c.unicode()) { + case u'\\': + out += QStringLiteral("\\\\"); + break; + case u'\n': + out += QStringLiteral("\\n"); + break; + case u'\r': + out += QStringLiteral("\\r"); + break; + case u'\t': + out += QStringLiteral("\\t"); + break; + default: + if (c == quote) { + out += u'\\'; + } + out += c; + } + } + out += quote; + return out; +} + +} // namespace + +QString redact(const QString& text, const std::optional& home_dir, + const std::optional& username) { + QString result = text; + + QString home = home_dir ? *home_dir : QDir::homePath(); + while (home.endsWith(u'/') || home.endsWith(u'\\')) { + home.chop(1); + } + if (!home.isEmpty()) { + // The same directory may appear with either separator, and with doubled backslashes + // when it was embedded in a repr(). + const QString backslashed = QString(home).replace(u'/', u'\\'); + QSet variants{home, QString(home).replace(u'\\', u'/'), backslashed, + QString(backslashed).replace(u'\\', QStringLiteral("\\\\"))}; + QStringList ordered(variants.begin(), variants.end()); + // Longest first, so a variant that is a prefix of another cannot shadow it. + std::ranges::stable_sort(ordered, {}, [](const QString& s) { return s.size(); }); + std::ranges::reverse(ordered); + + QStringList alternatives; + for (const QString& variant : ordered) { + alternatives.append(QRegularExpression::escape(variant)); + } + const QRegularExpression home_pattern = unicode_regex( + QString(COMPONENT_START) + QStringLiteral("(?:") + alternatives.join(u'|') + + QStringLiteral(")") + QString(COMPONENT_END)); + result.replace(home_pattern, QStringLiteral("~")); + } + + // Only in path contexts: a bare word match would corrupt ids such as ludeon.rimworld. + const QString user = username ? *username : current_username(); + if (!user.isEmpty()) { + const QRegularExpression user_pattern = unicode_regex( + QStringLiteral("(") + QString(USER_DIR_PREFIX) + QStringLiteral(")") + + QRegularExpression::escape(user) + QString(COMPONENT_END), + QRegularExpression::CaseInsensitiveOption); + result.replace(user_pattern, QStringLiteral("\\1")); + } + return result; +} + +QString read_log_tail(const std::optional& log_path, qint64 max_bytes) { + const QString path = log_path ? *log_path + : QDir(config_dir()).filePath(QStringLiteral("logs/pxmodrim.log")); + const QFileInfo info(path); + if (!info.isFile()) { + return QStringLiteral(""); + } + const qint64 size = info.size(); + if (size == 0) { + return QStringLiteral(""); + } + + // JSON records shrink once unpacked, so read a wider raw window than the byte budget. + const qint64 read_window = std::max(max_bytes * 5, MIN_READ_WINDOW); + QFile file(path); + auto raw = read_raw_tail(file, size, read_window); + if (!raw) { + return QStringLiteral("").arg(raw.error().message); + } + + const QString plain = parse_log_text(QString::fromUtf8(*raw)); + const QByteArray encoded = plain.toUtf8(); + if (encoded.size() <= max_bytes) { + return plain; + } + + const QByteArray tail = after_first_newline(encoded.right(max_bytes)); + return QStringLiteral("[... truncated, showing last %1 of %2 ...]\n") + .arg(format_size(tail.size()), format_size(encoded.size())) + + QString::fromUtf8(tail); +} + +QString build_report(const ReportSource& source, const std::optional& log_path, + const std::optional& home_dir, const std::optional& username, + qint64 max_log_bytes) { + QStringList lines; + + lines << QStringLiteral("=== PxModRim System Report ==="); + lines << QStringLiteral("App Version: ") + appVersion(); + lines << QStringLiteral("Qt Version: ") + QString::fromLatin1(qVersion()); + lines << QStringLiteral("OS: %1 %2 (%3)") + .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture()); + lines << QString(); + + const PathConfig& paths = source.paths; + lines << QStringLiteral("=== RimWorld Configuration ==="); + lines << QStringLiteral("Game Version: ") + source.game_version; + const auto or_none = [](const std::string& value) { + return value.empty() ? QStringLiteral("") : QString::fromStdString(value); + }; + lines << QStringLiteral("Game Path: ") + or_none(paths.game); + lines << QStringLiteral("Config Folder: ") + or_none(paths.config_folder); + lines << QStringLiteral("Local Mods Path: ") + or_none(paths.local); + lines << QStringLiteral("Workshop Mods Path: ") + or_none(paths.workshop); + lines << QString(); + + lines << QStringLiteral("=== Mod Collection ==="); + lines << QStringLiteral("Total Mods: %1").arg(source.all_mods.size()); + lines << QStringLiteral("Active Mods: %1").arg(source.active_uuids.size()); + lines << QString(); + + lines << QStringLiteral("=== Active Mods (Load Order) ==="); + if (source.active_uuids.isEmpty()) { + lines << QStringLiteral(" "); + } + for (qsizetype i = 0; i < source.active_uuids.size(); ++i) { + const QString& uuid = source.active_uuids[i]; + QString mod_name = QStringLiteral("Unknown Mod"); + QString pid = uuid; + if (const auto found = source.all_mods.constFind(uuid); found != source.all_mods.cend()) { + mod_name = (*found)->name; + const auto* about = dynamic_cast(found->get()); + pid = about ? about->package_id.str() : QStringLiteral("Unknown"); + } + lines << QStringLiteral("%1. %2 (%3)") + .arg(QString::number(i + 1).rightJustified(3), mod_name, pid); + } + lines << QString(); + + lines << QStringLiteral("=== Application Log ==="); + lines << read_log_tail(log_path, max_log_bytes); + + return redact(lines.join(QLatin1Char('\n')), home_dir, username); +} + +PasteUploader::PasteUploader(QNetworkAccessManager& network, QString endpoint, + std::chrono::milliseconds timeout) + : network_(network), endpoint_(std::move(endpoint)), timeout_(timeout) {} + +QCoro::Task> PasteUploader::upload(QString text) { + if (text.trimmed().isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Report text is empty")}); + } + + QNetworkRequest request{QUrl(endpoint_)}; + request.setRawHeader("User-Agent", (QStringLiteral("PxModRim/") + appVersion()).toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("text/plain; charset=utf-8")); + request.setTransferTimeout(static_cast(timeout_.count())); + + QNetworkReply* reply = network_.post(request, text.toUtf8()); + const auto release_reply = qScopeGuard([reply] { reply->deleteLater(); }); + co_await qCoro(reply).waitForFinished(); + + if (reply->error() == QNetworkReply::TimeoutError) { + co_return std::unexpected(Error{QStringLiteral("Upload failed: request timed out after %1s") + .arg(QString::number(timeout_.count() / 1000.0))}); + } + // No HTTP status means the request never got a response. + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status == 0) { + co_return std::unexpected(Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + if (status < 200 || status >= 300) { + const QString reason = QString::fromUtf8( + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toByteArray()); + co_return std::unexpected(Error{ + reason.isEmpty() ? QStringLiteral("Upload failed: HTTP %1").arg(status) + : QStringLiteral("Upload failed: HTTP %1 %2").arg(status).arg(reason)}); + } + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (!body.startsWith(QLatin1StringView("http://")) && + !body.startsWith(QLatin1StringView("https://"))) { + co_return std::unexpected(Error{ + QStringLiteral("Upload failed: server did not return a valid URL: %1") + .arg(py_repr(body.left(MAX_ECHOED_BODY)))}); + } + co_return body; +} + +} // namespace px::core::support diff --git a/src/core/support/report.h b/src/core/support/report.h new file mode 100644 index 0000000..0db2ae7 --- /dev/null +++ b/src/core/support/report.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/error.h" +#include "core/models/metadata/structures.h" + +class QNetworkAccessManager; + +namespace px::core::support { + +inline constexpr qint64 MAX_LOG_BYTES = 2 * 1024 * 1024; +inline constexpr QLatin1StringView DEFAULT_UPLOAD_ENDPOINT{"https://paste.rs/"}; + +// The app state a diagnostic report describes. Copied out of the live application so that +// building a report never touches it. +struct ReportSource { + QString game_version = QStringLiteral("Unknown"); + PathConfig paths; + // Keyed by uuid, as the mod registry is. + QHash all_mods; + QStringList active_uuids; +}; + +// Replaces the home directory with "~" and user-directory usernames with "". The home +// directory and username default to the current user's when not given. +QString redact(const QString& text, const std::optional& home_dir = std::nullopt, + const std::optional& username = std::nullopt); + +// The last `max_bytes` of the log as plain text, with loguru JSON records unpacked. Defaults to +// the app's own log under the config directory. +QString read_log_tail(const std::optional& log_path = std::nullopt, + qint64 max_bytes = MAX_LOG_BYTES); + +// Plain-text system and log report, with the user's details redacted. +QString build_report(const ReportSource& source, + const std::optional& log_path = std::nullopt, + const std::optional& home_dir = std::nullopt, + const std::optional& username = std::nullopt, + qint64 max_log_bytes = MAX_LOG_BYTES); + +// Posts plain-text reports to a paste service. Not owned; `network` must outlive the uploader. +class PasteUploader { +public: + PasteUploader(QNetworkAccessManager& network, + QString endpoint = QString(DEFAULT_UPLOAD_ENDPOINT), + std::chrono::milliseconds timeout = std::chrono::seconds(30)); + + // Resolves to the URL the service returned, or to a user-facing error message. + QCoro::Task> upload(QString text); + +private: + QNetworkAccessManager& network_; + QString endpoint_; + std::chrono::milliseconds timeout_; +}; + +} // namespace px::core::support diff --git a/src/core/toposort.h b/src/core/toposort.h new file mode 100644 index 0000000..d700dc6 --- /dev/null +++ b/src/core/toposort.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +#include "core/error.h" + +namespace px::core { + +// Kahn's algorithm, layered the way the `toposort` package lays levels: level 0 +// holds nodes without dependencies, and each later level holds nodes whose +// dependencies all sit in earlier levels. Names within a level are ascending, +// so the result is deterministic. Self-dependencies and dependencies that are +// not keys of `graph` are ignored. +inline Result> toposort_levels( + const std::map>& graph) { + std::map> pending; + for (const auto& [node, deps] : graph) { + QSet edges; + for (const auto& dep : deps) { + if (dep != node && graph.contains(dep)) { + edges.insert(dep); + } + } + pending.emplace(node, std::move(edges)); + } + + std::vector levels; + while (!pending.empty()) { + QStringList level; + for (const auto& [node, deps] : pending) { + if (deps.isEmpty()) { + level.append(node); + } + } + if (level.isEmpty()) { + QStringList stuck; + for (const auto& [node, deps] : pending) { + stuck.append(node); + } + return std::unexpected(Error{QStringLiteral("Circular dependencies exist among these plugins: %1") + .arg(stuck.join(QStringLiteral(", ")))}); + } + for (const auto& node : level) { + pending.erase(node); + } + for (auto& [node, deps] : pending) { + for (const auto& done : level) { + deps.remove(done); + } + } + levels.push_back(std::move(level)); + } + return levels; +} + +} // namespace px::core diff --git a/src/core/utils.cpp b/src/core/utils.cpp new file mode 100644 index 0000000..1da6340 --- /dev/null +++ b/src/core/utils.cpp @@ -0,0 +1,51 @@ +#include "core/utils.h" + +#include +#include + +namespace px::core { + +std::optional findAboutXml(const QString& modPath) { + if (!QFileInfo(modPath).isDir()) { + return std::nullopt; + } + const QDir modDir(modPath); + const QString candidate = modDir.filePath(QStringLiteral("About/About.xml")); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + + // Only the first directory whose name matches "about" (any case) is searched, as in the + // legacy implementation. + const QFileInfoList entries = + modDir.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (entry.fileName().toLower() != QStringLiteral("about") || !entry.isDir()) { + continue; + } + const QDir aboutDir(entry.absoluteFilePath()); + const QFileInfoList children = + aboutDir.entryInfoList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& child : children) { + if (child.fileName().toLower() == QStringLiteral("about.xml")) { + return child.absoluteFilePath(); + } + } + break; + } + return std::nullopt; +} + +bool isAsciiDigits(QStringView text) { + if (text.isEmpty()) { + return false; + } + for (const QChar ch : text) { + if (ch.unicode() < u'0' || ch.unicode() > u'9') { + return false; + } + } + return true; +} + +} // namespace px::core diff --git a/src/core/utils.h b/src/core/utils.h new file mode 100644 index 0000000..cef4375 --- /dev/null +++ b/src/core/utils.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include +#include + +namespace px::core { + +// Case-insensitive search for About/About.xml inside a mod directory. +std::optional findAboutXml(const QString& modPath); + +// True for a non-empty string made only of ASCII digits (Python's str.isdigit for ASCII input). +bool isAsciiDigits(QStringView text); + +} // namespace px::core diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp new file mode 100644 index 0000000..542cbcf --- /dev/null +++ b/src/core/workshop/catalog.cpp @@ -0,0 +1,341 @@ +#include "core/workshop/catalog.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "core/context.h" +#include "core/mod_service.h" + +namespace px::core::workshop { + +namespace { + +// Memory keeps the newest responses; the disk keeps the newest cache files. +constexpr qsizetype kCacheLimit = 200; +constexpr qsizetype kDiskLimit = 300; + +QStringList to_qstrings(const std::vector& values) { + QStringList out; + out.reserve(static_cast(values.size())); + for (const std::string& value : values) { + out.append(QString::fromStdString(value)); + } + return out; +} + +const std::string& title_of(const CatalogEntry& entry) { + return std::visit([](const auto& item) -> const std::string& { return item.title; }, entry); +} + +} // namespace + +namespace detail { + +Result write_cached(const QString& directory, const QString& path, const std::string& text) { + if (auto saved = write_file_atomically(path, text); !saved) { + return saved; + } + QFileInfoList files = QDir(directory).entryInfoList({QStringLiteral("*.json")}, QDir::Files); + std::ranges::sort(files, [](const QFileInfo& a, const QFileInfo& b) { return a.lastModified() < b.lastModified(); }); + for (qsizetype i = 0; i + kDiskLimit < files.size(); ++i) { + QFile::remove(files[i].absoluteFilePath()); + } + return {}; +} + +} // namespace detail + +WorkshopCatalog::WorkshopCatalog() + : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), + settings_(nullptr, QStringLiteral("workshop_catalog")), + base_url_(QString::fromStdString(settings_.value().url)), favourites_(nullptr) { + settings_.changed.connect([this](const CatalogSettings& value) { change_url(QString::fromStdString(value.url)); }); +} + +void WorkshopCatalog::setup(CoreContext& ctx) { + context_ = &ctx; + installed_mods_ = [&ctx] { return ctx.allMods().values(); }; + if (ctx.hasConfigService()) { + settings_ = PluginConfig(&ctx.configService(), QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}); + settings_.changed.connect([this](const CatalogSettings& value) { change_url(QString::fromStdString(value.url)); }); + change_url(QString::fromStdString(settings_.value().url)); + cache_dir_ = ctx.configService().directory() + QStringLiteral("/workshop-cache"); + favourites_ = FavouriteCollections(&ctx.configService()); + } + bind_downloads(ctx.downloads()); + mods_changed_id_ = ctx.modService().mods_changed.connect([this](const std::monostate&) { invalidate_installed(); }); + config_changed_id_ = ctx.configChanged().connect([this](const AppConfig&) { invalidate_installed(); }); +} + +WorkshopCatalog::WorkshopCatalog(ConfigService* config, DownloadManager& downloads, + InstalledModsProvider installed_mods) + : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), + settings_(config, QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}), + base_url_(normalize_base_url(QString::fromStdString(settings_.value().url))), + favourites_(config), + downloads_(&downloads), + installed_mods_(std::move(installed_mods)) { + if (config) { + cache_dir_ = config->directory() + QStringLiteral("/workshop-cache"); + } + settings_.changed.connect( + [this](const CatalogSettings& settings) { change_url(QString::fromStdString(settings.url)); }); + bind_downloads(downloads); +} + +void WorkshopCatalog::bind_downloads(DownloadManager& downloads) { + downloads_ = &downloads; + download_finished_id_ = downloads_->download_finished.connect([this](const DownloadResult&) { invalidate_installed(); }); + busy_changed_id_ = downloads_->busy_changed.connect([this](bool busy) { on_busy_changed(busy); }); +} + +void WorkshopCatalog::disconnect_events() { + if (downloads_) { + downloads_->download_finished.disconnect(download_finished_id_); + downloads_->busy_changed.disconnect(busy_changed_id_); + download_finished_id_ = 0; + busy_changed_id_ = 0; + } + if (context_) { + context_->modService().mods_changed.disconnect(mods_changed_id_); + context_->configChanged().disconnect(config_changed_id_); + context_ = nullptr; + } +} + +WorkshopCatalog::~WorkshopCatalog() { + disconnect_events(); + // Destroying the queue's coroutine must not leave the downloads holding its handle. + idle_waiters_.clear(); + runner_.reset(); +} + +QCoro::Task WorkshopCatalog::shutdown() { + disconnect_events(); + cancel_queue(); + idle_waiters_.clear(); + runner_.reset(); + draining_ = false; + for (const auto& client : clients_) { + client->shutdown(); + } + co_return; +} + +void WorkshopCatalog::set_base_url(const QString& url) { + settings_.update(CatalogSettings{.url = normalize_base_url(url).toStdString()}); +} + +void WorkshopCatalog::change_url(const QString& url) { + const QString normalized = normalize_base_url(url); + if (normalized == base_url_) { + return; + } + base_url_ = normalized; + client_ = nullptr; + catalog_url_changed.notify(base_url_); +} + +CatalogClient& WorkshopCatalog::client() { + if (!client_) { + clients_.push_back(std::make_unique(base_url_)); + client_ = clients_.back().get(); + } + return *client_; +} + +QString WorkshopCatalog::cache_file(const QString& key) const { + const QByteArray digest = QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256).toHex(); + return *cache_dir_ + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + QStringLiteral(".json"); +} + +void WorkshopCatalog::remember(const QString& key, std::any value, bool move_to_end) { + if (move_to_end || !responses_.contains(key)) { + response_order_.removeAll(key); + response_order_.append(key); + } + responses_.insert(key, std::move(value)); + while (response_order_.size() > kCacheLimit) { + responses_.remove(response_order_.first()); + response_order_.removeFirst(); + } +} + +QCoro::Task> WorkshopCatalog::discover() { + co_return co_await client().discover(); +} + +QCoro::Task>> WorkshopCatalog::mods(CatalogQuery query) { + co_return co_await client().mods(std::move(query)); +} + +QCoro::Task>> WorkshopCatalog::collections(CatalogQuery query) { + co_return co_await client().collections(std::move(query)); +} + +QCoro::Task>> WorkshopCatalog::mod(QString id) { + co_return co_await client().mod(std::move(id)); +} + +QCoro::Task>> WorkshopCatalog::collection(QString id) { + co_return co_await client().collection(std::move(id)); +} + +const WorkshopCatalog::InstalledIndex& WorkshopCatalog::installed() { + if (!installed_index_) { + InstalledIndex index; + for (const ListedModPtr& mod : installed_mods_()) { + if (auto id = mod->published_file_id()) { + index[*id].append(mod); + } + } + installed_index_ = std::move(index); + } + return *installed_index_; +} + +InstallState WorkshopCatalog::install_state_of(const CatalogMod& mod, const InstalledIndex& index) const { + const QList local = index.value(QString::fromStdString(mod.id)); + if (local.isEmpty()) { + return InstallState::missing; + } + // Only copies downloaded through the manager have a sync time; a copy without one is never outdated. + std::optional newest_sync; + for (const ListedModPtr& item : local) { + if (auto synced = downloads_->last_synced(*item)) { + newest_sync = newest_sync ? std::max(*newest_sync, *synced) : *synced; + } + } + if (newest_sync && mod.updated_at && *newest_sync < static_cast(*mod.updated_at)) { + return InstallState::outdated; + } + return InstallState::installed; +} + +InstallState WorkshopCatalog::install_state(const CatalogMod& mod) { + return install_state_of(mod, installed()); +} + +void WorkshopCatalog::invalidate_installed() { + installed_index_.reset(); + installed_changed.notify(std::monostate{}); +} + +QCoro::Task>> WorkshopCatalog::installed_with_updates() { + QStringList ids = installed().keys(); + // The index is a hash, so sort the ids to request them in a stable order. + ids.sort(); + auto batch = co_await client().mods_batch(std::move(ids)); + if (!batch) { + co_return std::unexpected(batch.error()); + } + co_return std::move(batch->items); +} + +QCoro::Task> WorkshopCatalog::plan(QStringList mod_ids, QStringList collection_ids) { + auto resolved = co_await client().resolve(std::move(mod_ids), std::move(collection_ids)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + const InstalledIndex& index = installed(); + DownloadPlan result; + for (const std::string& id : resolved->mod_ids) { + const auto found = resolved->items.find(id); + const auto* mod = found == resolved->items.end() ? nullptr : std::get_if(&found->second); + if (!mod) { + co_return std::unexpected(CatalogError{ + QStringLiteral("The Workshop catalog returned an invalid download plan."), std::nullopt}); + } + const QString qid = QString::fromStdString(id); + if (install_state_of(*mod, index) == InstallState::installed) { + result.already_current.append(qid); + } else { + result.to_download.append(qid); + } + } + for (const auto& [id, entry] : resolved->items) { + result.titles.insert(QString::fromStdString(id), QString::fromStdString(title_of(entry))); + } + result.unavailable_ids = to_qstrings(resolved->unavailable_ids); + result.incomplete_collection_ids = to_qstrings(resolved->incomplete_collection_ids); + result.is_complete = resolved->is_complete; + co_return result; +} + +QCoro::Task> WorkshopCatalog::download(DownloadPlan download_plan) { + co_return co_await downloads_->download_mods(std::move(download_plan.to_download)); +} + +void WorkshopCatalog::enqueue(const DownloadPlan& download_plan) { + const QSet active = downloads_->active_ids(); + QStringList ids; + for (const QString& id : download_plan.to_download) { + if (!queued_.contains(id) && !active.contains(id)) { + ids.append(id); + } + } + if (ids.isEmpty()) { + return; + } + queue_.append(ids); + queued_.unite(QSet(ids.begin(), ids.end())); + queue_changed.notify(std::monostate{}); + if (!draining_) { + draining_ = true; + runner_.emplace(drain()); + } +} + +void WorkshopCatalog::cancel_queue() { + queue_.clear(); + queued_.clear(); + queue_changed.notify(std::monostate{}); +} + +QCoro::Task WorkshopCatalog::drain() { + while (!queue_.isEmpty()) { + const QStringList ids = queue_.takeFirst(); + // A batch that waited for the running download stops the queue if that download was cancelled. + const bool waited = downloads_->is_downloading(); + while (downloads_->is_downloading()) { + co_await IdleWait{*this}; + } + if (waited && downloads_->cancelled()) { + cancel_queue(); + break; + } + + auto result = co_await downloads_->download_mods(ids); + if (!result) { + spdlog::warn("[workshop] queued download failed: {}", result.error().message.toStdString()); + } + queued_.subtract(QSet(ids.begin(), ids.end())); + queue_changed.notify(std::monostate{}); + if (downloads_->cancelled()) { + cancel_queue(); + break; + } + } + draining_ = false; +} + +void WorkshopCatalog::on_busy_changed(bool busy) { + if (busy) { + return; + } + auto waiters = std::move(idle_waiters_); + idle_waiters_.clear(); + for (std::coroutine_handle<> waiter : waiters) { + waiter.resume(); + } +} + +} // namespace px::core::workshop diff --git a/src/core/workshop/catalog.h b/src/core/workshop/catalog.h new file mode 100644 index 0000000..6b8a78d --- /dev/null +++ b/src/core/workshop/catalog.h @@ -0,0 +1,266 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/downloads/manager.h" +#include "core/error.h" +#include "core/events.h" +#include "core/file_io.h" +#include "core/models/metadata/structures.h" +#include "core/plugin.h" +#include "core/plugin_config.h" +#include "core/workshop/client.h" +#include "core/workshop/favourites.h" +#include "core/workshop/types.h" + +namespace px::core::workshop { + +inline constexpr std::string_view kDefaultCatalogUrl = "https://api.modrim.pyxiion.dev"; + +// A value remembered for less than this many seconds is served without contacting the catalog. +inline constexpr double kFreshSeconds = 300.0; + +// Stored as plugins/workshop_catalog.json. +struct CatalogSettings { + std::string url = std::string(kDefaultCatalogUrl); + + bool operator==(const CatalogSettings&) const = default; +}; + +// The installed mods the catalog compares against. The composition root supplies it, so the catalog +// does not reach for global state. +using InstalledModsProvider = std::function()>; + +// A cached value with the time it was fetched (seconds since the epoch). +template +struct Remembered { + double at = 0; + T value{}; +}; + +namespace detail { + +// A missing, unreadable or undecodable cache file is treated as a miss. +template +std::optional> read_cached(const QString& path) { + auto bytes = read_file_bytes(path); + if (!bytes) { + return std::nullopt; + } + Remembered entry; + if (glz::read(entry, *bytes)) { + return std::nullopt; + } + return entry; +} + +// Writes one cache file, then drops the oldest files beyond the disk limit. +Result write_cached(const QString& directory, const QString& path, const std::string& text); + +} // namespace detail + +// The Workshop catalog: remote browsing and resolution of mods, plus a download queue that runs behind +// the downloads manager. The catalog never mutates installed mods itself. +class WorkshopCatalog : public Plugin { +public: + WorkshopCatalog(); + void setup(CoreContext& ctx) override; + // `config` may be null: settings, favourites and the response cache then stay in memory. + WorkshopCatalog(ConfigService* config, DownloadManager& downloads, InstalledModsProvider installed_mods); + ~WorkshopCatalog(); + + WorkshopCatalog(const WorkshopCatalog&) = delete; + WorkshopCatalog& operator=(const WorkshopCatalog&) = delete; + + // Stops the queue and aborts requests in flight. + QCoro::Task shutdown() override; + + Event catalog_url_changed; + Event installed_changed; + Event queue_changed; + + const CatalogSettings& settings() const noexcept { return settings_.value(); } + void set_base_url(const QString& url); + bool configured() const noexcept { return !base_url_.isEmpty(); } + + FavouriteCollections& favourites() noexcept { return favourites_; } + + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections(CatalogQuery query); + QCoro::Task>> mod(QString id); + QCoro::Task>> collection(QString id); + + // Serves the remembered value at once through `show`, then the refreshed one when it differs. + // A value younger than `fresh_for` is served without contacting the catalog. With `persist`, the + // value also survives a restart. `T` must be serialisable with glaze and comparable. + template + QCoro::Task> cached(QString key, std::function>()> produce, + std::function show, double fresh_for = kFreshSeconds, + bool persist = false); + + // Installed state is cached until invalidate_installed(). + InstallState install_state(const CatalogMod& mod); + QCoro::Task>> installed_with_updates(); + + // Resolves ids into the mods to fetch, skipping those already current. + QCoro::Task> plan(QStringList mod_ids, QStringList collection_ids); + QCoro::Task> download(DownloadPlan download_plan); + + // Ids waiting in the queue or downloading. + QSet queued_ids() const { return queued_; } + // Queues `plan` behind any running download without waiting for it. + void enqueue(const DownloadPlan& download_plan); + void cancel_queue(); + + // Call when the installed mods changed (mods changed, config changed). + void invalidate_installed(); + +private: + using InstalledIndex = QHash>; + + // Suspends the queue until the downloads report they are idle again. + struct IdleWait { + WorkshopCatalog& catalog; + + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) const { catalog.idle_waiters_.push_back(handle); } + void await_resume() const noexcept {} + }; + + CatalogClient& client(); + const InstalledIndex& installed(); + void bind_downloads(DownloadManager& downloads); + void disconnect_events(); + InstallState install_state_of(const CatalogMod& mod, const InstalledIndex& installed) const; + void change_url(const QString& url); + void on_busy_changed(bool busy); + QCoro::Task drain(); + + template + std::optional> recall(const QString& key) const; + // Keeps the memory cache within its limit. `move_to_end` marks the entry as the newest. + void remember(const QString& key, std::any value, bool move_to_end); + QString cache_file(const QString& key) const; + + PluginConfig settings_; + QString base_url_; + std::optional cache_dir_; + FavouriteCollections favourites_; + DownloadManager* downloads_ = nullptr; + InstalledModsProvider installed_mods_; + Event::Id download_finished_id_ = 0; + Event::Id busy_changed_id_ = 0; + CoreContext* context_ = nullptr; + Event::Id mods_changed_id_ = 0; + Event::Id config_changed_id_ = 0; + + std::optional installed_index_; + // Insertion-ordered: the oldest entries are evicted first. + QList response_order_; + // Each value is a Remembered for the T it was stored with. + QHash responses_; + + // Every client made so far stays alive until shutdown, so requests in flight on a replaced client finish. + std::vector> clients_; + CatalogClient* client_ = nullptr; + + QList queue_; + QSet queued_; + bool draining_ = false; + std::optional> runner_; + std::vector> idle_waiters_; +}; + +template +std::optional> WorkshopCatalog::recall(const QString& key) const { + const auto found = responses_.constFind(key); + if (found == responses_.constEnd()) { + return std::nullopt; + } + const auto* typed = std::any_cast>(&found.value()); + if (!typed) { + return std::nullopt; + } + return *typed; +} + +template +QCoro::Task> WorkshopCatalog::cached(QString key, + std::function>()> produce, + std::function show, double fresh_for, + bool persist) { + const QString full_key = base_url_ + QLatin1Char('|') + key; + std::optional path; + if (persist && cache_dir_) { + path = cache_file(full_key); + } + + std::optional> entry = recall(full_key); + if (!entry && path) { + entry = co_await QtConcurrent::run([path = *path] { return detail::read_cached(path); }); + if (entry) { + remember(full_key, *entry, false); + } + } + if (entry) { + show(entry->value); + const double age = QDateTime::currentMSecsSinceEpoch() / 1000.0 - entry->at; + if (age < fresh_for) { + co_return {}; + } + } + + auto fetched = co_await produce(); + if (!fetched) { + co_return std::unexpected(fetched.error()); + } + T value = std::move(*fetched); + const bool changed = !entry || value != entry->value; + if (entry && !changed) { + // Keep the displayed copy so an equal refresh does not replace what is on screen. + value = entry->value; + } + const Remembered remembered{QDateTime::currentMSecsSinceEpoch() / 1000.0, value}; + remember(full_key, remembered, true); + + if (path) { + auto encoded = glz::write(remembered); + if (encoded) { + auto saved = co_await QtConcurrent::run( + [directory = *cache_dir_, file = *path, bytes = std::move(*encoded)] { + return detail::write_cached(directory, file, bytes); + }); + if (!saved) { + spdlog::warn("[workshop] cannot write cache: {}", saved.error().message.toStdString()); + } + } + } + if (changed) { + show(value); + } + co_return {}; +} + +} // namespace px::core::workshop diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp new file mode 100644 index 0000000..51f253d --- /dev/null +++ b/src/core/workshop/client.cpp @@ -0,0 +1,288 @@ +#include "core/workshop/client.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace px::core::workshop { + +namespace { + +constexpr int kRequestTimeoutMs = 30000; +// Per-request limits of the catalog service; larger inputs are split across requests. +constexpr qsizetype kIdsPerBatch = 100; +constexpr qsizetype kModIdsPerResolve = 50; +constexpr qsizetype kCollectionIdsPerResolve = 10; + +struct ErrorMessage { + std::string message; +}; + +struct ErrorEnvelope { + ErrorMessage error; +}; + +struct BatchWire { + std::vector items; + std::vector unavailable_ids; +}; + +struct IdsBody { + std::vector ids; +}; + +struct ResolveBody { + std::vector ids; + std::vector collection_ids; +}; + +std::vector to_utf8(const QStringList& list) { + std::vector out; + out.reserve(static_cast(list.size())); + for (const QString& value : list) { + out.push_back(value.toStdString()); + } + return out; +} + +// Percent-encodes everything except the unreserved characters, as Python's quote(safe="") does. +QString encode_segment(const QString& segment) { + return QString::fromLatin1(QUrl::toPercentEncoding(segment)); +} + +QUrlQuery query_of(const CatalogQuery& query) { + QUrlQuery params; + params.addQueryItem(QStringLiteral("q"), query.query); + params.addQueryItem(QStringLiteral("sort"), query.sort); + params.addQueryItem(QStringLiteral("limit"), QString::number(query.limit)); + params.addQueryItem(QStringLiteral("source"), query.source); + if (query.tag) { + params.addQueryItem(QStringLiteral("tag"), *query.tag); + } + if (query.version) { + params.addQueryItem(QStringLiteral("version"), *query.version); + } + if (query.cursor) { + params.addQueryItem(QStringLiteral("cursor"), *query.cursor); + } + return params; +} + +template +std::string to_json(const T& value) { + auto text = glz::write(value); + Q_ASSERT(text.has_value()); + return std::move(*text); +} + +template +CatalogResult decode(const std::string& body, int status) { + T value{}; + if (auto error = glz::read(value, body)) { + return std::unexpected(CatalogError{QStringLiteral("The Workshop catalog returned an invalid response."), status}); + } + return value; +} + +// Appends the values not seen before, keeping first-occurrence order (Python dict.fromkeys). +void add_unique(std::vector& out, std::unordered_set& seen, + const std::vector& values) { + for (const std::string& value : values) { + if (seen.insert(value).second) { + out.push_back(value); + } + } +} + +} // namespace + +QString normalize_base_url(QString url) { + url = url.trimmed(); + while (url.endsWith(u'/')) { + url.chop(1); + } + return url; +} + +CatalogClient::CatalogClient(QString base_url) : base_url_(normalize_base_url(std::move(base_url))) {} + +void CatalogClient::shutdown() { + // abort() can resume waiting requests, which remove themselves from pending_: iterate a copy. + const auto replies = pending_; + for (QNetworkReply* reply : replies) { + reply->abort(); + } +} + +QUrl CatalogClient::endpoint(const QString& path, const QUrlQuery& query) const { + QUrl url(base_url_ + QStringLiteral("/catalog/") + path); + url.setQuery(query); + return url; +} + +QCoro::Task> CatalogClient::send(bool post, QUrl url, std::string payload) { + if (base_url_.isEmpty()) { + co_return std::unexpected(CatalogError{QStringLiteral("Workshop catalog is disabled."), std::nullopt}); + } + + QNetworkRequest request(url); + request.setTransferTimeout(kRequestTimeoutMs); + QNetworkReply* reply = nullptr; + if (post) { + request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); + reply = network_.post(request, QByteArray::fromStdString(payload)); + } else { + reply = network_.get(request); + } + pending_.insert(reply); + // Runs however the coroutine leaves, so a finished reply is never left registered for abort. + struct Release { + QSet& pending; + QNetworkReply* reply; + ~Release() { + pending.remove(reply); + reply->deleteLater(); + } + } release{pending_, reply}; + + co_await qCoro(reply).waitForFinished(); + + // An HTTP status is only present when the server answered; without one the request failed in transport. + const QVariant status_attribute = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (!status_attribute.isValid()) { + co_return std::unexpected(CatalogError{ + QStringLiteral("Cannot reach the Workshop catalog: %1").arg(reply->errorString()), std::nullopt}); + } + + const int status = status_attribute.toInt(); + Reply received{status, reply->readAll().toStdString()}; + if (status >= 200 && status < 300) { + co_return received; + } + + QString message; + ErrorEnvelope envelope; + if (!glz::read(envelope, received.body)) { + message = QString::fromStdString(envelope.error.message); + } else { + const QString reason = reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + message = reason.isEmpty() + ? QStringLiteral("Workshop catalog request failed (HTTP %1).").arg(status) + : QStringLiteral("Workshop catalog request failed (HTTP %1 %2).").arg(status).arg(reason); + } + co_return std::unexpected(CatalogError{message, status}); +} + +template +QCoro::Task> CatalogClient::call(bool post, QUrl url, std::string payload) { + auto reply = co_await send(post, std::move(url), std::move(payload)); + if (!reply) { + co_return std::unexpected(reply.error()); + } + co_return decode(reply->body, reply->status); +} + +QCoro::Task> CatalogClient::discover() { + co_return co_await call(false, endpoint(QStringLiteral("discover"))); +} + +QCoro::Task>> CatalogClient::mods(CatalogQuery query) { + co_return co_await call>(false, endpoint(QStringLiteral("mods"), query_of(query))); +} + +QCoro::Task>> CatalogClient::collections(CatalogQuery query) { + co_return co_await call>( + false, endpoint(QStringLiteral("collections"), query_of(query))); +} + +QCoro::Task>> CatalogClient::mod(QString id) { + auto found = co_await call(false, endpoint(QStringLiteral("mods/") + encode_segment(id))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); +} + +QCoro::Task>> CatalogClient::collection(QString id) { + // Collection ids are ":"; the key may itself contain colons. + const qsizetype colon = id.indexOf(u':'); + const QString source = colon < 0 ? QString() : id.left(colon); + const QString key = colon < 0 ? QString() : id.mid(colon + 1); + if (colon < 0 || key.isEmpty() || + (source != QStringLiteral("steam") && source != QStringLiteral("picked"))) { + co_return std::unexpected(CatalogError{QStringLiteral("Invalid Workshop collection ID."), std::nullopt}); + } + + auto found = co_await call( + false, endpoint(QStringLiteral("collections/") + source + QLatin1Char('/') + encode_segment(key))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); +} + +QCoro::Task> CatalogClient::mods_batch(QStringList ids) { + BatchResult result; + for (qsizetype start = 0; start < ids.size(); start += kIdsPerBatch) { + auto batch = co_await call( + true, endpoint(QStringLiteral("mods/batch")), + to_json(IdsBody{to_utf8(ids.mid(start, kIdsPerBatch))})); + if (!batch) { + co_return std::unexpected(batch.error()); + } + result.items.insert(result.items.end(), std::make_move_iterator(batch->items.begin()), + std::make_move_iterator(batch->items.end())); + result.unavailable_ids.insert(result.unavailable_ids.end(), batch->unavailable_ids.begin(), + batch->unavailable_ids.end()); + } + co_return result; +} + +QCoro::Task> CatalogClient::resolve(QStringList ids, QStringList collection_ids) { + // One request covers up to 50 mods and 10 collections; the longer list sets the number of requests. + const qsizetype batches = std::max({(ids.size() + kModIdsPerResolve - 1) / kModIdsPerResolve, + (collection_ids.size() + kCollectionIdsPerResolve - 1) / kCollectionIdsPerResolve, + qsizetype{1}}); + ResolvedDownload merged; + merged.is_complete = true; + std::unordered_set roots_seen; + std::unordered_set mod_ids_seen; + std::unordered_set unavailable_seen; + std::unordered_set incomplete_seen; + for (qsizetype batch = 0; batch < batches; ++batch) { + ResolveBody body{ + to_utf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), + to_utf8(collection_ids.mid(batch * kCollectionIdsPerResolve, kCollectionIdsPerResolve)), + }; + auto resolved = co_await call(true, endpoint(QStringLiteral("resolve")), to_json(body)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + add_unique(merged.roots, roots_seen, resolved->roots); + for (auto& [id, entry] : resolved->items) { + merged.items.insert_or_assign(id, std::move(entry)); + } + add_unique(merged.mod_ids, mod_ids_seen, resolved->mod_ids); + add_unique(merged.unavailable_ids, unavailable_seen, resolved->unavailable_ids); + add_unique(merged.incomplete_collection_ids, incomplete_seen, resolved->incomplete_collection_ids); + merged.is_complete = merged.is_complete && resolved->is_complete; + } + co_return merged; +} + +} // namespace px::core::workshop diff --git a/src/core/workshop/client.h b/src/core/workshop/client.h new file mode 100644 index 0000000..e8dcdf2 --- /dev/null +++ b/src/core/workshop/client.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/workshop/types.h" + +namespace px::core::workshop { + +// Trims whitespace and trailing slashes, so "https://x/" and "https://x" name the same catalog. +QString normalize_base_url(QString url); + +// HTTP client for the Workshop catalog service. An empty base URL disables the catalog: every call +// fails with a CatalogError and nothing is sent. +class CatalogClient { +public: + struct BatchResult { + std::vector items; + std::vector unavailable_ids; + }; + + explicit CatalogClient(QString base_url); + CatalogClient(const CatalogClient&) = delete; + CatalogClient& operator=(const CatalogClient&) = delete; + + // Aborts requests still in flight; each one completes with a CatalogError. + void shutdown(); + + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections(CatalogQuery query); + // nullopt when the catalog does not know the mod (HTTP 404). + QCoro::Task>> mod(QString id); + // nullopt when the catalog does not know the collection (HTTP 404). + QCoro::Task>> collection(QString id); + QCoro::Task> mods_batch(QStringList ids); + QCoro::Task> resolve(QStringList ids, QStringList collection_ids); + +private: + struct Reply { + int status = 0; + std::string body; + }; + + template + QCoro::Task> call(bool post, QUrl url, std::string payload = {}); + + QCoro::Task> send(bool post, QUrl url, std::string payload); + QUrl endpoint(const QString& path, const QUrlQuery& query = {}) const; + + QString base_url_; + QNetworkAccessManager network_; + // Requests in flight, aborted by shutdown(). + QSet pending_; +}; + +} // namespace px::core::workshop diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp new file mode 100644 index 0000000..b3a5935 --- /dev/null +++ b/src/core/workshop/favourites.cpp @@ -0,0 +1,60 @@ +#include "core/workshop/favourites.h" + +#include +#include + +namespace px::core::workshop { + +FavouriteCollections::FavouriteCollections(ConfigService* service) + : config_(service, QStringLiteral("workshop_favourites")) { + for (const CatalogCollection& collection : config_.value().collections) { + ids_.insert(collection.id); + } +} + +bool FavouriteCollections::contains(const std::string& collection_id) const { + return ids_.contains(collection_id); +} + +void FavouriteCollections::set(const CatalogCollection& collection, bool favourite) { + if (contains(collection.id) == favourite) { + return; + } + std::vector rest; + for (const CatalogCollection& stored : config_.value().collections) { + if (stored.id != collection.id) { + rest.push_back(stored); + } + } + if (favourite) { + rest.insert(rest.begin(), collection); + } + save(std::move(rest)); +} + +void FavouriteCollections::remember(const CatalogCollection& collection) { + if (!contains(collection.id)) { + return; + } + const auto& stored = config_.value().collections; + if (std::ranges::find(stored, collection) != stored.end()) { + return; + } + std::vector updated = stored; + for (CatalogCollection& entry : updated) { + if (entry.id == collection.id) { + entry = collection; + } + } + save(std::move(updated)); +} + +void FavouriteCollections::save(std::vector collections) { + ids_.clear(); + for (const CatalogCollection& collection : collections) { + ids_.insert(collection.id); + } + config_.update(FavouritesState{std::move(collections)}); +} + +} // namespace px::core::workshop diff --git a/src/core/workshop/favourites.h b/src/core/workshop/favourites.h new file mode 100644 index 0000000..c7b0c84 --- /dev/null +++ b/src/core/workshop/favourites.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include "core/config.h" +#include "core/events.h" +#include "core/plugin_config.h" +#include "core/workshop/types.h" + +namespace px::core::workshop { + +// The persisted form, plugins/workshop_favourites.json. +struct FavouritesState { + std::vector collections; + + bool operator==(const FavouritesState&) const = default; +}; + +// Collections the user starred, newest first. Each entry is the last catalog snapshot of the collection, +// so the list shows without a network round trip and survives the catalog dropping it. +class FavouriteCollections { +public: + // Without a service the favourites live in memory only. + explicit FavouriteCollections(ConfigService* service); + + Event& changed() noexcept { return config_.changed; } + const std::vector& items() const noexcept { return config_.value().collections; } + bool contains(const std::string& collection_id) const; + + void set(const CatalogCollection& collection, bool favourite); + // Refreshes the stored snapshot of a starred collection; other collections are ignored. + void remember(const CatalogCollection& collection); + +private: + void save(std::vector collections); + + PluginConfig config_; + std::unordered_set ids_; +}; + +} // namespace px::core::workshop diff --git a/src/core/workshop/types.h b/src/core/workshop/types.h new file mode 100644 index 0000000..7326668 --- /dev/null +++ b/src/core/workshop/types.h @@ -0,0 +1,223 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace px::core::workshop { + +// Catalog responses are strict: every key is present (nullable ones may be null), unknown keys are +// ignored and a value of the wrong type fails. Writing emits nulls so payloads round-trip exactly. +inline constexpr glz::opts kWireOpts{ + .error_on_unknown_keys = false, + .skip_null_members = false, + .error_on_missing_keys = true, +}; + +// Literal-typed wire fields are enums so that a value outside the catalog's contract fails to decode. +enum class PreviewType { image, video }; +enum class ModSource { steam }; +enum class ModDescriptionFormat { bbcode }; +enum class ModKind { mod }; +enum class CollectionSource { steam, picked }; +enum class CollectionDescriptionFormat { bbcode, text }; +enum class CollectionKind { collection }; + +struct Author { + std::optional id; + std::optional name; + std::optional profile_url; + + bool operator==(const Author&) const = default; +}; + +struct Preview { + PreviewType type = PreviewType::image; + std::string url; + + bool operator==(const Preview&) const = default; +}; + +struct Votes { + std::int64_t up = 0; + std::int64_t down = 0; + std::optional positive_percent; + + bool operator==(const Votes&) const = default; +}; + +struct CatalogMod { + std::string id; + ModSource source = ModSource::steam; + std::string title; + Author author; + std::string description; + ModDescriptionFormat description_format = ModDescriptionFormat::bbcode; + std::optional preview_url; + std::vector previews; + std::string workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::optional file_size; + std::optional subscriptions; + std::optional votes; + std::vector dependencies; + std::optional package_id; + bool incompatible = false; + // The wire tag: decoding a collection payload as a mod fails here. + ModKind kind = ModKind::mod; + + bool operator==(const CatalogMod&) const = default; +}; + +struct CatalogCollection { + std::string id; + CollectionSource source = CollectionSource::steam; + std::optional steam_id; + std::string title; + Author author; + std::string description; + CollectionDescriptionFormat description_format = CollectionDescriptionFormat::text; + std::optional preview_url; + std::vector previews; + std::optional workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::vector member_ids; + std::int64_t member_count = 0; + std::vector member_previews; + std::optional total_size; + bool no_common_version = false; + // The wire tag: decoding a mod payload as a collection fails here. + CollectionKind kind = CollectionKind::collection; + + bool operator==(const CatalogCollection&) const = default; +}; + +// A mod or a collection, told apart by the wire `kind` tag. +using CatalogEntry = std::variant; + +template +struct CatalogPage { + std::vector items; + std::int64_t total = 0; + std::optional next_cursor; + + bool operator==(const CatalogPage&) const = default; +}; + +struct CollectionDetail { + CatalogCollection collection; + std::vector members; + std::vector unavailable_ids; + bool is_complete = false; + + bool operator==(const CollectionDetail&) const = default; +}; + +struct Discover { + CatalogPage mods; + CatalogPage collections; + + bool operator==(const Discover&) const = default; +}; + +struct ResolvedDownload { + std::vector roots; + std::map items; + std::vector mod_ids; + std::vector unavailable_ids; + std::vector incomplete_collection_ids; + bool is_complete = false; + + bool operator==(const ResolvedDownload&) const = default; +}; + +struct CatalogQuery { + QString query; + std::optional tag; + std::optional version; + QString sort = QStringLiteral("popular"); + int limit = 24; + std::optional cursor; + QString source = QStringLiteral("all"); +}; + +enum class InstallState { missing, installed, outdated }; + +// What a download would fetch, in dependency order. Built on the client side, so it uses Qt types. +struct DownloadPlan { + QStringList to_download{}; + QStringList already_current{}; + QStringList unavailable_ids{}; + QStringList incomplete_collection_ids{}; + QHash titles{}; + bool is_complete = false; +}; + +// Failure of a catalog request. `status` is the HTTP status when the server answered, empty when the +// request never got a response (disabled catalog, unreachable host, undecodable payload). +struct CatalogError { + QString message; + std::optional status; +}; + +template +using CatalogResult = std::expected; + +} // namespace px::core::workshop + +template <> +struct glz::meta { + using enum px::core::workshop::PreviewType; + static constexpr auto value = glz::enumerate(image, video); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::ModSource; + static constexpr auto value = glz::enumerate(steam); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::ModDescriptionFormat; + static constexpr auto value = glz::enumerate(bbcode); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::ModKind; + static constexpr auto value = glz::enumerate(mod); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::CollectionSource; + static constexpr auto value = glz::enumerate(steam, picked); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::CollectionDescriptionFormat; + static constexpr auto value = glz::enumerate(bbcode, text); +}; + +template <> +struct glz::meta { + using enum px::core::workshop::CollectionKind; + static constexpr auto value = glz::enumerate(collection); +}; diff --git a/src/core/xml.cpp b/src/core/xml.cpp new file mode 100644 index 0000000..4fed441 --- /dev/null +++ b/src/core/xml.cpp @@ -0,0 +1,206 @@ +#include "core/xml.h" + +#include +#include + +#include +#include +#include + +namespace px::core { + +namespace { + +glz::json_t jsonString(std::string value) { + glz::json_t out; + out.data = std::move(value); + return out; +} + +QString scalarToString(const glz::json_t& value) { + return std::visit( + [](const auto& v) -> QString { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return QString::fromStdString(v); + } else if constexpr (std::is_same_v) { + // Python's str(True) spelling, matching the legacy writer. + return v ? QStringLiteral("True") : QStringLiteral("False"); + } else if constexpr (std::is_same_v) { + return QString::number(v, 'g', 17); + } else { + return {}; + } + }, + value.data); +} + +// The value part of `{tag: value}` for one element. +glz::json_t elementValue(pugi::xml_node element) { + glz::json_t::object_t container; + bool hasChildren = false; + for (pugi::xml_node child = element.first_child(); child; child = child.next_sibling()) { + if (child.type() != pugi::node_element) { + continue; + } + hasChildren = true; + const std::string key = child.name(); + glz::json_t value = elementValue(child); + auto existing = container.find(key); + if (existing == container.end()) { + container.emplace(key, std::move(value)); + continue; + } + if (auto* items = std::get_if(&existing->second.data)) { + items->push_back(std::move(value)); + } else { + glz::json_t::array_t merged; + merged.push_back(std::move(existing->second)); + merged.push_back(std::move(value)); + existing->second.data = std::move(merged); + } + } + + bool hasAttributes = false; + for (pugi::xml_attribute attribute = element.first_attribute(); attribute; + attribute = attribute.next_attribute()) { + hasAttributes = true; + container["@" + std::string(attribute.name())] = jsonString(attribute.value()); + } + + // lxml's `if t.text:` is a truthiness check on the raw text; the stored value is stripped. + const QString text = rawText(element); + const QString trimmed = text.trimmed(); + + glz::json_t result; + if (hasChildren || hasAttributes) { + if (!trimmed.isEmpty()) { + container["#text"] = jsonString(trimmed.toStdString()); + } + result.data = std::move(container); + } else if (!text.isEmpty()) { + result = jsonString(trimmed.toStdString()); + } else { + result.data = std::move(container); + } + return result; +} + +// Fills `element` from a JSON value: objects describe children/attributes/text, arrays become +// anonymous `li` children, and scalars become the element's text. +Result fillElement(pugi::xml_node element, const glz::json_t& value) { + if (const auto* object = std::get_if(&value.data)) { + for (const auto& [key, child] : *object) { + if (key.starts_with('@')) { + const std::string name = key.substr(1); + pugi::xml_attribute attribute = element.attribute(name.c_str()); + if (!attribute) { + attribute = element.append_attribute(name.c_str()); + } + attribute.set_value(scalarToString(child).toUtf8().constData()); + } else if (key == "#text") { + element.text().set(scalarToString(child).toUtf8().constData()); + } else if (const auto* items = std::get_if(&child.data)) { + for (const glz::json_t& item : *items) { + pugi::xml_node repeated = element.append_child(key.c_str()); + if (auto filled = fillElement(repeated, item); !filled) { + return filled; + } + } + } else { + pugi::xml_node single = element.append_child(key.c_str()); + if (auto filled = fillElement(single, child); !filled) { + return filled; + } + } + } + return {}; + } + if (const auto* items = std::get_if(&value.data)) { + for (const glz::json_t& item : *items) { + pugi::xml_node li = element.append_child("li"); + if (auto filled = fillElement(li, item); !filled) { + return filled; + } + } + return {}; + } + element.text().set(scalarToString(value).toUtf8().constData()); + return {}; +} + +} // namespace + +Result loadXmlFile(pugi::xml_document& doc, const QString& path) { + if (!QFileInfo::exists(path)) { + return std::unexpected(Error{QStringLiteral("XML file does not exist at: %1").arg(path)}); + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected( + Error{QStringLiteral("Error parsing XML file %1: %2").arg(path, file.errorString())}); + } + const QByteArray bytes = file.readAll(); + + const pugi::xml_parse_result parsed = doc.load_buffer(bytes.constData(), bytes.size()); + if (!parsed) { + return std::unexpected(Error{QStringLiteral("Error parsing XML file %1: %2") + .arg(path, QString::fromUtf8(parsed.description()))}); + } + if (!doc.document_element()) { + return std::unexpected( + Error{QStringLiteral("Error parsing XML file %1: no root element").arg(path)}); + } + return {}; +} + +QString rawText(pugi::xml_node node) { + QString text; + for (pugi::xml_node child = node.first_child(); child; child = child.next_sibling()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { + break; + } + text += QString::fromUtf8(child.value()); + } + return text; +} + +std::optional childText(pugi::xml_node parent, const char* tag) { + const pugi::xml_node found = parent.child(tag); + if (!found) { + return std::nullopt; + } + const QString text = rawText(found).trimmed(); + if (text.isEmpty()) { + return std::nullopt; + } + return text; +} + +glz::json_t elementToJson(pugi::xml_node element) { + glz::json_t::object_t wrapped; + wrapped.emplace(std::string(element.name()), elementValue(element)); + glz::json_t out; + out.data = std::move(wrapped); + return out; +} + +Result xmlFileToJson(const QString& path) { + pugi::xml_document doc; + if (auto loaded = loadXmlFile(doc, path); !loaded) { + return std::unexpected(loaded.error()); + } + return elementToJson(doc.document_element()); +} + +Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data) { + const auto* object = std::get_if(&data.data); + if (object == nullptr || object->size() != 1) { + return std::unexpected(Error{QStringLiteral("Dictionary must have exactly one root key")}); + } + const auto& [rootKey, rootValue] = *object->begin(); + pugi::xml_node root = parent.append_child(rootKey.c_str()); + return fillElement(root, rootValue); +} + +} // namespace px::core diff --git a/src/core/xml.h b/src/core/xml.h new file mode 100644 index 0000000..b7527db --- /dev/null +++ b/src/core/xml.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include +#include + +#include "core/error.h" + +namespace px::core { + +// Loads and parses an XML file into `doc`. Fails if the file is missing, unreadable, malformed, +// or has no root element. +Result loadXmlFile(pugi::xml_document& doc, const QString& path); + +// Text before the first child node of `node` (lxml's `.text`), unstripped; empty when absent. +QString rawText(pugi::xml_node node); + +// Stripped text of the first child element named `tag`, or nullopt when missing or blank. +std::optional childText(pugi::xml_node parent, const char* tag); + +// Converts an element into the legacy dict shape: `{tag: value}`. Repeated child tags become +// arrays, attributes become "@name" keys, and mixed text becomes "#text". +glz::json_t elementToJson(pugi::xml_node element); + +// Parses an XML file and returns `{rootTag: value}` as produced by elementToJson. +Result xmlFileToJson(const QString& path); + +// Appends the one-key dict `{tag: value}` as an element under `parent` (the inverse of +// elementToJson). Fails unless `data` is an object with exactly one key. +Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data); + +} // namespace px::core diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp new file mode 100644 index 0000000..b7d583d --- /dev/null +++ b/tests/core/checker/test_checker.cpp @@ -0,0 +1,236 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "core/checker/checker.h" +#include "core/checker/graph.h" +#include "core/checker/issues.h" +#include "core/checker/models.h" +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +using namespace px::core; +using namespace px::core::checker; + +namespace { + +std::shared_ptr make_mod(const QString& package_id, const QString& name = {}) { + auto m = std::make_shared(); + m->package_id = CaseInsensitiveStr(package_id); + m->name = name.isEmpty() ? package_id : name; + return m; +} + +sort::SortSettings settings() { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; +} + +QHash as_listed(const std::vector>& mods) { + QHash result; + for (std::size_t i = 0; i < mods.size(); ++i) { + result.insert(QString::number(i), mods[i]); + } + return result; +} + +QString uuid_for(const QHash& mods, const QString& name) { + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + if (it.value()->name == name) { + return it.key(); + } + } + return {}; +} + +std::vector> default_checkers() { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; +} + +} // namespace + +TEST_CASE("ModChecker basic rebuild") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.c")); + a->about_rules.dependencies.append(dep); + + const auto mods = as_listed({a, b}); + const QStringList active_uuids{ + uuid_for(mods, QStringLiteral("mod.a")), + uuid_for(mods, QStringLiteral("mod.b")), + }; + + DiagnosticsByUuid collected; + auto on_change = [&](const DiagnosticsByUuid& diags) { + for (auto it = diags.cbegin(); it != diags.cend(); ++it) { + collected.insert(it.key(), it.value()); + } + }; + + ModChecker checker(default_checkers(), settings(), QStringLiteral("1.5"), on_change); + checker.rebuild(mods, active_uuids); + + const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); + const auto* diag_a = checker.diagnostics_for(a_uuid); + REQUIRE(diag_a != nullptr); + CHECK(diag_a->has_errors()); + REQUIRE(diag_a->errors.size() == 1); + CHECK(diag_a->errors[0].category == QStringLiteral("missing_dependency")); + + const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); + const auto* diag_b = checker.diagnostics_for(b_uuid); + REQUIRE(diag_b != nullptr); + CHECK(!diag_b->has_errors()); + CHECK(!diag_b->has_warnings()); +} + +TEST_CASE("ModChecker toggle mod rebuilds diagnostics and constraints") { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->about_rules.dependencies.append(dep); + b->about_rules.load_after.add(QStringLiteral("mod.a")); + + const auto mods = as_listed({a, b}); + const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); + const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); + + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {a_uuid, b_uuid}); + + checker.toggle_mod(*mods[b_uuid], {a_uuid}); + const auto* diag_disabled = checker.diagnostics_for(a_uuid); + REQUIRE(diag_disabled != nullptr); + CHECK(diag_disabled->has_errors()); + CHECK(diag_disabled->errors[0].category == QStringLiteral("missing_dependency")); + + checker.toggle_mod(*mods[b_uuid], {a_uuid, b_uuid}); + const auto* diag_enabled = checker.diagnostics_for(a_uuid); + REQUIRE(diag_enabled != nullptr); + CHECK(!diag_enabled->has_errors()); + + QSet dependency_sources; + for (const auto& edge : + checker.graph().incoming_of_type(PackageId(QStringLiteral("mod.b")), EdgeType::Dependency)) { + dependency_sources.insert(edge.source); + } + CHECK(dependency_sources == QSet{PackageId(QStringLiteral("mod.a"))}); + + QSet load_after_targets; + for (const auto& edge : + checker.graph().edges_of_type(PackageId(QStringLiteral("mod.b")), EdgeType::LoadAfter)) { + load_after_targets.insert(edge.target); + } + CHECK(load_after_targets == QSet{PackageId(QStringLiteral("mod.a"))}); +} + +TEST_CASE("ModChecker move mod updates load order") { + std::vector> checkers; + checkers.push_back(std::make_unique()); + + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + + // b first, then a -> satisfies load_after + const auto mods = as_listed({b, a}); + const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); + const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); + + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {b_uuid, a_uuid}); + + const auto* initial = checker.diagnostics_for(a_uuid); + CHECK((initial == nullptr || !initial->has_warnings())); + + // Move a before b (old_index 1 -> new_index 0) + checker.move_mod(a_uuid, 1, 0); + + const auto* after_move = checker.diagnostics_for(a_uuid); + REQUIRE(after_move != nullptr); + CHECK(after_move->has_warnings()); + CHECK(after_move->warnings[0].category == QStringLiteral("load_order")); +} + +TEST_CASE("ModChecker reorder matches full rebuild") { + std::mt19937 rng(0); + constexpr int kModCount = 12; + std::vector> mods_list; + mods_list.reserve(kModCount); + for (int i = 0; i < kModCount; ++i) { + mods_list.push_back(make_mod(QStringLiteral("mod.%1").arg(i))); + } + + for (int i = 0; i < kModCount; ++i) { + std::vector others; + for (int j = 0; j < kModCount; ++j) { + if (j != i) { + others.push_back(j); + } + } + std::ranges::shuffle(others, rng); + mods_list[i]->about_rules.load_after.add(QStringLiteral("mod.%1").arg(others[0])); + mods_list[i]->about_rules.load_after.add(QStringLiteral("mod.%1").arg(others[1])); + mods_list[i]->about_rules.load_before.add(QStringLiteral("mod.%1").arg(others[2])); + } + + const auto mods = as_listed(mods_list); + QStringList order; + for (int i = 0; i < kModCount; ++i) { + order.append(QString::number(i)); + } + + ModChecker incremental(default_checkers(), settings()); + incremental.rebuild(mods, order); + + for (int step = 0; step < 30; ++step) { + if (std::uniform_real_distribution(0.0, 1.0)(rng) < 0.5) { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype i = pick(rng); + const qsizetype j = pick(rng); + order.swapItemsAt(i, j); + } else { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype from = pick(rng); + const qsizetype to = pick(rng); + order.insert(to, order.takeAt(from)); + } + incremental.reorder(order); + + ModChecker full(default_checkers(), settings()); + full.rebuild(mods, order); + + for (const QString& uuid : order) { + const auto* got = incremental.diagnostics_for(uuid); + const auto* want = full.diagnostics_for(uuid); + REQUIRE(got != nullptr); + REQUIRE(want != nullptr); + + CHECK(got->errors == want->errors); + CHECK(got->warnings == want->warnings); + } + } +} diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp new file mode 100644 index 0000000..81183d7 --- /dev/null +++ b/tests/core/checker/test_databases.cpp @@ -0,0 +1,163 @@ +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include "core/checker/databases.h" +#include "core/config.h" + +using namespace px::core; +using namespace px::core::checker; + +namespace { + +std::string gzip_compress(std::string_view data) { + z_stream stream{}; + deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY); + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::vector buffer(16384); + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = deflate(&stream, Z_FINISH); + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + deflateEnd(&stream); + return out; +} + +void write_file(const QString& path, std::string_view content) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(content.data(), static_cast(content.size())); +} + +} // namespace + +TEST_CASE("NoVersionWarningService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(!service.cache_age_s().has_value()); + CHECK(service.load_if_exists().isEmpty()); +} + +TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { + QTemporaryDir dir; + const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + write_file(xml_path, + "" + "
  • brrainz.harmony
  • " + "
  • unlimitedhugs.hugslib
  • " + ""); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(service.cache_age_s().has_value()); + CHECK(*service.cache_age_s() >= 0.0); + + const auto pids = service.load_if_exists(); + CHECK(pids.size() == 2); + CHECK(pids.contains(PackageId(QStringLiteral("brrainz.harmony")))); + CHECK(pids.contains(PackageId(QStringLiteral("unlimitedhugs.hugslib")))); +} + +TEST_CASE("NoVersionWarningService parses single-item li") { + QTemporaryDir dir; + const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + write_file(xml_path, "
  • lone.mod
  • "); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + const auto pids = service.load_if_exists(); + CHECK(pids.size() == 1); + CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); +} + +TEST_CASE("NoVersionWarningService handles malformed XML gracefully") { + QTemporaryDir dir; + const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + write_file(xml_path, "not xml at all"); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(service.load_if_exists().isEmpty()); +} + +TEST_CASE("UseThisInsteadService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(!service.cache_age_s().has_value()); + CHECK(service.load_if_exists().isEmpty()); +} + +TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { + QTemporaryDir dir; + const QString gz_path = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + const std::string json = R"({ + "rules": [ + { + "oldWorkshopId": "123456", + "newName": "New Mod", + "newAuthor": "Author Guy", + "newPackageId": "author.newmod", + "newWorkshopId": "654321", + "newVersions": ["1.4", "1.5"] + } + ] + })"; + write_file(gz_path, gzip_compress(json)); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(service.cache_age_s().has_value()); + const auto entries = service.load_if_exists(); + REQUIRE(entries.size() == 1); + REQUIRE(entries.contains(QStringLiteral("123456"))); + + const ReplacementInfo& info = entries.value(QStringLiteral("123456")); + CHECK(info.name == QStringLiteral("New Mod")); + CHECK(info.author == QStringLiteral("Author Guy")); + CHECK(info.packageid == QStringLiteral("author.newmod")); + CHECK(info.pfid == QStringLiteral("654321")); + CHECK(info.supportedversions == QStringList{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(info.source == QStringLiteral("database")); +} + +TEST_CASE("UseThisInsteadService handles corrupt gzip gracefully") { + QTemporaryDir dir; + const QString gz_path = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + write_file(gz_path, "not gzip data"); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(service.load_if_exists().isEmpty()); +} diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp new file mode 100644 index 0000000..bd0edc8 --- /dev/null +++ b/tests/core/checker/test_graph.cpp @@ -0,0 +1,315 @@ +#include + +#include + +#include +#include +#include +#include + +#include "core/checker/graph.h" +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +using namespace px::core; +using namespace px::core::checker; + +namespace { + +std::shared_ptr make_mod(const QString& package_id) { + auto m = std::make_shared(); + m->package_id = CaseInsensitiveStr(package_id); + m->name = package_id; + return m; +} + +sort::SortSettings settings(bool use_alternative_package_ids = false) { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = use_alternative_package_ids; + return s; +} + +} // namespace + +TEST_CASE("ConstraintGraph empty graph") { + ConstraintGraph g; + CHECK(g.nodes().isEmpty()); +} + +TEST_CASE("ConstraintGraph build no edges") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + CHECK(g.has_node(PackageId(QStringLiteral("mod.a")))); + CHECK(g.has_node(PackageId(QStringLiteral("mod.b")))); + CHECK(g.nodes().size() == 2); + CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 1); +} + +TEST_CASE("ConstraintGraph dependency edge") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->about_rules.dependencies.append(dep); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::Dependency); + REQUIRE(edges.size() == 1); + const ConstraintEdge edge = *edges.cbegin(); + CHECK(edge.source == PackageId(QStringLiteral("mod.a"))); + CHECK(edge.target == PackageId(QStringLiteral("mod.b"))); + CHECK(edge.type == EdgeType::Dependency); + CHECK(edge.origin == EdgeOrigin::AboutXml); +} + +TEST_CASE("ConstraintGraph dependency alternative resolution respects setting") { + ConstraintGraph g; + auto consumer = make_mod(QStringLiteral("mod.consumer")); + auto alternative = make_mod(QStringLiteral("mod.alternative")); + DependencyMod dependency; + dependency.package_id = CaseInsensitiveStr(QStringLiteral("mod.dependency")); + dependency.alternative_package_ids.add(QStringLiteral("mod.alternative")); + consumer->about_rules.dependencies.append(dependency); + + QHash> mods{ + {PackageId(QStringLiteral("mod.consumer")), consumer}, + {PackageId(QStringLiteral("mod.alternative")), alternative}, + }; + const QList ordered_pids{ + PackageId(QStringLiteral("mod.consumer")), + PackageId(QStringLiteral("mod.alternative")), + }; + + g.build(mods, ordered_pids, settings(true)); + QSet enabled_targets; + for (const auto& edge : g.edges_of_type(PackageId(QStringLiteral("mod.consumer")), EdgeType::Dependency)) { + enabled_targets.insert(edge.target); + } + CHECK(enabled_targets == QSet{PackageId(QStringLiteral("mod.alternative"))}); + + g.build(mods, ordered_pids, settings(false)); + QSet disabled_targets; + for (const auto& edge : g.edges_of_type(PackageId(QStringLiteral("mod.consumer")), EdgeType::Dependency)) { + disabled_targets.insert(edge.target); + } + CHECK(disabled_targets == QSet{PackageId(QStringLiteral("mod.dependency"))}); +} + +TEST_CASE("ConstraintGraph incompatibility edge") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::Incompatibility); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph load after edge") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::LoadAfter); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph load before edge") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_before.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::LoadBefore); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph neighbors") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto nbrs = g.neighbors(PackageId(QStringLiteral("mod.a"))); + CHECK(nbrs.contains(PackageId(QStringLiteral("mod.b")))); + auto nbrs_b = g.neighbors(PackageId(QStringLiteral("mod.b"))); + CHECK(nbrs_b.contains(PackageId(QStringLiteral("mod.a")))); +} + +TEST_CASE("ConstraintGraph incoming") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + + auto inc = g.incoming(PackageId(QStringLiteral("mod.b"))); + REQUIRE(inc.size() == 1); + CHECK(inc.cbegin()->source == PackageId(QStringLiteral("mod.a"))); +} + +TEST_CASE("ConstraintGraph remove mod") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + CHECK(g.has_node(PackageId(QStringLiteral("mod.b")))); + + g.remove_mod(PackageId(QStringLiteral("mod.b"))); + CHECK(!g.has_node(PackageId(QStringLiteral("mod.b")))); + CHECK(g.outgoing(PackageId(QStringLiteral("mod.a"))).isEmpty()); +} + +TEST_CASE("ConstraintGraph update order") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 1); + + g.update_order({PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); + CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 1); + CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 0); +} + +TEST_CASE("ConstraintGraph cycle detection") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto c = make_mod(QStringLiteral("mod.c")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + b->about_rules.load_after.add(QStringLiteral("mod.c")); + c->about_rules.load_after.add(QStringLiteral("mod.a")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + auto cycles = g.find_cycles(); + REQUIRE(cycles.size() >= 1); + QSet cycle_pids; + for (const auto& cycle : cycles) { + for (const auto& p : cycle) { + cycle_pids.insert(p); + } + } + for (const auto& name : {QStringLiteral("mod.a"), QStringLiteral("mod.b"), QStringLiteral("mod.c")}) { + CHECK(cycle_pids.contains(PackageId(name))); + } +} + +TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto c = make_mod(QStringLiteral("mod.c")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + b->about_rules.load_after.add(QStringLiteral("mod.a")); + b->about_rules.load_after.add(QStringLiteral("mod.c")); + c->about_rules.load_after.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + + auto cycles = g.find_cycles(); + REQUIRE(cycles.size() == 1); + CHECK(QSet(cycles[0].cbegin(), cycles[0].cend()) == + QSet(order.cbegin(), order.cend())); +} + +TEST_CASE("ConstraintGraph no cycle") { + ConstraintGraph g; + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); + auto cycles = g.find_cycles(); + CHECK(cycles.isEmpty()); +} + +TEST_CASE("ConstraintGraph cycle detection handles long chain") { + constexpr int kCount = 5000; + QList nodes; + nodes.reserve(kCount); + QHash> mods; + for (int i = 0; i < kCount; ++i) { + PackageId pid(QStringLiteral("mod.%1").arg(i)); + nodes.append(pid); + mods.insert(pid, make_mod(pid.str())); + } + for (int i = 0; i < kCount - 1; ++i) { + mods[nodes[i]]->about_rules.load_after.add(nodes[i + 1].str()); + } + ConstraintGraph g; + g.build(mods, nodes, settings()); + CHECK(g.find_cycles().isEmpty()); +} diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp new file mode 100644 index 0000000..fe94b7a --- /dev/null +++ b/tests/core/checker/test_issues.cpp @@ -0,0 +1,307 @@ +#include + +#include + +#include +#include +#include +#include + +#include "core/checker/graph.h" +#include "core/checker/issues.h" +#include "core/checker/models.h" +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" +#include "core/sort/models.h" + +using namespace px::core; +using namespace px::core::checker; + +namespace { + +std::shared_ptr make_mod(const QString& package_id) { + auto m = std::make_shared(); + m->package_id = CaseInsensitiveStr(package_id); + m->name = package_id; + return m; +} + +sort::SortSettings default_settings() { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; +} + +// Holds the storage referenced by a CheckContext so tests can construct one simply. +struct ContextStorage { + QHash> active_mods; + QList ordered_pids; + QHash pid_to_index; + ConstraintGraph graph; + sort::SortSettings settings = default_settings(); + QString target_version = QStringLiteral("1.5"); + QSet no_version_warning; + QHash use_this_instead; + QList> cycles; + + CheckContext ctx() const { + return CheckContext{ + .active_mods = active_mods, + .ordered_pids = ordered_pids, + .pid_to_index = pid_to_index, + .graph = graph, + .settings = settings, + .target_version = target_version, + .no_version_warning = no_version_warning, + .use_this_instead = use_this_instead, + .cycles = cycles, + }; + } +}; + +ContextStorage make_storage(QHash> mods, + std::optional> ordered = std::nullopt, + const sort::SortSettings* settings = nullptr, + const sort::CommunityRules* community_rules = nullptr) { + ContextStorage storage; + storage.active_mods = std::move(mods); + storage.ordered_pids = ordered ? *ordered : storage.active_mods.keys(); + if (settings != nullptr) { + storage.settings = *settings; + } + for (qsizetype i = 0; i < storage.ordered_pids.size(); ++i) { + storage.pid_to_index.insert(storage.ordered_pids[i], static_cast(i)); + } + storage.graph.build(storage.active_mods, storage.ordered_pids, storage.settings, community_rules); + return storage; +} + +} // namespace + +TEST_CASE("DependencyIssueChecker no issues when mod has no dependencies") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + DependencyIssueChecker checker; + CHECK(!checker.should_check(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("DependencyIssueChecker reports missing dependency") { + auto a = make_mod(QStringLiteral("mod.a")); + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->about_rules.dependencies.append(dep); + auto storage = make_storage({{a->package_id, a}}); + + DependencyIssueChecker checker; + CHECK(checker.should_check(*a, storage.ctx())); + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("missing_dependency")); + CHECK(issues[0].severity == IssueSeverity::Error); +} + +TEST_CASE("DependencyIssueChecker satisfied dependency yields no issues") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->about_rules.dependencies.append(dep); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + + DependencyIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("IncompatibilityIssueChecker no incompatibilities") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + IncompatibilityIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("IncompatibilityIssueChecker self-declared incompatibility") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].severity == IssueSeverity::Error); +} + +TEST_CASE("IncompatibilityIssueChecker reverse-declared incompatibility") { + auto declarer = make_mod(QStringLiteral("mod.declarer")); + auto target = make_mod(QStringLiteral("mod.target")); + declarer->about_rules.incompatible_with.add(QStringLiteral("mod.target")); + auto storage = make_storage({{declarer->package_id, declarer}, {target->package_id, target}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].related_package_ids == QList{PackageId(QStringLiteral("mod.declarer"))}); +} + +TEST_CASE("IncompatibilityIssueChecker reverse-declared deduplicated across origins") { + auto declarer = make_mod(QStringLiteral("mod.declarer")); + auto target = make_mod(QStringLiteral("mod.target")); + const PackageId declarer_pid(QStringLiteral("mod.declarer")); + const PackageId target_pid(QStringLiteral("mod.target")); + declarer->about_rules.incompatible_with.add(target_pid.str()); + + sort::SortSettings settings; + settings.use_community_rules = true; + settings.use_alternative_package_ids = true; + settings.check_missing_dependencies = true; + + sort::CommunityRule rule; + rule.package_id = declarer_pid; + rule.incompatible_with.add(target_pid.str()); + sort::CommunityRules cr{{declarer_pid, rule}}; + + auto storage = make_storage({{declarer_pid, declarer}, {target_pid, target}}, + std::nullopt, + &settings, + &cr); + + auto incoming_edges = storage.graph.incoming_of_type(target_pid, EdgeType::Incompatibility); + QSet origins; + for (const auto& edge : incoming_edges) { + origins.insert(edge.origin); + } + CHECK(origins == QSet{EdgeOrigin::AboutXml, EdgeOrigin::CommunityRules}); + + auto issues = IncompatibilityIssueChecker().check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].related_package_ids == QList{declarer_pid}); +} + +TEST_CASE("LoadOrderIssueChecker no violations when order satisfies rules") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + + LoadOrderIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("LoadOrderIssueChecker load-after violation reported") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_after.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}; + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::Warning); +} + +TEST_CASE("LoadOrderIssueChecker load-before violation reported") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + a->about_rules.load_before.add(QStringLiteral("mod.b")); + // A should load before B, but appears after + const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::Warning); +} + +TEST_CASE("CycleIssueChecker no cycles") { + auto a = make_mod(QStringLiteral("mod.a")); + auto storage = make_storage({{a->package_id, a}}); + CycleIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("CycleIssueChecker reports cycle membership") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("cycle")); + CHECK(issues[0].severity == IssueSeverity::Warning); +} + +TEST_CASE("CycleIssueChecker non-member gets no issue") { + auto a = make_mod(QStringLiteral("mod.a")); + auto b = make_mod(QStringLiteral("mod.b")); + auto c = make_mod(QStringLiteral("mod.c")); + auto storage = make_storage({{a->package_id, a}, {b->package_id, b}, {c->package_id, c}}); + storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + CHECK(checker.check(*c, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker no versions declared is not checked") { + auto a = make_mod(QStringLiteral("mod.a")); + a->supported_versions.clear(); + auto storage = make_storage({{a->package_id, a}}); + + GameVersionIssueChecker checker; + CHECK(!checker.should_check(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker matching version yields no issues") { + auto a = make_mod(QStringLiteral("mod.a")); + a->supported_versions = {QStringLiteral("1.5")}; + auto storage = make_storage({{a->package_id, a}}); + storage.target_version = QStringLiteral("1.5"); + + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker mismatch reported as warning") { + auto a = make_mod(QStringLiteral("mod.a")); + a->supported_versions = {QStringLiteral("1.4")}; + auto storage = make_storage({{a->package_id, a}}); + storage.target_version = QStringLiteral("1.5"); + + GameVersionIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("version_mismatch")); + CHECK(issues[0].severity == IssueSeverity::Warning); +} + +TEST_CASE("GameVersionIssueChecker suppressed by no_version_warning") { + auto a = make_mod(QStringLiteral("mod.a")); + a->supported_versions = {QStringLiteral("1.4")}; + auto storage = make_storage({{a->package_id, a}}); + storage.target_version = QStringLiteral("1.5"); + storage.no_version_warning.insert(PackageId(QStringLiteral("mod.a"))); + + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("ReplacementIssueChecker no replacement without published file id") { + auto a = make_mod(QStringLiteral("mod.a")); + auto storage = make_storage({{a->package_id, a}}); + ReplacementIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp new file mode 100644 index 0000000..50f145c --- /dev/null +++ b/tests/core/downloads/test_manager.cpp @@ -0,0 +1,173 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "core/downloads/downloader.h" +#include "core/downloads/manager.h" +#include "core/downloads/types.h" +#include "core/models/metadata/structures.h" +#include "../test_support.h" + +namespace { + +using px::core::Downloader; +using px::core::DownloadManager; +using px::core::DownloadResult; +using px::core::ListedMod; +using px::core::ListedModPtr; +using px::core::Result; +using test_support::run_sync; + +// Claims ids that start with `prefix`, and records each batch it was asked to download. +class FakeDownloader : public Downloader { +public: + FakeDownloader(QString name, QString label, QString prefix, DownloadManager& manager) + : Downloader(std::move(name), std::move(label), manager), prefix_(std::move(prefix)) {} + + bool is_downloading() const override { return busy_; } + QSet active_ids() const override { return {}; } + bool accepts(const QString& mod_id) const override { return mod_id.startsWith(prefix_); } + + std::optional updatable_id(const ListedMod& mod) const override { + const auto& path = mod.mod_path(); + if (!path) { + return std::nullopt; + } + const QString id = QFileInfo(*path).fileName(); + return accepts(id) ? std::optional(id) : std::nullopt; + } + + std::optional last_synced(const ListedMod& mod) const override { + return updatable_id(mod) ? std::optional(1.0) : std::nullopt; + } + + QCoro::Task> download_mods(QStringList mod_ids) override { + calls.push_back(mod_ids); + busy_ = true; + busy_changed.notify(true); + busy_ = false; + busy_changed.notify(false); + DownloadResult result{.succeeded = mod_ids, .failed = {}, .changed = {}}; + download_finished.notify(result); + co_return result; + } + + void cancel() override { cancelled = true; } + + std::vector calls; + bool cancelled = false; + +private: + QString prefix_; + bool busy_ = false; +}; + +class FakeSteam : public FakeDownloader { +public: + explicit FakeSteam(DownloadManager& manager) : FakeDownloader("steam", "Steam", "s", manager) {} +}; + +class FakeGithub : public FakeDownloader { +public: + explicit FakeGithub(DownloadManager& manager) : FakeDownloader("github", "Github", "g", manager) {} +}; + +ListedModPtr make_mod(const QString& folder) { + auto mod = std::make_shared(); + REQUIRE(mod->set_mod_path(QStringLiteral("/mods/%1").arg(folder)).has_value()); + return mod; +} + +} // namespace + +TEST_CASE("empty manager offers nothing", "[downloads][manager]") { + DownloadManager manager; + REQUIRE_FALSE(manager.available()); + REQUIRE(manager.updatable_ids({make_mod("s1")}).isEmpty()); + REQUIRE(manager.source_of(*make_mod("s1")) == nullptr); +} + +TEST_CASE("download routes ids to the accepting downloader", "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.register_downloader(steam); + manager.register_downloader(github); + + auto result = run_sync(manager.download_mods({"s1", "g1", "s2", "s1"})); + + REQUIRE(result.has_value()); + REQUIRE(steam.calls == std::vector{{"s1", "s2"}}); + REQUIRE(github.calls == std::vector{{"g1"}}); + QStringList succeeded = result->succeeded; + succeeded.sort(); + REQUIRE(succeeded == QStringList{"g1", "s1", "s2"}); +} + +TEST_CASE("unclaimed id is rejected before anything starts", "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + manager.register_downloader(steam); + + auto result = run_sync(manager.download_mods({"s1", "x1"})); + + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("x1")); + REQUIRE(steam.calls.empty()); +} + +TEST_CASE("source and ids come from the owning downloader", "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.register_downloader(steam); + manager.register_downloader(github); + const auto g1 = make_mod("g1"); + const auto other = make_mod("other"); + const QList mods{make_mod("s1"), g1, other}; + + const Downloader* source = manager.source_of(*g1); + REQUIRE(source != nullptr); + REQUIRE(source->label() == "Github"); + REQUIRE(manager.updatable_ids(mods) == QStringList{"s1", "g1"}); + REQUIRE_FALSE(manager.last_synced(*other).has_value()); +} + +TEST_CASE("busy reflects any downloader and cancel stops the rest", "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.register_downloader(steam); + manager.register_downloader(github); + std::vector busy; + manager.busy_changed.connect([&busy](const bool& value) { busy.push_back(value); }); + + steam.download_finished.connect([&manager](const DownloadResult&) { manager.cancel(); }); + auto result = run_sync(manager.download_mods({"s1", "g1"})); + + REQUIRE(result.has_value()); + REQUIRE(github.calls.empty()); + REQUIRE(busy == std::vector{true, false}); +} + +TEST_CASE("names are keyed by source id, not workshop id", "[downloads][manager]") { + DownloadManager manager; + FakeGithub github(manager); + manager.register_downloader(github); + const auto mod = make_mod("g1"); + mod->name = QStringLiteral("Git Mod"); + + const QHash names = manager.names_by_id({mod, make_mod("s1")}); + + REQUIRE(names == QHash{{"g1", "Git Mod"}}); +} diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp new file mode 100644 index 0000000..a79f96c --- /dev/null +++ b/tests/core/downloads/test_steam.cpp @@ -0,0 +1,703 @@ +#ifdef PX_HAS_STEAM + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../test_support.h" +#include "core/config.h" +#include "core/downloads/manager.h" +#include "core/downloads/steam.h" +#include "core/downloads/types.h" +#include "core/models/metadata/structures.h" + +namespace { + +using namespace std::chrono_literals; + +using px::core::ConfigService; +using px::core::DownloadItemStatus; +using px::core::DownloadManager; +using px::core::DownloadProgress; +using px::core::DownloadResult; +using px::core::ListedMod; +using px::core::ListedModPtr; +using px::core::LoginOptions; +using px::core::Result; +using px::core::SteamBatch; +using px::core::SteamDownloader; +using px::core::SteamDownloaderDeps; +using px::core::SteamSettings; +using px::core::WorkshopClient; +using px::core::WorkshopSyncState; +using test_support::TestApplication; + +// Thread-safe flag with a timeout-aware wait. +class Flag { +public: + void set() { + { + std::lock_guard lock(mutex_); + value_ = true; + } + cv_.notify_all(); + } + + void clear() { + std::lock_guard lock(mutex_); + value_ = false; + } + + bool is_set() const { + std::lock_guard lock(mutex_); + return value_; + } + + bool wait_for(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return value_; }); + } + +private: + mutable std::mutex mutex_; + std::condition_variable cv_; + bool value_ = false; +}; + +// Drives an event loop until `task` co_returns. Fails if `timeout` expires. +template +QCoro::Task drive(QCoro::Task task, std::optional& out, QEventLoop& loop) { + out.emplace(co_await std::move(task)); + loop.quit(); +} + +template +T run_loop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { + std::optional out; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); + + auto driver = drive(std::move(task), out, loop); + if (!out.has_value()) { + loop.exec(); + } + REQUIRE(out.has_value()); + return std::move(*out); +} + +inline QCoro::Task drive_void(QCoro::Task task, bool& done, QEventLoop& loop) { + co_await std::move(task); + done = true; + loop.quit(); +} + +inline void run_loop_void(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { + bool done = false; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); + + auto driver = drive_void(std::move(task), done, loop); + if (!done) { + loop.exec(); + } + REQUIRE(done); +} + +class FakeClient : public WorkshopClient { +public: + struct Call { + std::vector ids; + QString root; + int workers = 0; + }; + + QHash errors; + QHash> gates; + QSet network_failures; + std::shared_ptr blocked = std::make_shared(); + + std::vector calls; + + std::vector download(std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + calls.push_back(Call{ + .ids = std::vector(ids.begin(), ids.end()), + .root = QString::fromStdWString(root.wstring()), + .workers = static_cast(options.parallel_items * options.threads_per_item), + }); + + std::vector results; + results.reserve(ids.size()); + for (const auto id : ids) { + if (options.on_resolved) { + pxsteamdl::ItemInfo info; + info.item_id = id; + info.title = "Title " + std::to_string(id); + options.on_resolved(info); + } + if (options.on_progress) { + pxsteamdl::Progress p; + p.item_id = id; + p.unpacked_bytes = 5; + p.unpacked_total = 10; + options.on_progress(p); + } + if (auto gate = gates.value(id)) { + blocked->set(); + gate->wait_for(5s); + } + + const bool cancelled = options.stop.stop_requested(); + const QString error_text = cancelled ? QStringLiteral("cancelled") : errors.value(id); + if (error_text.isEmpty()) { + std::error_code ec; + std::filesystem::create_directories(root / std::to_string(id), ec); + } + + pxsteamdl::ErrorKind kind = pxsteamdl::ErrorKind::kNone; + if (network_failures.contains(id)) { + kind = pxsteamdl::ErrorKind::kNetwork; + } else if (cancelled) { + kind = pxsteamdl::ErrorKind::kCancelled; + } else if (!error_text.isEmpty()) { + kind = pxsteamdl::ErrorKind::kOther; + } + + results.push_back(pxsteamdl::Result{ + .item_id = id, + .title = "Title " + std::to_string(id), + .path = root / std::to_string(id), + .error = error_text.toStdString(), + .error_kind = kind, + .downloaded_bytes = 0, + .unpacked_bytes = 0, + }); + } + return results; + } +}; + +class DyingClient : public WorkshopClient { +public: + std::vector download(std::span, const std::filesystem::path&, + const pxsteamdl::Options&) override { + throw std::runtime_error("session lost"); + } +}; + +struct Service { + explicit Service(std::deque> clients, bool local = true) + : config_dir(), + config(config_dir.path()), + manager(), + local_path(local ? config_dir.path() + "/Mods" : QString()), + clients_(std::move(clients)) { + SteamDownloaderDeps deps{ + .manager = manager, + .config = config, + .local_mods_path = [this] { return local_path; }, + .installed_mods = [this] { return mods; }, + .client_factory = [this](LoginOptions opts) { return login(std::move(opts)); }, + }; + steam = std::make_unique(std::move(deps)); + manager.register_downloader(*steam); + } + + QCoro::Task>> login(LoginOptions opts) { + logins.push_back(opts); + if (clients_.empty()) { + co_return std::unexpected(px::core::Error{QStringLiteral("logon denied")}); + } + auto client = clients_.front(); + clients_.pop_front(); + co_return client; + } + + QTemporaryDir config_dir; + ConfigService config; + DownloadManager manager; + QString local_path; + QList mods; + + std::vector logins; + std::deque> clients_; + std::unique_ptr steam; +}; + +ListedModPtr make_mod_at(const QString& path, const QString& pfid = {}) { + QDir().mkpath(path); + if (!pfid.isEmpty()) { + QDir().mkpath(path + "/About"); + test_support::write_bytes(path + "/About/PublishedFileId.txt", pfid.toStdString()); + } + auto mod = std::make_shared(); + REQUIRE(mod->set_mod_path(path).has_value()); + return mod; +} + +std::optional synced_of(Service& svc, const QString& pid) { + auto mod = std::make_shared(); + REQUIRE(mod->set_mod_path(svc.local_path + "/" + pid).has_value()); + return svc.steam->last_synced(*mod); +} + +} // namespace + +TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client, client}); + + REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = " socks5h://host:1080 ", + .connect_timeout = 3, + .stall_timeout = 7, + }); + REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + + const std::vector expected{ + LoginOptions{.proxy = std::nullopt, .connect_timeout = 10, .stall_timeout = 30}, + LoginOptions{.proxy = "socks5h://host:1080", .connect_timeout = 3, .stall_timeout = 7}, + }; + REQUIRE(svc.logins == expected); +} + +TEST_CASE("network failure triggers new login", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->network_failures.insert(111); + Service svc({client, client}); + + REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("downloads into local mods and splits results", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("item not found")); + Service svc({client}); + + std::vector statuses; + std::vector finished; + svc.steam->download_item_status_changed.connect([&](const auto& s) { statuses.push_back(s); }); + svc.steam->download_finished.connect([&](const auto& r) { finished.push_back(r); }); + + auto result = run_loop(svc.steam->download_mods({"111", "222", "111"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed == QStringList{"222"}); + REQUIRE(finished.size() == 1); + REQUIRE(finished[0] == *result); + REQUIRE(QFileInfo::exists(svc.local_path + "/111")); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].ids == std::vector{111, 222}); + REQUIRE(client->calls[0].root == svc.local_path); + + QHash final_status; + for (const auto& s : statuses) { + if (s.status != "downloading") { + final_status.insert(s.mod_id, s); + } + } + REQUIRE(final_status.value("111").status == "success"); + REQUIRE(final_status.value("222").status == "error"); + REQUIRE(final_status.value("222").error == "item not found"); +} + +TEST_CASE("rejects invalid ids", "[downloads][steam]") { + TestApplication app; + const std::vector bad_inputs{ + {"12a"}, {"\u0661\u0662\u0663"}, {"1 2"}, {}, {"18446744073709551616"}}; + for (const auto& ids : bad_inputs) { + Service svc({std::make_shared()}); + auto result = run_loop(svc.steam->download_mods(ids)); + REQUIRE_FALSE(result.has_value()); + REQUIRE(svc.logins.empty()); + } +} + +TEST_CASE("requires local mods path", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}, /*local=*/false); + + auto result = run_loop(svc.steam->download_mods({"111"})); + + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Local mods path")); + REQUIRE(svc.logins.empty()); +} + +TEST_CASE("accepts uint64 max id", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + auto result = run_loop(svc.steam->download_mods({"18446744073709551615"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"18446744073709551615"}); + REQUIRE(client->calls[0].ids == std::vector{UINT64_MAX}); +} + +TEST_CASE("unwritable local path fails every item", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}); + test_support::write_bytes(svc.local_path, "not a directory"); + + auto result = run_loop(svc.steam->download_mods({"111", "222"})); + + REQUIRE(result.has_value()); + QStringList failed = result->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); + REQUIRE(svc.logins.empty()); +} + +TEST_CASE("login failure fails every item and retries next time", "[downloads][steam]") { + TestApplication app; + Service svc({}); + std::vector messages; + svc.steam->status_message_changed.connect([&](const QString& m) { messages.push_back(m); }); + + auto first = run_loop(svc.steam->download_mods({"111", "222"})); + auto second = run_loop(svc.steam->download_mods({"111"})); + + REQUIRE(first.has_value()); + REQUIRE(first->succeeded.isEmpty()); + QStringList failed = first->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); + + const bool has_denied = std::ranges::any_of(messages, [](const auto& m) { return m.contains("logon denied"); }); + REQUIRE(has_denied); + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("client session is reused", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + REQUIRE(svc.logins.size() == 1); +} + +TEST_CASE("uses at most eight download workers", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QStringList ids; + for (int i = 1; i <= 20; ++i) { + ids.append(QString::number(i)); + } + + REQUIRE(run_loop(svc.steam->download_mods(ids)).has_value()); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].workers <= 8); +} + +TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(222, gate); + Service svc({client}); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (client->blocked->is_set()) { + svc.steam->cancel(); + gate->set(); + poll.stop(); + } + }); + poll.start(10ms); + + auto result = run_loop(svc.steam->download_mods({"111", "222", "333"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed.isEmpty()); + REQUIRE_FALSE(svc.steam->is_downloading()); +} + +TEST_CASE("second download while running is rejected", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(111, gate); + Service svc({client}); + + std::optional> first_out; + std::optional> second_out; + QEventLoop loop; + + auto first_task = test_support::await_into(svc.steam->download_mods({"111"}), first_out); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (svc.steam->is_downloading()) { + poll.stop(); + auto second_task = test_support::await_into(svc.steam->download_mods({"222"}), second_out); + gate->set(); + loop.quit(); + } + }); + poll.start(10ms); + loop.exec(); + + REQUIRE(second_out.has_value()); + REQUIRE_FALSE(second_out->has_value()); + REQUIRE(second_out->error().message.contains("already running")); +} + +TEST_CASE("updatable_id only for downloads in local mods", "[downloads][steam]") { + TestApplication app; + Service svc({}); + const QString root = svc.local_path; + const auto m1 = make_mod_at(root + "/111", "111"); + const auto m2 = make_mod_at(root + "/Renamed", "222"); + const auto m3 = make_mod_at(root + "/333"); + const auto m4 = make_mod_at(svc.config_dir.path() + "/Workshop/444", "444"); + svc.mods = {m1, m2, m3, m4}; + + REQUIRE(svc.steam->updatable_id(*m1) == std::optional("111")); + REQUIRE(svc.steam->updatable_id(*m2) == std::nullopt); + REQUIRE(svc.steam->updatable_id(*m3) == std::optional("333")); + REQUIRE(svc.steam->updatable_id(*m4) == std::nullopt); + + REQUIRE(svc.steam->updatable_ids(svc.mods) == QStringList{"111", "333"}); +} + +TEST_CASE("updatable_id none without local path", "[downloads][steam]") { + TestApplication app; + Service svc({}, /*local=*/false); + const auto mod = make_mod_at(svc.config_dir.path() + "/Mods/111", "111"); + REQUIRE(svc.steam->updatable_id(*mod) == std::nullopt); +} + +TEST_CASE("busy changed brackets download", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + std::vector events; + svc.steam->busy_changed.connect([&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); + svc.steam->download_finished.connect([&](const auto&) { events.push_back("finished"); }); + + REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + + const std::vector expected{"busy:1", "busy:0", "finished"}; + REQUIRE(events == expected); +} + +TEST_CASE("invalid ids do not toggle busy", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}); + std::vector busy; + svc.steam->busy_changed.connect([&](bool b) { busy.push_back(b); }); + + auto result = run_loop(svc.steam->download_mods({"12a"})); + REQUIRE_FALSE(result.has_value()); + REQUIRE(busy.empty()); +} + +TEST_CASE("progress counts complete and up to date items", "[downloads][steam]") { + SteamBatch batch(3); + batch.bytes.insert("1", {10, 10}); + batch.bytes.insert("2", {5, 10}); + batch.bytes.insert("3", {0, 0}); + REQUIRE(batch.progress().completed == 2); + + batch.succeeded.append("1"); + REQUIRE(batch.progress().completed == 2); +} + +TEST_CASE("updatable_ids limited to given mods and deduplicated", "[downloads][steam]") { + TestApplication app; + Service svc({}); + const auto a = make_mod_at(svc.local_path + "/111", "111"); + const auto b = make_mod_at(svc.local_path + "/222", "222"); + const auto other = make_mod_at(svc.config_dir.path() + "/Other/9", "9"); + + REQUIRE(svc.steam->updatable_ids({b, b, other}) == QStringList{"222"}); +} + +TEST_CASE("result changed excludes up to date items", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + auto result = run_loop(svc.steam->download_mods({"111"})); + + REQUIRE(result.has_value()); + REQUIRE(result->changed == QStringList{"111"}); + REQUIRE(svc.steam->active_ids().isEmpty()); +} + +TEST_CASE("batch changed ignores zero byte items", "[downloads][steam]") { + SteamBatch batch(2); + batch.succeeded = {"1", "2"}; + batch.bytes.insert("1", {0, 0}); + batch.bytes.insert("2", {5, 5}); + REQUIRE(batch.changed() == QStringList{"2"}); +} + +TEST_CASE("success records sync time and persists", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("gone")); + Service svc({client}); + + REQUIRE(run_loop(svc.steam->download_mods({"111", "222"})).has_value()); + REQUIRE(synced_of(svc, "111").has_value()); + REQUIRE_FALSE(synced_of(svc, "222").has_value()); + + auto reloaded = svc.config.load(px::core::kWorkshopSyncFile); + REQUIRE(reloaded.has_value()); + std::vector keys; + for (const auto& [k, _] : reloaded->synced) { + keys.push_back(k); + } + REQUIRE(keys == std::vector{"111"}); +} + +TEST_CASE("stale ids never synced first then oldest", "[downloads][steam]") { + TestApplication app; + Service svc({}); + for (const char* name : {"1", "2", "3", "4"}) { + svc.mods.push_back(make_mod_at(svc.local_path + "/" + name, name)); + } + WorkshopSyncState seed; + seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; + REQUIRE(svc.config.save(px::core::kWorkshopSyncFile, seed).has_value()); + run_loop_void(svc.steam->load_sync_state()); + + REQUIRE(svc.steam->stale_ids(3600.0, 10000.0) == QStringList{"4", "3", "1"}); +} + +TEST_CASE("auto update only syncs stale mods when enabled", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + svc.mods.push_back(make_mod_at(svc.local_path + "/1", "1")); + + run_loop_void(svc.steam->auto_update_once()); + REQUIRE_FALSE(synced_of(svc, "1").has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 6, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + run_loop_void(svc.steam->auto_update_once()); + REQUIRE(synced_of(svc, "1").has_value()); + const double first_synced = *synced_of(svc, "1"); + + run_loop_void(svc.steam->auto_update_once()); + REQUIRE(*synced_of(svc, "1") == first_synced); +} + +TEST_CASE("titles from steam are emitted", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QHash titles; + svc.steam->download_item_titled.connect([&](const auto& t) { titles.insert(t.mod_id, t.title); }); + std::vector phases; + svc.steam->download_phase_changed.connect([&](const auto& p) { phases.push_back(p); }); + + REQUIRE(run_loop(svc.steam->download_mods({"111", "222"})).has_value()); + + REQUIRE(phases == std::vector{"login", "query", "run"}); + REQUIRE(titles.value("111") == "Title 111"); + REQUIRE(titles.value("222") == "Title 222"); +} + +TEST_CASE("client is relogged after download raises", "[downloads][steam]") { + TestApplication app; + auto dying = std::make_shared(); + auto fresh = std::make_shared(); + Service svc({dying, fresh}); + + auto first = run_loop(svc.steam->download_mods({"1"})); + REQUIRE(first.has_value()); + REQUIRE(first->failed == QStringList{"1"}); + + auto second = run_loop(svc.steam->download_mods({"2"})); + REQUIRE(second.has_value()); + REQUIRE(second->succeeded == QStringList{"2"}); + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("download uses configured parallelism clamped", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 3, + .threads_per_item = 5, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(run_loop(svc.steam->download_mods({"1"})).has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 0, + .threads_per_item = 999, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(run_loop(svc.steam->download_mods({"2"})).has_value()); + + REQUIRE(client->calls.size() == 2); + REQUIRE(client->calls[0].workers == 15); + REQUIRE(client->calls[1].workers == 16); +} + +#endif // PX_HAS_STEAM diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp new file mode 100644 index 0000000..4b00a9e --- /dev/null +++ b/tests/core/models/metadata/test_parsing.cpp @@ -0,0 +1,277 @@ +#include + +#include +#include +#include +#include + +#include "core/models/metadata/parsing.h" +#include "core/models/metadata/structures.h" + +using px::core::AboutXmlMod; +using px::core::CaseInsensitiveStr; +using px::core::createAboutModFromElement; +using px::core::createListedModFromPath; +using px::core::ListedMod; + +namespace { + +// Parses a `` document and builds the mod from its root element. +std::shared_ptr aboutFromXml(const char* xml, const char* targetVersion = "1.5") { + pugi::xml_document doc; + REQUIRE(doc.load_string(xml)); + return createAboutModFromElement(doc.document_element(), QString::fromUtf8(targetVersion)); +} + +// Writes `content` as About/About.xml under a fresh mod directory inside `root`. +QString writeAboutXml(const QString& root, const QByteArray& content) { + REQUIRE(QDir(root).mkpath(QStringLiteral("About"))); + QFile file(QDir(root).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); + return root; +} + +} // namespace + +TEST_CASE("basic About.xml fields are parsed") { + const auto mod = aboutFromXml(R"( + Test Mod + Tester + test.mod + A test mod. +
  • 1.5
  • +
    )"); + + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Test Mod")); + CHECK(mod->authors == QStringList{QStringLiteral("Tester")}); + CHECK(mod->package_id.str() == QStringLiteral("test.mod")); + CHECK(mod->description == QStringLiteral("A test mod.")); + CHECK(mod->supported_versions == QSet{QStringLiteral("1.5")}); +} + +TEST_CASE("About.xml without packageId is invalid") { + const auto mod = aboutFromXml("No ID Mod"); + CHECK_FALSE(mod->valid); + CHECK(mod->package_id.str() == QStringLiteral("missing.packageid")); +} + +TEST_CASE("About.xml with empty packageId is invalid") { + const auto mod = aboutFromXml(""); + CHECK_FALSE(mod->valid); + CHECK(mod->package_id.str() == QStringLiteral("missing.packageid")); +} + +TEST_CASE("versioned entries must match the version key exactly") { + const auto wrong = aboutFromXml(R"(x.y + +
  • wrong
  • +
  • wrong
  • +
    + +
  • wrong.dep
  • +
    +
    )"); + CHECK(wrong->about_rules.load_after.size() == 0); + CHECK(wrong->about_rules.dependencies.isEmpty()); + + const auto right = aboutFromXml(R"(x.y +
  • right
  • +
    )"); + CHECK(right->about_rules.load_after.contains(QStringLiteral("right"))); +} + +TEST_CASE("versioned dependencies replace the unversioned list") { + const auto mod = aboutFromXml(R"(x.y +
  • plain.dep
  • + +
  • versioned.dep
  • +
    +
    )"); + + REQUIRE(mod->about_rules.dependencies.size() == 1); + CHECK(mod->about_rules.dependencies.first().package_id == + CaseInsensitiveStr(QStringLiteral("versioned.dep"))); +} + +TEST_CASE("preferVersioned=false ignores versioned entries") { + pugi::xml_document doc; + REQUIRE(doc.load_string(R"(x.y + +
  • versioned.dep
  • +
    +
    )")); + const auto mod = createAboutModFromElement(doc.document_element(), QStringLiteral("1.5"), + /*preferVersioned=*/false); + CHECK(mod->about_rules.dependencies.isEmpty()); +} + +TEST_CASE("dependencies keep declaration order, skip duplicates and null entries") { + const auto mod = aboutFromXml(R"(x.y + +
  • Dep.OneDep One + https://example.test/one +
  • alt.one
  • + +
  • skipped.null
  • +
  • dep.oneDuplicate
  • +
  • Dep.Two
  • +
    +
    )"); + + const auto& deps = mod->about_rules.dependencies; + REQUIRE(deps.size() == 2); + CHECK(deps[0].package_id == CaseInsensitiveStr(QStringLiteral("dep.one"))); + CHECK(deps[0].name == QStringLiteral("Dep One")); + CHECK(deps[0].workshop_url == QStringLiteral("https://example.test/one")); + CHECK(deps[0].alternative_package_ids.contains(QStringLiteral("alt.one"))); + CHECK(deps[1].package_id == CaseInsensitiveStr(QStringLiteral("dep.two"))); +} + +TEST_CASE("force-load entries are merged into the load-order sets") { + const auto mod = aboutFromXml(R"(x.y +
  • before.a
  • +
  • before.b
  • +
  • after.a
  • +
  • after.b
  • +
  • bad.one
  • +
    )"); + + CHECK(mod->about_rules.load_before.contains(QStringLiteral("before.a"))); + CHECK(mod->about_rules.load_before.contains(QStringLiteral("before.b"))); + CHECK(mod->about_rules.load_after.contains(QStringLiteral("after.a"))); + CHECK(mod->about_rules.load_after.contains(QStringLiteral("after.b"))); + CHECK(mod->about_rules.incompatible_with.contains(QStringLiteral("bad.one"))); +} + +TEST_CASE("descriptionsByVersion selects the description for the target version") { + const auto mod = aboutFromXml(R"(x.y + Old textText for 1.5 + )"); + CHECK(mod->description == QStringLiteral("Text for 1.5")); +} + +TEST_CASE("explicit steamAppId is read") { + const auto mod = aboutFromXml(R"(x.y + 1234)"); + CHECK(mod->steam_app_id == 1234); +} + +TEST_CASE("DLC package ids depend on the RimWorld base game and take the DLC name") { + const auto mod = aboutFromXml( + "ludeon.rimworld.royalty"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Royalty")); + + const auto* base = mod->about_rules.find_dependency( + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))); + REQUIRE(base != nullptr); + CHECK(base->name == QStringLiteral("RimWorld")); +} + +TEST_CASE("official mods without a name use the package id mapping") { + struct Case { + const char* packageId; + const char* expectedName; + }; + const Case cases[] = { + {"Ludeon.RimWorld", "Core"}, + {"Ludeon.RimWorld.Royalty", "Royalty"}, + {"Ludeon.RimWorld.Ideology", "Ideology"}, + {"Ludeon.RimWorld.Biotech", "Biotech"}, + {"Ludeon.RimWorld.Anomaly", "Anomaly"}, + {"Ludeon.RimWorld.Odyssey", "Odyssey"}, + }; + QTemporaryDir root; + REQUIRE(root.isValid()); + + for (const Case& test : cases) { + const QString modDir = root.filePath(QString::fromUtf8(test.packageId)); + writeAboutXml(modDir, + QStringLiteral("%1" + "Ludeon Studios") + .arg(QString::fromUtf8(test.packageId)) + .toUtf8()); + + const auto result = createListedModFromPath(modDir, QStringLiteral("1.6")); + REQUIRE(result.has_value()); + CHECK((*result)->valid); + CHECK((*result)->name == QString::fromUtf8(test.expectedName)); + } +} + +TEST_CASE("explicit name wins for official mods") { + const auto mod = aboutFromXml(R"( + ludeon.rimworld.royalty + Custom official name)", + "1.6"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Custom official name")); +} + +TEST_CASE("createListedModFromPath parses a valid mod directory") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = writeAboutXml(root.path(), R"( + + Test Mod + TestAuthor + test.mod + A test mod for unit testing. +
  • 1.5
  • + 1.0.0 +
  • ludeon.rimworld
  • +
    +)"); + + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK(mod->valid); + const auto* about = dynamic_cast(mod.get()); + REQUIRE(about != nullptr); + CHECK(about->name == QStringLiteral("Test Mod")); + CHECK(about->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(about->package_id.str() == QStringLiteral("test.mod")); + CHECK(about->mod_version == QStringLiteral("1.0.0")); + CHECK(about->about_rules.load_after.contains(QStringLiteral("ludeon.rimworld"))); + CHECK(mod->mod_path() == modPath); + CHECK(mod->provider_id.isEmpty()); + CHECK(about->mtime > 0.0); +} + +TEST_CASE("createListedModFromPath rejects non-directory paths") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString file = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath( + QDir(file).filePath(QStringLiteral("About/About.xml")), QStringLiteral("1.5")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith(QStringLiteral("Path must be a directory"))); +} + +TEST_CASE("malformed About.xml yields an invalid AboutXmlMod with the mod path") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK_FALSE(mod->valid); + CHECK(dynamic_cast(mod.get()) != nullptr); + CHECK(mod->mod_path() == modPath); + CHECK(mod->uuid() == modPath); +} + +TEST_CASE("directory without About.xml yields an invalid plain ListedMod") { + QTemporaryDir root; + REQUIRE(root.isValid()); + + const auto result = createListedModFromPath(root.path(), QStringLiteral("1.5")); + REQUIRE(result.has_value()); + CHECK_FALSE((*result)->valid); + CHECK(dynamic_cast(result->get()) == nullptr); +} diff --git a/tests/core/models/metadata/test_schemas.cpp b/tests/core/models/metadata/test_schemas.cpp new file mode 100644 index 0000000..1a0c78b --- /dev/null +++ b/tests/core/models/metadata/test_schemas.cpp @@ -0,0 +1,87 @@ +#include + +#include + +#include + +#include "core/models/metadata/structures.h" + +using namespace px::core; + +TEST_CASE("CaseInsensitiveStr serializes as a lowercased JSON string") { + const CaseInsensitiveStr pid(QStringLiteral("Ludeon.RimWorld")); + const auto written = glz::write_json(pid); + REQUIRE(written.has_value()); + CHECK(*written == R"("ludeon.rimworld")"); + + CaseInsensitiveStr parsed; + REQUIRE_FALSE(glz::read_json(parsed, R"("Test.Mod")")); + CHECK(parsed == CaseInsensitiveStr(QStringLiteral("test.mod"))); +} + +TEST_CASE("community rules schema accepts single-value and list comments") { + const std::string json = R"({ + "timestamp": 42, + "rules": { + "pkg.a": { + "loadAfter": { + "other": {"name": "Other", "comment": ["first", "second"]} + }, + "loadTop": {"value": true, "comment": "top"} + } + } + })"; + + ExternalRulesSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.timestamp == 42); + + const ExternalRule& rule = schema.rules.at("pkg.a"); + CHECK(rule.loadTop.value); + CHECK(std::get(rule.loadTop.comment) == "top"); + + const SubExternalRule& after = rule.loadAfter.at("other"); + CHECK(std::get(after.name) == "Other"); + CHECK(std::get>(after.comment) == + std::vector{"first", "second"}); + CHECK(rule.loadBottom.value == false); +} + +TEST_CASE("steam workshop database accepts null, list, and object unions") { + const std::string json = R"({ + "version": 3, + "database": { + "123": { + "packageId": "author.mod", + "gameVersions": null, + "authors": "Solo Author", + "dependencies": { + "dep.list": ["alt.a"], + "dep.object": {"name": "Dep", "url": "https://example.test"} + }, + "tags": [{"key": "value"}], + "blacklist": {"value": true, "comment": "outdated"} + } + } + })"; + + SteamDbSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.version == 3); + + const SteamDbEntry& entry = schema.database.at("123"); + CHECK(entry.packageId == "author.mod"); + CHECK(std::holds_alternative(entry.gameVersions)); + CHECK(std::get(entry.authors) == "Solo Author"); + CHECK(std::get>(entry.dependencies.at("dep.list")) == + std::vector{"alt.a"}); + const auto& object = std::get(entry.dependencies.at("dep.object")); + CHECK(object.name == "Dep"); + CHECK(object.url == "https://example.test"); + REQUIRE(entry.tags.size() == 1); + CHECK(entry.tags.front().at("key") == "value"); + CHECK(entry.blacklist.value); + CHECK(entry.blacklist.comment == "outdated"); +} diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp new file mode 100644 index 0000000..4df4b5c --- /dev/null +++ b/tests/core/models/metadata/test_structures.cpp @@ -0,0 +1,167 @@ +#include + +#include +#include + +#include "core/models/metadata/structures.h" + +using px::core::AboutXmlMod; +using px::core::CaseInsensitiveSet; +using px::core::CaseInsensitiveStr; +using px::core::ListedMod; +using px::core::ModsConfig; + +TEST_CASE("CaseInsensitiveStr stores the lowercased package id") { + const CaseInsensitiveStr pid(QStringLiteral("Test.Mod.PackageId")); + CHECK(pid.str() == QStringLiteral("test.mod.packageid")); +} + +TEST_CASE("CaseInsensitiveStr compares ignoring case") { + const CaseInsensitiveStr a(QStringLiteral("Test.Mod")); + const CaseInsensitiveStr b(QStringLiteral("test.mod")); + CHECK(a == b); +} + +TEST_CASE("CaseInsensitiveStr finds hash entries regardless of case") { + QHash map; + map.insert(CaseInsensitiveStr(QStringLiteral("Test.Mod")), QStringLiteral("value")); + CHECK(map.value(CaseInsensitiveStr(QStringLiteral("test.mod"))) == QStringLiteral("value")); +} + +TEST_CASE("CaseInsensitiveSet membership ignores case") { + CaseInsensitiveSet set; + set.add(QStringLiteral("Test.Mod")); + CHECK(set.contains(QStringLiteral("test.mod"))); + CHECK(set.contains(QStringLiteral("Test.Mod"))); +} + +TEST_CASE("CaseInsensitiveSet discard removes regardless of case") { + CaseInsensitiveSet set(QStringList{QStringLiteral("Test.Mod")}); + set.discard(QStringLiteral("test.mod")); + CHECK(set.size() == 0); +} + +TEST_CASE("CaseInsensitiveSet union keeps elements of both operands") { + const CaseInsensitiveSet a(QStringList{QStringLiteral("mod.a")}); + const CaseInsensitiveSet b(QStringList{QStringLiteral("Mod.B")}); + const CaseInsensitiveSet c = a | b; + CHECK(c.contains(QStringLiteral("mod.a"))); + CHECK(c.contains(QStringLiteral("mod.b"))); +} + +TEST_CASE("empty CaseInsensitiveSets compare equal") { + CHECK(CaseInsensitiveSet() == CaseInsensitiveSet()); +} + +TEST_CASE("ListedMod has the invalid-mod defaults") { + const ListedMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.valid); + CHECK(mod.provider_id.isEmpty()); + CHECK_FALSE(mod.mod_path().has_value()); +} + +TEST_CASE("ListedMod provider_id is assignable") { + ListedMod mod; + mod.provider_id = QStringLiteral("local"); + CHECK(mod.provider_id == QStringLiteral("local")); +} + +TEST_CASE("pathless ListedMod uuids are unique") { + const ListedMod a; + const ListedMod b; + CHECK(a.uuid() != b.uuid()); +} + +TEST_CASE("ListedMod mod_path can be set only once") { + ListedMod mod; + REQUIRE(mod.set_mod_path(QStringLiteral("/mods/a"))); + CHECK(mod.uuid() == QStringLiteral("/mods/a")); + + const auto second = mod.set_mod_path(QStringLiteral("/mods/b")); + REQUIRE_FALSE(second); + CHECK(second.error().message == QStringLiteral("Mod path already set. Cannot override.")); + CHECK(mod.mod_path() == QStringLiteral("/mods/a")); +} + +TEST_CASE("AboutXmlMod has the About.xml defaults") { + const AboutXmlMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.authors.isEmpty()); + CHECK(mod.mod_version.isEmpty()); + CHECK(mod.package_id == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); +} + +TEST_CASE("AboutXmlMod reports the DLC name for DLC package ids") { + AboutXmlMod mod; + mod.package_id = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); + REQUIRE(mod.get_dlc_name().has_value()); + CHECK(*mod.get_dlc_name() == QStringLiteral("RimWorld - Royalty")); +} + +TEST_CASE("ModsConfig normalizes package ids to lowercase") { + const ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("Test.UPPERCASE.Mod"))}, + .knownExpansions = {}, + }; + CHECK(config.activeMods.first() == CaseInsensitiveStr(QStringLiteral("test.uppercase.mod"))); +} + +TEST_CASE("ModsConfig clear_active_mods keeps expansions") { + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clear_active_mods(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.size() == 1); +} + +TEST_CASE("ModsConfig clear_all empties both lists") { + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clear_all(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.isEmpty()); +} + +TEST_CASE("ModsConfig detects duplicate active mods ignoring case") { + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("Mod.A"))}, + .knownExpansions = {}, + }; + CHECK(duplicated.check_active_duplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("mod.b"))}, + .knownExpansions = {}, + }; + CHECK_FALSE(distinct.check_active_duplicates()); +} + +TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("Ludeon.RimWorld.Royalty"))}, + }; + CHECK(duplicated.check_expansions_duplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.ideology"))}, + }; + CHECK_FALSE(distinct.check_expansions_duplicates()); +} diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp new file mode 100644 index 0000000..7f3291b --- /dev/null +++ b/tests/core/organizer/test_db.cpp @@ -0,0 +1,331 @@ +#include + +#include +#include +#include + +#include "core/organizer/db.h" +#include "core/organizer/defaults.h" +#include "core/organizer/models.h" +#include "test_util.h" + +using namespace px::core::organizer; +using organizer_test::expect; +using organizer_test::expect_error; +using organizer_test::wait; +using test_support::TestApplication; + +namespace { + +QString organizer_file(const QTemporaryDir& dir) { + return dir.filePath(QStringLiteral("organizer.db")); +} + +} // namespace + +TEST_CASE("a fresh database holds only the root folder") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const OrganizerState state = expect(wait(db.load())); + REQUIRE(state.folders.size() == 1); + const Folder root = state.folders.value(ROOT_ID); + CHECK(root.id == ROOT_ID); + CHECK_FALSE(root.parent_id.has_value()); + CHECK(root.name == QStringLiteral("Root")); + CHECK_FALSE(root.collapsed); + CHECK(state.placements.isEmpty()); + CHECK(state.tags.isEmpty()); + CHECK(state.mod_tags.isEmpty()); + CHECK(state.rules.isEmpty()); + + expect_error(wait(db.delete_folder(ROOT_ID))); + expect_error(wait(db.rename_folder(ROOT_ID, QStringLiteral("CustomRoot")))); + expect_error(wait(db.set_parent(ROOT_ID, 2))); +} + +TEST_CASE("sibling names are unique case-insensitively, per parent") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder f_a = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("FolderA")))); + CHECK(f_a.id > ROOT_ID); + CHECK(f_a.parent_id == ROOT_ID); + CHECK(f_a.name == QStringLiteral("FolderA")); + + expect_error(wait(db.create_folder(ROOT_ID, QStringLiteral("foldera")))); + + const Folder f_b = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("FolderB")))); + expect_error(wait(db.rename_folder(f_b.id, QStringLiteral("foldera")))); + + // The same name is fine under a different parent. + const Folder f_sub = expect(wait(db.create_folder(f_a.id, QStringLiteral("FolderB")))); + CHECK(f_sub.parent_id == f_a.id); + CHECK(f_sub.name == QStringLiteral("FolderB")); + + expect_error(wait(db.set_parent(f_sub.id, ROOT_ID))); +} + +TEST_CASE("placing lowercases package ids and upserts; unplacing removes rows") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder f1 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Folder1")))); + const Folder f2 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Folder2")))); + + expect(wait(db.place({}, 9999))); + expect(wait(db.place({QStringLiteral("Package.One"), QStringLiteral("PACKAGE.TWO")}, f1.id))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f1.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + // Upsert: moving an existing placement. + expect(wait(db.place({QStringLiteral("package.ONE")}, f2.id))); + st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f2.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expect(wait(db.unplace({QStringLiteral("PACKAGE.ONE")}))); + st = expect(wait(db.load())); + CHECK_FALSE(st.placements.contains(QStringLiteral("package.one"))); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expect_error(wait(db.place({QStringLiteral("package.three")}, 9999))); +} + +TEST_CASE("create_folder_with places its mods and rolls back on a duplicate name") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder f = expect(wait(db.create_folder_with( + ROOT_ID, QStringLiteral("CoreMods"), + {QStringLiteral("Package.One"), QStringLiteral("package.two"), QStringLiteral("PACKAGE.THREE")}))); + CHECK(f.id > ROOT_ID); + CHECK(f.name == QStringLiteral("CoreMods")); + CHECK(f.parent_id == ROOT_ID); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.folders.contains(f.id)); + CHECK(st.placements.value(QStringLiteral("package.one")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.three")) == f.id); + + expect_error(wait(db.create_folder_with(ROOT_ID, QStringLiteral("coremods"), + {QStringLiteral("package.four"), QStringLiteral("package.five")}))); + + const OrganizerState after = expect(wait(db.load())); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.four"))); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.five"))); + CHECK(after.folders.size() == st.folders.size()); +} + +TEST_CASE("deleting a folder re-homes its subtree's placements and drops its descendants") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder f_parent = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Parent")))); + const Folder f_child = expect(wait(db.create_folder(f_parent.id, QStringLiteral("Child")))); + const Folder f_grandchild = expect(wait(db.create_folder(f_child.id, QStringLiteral("Grandchild")))); + const Folder f_other = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Other")))); + + expect(wait(db.place({QStringLiteral("mod.parent")}, f_parent.id))); + expect(wait(db.place({QStringLiteral("mod.child")}, f_child.id))); + expect(wait(db.place({QStringLiteral("mod.grandchild")}, f_grandchild.id))); + expect(wait(db.place({QStringLiteral("mod.other")}, f_other.id))); + expect(wait(db.place({QStringLiteral("mod.root")}, ROOT_ID))); + + expect(wait(db.delete_folder(f_parent.id))); + + const OrganizerState st = expect(wait(db.load())); + CHECK_FALSE(st.folders.contains(f_parent.id)); + CHECK_FALSE(st.folders.contains(f_child.id)); + CHECK_FALSE(st.folders.contains(f_grandchild.id)); + CHECK(st.folders.contains(f_other.id)); + + CHECK(st.placements.value(QStringLiteral("mod.parent")) == ROOT_ID); + CHECK(st.placements.value(QStringLiteral("mod.child")) == ROOT_ID); + CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == ROOT_ID); + CHECK(st.placements.value(QStringLiteral("mod.other")) == f_other.id); + CHECK(st.placements.value(QStringLiteral("mod.root")) == ROOT_ID); + + expect_error(wait(db.delete_folder(9999))); +} + +TEST_CASE("tags: create, rename, unique names, mod tagging and cascade on delete") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Tag t1 = expect(wait(db.create_tag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); + const Tag t2 = expect(wait(db.create_tag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); + CHECK(t1.name == QStringLiteral("Tag1")); + CHECK(t1.color == QStringLiteral("#ff0000")); + + expect_error(wait(db.create_tag(QStringLiteral("tag1"), QStringLiteral("#123456")))); + + expect(wait(db.set_mod_tags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, {t1.id, t2.id}, {}))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.mod_tags.value(QStringLiteral("mod.alpha")) == QSet{t1.id, t2.id}); + CHECK(st.mod_tags.value(QStringLiteral("mod.beta")) == QSet{t1.id, t2.id}); + + expect(wait(db.set_mod_tags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); + st = expect(wait(db.load())); + CHECK(st.mod_tags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); + + expect_error(wait(db.set_mod_tags({QStringLiteral("mod.alpha")}, {9999}, {}))); + + expect(wait(db.update_tag(t1.id, QStringLiteral("Tag1Updated"), QStringLiteral("#aaaaaa")))); + st = expect(wait(db.load())); + CHECK(st.tags.value(t1.id).name == QStringLiteral("Tag1Updated")); + + expect_error(wait(db.update_tag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); + + expect(wait(db.delete_tag(t2.id))); + st = expect(wait(db.load())); + CHECK_FALSE(st.tags.contains(t2.id)); + CHECK((!st.mod_tags.contains(QStringLiteral("mod.alpha")) || + st.mod_tags.value(QStringLiteral("mod.alpha")).isEmpty())); + CHECK(st.mod_tags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); + + expect_error(wait(db.delete_tag(9999))); +} + +TEST_CASE("replacing rules numbers them by position and deleting a folder drops its rules") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder f1 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Target1")))); + const Folder f2 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Target2")))); + + const QList specs{ + RuleSpec{.field = RuleField::Name, .op = RuleOp::Prefix, .pattern = QStringLiteral("Vanilla"), .folder_id = f1.id}, + RuleSpec{.field = RuleField::Author, .op = RuleOp::Contains, .pattern = QStringLiteral("Oskar"), .folder_id = f2.id}, + RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Equals, .pattern = QStringLiteral("ludeon.rimworld"), .folder_id = f1.id}, + }; + const QList rules = expect(wait(db.replace_rules(specs))); + REQUIRE(rules.size() == 3); + CHECK(rules[0].position == 0); + CHECK(rules[1].position == 1); + CHECK(rules[2].position == 2); + CHECK(rules[0].field == RuleField::Name); + CHECK(rules[1].field == RuleField::Author); + CHECK(rules[2].field == RuleField::PackageId); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.rules == rules); + + // A rule pointing at a missing folder fails the whole replacement. + expect_error(wait(db.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Prefix, + .pattern = QStringLiteral("x"), .folder_id = 9999}}))); + + // Deleting Target1 drops its rules; the rule for Target2 remains. + expect(wait(db.delete_folder(f1.id))); + const OrganizerState after = expect(wait(db.load())); + REQUIRE(after.rules.size() == 1); + CHECK(after.rules[0].id == rules[1].id); + CHECK(after.rules[0].folder_id == f2.id); +} + +TEST_CASE("state survives closing and reopening the database") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = organizer_file(dir); + + Folder folder; + Tag tag; + { + OrganizerDb db1(file); + folder = expect(wait(db1.create_folder(ROOT_ID, QStringLiteral("PersistFolder")))); + expect(wait(db1.set_collapsed(folder.id, true))); + expect(wait(db1.place({QStringLiteral("persist.pkg")}, folder.id))); + tag = expect(wait(db1.create_tag(QStringLiteral("PersistTag"), QStringLiteral("#abcdef")))); + expect(wait(db1.set_mod_tags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); + expect(wait(db1.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, + .pattern = QStringLiteral("mod"), .folder_id = folder.id}}))); + wait(db1.close()); + } + + OrganizerDb db2(file); + const OrganizerState st = expect(wait(db2.load())); + REQUIRE(st.folders.contains(folder.id)); + CHECK(st.folders.value(folder.id).name == QStringLiteral("PersistFolder")); + CHECK(st.folders.value(folder.id).collapsed); + CHECK(st.placements.value(QStringLiteral("persist.pkg")) == folder.id); + REQUIRE(st.tags.contains(tag.id)); + CHECK(st.tags.value(tag.id).name == QStringLiteral("PersistTag")); + CHECK(st.mod_tags.value(QStringLiteral("persist.pkg")) == QSet{tag.id}); + REQUIRE(st.rules.size() == 1); + CHECK(st.rules[0].pattern == QStringLiteral("mod")); + CHECK(st.rules[0].folder_id == folder.id); +} + +TEST_CASE("append_named_rules is atomic and resolves folders case-insensitively") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizer_file(dir)); + + const Folder existing = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Existing")))); + expect(wait(db.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, + .pattern = QStringLiteral("a"), .folder_id = existing.id}}))); + const OrganizerState before = expect(wait(db.load())); + + expect_error(wait(db.append_named_rules( + ROOT_ID, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("New"), .field = RuleField::Name, + .op = RuleOp::Contains, .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("Missing"), .field = RuleField::Name, + .op = RuleOp::Contains, .pattern = QStringLiteral("c")}})), + QStringLiteral("Missing")); + CHECK(expect(wait(db.load())) == before); + + expect(wait(db.append_named_rules( + ROOT_ID, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("existing"), .field = RuleField::Name, + .op = RuleOp::Contains, .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("NEW"), .field = RuleField::Name, + .op = RuleOp::Contains, .pattern = QStringLiteral("c")}}))); + + const OrganizerState st = expect(wait(db.load())); + qint64 new_id = -1; + for (const Folder& folder : st.folders) { + if (folder.name == QStringLiteral("New")) { + new_id = folder.id; + } + } + REQUIRE(st.rules.size() == 3); + CHECK(st.rules[0].position == 0); + CHECK(st.rules[0].pattern == QStringLiteral("a")); + CHECK(st.rules[0].folder_id == existing.id); + CHECK(st.rules[1].position == 1); + CHECK(st.rules[1].pattern == QStringLiteral("b")); + CHECK(st.rules[1].folder_id == existing.id); + CHECK(st.rules[2].position == 2); + CHECK(st.rules[2].pattern == QStringLiteral("c")); + CHECK(st.rules[2].folder_id == new_id); +} + +TEST_CASE("the database file is created in a missing directory on first use") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = dir.filePath(QStringLiteral("nested/config/organizer.db")); + OrganizerDb db(file); + + expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Made")))); + CHECK(QFile::exists(file)); +} diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp new file mode 100644 index 0000000..f1dd834 --- /dev/null +++ b/tests/core/organizer/test_resolve.cpp @@ -0,0 +1,544 @@ +#include + +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/organizer/models.h" +#include "core/organizer/resolve.h" + +using namespace px::core; +using namespace px::core::organizer; + +namespace { + +ListedModPtr make_mod([[maybe_unused]] const QString& uuid, const QString& name, + const std::optional& pid = std::nullopt, + const QString& author = {}, const QString& provider = QStringLiteral("local")) { + if (!pid) { + auto mod = std::make_shared(); + mod->name = name; + mod->provider_id = provider; + return mod; + } + auto mod = std::make_shared(); + mod->name = name; + mod->provider_id = provider; + mod->package_id = CaseInsensitiveStr(QStringView(*pid)); + if (!author.isEmpty()) { + mod->authors.append(author); + } + return mod; +} + +Folder folder(qint64 id, std::optional parent_id, QString name) { + Folder f; + f.id = id; + f.parent_id = parent_id; + f.name = std::move(name); + f.collapsed = false; + return f; +} + +struct RuleSeed { + RuleField field; + RuleOp op; + QString pattern; + qint64 folder_id; +}; + +OrganizerState make_state(QList folders = {}, QHash placements = {}, + QList rules = {}, QHash> mod_tags = {}, + QList tags = {}) { + OrganizerState state; + state.folders.insert(ROOT_ID, folder(ROOT_ID, std::nullopt, QStringLiteral(""))); + for (const Folder& f : folders) { + state.folders.insert(f.id, f); + } + state.placements = std::move(placements); + state.mod_tags = std::move(mod_tags); + for (const Tag& tag : tags) { + state.tags.insert(tag.id, tag); + } + for (qsizetype i = 0; i < rules.size(); ++i) { + const auto& [fld, op, pat, fid] = rules[i]; + state.rules.append(Rule{.id = static_cast(i + 1), + .position = static_cast(i), + .field = fld, + .op = op, + .pattern = pat, + .folder_id = fid}); + } + return state; +} + +const FolderNode* find_node(const FolderNode& root, qint64 folder_id) { + for (const FolderNode* node : root.walk()) { + if (node->folder.id == folder_id) { + return node; + } + } + return nullptr; +} + +} // namespace + +TEST_CASE("preview_rule_matches counts first matches and assignable ungrouped mods") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, + {{QStringLiteral("manual.mod"), 3}, {QStringLiteral("root.mod"), ROOT_ID}}, + {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("old."), 2}}); + + const ModsByUuid mods{ + {QStringLiteral("a"), make_mod(QStringLiteral("a"), QStringLiteral("Core Alpha"), QStringLiteral("new.alpha"), QStringLiteral("Author"))}, + {QStringLiteral("b"), make_mod(QStringLiteral("b"), QStringLiteral("Core Beta"), QStringLiteral("old.beta"), QStringLiteral("Other"))}, + {QStringLiteral("c"), make_mod(QStringLiteral("c"), QStringLiteral("Core Manual"), QStringLiteral("manual.mod"), QStringLiteral("Author"))}, + {QStringLiteral("d"), make_mod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), QStringLiteral("root.mod"), QStringLiteral("Author"))}, + {QStringLiteral("e"), make_mod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, + }; + + const QList draft{ + RuleSpec{.field = RuleField::Author, .op = RuleOp::Equals, .pattern = QStringLiteral("author"), .folder_id = 3}, + RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, .pattern = QStringLiteral("core"), .folder_id = 2}, + }; + + RulePreview preview = preview_rule_matches(state, mods, draft); + CHECK(preview.counts == QList{3, 1}); + CHECK(preview.assignable == 1); + + const QList reversed{draft[1], draft[0]}; + preview = preview_rule_matches(state, mods, reversed); + CHECK(preview.counts == QList{4, 0}); + CHECK(preview.assignable == 1); + + QList with_blank{ + RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, .pattern = QStringLiteral(" "), .folder_id = 2}, + draft[0], + draft[1], + }; + preview = preview_rule_matches(state, mods, with_blank); + CHECK(preview.counts == QList{0, 3, 1}); + CHECK(preview.assignable == 1); +} + +TEST_CASE("folder_for manual placement beats rules, and root placement blocks rules") { + SECTION("manual beats rule") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, + {{QStringLiteral("x.mod"), 3}}, + {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("x."), 2}}); + const Resolution res = folder_for(state, QStringLiteral("X.Mod"), QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folder_id == 3); + CHECK(res.placement == Placement::Manual); + } + + SECTION("root placement blocks rules") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A"))}, + {{QStringLiteral("x.mod"), ROOT_ID}}, + {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("x."), 2}}); + const Resolution res = folder_for(state, QStringLiteral("x.mod"), QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folder_id == ROOT_ID); + CHECK(res.placement == Placement::Manual); + } + + SECTION("first rule by position wins") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, {}, + {{RuleField::Author, RuleOp::Equals, QStringLiteral("OSKAR"), 3}, + {RuleField::Name, RuleOp::Contains, QStringLiteral("core"), 2}}); + const Resolution res1 = folder_for(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), QStringLiteral("Oskar")); + CHECK(res1.folder_id == 3); + CHECK(res1.placement == Placement::Rule); + const Resolution res2 = folder_for(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), QStringLiteral("Someone")); + CHECK(res2.folder_id == 2); + CHECK(res2.placement == Placement::Rule); + } + + SECTION("ops are case-insensitive") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, + {{RuleField::Name, RuleOp::Equals, QStringLiteral("hello world"), 2}}); + CHECK(folder_for(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), QStringLiteral("")).folder_id == 2); + CHECK(folder_for(state, QStringLiteral("a.b"), QStringLiteral("Hello World!"), QStringLiteral("")).folder_id == ROOT_ID); + } + + SECTION("no package id or no match is ungrouped") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, + {{RuleField::Name, RuleOp::Contains, QStringLiteral(""), 2}}); + CHECK(folder_for(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).folder_id == ROOT_ID); + CHECK(folder_for(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).placement == Placement::None); + + const OrganizerState state2 = make_state( + {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, + {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("zzz"), 2}}); + CHECK(folder_for(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), QStringLiteral("")).folder_id == ROOT_ID); + } +} + +namespace { + +struct NestedFixture { + OrganizerState state; + ModsByUuid mods; +}; + +NestedFixture make_nested() { + OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("Zeta")), folder(3, 2, QStringLiteral("inner")), + folder(4, ROOT_ID, QStringLiteral("alpha"))}, + {{QStringLiteral("a.one"), 2}, + {QStringLiteral("a.two"), 3}, + {QStringLiteral("a.three"), 3}, + {QStringLiteral("b.one"), 4}}); + ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("two"), QStringLiteral("a.two"))}, + {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"))}, + {QStringLiteral("u4"), make_mod(QStringLiteral("u4"), QStringLiteral("B One"), QStringLiteral("b.one"))}, + {QStringLiteral("u5"), make_mod(QStringLiteral("u5"), QStringLiteral("loose"))}, + }; + return {std::move(state), std::move(mods)}; +} + +} // namespace + +TEST_CASE("build_tree computes tri-state checks across nested folders") { + auto [state, mods] = make_nested(); + const FolderNode root = build_tree(state, mods, {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); + const FolderNode* zeta = find_node(root, 2); + const FolderNode* inner = find_node(root, 3); + const FolderNode* alpha = find_node(root, 4); + REQUIRE((zeta && inner && alpha)); + + CHECK(inner->total == 2); + CHECK(inner->enabled == 2); + CHECK(inner->check == CheckState::On); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 3); + CHECK(zeta->check == CheckState::On); + + CHECK(alpha->total == 1); + CHECK(alpha->enabled == 0); + CHECK(alpha->check == CheckState::Off); + + CHECK(root.total == 5); + CHECK(root.enabled == 3); + CHECK(root.check == CheckState::Partial); + + const FolderNode root2 = build_tree(state, mods, {QStringLiteral("u2")}); + CHECK(find_node(root2, 3)->check == CheckState::Partial); + CHECK(find_node(root2, 2)->check == CheckState::Partial); +} + +TEST_CASE("build_tree reports depths and places ungrouped mods at root") { + auto [state, mods] = make_nested(); + const FolderNode root = build_tree(state, mods, {}); + CHECK(root.depth == 0); + CHECK(find_node(root, 2)->depth == 1); + CHECK(find_node(root, 3)->depth == 2); + + REQUIRE(root.mods.size() == 1); + CHECK(root.mods[0].uuid == QStringLiteral("u5")); + CHECK(root.mods[0].placement == Placement::None); + CHECK_FALSE(root.mods[0].package_id.has_value()); +} + +TEST_CASE("build_tree sorts siblings and mods by name casefolded") { + auto [state, mods] = make_nested(); + const FolderNode root = build_tree(state, mods, {}); + REQUIRE(root.children.size() == 2); + CHECK(root.children[0].folder.name == QStringLiteral("alpha")); + CHECK(root.children[1].folder.name == QStringLiteral("Zeta")); + + const FolderNode* inner = find_node(root, 3); + REQUIRE(inner->mods.size() == 2); + CHECK(inner->mods[0].name == QStringLiteral("Three")); + CHECK(inner->mods[1].name == QStringLiteral("two")); +} + +TEST_CASE("build_tree query prunes nodes but counts cover all mods") { + auto [state, mods] = make_nested(); + TreeQuery query; + query.text = QStringLiteral("TWO"); + + const FolderNode root = build_tree(state, mods, {QStringLiteral("u1")}, query); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + + const FolderNode* zeta = find_node(root, 2); + const FolderNode* inner = find_node(root, 3); + REQUIRE((zeta && inner)); + CHECK(zeta->mods.isEmpty()); + REQUIRE(inner->mods.size() == 1); + CHECK(inner->mods[0].uuid == QStringLiteral("u2")); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 1); + CHECK(zeta->visible_total == 1); + CHECK(root.total == 5); + CHECK(root.visible_total == 1); + CHECK(root.mods.isEmpty()); +} + +TEST_CASE("an empty query does not prune empty folders") { + const OrganizerState state = make_state({folder(2, ROOT_ID, QStringLiteral("Empty"))}); + const FolderNode root = build_tree(state, {}, {}, TreeQuery{}); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + CHECK(find_node(root, 2)->check == CheckState::Off); +} + +TEST_CASE("build_tree filters by status, tags, provider and text") { + const OrganizerState state = make_state( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, + {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"))}, + }; + + const auto visible = [&](const TreeQuery& q) { + const FolderNode root = build_tree(state, mods, {QStringLiteral("u1")}, q); + QSet uuids; + for (const ModLeaf& leaf : root.all_mods()) { + uuids.insert(leaf.uuid); + } + return uuids; + }; + + TreeQuery active_q; + active_q.status = StatusFilter::Active; + CHECK(visible(active_q) == QSet{QStringLiteral("u1")}); + + TreeQuery inactive_q; + inactive_q.status = StatusFilter::Inactive; + CHECK(visible(inactive_q) == QSet{QStringLiteral("u2"), QStringLiteral("u3")}); + + TreeQuery tag1_q; + tag1_q.tag_ids = {1}; + CHECK(visible(tag1_q) == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + TreeQuery tag12_q; + tag12_q.tag_ids = {1, 2}; + CHECK(visible(tag12_q) == QSet{QStringLiteral("u1")}); + + TreeQuery steam_q; + steam_q.provider_ids = {QStringLiteral("steam")}; + CHECK(visible(steam_q) == QSet{QStringLiteral("u1")}); + + TreeQuery text_q; + text_q.text = QStringLiteral("ludeon"); + CHECK(visible(text_q) == QSet{QStringLiteral("u2")}); + + TreeQuery pid_q; + pid_q.text = QStringLiteral("a.one"); + CHECK(visible(pid_q) == QSet{QStringLiteral("u1")}); +} + +TEST_CASE("mods sharing a package id share their placement and tags") { + const OrganizerState state = make_state( + {folder(2, ROOT_ID, QStringLiteral("A"))}, + {{QStringLiteral("dup.mod"), 2}}, {}, + {{QStringLiteral("dup.mod"), {7}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Copy 1"), QStringLiteral("Dup.Mod"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Copy 2"), QStringLiteral("dup.mod"))}, + }; + + const FolderNode root = build_tree(state, mods, {}); + const FolderNode* a = find_node(root, 2); + REQUIRE(a != nullptr); + REQUIRE(a->mods.size() == 2); + CHECK(a->mods[0].uuid == QStringLiteral("u1")); + CHECK(a->mods[1].uuid == QStringLiteral("u2")); + CHECK(a->mods[0].tag_ids == QSet{7}); + CHECK(a->mods[1].tag_ids == QSet{7}); +} + +TEST_CASE("build_tree computes own counts and flags folders that have rules") { + auto [base_state, mods] = make_nested(); + QList nested_folders; + for (const Folder& f : base_state.folders) { + if (f.id != ROOT_ID) { + nested_folders.append(f); + } + } + const OrganizerState state = make_state( + nested_folders, base_state.placements, + {{RuleField::Name, RuleOp::Contains, QStringLiteral("zzz"), 3}}); + + TreeQuery query; + query.text = QStringLiteral("nothing"); + const FolderNode root = build_tree(state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); + CHECK(root.own_total == 1); + CHECK(root.own_enabled == 1); + CHECK(root.own_check() == CheckState::On); + CHECK(root.total == 5); + CHECK(root.check == CheckState::Partial); + + const FolderNode full = build_tree(state, mods, {}); + QList ruled_ids; + for (const FolderNode* node : full.walk()) { + if (node->has_rules) { + ruled_ids.append(node->folder.id); + } + } + CHECK(ruled_ids == QList{3}); + const FolderNode* zeta = find_node(full, 2); + REQUIRE(zeta != nullptr); + CHECK(zeta->own_total == 1); +} + +TEST_CASE("TreeFilter::query builds a query matching its preset") { + TreeFilter tf; + tf.key = QStringLiteral("tag:10"); + tf.label = QStringLiteral("Medieval"); + tf.count = 3; + tf.tag_id = 10; + + const TreeQuery q = tf.query(QStringLiteral("sword")); + CHECK(q.tag_ids == QSet{10}); + CHECK(q.text == QStringLiteral("sword")); + CHECK(q.status == StatusFilter::All); + CHECK(q.provider_ids.isEmpty()); + CHECK_FALSE(q.is_empty()); +} + +TEST_CASE("tree_filters preserves order, counts status and counts tags across mods") { + const Tag t1{.id = 1, .name = QStringLiteral("Medieval"), .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, .name = QStringLiteral("Combat"), .color = QStringLiteral("#fb923c")}; + const Tag t3{.id = 3, .name = QStringLiteral("Magic"), .color = QStringLiteral("#a3e635")}; + + const OrganizerState state = make_state( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2, t3}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, + }; + + const QList filters = tree_filters(mods, {QStringLiteral("u1")}, &state); + REQUIRE(filters.size() >= 3); + CHECK(filters[0].key == QStringLiteral("all")); + CHECK(filters[0].count == 3); + CHECK(filters[1].key == QStringLiteral("active")); + CHECK(filters[1].count == 1); + CHECK(filters[2].key == QStringLiteral("inactive")); + CHECK(filters[2].count == 2); + + QList providers; + for (const TreeFilter& f : filters) { + if (f.provider_id) { + providers.append(f); + } + } + REQUIRE(providers.size() == 2); + CHECK(providers[0].key == QStringLiteral("provider:local")); + CHECK(providers[0].count == 1); + CHECK(providers[1].key == QStringLiteral("provider:steam")); + CHECK(providers[1].count == 2); + + QList tags; + for (const TreeFilter& f : filters) { + if (f.tag_id) { + tags.append(f); + } + } + REQUIRE(tags.size() == 3); + CHECK(tags[0].key == QStringLiteral("tag:2")); + CHECK(tags[0].label == QStringLiteral("Combat")); + CHECK(tags[0].count == 1); + + CHECK(tags[1].key == QStringLiteral("tag:3")); + CHECK(tags[1].label == QStringLiteral("Magic")); + CHECK(tags[1].count == 0); + + CHECK(tags[2].key == QStringLiteral("tag:1")); + CHECK(tags[2].label == QStringLiteral("Medieval")); + CHECK(tags[2].count == 2); +} + +TEST_CASE("tree_filters counts all copies sharing a package id") { + const Tag t5{.id = 5, .name = QStringLiteral("Overhaul"), .color = QStringLiteral("#5eead4")}; + const OrganizerState state = make_state({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, + }; + + const QList filters = tree_filters(mods, {}, &state); + const auto it = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:5"); }); + REQUIRE(it != filters.cend()); + CHECK(it->count == 2); + CHECK(it->label == QStringLiteral("Overhaul")); + CHECK(it->tag_id == 5); +} + +TEST_CASE("tree_filters without state or without tags only produces presets and providers") { + const ModsByUuid mods{{QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}}; + + const QList without_state = tree_filters(mods, {}, nullptr); + QStringList keys; + for (const TreeFilter& f : without_state) { + keys.append(f.key); + } + CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), QStringLiteral("inactive"), QStringLiteral("provider:local")}); + + const OrganizerState empty_state = make_state(); + const QList with_empty_state = tree_filters(mods, {}, &empty_state); + QStringList empty_keys; + for (const TreeFilter& f : with_empty_state) { + empty_keys.append(f.key); + } + CHECK(empty_keys == keys); +} + +TEST_CASE("filtering tree with a tag query selects only tagged mods") { + const Tag t1{.id = 1, .name = QStringLiteral("Tagged"), .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, .name = QStringLiteral("Empty"), .color = QStringLiteral("#fb923c")}; + const OrganizerState state = make_state( + {}, {}, {}, + {{QStringLiteral("a.one"), {1}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Mod One"), QStringLiteral("a.one"))}, + {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Mod Two"), QStringLiteral("a.two"))}, + {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Mod Three"), QStringLiteral("a.three"))}, + }; + + const QList filters = tree_filters(mods, {}, &state); + const auto it1 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:1"); }); + REQUIRE(it1 != filters.cend()); + + const FolderNode tree1 = build_tree(state, mods, {}, it1->query()); + QSet visible1; + for (const ModLeaf& leaf : tree1.all_mods()) { + visible1.insert(leaf.uuid); + } + CHECK(visible1 == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + const auto it2 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:2"); }); + REQUIRE(it2 != filters.cend()); + const FolderNode empty_tree = build_tree(state, mods, {}, it2->query()); + CHECK(empty_tree.all_mods().isEmpty()); + CHECK(empty_tree.visible_total == 0); + + const FolderNode narrowed = build_tree(state, mods, {}, it1->query(QStringLiteral("Two"))); + QSet visible_narrowed; + for (const ModLeaf& leaf : narrowed.all_mods()) { + visible_narrowed.insert(leaf.uuid); + } + CHECK(visible_narrowed == QSet{QStringLiteral("u2")}); +} diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp new file mode 100644 index 0000000..2ce35cb --- /dev/null +++ b/tests/core/organizer/test_service.cpp @@ -0,0 +1,536 @@ +#include + +#include +#include + +#include +#include + +#include "core/models/metadata/structures.h" +#include "core/organizer/db.h" +#include "core/organizer/defaults.h" +#include "core/organizer/models.h" +#include "core/organizer/resolve.h" +#include "core/organizer/service.h" +#include "test_util.h" + +using namespace px::core; +using namespace px::core::organizer; +using organizer_test::expect; +using organizer_test::expect_error; +using organizer_test::wait; +using test_support::TestApplication; + +namespace { + +struct FakeContext { + QHash all_mods; + QStringList active_uuids; + + void add(const QString& uuid, const QString& name, const QString& pid, + const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + mod->name = name; + mod->provider_id = provider; + mod->package_id = CaseInsensitiveStr(QStringView(pid)); + all_mods.insert(uuid, mod); + } +}; + +struct ChangeCounter { + int count = 0; + void record(const std::monostate&) { ++count; } +}; + +struct TestHarness { + TestApplication app; + QTemporaryDir dir; + FakeContext ctx; + OrganizerDb db; + OrganizerService svc; + ChangeCounter changes; + + explicit TestHarness() + : dir(), + db(dir.filePath(QStringLiteral("organizer.db"))), + svc([this]() -> const auto& { return ctx.all_mods; }, + [this]() -> const auto& { return ctx.active_uuids; }, db) { + REQUIRE(dir.isValid()); + svc.changed.connect([this](const std::monostate& m) { changes.record(m); }); + wait(svc.init()); + // Reset counter after init notification so tests count mutations only. + changes.count = 0; + } + + ~TestHarness() { + wait(svc.shutdown()); + } +}; + +} // namespace + +TEST_CASE("bulk folder expansion and collapse persists") { + TestHarness h; + const Folder parent = expect(wait(h.svc.create_folder(QStringLiteral("Parent")))); + const Folder child = expect(wait(h.svc.create_folder(QStringLiteral("Child"), parent.id))); + const Folder other = expect(wait(h.svc.create_folder(QStringLiteral("Other")))); + + expect(wait(h.svc.set_all_collapsed(true))); + OrganizerState persisted = expect(wait(h.db.load())); + CHECK(persisted.folders.value(parent.id).collapsed); + CHECK(persisted.folders.value(child.id).collapsed); + CHECK(persisted.folders.value(other.id).collapsed); + + expect(wait(h.svc.set_collapsed(child.id, false))); + expect(wait(h.svc.set_all_collapsed(false))); + persisted = expect(wait(h.db.load())); + CHECK_FALSE(persisted.folders.value(parent.id).collapsed); + CHECK_FALSE(persisted.folders.value(child.id).collapsed); + CHECK_FALSE(persisted.folders.value(other.id).collapsed); +} + +TEST_CASE("folder depth cannot exceed maximum depth") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.create_folder(QStringLiteral("C"), b.id))); + CHECK(h.changes.count == 3); + + expect_error(wait(h.svc.create_folder(QStringLiteral("D"), c.id)), QStringLiteral("at most 3 levels")); + CHECK(h.changes.count == 3); + CHECK(h.svc.state().folders.size() == 4); +} + +TEST_CASE("moving a folder respects subtree height") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.create_folder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.create_folder(QStringLiteral("Y"), x.id))); + + expect_error(wait(h.svc.move_folder(x.id, b.id)), QStringLiteral("at most 3 levels")); + + expect(wait(h.svc.move_folder(x.id, a.id))); + CHECK(h.svc.state().folders.value(x.id).parent_id == a.id); + CHECK(h.svc.state().folders.value(y.id).parent_id == x.id); + CHECK(h.changes.count == 5); +} + +TEST_CASE("moving a folder into its own subtree or moving root is rejected") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + + expect_error(wait(h.svc.move_folder(a.id, a.id)), QStringLiteral("into itself")); + expect_error(wait(h.svc.move_folder(a.id, b.id)), QStringLiteral("into itself")); + expect_error(wait(h.svc.move_folder(ROOT_ID, a.id))); + + CHECK(h.changes.count == 2); + CHECK(h.svc.state().folders.value(a.id).parent_id == ROOT_ID); +} + +TEST_CASE("folder creation and rename reject duplicate names case-insensitively") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("Graphics")))); + expect_error(wait(h.svc.create_folder(QStringLiteral(" graphics "))), QStringLiteral("already exists")); + + const Folder other = expect(wait(h.svc.create_folder(QStringLiteral("Other")))); + expect_error(wait(h.svc.rename_folder(other.id, QStringLiteral("GRAPHICS"))), QStringLiteral("already exists")); + + const Folder in_other = expect(wait(h.svc.create_folder(QStringLiteral("Graphics"), other.id))); + CHECK(in_other.parent_id == other.id); + + expect(wait(h.svc.rename_folder(a.id, QStringLiteral("graphics")))); + CHECK(h.svc.state().folders.value(a.id).name == QStringLiteral("graphics")); + CHECK(h.changes.count == 4); +} + +TEST_CASE("empty folder name is rejected") { + TestHarness h; + expect_error(wait(h.svc.create_folder(QStringLiteral(" "))), QStringLiteral("cannot be empty")); + CHECK(h.changes.count == 0); +} + +TEST_CASE("create_folder_from emits once and rolls back on duplicate") { + TestHarness h; + const Folder f = expect(wait(h.svc.create_folder_from( + QStringLiteral("Core"), {QStringLiteral("mod.a"), QStringLiteral("mod.b")}))); + CHECK(h.changes.count == 1); + CHECK(f.name == QStringLiteral("Core")); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.a")) == f.id); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.b")) == f.id); + + const OrganizerState state_before = h.svc.state(); + const int changes_before = h.changes.count; + + expect_error(wait(h.svc.create_folder_from(QStringLiteral("core"), {QStringLiteral("mod.c")}))); + CHECK(h.changes.count == changes_before); + CHECK(h.svc.state() == state_before); + + const OrganizerState db_state = expect(wait(h.db.load())); + CHECK_FALSE(db_state.placements.contains(QStringLiteral("mod.c"))); + CHECK(db_state.folders.size() == state_before.folders.size()); +} + +TEST_CASE("deleting a folder ungroups mods to root manual placement") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); + h.ctx.active_uuids = {QStringLiteral("u1")}; + + const Folder a = expect(wait(h.svc.create_folder_from(QStringLiteral("A"), {QStringLiteral("A.One")}))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + expect(wait(h.svc.set_rules({RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Prefix, + .pattern = QStringLiteral("a."), .folder_id = a.id}}))); + CHECK(h.changes.count == 4); + + QStringList uuids = expect(h.svc.folder_mod_uuids(a.id)); + std::sort(uuids.begin(), uuids.end()); + CHECK(uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + expect(wait(h.svc.delete_folder(a.id))); + CHECK(h.changes.count == 5); + CHECK(h.svc.state().folders.keys() == QList{ROOT_ID}); + CHECK(h.svc.state().rules.isEmpty()); + + const FolderNode root = h.svc.tree(); + QStringList root_uuids; + for (const ModLeaf& leaf : root.mods) { + root_uuids.append(leaf.uuid); + CHECK(leaf.placement == Placement::Manual); + } + std::sort(root_uuids.begin(), root_uuids.end()); + CHECK(root_uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(root.total == 2); + CHECK(root.enabled == 1); + CHECK(root.check == CheckState::Partial); +} + +TEST_CASE("place, ungroup and reset_to_rules alter placement source") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + expect(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, + .pattern = QStringLiteral(" one "), .folder_id = a.id}}))); + CHECK(h.svc.state().rules[0].pattern == QStringLiteral("one")); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::Rule); + + expect(wait(h.svc.ungroup({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().mods[0].placement == Placement::Manual); + + expect(wait(h.svc.reset_to_rules({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::Rule); + CHECK(h.changes.count == 4); +} + +TEST_CASE("rules validation rejects root target, empty pattern or missing folder") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + + expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, + .pattern = QStringLiteral("x"), .folder_id = ROOT_ID}})), + QStringLiteral("root")); + + expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, + .pattern = QStringLiteral(" "), .folder_id = a.id}})), + QStringLiteral("pattern cannot be empty")); + + expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, + .pattern = QStringLiteral("x"), .folder_id = 999}}))); + CHECK(h.changes.count == 1); +} + +TEST_CASE("tags lifecycle, validation and removal cascade") { + TestHarness h; + const Tag tag = expect(wait(h.svc.create_tag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); + + expect_error(wait(h.svc.create_tag(QStringLiteral("qol"), QStringLiteral("#000000"))), QStringLiteral("already exists")); + expect_error(wait(h.svc.create_tag(QStringLiteral("Other"), QStringLiteral("red"))), QStringLiteral("not a valid color")); + + expect(wait(h.svc.set_mod_tags({QStringLiteral("A.Mod")}, {tag.id}, {}))); + CHECK(h.svc.state().mod_tags.value(QStringLiteral("a.mod")) == QSet{tag.id}); + + expect_error(wait(h.svc.set_mod_tags({QStringLiteral("a.mod")}, {999}, {}))); + + expect(wait(h.svc.delete_tag(tag.id))); + CHECK(h.svc.state().tags.isEmpty()); + CHECK(h.svc.state().mod_tags.value(QStringLiteral("a.mod")).isEmpty()); + CHECK(h.changes.count == 3); +} + +TEST_CASE("service state persists across instances") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString db_file = dir.filePath(QStringLiteral("organizer.db")); + FakeContext ctx; + + Folder folder; + { + OrganizerDb db1(db_file); + OrganizerService svc1([&]() -> const auto& { return ctx.all_mods; }, + [&]() -> const auto& { return ctx.active_uuids; }, db1); + wait(svc1.init()); + folder = expect(wait(svc1.create_folder(QStringLiteral("Kept")))); + wait(svc1.shutdown()); + } + + { + OrganizerDb db2(db_file); + OrganizerService svc2([&]() -> const auto& { return ctx.all_mods; }, + [&]() -> const auto& { return ctx.active_uuids; }, db2); + wait(svc2.init()); + CHECK(svc2.state().folders.contains(folder.id)); + CHECK(svc2.state().folders.value(folder.id).name == QStringLiteral("Kept")); + wait(svc2.shutdown()); + } +} + +TEST_CASE("init signals readiness and emits changed") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + FakeContext ctx; + OrganizerDb db(dir.filePath(QStringLiteral("organizer.db"))); + OrganizerService svc([&]() -> const auto& { return ctx.all_mods; }, + [&]() -> const auto& { return ctx.active_uuids; }, db); + ChangeCounter counter; + svc.changed.connect([&](const std::monostate& m) { counter.record(m); }); + + CHECK_FALSE(svc.ready()); + wait(svc.init()); + CHECK(svc.ready()); + CHECK(counter.count == 1); + wait(svc.shutdown()); +} + +TEST_CASE("folder_toggle enables partial/off and disables full") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Loose"), QStringLiteral("loose.mod")); + + const Folder a = expect(wait(h.svc.create_folder_from(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + + h.ctx.active_uuids = {QStringLiteral("u1")}; + FolderToggle toggle = expect(h.svc.folder_toggle(a.id)); + toggle.enable.sort(); + CHECK(toggle.enable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.active_uuids = {QStringLiteral("u1"), QStringLiteral("u2")}; + toggle = expect(h.svc.folder_toggle(a.id)); + toggle.disable.sort(); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + toggle = expect(h.svc.folder_toggle(ROOT_ID, /*recursive=*/false)); + CHECK(toggle.enable == QStringList{QStringLiteral("u3")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.active_uuids = {QStringLiteral("u3")}; + toggle = expect(h.svc.folder_toggle(ROOT_ID, /*recursive=*/false)); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == QStringList{QStringLiteral("u3")}); + + CHECK(expect(h.svc.folder_mod_uuids(ROOT_ID, /*recursive=*/false)) == QStringList{QStringLiteral("u3")}); +} + +TEST_CASE("folder_move_targets respects subtree depth and duplicate names") { + TestHarness h; + const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.create_folder(QStringLiteral("C"), b.id))); + const Folder x = expect(wait(h.svc.create_folder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.create_folder(QStringLiteral("Y"), x.id))); + const Folder clash = expect(wait(h.svc.create_folder(QStringLiteral("Clash")))); + const Folder inner_x = expect(wait(h.svc.create_folder(QStringLiteral("X"), clash.id))); + + CHECK(expect(h.svc.folder_move_targets(b.id)) == QSet{x.id, clash.id, ROOT_ID}); + CHECK(expect(h.svc.folder_move_targets(x.id)) == QSet{a.id}); + CHECK(expect(h.svc.folder_move_targets(c.id)) == QSet{a.id, x.id, y.id, clash.id, inner_x.id, ROOT_ID}); + expect_error(h.svc.folder_move_targets(ROOT_ID)); +} + +TEST_CASE("service tree_filters counts status and providers") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); + h.ctx.active_uuids = {QStringLiteral("u1")}; + + QHash counts; + for (const TreeFilter& f : h.svc.tree_filters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("all")) == 2); + CHECK(counts.value(QStringLiteral("active")) == 1); + CHECK(counts.value(QStringLiteral("inactive")) == 1); + CHECK(counts.value(QStringLiteral("provider:local")) == 2); +} + +TEST_CASE("service tree_filters with persisted tags and empty tag") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three")); + + const Tag t1 = expect(wait(h.svc.create_tag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); + const Tag t2 = expect(wait(h.svc.create_tag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); + const Tag t3 = expect(wait(h.svc.create_tag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); + + expect(wait(h.svc.set_mod_tags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); + expect(wait(h.svc.set_mod_tags({QStringLiteral("a.two")}, {t1.id}, {}))); + + QHash filter_map; + for (const TreeFilter& f : h.svc.tree_filters()) { + filter_map[f.key] = f; + } + + const QString k1 = QStringLiteral("tag:%1").arg(t1.id); + const QString k2 = QStringLiteral("tag:%1").arg(t2.id); + const QString k3 = QStringLiteral("tag:%1").arg(t3.id); + + REQUIRE(filter_map.contains(k1)); + CHECK(filter_map[k1].count == 2); + CHECK(filter_map[k1].label == QStringLiteral("Medieval")); + + REQUIRE(filter_map.contains(k2)); + CHECK(filter_map[k2].count == 1); + CHECK(filter_map[k2].label == QStringLiteral("Combat")); + + REQUIRE(filter_map.contains(k3)); + CHECK(filter_map[k3].count == 0); + CHECK(filter_map[k3].label == QStringLiteral("Empty")); + + expect(wait(h.svc.delete_tag(t2.id))); + filter_map.clear(); + for (const TreeFilter& f : h.svc.tree_filters()) { + filter_map[f.key] = f; + } + CHECK_FALSE(filter_map.contains(k2)); + CHECK(filter_map.contains(k1)); +} + +TEST_CASE("service tree_filters counts shared package ids across copies") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("shared.pkg"), QStringLiteral("steam")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.pkg"), QStringLiteral("local")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.pkg"), QStringLiteral("local")); + + const Tag tag = expect(wait(h.svc.create_tag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); + expect(wait(h.svc.set_mod_tags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); + + QHash counts; + for (const TreeFilter& f : h.svc.tree_filters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("tag:%1").arg(tag.id)) == 2); +} + +TEST_CASE("add_standard_rules is fresh and idempotent") { + TestHarness h; + const int total = static_cast(standard_rules().size()); + QSet names; + for (const StandardRule& r : standard_rules()) { + names.insert(r.folder); + } + + CHECK(expect(wait(h.svc.add_standard_rules())) == total); + CHECK(h.changes.count == 1); + + QSet top_folders; + for (const Folder& f : h.svc.state().folders) { + if (f.parent_id == ROOT_ID) { + top_folders.insert(f.name); + } + } + CHECK(top_folders == names); + CHECK(h.svc.state().folders.size() == names.size() + 1); + CHECK(h.svc.state().rules.size() == total); + + CHECK(expect(wait(h.svc.add_standard_rules())) == 0); + CHECK(h.changes.count == 1); + CHECK(h.svc.state().rules.size() == total); + CHECK(h.svc.state().folders.size() == names.size() + 1); +} + +TEST_CASE("add_standard_rules reuses existing folders and preserves user rules") { + TestHarness h; + const Folder official = expect(wait(h.svc.create_folder(QStringLiteral("OFFICIAL")))); + const Folder mine = expect(wait(h.svc.create_folder(QStringLiteral("Mine")))); + expect(wait(h.svc.set_rules({ + RuleSpec{.field = RuleField::Author, .op = RuleOp::Contains, .pattern = QStringLiteral("me"), .folder_id = mine.id}, + RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Prefix, .pattern = QStringLiteral("SARG."), .folder_id = mine.id}, + }))); + h.changes.count = 0; + + const int total = static_cast(standard_rules().size()); + CHECK(expect(wait(h.svc.add_standard_rules())) == total - 1); + CHECK(h.changes.count == 1); + + QStringList names; + for (const Folder& f : h.svc.state().folders) { + names.append(f.name); + } + CHECK_FALSE(names.contains(QStringLiteral("Official"))); + CHECK_FALSE(names.contains(QStringLiteral("Alpha Mods"))); + CHECK(names.count(QStringLiteral("OFFICIAL")) == 1); + + const auto& rules = h.svc.state().rules; + REQUIRE(rules.size() >= 3); + CHECK(h.svc.state().folders.value(rules[0].folder_id).name == QStringLiteral("Mine")); + CHECK(rules[0].pattern == QStringLiteral("me")); + CHECK(h.svc.state().folders.value(rules[1].folder_id).name == QStringLiteral("Mine")); + CHECK(rules[1].pattern == QStringLiteral("SARG.")); + + // The first standard rule for Official got re-targeted to the existing OFFICIAL folder. + CHECK(rules[2].folder_id == official.id); +} + +TEST_CASE("standard rules resolve mods correctly") { + TestHarness h; + expect(wait(h.svc.add_standard_rules())); + const OrganizerState& state = h.svc.state(); + + const auto folder_name = [&](const QString& pid, const QString& name = QStringLiteral("Mod"), + const QString& author = {}) { + const auto [fid, _] = folder_for(state, pid, name, author); + return state.folders.value(fid).name; + }; + + CHECK(folder_name(QStringLiteral("oskarpotocki.vfe.core")) == QStringLiteral("Vanilla Expanded")); + CHECK(folder_name(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), QStringLiteral("Oskar Potocki, Taranchuk")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folder_name(QStringLiteral("ludeon.rimworld.biotech"), QStringLiteral("Biotech")) == QStringLiteral("Official")); + CHECK(folder_name(QStringLiteral("brrainz.harmony"), QStringLiteral("Harmony")) == QStringLiteral("Frameworks & Libraries")); + CHECK(folder_name(QStringLiteral("ceteam.combatextended"), QStringLiteral("Combat Extended")) == QStringLiteral("Combat Extended")); + CHECK(folder_name(QStringLiteral("sarg.alphagenes")) == QStringLiteral("Alpha Mods")); + CHECK(folder_name(QStringLiteral("bs.performance"), QStringLiteral("Performance Fish")) == QStringLiteral("Performance")); + CHECK(folder_name(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == QStringLiteral("Frameworks & Libraries")); + CHECK(folder_name(QStringLiteral("vanillaracesexpanded.sanguophage")) == QStringLiteral("Vanilla Expanded")); + CHECK(folder_name(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == QStringLiteral("Performance")); + CHECK(folder_name(QStringLiteral("dubwise.rimatomics")) == QStringLiteral("Dubs Mods")); + CHECK(folder_name(QStringLiteral("brrainz.achtung"), QStringLiteral("Achtung!")) == QStringLiteral("Quality of Life")); + CHECK(folder_name(QStringLiteral("brrainz.jobsofopportunity")) == QStringLiteral("Root")); + CHECK(folder_name(QStringLiteral("smartkar.athena.framework"), QStringLiteral("Athena Framework")) == QStringLiteral("Root")); + CHECK(folder_name(QStringLiteral("someone.perf"), QStringLiteral("Performance Tweaks")) == QStringLiteral("Root")); + CHECK(folder_name(QStringLiteral("someone.other"), QStringLiteral("Other")) == QStringLiteral("Root")); +} + +TEST_CASE("standard package_id rules are not shadowed") { + TestHarness h; + expect(wait(h.svc.add_standard_rules())); + const OrganizerState& state = h.svc.state(); + + for (const StandardRule& rule : standard_rules()) { + if (rule.field != RuleField::PackageId) { + continue; + } + const QString probe = rule.op == RuleOp::Equals ? rule.pattern : rule.pattern + QStringLiteral("x"); + const auto [fid, _] = folder_for(state, probe, QStringLiteral("Mod"), {}); + CHECK(state.folders.value(fid).name == rule.folder); + } +} diff --git a/tests/core/organizer/test_util.h b/tests/core/organizer/test_util.h new file mode 100644 index 0000000..d499140 --- /dev/null +++ b/tests/core/organizer/test_util.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include +#include + +#include "core/error.h" +#include "../test_support.h" + +namespace organizer_test { + +// Runs a coroutine to completion on the Qt event loop. Needs a test_support::TestApplication in scope. +template +T wait(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +template +T expect(px::core::Result result) { + REQUIRE(result.has_value()); + return std::move(*result); +} +inline void expect(const px::core::Result& result) { + REQUIRE(result.has_value()); +} + + +template +void expect_error(const px::core::Result& result, const QString& fragment = {}) { + REQUIRE_FALSE(result.has_value()); + if (!fragment.isEmpty()) { + CHECK(result.error().message.contains(fragment)); + } +} + +} // namespace organizer_test diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp new file mode 100644 index 0000000..ffc84ba --- /dev/null +++ b/tests/core/providers/test_factory.cpp @@ -0,0 +1,44 @@ +#include + +#include +#include + +#include "core/config.h" +#include "core/providers/factory.h" +#include "core/providers/local.h" + +using px::core::PathConfig; +using px::core::providers::create_providers; +using px::core::providers::SteamWorkshopModProvider; + +TEST_CASE("no configured paths produce no providers") { + CHECK(create_providers(PathConfig{}).empty()); +} + +TEST_CASE("each configured path yields its providers in core, local, downloaded, steam order") { + PathConfig paths; + paths.game = "/games/rimworld"; + paths.local = "/mods"; + paths.workshop = "/steam/workshop"; + + const auto providers = create_providers(paths); + QStringList ids; + for (const auto& provider : providers) { + ids.append(provider->provider_id()); + } + CHECK(ids == QStringList{"core", "local", "downloaded", "steam"}); + CHECK(providers[0]->path() == QStringLiteral("/games/rimworld")); + CHECK(providers[1]->path() == QStringLiteral("/mods")); + CHECK(providers[2]->path() == QStringLiteral("/mods")); + CHECK(providers[3]->path() == QStringLiteral("/steam/workshop")); + CHECK(dynamic_cast(providers[3].get()) != nullptr); +} + +TEST_CASE("only the configured paths produce providers") { + PathConfig paths; + paths.local = "/mods"; + const auto providers = create_providers(paths); + REQUIRE(providers.size() == 2); + CHECK(providers[0]->provider_id() == QStringLiteral("local")); + CHECK(providers[1]->provider_id() == QStringLiteral("downloaded")); +} diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp new file mode 100644 index 0000000..8745a65 --- /dev/null +++ b/tests/core/providers/test_providers.cpp @@ -0,0 +1,230 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include "core/providers/core.h" +#include "core/providers/local.h" + +using namespace px::core::providers; +using px::core::ListedModPtr; +using px::core::Result; + +namespace { + +QCoreApplication& eventLoop() { + static int argc = 0; + static QCoreApplication app(argc, nullptr); + return app; +} + +// Runs a discover() coroutine to completion on the Qt event loop. +template +T runToCompletion(QCoro::Task task) { + QCoreApplication& app = eventLoop(); + std::optional result; + auto driver = [](QCoro::Task inner, std::optional* out) -> QCoro::Task { + *out = co_await std::move(inner); + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + // A discover() that never awaits finishes before the loop starts. + if (!result) { + app.exec(); + } + REQUIRE(result.has_value()); + return *result; +} + +// Writes a mod directory with About/About.xml, and optionally About/PublishedFileId.txt. +QString makeMod(const QString& root, const QString& folder, const QString& packageId, + bool withPublishedFileId) { + const QString modPath = QDir(root).filePath(folder); + REQUIRE(QDir(modPath).mkpath(QStringLiteral("About"))); + + QFile about(QDir(modPath).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(about.open(QIODevice::WriteOnly)); + about.write(QStringLiteral("%1%1" + "
  • 1.5
  • " + "
    ") + .arg(packageId) + .toUtf8()); + + if (withPublishedFileId) { + QFile pfid(QDir(modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); + REQUIRE(pfid.open(QIODevice::WriteOnly)); + pfid.write("123456"); + } + return modPath; +} + +// In-memory MetadataCacheStore. `serveCached` makes every candidate a cache hit. +class FakeCache final : public MetadataCacheStore { +public: + bool serveCached = false; + ListedModPtr cachedMod; + int getCalls = 0; + QList stored; + + QCoro::Task get_mods( + QList candidates, QString /*target_version*/) override { + ++getCalls; + px::core::providers::CacheLookup lookup; + if (serveCached) { + lookup.cached.append(cachedMod); + } else { + lookup.missing = candidates; + } + co_return lookup; + } + + QCoro::Task put_mods(QList entries, QString /*target_version*/) override { + stored.append(entries); + co_return; + } +}; + +} // namespace + +TEST_CASE("workshop provider discovers mods that carry a published file id") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString workshop = root.filePath(QStringLiteral("workshop")); + const QString modPath = makeMod(workshop, QStringLiteral("123456"), + QStringLiteral("author.workshop"), true); + + SteamWorkshopModProvider provider(workshop); + CHECK(provider.provider_id() == QStringLiteral("steam")); + CHECK(provider.path() == workshop); + + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + const ListedModPtr mod = discovered->constBegin().value(); + CHECK(mod->mod_path() == modPath); + CHECK(mod->provider_id == QStringLiteral("steam")); +} + +TEST_CASE("local and downloaded providers split mods on the published file id") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString local = root.path(); + const QString plain = makeMod(local, QStringLiteral("plain"), QStringLiteral("local.mod"), false); + const QString downloaded = + makeMod(local, QStringLiteral("downloaded"), QStringLiteral("dl.mod"), true); + + LocalModProvider localProvider(local); + DownloadedModProvider downloadedProvider(local); + CHECK(localProvider.provider_id() == QStringLiteral("local")); + CHECK(downloadedProvider.provider_id() == QStringLiteral("downloaded")); + + const auto localMods = runToCompletion(localProvider.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(localMods->size() == 1); + CHECK(localMods->contains(plain)); + + const auto downloadedMods = + runToCompletion(downloadedProvider.discover(QStringLiteral("1.5"))); + REQUIRE(downloadedMods.has_value()); + REQUIRE(downloadedMods->size() == 1); + CHECK(downloadedMods->contains(downloaded)); +} + +TEST_CASE("providers parse several mods through the thread pool") { + QTemporaryDir root; + REQUIRE(root.isValid()); + for (const char* folder : {"a", "b", "c"}) { + makeMod(root.path(), QString::fromUtf8(folder), + QStringLiteral("pool.%1").arg(QString::fromUtf8(folder)), false); + } + + QThreadPool pool; + LocalModProvider provider(root.path(), &pool); + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->size() == 3); + for (const ListedModPtr& mod : *discovered) { + CHECK(mod->valid); + } +} + +TEST_CASE("core provider reads mods from the game's Data directory") { + QTemporaryDir game; + REQUIRE(game.isValid()); + CoreModProvider provider(game.path()); + CHECK(provider.provider_id() == QStringLiteral("core")); + CHECK(provider.color() == QStringLiteral("#e67e22")); + + // No Data/ directory: nothing to discover. + const auto missing = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(missing.has_value()); + CHECK(missing->isEmpty()); + + makeMod(QDir(game.path()).filePath(QStringLiteral("Data")), QStringLiteral("ludeon.rimworld"), + QStringLiteral("ludeon.rimworld"), false); + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value()->name == QStringLiteral("Core")); +} + +TEST_CASE("cached metadata is returned without parsing") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("cached.mod"), false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + REQUIRE(cache.cachedMod->set_mod_path(plain)); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value() == cache.cachedMod); + CHECK(cache.cachedMod->provider_id == QStringLiteral("local")); + CHECK(cache.stored.isEmpty()); +} + +TEST_CASE("newly parsed About.xml data is stored in the cache") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("parsed.mod"), false); + + FakeCache cache; + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->contains(plain)); + + REQUIRE(cache.stored.size() == 1); + CHECK(cache.stored.first().candidate.modPath == plain); + CHECK(cache.stored.first().mod->valid); +} + +TEST_CASE("force_reparse bypasses the cache lookup") { + QTemporaryDir root; + REQUIRE(root.isValid()); + makeMod(root.path(), QStringLiteral("plain"), QStringLiteral("forced.mod"), false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"), nullptr, + nullptr, /*force_reparse=*/true)); + REQUIRE(discovered.has_value()); + CHECK(cache.getCalls == 0); + CHECK(discovered->size() == 1); + CHECK(discovered->constBegin().value() != cache.cachedMod); +} diff --git a/tests/core/services/services_support.h b/tests/core/services/services_support.h new file mode 100644 index 0000000..d7711da --- /dev/null +++ b/tests/core/services/services_support.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace services_test { + +// Owns a QCoreApplication for one test case, unless one already exists (test_providers keeps a +// process-wide instance, and Qt asserts when a second one is created). +class EventLoop { +public: + EventLoop() { + if (!QCoreApplication::instance()) { + app_.emplace(argc_, nullptr); + } + } + +private: + int argc_ = 0; + std::optional app_; +}; + +// Runs a coroutine to completion on the event loop. Coroutines that never suspend finish +// before the loop starts. +template +T run_async(QCoro::Task task) { + // std::optional is ill-formed, so a void result is carried as std::monostate. + using Outcome = std::conditional_t, std::monostate, T>; + std::optional result; + auto driver = [](QCoro::Task inner, std::optional* out) -> QCoro::Task { + if constexpr (std::is_void_v) { + co_await std::move(inner); + out->emplace(); + } else { + *out = co_await std::move(inner); + } + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + if (!result) { + QCoreApplication::exec(); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } +} + +inline bool write_text(const QString& path, const QByteArray& bytes) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(bytes) == bytes.size(); +} + +} // namespace services_test diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp new file mode 100644 index 0000000..facc6ae --- /dev/null +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -0,0 +1,286 @@ +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include "../../test_support.h" +#include "core/services/startup_impact_service/db.h" + +using namespace px::core::startup_impact; +using Catch::Matchers::WithinAbs; +using px::core::Result; +using test_support::TestApplication; + +namespace { + +// Awaits a DB call on the event loop and fails the test on error. +template +auto ok(QCoro::Task> task) { + auto result = QCoro::waitFor(std::move(task)); + if (!result.has_value()) { + UNSCOPED_INFO("DB error: " << result.error().message.toStdString()); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } +} + +StartupImpactMod mod_entry(const QString& name, const std::optional& pid, + double total_s, double off_s = 0.0) { + StartupImpactMod mod; + mod.mod_name = name; + mod.package_id = pid; + mod.total_impact_s = total_s; + mod.off_thread_total_impact_s = off_s; + return mod; +} + +StartupImpactReport make_report(const QString& pid, double total_s, double off_thread_s = 0.0) { + StartupImpactReport report; + report.loading_time_s = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append(mod_entry(QStringLiteral("Mod ") + pid, pid, total_s, off_thread_s)); + return report; +} + +QString db_file(const QTemporaryDir& dir) { return QDir(dir.path()).filePath(QStringLiteral("cache.db")); } + +} // namespace + +TEST_CASE("store and clear run in the order they were issued") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + // Both calls are issued before either is awaited. The single worker runs them in order. + const auto report = make_report(QStringLiteral("author.example"), 0.5); + auto stored = db.store_report(path, report); + auto cleared = db.clear(path); + REQUIRE(QCoro::waitFor(std::move(stored)).has_value()); + REQUIRE(QCoro::waitFor(std::move(cleared)).has_value()); + CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); + + ok(db.store_report(path, report)); + const auto latest = ok(db.get_latest_report(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK(latest->mods[0].package_id == QStringLiteral("author.example")); +} + +TEST_CASE("base game keeps its sentinel package id after a round trip") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + StartupImpactReport report; + report.loading_time_s = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append(mod_entry(QStringLiteral("Example"), QStringLiteral("author.example"), 0.5)); + report.mods.append(mod_entry(QString(BASE_GAME_PID), QString(BASE_GAME_PID), 2.0)); + ok(db.store_report(path, report)); + const auto latest = ok(db.get_latest_report(path)); + REQUIRE(latest.has_value()); + + QHash> by_name; + for (const StartupImpactMod& mod : latest->mods) { + by_name.insert(mod.mod_name, mod.package_id); + } + REQUIRE(by_name.value(QString(BASE_GAME_PID)) == QString(BASE_GAME_PID)); +} + +TEST_CASE("a mod stored without a package id reads back without one") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + StartupImpactReport report; + report.loading_time_s = 1.0; + report.mods.append(mod_entry(QStringLiteral("No Package"), std::nullopt, 0.2)); + ok(db.store_report(path, report)); + const auto latest = ok(db.get_latest_report(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK_FALSE(latest->mods[0].package_id.has_value()); + CHECK(latest->mods[0].mod_name == QStringLiteral("No Package")); +} + +TEST_CASE("metrics survive a round trip") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + StartupImpactReport report; + report.loading_time_s = 3.0; + StartupImpactMod harmony = + mod_entry(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); + harmony.metrics.insert(QStringLiteral("ModConstructor"), 1.0); + harmony.off_thread_metrics.insert(QStringLiteral("LoadPatches"), 0.3); + report.mods.append(std::move(harmony)); + ok(db.store_report(path, report)); + const auto latest = ok(db.get_latest_report(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + const StartupImpactMod& mod = latest->mods[0]; + CHECK(mod.total_impact_s == Catch::Approx(1.25)); + CHECK(mod.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); + CHECK(mod.off_thread_metrics.value(QStringLiteral("LoadPatches")) == Catch::Approx(0.3)); + CHECK(mod.off_thread_total_impact_s == Catch::Approx(0.3)); + CHECK(latest->loading_time_s == Catch::Approx(3.0)); +} + +TEST_CASE("averages weight the newest session most") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + const QString pid = QStringLiteral("a.a"); + + ok(db.store_report(path, make_report(pid, 1.0, 0.5))); + ok(db.store_report(path, make_report(pid, 3.0, 1.5))); + + // Older session weighs 9, newest weighs 10: (1.0*9 + 3.0*10) / 19 and (0.5*9 + 1.5*10) / 19. + CHECK_THAT(ok(db.get_average(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); + const auto totals = ok(db.get_totals_for(path, {pid})); + REQUIRE(totals.contains(pid)); + CHECK_THAT(totals.value(pid).on_thread, WithinAbs(39.0 / 19.0, 1e-9)); + CHECK_THAT(totals.value(pid).off_thread, WithinAbs(19.5 / 19.0, 1e-9)); + CHECK(ok(db.get_all_averages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); +} + +TEST_CASE("only the most recent sessions contribute to averages") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + ok(db.store_report(path, make_report(QStringLiteral("old.mod"), 100.0))); + for (int i = 0; i < 5; ++i) { + ok(db.store_report(path, make_report(QStringLiteral("new.mod"), 1.0))); + } + + // Five sessions are kept, so the old one is gone from history entirely. + CHECK(ok(db.get_average(path, QStringLiteral("old.mod"))) == 0.0); + CHECK_THAT(ok(db.get_average(path, QStringLiteral("new.mod"))), WithinAbs(1.0, 1e-9)); + CHECK_FALSE(ok(db.get_all_averages(path)).contains(QStringLiteral("old.mod"))); +} + +TEST_CASE("snapshot returns the report with averages for the requested mods") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + StartupImpactReport report = make_report(QStringLiteral("a.a"), 1.0, 0.2); + report.mods.append(mod_entry(QString(BASE_GAME_PID), QString(BASE_GAME_PID), 2.0)); + ok(db.store_report(path, report)); + + const auto snap = ok(db.get_latest_with_averages( + path, {QStringLiteral("a.a"), QStringLiteral("missing.mod")}, QStringLiteral("a.a"))); + REQUIRE(snap.report.has_value()); + CHECK(snap.report->mods.size() == 2); + CHECK_THAT(snap.base_game_avg, WithinAbs(2.0, 1e-9)); + CHECK_THAT(snap.selected_avg, WithinAbs(1.0, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("a.a"))); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).on_thread, WithinAbs(1.0, 1e-9)); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).off_thread, WithinAbs(0.2, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("missing.mod"))); + CHECK(snap.totals.value(QStringLiteral("missing.mod")).on_thread == 0.0); +} + +TEST_CASE("reads of a missing database return empty results and create no file") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); + CHECK(ok(db.get_average(path, QStringLiteral("a.a"))) == 0.0); + CHECK(ok(db.get_all_averages(path)).isEmpty()); + + const auto snap = ok(db.get_latest_with_averages(path, {QStringLiteral("a.a")}, {})); + CHECK_FALSE(snap.report.has_value()); + CHECK(snap.totals.value(QStringLiteral("a.a")).on_thread == 0.0); + CHECK_FALSE(QFileInfo::exists(path)); +} + +TEST_CASE("store creates a missing parent directory") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = + QDir(dir.path()).filePath(QStringLiteral("nested/config/cache.db")); + + ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + CHECK(QFileInfo::exists(path)); +} + +TEST_CASE("clear removes all history") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + + ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + ok(db.clear(path)); + CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); + CHECK(ok(db.get_all_averages(path)).isEmpty()); +} + +TEST_CASE("the database uses the legacy schema and version") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = db_file(dir); + ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + + // A second connection reads what the first wrote, as the legacy app would. + const QString name = QStringLiteral("startup_impact_schema_check"); + { + QSqlDatabase verify = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + verify.setDatabaseName(path); + REQUIRE(verify.open()); + + QSqlQuery version(verify); + REQUIRE(version.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(version.next()); + CHECK(version.value(0).toInt() == 1); + + QSqlQuery tables(verify); + REQUIRE(tables.exec(QStringLiteral( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'startup_impact_%'" + " ORDER BY name"))); + QStringList names; + while (tables.next()) { + names.append(tables.value(0).toString()); + } + CHECK(names == QStringList{QStringLiteral("startup_impact_entries"), + QStringLiteral("startup_impact_sessions")}); + } + QSqlDatabase::removeDatabase(name); +} diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp new file mode 100644 index 0000000..cce23a5 --- /dev/null +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -0,0 +1,82 @@ +#include +#include + +#include +#include + +#include "core/services/startup_impact_service/labels.h" +#include "core/services/startup_impact_service/phases.h" + +using namespace px::core::startup_impact; +using Catch::Matchers::WithinAbs; + +namespace { + +const QString kPrefix = QStringLiteral("LoadingProgress.StartupImpact."); + +} // namespace + +TEST_CASE("metric_phase assigns each metric key to its phase") { + struct Case { + const char* key; + Phase phase; + }; + const Case cases[] = { + {"ModContentPackReloadContentInt.Textures", Phase::Content}, + {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::Content}, + {"ModConstructor", Phase::ModConstructors}, + {"StaticConstructorOnStartupUtilityCallAll", Phase::StaticConstructors}, + {"ApplyPatches", Phase::Patches}, + {"ErrorCheckPatches", Phase::Patches}, + {"LoadDefs", Phase::Defs}, + {"ResolveAllWantedCrossReferences.NonImplied", Phase::Defs}, + {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", Phase::Deferred}, + {"LanguageDatabaseInitAllMetadata", Phase::Other}, + {"SomethingNew", Phase::Other}, + }; + for (const Case& c : cases) { + INFO(c.key); + CHECK(metric_phase(kPrefix + QString::fromLatin1(c.key)) == c.phase); + } +} + +TEST_CASE("metric_phase also accepts keys without the prefix") { + CHECK(metric_phase(QStringLiteral("LoadDefs")) == Phase::Defs); +} + +TEST_CASE("phase_totals sums per phase and zero-fills the rest") { + const QMap metrics{ + {kPrefix + QStringLiteral("ApplyPatches"), 1.5}, + {kPrefix + QStringLiteral("LoadPatches"), 0.5}, + {kPrefix + QStringLiteral("LoadDefs"), 2.0}, + }; + const PhaseTotals totals = phase_totals(metrics); + CHECK_THAT(totals[phase_index(Phase::Patches)], WithinAbs(2.0, 1e-9)); + CHECK_THAT(totals[phase_index(Phase::Defs)], WithinAbs(2.0, 1e-9)); + CHECK(totals[phase_index(Phase::Content)] == 0.0); + CHECK(totals.size() == PHASE_COUNT); +} + +TEST_CASE("every phase has a label") { + for (std::size_t i = 0; i < PHASE_COUNT; ++i) { + CHECK_FALSE(phase_label(static_cast(i)).isEmpty()); + } + CHECK(phase_label(Phase::Defs) == QStringLiteral("Defs & XML")); +} + +TEST_CASE("metric_label names a deferred task's target") { + const QString key = + kPrefix + + QStringLiteral("ExecuteToExecuteWhenFinished|RimWorld.AbilityDef -> Void b__88_0()"); + CHECK(metric_label(key) == + QStringLiteral("Running delayed initialization task (RimWorld.AbilityDef)")); +} + +TEST_CASE("metric_label falls back to the last key segment, then the key itself") { + CHECK(metric_label(kPrefix + QStringLiteral("ModContentPackReloadContentInt.Textures")) == + QStringLiteral("Loading textures")); + CHECK(metric_label(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == + QStringLiteral("Loading defs")); + CHECK(metric_label(kPrefix + QStringLiteral("Totally.New")) == QStringLiteral("Totally.New")); + CHECK(metric_label(QStringLiteral("ModConstructor")) == QStringLiteral("Running mod constructor")); +} diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp new file mode 100644 index 0000000..23a3286 --- /dev/null +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -0,0 +1,296 @@ +#include +#include +#include + +#include +#include +#include + +#include "core/services/startup_impact_service/models.h" +#include "core/services/startup_impact_service/parser.h" +#include "../../test_support.h" + +using namespace px::core::startup_impact; +using Catch::Matchers::WithinAbs; +using test_support::write_bytes; + +namespace { + +constexpr double kEps = 1e-9; + +const char* kSampleJson = R"({ + "timestamp": "2026-07-16T14:30:45.123Z", + "loadingTime": 42500.5, + "metrics": { + "LoadModXml": 10200.0, + "ResolveReferences": 1500.0 + }, + "totalImpact": 0.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0, + "mods": [ + { + "modName": "Harmony", + "modPackageId": "brrainz.harmony", + "metrics": { + "ModConstructor": 1000.0 + }, + "totalImpact": 1230.5, + "offThreadMetrics": { + "LoadPatches": 300.0 + }, + "offThreadTotalImpact": 300.0 + }, + { + "modName": "Some Mod", + "modPackageId": "author.somemod", + "metrics": { + "LoadModXml": 4500.0 + }, + "totalImpact": 4500.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + }, + { + "modName": "Fast Mod", + "modPackageId": "author.fast", + "metrics": {}, + "totalImpact": 50.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + }, + { + "modName": "No Package ID Mod", + "totalImpact": 200.0, + "metrics": {}, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + } + ] +} +)"; + +// Writes `json` to a file in `dir` and parses it. +std::optional parse_text(const QTemporaryDir& dir, const QString& name, + const char* json) { + const QString path = QDir(dir.path()).filePath(name); + REQUIRE(write_bytes(path, json)); + return parse_startup_impact(path); +} + +} // namespace + +TEST_CASE("normalize_package_id lowercases and strips the steam suffix") { + CHECK(normalize_package_id(QStringLiteral("Author.Mod")) == QStringLiteral("author.mod")); + CHECK(normalize_package_id(QStringLiteral("author.mod_steam")) == + QStringLiteral("author.mod")); + CHECK(normalize_package_id(QStringLiteral("author.mod")) == QStringLiteral("author.mod")); +} + +TEST_CASE("format_impact rounds seconds to whole milliseconds") { + CHECK(format_impact(1.234) == QStringLiteral("1234ms")); + CHECK(format_impact(0.05) == QStringLiteral("50ms")); + CHECK(format_impact(0.0) == QStringLiteral("0ms")); +} + +TEST_CASE("format_impact rounds half to even like Python's round") { + CHECK(format_impact(0.0005) == QStringLiteral("0ms")); + CHECK(format_impact(0.0015) == QStringLiteral("2ms")); + CHECK(format_impact(0.0025) == QStringLiteral("2ms")); +} + +TEST_CASE("get_startup_impact_path sits beside the config folder") { + CHECK(get_startup_impact_path(QStringLiteral("/home/user/Config")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(get_startup_impact_path(QStringLiteral("/home/user/Config/")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(get_startup_impact_path(QStringLiteral("/Config")) == + QStringLiteral("/StartupImpactData.json")); +} + +TEST_CASE("parse_startup_impact reads the full report") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + CHECK(report->loading_time_s == Catch::Approx(42.5005)); + CHECK(report->timestamp == QStringLiteral("2026-07-16T14:30:45.123Z")); + REQUIRE(report->metrics.size() == 2); + CHECK(report->metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(10.2)); + CHECK(report->metrics.value(QStringLiteral("ResolveReferences")) == Catch::Approx(1.5)); + CHECK(report->total_impact == 0.0); + CHECK(report->mods.size() == 4); +} + +TEST_CASE("base game time is the sum of the base-game metrics") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + // Not loading time minus mod impacts: loading time includes unaccounted time. + CHECK_THAT(report->base_game_time(), WithinAbs(11.7, kEps)); + CHECK(std::abs(report->loading_time_s - report->total_time()) > 1.0); +} + +TEST_CASE("mods_total_time includes off-thread impact") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.total_impact_s == Catch::Approx(1.2305)); + CHECK(harmony.off_thread_total_impact_s == Catch::Approx(0.3)); + // total_time counts on-thread impact only; mods_total_time adds off-thread impact. + CHECK_THAT(report->total_time(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); + CHECK_THAT(report->off_thread_total_time(), WithinAbs(0.3, kEps)); + CHECK_THAT(report->mods_total_time(), WithinAbs(report->total_time() + 0.3, kEps)); +} + +TEST_CASE("parse_startup_impact reads mod values") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.mod_name == QStringLiteral("Harmony")); + CHECK(harmony.package_id == QStringLiteral("brrainz.harmony")); + CHECK(harmony.total_impact_s == Catch::Approx(1.2305)); + CHECK(harmony.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); + + const StartupImpactMod& some_mod = report->mods[1]; + CHECK(some_mod.mod_name == QStringLiteral("Some Mod")); + CHECK(some_mod.package_id == QStringLiteral("author.somemod")); + CHECK(some_mod.total_impact_s == Catch::Approx(4.5)); + CHECK(some_mod.metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(4.5)); + + const StartupImpactMod& fast = report->mods[2]; + CHECK(fast.mod_name == QStringLiteral("Fast Mod")); + CHECK(fast.package_id == QStringLiteral("author.fast")); + CHECK(fast.total_impact_s == Catch::Approx(0.05)); + CHECK(fast.metrics.isEmpty()); +} + +TEST_CASE("a mod without a package id has none after parsing") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& no_pid = report->mods[3]; + CHECK(no_pid.mod_name == QStringLiteral("No Package ID Mod")); + CHECK_FALSE(no_pid.package_id.has_value()); + CHECK(no_pid.total_impact_s == Catch::Approx(0.2)); + CHECK(no_pid.metrics.isEmpty()); +} + +TEST_CASE("parse_startup_impact returns nullopt for a missing file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE(parse_startup_impact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) + .has_value()); +} + +TEST_CASE("parse_startup_impact reads an empty mod list") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("empty_mods.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->loading_time_s == Catch::Approx(1.0)); + CHECK(report->mods.isEmpty()); +} + +TEST_CASE("parse_startup_impact rejects a top-level array") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE(parse_text(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); +} + +TEST_CASE("parse_startup_impact rejects malformed JSON") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE(parse_text(dir, QStringLiteral("bad.json"), "{invalid").has_value()); +} + +TEST_CASE("parse_startup_impact accepts an object without known keys") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("invalid.json"), + R"({"wrongKey": "value"})"); + REQUIRE(report.has_value()); + CHECK(report->mods.isEmpty()); +} + +TEST_CASE("total_time sums on-thread mod impact") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("total.json"), R"({ + "loadingTime": 50000.0, + "mods": [ + {"modName": "A", "modPackageId": "a.a", + "totalImpact": 2000.0, "metrics": {}, + "offThreadMetrics": {}, "offThreadTotalImpact": 0.0}, + {"modName": "B", "modPackageId": "b.b", + "totalImpact": 3000.0, "metrics": {}, + "offThreadMetrics": {}, "offThreadTotalImpact": 0.0} + ] + })"); + REQUIRE(report.has_value()); + CHECK(report->total_time() == Catch::Approx(5.0)); +} + +TEST_CASE("total_time of an empty report is zero") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("total_empty.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->total_time() == 0.0); +} + +TEST_CASE("parse_startup_impact reads off-thread mod fields") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parse_text(dir, QStringLiteral("offthread.json"), R"({ + "loadingTime": 10000.0, + "mods": [ + { + "modName": "Test Mod", + "modPackageId": "author.test", + "totalImpact": 2000.0, + "metrics": {"A": 1000.0, "B": 1000.0}, + "offThreadMetrics": {"C": 500.0}, + "offThreadTotalImpact": 500.0 + } + ] + })"); + REQUIRE(report.has_value()); + const StartupImpactMod& mod = report->mods[0]; + REQUIRE(mod.off_thread_metrics.size() == 1); + CHECK(mod.off_thread_metrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); + CHECK(mod.off_thread_total_impact_s == Catch::Approx(0.5)); +} + +TEST_CASE("find matches by package id, then by mod name") { + StartupImpactReport report; + StartupImpactMod m1; + m1.mod_name = QStringLiteral("Harmony"); + m1.package_id = QStringLiteral("brrainz.harmony"); + report.mods.append(std::move(m1)); + StartupImpactMod m2; + m2.mod_name = QStringLiteral("Other"); + m2.package_id = QStringLiteral("other.mod"); + report.mods.append(std::move(m2)); + + const StartupImpactMod* by_id = report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); + REQUIRE(by_id != nullptr); + CHECK(by_id->mod_name == QStringLiteral("Harmony")); + + const StartupImpactMod* by_name = report.find({}, QStringLiteral("OTHER")); + REQUIRE(by_name != nullptr); + CHECK(by_name->package_id == QStringLiteral("other.mod")); + + CHECK(report.find(QStringLiteral("missing.mod"), QStringLiteral("Missing")) == nullptr); + CHECK(report.find(QString(), QString()) == nullptr); +} diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp new file mode 100644 index 0000000..eb84258 --- /dev/null +++ b/tests/core/services/test_activation_service.cpp @@ -0,0 +1,365 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/models/metadata/structures.h" +#include "core/models/view/sidebar.h" +#include "core/services/activation_service.h" +#include "core/services/diagnostics_service.h" +#include "services_support.h" + +using namespace px::core; + +namespace { + +using Mods = QHash; + +QStringList ids(std::initializer_list items) { + QStringList result; + for (const char* item : items) { + result.append(QString::fromLatin1(item)); + } + return result; +} + +// About.xml mod with the given rules. Each dependency is a bare package id. +ListedModPtr make_mod(const char* name, + const char* pid, + std::initializer_list deps = {}, + std::initializer_list load_after = {}, + std::initializer_list load_before = {}) { + auto mod = std::make_shared(); + mod->name = QString::fromLatin1(name); + mod->package_id = CaseInsensitiveStr(QString::fromLatin1(pid)); + mod->provider_id = QStringLiteral("stub"); + mod->valid = true; + mod->about_rules.load_after = CaseInsensitiveSet(ids(load_after)); + mod->about_rules.load_before = CaseInsensitiveSet(ids(load_before)); + for (const char* dep : deps) { + DependencyMod dependency; + dependency.name = QString::fromLatin1(dep); + dependency.package_id = CaseInsensitiveStr(QString::fromLatin1(dep)); + mod->about_rules.set_dependency(dependency); + } + return mod; +} + +Mods base_mods() { + Mods mods; + mods.insert("uuid-a", make_mod("Mod A", "mod.a")); + mods.insert("uuid-b", make_mod("Mod B", "mod.b", {"mod.a"})); + mods.insert("uuid-c", make_mod("Mod C", "mod.c", {"mod.b"})); + mods.insert("uuid-d", make_mod("Mod D", "mod.d")); + mods.insert("uuid-e", make_mod("Mod E", "mod.e", {"mod.a"})); + mods.insert("uuid-f", make_mod("Mod F", "mod.f")); + mods.insert("uuid-g", make_mod("Mod G", "mod.g", {"mod.f"})); + mods.insert("uuid-h", make_mod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); + mods.insert("uuid-i", make_mod("Mod I", "mod.i", {}, {"mod.d"})); + mods.insert("uuid-j", make_mod("Mod J", "mod.j", {}, {}, {"mod.a"})); + return mods; +} + +// Owns the config directory and the context for one test. Starts with the base mods loaded as +// c, d, a, b and the diagnostics rebuilt. +struct Fixture { + services_test::EventLoop loop; + QTemporaryDir dir; + ConfigService config_service{dir.path()}; + std::unique_ptr ctx = CoreContext::create(AppConfig{}, &config_service); + + Fixture() { + ctx->load(base_mods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + ctx->diagnosticsService().rebuild(); + } + + void reload(Mods mods, std::initializer_list active) { + ctx->load(std::move(mods), ids(active)); + ctx->diagnosticsService().rebuild(); + } +}; + +} // namespace + +TEST_CASE("enabling inserts after dependency without reordering existing mods") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { + emitted.push_back(uuids); + }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}))); + + const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("unconstrained enable appends") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("enabling mod with load-after rule inserts after target") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-i"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-i", "uuid-a", "uuid-b"})); +} + +TEST_CASE("enabling mod with load-before rule inserts before target") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-j"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-j", "uuid-a", "uuid-b"})); +} + +TEST_CASE("enabling dependency before active dependent") { + Fixture f; + f.reload(base_mods(), {"uuid-b", "uuid-c", "uuid-d"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-a"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c", "uuid-d"})); +} + +TEST_CASE("conflicting constraints fall back to appending") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-h"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-h"})); +} + +TEST_CASE("batch enables respect dependency order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-g", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f", "uuid-g"})); +} + +TEST_CASE("enabling already active mod keeps its position") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-c", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("disabling removes and keeps remaining order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-d", "uuid-a"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-b"})); +} + +TEST_CASE("unknown ids are ignored") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-missing", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("mixed selection applies in a single emission") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { + emitted.push_back(uuids); + }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}), ids({"uuid-a", "uuid-d"}))); + + const QStringList expected = ids({"uuid-c", "uuid-b", "uuid-f", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("uuid in both enable and disable favors disable") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { + emitted.push_back(uuids); + }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-a", "uuid-f", "uuid-e"}), + ids({"uuid-a", "uuid-f"}))); + + const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-b", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("duplicate enable is deduped in input order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"})); +} + +TEST_CASE("no-op returns false without emitting") { + const std::vector> cases{ + {ids({"uuid-a", "uuid-c"}), {}}, + {{}, ids({"uuid-e", "uuid-f"})}, + {ids({"uuid-missing"}), {}}, + {{}, ids({"uuid-missing"})}, + {{}, {}}, + {ids({"uuid-missing"}), ids({"uuid-missing"})}, + }; + for (const auto& [enable, disable] : cases) { + Fixture f; + int emissions = 0; + f.ctx->activeStateChanged().connect([&emissions](const QStringList&) { ++emissions; }); + + CHECK_FALSE(f.ctx->activation().apply(enable, disable)); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + CHECK(emissions == 0); + } +} + +TEST_CASE("edges through an uninstalled mod add no constraint") { + Fixture f; + Mods mods; + mods.insert("uuid-a", make_mod("Mod A", "mod.a")); + mods.insert("uuid-w", make_mod("Mod W", "mod.w", {}, {"ce.absent"})); + mods.insert("uuid-y", make_mod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); + f.reload(mods, {"uuid-a", "uuid-w"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-y"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-w", "uuid-y"})); +} + +TEST_CASE("bounds ignore an inconsistent active order") { + Fixture f; + Mods mods = base_mods(); + // Active c depends on b but sits before it; X must still land in d..b. + mods.insert("uuid-x", make_mod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-x"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"})); +} + +TEST_CASE("batch cycle does not unplace downstream mods") { + Fixture f; + Mods mods = base_mods(); + mods.insert("uuid-p", make_mod("Mod P", "mod.p", {"mod.q"})); + mods.insert("uuid-q", make_mod("Mod Q", "mod.q", {"mod.p"})); + mods.insert("uuid-z", make_mod("Mod Z", "mod.z", {"mod.p"})); + mods.insert("uuid-k", make_mod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-z", "uuid-k", "uuid-q", "uuid-p"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-k", "uuid-a", "uuid-b", + "uuid-q", "uuid-p", "uuid-z"})); +} + +TEST_CASE("dependents_of lists transitive dependents in active order") { + Fixture f; + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-c", "uuid-b"})); +} + +TEST_CASE("dependents_of excludes its inputs") { + Fixture f; + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a", "uuid-b"})) == ids({"uuid-c"})); +} + +TEST_CASE("dependents_of excludes inactive dependents") { + Fixture f; + + CHECK_FALSE(f.ctx->activeUuids().contains(QStringLiteral("uuid-e"))); + CHECK_FALSE(f.ctx->activation().dependents_of(ids({"uuid-a"})).contains(QStringLiteral("uuid-e"))); +} + +TEST_CASE("dependents_of reflects the graph after a state change") { + Fixture f; + REQUIRE(f.ctx->activation().apply(ids({"uuid-e"}))); + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-c", "uuid-e", "uuid-b"})); +} + +TEST_CASE("dependents_of is empty for a mod without dependents") { + Fixture f; + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-d", "uuid-missing"})).isEmpty()); +} + +TEST_CASE("removing a duplicate copy keeps dependents") { + Fixture f; + Mods mods = base_mods(); + mods.insert("uuid-a-copy", make_mod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a-copy"})).isEmpty()); + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-a-copy"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c"})); + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-b", "uuid-c"})); +} + +TEST_CASE("enabling a replacement copy keeps dependents") { + Fixture f; + Mods mods = base_mods(); + mods.insert("uuid-a-copy", make_mod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"}), ids({"uuid-a-copy"})).isEmpty()); + REQUIRE(f.ctx->activation().apply(ids({"uuid-a-copy"}), ids({"uuid-a"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a-copy", "uuid-b", "uuid-c"})); +} + +TEST_CASE("sidebar entries are published before the summary") { + // The summary handler re-applies the sidebar filter, so entries must be fresh. + Fixture f; + DiagnosticsService& diagnostics = f.ctx->diagnosticsService(); + QSet inactive; + std::vector> seen_at_summary; + + diagnostics.sidebar_entries_changed.connect([&inactive](const QList& entries) { + inactive.clear(); + for (const view::SidebarEntry& entry : entries) { + if (entry.label == QStringLiteral("Inactive")) { + inactive = entry.visible_uuids; + } + } + }); + diagnostics.diagnostics_summary_changed.connect( + [&inactive, &seen_at_summary](const QHash&) { + seen_at_summary.push_back(inactive); + }); + + diagnostics.rebuild(f.ctx->activeUuids()); + seen_at_summary.clear(); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + + REQUIRE_FALSE(seen_at_summary.empty()); + CHECK_FALSE(seen_at_summary.back().contains(QStringLiteral("uuid-f"))); +} diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp new file mode 100644 index 0000000..4628308 --- /dev/null +++ b/tests/core/services/test_game_launcher.cpp @@ -0,0 +1,272 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/constants.h" +#include "core/context.h" +#include "core/services/game_launcher.h" +#include "core/services/game_process.h" +#include "services_support.h" + +using px::core::AppConfig; +using px::core::CoreContext; +using px::core::LaunchStrategy; +using px::core::PathConfig; +using px::core::Result; +using px::core::services::build_direct_command; +using px::core::services::find_executable; +using px::core::services::GameLauncher; +using px::core::services::GameProcessMonitor; +using px::core::services::is_game_running; +using px::core::services::MonitorTiming; +using px::core::services::Platform; +using px::core::services::ProcessInfo; +using px::core::services::ProcessLister; +using px::core::services::split_arguments; +using px::core::services::steam_run_url; +using services_test::EventLoop; +using services_test::run_async; + +namespace { + +ProcessInfo proc(const QString& name, qint64 pid = 1, bool zombie = false) { + return ProcessInfo{pid, name, zombie}; +} + +struct Replay { + std::vector> snapshots; + std::size_t next = 0; +}; + +// Lister that returns the snapshots in order, then keeps returning the last one. +ProcessLister replay(std::vector> snapshots) { + auto state = std::make_shared(Replay{std::move(snapshots), 0}); + return [state] { + const std::size_t last = state->snapshots.size() - 1; + const auto snapshot = state->snapshots[std::min(state->next, last)]; + if (state->next < last) { + ++state->next; + } + return snapshot; + }; +} + +const MonitorTiming kNoSleep{std::chrono::milliseconds(0), std::chrono::milliseconds(0)}; + +QCoro::Task capture(GameProcessMonitor& monitor) { + co_await monitor.capture_baseline(); + co_return true; +} + +void touch(const QString& path) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); +} + +void make_executable(const QString& path) { + touch(path); + REQUIRE(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); +} + +std::unique_ptr context_for(const QString& game_path) { + static QTemporaryDir configDir; + static px::core::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = game_path.toStdString(); + return CoreContext::create(cfg, &configService); +} + +} // namespace + +TEST_CASE("command without wrapper is the executable followed by the arguments") { + auto command = build_direct_command(QStringLiteral("/g/RimWorldLinux"), {}, + QStringLiteral("-popupwindow -logfile"), Platform::Linux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"/g/RimWorldLinux", "-popupwindow", "-logfile"}); + CHECK(command->env.isEmpty()); +} + +TEST_CASE("wrapper without placeholder is a prefix") { + auto command = build_direct_command(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), + QStringLiteral("-x"), Platform::Linux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"gamemoderun", "/g/rw", "-x"}); +} + +TEST_CASE("placeholder marks the game position and leading assignments become environment") { + auto command = build_direct_command( + QStringLiteral("/g/rw"), QStringLiteral("MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail"), + QStringLiteral("-x"), Platform::Linux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"mangohud", "/g/rw", "-x", "--tail"}); + CHECK(command->env.value(QStringLiteral("MANGOHUD")) == QStringLiteral("1")); + CHECK(command->env.value(QStringLiteral("DXVK_HUD")) == QStringLiteral("fps")); +} + +TEST_CASE("quoted POSIX arguments stay one token") { + auto command = build_direct_command(QStringLiteral("/g/rw"), {}, + QStringLiteral(R"(-savedatafolder="/tmp/my saves")"), + Platform::Linux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"/g/rw", "-savedatafolder=/tmp/my saves"}); +} + +TEST_CASE("an option containing '=' is not an environment assignment") { + auto command = build_direct_command(QStringLiteral("/g/rw"), QStringLiteral("--cfg=/p wrap"), {}, + Platform::Linux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"--cfg=/p", "wrap", "/g/rw"}); + CHECK(command->env.isEmpty()); +} + +TEST_CASE("unbalanced quotes are rejected") { + CHECK_FALSE(build_direct_command(QStringLiteral("/g/rw"), {}, QStringLiteral("\"oops"), + Platform::Linux) + .has_value()); +} + +TEST_CASE("Windows splitting keeps quotes in the token, as shlex with posix=False does") { + auto tokens = split_arguments(QStringLiteral(R"(-a "b c" d)"), Platform::Windows); + REQUIRE(tokens.has_value()); + CHECK(*tokens == QStringList{"-a", R"("b c")", "d"}); +} + +TEST_CASE("executable lookup prefers the current binary over the legacy one") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + make_executable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + make_executable(QDir(root).filePath(QStringLiteral("RimWorld"))); + auto found = find_executable(root, Platform::Linux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorld"))); +} + +TEST_CASE("executable lookup skips a non-executable binary") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorld"))); + make_executable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + REQUIRE(QFile::setPermissions(QDir(root).filePath(QStringLiteral("RimWorld")), + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + auto found = find_executable(root, Platform::Linux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorldLinux"))); +} + +TEST_CASE("executable lookup finds the Windows and macOS layouts") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + auto windows = find_executable(root, Platform::Windows); + REQUIRE(windows.has_value()); + CHECK(*windows == QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + + REQUIRE(QDir(root).mkpath(QStringLiteral("RimWorldMac.app"))); + auto mac = find_executable(root, Platform::MacOS); + REQUIRE(mac.has_value()); + CHECK(*mac == QDir(root).filePath(QStringLiteral("RimWorldMac.app"))); +} + +TEST_CASE("steam URL is the app id alone without launch arguments") { + CHECK(steam_run_url(QString()) == QStringLiteral("steam://rungameid/294100")); + CHECK(steam_run_url(QStringLiteral(" ")) == QStringLiteral("steam://rungameid/294100")); +} + +TEST_CASE("steam URL percent-encodes launch arguments") { + CHECK(steam_run_url(QStringLiteral("-savedatafolder=/a b")) == + QStringLiteral("steam://run/294100//-savedatafolder%3D%2Fa%20b/")); +} + +TEST_CASE("legacy and current game process names are recognized") { + for (const char* name : {"RimWorld", "RimWorld.exe", "RimWorldLinux", "RimWorldWin64.exe", + "RimWorldWin.exe", "RimWorld by Ludeon Studios"}) { + const auto lister = replay({{proc(QString::fromLatin1(name)), proc("bash", 2)}}); + CHECK(is_game_running(lister)); + } +} + +TEST_CASE("zombie game process is not running") { + const auto lister = replay({{proc("RimWorldLinux", 1, true), proc("bash", 2)}}); + CHECK_FALSE(is_game_running(lister)); +} + +TEST_CASE("wait_for_exit returns once the game processes disappear") { + EventLoop loop; + // The first snapshot is the baseline, which is empty; then the game runs, then exits. + GameProcessMonitor monitor(replay({{}, {proc("RimWorldLinux")}, {proc("RimWorldLinux")}, {}}), + kNoSleep); + REQUIRE(run_async(capture(monitor))); + CHECK(run_async(monitor.wait_for_exit()) == std::nullopt); +} + +TEST_CASE("processes running before the launch are not counted as started") { + EventLoop loop; + // Baseline holds PID 10; the first poll still sees only PID 10, the second sees PID 20 too. + GameProcessMonitor monitor(replay({{proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10), proc("RimWorldLinux", 20)}}), + kNoSleep); + REQUIRE(run_async(capture(monitor))); + CHECK(run_async(monitor.wait_for_start(std::chrono::milliseconds(5000)))); +} + +TEST_CASE("wait_for_start is false when the spawned process died before the game appeared") { + EventLoop loop; + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attach_process(std::make_unique()); // never started: state NotRunning + CHECK_FALSE(run_async(monitor.wait_for_start(std::chrono::milliseconds(5000)))); +} + +#if defined(Q_OS_UNIX) +TEST_CASE("wait_for_exit reports the spawned process's exit code") { + EventLoop loop; + auto spawned = std::make_unique(); + spawned->start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), QStringLiteral("exit 7")}); + REQUIRE(spawned->waitForFinished(10'000)); + + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attach_process(std::move(spawned)); + CHECK(run_async(monitor.wait_for_exit()) == std::optional(7)); +} +#endif + +TEST_CASE("launch without a game path fails before anything runs") { + EventLoop loop; + auto ctx = context_for({}); + GameLauncher launcher(*ctx); + const Result result = run_async(launcher.launch(LaunchStrategy::Direct)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game path not configured")); +} + +TEST_CASE("direct launch without an executable fails") { + EventLoop loop; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + auto ctx = context_for(dir.path()); + GameLauncher launcher(*ctx); + const Result result = run_async(launcher.launch(LaunchStrategy::Direct)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game executable not found")); +} diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp new file mode 100644 index 0000000..445b15c --- /dev/null +++ b/tests/core/services/test_metadata_cache.cpp @@ -0,0 +1,332 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/parsing.h" +#include "core/providers/local.h" +#include "core/services/metadata_cache.h" +#include "services_support.h" + +using namespace px::core; +using namespace px::core::providers; +using namespace px::core::services; +using services_test::EventLoop; +using services_test::run_async; +using services_test::write_text; + +namespace { + +constexpr auto kAboutV1 = R"( + + Sample Mod One + TestAuthor + Author.SampleModOne + +
  • 1.4
  • +
  • 1.5
  • +
    + Initial description + 1.0.0 + https://example.com/mod + UI/Icon.png + +
  • + ludeon.rimworld + RimWorld +
  • +
    +
    +)"; + +// Same length as kAboutV1, so only the mtime tells the two apart. +constexpr auto kAboutSameSize = R"( + + Sample Mod Two + TestAuthor + Author.SampleModOne + +
  • 1.4
  • +
  • 1.5
  • +
    + Initial description + 1.0.0 + https://example.com/mod + UI/Icon.png + +
  • + ludeon.rimworld + RimWorld +
  • +
    +
    +)"; + +constexpr auto kAboutTwo = R"( + + Sample Mod Two + OtherAuthor + Author.SampleModTwo + +
  • 1.5
  • +
    + Second mod description +
    +)"; + +// Writes About/About.xml (and optionally About/PublishedFileId.txt) under `modDir`. +QString create_mod(const QString& modDir, const char* xml, bool withPublishedFileId = false) { + REQUIRE(QDir().mkpath(QDir(modDir).filePath(QStringLiteral("About")))); + REQUIRE(write_text(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); + if (withPublishedFileId) { + REQUIRE(write_text(QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), + "123456")); + } + return modDir; +} + +// Stat data the way the providers take it: About.xml mtime in seconds, and its size. +ModCandidate candidate_for(const QString& modDir) { + const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const QFileInfo info(about); + return ModCandidate{modDir, about, info.lastModified().toMSecsSinceEpoch() / 1000.0, + info.size()}; +} + +// Parses the mod exactly as the providers do, so cache entries match real ones. +std::shared_ptr parse_about(const QString& modDir) { + auto listed = createListedModFromPath(modDir, QStringLiteral("1.5"), true); + REQUIRE(listed.has_value()); + auto about = std::dynamic_pointer_cast(*listed); + REQUIRE(about != nullptr); + return about; +} + +// Moves About.xml's mtime, keeping the file's contents. +void set_about_mtime(const ModCandidate& candidate, double mtimeSeconds) { + QFile file(candidate.aboutXmlPath); + REQUIRE(file.open(QIODevice::ReadWrite)); + REQUIRE(file.setFileTime(QDateTime::fromMSecsSinceEpoch(std::llround(mtimeSeconds * 1000)), + QFileDevice::FileModificationTime)); +} + +// Reads mod_path values with an independent connection, to check what the cache stored. +QStringList stored_mod_paths(const QString& dbPath) { + const QString name = QStringLiteral("metadata-cache-test-reader"); + QStringList paths; + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(dbPath); + REQUIRE(db.open()); + QSqlQuery query(db); + REQUIRE(query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata ORDER BY mod_path"))); + while (query.next()) { + paths.append(query.value(0).toString()); + } + } + QSqlDatabase::removeDatabase(name); + return paths; +} + +} // namespace + +TEST_CASE("cache returns the parsed fields of a stored mod") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidate_for(modDir); + const QString dbPath = metadata_cache_path(root.path()); + MetadataCache cache(dbPath); + + run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); + const CacheLookup lookup = run_async(cache.get_mods({candidate}, QStringLiteral("1.5"))); + + REQUIRE(lookup.cached.size() == 1); + REQUIRE(lookup.missing.isEmpty()); + const auto mod = std::dynamic_pointer_cast(lookup.cached[0]); + REQUIRE(mod != nullptr); + CHECK(mod->name == QStringLiteral("Sample Mod One")); + CHECK(mod->package_id.str() == QStringLiteral("author.samplemodone")); + CHECK(mod->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(mod->supported_versions == QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(mod->description == QStringLiteral("Initial description")); + CHECK(mod->mod_version == QStringLiteral("1.0.0")); + CHECK(mod->url == QStringLiteral("https://example.com/mod")); + REQUIRE(mod->mod_icon_path.has_value()); + CHECK(*mod->mod_icon_path == QStringLiteral("UI/Icon.png")); + CHECK(std::abs(mod->mtime - candidate.mtime) < 1e-3); + CHECK(mod->about_rules.dependencies.size() == 1); + CHECK(mod->about_rules.has_dependency(CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); + REQUIRE(mod->mod_path().has_value()); + CHECK(*mod->mod_path() == modDir); +} + +TEST_CASE("cache entries are misses when the file size, mtime or target version changes") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidate_for(modDir); + MetadataCache cache(metadata_cache_path(root.path())); + run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); + + ModCandidate resized = candidate; + resized.size += 1; + ModCandidate touched = candidate; + touched.mtime += 5.0; + + for (const ModCandidate& stale : {resized, touched}) { + const CacheLookup lookup = run_async(cache.get_mods({stale}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); + CHECK(lookup.missing.size() == 1); + } + const CacheLookup otherVersion = run_async(cache.get_mods({candidate}, QStringLiteral("1.4"))); + CHECK(otherVersion.cached.isEmpty()); + CHECK(otherVersion.missing.size() == 1); +} + +TEST_CASE("pruning removes entries for mods that disappeared") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modA = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const QString modB = create_mod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); + const QString dbPath = metadata_cache_path(root.path()); + MetadataCache cache(dbPath); + run_async(cache.put_mods({CachedMod{parse_about(modA), candidate_for(modA)}, + CachedMod{parse_about(modB), candidate_for(modB)}}, + QStringLiteral("1.5"))); + QStringList expectedPaths{normalize_path(modA), normalize_path(modB)}; + expectedPaths.sort(); + REQUIRE(stored_mod_paths(dbPath) == expectedPaths); + + run_async(cache.prune_disappeared(QSet{normalize_path(modA)})); + + CHECK(stored_mod_paths(dbPath) == QStringList{normalize_path(modA)}); + const CacheLookup lookup = run_async(cache.get_mods({candidate_for(modB)}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); +} + +TEST_CASE("corrupt cache database is recovered and keeps working") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidate_for(modDir); + const QString dbPath = metadata_cache_path(root.path()); + { + MetadataCache cache(dbPath); + run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); + run_async(cache.close()); + } + QByteArray garbage; + for (int i = 0; i < 50; ++i) { + garbage += "corrupt non-sqlite data"; + } + REQUIRE(write_text(dbPath, garbage)); + + MetadataCache recovered(dbPath); + const CacheLookup empty = run_async(recovered.get_mods({candidate}, QStringLiteral("1.5"))); + CHECK(empty.cached.isEmpty()); + REQUIRE(empty.missing.size() == 1); + + run_async(recovered.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); + const CacheLookup warm = run_async(recovered.get_mods({candidate}, QStringLiteral("1.5"))); + CHECK(warm.cached.size() == 1); +} + +TEST_CASE("providers reuse cached metadata and reparse when the file changes") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modsRoot = QDir(root.path()).filePath(QStringLiteral("mods")); + const QString modDir = create_mod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); + const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const ModCandidate original = candidate_for(modDir); + MetadataCache cache(metadata_cache_path(root.path())); + LocalModProvider provider(modsRoot, nullptr, &cache); + + auto first = run_async(provider.discover(QStringLiteral("1.5"))); + REQUIRE(first.has_value()); + REQUIRE(first->contains(modDir)); + CHECK((*first)[modDir]->name == QStringLiteral("Sample Mod One")); + + // Same size and mtime, different text: the cache still answers, a forced reparse does not. + REQUIRE(write_text(about, kAboutSameSize)); + set_about_mtime(original, original.mtime); + auto cached = run_async(provider.discover(QStringLiteral("1.5"))); + REQUIRE(cached.has_value()); + CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); + auto forced = run_async(provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); + REQUIRE(forced.has_value()); + CHECK((*forced)[modDir]->name == QStringLiteral("Sample Mod Two")); + + // A new mtime invalidates the entry even though the cache is consulted. + set_about_mtime(original, original.mtime + 5.0); + auto changed = run_async(provider.discover(QStringLiteral("1.5"))); + REQUIRE(changed.has_value()); + CHECK((*changed)[modDir]->name == QStringLiteral("Sample Mod Two")); + // mtime lives on AboutXmlMod; ListedMod has no such field. + const auto changedAbout = std::dynamic_pointer_cast((*changed)[modDir]); + REQUIRE(changedAbout != nullptr); + CHECK(std::abs(changedAbout->mtime - (original.mtime + 5.0)) < 1e-3); +} + +TEST_CASE("providers keep their identity when they share one cache") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString localRoot = QDir(root.path()).filePath(QStringLiteral("local")); + const QString downloadedRoot = QDir(root.path()).filePath(QStringLiteral("downloaded")); + const QString localMod = create_mod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); + const QString steamMod = create_mod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), kAboutTwo, true); + MetadataCache cache(metadata_cache_path(root.path())); + LocalModProvider local(localRoot, nullptr, &cache); + DownloadedModProvider downloaded(downloadedRoot, nullptr, &cache); + + for (int pass = 0; pass < 2; ++pass) { + auto localMods = run_async(local.discover(QStringLiteral("1.5"))); + auto steamMods = run_async(downloaded.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(steamMods.has_value()); + REQUIRE(localMods->contains(localMod)); + REQUIRE(steamMods->contains(steamMod)); + CHECK((*localMods)[localMod]->provider_id == QStringLiteral("local")); + CHECK((*steamMods)[steamMod]->provider_id == QStringLiteral("downloaded")); + } +} + +TEST_CASE("normalize_paths matches normalize_path, including symlinks") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString real = QDir(root.path()).filePath(QStringLiteral("real")); + const QString other = QDir(root.path()).filePath(QStringLiteral("other")); + REQUIRE(QDir().mkpath(QDir(real).filePath(QStringLiteral("mod")))); + REQUIRE(QDir().mkpath(other)); + REQUIRE(QFile::link(real, QDir(root.path()).filePath(QStringLiteral("linkdir")))); + REQUIRE(QFile::link(other, QDir(real).filePath(QStringLiteral("linkmod")))); + + const QString linkdir = QDir(root.path()).filePath(QStringLiteral("linkdir")); + const QStringList paths{ + QDir(real).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("linkmod")), + QDir(root.path()).filePath(QStringLiteral("linkdir/../real/mod")), + QDir(root.path()).filePath(QStringLiteral("missing/x")), + QStringLiteral("relative/mod"), + }; + + QStringList expected; + for (const QString& path : paths) { + expected.append(normalize_path(path)); + } + CHECK(normalize_paths(paths) == expected); + CHECK(normalize_path(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == normalize_path(other)); +} diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp new file mode 100644 index 0000000..14d7564 --- /dev/null +++ b/tests/core/services/test_sort_service.cpp @@ -0,0 +1,196 @@ +#include + +#include + +#include +#include +#include +#include + +#include "core/services/sort_service.h" + +using px::core::AboutXmlMod; +using px::core::CaseInsensitiveStr; +using px::core::DependencyMod; +using px::core::ListedMod; +using px::core::ListedModPtr; +using px::core::SortService; +using px::core::sort::PackageId; + +namespace { + +std::shared_ptr about(const QString& name, const QString& package_id) { + auto mod = std::make_shared(); + mod->name = name; + mod->package_id = CaseInsensitiveStr(package_id); + mod->provider_id = QStringLiteral("stub"); + mod->valid = true; + return mod; +} + +// `deps` maps a dependency package id to its alternative package ids. +std::shared_ptr about_with_deps(const QString& name, const QString& package_id, + const QHash& deps) { + auto mod = about(name, package_id); + for (auto it = deps.cbegin(); it != deps.cend(); ++it) { + DependencyMod dep; + dep.name = QStringLiteral("Dep of %1").arg(name); + dep.package_id = CaseInsensitiveStr(it.key()); + for (const auto& alt : it.value()) { + dep.alternative_package_ids.add(alt); + } + mod->about_rules.dependencies.append(std::move(dep)); + } + return mod; +} + +ListedModPtr listed(const QString& name) { + auto mod = std::make_shared(); + mod->name = name; + mod->provider_id = QStringLiteral("stub"); + return mod; +} + +QHash mods(std::initializer_list> entries) { + QHash result; + for (const auto& [uuid, mod] : entries) { + result.insert(uuid, mod); + } + return result; +} + +} // namespace + +TEST_CASE("duplicate inactive copy does not replace the active uuid") { + const auto all = mods({ + {QStringLiteral("uuid-active"), about("Active Copy", "shared.package")}, + {QStringLiteral("uuid-inactive"), about("Inactive Copy", "shared.package")}, + }); + + const auto ordered = SortService::order_uuids({PackageId(QStringLiteral("shared.package"))}, all, + {QStringLiteral("uuid-active")}); + + CHECK(ordered == QStringList{QStringLiteral("uuid-active")}); +} + +TEST_CASE("order_uuids follows the sorted package ids and drops unknown ones") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + const auto ordered = SortService::order_uuids( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("ghost")), + PackageId(QStringLiteral("mod.a"))}, + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); + + CHECK(ordered == QStringList{QStringLiteral("uuid-b"), QStringLiteral("uuid-a")}); +} + +TEST_CASE("order_uuids skips active mods that are not about.xml mods") { + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); + + const auto ordered = SortService::order_uuids( + {PackageId(QStringLiteral("mod.a"))}, all, + {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}); + + CHECK(ordered == QStringList{QStringLiteral("uuid-a")}); +} + +TEST_CASE("missing dependencies: no dependencies yields nothing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} + +TEST_CASE("missing dependency that exists as an inactive mod is enabled") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missing_dependency_uuids(all, {QStringLiteral("uuid-a")}) == + QStringList{QStringLiteral("uuid-b")}); +} + +TEST_CASE("missing dependency that is not among the known mods is ignored") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + }); + + CHECK(SortService::missing_dependency_uuids(all, {QStringLiteral("uuid-a")}).isEmpty()); +} + +TEST_CASE("missing dependency that is already active is not enabled again") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} + +TEST_CASE("dependency satisfied by an active alternative package id is not missing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + about_with_deps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-c")}) + .isEmpty()); +} + +TEST_CASE("non about.xml mods in the active set contribute no dependencies") { + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}) + .isEmpty()); +} + +TEST_CASE("a dependency shared by two active mods is listed once") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.c", {}}})}, + {QStringLiteral("uuid-b"), about_with_deps("Mod B", "mod.b", {{"mod.c", {}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) == + QStringList{QStringLiteral("uuid-c")}); +} + +TEST_CASE("an empty active set needs nothing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missing_dependency_uuids(all, {}).isEmpty()); +} + +TEST_CASE("a mod without dependencies adds nothing to the result") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about_with_deps("Mod B", "mod.b", {{"mod.a", {}}})}, + }); + + CHECK(SortService::missing_dependency_uuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp new file mode 100644 index 0000000..a10350f --- /dev/null +++ b/tests/core/services/test_update_service.cpp @@ -0,0 +1,188 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/services/update_service.h" +#include "services_support.h" + +using namespace px::core; +using namespace px::core::services; +using services_test::EventLoop; +using services_test::run_async; + +namespace { + +// Minimal local HTTP server serving a single canned response per connection. +class CannedHttpServer : public QObject { +public: + CannedHttpServer(int statusCode, QByteArray body, QByteArray contentType = "application/json") + : statusCode_(statusCode), body_(std::move(body)), contentType_(std::move(contentType)) { + connect(&server_, &QTcpServer::newConnection, this, &CannedHttpServer::onNewConnection); + REQUIRE(server_.listen(QHostAddress::LocalHost, 0)); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/latest").arg(server_.serverPort()); + } + +private: + void onNewConnection() { + while (QTcpSocket* socket = server_.nextPendingConnection()) { + connect(socket, &QTcpSocket::readyRead, this, [this, socket] { + if (socket->canReadLine()) { + // Drain the headers. + while (!socket->readLine().trimmed().isEmpty()) { + } + const QByteArray header = + QByteArray("HTTP/1.1 ") + QByteArray::number(statusCode_) + " OK\r\n" + + "Content-Type: " + contentType_ + "\r\n" + + "Content-Length: " + QByteArray::number(body_.size()) + "\r\n" + + "Connection: close\r\n\r\n"; + socket->write(header + body_); + socket->disconnectFromHost(); + } + }); + } + } + + QTcpServer server_; + int statusCode_; + QByteArray body_; + QByteArray contentType_; +}; + +// Returns a port on localhost that is not currently listening. +quint16 closed_port() { + QTcpServer server; + REQUIRE(server.listen(QHostAddress::LocalHost, 0)); + const quint16 port = server.serverPort(); + server.close(); + return port; +} + +constexpr auto kReleaseJson = R"({ + "tag_name": "v0.2.0", + "html_url": "https://example.test/r/v0.2.0", + "name": "Second", + "body": "notes" +})"; + +Result> check_payload(const QByteArray& payload, int status = 200, + const QString& current = QStringLiteral("0.1.0")) { + EventLoop loop; + QNetworkAccessManager network; + CannedHttpServer server(status, payload); + UpdateService service(current, network, server.url()); + return run_async(service.check()); +} + +} // namespace + +TEST_CASE("is_newer compares versions with padding, pre-release suffixes and dev versions") { + struct Case { + const char* tag; + const char* current; + bool expected; + }; + const std::vector cases = { + {"v0.2.0", "0.1.0", true}, + {"v0.1.0", "0.1.0", false}, + {"v0.1.0", "0.2.0", false}, + {"v0.10.0", "0.9.0", true}, + {"v1.0", "1.0.0", false}, + {"v1.0.1", "1.0", true}, + {"v0.2.0-beta.1", "0.1.0", true}, + {"nightly", "0.1.0", false}, + {"v0.2.0", "unknown", false}, + {"v0.2.0", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0", false}, + {"v0.2.0-rc2", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0-rc1", false}, + {"v0.2.0-rc1", "0.1.9", true}, + {"v0.1.9", "0.2.0-rc1", false}, + {"v0.2.0+build5", "0.2.0", false}, + }; + for (const auto& [tag, current, expected] : cases) { + CAPTURE(tag, current); + CHECK(is_newer(QString::fromLatin1(tag), QString::fromLatin1(current)) == expected); + } +} + +TEST_CASE("parse_version extracts leading numeric parts and rejects non-numeric tags") { + CHECK(parse_version(QStringLiteral("latest")) == std::nullopt); + const auto parsed = parse_version(QStringLiteral(" v1.2.3 ")); + REQUIRE(parsed.has_value()); + CHECK(*parsed == std::vector{1, 2, 3}); +} + +TEST_CASE("UpdateService returns a newer release") { + const auto result = check_payload(kReleaseJson); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + const ReleaseInfo& release = **result; + CHECK(release.tag == QStringLiteral("v0.2.0")); + CHECK(release.name == QStringLiteral("Second")); + CHECK(release.notes == QStringLiteral("notes")); + CHECK(release.url == QStringLiteral("https://example.test/r/v0.2.0")); +} + +TEST_CASE("UpdateService returns nullopt when the latest release is not newer") { + const auto result = check_payload(kReleaseJson, 200, QStringLiteral("0.2.0")); + REQUIRE(result.has_value()); + CHECK(!result->has_value()); +} + +TEST_CASE("UpdateService offers a plain release over its own rc") { + const auto result = check_payload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + CHECK((**result).tag == QStringLiteral("v0.2.0")); +} + +TEST_CASE("UpdateService wraps HTTP status errors") { + const auto result = check_payload("{}", 500); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService wraps transport failures") { + EventLoop loop; + QNetworkAccessManager network; + const QString deadUrl = QStringLiteral("http://127.0.0.1:%1/latest").arg(closed_port()); + UpdateService service(QStringLiteral("0.1.0"), network, deadUrl); + const auto result = run_async(service.check()); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService wraps non-JSON bodies") { + const auto result = check_payload("", 200); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService rejects non-object JSON payloads") { + for (const QByteArray& payload : {"[]", "null", "\"tag\"", "3"}) { + CAPTURE(payload); + const auto result = check_payload(payload); + CHECK(!result.has_value()); + } +} + +TEST_CASE("UpdateService rejects responses lacking tag_name or html_url") { + for (const QByteArray& payload : { + QByteArray("{}"), + QByteArray(R"({"tag_name": "v0.2.0"})"), + QByteArray(R"({"html_url": "https://example.test/r"})"), + QByteArray(R"({"tag_name": "", "html_url": "https://example.test/r"})"), + }) { + CAPTURE(payload); + const auto result = check_payload(payload); + CHECK(!result.has_value()); + } +} diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp new file mode 100644 index 0000000..8df3017 --- /dev/null +++ b/tests/core/sort/test_community.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +#include "core/sort/community.h" +#include "../test_support.h" + +using px::core::sort::load_community_rules; +using px::core::sort::PackageId; + +TEST_CASE("a missing community rules file means no rules") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const auto rules = load_community_rules(dir.filePath(QStringLiteral("communityRules.json"))); + + REQUIRE(rules.has_value()); + CHECK(rules->isEmpty()); +} + +TEST_CASE("community rules are keyed by lowercased package id") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::write_bytes(path, R"({ + "timestamp": 1, + "rules": { + "Some.Mod": { + "loadAfter": {"Base.Mod": {"name": "Base"}}, + "loadBefore": {"Late.Mod": {}}, + "loadTop": {"value": true}, + "loadBottom": {"value": false} + } + } + })")); + + const auto loaded = load_community_rules(path); + + REQUIRE(loaded.has_value()); + REQUIRE(loaded->contains(PackageId(QStringLiteral("some.mod")))); + const auto& rule = loaded->value(PackageId(QStringLiteral("some.mod"))); + CHECK(rule.package_id == PackageId(QStringLiteral("some.mod"))); + CHECK(rule.load_after.contains(QStringLiteral("base.mod"))); + CHECK(rule.load_before.contains(QStringLiteral("late.mod"))); + CHECK(rule.load_first); + CHECK_FALSE(rule.load_last); + CHECK(rule.incompatible_with.empty()); +} + +TEST_CASE("a malformed community rules file is an error") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::write_bytes(path, R"({"rules": [1, 2)")); + + const auto loaded = load_community_rules(path); + + CHECK_FALSE(loaded.has_value()); +} diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp new file mode 100644 index 0000000..e3af9a8 --- /dev/null +++ b/tests/core/sort/test_tier_order.cpp @@ -0,0 +1,190 @@ +#include + +#include +#include + +#include +#include +#include +#include + +#include "core/checker/graph.h" +#include "core/checker/sort.h" +#include "core/models/metadata/structures.h" +#include "core/sort/config.h" + +using px::core::AboutXmlMod; +using px::core::CaseInsensitiveSet; +using px::core::CaseInsensitiveStr; +using px::core::checker::ConstraintGraph; +using px::core::checker::topological_sort; +using px::core::sort::PackageId; +using px::core::sort::SortSettings; +using px::core::sort::Tier; +using px::core::sort::TierConfig; + +namespace { + +using ModPtr = std::shared_ptr; +using ModMap = QHash; + +PackageId pid(const QString& value) { + return PackageId(value); +} + +ModPtr make_mod(const QString& package_id) { + auto mod = std::make_shared(); + mod->package_id = CaseInsensitiveStr(package_id); + mod->name = package_id; + return mod; +} + +ModMap active_dict(std::initializer_list mods) { + ModMap result; + for (const auto& mod : mods) { + result.insert(mod->package_id, mod); + } + return result; +} + +// Community rules are off and alternative ids are ignored, so only the tier config and explicit +// load-order rules shape the result. +QList sort_mods(const ModMap& mods, const TierConfig& tier_config = TierConfig::defaults()) { + SortSettings settings; + settings.use_community_rules = false; + settings.use_alternative_package_ids = false; + settings.tier_config = tier_config; + + ConstraintGraph graph; + graph.build(mods, mods.keys(), settings); + return topological_sort(mods, graph, settings); +} + +// Checks only the entries of `result` that belong to `expected`, in their order. +void assert_order(const QList& result, const QStringList& expected) { + QStringList in_tier; + for (const auto& entry : result) { + if (expected.contains(entry.str())) { + in_tier.append(entry.str()); + } + } + CHECK(in_tier == expected); +} + +} // namespace + +TEST_CASE("tier 0 mods follow the config order") { + const auto mods = active_dict({ + make_mod(QStringLiteral("brrainz.harmony")), + make_mod(QStringLiteral("zetrith.prepatcher")), + make_mod(QStringLiteral("ludeon.rimworld")), + make_mod(QStringLiteral("ludeon.rimworld.royalty")), + make_mod(QStringLiteral("ludeon.rimworld.ideology")), + make_mod(QStringLiteral("ludeon.rimworld.biotech")), + make_mod(QStringLiteral("ludeon.rimworld.anomaly")), + make_mod(QStringLiteral("ludeon.rimworld.odyssey")), + }); + + assert_order(sort_mods(mods), + {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony"), + QStringLiteral("ludeon.rimworld"), QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.ideology"), QStringLiteral("ludeon.rimworld.biotech"), + QStringLiteral("ludeon.rimworld.anomaly"), QStringLiteral("ludeon.rimworld.odyssey")}); +} + +TEST_CASE("tier 0 partial active set keeps config order") { + const auto mods = active_dict({ + make_mod(QStringLiteral("ludeon.rimworld.biotech")), + make_mod(QStringLiteral("zetrith.prepatcher")), + make_mod(QStringLiteral("ludeon.rimworld.royalty")), + }); + + assert_order(sort_mods(mods), + {QStringLiteral("zetrith.prepatcher"), QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.biotech")}); +} + +TEST_CASE("tier 0 load-after rule orders mods within the tier") { + const auto mods = active_dict({ + make_mod(QStringLiteral("brrainz.harmony")), + make_mod(QStringLiteral("zetrith.prepatcher")), + }); + mods.value(pid(QStringLiteral("brrainz.harmony")))->about_rules.load_after = + CaseInsensitiveSet(QStringList{QStringLiteral("zetrith.prepatcher")}); + + assert_order(sort_mods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); +} + +TEST_CASE("tier 1 mods follow the config order") { + const auto mods = active_dict({ + make_mod(QStringLiteral("unlimitedhugs.hugslib")), + make_mod(QStringLiteral("adaptive.storage.framework")), + make_mod(QStringLiteral("aoba.framework")), + make_mod(QStringLiteral("smashphil.vehicleframework")), + make_mod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assert_order(sort_mods(mods), + {QStringLiteral("adaptive.storage.framework"), QStringLiteral("aoba.framework"), + QStringLiteral("smashphil.vehicleframework"), QStringLiteral("unlimitedhugs.hugslib"), + QStringLiteral("vanillaexpanded.backgrounds")}); +} + +TEST_CASE("tier 1 partial active set keeps config order") { + const auto mods = active_dict({ + make_mod(QStringLiteral("smashphil.vehicleframework")), + make_mod(QStringLiteral("adaptive.storage.framework")), + make_mod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assert_order(sort_mods(mods), + {QStringLiteral("adaptive.storage.framework"), QStringLiteral("smashphil.vehicleframework"), + QStringLiteral("vanillaexpanded.backgrounds")}); +} + +TEST_CASE("tier 1 load-after rule orders mods within the tier") { + const auto mods = active_dict({ + make_mod(QStringLiteral("aoba.exosuit.framework")), + make_mod(QStringLiteral("aoba.framework")), + }); + mods.value(pid(QStringLiteral("aoba.exosuit.framework")))->about_rules.load_after = + CaseInsensitiveSet(QStringList{QStringLiteral("aoba.framework")}); + + assert_order(sort_mods(mods), {QStringLiteral("aoba.framework"), QStringLiteral("aoba.exosuit.framework")}); +} + +TEST_CASE("tiers are ordered: 0 before 1 before 2 before 3") { + const auto mods = active_dict({ + make_mod(QStringLiteral("some.two.mod")), + make_mod(QStringLiteral("brrainz.harmony")), + make_mod(QStringLiteral("adaptive.storage.framework")), + make_mod(QStringLiteral("some.three.mod")), + }); + TierConfig tier_config; + tier_config.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; + tier_config.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; + tier_config.tier_3 = {pid(QStringLiteral("some.three.mod"))}; + + const auto result = sort_mods(mods, tier_config); + + CHECK(result.indexOf(pid(QStringLiteral("brrainz.harmony"))) < + result.indexOf(pid(QStringLiteral("adaptive.storage.framework")))); + CHECK(result.indexOf(pid(QStringLiteral("adaptive.storage.framework"))) < + result.indexOf(pid(QStringLiteral("some.two.mod")))); + CHECK(result.indexOf(pid(QStringLiteral("some.two.mod"))) < + result.indexOf(pid(QStringLiteral("some.three.mod")))); +} + +TEST_CASE("mods outside every configured tier come after configured mods") { + const auto mods = active_dict({ + make_mod(QStringLiteral("unknown.first")), + make_mod(QStringLiteral("zetrith.prepatcher")), + make_mod(QStringLiteral("unknown.second")), + make_mod(QStringLiteral("brrainz.harmony")), + }); + TierConfig tier_config; + tier_config.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), pid(QStringLiteral("brrainz.harmony"))}; + + assert_order(sort_mods(mods, tier_config), + {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); +} diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp new file mode 100644 index 0000000..79c026c --- /dev/null +++ b/tests/core/sort/test_tiers.cpp @@ -0,0 +1,148 @@ +#include + +#include +#include +#include + +#include "core/sort/tiers.h" + +using px::core::sort::assign_tiers; +using px::core::sort::find_cycle; +using px::core::sort::get_deps_recursive; +using px::core::sort::get_reverse_deps_recursive; +using px::core::sort::PackageGraph; +using px::core::sort::PackageId; +using px::core::sort::Tier; +using px::core::sort::TierConfig; + +namespace { + +PackageId pid(const QString& value) { + return PackageId(value); +} + +void add_edge(PackageGraph& graph, const QString& from, const QString& to) { + graph[pid(from)].insert(pid(to)); +} + +} // namespace + +TEST_CASE("tier graph algorithms handle long chains") { + constexpr int kCount = 5000; + QList nodes; + for (int index = 0; index < kCount; ++index) { + nodes.append(pid(QStringLiteral("mod.%1").arg(index))); + } + const QSet all(nodes.cbegin(), nodes.cend()); + + PackageGraph deps; + PackageGraph reverse_deps; + for (int index = 0; index + 1 < kCount; ++index) { + deps[nodes[index]].insert(nodes[index + 1]); + reverse_deps[nodes[index + 1]].insert(nodes[index]); + } + + const QSet tail(nodes.cbegin() + 1, nodes.cend()); + const QSet head(nodes.cbegin(), nodes.cend() - 1); + CHECK(get_deps_recursive(nodes.front(), deps) == tail); + CHECK(get_reverse_deps_recursive(nodes.back(), reverse_deps) == head); + CHECK_FALSE(find_cycle(all, deps).has_value()); + + deps[nodes.back()].insert(nodes.front()); + const auto cycle = find_cycle(all, deps); + REQUIRE(cycle.has_value()); + CHECK(cycle->size() == kCount + 1); + CHECK(cycle->front() == cycle->back()); + CHECK(QSet(cycle->cbegin(), cycle->cend() - 1) == all); +} + +TEST_CASE("find_cycle returns a closed path whose steps are dependency edges") { + PackageGraph deps; + add_edge(deps, QStringLiteral("a"), QStringLiteral("b")); + add_edge(deps, QStringLiteral("b"), QStringLiteral("c")); + add_edge(deps, QStringLiteral("c"), QStringLiteral("a")); + add_edge(deps, QStringLiteral("c"), QStringLiteral("d")); + const QSet nodes{pid(QStringLiteral("a")), pid(QStringLiteral("b")), + pid(QStringLiteral("c")), pid(QStringLiteral("d"))}; + + const auto cycle = find_cycle(nodes, deps); + REQUIRE(cycle.has_value()); + REQUIRE(cycle->size() == 4); + CHECK(cycle->front() == cycle->back()); + for (qsizetype index = 0; index + 1 < cycle->size(); ++index) { + CHECK(deps.value((*cycle)[index]).contains((*cycle)[index + 1])); + } +} + +TEST_CASE("find_cycle ignores edges that leave the node set") { + PackageGraph deps; + add_edge(deps, QStringLiteral("a"), QStringLiteral("outside")); + add_edge(deps, QStringLiteral("outside"), QStringLiteral("a")); + const QSet nodes{pid(QStringLiteral("a"))}; + + CHECK_FALSE(find_cycle(nodes, deps).has_value()); +} + +TEST_CASE("assign_tiers places tier 0 mods and their dependencies in tier 0") { + PackageGraph deps; + add_edge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); + const QSet canonical{pid(QStringLiteral("zetrith")), pid(QStringLiteral("bar")), + pid(QStringLiteral("other"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("zetrith"))}; + + const auto tiers = assign_tiers(canonical, deps, {}, {}, {}, config); + + CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::Tier0); + CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::Tier0); + CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::Tier2); +} + +TEST_CASE("assign_tiers sinks load-last mods and their dependents to tier 3") { + PackageGraph rev_deps; + add_edge(rev_deps, QStringLiteral("late"), QStringLiteral("dependent")); + const QSet canonical{pid(QStringLiteral("late")), pid(QStringLiteral("dependent")), + pid(QStringLiteral("mid"))}; + + const auto tiers = assign_tiers(canonical, {}, rev_deps, {}, + QSet{pid(QStringLiteral("late"))}, TierConfig{}); + + CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::Tier3); + CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::Tier3); + CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::Tier2); +} + +TEST_CASE("tier 0 overrides tier 3 on overlap") { + const QSet canonical{pid(QStringLiteral("x"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("x"))}; + + const auto tiers = assign_tiers(canonical, {}, {}, {}, + QSet{pid(QStringLiteral("x"))}, config); + + CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::Tier0); +} + +TEST_CASE("tier 1 only promotes tier 2 mods") { + const QSet canonical{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; + const QSet load_first{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; + const QSet load_last{pid(QStringLiteral("last"))}; + + const auto tiers = assign_tiers(canonical, {}, {}, load_first, load_last, TierConfig{}); + + CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::Tier1); + CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::Tier3); +} + +TEST_CASE("assign_tiers never returns ids outside the canonical set") { + const QSet canonical{pid(QStringLiteral("a"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("ghost"))}; + config.tier_3 = {pid(QStringLiteral("ghost"))}; + + const auto tiers = assign_tiers(canonical, {}, {}, {}, {}, config); + + CHECK(tiers.size() == 1); + CHECK_FALSE(tiers.contains(pid(QStringLiteral("ghost")))); + CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::Tier2); +} diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp new file mode 100644 index 0000000..27184b6 --- /dev/null +++ b/tests/core/support/test_report.cpp @@ -0,0 +1,351 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../test_support.h" +#include "core/models/metadata/structures.h" +#include "core/support/report.h" +#include "core/version.h" + +using namespace px::core; +using namespace px::core::support; +using px::core::Result; +using test_support::TestApplication; +using test_support::write_bytes; + +namespace { + +// Awaits a coroutine on the event loop. +template +T wait(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +void require_error(const Result& result, const QString& fragment) { + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(fragment)); +} + +// Answers each request with `response`. An empty response never answers, which lets a test +// hit the client's timeout. The request is recorded for inspection. +class CannedServer { +public: + explicit CannedServer(QByteArray response) : response_(std::move(response)) { + REQUIRE(server_.listen(QHostAddress::LocalHost)); + QObject::connect(&server_, &QTcpServer::newConnection, [this] { accept(); }); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/").arg(server_.serverPort()); + } + + const QByteArray& request() const { return request_; } + +private: + void accept() { + QTcpSocket* socket = server_.nextPendingConnection(); + socket->setParent(&server_); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket] { + request_ += socket->readAll(); + if (response_.isEmpty() || !complete(request_)) { + return; + } + socket->write(response_); + socket->disconnectFromHost(); + }); + } + + // True once the headers have arrived and the Content-Length body is complete. + static bool complete(const QByteArray& raw) { + const qsizetype headers_end = raw.indexOf("\r\n\r\n"); + if (headers_end < 0) { + return false; + } + const QByteArray headers = raw.left(headers_end).toLower(); + const qsizetype key = headers.indexOf("content-length:"); + if (key < 0) { + return true; + } + qsizetype eol = headers.indexOf("\r\n", key); + if (eol < 0) { + eol = headers.size(); + } + const qint64 length = headers.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + return raw.size() - (headers_end + 4) >= length; + } + + QTcpServer server_; + QByteArray response_; + QByteArray request_; +}; + +QByteArray http_response(const QByteArray& status_line, const QByteArray& body) { + return "HTTP/1.1 " + status_line + "\r\nContent-Type: text/plain\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body; +} + +ListedModPtr about_mod(const QString& name, const QString& package_id) { + auto mod = std::make_shared(); + mod->name = name; + mod->package_id = CaseInsensitiveStr(package_id); + return mod; +} + +} // namespace + +TEST_CASE("redact replaces the home directory and username in user paths") { + CHECK(redact(QStringLiteral("User alice at /home/alice/.config/log.txt and /mnt/home/alice/x"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral("User alice at ~/.config/log.txt and /mnt/home//x")); +} + +TEST_CASE("redact handles Windows-style paths") { + CHECK(redact(QString::fromLatin1(R"(Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log)"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QString::fromLatin1(R"(Loaded ~\AppData\Local\pxmodrim\test.log)")); + CHECK(redact(QStringLiteral("Loaded C:/Users/Bob/AppData/test.log"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QStringLiteral("Loaded ~/AppData/test.log")); +} + +TEST_CASE("redact respects path boundaries around the home directory") { + CHECK(redact(QStringLiteral("/home/bob/a and /home/bobby/b and /home/bob"), + QStringLiteral("/home/bob"), QStringLiteral("bob")) == + QStringLiteral("~/a and /home/bobby/b and ~")); +} + +TEST_CASE("redact redacts usernames on removable media mounts") { + CHECK(redact(QStringLiteral("/run/media/alice/SD/mods and /media/alice/x and /media/alicia/y"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral("/run/media//SD/mods and /media//x and /media/alicia/y")); +} + +TEST_CASE("redact handles doubled backslashes from repr") { + const QString raw = QString::fromLatin1(R"('C:\\Users\\Bob\\AppData' and 'D:\\Users\\Bob\\mods')"); + const QString result = + redact(raw, QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")); + CHECK_FALSE(result.contains(QStringLiteral("Bob"))); + CHECK(result.startsWith(QStringLiteral("'~"))); +} + +TEST_CASE("redact matches short usernames only in user directories") { + CHECK(redact(QString::fromLatin1(R"(ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar)"), + QStringLiteral("/home/ed"), QStringLiteral("ed")) == + QString::fromLatin1(R"(ed saw D:\Users\\mods and /Users//x but not /Users/edgar)")); +} + +TEST_CASE("redact leaves package ids that contain the username alone") { + const QString raw = QStringLiteral("Core (ludeon.rimworld) from /opt/rimworld; rimworld started"); + CHECK(redact(raw, QStringLiteral("/home/rimworld"), QStringLiteral("rimworld")) == raw); +} + +TEST_CASE("read_log_tail reports a missing file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString content = + read_log_tail(QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); + CHECK(content.contains(QStringLiteral(""))); +} + +TEST_CASE("read_log_tail reports an empty file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("empty.log")); + REQUIRE(write_bytes(path, "")); + CHECK(read_log_tail(path).contains(QStringLiteral(""))); +} + +TEST_CASE("read_log_tail returns a small file unchanged") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("app.log")); + const char* text = "Line 1\nLine 2\nLine 3\n"; + REQUIRE(write_bytes(path, text)); + CHECK(read_log_tail(path) == QString::fromLatin1(text)); +} + +TEST_CASE("read_log_tail unpacks loguru JSON records") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("json.log")); + REQUIRE(write_bytes(path, + R"({"text": "2026-09-28 | INFO | Loaded mods\n", "record": {"id": 1}})" + "\n" + "Plain log line\n" + R"({"text": "2026-09-28 | INFO | Ready\n", "record": {"id": 2}})" + "\n")); + const QString content = read_log_tail(path); + CHECK(content == QStringLiteral("2026-09-28 | INFO | Loaded mods\n" + "Plain log line\n" + "2026-09-28 | INFO | Ready\n")); + CHECK_FALSE(content.contains(QString::fromLatin1(R"({"text")"))); +} + +TEST_CASE("read_log_tail keeps the end of a large file and adds a banner") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("large.log")); + QStringList lines; + for (int i = 0; i < 200; ++i) { + lines.append(QStringLiteral("Log record number %1 with some extra padding").arg(i, 4, 10, QLatin1Char('0'))); + } + REQUIRE(write_bytes(path, lines.join(QLatin1Char('\n')).toStdString())); + + const QString content = read_log_tail(path, 200); + CHECK(content.startsWith(QStringLiteral("[... truncated, showing last "))); + CHECK(content.contains(QStringLiteral(" of "))); + CHECK(content.contains(QStringLiteral("Log record number 0199"))); + CHECK_FALSE(content.contains(QStringLiteral("Log record number 0001"))); +} + +TEST_CASE("build_report contains every section with active mods in load order") { + TestApplication app; + QTemporaryDir home_dir; + REQUIRE(home_dir.isValid()); + QTemporaryDir log_dir; + REQUIRE(log_dir.isValid()); + + const QString home = QDir(home_dir.path()).filePath(QStringLiteral("home/testuser")); + REQUIRE(QDir().mkpath(home)); + + ReportSource source; + source.game_version = QStringLiteral("1.5.4243"); + source.paths.game = QDir(home).filePath(QStringLiteral("Games/RimWorld")).toStdString(); + source.paths.config_folder = QDir(home).filePath(QStringLiteral(".config/unity3d")).toStdString(); + source.paths.local = QDir(home).filePath(QStringLiteral("Games/RimWorld/Mods")).toStdString(); + source.all_mods = { + {QStringLiteral("u1"), about_mod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, + {QStringLiteral("u2"), about_mod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, + {QStringLiteral("u3"), about_mod(QStringLiteral("Royalty"), QStringLiteral("ludeon.rimworld.royalty"))}, + }; + source.active_uuids = {QStringLiteral("u2"), QStringLiteral("u1"), QStringLiteral("u3")}; + + const QString log_path = QDir(log_dir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(write_bytes(log_path, "INFO | Loaded /home/testuser/mods for testuser\n")); + + const QString report = + build_report(source, log_path, home, QStringLiteral("testuser")); + + CHECK(report.contains(QStringLiteral("=== PxModRim System Report ==="))); + CHECK(report.contains(QStringLiteral("=== RimWorld Configuration ==="))); + CHECK(report.contains(QStringLiteral("=== Mod Collection ==="))); + CHECK(report.contains(QStringLiteral("=== Active Mods (Load Order) ==="))); + CHECK(report.contains(QStringLiteral("=== Application Log ==="))); + + CHECK(report.contains(QStringLiteral("App Version: ") + appVersion())); + CHECK(report.contains(QStringLiteral("Qt Version: "))); + CHECK(report.contains(QStringLiteral("OS: "))); + + CHECK(report.contains(QStringLiteral("Game Version: 1.5.4243"))); + const QString slashed_report = QString(report).replace(u'\\', u'/'); + CHECK(slashed_report.contains(QStringLiteral("~/Games/RimWorld"))); + CHECK(slashed_report.contains(QStringLiteral("~/.config/unity3d"))); + CHECK_FALSE(report.contains(home)); + + CHECK(report.contains(QStringLiteral("Total Mods: 3"))); + CHECK(report.contains(QStringLiteral("Active Mods: 3"))); + + CHECK(report.contains(QStringLiteral(" 1. Core (ludeon.rimworld)\n" + " 2. Harmony (brrainz.harmony)\n" + " 3. Royalty (ludeon.rimworld.royalty)"))); + + CHECK(report.contains(QStringLiteral("Loaded /home//mods for testuser"))); +} + +TEST_CASE("build_report names active uuids it cannot resolve") { + TestApplication app; + QTemporaryDir log_dir; + REQUIRE(log_dir.isValid()); + ReportSource source; + source.active_uuids = {QStringLiteral("/mods/gone")}; + + const QString log_path = QDir(log_dir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(write_bytes(log_path, "x\n")); + const QString report = build_report(source, log_path); + CHECK(report.contains(QStringLiteral(" 1. Unknown Mod (/mods/gone)"))); + CHECK(report.contains(QStringLiteral("Total Mods: 0"))); +} + +TEST_CASE("PasteUploader posts the report and returns the URL") { + TestApplication app; + CannedServer server(http_response("201 Created", "https://paste.rs/abc123\n")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + + const Result result = wait(uploader.upload(QStringLiteral("Report text payload"))); + REQUIRE(result.has_value()); + CHECK(*result == QStringLiteral("https://paste.rs/abc123")); + + const QByteArray request = server.request(); + CHECK(request.contains("User-Agent: PxModRim/")); + CHECK(request.contains("Content-Type: text/plain; charset=utf-8")); + CHECK(request.endsWith("Report text payload")); +} + +TEST_CASE("PasteUploader rejects empty text without sending it") { + TestApplication app; + QNetworkAccessManager network; + PasteUploader uploader(network, QStringLiteral("http://127.0.0.1:9/")); + require_error(wait(uploader.upload(QStringLiteral(" "))), QStringLiteral("Report text is empty")); +} + +TEST_CASE("PasteUploader reports an HTTP error status") { + TestApplication app; + CannedServer server(http_response("403 Forbidden", "nope")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + require_error(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 403 Forbidden")); +} + +TEST_CASE("PasteUploader reports a server error status") { + TestApplication app; + CannedServer server(http_response("500 Internal Server Error", "boom")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + require_error(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 500 Internal Server Error")); +} + +TEST_CASE("PasteUploader rejects a success body that is not a URL") { + TestApplication app; + CannedServer server(http_response("200 OK", "Something unexpected went wrong")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + require_error(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("valid URL")); +} + +TEST_CASE("PasteUploader reports a refused connection as a network error") { + TestApplication app; + // Listen and close to obtain a port that nothing is listening on. + QTcpServer closed; + REQUIRE(closed.listen(QHostAddress::LocalHost)); + const QString url = QStringLiteral("http://127.0.0.1:%1/").arg(closed.serverPort()); + closed.close(); + + QNetworkAccessManager network; + PasteUploader uploader(network, url); + require_error(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("network error")); +} + +TEST_CASE("PasteUploader reports a request that gets no answer as a timeout") { + TestApplication app; + CannedServer server{QByteArray{}}; + QNetworkAccessManager network; + PasteUploader uploader(network, server.url(), std::chrono::milliseconds(200)); + require_error(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("timed out")); +} diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp new file mode 100644 index 0000000..768310e --- /dev/null +++ b/tests/core/test_config.cpp @@ -0,0 +1,248 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/constants.h" +#include "core/file_io.h" +#include "test_support.h" + +namespace { + +using namespace px::core; +using test_support::read_bytes; +using test_support::run_sync; +using test_support::write_bytes; + +// Stands in for ui_prefs.json's struct: these tests exercise ConfigService's +// versioning, not the UI settings themselves. +struct TestPrefs { + bool desc_expanded = false; + LaunchStrategy launch_strategy = LaunchStrategy::Direct; + std::string skipped_update_tag; + + bool operator==(const TestPrefs&) const = default; +}; + +QString path_in(const QTemporaryDir& dir, const char* name) { return dir.filePath(QString::fromLatin1(name)); } + +QStringList backups_of(const QTemporaryDir& dir, const char* name) { + return QDir(dir.path()).entryList({QString::fromLatin1(name) + QStringLiteral(".bak.*")}, QDir::Files); +} + +int schema_version_of(const QString& path) { + glz::json_t document; + REQUIRE_FALSE(glz::read_json(document, read_bytes(path))); + const auto& object = document.get_object(); + const auto marker = object.find(std::string(kSchemaVersionKey)); + REQUIRE(marker != object.end()); + return static_cast(marker->second.get()); +} + +AppConfig legacy_expected_config() { + AppConfig cfg; + cfg.paths = PathConfig{ + .game = "/legacy/game", + .local = "/legacy/mods", + .workshop = "/legacy/workshop", + .config_folder = "/legacy/config", + .community_rules_file = "/legacy/rules", + .no_version_warning_file = "/legacy/no-warning", + .use_this_instead_file = "/legacy/use-instead", + }; + return cfg; +} + +} // namespace + +TEST_CASE("read_game_version reads and trims Version.txt") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(write_bytes(path_in(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(read_game_version(dir.path()) == QStringLiteral("1.6.4871 rev598")); + + REQUIRE(write_bytes(path_in(dir, "Version.txt"), " 1.6.4871 rev598 \n")); + REQUIRE(read_game_version(dir.path()) == QStringLiteral("1.6.4871 rev598")); +} + +TEST_CASE("read_game_version returns nullopt when Version.txt is missing or unreadable") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE_FALSE(read_game_version(dir.path()).has_value()); + + REQUIRE(write_bytes(path_in(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(QFile(path_in(dir, "Version.txt")).setPermissions(QFileDevice::Permissions{})); + REQUIRE_FALSE(read_game_version(dir.path()).has_value()); +} + +TEST_CASE("legacy app config migrates with a backup") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "config.json"); + const std::string legacy = + R"({"paths":{"game":"/legacy/game","local":"/legacy/mods","workshop":"/legacy/workshop",)" + R"("config_folder":"/legacy/config","community_rules_file":"/legacy/rules",)" + R"("no_version_warning_file":"/legacy/no-warning","use_this_instead_file":"/legacy/use-instead",)" + R"("steamcmd_prefix":"/legacy/steamcmd"}})"; + REQUIRE(write_bytes(path, legacy)); + + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(run_sync(app.setup()).has_value()); + + REQUIRE(app.cfg() == legacy_expected_config()); + REQUIRE(schema_version_of(path) == 1); + const auto backups = backups_of(dir, "config.json"); + REQUIRE(backups.size() == 1); + REQUIRE(read_bytes(dir.filePath(backups.first())) == legacy); +} + +TEST_CASE("legacy ui prefs migrate with a backup") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "ui_prefs.json"); + const std::string legacy = R"({"validate_downloads":true,"desc_expanded":true,"launch_strategy":1})"; + REQUIRE(write_bytes(path, legacy)); + + ConfigService service(dir.path()); + const auto prefs = service.load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(*prefs == TestPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::Steam, .skipped_update_tag = ""}); + REQUIRE(schema_version_of(path) == 1); + const auto backups = backups_of(dir, "ui_prefs.json"); + REQUIRE(backups.size() == 1); + REQUIRE(read_bytes(dir.filePath(backups.first())) == legacy); +} + +TEST_CASE("fresh services return defaults without creating files") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(run_sync(app.setup()).has_value()); + + REQUIRE(app.cfg() == AppConfig{}); + REQUIRE(service.load("ui_prefs.json") == TestPrefs{}); + REQUIRE_FALSE(QFileInfo::exists(path_in(dir, "config.json"))); + REQUIRE_FALSE(QFileInfo::exists(path_in(dir, "ui_prefs.json"))); +} + +TEST_CASE("managed saves stamp schema_version and round-trip values") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfig expected_config; + expected_config.paths.game = "/game"; + expected_config.paths.local = "/mods"; + const TestPrefs expected_prefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::Steam, .skipped_update_tag = ""}; + + REQUIRE(service.save("config.json", expected_config).has_value()); + REQUIRE(read_bytes(path_in(dir, "config.json")).find("\"schema_version\": 1\n}") != std::string::npos); + REQUIRE(service.save("ui_prefs.json", expected_prefs).has_value()); + + REQUIRE(schema_version_of(path_in(dir, "config.json")) == 1); + REQUIRE(schema_version_of(path_in(dir, "ui_prefs.json")) == 1); + REQUIRE(service.load("config.json") == expected_config); + REQUIRE(service.load("ui_prefs.json") == expected_prefs); +} + +TEST_CASE("current config load does not rewrite the file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "config.json"); + REQUIRE(write_bytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); + const std::string before = read_bytes(path); + + const auto cfg = ConfigService(dir.path()).load("config.json"); + REQUIRE(cfg.has_value()); + REQUIRE(cfg->paths.game == "/current"); + REQUIRE(read_bytes(path) == before); + REQUIRE(backups_of(dir, "config.json").isEmpty()); +} + +TEST_CASE("current ui prefs load does not rewrite the file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "ui_prefs.json"); + REQUIRE(write_bytes(path, R"({"schema_version":1,"desc_expanded":true})")); + const std::string before = read_bytes(path); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(read_bytes(path) == before); + REQUIRE(backups_of(dir, "ui_prefs.json").isEmpty()); +} + +TEST_CASE("corrupt config returns safe defaults") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(write_bytes(path_in(dir, "config.json"), "{invalid")); + REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); +} + +TEST_CASE("unreadable config returns safe defaults") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "config.json"); + REQUIRE(write_bytes(path, "{}")); + REQUIRE(QFile(path).setPermissions(QFileDevice::Permissions{})); + REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); +} + +TEST_CASE("missing JSON migration step fails without rewrite") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "config.json"); + REQUIRE(write_bytes(path, R"({"paths":{"game":"/legacy"}})")); + const std::string before = read_bytes(path); + + const ConfigService service(dir.path(), JsonMigrations{}); + const auto loaded = service.load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Missing JSON migration step")); + REQUIRE(read_bytes(path) == before); + REQUIRE(backups_of(dir, "config.json").isEmpty()); +} + +TEST_CASE("unsupported config schema fails without mutation") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = path_in(dir, "config.json"); + REQUIRE(write_bytes(path, R"({"schema_version":2})")); + const std::string before = read_bytes(path); + + const auto loaded = ConfigService(dir.path()).load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Unsupported JSON schema version")); + REQUIRE(read_bytes(path) == before); +} + +TEST_CASE("ui prefs skipped update tag round-trips") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + REQUIRE(service.save("ui_prefs.json", TestPrefs{.desc_expanded = false, .launch_strategy = LaunchStrategy::Direct, .skipped_update_tag = "v0.2.0"}).has_value()); + REQUIRE(service.load("ui_prefs.json").value().skipped_update_tag == "v0.2.0"); +} + +TEST_CASE("ui prefs with removed keys still load") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(write_bytes(path_in(dir, "ui_prefs.json"), + R"({"schema_version":1,"deps_expanded":true,"desc_expanded":true,)" + R"("skipped_update_tag":"v0.3.0"})")); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(prefs->skipped_update_tag == "v0.3.0"); +} diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp new file mode 100644 index 0000000..538a5a1 --- /dev/null +++ b/tests/core/test_context.cpp @@ -0,0 +1,147 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/models/metadata/structures.h" +#include "services/services_support.h" + +using px::core::AboutXmlMod; +using px::core::AppConfig; +using px::core::CaseInsensitiveStr; +using px::core::CoreContext; +using px::core::DependencyMod; +using px::core::ListedModPtr; +using services_test::EventLoop; +using services_test::run_async; + +namespace { + +std::shared_ptr about(const QString& package_id, bool valid = true) { + auto mod = std::make_shared(); + mod->name = package_id; + mod->package_id = CaseInsensitiveStr(package_id); + mod->provider_id = QStringLiteral("local"); + mod->valid = valid; + return mod; +} + +std::unique_ptr make_context(const QString& game_path = {}) { + static QTemporaryDir configDir; + static px::core::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = game_path.toStdString(); + return CoreContext::create(cfg, &configService); +} + +void write_version(const QString& game_dir, const QString& version) { + QFile file(QDir(game_dir).filePath(QStringLiteral("Version.txt"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(version.toUtf8()); +} + +} // namespace + +TEST_CASE("target version is the major.minor of the game version") { + QTemporaryDir game; + REQUIRE(game.isValid()); + + auto ctx = make_context(); + CHECK(ctx->gameVersion() == QStringLiteral("Unknown")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); + + write_version(game.path(), QStringLiteral("1.5.4297 rev123")); + ctx->updateConfig([&] { + AppConfig cfg = ctx->config(); + cfg.paths.game = game.path().toStdString(); + return cfg; + }()); + CHECK(ctx->gameVersion() == QStringLiteral("1.5.4297 rev123")); + CHECK(ctx->targetVersion() == QStringLiteral("1.5")); +} + +TEST_CASE("a game version without a minor part has no target version") { + QTemporaryDir game; + REQUIRE(game.isValid()); + write_version(game.path(), QStringLiteral("1")); + + auto ctx = make_context(game.path()); + CHECK(ctx->gameVersion() == QStringLiteral("1")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); +} + +TEST_CASE("setActive emits on every change and keeps the loaded list") { + EventLoop loop; + auto ctx = make_context(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}}, + {QStringLiteral("a")}); + + std::vector events; + const auto id = ctx->activeStateChanged().connect( + [&events](const QStringList& active) { events.push_back(active); }); + + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + REQUIRE(events.size() == 1); + CHECK(events.back() == QStringList{"a", "b"}); + + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK(events.size() == 1); + + ctx->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(events.size() == 2); + CHECK(ctx->activeUuids() == QStringList{"b", "a"}); + CHECK(ctx->loadedActiveUuids() == QStringList{"a"}); + + CHECK(ctx->activeStateChanged().disconnect(id)); +} + +TEST_CASE("computeStats counts total, active, inactive and invalid mods") { + EventLoop loop; + auto ctx = make_context(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}, + {QStringLiteral("c"), about(QStringLiteral("c.mod"), false)}}, + {QStringLiteral("a")}); + + const auto stats = ctx->computeStats(); + CHECK(stats.total == 3); + CHECK(stats.active == 1); + CHECK(stats.inactive == 2); + CHECK(stats.errors == 1); + + const auto override_stats = ctx->computeStats(QStringList{"a", "b"}); + CHECK(override_stats.active == 2); + CHECK(override_stats.inactive == 1); +} + +TEST_CASE("autoSort enables a missing dependency and orders it first") { + EventLoop loop; + auto dependent = about(QStringLiteral("mod.a")); + DependencyMod dependency; + dependency.name = QStringLiteral("Mod B"); + dependency.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); + dependent->about_rules.dependencies.append(std::move(dependency)); + + auto ctx = make_context(); + ctx->load({{QStringLiteral("uuid-a"), dependent}, + {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, + {QStringLiteral("uuid-a")}); + + const auto [count, elapsed_ms] = run_async(ctx->autoSort()); + CHECK(count == 2); + CHECK(elapsed_ms >= 0.0); + REQUIRE(ctx->activeUuids().size() == 2); + CHECK(ctx->activeUuids().indexOf(QStringLiteral("uuid-b")) < + ctx->activeUuids().indexOf(QStringLiteral("uuid-a"))); +} diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp new file mode 100644 index 0000000..b3ad7bf --- /dev/null +++ b/tests/core/test_loading.cpp @@ -0,0 +1,85 @@ +#include + +#include +#include +#include +#include + +#include "core/loading.h" + +namespace { + +struct Emission { + QString status; + int pct; + int current; + int total; + + bool operator==(const Emission&) const = default; +}; + +} // namespace + +TEST_CASE("loading state reports the innermost frame and pops back to its parent") { + px::core::LoadingState state; + QList emissions; + int finished = 0; + QObject::connect(&state, &px::core::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + QObject::connect(&state, &px::core::LoadingState::finished, [&] { ++finished; }); + + { + auto outer = state.task(QStringLiteral("outer"), 4); + REQUIRE(state.statuses() == QStringList{"outer"}); + state.set_progress(1); + { + auto inner = state.task(QStringLiteral("inner")); + REQUIRE(state.statuses() == QStringList{"outer", "inner"}); + state.step(29); + } + REQUIRE(state.statuses() == QStringList{"outer"}); + } + + REQUIRE(state.statuses().isEmpty()); + REQUIRE(finished == 1); + REQUIRE(emissions.last() == Emission{QStringLiteral("outer"), 25, 1, 4}); + REQUIRE(emissions.contains(Emission{QStringLiteral("inner"), 28, 29, 100})); +} + +TEST_CASE("percent truncates the float quotient, as the legacy progress did") { + px::core::LoadingState state; + QList emissions; + QObject::connect(&state, &px::core::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("p"), 100); + state.set_progress(29); + REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); +} + +TEST_CASE("set_total clamps to at least one step and progress stays within bounds") { + px::core::LoadingState state; + QList emissions; + QObject::connect(&state, &px::core::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("t"), 10); + state.set_total(0); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); + state.set_progress(50); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 100, 1, 1}); + state.set_progress(-5); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); +} + +TEST_CASE("popping an empty stack still reports completion") { + px::core::LoadingState state; + int finished = 0; + QObject::connect(&state, &px::core::LoadingState::finished, [&] { ++finished; }); + state.pop(); + REQUIRE(finished == 1); +} diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp new file mode 100644 index 0000000..555ae9e --- /dev/null +++ b/tests/core/test_migrator.cpp @@ -0,0 +1,235 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/migrator.h" +#include "test_support.h" + +namespace { + +using px::core::ensure_schema; +using px::core::MigrationSteps; +using px::core::Result; +using test_support::read_bytes; + +// Owns one named SQLite connection and closes it when it goes out of scope. +class Database { +public: + Database(const QString& path, QString name) : name_(std::move(name)) { + db_ = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name_); + db_.setDatabaseName(path); + REQUIRE(db_.open()); + } + + ~Database() { + db_.close(); + db_ = QSqlDatabase(); + QSqlDatabase::removeDatabase(name_); + } + + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + QSqlDatabase& get() { return db_; } + + void exec(const QString& sql) { + QSqlQuery query(db_); + REQUIRE(query.exec(sql)); + } + + QStringList column(const QString& sql) { + QSqlQuery query(db_); + REQUIRE(query.exec(sql)); + QStringList result; + while (query.next()) { + result.append(query.value(0).toString()); + } + return result; + } + + int user_version() { + QSqlQuery query(db_); + REQUIRE(query.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(query.next()); + return query.value(0).toInt(); + } + +private: + QSqlDatabase db_; + QString name_; +}; + +QStringList backups_of(const QString& dir, const QString& db_name) { + return QDir(dir).entryList({db_name + QStringLiteral(".bak.*")}, QDir::Files); +} + +Result noop() { return {}; } + +} // namespace + +TEST_CASE("fresh database is initialised and stamped without running steps") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + bool stepped = false; + const MigrationSteps steps{ + {1, noop}, + {2, [&]() -> Result { + stepped = true; + return {}; + }}, + }; + { + Database db(path, QStringLiteral("fresh")); + REQUIRE(ensure_schema(db.get(), + QStringLiteral("CREATE TABLE state (value TEXT DEFAULT 'a;b'); -- note; ignored\n" + "CREATE TABLE other (x INTEGER);"), + 2, steps, path) + .has_value()); + REQUIRE(db.user_version() == 2); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'")) == + QStringList{"state"}); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'other'")) == + QStringList{"other"}); + } + REQUIRE_FALSE(stepped); + REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} + +TEST_CASE("migrations checkpoint the WAL before backing up") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + QStringList events; + { + Database db(path, QStringLiteral("wal")); + REQUIRE(db.column(QStringLiteral("PRAGMA journal_mode = WAL")) == QStringList{"wal"}); + db.exec(QStringLiteral("PRAGMA wal_autocheckpoint = 0")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("INSERT INTO state VALUES ('before')")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + REQUIRE(QFileInfo(path + QStringLiteral("-wal")).size() > 0); + + const MigrationSteps steps{ + {2, [&]() -> Result { + events.append(QStringLiteral("two")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN second TEXT")); + return {}; + }}, + {3, [&]() -> Result { + events.append(QStringLiteral("three")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN third TEXT")); + return {}; + }}, + }; + REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, steps, + path) + .has_value()); + REQUIRE(db.user_version() == 3); + REQUIRE(db.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == + QStringList{"value", "second", "third"}); + REQUIRE(events == QStringList{"two", "three"}); + } + + const auto found = backups_of(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + const QString backup = dir.filePath(found.first()); + { + Database backup_db(backup, QStringLiteral("wal-backup")); + REQUIRE(backup_db.user_version() == 1); + REQUIRE(backup_db.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == + QStringList{"value"}); + REQUIRE(backup_db.column(QStringLiteral("SELECT value FROM state")) == QStringList{"before"}); + } + for (const char* suffix : {"-wal", "-shm"}) { + QFile::remove(backup + QString::fromLatin1(suffix)); + } +} + +TEST_CASE("missing migration step fails without advancing the version") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + { + Database db(path, QStringLiteral("missing")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = read_bytes(path); + const auto result = ensure_schema(db.get(), + QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, + MigrationSteps{{2, noop}}, path); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Missing migration step")); + REQUIRE(db.user_version() == 1); + } + + REQUIRE(read_bytes(path) == before); + const auto found = backups_of(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + REQUIRE(read_bytes(dir.filePath(found.first())) == before); +} + +TEST_CASE("current schema is a no-op") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("current")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = read_bytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, + steps, path) + .has_value()); + REQUIRE(db.user_version() == 1); + } + REQUIRE_FALSE(called); + REQUIRE(read_bytes(path) == before); + REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} + +TEST_CASE("newer schema is a no-op") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("newer")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 2")); + before = read_bytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, + steps, path) + .has_value()); + REQUIRE(db.user_version() == 2); + } + REQUIRE_FALSE(called); + REQUIRE(read_bytes(path) == before); + REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp new file mode 100644 index 0000000..74a49b7 --- /dev/null +++ b/tests/core/test_mods_config.cpp @@ -0,0 +1,345 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include "core/file_io.h" +#include "core/mods_config.h" +#include "core/models/metadata/structures.h" +#include "test_support.h" + +namespace { + +using namespace px::core; +using test_support::read_bytes; +using test_support::write_bytes; + +constexpr auto kSampleXml = R"( + + 1.6.4871 rev600 + +
  • ludeon.rimworld
  • +
  • ludeon.rimworld.royalty
  • +
  • ludeon.rimworld.ideology
  • +
  • ludeon.rimworld.biotech
  • +
  • ludeon.rimworld.anomaly
  • +
  • ludeon.rimworld.odyssey
  • +
    + +
  • ludeon.rimworld.royalty
  • +
  • ludeon.rimworld.ideology
  • +
  • ludeon.rimworld.biotech
  • +
  • ludeon.rimworld.anomaly
  • +
  • ludeon.rimworld.odyssey
  • +
    +
    +)"; + +CaseInsensitiveStr pid(const char* text) { return CaseInsensitiveStr(QString::fromLatin1(text)); } + +std::optional parse_text(const std::string& content, const QTemporaryDir& dir) { + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_bytes(path, content)); + return parse_mods_config(path); +} + +ModsConfig mods_config(const char* package_id) { + return ModsConfig{ + .version = QStringLiteral("1.5"), + .activeMods = {pid(package_id)}, + .knownExpansions = {}, + }; +} + +// Package ids of the snapshots in `dir`, newest first. +QList retained_ids(const QString& dir) { + QList ids; + for (const auto& snapshot : get_snapshots(dir)) { + const auto parsed = parse_mods_config(snapshot); + REQUIRE(parsed.has_value()); + ids.append(parsed->activeMods.first()); + } + return ids; +} + +} // namespace + +TEST_CASE("parse_mods_config reads version, active mods and expansions") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parse_text(kSampleXml, dir); + REQUIRE(config.has_value()); + REQUIRE(config->version == QStringLiteral("1.6.4871 rev600")); + REQUIRE(config->activeMods == QList{ + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); + REQUIRE(config->knownExpansions == QList{ + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); +} + +TEST_CASE("parse_mods_config accepts a single active mod") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parse_text(R"( + + 1.5 + +
  • ludeon.rimworld
  • +
    + +
  • ludeon.rimworld.royalty
  • +
    +
    )", + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods == QList{pid("ludeon.rimworld")}); + REQUIRE(config->knownExpansions == QList{pid("ludeon.rimworld.royalty")}); +} + +TEST_CASE("parse_mods_config falls back to the DLC list when no expansions are declared") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parse_text(R"( + + 1.5 + +)", + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods.isEmpty()); + REQUIRE_FALSE(config->knownExpansions.isEmpty()); +} + +TEST_CASE("parse_mods_config returns nullopt for a missing file") { + REQUIRE_FALSE(parse_mods_config(QStringLiteral("/nonexistent/ModsConfig.xml")).has_value()); +} + +TEST_CASE("write then parse round-trips the mod lists") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const ModsConfig original{ + .version = QStringLiteral("1.6.4871 rev600"), + .activeMods = { + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + .knownExpansions = { + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + }; + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(path, original, dir.filePath(QStringLiteral("snapshots"))).has_value()); + + const auto loaded = parse_mods_config(path); + REQUIRE(loaded.has_value()); + REQUIRE(loaded->version == original.version); + REQUIRE(loaded->activeMods == original.activeMods); + REQUIRE(loaded->knownExpansions == original.knownExpansions); +} + +TEST_CASE("write keeps unknown XML in an existing ModsConfigData document") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + std::string text = kSampleXml; + text.replace(text.find(""), std::string("").size(), + R"(42)"); + REQUIRE(write_bytes(path, text)); + + auto loaded = parse_mods_config(path); + REQUIRE(loaded.has_value()); + loaded->activeMods = {pid("author.updated")}; + REQUIRE(write_mods_config(path, *loaded, dir.filePath(QStringLiteral("snapshots"))).has_value()); + + pugi::xml_document doc; + REQUIRE(doc.load_file(path.toStdString().c_str())); + const pugi::xml_node root = doc.document_element(); + REQUIRE(std::string(root.attribute("custom").value()) == "preserve"); + REQUIRE(std::string(root.child("otherSetting").child_value("value")) == "42"); + REQUIRE(std::string(root.child("otherSetting").attribute("enabled").value()) == "true"); + REQUIRE(std::string(root.child("activeMods").child_value("li")) == "author.updated"); +} + +TEST_CASE("replacing a config keeps the previous contents as a snapshot") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + + REQUIRE(write_mods_config(config_path, mods_config("mod.alpha"), snapshots_dir).has_value()); + REQUIRE(get_snapshots(snapshots_dir).isEmpty()); + + REQUIRE(write_mods_config(config_path, mods_config("mod.beta"), snapshots_dir).has_value()); + + const auto snapshots = get_snapshots(snapshots_dir); + REQUIRE(snapshots.size() == 1); + const auto previous = parse_mods_config(snapshots.first()); + const auto current = parse_mods_config(config_path); + REQUIRE(previous.has_value()); + REQUIRE(previous->activeMods == QList{pid("mod.alpha")}); + REQUIRE(current.has_value()); + REQUIRE(current->activeMods == QList{pid("mod.beta")}); +} + +TEST_CASE("replacement retains only the newest ten snapshots") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir).has_value()); + + for (int index = 1; index < 12; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir).has_value()); + } + + QList expected; + for (int index = 10; index >= 1; --index) { + expected.append(pid(("mod." + std::to_string(index)).c_str())); + } + REQUIRE(get_snapshots(snapshots_dir).size() == 10); + REQUIRE(retained_ids(snapshots_dir) == expected); +} + +TEST_CASE("configurable retention limit is honored") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir, 3).has_value()); + + for (int index = 1; index < 6; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir, 3).has_value()); + } + + const auto snapshots = get_snapshots(snapshots_dir); + REQUIRE(snapshots.size() == 3); + REQUIRE(retained_ids(snapshots_dir) == + QList{pid("mod.4"), pid("mod.3"), pid("mod.2")}); + + // Lowering the limit on duplicate content prunes the excess before returning. + REQUIRE(write_bytes(config_path, read_bytes(snapshots.first()))); + const auto duplicate = create_snapshot(config_path, snapshots_dir, 2); + REQUIRE(duplicate.has_value()); + REQUIRE(duplicate->has_value()); + REQUIRE(**duplicate == snapshots.first()); + REQUIRE(get_snapshots(snapshots_dir).size() == 2); +} + +TEST_CASE("restoring the oldest snapshot at full retention keeps it and backs up the current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + constexpr int max_snapshots = 3; + REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir, max_snapshots).has_value()); + for (int index = 1; index < 4; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir, max_snapshots).has_value()); + } + + const QString selected = get_snapshots(snapshots_dir).last(); + const std::string selected_data = read_bytes(selected); + + REQUIRE(restore_snapshot(selected, config_path, snapshots_dir, max_snapshots).has_value()); + + const auto restored = parse_mods_config(config_path); + REQUIRE(restored.has_value()); + REQUIRE(restored->activeMods == QList{pid("mod.0")}); + REQUIRE(QFileInfo::exists(selected)); + REQUIRE(read_bytes(selected) == selected_data); + REQUIRE(get_snapshots(snapshots_dir).size() == max_snapshots); + REQUIRE(retained_ids(snapshots_dir) == + QList{pid("mod.3"), pid("mod.2"), pid("mod.0")}); +} + +TEST_CASE("restoring a snapshot writes its exact bytes and backs up the current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(config_path, mods_config("mod.current"), snapshots_dir).has_value()); + + const QString snapshot_path = dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); + const std::string snapshot_data = + "\n" + "1.5" + "
  • mod.saved
  • \n"; + REQUIRE(QDir().mkpath(dir.filePath(QStringLiteral("snapshots")))); + REQUIRE(write_bytes(snapshot_path, snapshot_data)); + + REQUIRE(restore_snapshot(snapshot_path, config_path, snapshots_dir).has_value()); + REQUIRE(read_bytes(config_path) == snapshot_data); + + QStringList backups; + for (const auto& snapshot : get_snapshots(snapshots_dir)) { + if (snapshot != snapshot_path) { + backups.append(snapshot); + } + } + REQUIRE(backups.size() == 1); + const auto backup = parse_mods_config(backups.first()); + REQUIRE(backup.has_value()); + REQUIRE(backup->activeMods == QList{pid("mod.current")}); +} + +TEST_CASE("invalid snapshots never change the current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(config_path, mods_config("mod.safe"), snapshots_dir).has_value()); + const std::string original = read_bytes(config_path); + + const QString missing = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing.xml")); + const QString corrupt = dir.filePath(QStringLiteral("snapshots/ModsConfig_corrupt.xml")); + const QString wrong_root = dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); + const QString missing_active = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); + REQUIRE(QDir().mkpath(snapshots_dir)); + REQUIRE(write_bytes(corrupt, "not XML")); + REQUIRE(write_bytes(wrong_root, "")); + REQUIRE(write_bytes(missing_active, "1.5")); + + for (const QString& snapshot : {missing, corrupt, wrong_root, missing_active}) { + const QStringList before = get_snapshots(snapshots_dir); + REQUIRE_FALSE(restore_snapshot(snapshot, config_path, snapshots_dir).has_value()); + REQUIRE(read_bytes(config_path) == original); + REQUIRE(get_snapshots(snapshots_dir) == before); + } +} + +TEST_CASE("a failed replacement reports an error and leaves no temporary files") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); + const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(write_mods_config(config_path, mods_config("mod.safe"), snapshots_dir).has_value()); + + // A directory at the target makes the final rename fail after the temporary file is written. + const QString blocked = dir.filePath(QStringLiteral("Blocked.xml")); + REQUIRE(QDir().mkpath(blocked)); + REQUIRE_FALSE(write_mods_config(blocked, mods_config("mod.new"), snapshots_dir).has_value()); + REQUIRE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden) == QStringList{QStringLiteral("ModsConfig.xml")}); +} diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp new file mode 100644 index 0000000..ab67d4f --- /dev/null +++ b/tests/core/test_plugin.cpp @@ -0,0 +1,137 @@ +#include + +#include +#include + +#include +#include +#include +#include + +#include "core/plugin.h" +#include "test_support.h" + +// Stand-in for a composition root's context: records what each plugin did. +namespace { + +struct TestContext { + QStringList log; +}; + +using Plugin = px::core::BasicPlugin; +using PluginRegistry = px::core::BasicPluginRegistry; +using test_support::run_sync; + +class RecorderPlugin : public Plugin { +public: + RecorderPlugin(QString name, QStringList deps = {}, QStringList* shutdown_log = nullptr) + : Plugin(std::move(name), std::move(deps)), shutdown_log_(shutdown_log) {} + + void setup(TestContext& ctx) override { ctx.log.append(name()); } + + QCoro::Task init(TestContext& ctx) override { + ctx.log.append(name()); + co_return; + } + + QCoro::Task shutdown() override { + if (shutdown_log_) { + shutdown_log_->append(name()); + } + co_return; + } + +private: + QStringList* shutdown_log_; +}; + +} // namespace + +TEST_CASE("empty registry sets up nothing") { + PluginRegistry reg; + TestContext ctx; + REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("plugins without dependencies set up in name order") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a")); + reg.register_plugin(std::make_unique("b")); + reg.register_plugin(std::make_unique("c")); + TestContext ctx; + REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("linear dependency chain sets up in order") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a")); + reg.register_plugin(std::make_unique("b", QStringList{"a"})); + reg.register_plugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("complex DAG sets up level by level, names sorted within a level") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a")); + reg.register_plugin(std::make_unique("b", QStringList{"a"})); + reg.register_plugin(std::make_unique("c", QStringList{"a"})); + reg.register_plugin(std::make_unique("d", QStringList{"b", "c"})); + reg.register_plugin(std::make_unique("e", QStringList{"d"})); + reg.register_plugin(std::make_unique("f")); + reg.register_plugin(std::make_unique("g", QStringList{"e", "f"})); + TestContext ctx; + REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "f", "b", "c", "d", "e", "g"}); +} + +TEST_CASE("missing dependency is reported before anything is set up") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a", QStringList{"nonexistent"})); + TestContext ctx; + const auto result = reg.setup_all(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("depends on 'nonexistent'")); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("extra dependencies satisfy missing plugins") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a", QStringList{"external_dep"})); + TestContext ctx; + REQUIRE(reg.setup_all(ctx, QSet{"external_dep"}).has_value()); + REQUIRE(ctx.log == QStringList{"a"}); +} + +TEST_CASE("cyclic dependencies are reported as an error") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a", QStringList{"b"})); + reg.register_plugin(std::make_unique("b", QStringList{"a"})); + TestContext ctx; + const auto result = reg.setup_all(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Circular dependencies")); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("init runs in dependency order") { + PluginRegistry reg; + reg.register_plugin(std::make_unique("a")); + reg.register_plugin(std::make_unique("b", QStringList{"a"})); + reg.register_plugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(run_sync(reg.init_all(ctx)).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("shutdown runs in reverse dependency order") { + PluginRegistry reg; + QStringList shutdown_order; + reg.register_plugin(std::make_unique("dependent", QStringList{"dependency"}, &shutdown_order)); + reg.register_plugin(std::make_unique("dependency", QStringList{}, &shutdown_order)); + REQUIRE(run_sync(reg.shutdown_all()).has_value()); + REQUIRE(shutdown_order == QStringList{"dependent", "dependency"}); +} diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp new file mode 100644 index 0000000..b23f109 --- /dev/null +++ b/tests/core/test_plugin_config.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include +#include +#include +#include + +#include "core/config.h" +#include "core/plugin_config.h" +#include "test_support.h" + +namespace { + +using namespace px::core; +using test_support::read_bytes; +using test_support::write_bytes; + +struct DemoSettings { + bool show_badges = false; + int level = 0; + + bool operator==(const DemoSettings&) const = default; +}; + +} // namespace + +TEST_CASE("plugin config seeds from legacy config.json keys and persists changes") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(write_bytes(dir.filePath(QStringLiteral("config.json")), R"({"old_badges":true,"paths":{}})")); + const QString settings_path = dir.filePath(QStringLiteral("plugins/demo.json")); + + ConfigService service(dir.path()); + PluginConfig settings(&service, QStringLiteral("demo"), + QHash{{QStringLiteral("old_badges"), + QStringLiteral("show_badges")}}); + REQUIRE(settings.value().show_badges); + REQUIRE(QFileInfo::exists(settings_path)); + + int emitted = 0; + DemoSettings last; + settings.changed.connect([&](const DemoSettings& value) { + ++emitted; + last = value; + }); + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); + REQUIRE(last == DemoSettings{.show_badges = false, .level = 2}); + + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); + + PluginConfig reloaded(&service, QStringLiteral("demo")); + REQUIRE(reloaded.value() == DemoSettings{.show_badges = false, .level = 2}); +} + +TEST_CASE("plugin config without a service keeps settings in memory") { + PluginConfig settings(nullptr, QStringLiteral("memory")); + REQUIRE(settings.value() == DemoSettings{}); + settings.update(DemoSettings{.show_badges = true, .level = 0}); + REQUIRE(settings.value().show_badges); +} diff --git a/tests/core/test_support.h b/tests/core/test_support.h new file mode 100644 index 0000000..489d076 --- /dev/null +++ b/tests/core/test_support.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace test_support { + +// Qt resolves database driver plugins through the application instance, so tests +// that open databases create one for their scope (one at a time, as test_version does). +class TestApplication { +public: + TestApplication() : app_(argc_, nullptr) {} + +private: + int argc_ = 0; + QCoreApplication app_; +}; + +// Drives a coroutine whose awaited work finishes without suspending, which holds for +// every coroutine these tests start. The REQUIRE fails if it suspended instead. +template +QCoro::Task await_into(QCoro::Task task, std::optional& out) { + out.emplace(co_await std::move(task)); +} + +template +T run_sync(QCoro::Task task) { + std::optional out; + [[maybe_unused]] auto pending = await_into(std::move(task), out); + REQUIRE(out.has_value()); + return std::move(*out); +} + +inline bool write_bytes(const QString& path, std::string_view bytes) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + const auto size = static_cast(bytes.size()); + return file.write(bytes.data(), size) == size; +} + +inline std::string read_bytes(const QString& path) { + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly)); + const QByteArray data = file.readAll(); + return std::string(data.constData(), static_cast(data.size())); +} + +} // namespace test_support diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp new file mode 100644 index 0000000..45ff75a --- /dev/null +++ b/tests/core/test_utils.cpp @@ -0,0 +1,63 @@ +#include + +#include +#include +#include + +#include "core/utils.h" + +namespace { + +void writeFile(const QString& path, const QByteArray& content) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); +} + +} // namespace + +TEST_CASE("findAboutXml locates About/About.xml") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("About"))); + writeFile(mod.filePath(QStringLiteral("About/About.xml")), ""); + + const auto found = px::core::findAboutXml(root.path()); + REQUIRE(found.has_value()); + CHECK(found->endsWith(QStringLiteral("About/About.xml"))); +} + +TEST_CASE("findAboutXml matches the About folder and file name case-insensitively") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("ABOUT"))); + writeFile(mod.filePath(QStringLiteral("ABOUT/about.XML")), ""); + + const auto found = px::core::findAboutXml(root.path()); + REQUIRE(found.has_value()); + CHECK(found->endsWith(QStringLiteral("ABOUT/about.XML"))); +} + +TEST_CASE("findAboutXml returns nullopt when About has no About.xml") { + QTemporaryDir root; + REQUIRE(root.isValid()); + REQUIRE(QDir(root.path()).mkpath(QStringLiteral("About"))); + + CHECK_FALSE(px::core::findAboutXml(root.path()).has_value()); +} + +TEST_CASE("findAboutXml returns nullopt for a missing directory") { + QTemporaryDir root; + REQUIRE(root.isValid()); + + CHECK_FALSE(px::core::findAboutXml(root.filePath(QStringLiteral("nonexistent"))).has_value()); +} + +TEST_CASE("isAsciiDigits accepts only non-empty ASCII digit strings") { + CHECK(px::core::isAsciiDigits(QStringLiteral("123"))); + CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral(""))); + CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral("12a"))); + CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral(" 12"))); +} diff --git a/tests/core/test_xml.cpp b/tests/core/test_xml.cpp new file mode 100644 index 0000000..9d46c97 --- /dev/null +++ b/tests/core/test_xml.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "core/xml.h" + +using namespace px::core; + +namespace { + +std::string dump(const glz::json_t& value) { + const auto text = value.dump(); + REQUIRE(text.has_value()); + return *text; +} + +} // namespace + +TEST_CASE("elementToJson maps attributes, repeated children, and trimmed text") { + pugi::xml_document doc; + REQUIRE(doc.load_string( + R"(xy hi )")); + + CHECK(dump(elementToJson(doc.document_element())) == + R"({"root":{"@a":"1","item":["x","y"],"text":"hi"}})"); +} + +TEST_CASE("childText returns stripped text and treats blank elements as missing") { + pugi::xml_document doc; + REQUIRE(doc.load_string(" value ")); + const pugi::xml_node root = doc.document_element(); + + REQUIRE(childText(root, "a").has_value()); + CHECK(*childText(root, "a") == QStringLiteral("value")); + CHECK_FALSE(childText(root, "b").has_value()); + CHECK_FALSE(childText(root, "missing").has_value()); +} + +TEST_CASE("appendJsonAsXml inverts elementToJson") { + glz::json_t::object_t inner; + glz::json_t::array_t items; + glz::json_t first; + first.data = std::string("a"); + glz::json_t second; + second.data = std::string("b"); + items.push_back(first); + items.push_back(second); + glz::json_t activeMods; + activeMods.data = glz::json_t::object_t{{"li", glz::json_t{}}}; + std::get(activeMods.data)["li"].data = items; + glz::json_t version; + version.data = std::string("1.5"); + inner["version"] = version; + inner["activeMods"] = activeMods; + glz::json_t data; + data.data = glz::json_t::object_t{{"ModsConfigData", glz::json_t{}}}; + std::get(data.data)["ModsConfigData"].data = inner; + + pugi::xml_document doc; + REQUIRE(appendJsonAsXml(doc, data)); + CHECK(dump(elementToJson(doc.document_element())) == dump(data)); +} + +TEST_CASE("appendJsonAsXml rejects dictionaries without exactly one root key") { + glz::json_t data; + data.data = glz::json_t::object_t{}; + pugi::xml_document doc; + const auto result = appendJsonAsXml(doc, data); + REQUIRE_FALSE(result); + CHECK(result.error().message == QStringLiteral("Dictionary must have exactly one root key")); +} + +TEST_CASE("loadXmlFile reports missing files") { + QTemporaryDir root; + REQUIRE(root.isValid()); + pugi::xml_document doc; + const auto result = loadXmlFile(doc, QDir(root.path()).filePath(QStringLiteral("nope.xml"))); + REQUIRE_FALSE(result); + CHECK(result.error().message.startsWith(QStringLiteral("XML file does not exist at"))); +} + +TEST_CASE("xmlFileToJson reads a file into the legacy dict shape") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString path = QDir(root.path()).filePath(QStringLiteral("ModIds.xml")); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write("
  • one
  • two
  • "); + file.close(); + + const auto data = xmlFileToJson(path); + REQUIRE(data.has_value()); + CHECK(dump(*data) == R"({"ModIdsToFix":{"li":["one","two"]}})"); +} diff --git a/tests/core/workshop/support.h b/tests/core/workshop/support.h new file mode 100644 index 0000000..f8783ae --- /dev/null +++ b/tests/core/workshop/support.h @@ -0,0 +1,222 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/workshop/types.h" + +namespace workshop_test { + +// Awaits a coroutine on a local event loop, so network replies and disk work can complete. +template +T run(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +// Payloads exactly as the catalog service sends them. +inline constexpr std::string_view kModJson = R"JSON({ + "id": "2009463077", + "kind": "mod", + "source": "steam", + "title": "Harmony", + "author": { + "id": "76561198000000000", + "name": "Author", + "profile_url": "https://steamcommunity.com/profiles/76561198000000000" + }, + "description": "[b]Untrusted[/b] ", + "description_format": "bbcode", + "preview_url": "https://images.example/harmony.jpg", + "previews": [ + {"type": "image", "url": "https://images.example/harmony.jpg"}, + {"type": "video", "url": "https://www.youtube.com/watch?v=example"} + ], + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2009463077", + "tags": ["1.6", "Libraries"], + "supported_versions": ["1.5", "1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "file_size": "18446744073709551615", + "subscriptions": 12345, + "votes": {"up": 9, "down": 1, "positive_percent": 90}, + "dependencies": ["818773962"], + "package_id": "brrainz.harmony", + "incompatible": false +})JSON"; + +inline constexpr std::string_view kCollectionJson = R"JSON({ + "id": "picked:starter", + "kind": "collection", + "source": "picked", + "steam_id": null, + "title": "Starter pack", + "author": {"id": null, "name": "PxModRim", "profile_url": null}, + "description": "Plain text", + "description_format": "text", + "preview_url": null, + "previews": [], + "workshop_url": null, + "tags": ["Quality of Life"], + "supported_versions": ["1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "member_ids": ["2009463077"], + "member_count": 1, + "member_previews": [], + "total_size": null, + "no_common_version": false +})JSON"; + +inline px::core::workshop::CatalogMod decode_mod(std::string_view json) { + px::core::workshop::CatalogMod mod; + REQUIRE_FALSE(glz::read(mod, std::string(json))); + return mod; +} + +inline px::core::workshop::CatalogCollection decode_collection(std::string_view json) { + px::core::workshop::CatalogCollection collection; + REQUIRE_FALSE(glz::read(collection, std::string(json))); + return collection; +} + +// The wire form of any catalog type, for building responses. +template +std::string wire(const T& value) { + auto text = glz::write(value); + REQUIRE(text.has_value()); + return *text; +} + +struct HttpRequest { + std::string method; + std::string target; + std::string body; + + QUrlQuery query() const { + return QUrlQuery(QUrl(QStringLiteral("http://localhost") + QString::fromStdString(target))); + } + QString path() const { return QUrl(QStringLiteral("http://localhost") + QString::fromStdString(target)).path(); } +}; + +struct HttpResponse { + int status = 200; + std::string body; +}; + +using Handler = std::function; + +// A loopback catalog: answers each request with `handler`, one request per connection. +class FakeCatalog { +public: + explicit FakeCatalog(Handler handler) : handler_(std::move(handler)) { + REQUIRE(server_.listen(QHostAddress::LocalHost)); + QObject::connect(&server_, &QTcpServer::newConnection, &server_, [this] { accept(); }); + } + + QString url() const { return QStringLiteral("http://127.0.0.1:%1").arg(server_.serverPort()); } + + std::vector requests; + +private: + void accept() { + QTcpSocket* socket = server_.nextPendingConnection(); + socket->setParent(&server_); + QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); + auto raw = std::make_shared(); + auto answered = std::make_shared(false); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket, raw, answered] { + *raw += socket->readAll(); + if (*answered || !complete(*raw)) { + return; + } + *answered = true; + HttpRequest request = parse(*raw); + requests.push_back(request); + socket->write(serialize(handler_(request))); + socket->disconnectFromHost(); + }); + } + + // True once the headers and the Content-Length body have arrived. + static bool complete(const QByteArray& raw) { + const qsizetype headers_end = raw.indexOf("\r\n\r\n"); + if (headers_end < 0) { + return false; + } + return raw.size() - (headers_end + 4) >= content_length(raw.left(headers_end)); + } + + static qint64 content_length(const QByteArray& headers) { + const QByteArray lower = headers.toLower(); + const qsizetype key = lower.indexOf("content-length:"); + if (key < 0) { + return 0; + } + qsizetype eol = lower.indexOf("\r\n", key); + if (eol < 0) { + eol = lower.size(); + } + return lower.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + } + + static HttpRequest parse(const QByteArray& raw) { + const qsizetype headers_end = raw.indexOf("\r\n\r\n"); + const QByteArray request_line = raw.left(raw.indexOf("\r\n")); + const QList parts = request_line.split(' '); + HttpRequest request; + request.method = parts.value(0).toStdString(); + request.target = parts.value(1).toStdString(); + request.body = raw.mid(headers_end + 4, content_length(raw.left(headers_end))).toStdString(); + return request; + } + + static QByteArray serialize(const HttpResponse& response) { + return "HTTP/1.1 " + QByteArray::number(response.status) + " " + reason(response.status) + + "\r\nContent-Type: application/json\r\nContent-Length: " + + QByteArray::number(static_cast(response.body.size())) + + "\r\nConnection: close\r\n\r\n" + QByteArray::fromStdString(response.body); + } + + static QByteArray reason(int status) { + switch (status) { + case 200: return "OK"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 404: return "Not Found"; + case 500: return "Internal Server Error"; + case 502: return "Bad Gateway"; + case 503: return "Service Unavailable"; + default: return "Status"; + } + } + + QTcpServer server_; + Handler handler_; +}; + +// A URL that refuses connections: the port was bound, then released. +inline QString unreachable_url() { + QTcpServer probe; + REQUIRE(probe.listen(QHostAddress::LocalHost)); + const quint16 port = probe.serverPort(); + probe.close(); + return QStringLiteral("http://127.0.0.1:%1").arg(port); +} + +} // namespace workshop_test diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp new file mode 100644 index 0000000..aa30adb --- /dev/null +++ b/tests/core/workshop/test_catalog.cpp @@ -0,0 +1,468 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../test_support.h" +#include "core/config.h" +#include "core/downloads/downloader.h" +#include "core/downloads/manager.h" +#include "core/downloads/types.h" +#include "core/file_io.h" +#include "core/models/metadata/structures.h" +#include "core/workshop/catalog.h" +#include "core/workshop/client.h" +#include "core/workshop/types.h" +#include "support.h" + +using namespace px::core; +using namespace px::core::workshop; +using test_support::TestApplication; +using test_support::write_bytes; +using workshop_test::FakeCatalog; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::decode_mod; +using workshop_test::run; +using workshop_test::wire; + +namespace { +struct BatchWire { + std::vector items; + std::vector unavailable_ids; +}; + +struct IdsBatchPayload { + std::vector ids; +}; + +struct FakeDownloader : public Downloader { + FakeDownloader(DownloadManager& manager, std::map synced) + : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), manager), synced_(std::move(synced)) {} + + bool is_downloading() const override { return is_busy_; } + QSet active_ids() const override { return active_; } + bool accepts(const QString&) const override { return true; } + std::optional updatable_id(const ListedMod& mod) const override { return mod.published_file_id(); } + std::optional last_synced(const ListedMod& mod) const override { + const auto id = mod.published_file_id(); + if (!id) { + return std::nullopt; + } + const auto found = synced_.find(*id); + return found == synced_.end() ? std::nullopt : std::optional(found->second); + } + + QCoro::Task> download_mods(QStringList ids) override { + recorded_calls.push_back(ids); + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + + void cancel() override {} + + std::map synced_; + bool is_busy_ = false; + QSet active_; + std::vector recorded_calls; +}; + +ListedModPtr make_mod(const QString& path, const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + REQUIRE(mod->set_mod_path(path).has_value()); + mod->provider_id = provider; + return mod; +} + +CatalogMod mod_with(std::string id, std::optional updated) { + CatalogMod mod = decode_mod(workshop_test::kModJson); + mod.id = std::move(id); + mod.updated_at = updated; + return mod; +} + +struct ContextFixture { + QTemporaryDir dir; + std::unique_ptr config; + DownloadManager downloads; + std::unique_ptr downloader; + QList mods; + + ContextFixture() { + REQUIRE(dir.isValid()); + config = std::make_unique(dir.path()); + downloader = std::make_unique(downloads, std::map{ + {QStringLiteral("1"), 200.0}, + {QStringLiteral("2"), 50.0}, + }); + downloads.register_downloader(*downloader); + + // Mods 1, 2, 4 are local; 3 is workshop; NotWorkshop has no numeric folder name. + mods.append(make_mod(dir.filePath(QStringLiteral("Mods/1")))); + mods.append(make_mod(dir.filePath(QStringLiteral("Mods/2")))); + mods.append(make_mod(dir.filePath(QStringLiteral("Workshop/3")), QStringLiteral("workshop"))); + mods.append(make_mod(dir.filePath(QStringLiteral("Mods/4")))); + mods.append(make_mod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); + } +}; + +struct ResolvePayload { + std::vector ids; + std::vector collection_ids; +}; + +} // namespace + +TEST_CASE("workshop install state uses sync timestamp, not directory mtime") { + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + CHECK(catalog.install_state(mod_with("1", 100)) == InstallState::installed); + CHECK(catalog.install_state(mod_with("2", 100)) == InstallState::outdated); + // 3 has no sync record, so it cannot be outdated. + CHECK(catalog.install_state(mod_with("3", 100)) == InstallState::installed); + CHECK(catalog.install_state(mod_with("4", 100)) == InstallState::installed); + // 5 is not installed at all. + CHECK(catalog.install_state(mod_with("5", 100)) == InstallState::missing); + // When updated_at is null or equal to sync, the mod is installed. + CHECK(catalog.install_state(mod_with("2", std::nullopt)) == InstallState::installed); + CHECK(catalog.install_state(mod_with("2", 50)) == InstallState::installed); +} + +TEST_CASE("workshop plan keeps dependency order and incomplete flags") { + TestApplication app; + ContextFixture fixture; + + FakeCatalog server([](const HttpRequest&) -> HttpResponse { + ResolvedDownload answer; + answer.roots = {"5", "picked:starter"}; + for (const char* id : {"1", "2", "3", "4", "5"}) { + CatalogMod m = mod_with(id, 100); + m.title = std::string("Title ") + id; + answer.items.emplace(id, std::move(m)); + } + answer.items.emplace("picked:starter", workshop_test::decode_collection(workshop_test::kCollectionJson)); + answer.mod_ids = {"1", "2", "3", "4", "5"}; + answer.unavailable_ids = {"99"}; + answer.incomplete_collection_ids = {"picked:starter"}; + answer.is_complete = false; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog.set_base_url(server.url()); + + const auto plan = run(catalog.plan({QStringLiteral("5")}, {QStringLiteral("picked:starter")})); + REQUIRE(plan.has_value()); + CHECK(plan->to_download == QStringList{QStringLiteral("2"), QStringLiteral("5")}); + CHECK(plan->already_current == QStringList{QStringLiteral("1"), QStringLiteral("3"), QStringLiteral("4")}); + CHECK(plan->unavailable_ids == QStringList{QStringLiteral("99")}); + CHECK(plan->incomplete_collection_ids == QStringList{QStringLiteral("picked:starter")}); + CHECK_FALSE(plan->is_complete); + CHECK(plan->titles.value(QStringLiteral("2")) == QStringLiteral("Title 2")); + + const auto payload = glz::read_json(server.requests.back().body); + REQUIRE(payload.has_value()); + CHECK(payload->ids == std::vector{"5"}); + CHECK(payload->collection_ids == std::vector{"picked:starter"}); +} + +TEST_CASE("workshop installed metadata uses batch") { + TestApplication app; + ContextFixture fixture; + + std::vector requested_ids; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + requested_ids = body->ids; + BatchWire answer{ + .items = {mod_with("2", 100)}, + .unavailable_ids = {"1", "3", "4"}, + }; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog.set_base_url(server.url()); + + const auto installed = run(catalog.installed_with_updates()); + REQUIRE(installed.has_value()); + CHECK(requested_ids == std::vector{"1", "2", "3", "4"}); + REQUIRE(installed->size() == 1); + CHECK(catalog.install_state(installed->front()) == InstallState::outdated); +} + +TEST_CASE("workshop download delegates without side effects") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + DownloadPlan plan{ + .to_download = {QStringLiteral("2")}, + .already_current = {QStringLiteral("1")}, + .titles = {{QStringLiteral("2"), QStringLiteral("Mod")}}, + .is_complete = true, + }; + const auto result = run(catalog.download(plan)); + REQUIRE(result.has_value()); + CHECK(result->succeeded == QStringList{QStringLiteral("2")}); + REQUIRE(fixture.downloader->recorded_calls.size() == 1); + CHECK(fixture.downloader->recorded_calls[0] == QStringList{QStringLiteral("2")}); +} + +TEST_CASE("workshop empty URL is disabled without creating a client") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog.set_base_url(QString()); + CHECK_FALSE(catalog.configured()); + + const auto discover = run(catalog.discover()); + REQUIRE_FALSE(discover.has_value()); + CHECK(discover.error().message.contains(QStringLiteral("disabled"))); + + const auto installed = run(catalog.installed_with_updates()); + REQUIRE_FALSE(installed.has_value()); + CHECK(installed.error().message.contains(QStringLiteral("disabled"))); + + const auto plan = run(catalog.plan({QStringLiteral("1")}, {})); + REQUIRE_FALSE(plan.has_value()); + CHECK(plan.error().message.contains(QStringLiteral("disabled"))); +} + +TEST_CASE("workshop configuration and installed events") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + std::vector url_changes; + int installed_invalidated = 0; + catalog.catalog_url_changed.connect([&](const QString& url) { url_changes.push_back(url); }); + catalog.installed_changed.connect([&](std::monostate) { ++installed_invalidated; }); + + catalog.set_base_url(QStringLiteral(" https://other.example/ ")); + CHECK(catalog.settings().url == "https://other.example"); + const auto stored = fixture.config->load_existing(QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "https://other.example"); + + catalog.set_base_url(QString()); + CHECK_FALSE(catalog.configured()); + + // Firing download_finished and manual invalidation both emit installed_changed. + catalog.invalidate_installed(); + fixture.downloads.download_finished.notify(DownloadResult{}); + CHECK(installed_invalidated == 2); + CHECK(url_changes == std::vector{QStringLiteral("https://other.example"), QString()}); +} + +TEST_CASE("workshop cached serves remembered value before refreshing") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + std::vector sequence{"first", "second", "second"}; + std::size_t next_idx = 0; + int calls = 0; + + auto produce = [&]() -> QCoro::Task> { + ++calls; + co_return sequence.at(next_idx++); + }; + + auto seen = [&](double fresh_for) { + std::vector out; + const auto done = run(catalog.cached( + QStringLiteral("k"), produce, [&](const std::string& v) { out.push_back(v); }, fresh_for)); + REQUIRE(done.has_value()); + return out; + }; + + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(calls == 1); + CHECK(seen(0.0) == std::vector{"first", "second"}); + CHECK(calls == 2); + CHECK(seen(0.0) == std::vector{"second"}); + CHECK(calls == 3); +} + +TEST_CASE("workshop enqueue runs batches in order after the running download") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + fixture.downloader->is_busy_ = true; + DownloadPlan first{.to_download = {QStringLiteral("5"), QStringLiteral("6")}}; + DownloadPlan second{.to_download = {QStringLiteral("6"), QStringLiteral("7")}}; + catalog.enqueue(first); + catalog.enqueue(second); + + CHECK(catalog.queued_ids() == QSet{QStringLiteral("5"), QStringLiteral("6"), QStringLiteral("7")}); + CHECK(fixture.downloader->recorded_calls.empty()); + + // Once the downloads report idle, the queue drains both batches in order. + fixture.downloader->is_busy_ = false; + fixture.downloads.busy_changed.notify(false); + + CHECK(fixture.downloader->recorded_calls.size() == 2); + CHECK(fixture.downloader->recorded_calls[0] == QStringList{QStringLiteral("5"), QStringLiteral("6")}); + CHECK(fixture.downloader->recorded_calls[1] == QStringList{QStringLiteral("7")}); + CHECK(catalog.queued_ids().isEmpty()); +} + +TEST_CASE("workshop cached values survive a restart and corrupt files are ignored") { + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{1, 2}; + }; + auto must_not_run = []() -> QCoro::Task>> { + FAIL("A fresh value on disk must not hit the catalog"); + co_return std::vector{}; + }; + + // First run persists to disk. + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + std::vector out; + const auto done = run(first.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Second run loads from disk without invoking produce. + { + WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + std::vector out; + const auto done = run(second.cached>( + QStringLiteral("k"), must_not_run, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Corrupting the file causes a miss: produce is invoked again. + const QString cache_dir = fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : QDir(cache_dir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(write_bytes(file.absoluteFilePath(), "{not json")); + } + { + WorkshopCatalog third(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + std::vector out; + const auto done = run(third.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } +} + +TEST_CASE("workshop equal cache refresh keeps the displayed mods and refreshes age") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + + int calls = 0; + auto produce = [&]() -> QCoro::Task>> { + ++calls; + co_return std::vector{mod_with("1", 100)}; + }; + + std::vector> initial; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, [&](const auto& v) { initial.push_back(v); }, 300.0)) + .has_value()); + CHECK(initial.size() == 1); + CHECK(calls == 1); + + // Refresh with fresh_for = 0: produce runs, but because the value is equal, show is called only once. + std::vector> refreshed; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, [&](const auto& v) { refreshed.push_back(v); }, 0.0)) + .has_value()); + CHECK(refreshed.size() == 1); + CHECK(refreshed[0] == initial[0]); + CHECK(calls == 2); + + // Immediate repeat: the refreshed timestamp is fresh, so produce does not run. + std::vector> current; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, [&](const auto& v) { current.push_back(v); }, 300.0)) + .has_value()); + CHECK(current.size() == 1); + CHECK(current[0] == initial[0]); + CHECK(calls == 2); +} + +TEST_CASE("workshop disk cache is reused in memory after first load") { + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{mod_with("1", 100)}; + }; + auto must_not_run = []() -> QCoro::Task>> { + FAIL("A fresh cached response must not hit the catalog"); + co_return std::vector{}; + }; + + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + REQUIRE(run(first.cached>(QStringLiteral("installed"), produce, [](const auto&) {}, 300.0, + /*persist=*/true)) + .has_value()); + } + + WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + std::vector loaded; + REQUIRE(run(second.cached>(QStringLiteral("installed"), must_not_run, + [&](const auto& v) { loaded = v; }, 300.0, /*persist=*/true)) + .has_value()); + REQUIRE(loaded.size() == 1); + + // Corrupt the disk file now: second catalog must still serve its in-memory copy. + const QString cache_dir = fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : QDir(cache_dir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(write_bytes(file.absoluteFilePath(), "{not json")); + } + + std::vector from_memory; + REQUIRE(run(second.cached>(QStringLiteral("installed"), must_not_run, + [&](const auto& v) { from_memory = v; }, 300.0, /*persist=*/true)) + .has_value()); + CHECK(from_memory == loaded); +} + +TEST_CASE("workshop catalog URL moves out of the old shared config") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(write_bytes(dir.filePath(QStringLiteral("config.json")), + R"({"schema_version":1,"workshop_catalog_url":"http://localhost:8787"})")); + + ConfigService service(dir.path()); + DownloadManager downloads; + WorkshopCatalog catalog(&service, downloads, [] { return QList{}; }); + CHECK(catalog.settings().url == "http://localhost:8787"); + + const auto stored = service.load_existing(QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "http://localhost:8787"); +} diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp new file mode 100644 index 0000000..a51e37c --- /dev/null +++ b/tests/core/workshop/test_client.cpp @@ -0,0 +1,428 @@ +#include + +#include +#include +#include + +#include +#include +#include + +#include "../test_support.h" +#include "core/workshop/client.h" +#include "core/workshop/types.h" +#include "support.h" + +using namespace px::core::workshop; +using test_support::TestApplication; +using workshop_test::FakeCatalog; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::decode_collection; +using workshop_test::decode_mod; +using workshop_test::run; +using workshop_test::unreachable_url; +using workshop_test::wire; + +namespace { + +struct ResolveRequest { + std::vector ids; + std::vector collection_ids; +}; + +struct IdsRequest { + std::vector ids; +}; + +// The `{"items":[...],"total":N,"next_cursor":...}` page shape. +std::string page(const std::string& items, int total, const std::string& cursor_json) { + return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + R"(,"next_cursor":)" + cursor_json + "}"; +} + +std::string error_body(const std::string& message) { + return R"({"error":{"message":")" + message + R"("}})"; +} + +QString query_value(const HttpRequest& request, const char* key) { + return request.query().queryItemValue(QString::fromLatin1(key)); +} + +CatalogMod with_id(std::string id) { + CatalogMod mod = decode_mod(workshop_test::kModJson); + mod.id = std::move(id); + return mod; +} + +CatalogCollection steam_collection(std::string id, std::string steam_id, std::vector members) { + CatalogCollection collection = decode_collection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.source = CollectionSource::steam; + collection.steam_id = std::move(steam_id); + collection.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + *collection.steam_id; + collection.member_count = static_cast(members.size()); + collection.member_ids = std::move(members); + return collection; +} + +} // namespace + +TEST_CASE("workshop client decodes every endpoint and sends the query") { + TestApplication app; + const std::string mod = std::string(workshop_test::kModJson); + const std::string collection = std::string(workshop_test::kCollectionJson); + const std::string nested = wire(steam_collection("steam:456", "456", {"2009463077"})); + const std::string steam = wire(steam_collection("steam:123", "123", {"2009463077", "steam:456", "99"})); + + const std::string detail_picked = R"({"collection":)" + collection + R"(,"members":[)" + mod + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string detail_steam = R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string resolution = R"({"roots":["picked:starter"],"items":{"2009463077":)" + mod + + R"(,"picked:starter":)" + collection + + R"(},"mod_ids":["2009463077"],"unavailable_ids":["99"],)" + R"("incomplete_collection_ids":["picked:starter"],"is_complete":false})"; + + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const std::string path = request.path().toStdString(); + if (path == "/catalog/discover") { + return {200, R"({"mods":)" + page(mod, 1, R"("next-page")") + R"(,"collections":)" + + page(collection, 1, "null") + "}"}; + } + if (path == "/catalog/mods") { + return {200, page(mod, 1, R"("next-page")")}; + } + if (path == "/catalog/collections") { + return {200, page(collection, 1, "null")}; + } + if (path == "/catalog/mods/2009463077") { + return {200, mod}; + } + if (path == "/catalog/collections/picked/starter") { + return {200, detail_picked}; + } + if (path == "/catalog/collections/steam/123") { + return {200, detail_steam}; + } + if (path == "/catalog/resolve") { + return {200, resolution}; + } + return {404, error_body("unknown")}; + }); + + CatalogClient client(server.url()); + const auto discover = run(client.discover()); + REQUIRE(discover.has_value()); + CHECK(discover->mods.items[0].file_size == std::string("18446744073709551615")); + CHECK(discover->mods.items[0].votes->positive_percent == 90.0); + CHECK(discover->mods.items[0].previews[1].type == PreviewType::video); + CHECK(discover->collections.items[0].description_format == CollectionDescriptionFormat::text); + + CatalogQuery query; + query.query = QStringLiteral("quality"); + query.tag = QStringLiteral("Libraries"); + query.version = QStringLiteral("1.6"); + query.sort = QStringLiteral("updated"); + query.limit = 50; + query.cursor = QStringLiteral("next"); + query.source = QStringLiteral("picked"); + const auto mods = run(client.mods(query)); + REQUIRE(mods.has_value()); + CHECK(mods->next_cursor == std::optional("next-page")); + const HttpRequest& sent = server.requests.back(); + CHECK(query_value(sent, "q") == QStringLiteral("quality")); + CHECK(query_value(sent, "tag") == QStringLiteral("Libraries")); + CHECK(query_value(sent, "version") == QStringLiteral("1.6")); + CHECK(query_value(sent, "sort") == QStringLiteral("updated")); + CHECK(query_value(sent, "limit") == QStringLiteral("50")); + CHECK(query_value(sent, "cursor") == QStringLiteral("next")); + CHECK(query_value(sent, "source") == QStringLiteral("picked")); + CHECK(sent.query().queryItems().size() == 7); + + const auto collections = run(client.collections(query)); + REQUIRE(collections.has_value()); + CHECK(collections->items[0].id == "picked:starter"); + + const auto single = run(client.mod("2009463077")); + REQUIRE(single.has_value()); + REQUIRE(single->has_value()); + CHECK((*single)->description == decode_mod(mod).description); + CHECK((*single)->kind == ModKind::mod); + + for (const char* id : {"picked:starter", "steam:123"}) { + const auto detail = run(client.collection(id)); + REQUIRE(detail.has_value()); + REQUIRE(detail->has_value()); + const CollectionDetail& value = **detail; + const bool is_steam = std::string(id) == "steam:123"; + REQUIRE(value.members.size() == (is_steam ? 2 : 1)); + CHECK(std::holds_alternative(value.members[0])); + CHECK(value.unavailable_ids == std::vector{"99"}); + CHECK_FALSE(value.is_complete); + if (is_steam) { + CHECK(std::holds_alternative(value.members[1])); + } + } + + const auto resolved = run(client.resolve({}, {"picked:starter"})); + REQUIRE(resolved.has_value()); + CHECK(resolved->roots == std::vector{"picked:starter"}); + CHECK(resolved->mod_ids == std::vector{"2009463077"}); + CHECK(std::holds_alternative(resolved->items.at("picked:starter"))); + CHECK(resolved->incomplete_collection_ids == std::vector{"picked:starter"}); + CHECK_FALSE(resolved->is_complete); + const auto body = glz::read_json(server.requests.back().body); + REQUIRE(body.has_value()); + CHECK(body->ids.empty()); + CHECK(body->collection_ids == std::vector{"picked:starter"}); +} + +TEST_CASE("workshop client reports error statuses as errors, never as empty results") { + TestApplication app; + for (int status : {400, 401, 500, 502, 503}) { + for (bool json_body : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return json_body ? HttpResponse{status, error_body("Catalog unavailable")} + : HttpResponse{status, "Upstream failure"}; + }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().status == status); + if (json_body) { + CHECK(result.error().message == QStringLiteral("Catalog unavailable")); + } else { + CHECK(result.error().message.contains(QStringLiteral("HTTP %1").arg(status))); + CHECK_FALSE(result.error().message.contains(QStringLiteral(""))); + } + } + } +} + +TEST_CASE("workshop optional details return nothing only for a 404") { + TestApplication app; + for (bool json_body : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return json_body ? HttpResponse{404, error_body("Item unavailable")} : HttpResponse{404, "Not found"}; + }); + CatalogClient client(server.url()); + const auto mod = run(client.mod("123")); + REQUIRE(mod.has_value()); + CHECK_FALSE(mod->has_value()); + const auto collection = run(client.collection("steam:123")); + REQUIRE(collection.has_value()); + CHECK_FALSE(collection->has_value()); + } +} + +TEST_CASE("workshop optional details keep server, network and decoding failures") { + TestApplication app; + FakeCatalog failing([](const HttpRequest&) -> HttpResponse { return {500, "Server error"}; }); + CatalogClient failing_client(failing.url()); + const auto server_error = run(failing_client.mod("123")); + REQUIRE_FALSE(server_error.has_value()); + CHECK(server_error.error().status == 500); + const auto collection_error = run(failing_client.collection("steam:123")); + REQUIRE_FALSE(collection_error.has_value()); + CHECK(collection_error.error().status == 500); + + CatalogClient offline(unreachable_url()); + const auto network = run(offline.mod("123")); + REQUIRE_FALSE(network.has_value()); + CHECK_FALSE(network.error().status.has_value()); + + FakeCatalog garbage([](const HttpRequest&) -> HttpResponse { return {200, "not-json"}; }); + CatalogClient garbage_client(garbage.url()); + const auto decoding = run(garbage_client.collection("steam:123")); + REQUIRE_FALSE(decoding.has_value()); + CHECK(decoding.error().status == 200); +} + +TEST_CASE("workshop non-optional endpoints keep 404 errors") { + TestApplication app; + FakeCatalog server([](const HttpRequest&) -> HttpResponse { return {404, ""}; }); + CatalogClient client(server.url()); + CatalogQuery query; + + const auto mods = run(client.mods(query)); + REQUIRE_FALSE(mods.has_value()); + CHECK(mods.error().status == 404); + const auto collections = run(client.collections(query)); + REQUIRE_FALSE(collections.has_value()); + CHECK(collections.error().status == 404); + const auto batch = run(client.mods_batch({"123"})); + REQUIRE_FALSE(batch.has_value()); + CHECK(batch.error().status == 404); + const auto resolved = run(client.resolve({"123"}, {})); + REQUIRE_FALSE(resolved.has_value()); + CHECK(resolved.error().status == 404); +} + +TEST_CASE("workshop client rejects invalid collection ids before sending") { + TestApplication app; + FakeCatalog server([](const HttpRequest&) -> HttpResponse { return {200, "{}"}; }); + CatalogClient client(server.url()); + for (const char* id : {"steam", "steam:", ":123", "other:123"}) { + const auto result = run(client.collection(id)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Invalid Workshop collection ID.")); + } + CHECK(server.requests.empty()); +} + +TEST_CASE("workshop client treats responses that violate the wire contract as invalid") { + TestApplication app; + const std::string valid_mod(workshop_test::kModJson); + std::string numeric_id = valid_mod; + numeric_id.replace(numeric_id.find("\"2009463077\""), 12, "123"); + std::string bad_kind = valid_mod; + bad_kind.replace(bad_kind.find("\"mod\""), 5, "\"collection\""); + std::string missing_title = valid_mod; + missing_title.replace(missing_title.find("\"title\": \"Harmony\","), 21, ""); + + for (const std::string& body : {numeric_id, bad_kind, missing_title, std::string("not-json")}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { return {200, body}; }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("The Workshop catalog returned an invalid response.")); + CHECK(result.error().status == 200); + } +} + +TEST_CASE("workshop batch requests split ids into chunks of 100") { + TestApplication app; + FakeCatalog server([](const HttpRequest& request) -> HttpResponse { + const auto ids = glz::read_json(request.body); + REQUIRE(ids.has_value()); + std::string items; + std::vector unavailable; + for (const std::string& id : ids->ids) { + if (id == "105") { + unavailable.push_back(id); + continue; + } + items += (items.empty() ? "" : ",") + wire(with_id(id)); + } + std::string unavailable_json = "["; + for (const std::string& id : unavailable) { + unavailable_json += "\"" + id + "\""; + } + return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + unavailable_json + "]}"}; + }); + + CatalogClient client(server.url()); + std::vector ids; + for (int id = 1; id <= 205; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + const auto result = run(client.mods_batch(qids)); + REQUIRE(result.has_value()); + + REQUIRE(server.requests.size() == 3); + CHECK(glz::read_json(server.requests[0].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[1].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[2].body)->ids.size() == 5); + CHECK(result->items.size() == 204); + CHECK(result->items.front().id == "1"); + CHECK(result->unavailable_ids == std::vector{"105"}); + + const auto empty = run(client.mods_batch({})); + REQUIRE(empty.has_value()); + CHECK(empty->items.empty()); + CHECK(empty->unavailable_ids.empty()); + CHECK(server.requests.size() == 3); +} + +TEST_CASE("workshop transport failures are catalog errors without a status") { + TestApplication app; + CatalogClient client(unreachable_url()); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith(QStringLiteral("Cannot reach the Workshop catalog"))); + CHECK_FALSE(result.error().status.has_value()); +} + +TEST_CASE("workshop disabled client sends nothing") { + TestApplication app; + CatalogClient client(QStringLiteral("")); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(QStringLiteral("disabled"))); + CHECK_FALSE(result.error().status.has_value()); +} + +TEST_CASE("workshop resolve splits requests and merges their answers") { + TestApplication app; + std::vector sent; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + sent.push_back(*body); + const bool second = sent.size() == 2; + + // Every answer repeats the dependency 999, which must be reported once. + std::vector mod_ids{"999"}; + mod_ids.insert(mod_ids.end(), body->ids.begin(), body->ids.end()); + std::string items = "{"; + for (const std::string& id : mod_ids) { + items += "\"" + id + "\":" + wire(with_id(id)); + items += id == mod_ids.back() ? "" : ","; + } + items += "}"; + std::string mods_json = "["; + for (const std::string& id : mod_ids) { + mods_json += "\"" + id + "\","; + } + mods_json.back() = ']'; + std::string roots_json = "["; + for (const std::string& id : body->ids) { + roots_json += "\"" + id + "\","; + } + for (const std::string& id : body->collection_ids) { + roots_json += "\"" + id + "\","; + } + roots_json.back() = ']'; + + return {200, R"({"roots":)" + roots_json + R"(,"items":)" + items + R"(,"mod_ids":)" + mods_json + + R"(,"unavailable_ids":)" + (second ? R"(["404"])" : "[]") + + R"(,"incomplete_collection_ids":)" + (second ? R"(["picked:pack-10"])" : "[]") + + R"(,"is_complete":)" + (second ? "false" : "true") + "}"}; + }); + + std::vector ids; + for (int id = 1; id <= 103; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + QStringList collections; + for (int index = 0; index < 11; ++index) { + collections.append(QStringLiteral("picked:pack-%1").arg(index)); + } + + CatalogClient client(server.url()); + const auto resolved = run(client.resolve(qids, collections)); + REQUIRE(resolved.has_value()); + + REQUIRE(sent.size() == 3); + CHECK(sent[0].ids.size() == 50); + CHECK(sent[1].ids.size() == 50); + CHECK(sent[2].ids.size() == 3); + CHECK(sent[0].collection_ids.size() == 10); + CHECK(sent[1].collection_ids.size() == 1); + CHECK(sent[2].collection_ids.empty()); + + CHECK(resolved->roots.size() == 114); + CHECK(resolved->mod_ids.size() == 104); + CHECK(resolved->mod_ids.front() == "999"); + CHECK(resolved->unavailable_ids == std::vector{"404"}); + CHECK(resolved->incomplete_collection_ids == std::vector{"picked:pack-10"}); + CHECK_FALSE(resolved->is_complete); +} diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp new file mode 100644 index 0000000..23f3f91 --- /dev/null +++ b/tests/core/workshop/test_favourites.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include + +#include + +#include "core/config.h" +#include "core/workshop/favourites.h" +#include "core/workshop/types.h" +#include "support.h" + +using namespace px::core; +using namespace px::core::workshop; +using workshop_test::decode_collection; + +namespace { + +CatalogCollection make_collection(std::string id, std::string title = "Pack") { + CatalogCollection collection = decode_collection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.title = std::move(title); + return collection; +} + +} // namespace + +TEST_CASE("workshop favourites persist newest first and survive a restart") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + + FavouriteCollections favourites(&service); + const CatalogCollection first = make_collection("steam:1"); + const CatalogCollection second = make_collection("steam:2"); + favourites.set(first, true); + favourites.set(second, true); + // Setting an already-starred collection again is a no-op: it does not jump to the front. + favourites.set(second, true); + + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 2); + CHECK(reloaded.items()[0].id == "steam:2"); + CHECK(reloaded.items()[1].id == "steam:1"); + CHECK(reloaded.contains("steam:1")); + + reloaded.set(first, false); + FavouriteCollections after_removal(&service); + REQUIRE(after_removal.items().size() == 1); + CHECK(after_removal.items()[0].id == "steam:2"); + CHECK_FALSE(after_removal.contains("steam:1")); +} + +TEST_CASE("workshop favourites remember refreshes only starred snapshots") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + + FavouriteCollections favourites(&service); + favourites.set(make_collection("steam:1", "Old title"), true); + + int saves = 0; + favourites.changed().connect([&](const FavouritesState&) { ++saves; }); + + // An equal collection does not save. + favourites.remember(make_collection("steam:1", "Old title")); + // A collection that was never starred does not save. + favourites.remember(make_collection("steam:2", "Not starred")); + CHECK(saves == 0); + + favourites.remember(make_collection("steam:1", "New title")); + CHECK(saves == 1); + + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 1); + CHECK(reloaded.items()[0].title == "New title"); + CHECK(reloaded.items()[0].id == "steam:1"); +} diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp new file mode 100644 index 0000000..5ee1a23 --- /dev/null +++ b/tests/core/workshop/test_types.cpp @@ -0,0 +1,121 @@ +#include + +#include + +#include + +#include "core/workshop/types.h" +#include "support.h" + +using namespace px::core::workshop; +using workshop_test::decode_collection; +using workshop_test::decode_mod; +using workshop_test::wire; + +namespace { + +// Decodes `json` as a `T`, returning whether glaze accepted it. +template +bool accepts(const std::string& json) { + T value{}; + return !glz::read(value, json); +} + +} // namespace + +TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form") { + CatalogMod mod = decode_mod(workshop_test::kModJson); + REQUIRE(mod.id == "2009463077"); + REQUIRE(mod.title == "Harmony"); + REQUIRE(mod.votes.has_value()); + REQUIRE(mod.votes->positive_percent == 90.0); + REQUIRE(mod.previews[1].type == PreviewType::video); + REQUIRE(mod.file_size == std::string("18446744073709551615")); + + // Absent values stay absent instead of being invented. + const std::string nulled = R"({ + "id": "7", "kind": "mod", "source": "steam", "title": "T", + "author": {"id": "1", "name": null, "profile_url": null}, + "description": "", "description_format": "bbcode", "preview_url": null, + "previews": [], "workshop_url": "u", "tags": [], "supported_versions": [], + "created_at": null, "updated_at": null, "file_size": null, "subscriptions": null, + "votes": null, "dependencies": [], "package_id": null, "incompatible": false + })"; + CatalogMod sparse = decode_mod(nulled); + CHECK_FALSE(sparse.author.name.has_value()); + CHECK_FALSE(sparse.votes.has_value()); + CHECK_FALSE(sparse.updated_at.has_value()); + CHECK_FALSE(sparse.file_size.has_value()); + + const std::string encoded = wire(sparse); + CHECK(encoded.find(R"("kind":"mod")") != std::string::npos); + CHECK(decode_mod(encoded) == sparse); + CHECK(decode_mod(wire(mod)) == mod); +} + +TEST_CASE("workshop steam collection and empty page decode") { + CatalogCollection collection = decode_collection(R"({ + "id": "steam:123", "kind": "collection", "source": "steam", "steam_id": "123", + "title": "Pack", "author": {"id": null, "name": "Curator", "profile_url": null}, + "description": "[b]x[/b]", "description_format": "bbcode", + "preview_url": null, "previews": [], + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", + "tags": [], "supported_versions": ["1.6"], "created_at": null, "updated_at": 5, + "member_ids": ["2009463077", "steam:456"], "member_count": 2, + "member_previews": ["https://images.example.test/a.png"], + "total_size": "10", "no_common_version": true + })"); + CHECK(collection.member_ids == std::vector{"2009463077", "steam:456"}); + CHECK(collection.source == CollectionSource::steam); + CHECK(collection.no_common_version); + CHECK(decode_collection(wire(collection)) == collection); + + CatalogPage page{}; + REQUIRE(accepts>(R"({"items":[],"total":0,"next_cursor":null})")); + REQUIRE_FALSE(glz::read(page, std::string(R"({"items":[],"total":0,"next_cursor":null})"))); + CHECK(page.items.empty()); + CHECK(page.total == 0); + CHECK_FALSE(page.next_cursor.has_value()); +} + +TEST_CASE("workshop wire decoding rejects payloads outside the catalog contract") { + // A numeric id is not a string. + std::string numeric_id(workshop_test::kModJson); + numeric_id.replace(numeric_id.find("\"2009463077\""), 12, "123"); + CHECK_FALSE(accepts(numeric_id)); + + // A collection payload is not a mod. + CHECK_FALSE(accepts(std::string(workshop_test::kCollectionJson))); + + // A mod payload is not a collection. + CHECK_FALSE(accepts(std::string(workshop_test::kModJson))); + + // A missing required key fails. + std::string missing_title(workshop_test::kModJson); + missing_title.replace(missing_title.find("\"title\": \"Harmony\","), 21, ""); + CHECK_FALSE(accepts(missing_title)); + + // A preview type outside image|video fails. + std::string bad_preview(workshop_test::kModJson); + bad_preview.replace(bad_preview.find("\"image\""), 7, "\"audio\""); + CHECK_FALSE(accepts(bad_preview)); + + CHECK_FALSE(accepts("not-json")); +} + +TEST_CASE("workshop wire decoding ignores keys the contract does not name") { + std::string extra(workshop_test::kModJson); + extra.insert(1, R"("unexpected": {"nested": true},)"); + CHECK(accepts(extra)); +} + +TEST_CASE("workshop catalog query has the documented defaults") { + CatalogQuery query; + CHECK(query.query.isEmpty()); + CHECK(query.sort == QStringLiteral("popular")); + CHECK(query.limit == 24); + CHECK(query.source == QStringLiteral("all")); + CHECK_FALSE(query.tag.has_value()); + CHECK_FALSE(query.version.has_value()); + CHECK_FALSE(query.cursor.has_value()); +} diff --git a/tests/data/local_mods/valid_mod/About/About.xml b/tests/data/local_mods/valid_mod/About/About.xml new file mode 100644 index 0000000..c162c11 --- /dev/null +++ b/tests/data/local_mods/valid_mod/About/About.xml @@ -0,0 +1,14 @@ + + + Test Mod + TestAuthor + test.mod + A test mod for unit testing. + +
  • 1.5
  • +
    + 1.0.0 + +
  • ludeon.rimworld
  • +
    +
    diff --git a/tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml b/tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml new file mode 100644 index 0000000..c162c11 --- /dev/null +++ b/tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml @@ -0,0 +1,14 @@ + + + Test Mod + TestAuthor + test.mod + A test mod for unit testing. + +
  • 1.5
  • +
    + 1.0.0 + +
  • ludeon.rimworld
  • +
    +
    diff --git a/tests/data/valid_mod/About/About.xml b/tests/data/valid_mod/About/About.xml new file mode 100644 index 0000000..c162c11 --- /dev/null +++ b/tests/data/valid_mod/About/About.xml @@ -0,0 +1,14 @@ + + + Test Mod + TestAuthor + test.mod + A test mod for unit testing. + +
  • 1.5
  • +
    + 1.0.0 + +
  • ludeon.rimworld
  • +
    +
    From 146f649862cada5d9b09efb9dd3a90e3c9436afb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 01:52:12 +0300 Subject: [PATCH 03/50] feat(ui): port Qt Widgets/QML UI, plugins and composition root to C++23 --- .gitignore | 4 +- AGENTS.md | 4 +- CHANGELOG.md | 1 + CMakeLists.txt | 4 +- scripts/build-inject-js.py | 12 +- src/app/CMakeLists.txt | 3 +- src/app/main.cpp | 221 ++++- src/app/plugins.cpp | 105 +++ src/app/plugins.h | 48 + src/core/context.h | 5 +- src/core/workshop/catalog.cpp | 2 +- src/ui/CMakeLists.txt | 50 +- src/ui/CatalogCard.qml | 135 +++ src/ui/CatalogImage.qml | 18 + src/ui/CatalogSettings.qml | 36 + src/ui/CatalogThumb.qml | 45 + src/ui/CollectionBanner.qml | 126 +++ src/ui/DiagnosticBadge.qml | 49 ++ src/ui/DownloadSidebar.qml | 150 ++++ src/ui/Downloads.qml | 278 ++++++ src/ui/EmptyState.qml | 54 ++ src/ui/ErrorBanner.qml | 35 + src/ui/FilterSidebar.qml | 127 +++ src/ui/Header.qml | 368 ++++++++ src/ui/IconRail.qml | 246 ++++++ src/ui/ModDescription.qml | 29 + src/ui/ModInfo.qml | 539 ++++++++++++ src/ui/ModList.qml | 764 ++++++++++++++++ src/ui/ModRow.qml | 89 ++ src/ui/ModTree.qml | 410 +++++++++ src/ui/PxBadge.qml | 57 ++ src/ui/PxButton.qml | 132 +++ src/ui/PxCheckBox.qml | 31 + src/ui/PxCheckIndicator.qml | 48 + src/ui/PxComboBox.qml | 92 ++ src/ui/PxDialog.qml | 77 ++ src/ui/PxDialogFooter.qml | 37 + src/ui/PxMenu.qml | 37 + src/ui/PxMenuItem.qml | 62 ++ src/ui/PxProgressBar.qml | 49 ++ src/ui/PxScrollBar.qml | 21 + src/ui/PxSpinBox.qml | 71 ++ src/ui/PxTextField.qml | 40 + src/ui/PxToolTip.qml | 28 + src/ui/RuleEditor.qml | 481 ++++++++++ src/ui/Settings.qml | 419 +++++++++ src/ui/SteamSettings.qml | 114 +++ src/ui/SteamWorkshop.qml | 220 +++++ src/ui/TagEditor.qml | 208 +++++ src/ui/TagPicker.qml | 94 ++ src/ui/TimeAnalytics.qml | 832 ++++++++++++++++++ src/ui/Workshop.qml | 582 ++++++++++++ src/ui/assets/logo.png | Bin 0 -> 36953 bytes src/ui/assets/logo.svg | 15 + src/ui/assets/logo_nobg_1.svg | 14 + src/ui/assets/logo_nobg_2.svg | 15 + src/ui/assets/logo_nobg_3.svg | 14 + src/ui/assets/logo_nobg_4.svg | 15 + src/ui/components/banner_widget.cpp | 135 +++ src/ui/components/banner_widget.h | 46 + src/ui/components/button.cpp | 12 + src/ui/components/button.h | 16 + src/ui/components/components.h | 13 + src/ui/components/dialog_chrome.cpp | 179 ++++ src/ui/components/dialog_chrome.h | 16 + src/ui/components/dialogs.h | 34 + src/ui/components/filter_sidebar.cpp | 21 + src/ui/components/filter_sidebar.h | 13 + src/ui/components/header_controller.cpp | 118 +++ src/ui/components/header_controller.h | 115 +++ src/ui/components/header_panel.cpp | 24 + src/ui/components/header_panel.h | 19 + src/ui/components/icon_button.cpp | 46 + src/ui/components/icon_button.h | 23 + src/ui/components/icon_table.inc | 43 + src/ui/components/icons.cpp | 84 ++ src/ui/components/icons.h | 22 + src/ui/components/mod_activation.cpp | 43 + src/ui/components/mod_activation.h | 13 + src/ui/components/mod_updates.cpp | 57 ++ src/ui/components/mod_updates.h | 21 + src/ui/components/popup_parent.cpp | 24 + src/ui/components/popup_parent.h | 23 + src/ui/components/procedural_preview.cpp | 98 +++ src/ui/components/procedural_preview.h | 7 + src/ui/components/progress_dialog.cpp | 148 ++++ src/ui/components/progress_dialog.h | 50 ++ src/ui/components/svg_provider.cpp | 66 ++ src/ui/components/svg_provider.h | 26 + src/ui/components/toast.cpp | 155 ++++ src/ui/components/toast.h | 60 ++ src/ui/components/unity_rich_text.cpp | 135 +++ src/ui/components/unity_rich_text.h | 6 + src/ui/components/view_rail_panel.cpp | 42 + src/ui/components/view_rail_panel.h | 31 + src/ui/config.cpp | 40 + src/ui/config.h | 34 + src/ui/context.cpp | 70 ++ src/ui/context.h | 165 ++++ src/ui/mod_selection.cpp | 59 ++ src/ui/mod_selection.h | 46 + src/ui/models/impact.cpp | 19 + src/ui/models/impact.h | 6 + src/ui/models/mod_list_model.cpp | 200 +++++ src/ui/models/mod_list_model.h | 70 ++ src/ui/models/mod_list_proxy_model.cpp | 138 +++ src/ui/models/mod_list_proxy_model.h | 44 + src/ui/models/sidebar_model.cpp | 74 ++ src/ui/models/sidebar_model.h | 26 + src/ui/navigation.cpp | 34 + src/ui/navigation.h | 31 + src/ui/panels/about_panel.cpp | 233 +++++ src/ui/panels/about_panel.h | 24 + src/ui/panels/keyboard_shortcuts_dialog.cpp | 36 + src/ui/panels/keyboard_shortcuts_dialog.h | 21 + src/ui/panels/mod_info_data.cpp | 175 ++++ src/ui/panels/mod_info_data.h | 15 + src/ui/panels/mod_info_panel.cpp | 212 +++++ src/ui/panels/mod_info_panel.h | 52 ++ src/ui/panels/mod_list_panel.cpp | 151 ++++ src/ui/panels/mod_list_panel.h | 71 ++ src/ui/panels/restore_snapshot_dialog.cpp | 197 +++++ src/ui/panels/restore_snapshot_dialog.h | 57 ++ src/ui/panels/settings_panel.cpp | 159 ++++ src/ui/panels/settings_panel.h | 67 ++ src/ui/panels/sidebar_panel.cpp | 33 + src/ui/panels/sidebar_panel.h | 25 + src/ui/panels/startup_impact_data.cpp | 142 +++ src/ui/panels/startup_impact_data.h | 13 + src/ui/panels/time_analytics_panel.cpp | 48 + src/ui/panels/time_analytics_panel.h | 28 + src/ui/panels/update_dialog.cpp | 52 ++ src/ui/panels/update_dialog.h | 14 + src/ui/panels/upload_report_dialog.cpp | 149 ++++ src/ui/panels/upload_report_dialog.h | 21 + src/ui/placeholder.cpp | 1 - src/ui/plugins/downloads/model.cpp | 202 +++++ src/ui/plugins/downloads/model.h | 98 +++ src/ui/plugins/downloads/plugin.cpp | 10 + src/ui/plugins/downloads/plugin.h | 13 + src/ui/plugins/downloads/view.cpp | 52 ++ src/ui/plugins/downloads/view.h | 33 + src/ui/plugins/organizer/dialogs.cpp | 144 +++ src/ui/plugins/organizer/dialogs.h | 54 ++ src/ui/plugins/organizer/filter_model.cpp | 69 ++ src/ui/plugins/organizer/filter_model.h | 26 + src/ui/plugins/organizer/plugin.h | 14 + src/ui/plugins/organizer/tree_model.cpp | 315 +++++++ src/ui/plugins/organizer/tree_model.h | 87 ++ src/ui/plugins/organizer/view.cpp | 586 ++++++++++++ src/ui/plugins/organizer/view.h | 130 +++ src/ui/plugins/steam_downloader/plugin.cpp | 20 + src/ui/plugins/steam_downloader/plugin.h | 25 + src/ui/plugins/steam_workshop/bridge_obj.cpp | 99 +++ src/ui/plugins/steam_workshop/bridge_obj.h | 27 + .../steam_workshop/download_queue_model.cpp | 51 ++ .../steam_workshop/download_queue_model.h | 35 + .../steam_workshop/download_sidebar.cpp | 42 + .../plugins/steam_workshop/download_sidebar.h | 33 + src/ui/plugins/steam_workshop/inject.css | 393 +++++++++ src/ui/plugins/steam_workshop/plugin.cpp | 237 +++++ src/ui/plugins/steam_workshop/plugin.h | 71 ++ src/ui/plugins/steam_workshop/ts/api.ts | 69 ++ src/ui/plugins/steam_workshop/ts/badges.ts | 74 ++ src/ui/plugins/steam_workshop/ts/bridge.ts | 138 +++ .../plugins/steam_workshop/ts/collection.ts | 275 ++++++ src/ui/plugins/steam_workshop/ts/controls.ts | 165 ++++ src/ui/plugins/steam_workshop/ts/deps.ts | 216 +++++ src/ui/plugins/steam_workshop/ts/detail.ts | 237 +++++ src/ui/plugins/steam_workshop/ts/globals.d.ts | 24 + src/ui/plugins/steam_workshop/ts/lifecycle.ts | 230 +++++ src/ui/plugins/steam_workshop/ts/main.ts | 37 + .../plugins/steam_workshop/ts/qwebchannel.js | 457 ++++++++++ src/ui/plugins/steam_workshop/ts/rendering.ts | 165 ++++ .../plugins/steam_workshop/ts/tsconfig.json | 13 + src/ui/plugins/steam_workshop/ts/types.ts | 32 + src/ui/plugins/steam_workshop/ts/utils.ts | 59 ++ src/ui/plugins/steam_workshop/view.cpp | 164 ++++ src/ui/plugins/steam_workshop/view.h | 45 + src/ui/plugins/workshop/details.cpp | 59 ++ src/ui/plugins/workshop/details.h | 21 + src/ui/plugins/workshop/installed.cpp | 60 ++ src/ui/plugins/workshop/installed.h | 37 + src/ui/plugins/workshop/models.cpp | 225 +++++ src/ui/plugins/workshop/models.h | 51 ++ src/ui/plugins/workshop/plugin.cpp | 24 + src/ui/plugins/workshop/plugin.h | 28 + src/ui/plugins/workshop/view.cpp | 452 ++++++++++ src/ui/plugins/workshop/view.h | 199 +++++ src/ui/settings_section.cpp | 32 + src/ui/settings_section.h | 102 +++ src/ui/theme/constants.h | 26 + src/ui/theme/palette.cpp | 107 +++ src/ui/theme/palette.h | 30 + src/ui/theme/palette_table.inc | 28 + src/ui/theme/qml_theme.cpp | 91 ++ src/ui/theme/qml_theme.h | 122 +++ src/ui/theme/style.qss | 663 ++++++++++++++ src/ui/thumbs.js | 24 + src/ui/ui_prefs.h | 19 + src/ui/views/base.cpp | 25 + src/ui/views/base.h | 24 + src/ui/views/mods_view.cpp | 26 + src/ui/views/mods_view.h | 23 + src/ui/views/views.h | 5 + src/ui/window/actions.cpp | 66 ++ src/ui/window/actions.h | 38 + src/ui/window/main_window.cpp | 574 ++++++++++++ src/ui/window/main_window.h | 129 +++ src/ui/window/menu_bar.cpp | 28 + src/ui/window/menu_bar.h | 22 + tests/CMakeLists.txt | 9 +- tests/ui/main.cpp | 13 + tests/ui/test_dialog_chrome.cpp | 46 + tests/ui/test_downloads_model.cpp | 49 ++ tests/ui/test_main_window.cpp | 189 ++++ tests/ui/test_menu_bar.cpp | 69 ++ tests/ui/test_mod_info_data.cpp | 271 ++++++ tests/ui/test_mod_list_model.cpp | 123 +++ tests/ui/test_mod_list_panel.cpp | 175 ++++ tests/ui/test_mod_list_proxy_model.cpp | 129 +++ tests/ui/test_mod_tree_model.cpp | 279 ++++++ tests/ui/test_mod_updates.cpp | 27 + tests/ui/test_navigation.cpp | 30 + tests/ui/test_organizer_dialogs.cpp | 39 + tests/ui/test_organizer_rule_editor.cpp | 355 ++++++++ tests/ui/test_procedural_preview.cpp | 68 ++ tests/ui/test_restore_snapshot_dialog.cpp | 267 ++++++ tests/ui/test_settings_panel.cpp | 58 ++ tests/ui/test_sidebar_model.cpp | 33 + tests/ui/test_startup_impact_data.cpp | 98 +++ tests/ui/test_support.h | 20 + tests/ui/test_toast_and_progress.cpp | 88 ++ tests/ui/test_unity_rich_text.cpp | 128 +++ tests/ui/test_views.cpp | 51 ++ tests/ui/test_workshop_images.cpp | 165 ++++ tests/ui/test_workshop_installed.cpp | 91 ++ tests/ui/test_workshop_models.cpp | 228 +++++ tests/ui/test_workshop_view.cpp | 417 +++++++++ 239 files changed, 24905 insertions(+), 34 deletions(-) create mode 100644 src/app/plugins.cpp create mode 100644 src/app/plugins.h create mode 100644 src/ui/CatalogCard.qml create mode 100644 src/ui/CatalogImage.qml create mode 100644 src/ui/CatalogSettings.qml create mode 100644 src/ui/CatalogThumb.qml create mode 100644 src/ui/CollectionBanner.qml create mode 100644 src/ui/DiagnosticBadge.qml create mode 100644 src/ui/DownloadSidebar.qml create mode 100644 src/ui/Downloads.qml create mode 100644 src/ui/EmptyState.qml create mode 100644 src/ui/ErrorBanner.qml create mode 100644 src/ui/FilterSidebar.qml create mode 100644 src/ui/Header.qml create mode 100644 src/ui/IconRail.qml create mode 100644 src/ui/ModDescription.qml create mode 100644 src/ui/ModInfo.qml create mode 100644 src/ui/ModList.qml create mode 100644 src/ui/ModRow.qml create mode 100644 src/ui/ModTree.qml create mode 100644 src/ui/PxBadge.qml create mode 100644 src/ui/PxButton.qml create mode 100644 src/ui/PxCheckBox.qml create mode 100644 src/ui/PxCheckIndicator.qml create mode 100644 src/ui/PxComboBox.qml create mode 100644 src/ui/PxDialog.qml create mode 100644 src/ui/PxDialogFooter.qml create mode 100644 src/ui/PxMenu.qml create mode 100644 src/ui/PxMenuItem.qml create mode 100644 src/ui/PxProgressBar.qml create mode 100644 src/ui/PxScrollBar.qml create mode 100644 src/ui/PxSpinBox.qml create mode 100644 src/ui/PxTextField.qml create mode 100644 src/ui/PxToolTip.qml create mode 100644 src/ui/RuleEditor.qml create mode 100644 src/ui/Settings.qml create mode 100644 src/ui/SteamSettings.qml create mode 100644 src/ui/SteamWorkshop.qml create mode 100644 src/ui/TagEditor.qml create mode 100644 src/ui/TagPicker.qml create mode 100644 src/ui/TimeAnalytics.qml create mode 100644 src/ui/Workshop.qml create mode 100644 src/ui/assets/logo.png create mode 100644 src/ui/assets/logo.svg create mode 100644 src/ui/assets/logo_nobg_1.svg create mode 100644 src/ui/assets/logo_nobg_2.svg create mode 100644 src/ui/assets/logo_nobg_3.svg create mode 100644 src/ui/assets/logo_nobg_4.svg create mode 100644 src/ui/components/banner_widget.cpp create mode 100644 src/ui/components/banner_widget.h create mode 100644 src/ui/components/button.cpp create mode 100644 src/ui/components/button.h create mode 100644 src/ui/components/components.h create mode 100644 src/ui/components/dialog_chrome.cpp create mode 100644 src/ui/components/dialog_chrome.h create mode 100644 src/ui/components/dialogs.h create mode 100644 src/ui/components/filter_sidebar.cpp create mode 100644 src/ui/components/filter_sidebar.h create mode 100644 src/ui/components/header_controller.cpp create mode 100644 src/ui/components/header_controller.h create mode 100644 src/ui/components/header_panel.cpp create mode 100644 src/ui/components/header_panel.h create mode 100644 src/ui/components/icon_button.cpp create mode 100644 src/ui/components/icon_button.h create mode 100644 src/ui/components/icon_table.inc create mode 100644 src/ui/components/icons.cpp create mode 100644 src/ui/components/icons.h create mode 100644 src/ui/components/mod_activation.cpp create mode 100644 src/ui/components/mod_activation.h create mode 100644 src/ui/components/mod_updates.cpp create mode 100644 src/ui/components/mod_updates.h create mode 100644 src/ui/components/popup_parent.cpp create mode 100644 src/ui/components/popup_parent.h create mode 100644 src/ui/components/procedural_preview.cpp create mode 100644 src/ui/components/procedural_preview.h create mode 100644 src/ui/components/progress_dialog.cpp create mode 100644 src/ui/components/progress_dialog.h create mode 100644 src/ui/components/svg_provider.cpp create mode 100644 src/ui/components/svg_provider.h create mode 100644 src/ui/components/toast.cpp create mode 100644 src/ui/components/toast.h create mode 100644 src/ui/components/unity_rich_text.cpp create mode 100644 src/ui/components/unity_rich_text.h create mode 100644 src/ui/components/view_rail_panel.cpp create mode 100644 src/ui/components/view_rail_panel.h create mode 100644 src/ui/config.cpp create mode 100644 src/ui/config.h create mode 100644 src/ui/context.cpp create mode 100644 src/ui/context.h create mode 100644 src/ui/mod_selection.cpp create mode 100644 src/ui/mod_selection.h create mode 100644 src/ui/models/impact.cpp create mode 100644 src/ui/models/impact.h create mode 100644 src/ui/models/mod_list_model.cpp create mode 100644 src/ui/models/mod_list_model.h create mode 100644 src/ui/models/mod_list_proxy_model.cpp create mode 100644 src/ui/models/mod_list_proxy_model.h create mode 100644 src/ui/models/sidebar_model.cpp create mode 100644 src/ui/models/sidebar_model.h create mode 100644 src/ui/navigation.cpp create mode 100644 src/ui/navigation.h create mode 100644 src/ui/panels/about_panel.cpp create mode 100644 src/ui/panels/about_panel.h create mode 100644 src/ui/panels/keyboard_shortcuts_dialog.cpp create mode 100644 src/ui/panels/keyboard_shortcuts_dialog.h create mode 100644 src/ui/panels/mod_info_data.cpp create mode 100644 src/ui/panels/mod_info_data.h create mode 100644 src/ui/panels/mod_info_panel.cpp create mode 100644 src/ui/panels/mod_info_panel.h create mode 100644 src/ui/panels/mod_list_panel.cpp create mode 100644 src/ui/panels/mod_list_panel.h create mode 100644 src/ui/panels/restore_snapshot_dialog.cpp create mode 100644 src/ui/panels/restore_snapshot_dialog.h create mode 100644 src/ui/panels/settings_panel.cpp create mode 100644 src/ui/panels/settings_panel.h create mode 100644 src/ui/panels/sidebar_panel.cpp create mode 100644 src/ui/panels/sidebar_panel.h create mode 100644 src/ui/panels/startup_impact_data.cpp create mode 100644 src/ui/panels/startup_impact_data.h create mode 100644 src/ui/panels/time_analytics_panel.cpp create mode 100644 src/ui/panels/time_analytics_panel.h create mode 100644 src/ui/panels/update_dialog.cpp create mode 100644 src/ui/panels/update_dialog.h create mode 100644 src/ui/panels/upload_report_dialog.cpp create mode 100644 src/ui/panels/upload_report_dialog.h delete mode 100644 src/ui/placeholder.cpp create mode 100644 src/ui/plugins/downloads/model.cpp create mode 100644 src/ui/plugins/downloads/model.h create mode 100644 src/ui/plugins/downloads/plugin.cpp create mode 100644 src/ui/plugins/downloads/plugin.h create mode 100644 src/ui/plugins/downloads/view.cpp create mode 100644 src/ui/plugins/downloads/view.h create mode 100644 src/ui/plugins/organizer/dialogs.cpp create mode 100644 src/ui/plugins/organizer/dialogs.h create mode 100644 src/ui/plugins/organizer/filter_model.cpp create mode 100644 src/ui/plugins/organizer/filter_model.h create mode 100644 src/ui/plugins/organizer/plugin.h create mode 100644 src/ui/plugins/organizer/tree_model.cpp create mode 100644 src/ui/plugins/organizer/tree_model.h create mode 100644 src/ui/plugins/organizer/view.cpp create mode 100644 src/ui/plugins/organizer/view.h create mode 100644 src/ui/plugins/steam_downloader/plugin.cpp create mode 100644 src/ui/plugins/steam_downloader/plugin.h create mode 100644 src/ui/plugins/steam_workshop/bridge_obj.cpp create mode 100644 src/ui/plugins/steam_workshop/bridge_obj.h create mode 100644 src/ui/plugins/steam_workshop/download_queue_model.cpp create mode 100644 src/ui/plugins/steam_workshop/download_queue_model.h create mode 100644 src/ui/plugins/steam_workshop/download_sidebar.cpp create mode 100644 src/ui/plugins/steam_workshop/download_sidebar.h create mode 100644 src/ui/plugins/steam_workshop/inject.css create mode 100644 src/ui/plugins/steam_workshop/plugin.cpp create mode 100644 src/ui/plugins/steam_workshop/plugin.h create mode 100644 src/ui/plugins/steam_workshop/ts/api.ts create mode 100644 src/ui/plugins/steam_workshop/ts/badges.ts create mode 100644 src/ui/plugins/steam_workshop/ts/bridge.ts create mode 100644 src/ui/plugins/steam_workshop/ts/collection.ts create mode 100644 src/ui/plugins/steam_workshop/ts/controls.ts create mode 100644 src/ui/plugins/steam_workshop/ts/deps.ts create mode 100644 src/ui/plugins/steam_workshop/ts/detail.ts create mode 100644 src/ui/plugins/steam_workshop/ts/globals.d.ts create mode 100644 src/ui/plugins/steam_workshop/ts/lifecycle.ts create mode 100644 src/ui/plugins/steam_workshop/ts/main.ts create mode 100644 src/ui/plugins/steam_workshop/ts/qwebchannel.js create mode 100644 src/ui/plugins/steam_workshop/ts/rendering.ts create mode 100644 src/ui/plugins/steam_workshop/ts/tsconfig.json create mode 100644 src/ui/plugins/steam_workshop/ts/types.ts create mode 100644 src/ui/plugins/steam_workshop/ts/utils.ts create mode 100644 src/ui/plugins/steam_workshop/view.cpp create mode 100644 src/ui/plugins/steam_workshop/view.h create mode 100644 src/ui/plugins/workshop/details.cpp create mode 100644 src/ui/plugins/workshop/details.h create mode 100644 src/ui/plugins/workshop/installed.cpp create mode 100644 src/ui/plugins/workshop/installed.h create mode 100644 src/ui/plugins/workshop/models.cpp create mode 100644 src/ui/plugins/workshop/models.h create mode 100644 src/ui/plugins/workshop/plugin.cpp create mode 100644 src/ui/plugins/workshop/plugin.h create mode 100644 src/ui/plugins/workshop/view.cpp create mode 100644 src/ui/plugins/workshop/view.h create mode 100644 src/ui/settings_section.cpp create mode 100644 src/ui/settings_section.h create mode 100644 src/ui/theme/constants.h create mode 100644 src/ui/theme/palette.cpp create mode 100644 src/ui/theme/palette.h create mode 100644 src/ui/theme/palette_table.inc create mode 100644 src/ui/theme/qml_theme.cpp create mode 100644 src/ui/theme/qml_theme.h create mode 100644 src/ui/theme/style.qss create mode 100644 src/ui/thumbs.js create mode 100644 src/ui/ui_prefs.h create mode 100644 src/ui/views/base.cpp create mode 100644 src/ui/views/base.h create mode 100644 src/ui/views/mods_view.cpp create mode 100644 src/ui/views/mods_view.h create mode 100644 src/ui/views/views.h create mode 100644 src/ui/window/actions.cpp create mode 100644 src/ui/window/actions.h create mode 100644 src/ui/window/main_window.cpp create mode 100644 src/ui/window/main_window.h create mode 100644 src/ui/window/menu_bar.cpp create mode 100644 src/ui/window/menu_bar.h create mode 100644 tests/ui/main.cpp create mode 100644 tests/ui/test_dialog_chrome.cpp create mode 100644 tests/ui/test_downloads_model.cpp create mode 100644 tests/ui/test_main_window.cpp create mode 100644 tests/ui/test_menu_bar.cpp create mode 100644 tests/ui/test_mod_info_data.cpp create mode 100644 tests/ui/test_mod_list_model.cpp create mode 100644 tests/ui/test_mod_list_panel.cpp create mode 100644 tests/ui/test_mod_list_proxy_model.cpp create mode 100644 tests/ui/test_mod_tree_model.cpp create mode 100644 tests/ui/test_mod_updates.cpp create mode 100644 tests/ui/test_navigation.cpp create mode 100644 tests/ui/test_organizer_dialogs.cpp create mode 100644 tests/ui/test_organizer_rule_editor.cpp create mode 100644 tests/ui/test_procedural_preview.cpp create mode 100644 tests/ui/test_restore_snapshot_dialog.cpp create mode 100644 tests/ui/test_settings_panel.cpp create mode 100644 tests/ui/test_sidebar_model.cpp create mode 100644 tests/ui/test_startup_impact_data.cpp create mode 100644 tests/ui/test_support.h create mode 100644 tests/ui/test_toast_and_progress.cpp create mode 100644 tests/ui/test_unity_rich_text.cpp create mode 100644 tests/ui/test_views.cpp create mode 100644 tests/ui/test_workshop_images.cpp create mode 100644 tests/ui/test_workshop_installed.cpp create mode 100644 tests/ui/test_workshop_models.cpp create mode 100644 tests/ui/test_workshop_view.cpp diff --git a/.gitignore b/.gitignore index b182c5f..fba1793 100644 --- a/.gitignore +++ b/.gitignore @@ -59,14 +59,12 @@ ltex.dictionary* _dummy_pxmodrim.py # Generated JavaScript (built from ts/ sources) -src/pxmodrim/ui/plugins/steam_workshop/inject.js +src/ui/plugins/steam_workshop/inject.js # OpenCode .opencode/** opencode.json -# Steam workshop plugin -src/pxmodrim/ui/plugins/steam_workshop/ # Local classification data dump installed-mod-categories.csv diff --git a/AGENTS.md b/AGENTS.md index 700f0be..862b8d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only - Always make commits on the go -- never leave working tree uncommitted at milestones or task completion. ## TypeScript / JS build -Steam Workshop WebView injection uses TypeScript sources in `src/pxmodrim/ui/plugins/steam_workshop/ts/`, bundled to `inject.js` via esbuild: +Steam Workshop WebView injection uses TypeScript sources in `src/ui/plugins/steam_workshop/ts/`, bundled to `src/ui/plugins/steam_workshop/inject.js` via esbuild: ``` just build-js # npx esbuild ts/main.ts --bundle --format=iife --target=es2020 @@ -65,7 +65,7 @@ just build-js # npx esbuild ts/main.ts --bundle --format=iife --target=es2020 `build-js` runs as a prerequisite of `run`, `test`, `check`, and `build`. Cached by mtime comparison -- only rebuilds when `ts/` sources change. -Output `inject.js` is gitignored; always build before running. +Output `inject.js` is gitignored. When present at configure/build time, it is embedded as a Qt resource; without it the web browser remains usable but has no injected download controls. ## Layer dependency checker `just check-deps` (`scripts/check-deps.py`) enforces strict import rules via pydeps. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2614f..29f63d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Project license changed from LGPL-3.0 to GPL-3.0-or-later. Parts of earlier releases were derived from RimSort (GPL-3.0); `NOTICE` previously described RimSort as MIT-licensed, which was incorrect and is fixed. +- Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. ## [0.3.1] - 2026-10-13 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6128a0d..03c0f50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,8 +24,10 @@ set(CPM_USE_LOCAL_PACKAGES ON) set(CPM_SOURCE_CACHE "$ENV{HOME}/.cache/CPM" CACHE PATH "") include(cmake/CPM.cmake) -find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets Test) qt_standard_project_setup(REQUIRES 6.8) +# No .ui files; AUTOUIC would misread ui/ui_prefs.h as a generated uic header. +set(CMAKE_AUTOUIC OFF) set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") if(NOT PX_WORKSHOP_VARIANT STREQUAL "NativeWorkshop" AND NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") diff --git a/scripts/build-inject-js.py b/scripts/build-inject-js.py index 44d9ed2..de108f3 100644 --- a/scripts/build-inject-js.py +++ b/scripts/build-inject-js.py @@ -6,16 +6,8 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent -TS_DIR = PROJECT_ROOT / "src" / "pxmodrim" / "ui" / "plugins" / "steam_workshop" / "ts" -OUT_FILE = ( - PROJECT_ROOT - / "src" - / "pxmodrim" - / "ui" - / "plugins" - / "steam_workshop" - / "inject.js" -) +TS_DIR = PROJECT_ROOT / "src" / "ui" / "plugins" / "steam_workshop" / "ts" +OUT_FILE = TS_DIR.parent / "inject.js" def main() -> int: diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 3e2b02c..d0bbdfa 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -1,2 +1,3 @@ -qt_add_executable(pxmodrim main.cpp) +file(GLOB PX_APP_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +qt_add_executable(pxmodrim ${PX_APP_SOURCES}) target_link_libraries(pxmodrim PRIVATE pxui pxuiplugin) diff --git a/src/app/main.cpp b/src/app/main.cpp index 8cae79a..82042f5 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,18 +1,221 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include #include -#include +#include +#include +#include #include +#include +#include +#include +#include "app/plugins.h" +#include "core/config.h" +#include "core/context.h" +#include "core/organizer/db.h" #include "core/version.h" +#include "ui/components/popup_parent.h" +#include "ui/config.h" +#include "ui/context.h" +#include "ui/theme/palette.h" +#include "ui/window/main_window.h" Q_IMPORT_QML_PLUGIN(PxModRimPlugin) +namespace { + +constexpr int kWheelScrollLines = 8; +constexpr qint64 kLogMaxBytes = 10 * 1024 * 1024; +constexpr std::size_t kLogRetention = 5; + +// Qt6 defaults to PassThrough, which makes QtWebEngine render at integer buffer scale +// while the compositor upscales fractional scales, which blurs. RoundPreferFloor avoids +// the blur at the cost of slightly smaller UI (the QTBUG-113574 workaround). +void setEnvDefaults() { + const std::pair defaults[] = { + {"QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor"}, + {"QT_ENABLE_HIGHDPI_SCALING", "1"}, + {"QT_QUICK_CONTROLS_STYLE", "Basic"}, + }; + for (const auto& [name, value] : defaults) { + if (!qEnvironmentVariableIsSet(name)) { + qputenv(name, value); + } + } +} + +// PX_LOG_LEVEL takes precedence over LOGURU_LEVEL (the legacy name). Unknown values +// and loguru's SUCCESS level fall back to info. +spdlog::level::level_enum logLevelFromEnv() { + for (const char* name : {"PX_LOG_LEVEL", "LOGURU_LEVEL"}) { + const std::string value = qEnvironmentVariable(name).trimmed().toLower().toStdString(); + if (value.empty()) { + continue; + } + const spdlog::level::level_enum level = spdlog::level::from_str(value); + if (level != spdlog::level::off || value == "off") { + return level; + } + } + return spdlog::level::info; +} + +// Logs to stderr, and to a rotating file under the config directory when it can be opened. +void configureLogging(const QString& logsDir) { + std::vector sinks{std::make_shared()}; + auto logger = std::make_shared("pxmodrim", sinks.begin(), sinks.end()); + logger->set_pattern("%Y-%m-%d %H:%M:%S.%e | %^%-8l%$ | %v"); + logger->set_level(logLevelFromEnv()); + spdlog::set_default_logger(logger); + + QDir().mkpath(logsDir); + const std::string logFile = QDir(logsDir).filePath(QStringLiteral("pxmodrim.log")).toStdString(); + try { + // spdlog reports an unopenable file by throwing; that is the only way it reports it. + logger->sinks().push_back(std::make_shared( + logFile, kLogMaxBytes, kLogRetention)); + } catch (const spdlog::spdlog_ex& ex) { + spdlog::warn("Logging to {} is disabled: {}", logFile, ex.what()); + } +} + +int smokeExitMs() { + bool ok = false; + const int ms = qEnvironmentVariableIntValue("PX_SMOKE_EXIT_MS", &ok); + return ok ? ms : 0; +} + +// Shuts UI plugins down, then the core context. Used once the app has started. +QCoro::Task teardown(px::ui::AppContext& appContext, px::core::CoreContext& ctx) { + if (auto shut = co_await appContext.shutdownAll(); !shut) { + spdlog::error("Shutdown failed: {}", shut.error().message.toStdString()); + } + co_await ctx.close(); +} + +// Startup, then waiting for the application to quit. Eager: it runs until the first +// suspension, which is the quit wait, so main can tell an early failure from a +// running app (see main()). +QCoro::Task runApp(QApplication& qtApp, QSet disabled) { + spdlog::info("Starting PxModRim"); + + px::core::ConfigService configService(px::core::config_dir()); + px::core::AppConfigService appConfigService(configService); + px::ui::UiPrefsService uiPrefsService(configService); + + if (auto setup = co_await appConfigService.setup(); !setup) { + spdlog::error("Failed to initialize configuration: {}", setup.error().message.toStdString()); + co_return 1; + } + if (auto setup = co_await uiPrefsService.setup(); !setup) { + spdlog::error("Failed to initialize UI preferences: {}", setup.error().message.toStdString()); + co_return 1; + } + + px::core::AppConfig cfg = appConfigService.cfg(); + spdlog::info("Configuration loaded: game={}, local={}, workshop={}", cfg.paths.game, cfg.paths.local, + cfg.paths.workshop); + + if (cfg.paths.game.empty()) { + spdlog::info("No game path in config, attempting auto-detect"); + if (auto detected = px::core::detect_game_paths(); !detected.game.empty()) { + cfg.paths = detected; + if (auto saved = configService.save(QStringLiteral("config.json"), cfg); !saved) { + spdlog::warn("Cannot save detected game paths: {}", saved.error().message.toStdString()); + } + } + } + + // Declared before the context, so it outlives the organizer service that holds it. + px::core::organizer::OrganizerDb organizerDb(px::core::organizer::db_path(configService.directory())); + auto ctx = px::core::CoreContext::create(cfg, &configService); + const QStringList downloaders = px::app::registerCorePlugins(*ctx, configService, organizerDb, disabled); + + px::ui::AppContext appContext(*ctx, uiPrefsService.prefs()); + px::app::PluginSelection selection{std::move(disabled), downloaders}; + px::app::registerUiPlugins(appContext, selection); + + if (auto setup = appContext.setupAll(); !setup) { + spdlog::error("Plugin setup failed: {}", setup.error().message.toStdString()); + ctx->closeSync(); + co_return 1; + } + spdlog::info("Services initialized for game version {}", ctx->gameVersion().toStdString()); + + // The window opens before mods load, as in the legacy app. + auto window = std::make_unique(appContext); + window->setAppQuitCallback([] { QCoreApplication::quit(); }); + window->show(); + + if (ctx->config().paths.game.empty()) { + spdlog::warn("No game path configured; mods are not loaded until one is set"); + } else { + auto refreshed = co_await appContext.refreshMods(); + if (!refreshed) { + spdlog::error("Mod load failed: {}", refreshed.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; + } + spdlog::info("Initial mod load complete: {} mods", *refreshed); + + if (auto init = co_await appContext.initAll(); !init) { + spdlog::error("Plugin init failed: {}", init.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; + } + window->startStartupUpdateCheck(); + } + + // Runs until the application quits (window close, or PX_SMOKE_EXIT_MS). + co_await qCoro(&qtApp, &QCoreApplication::aboutToQuit); + co_await teardown(appContext, *ctx); + co_return 0; +} + +} // namespace + int main(int argc, char* argv[]) { - QGuiApplication app(argc, argv); - app.setApplicationName(QStringLiteral("PxModRim")); - app.setApplicationVersion(px::core::appVersion()); - - QQmlApplicationEngine engine; - engine.loadFromModule("PxModRim", "Main"); - if (engine.rootObjects().isEmpty()) return 1; - return app.exec(); + setEnvDefaults(); + + QApplication qtApp(argc, argv); + qtApp.setApplicationName(QStringLiteral("PxModRim")); + qtApp.setApplicationVersion(px::core::appVersion()); + qtApp.setOrganizationName(QStringLiteral("PxModRim")); + qtApp.setApplicationDisplayName(QStringLiteral("PxModRim")); + qtApp.setDesktopFileName(QStringLiteral("pxmodrim")); + qtApp.setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + // The app quits when its run finishes, not when the last window closes. + qtApp.setQuitOnLastWindowClosed(false); + qtApp.styleHints()->setWheelScrollLines(kWheelScrollLines); + px::ui::applyAppTheme(qtApp); + qtApp.installEventFilter(new px::ui::QuickPopupParenting(&qtApp)); + + configureLogging(QDir(px::core::config_dir()).filePath(QStringLiteral("logs"))); + + if (const int smokeMs = smokeExitMs(); smokeMs > 0) { + QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); + } + + const QString manifest = + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("workshop-variant.txt")); + auto disabled = px::app::parseDisabledPlugins(qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); + if (!disabled) { + spdlog::critical("{}", disabled.error().message.toStdString()); + return 1; + } + + auto run = runApp(qtApp, std::move(*disabled)); + // An early failure has already finished the run; otherwise run the event loop until quit. + if (!run.isReady()) { + qtApp.exec(); + } + return QCoro::waitFor(std::move(run)); } diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp new file mode 100644 index 0000000..e8ee5fe --- /dev/null +++ b/src/app/plugins.cpp @@ -0,0 +1,105 @@ +#include "app/plugins.h" + +#include + +#include +#include +#include +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/organizer/db.h" +#include "core/organizer/service.h" +#include "core/workshop/catalog.h" +#include "ui/plugins/downloads/plugin.h" +#include "ui/plugins/steam_downloader/plugin.h" +#include "ui/plugins/steam_workshop/plugin.h" +#include "ui/plugins/workshop/plugin.h" +#include "ui/plugins/organizer/plugin.h" +#include "ui/views/mods_view.h" + +#ifdef PX_HAS_STEAM +#include "core/downloads/steam.h" +#endif + +namespace px::app { + +std::expected, px::core::Error> parseDisabledPlugins(const QString& envValue, + const QString& manifestPath) { + QSet disabled; + for (const QString& part : envValue.split(QLatin1Char(','))) { + const QString name = part.trimmed(); + if (!name.isEmpty()) { + disabled.insert(name); + } + } + + if (!QFileInfo(manifestPath).isFile()) { + return disabled; + } + QFile manifest(manifestPath); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + return std::unexpected(px::core::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); + } + + const QString variant = QString::fromUtf8(manifest.readAll()).trimmed(); + if (variant == QLatin1StringView("NoWorkshop")) { + disabled.unite(QSet{QStringLiteral("steam_downloader"), QStringLiteral("steamworkshop"), + QStringLiteral("downloads_ui"), QStringLiteral("workshop_catalog"), + QStringLiteral("workshop_ui")}); + } else if (variant == QLatin1StringView("NativeWorkshop")) { + disabled.insert(QStringLiteral("steamworkshop")); + } else if (variant == QLatin1StringView("SteamWorkshop")) { + disabled.unite(QSet{QStringLiteral("workshop_catalog"), QStringLiteral("workshop_ui")}); + } else { + return std::unexpected(px::core::Error{QStringLiteral("Unknown workshop variant: %1").arg(variant)}); + } + return disabled; +} + +QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigService& config, + px::core::organizer::OrganizerDb& organizerDb, const QSet& disabled) { + ctx.registerPlugin(std::make_unique(ctx, organizerDb)); + + QStringList downloaders; + if (!disabled.contains(QStringLiteral("steam_downloader"))) { +#ifdef PX_HAS_STEAM + ctx.registerPlugin(std::make_unique(px::core::SteamDownloaderDeps{ + ctx.downloads(), + config, + [&ctx] { return QString::fromStdString(ctx.config().paths.local); }, + [&ctx] { return ctx.allMods().values(); }, + })); + downloaders.append(QStringLiteral("steam")); +#else + spdlog::info("PxSteamDL is not built in; Steam downloads disabled"); +#endif + } + + // The catalog is only useful with a downloader to fetch items. + if (!downloaders.isEmpty() && !disabled.contains(QStringLiteral("workshop_catalog"))) { + ctx.registerPlugin(std::make_unique()); + } + return downloaders; +} + +// How to add a UI plugin (mirrors the legacy _app.py `_setup`): +// - Guard it exactly as _app.py did, e.g. +// if (!selection.disabled.contains(QStringLiteral("workshop_ui")) && selection.downloaders.contains("steam")) +// app.registerPlugin(std::make_unique()); +// - Rail views go through app.addRailView(), and must come before the plugins +// that read the rail. +// Each port adds one line here; no other file changes. +void registerUiPlugins(px::ui::AppContext& ctx, const PluginSelection& selection) { + ctx.addRailView(); + ctx.registerPlugin(std::make_unique()); +#ifdef PX_HAS_STEAM + if (selection.downloaders.contains(QStringLiteral("steam"))) ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam")) && !selection.disabled.contains(QStringLiteral("steamworkshop"))) ctx.registerPlugin(std::make_unique()); +#endif + if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("downloads_ui"))) ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("workshop_catalog")) && !selection.disabled.contains(QStringLiteral("workshop_ui"))) ctx.registerPlugin(std::make_unique()); +} + +} // namespace px::app diff --git a/src/app/plugins.h b/src/app/plugins.h new file mode 100644 index 0000000..b708bf5 --- /dev/null +++ b/src/app/plugins.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#include +#include +#include + +#include "core/error.h" + +namespace px::core { +class ConfigService; +class CoreContext; +namespace organizer { +class OrganizerDb; +} +} // namespace px::core + +namespace px::ui { +class AppContext; +} + +namespace px::app { + +// Which plugins this run keeps, and which downloaders came up. Core registration +// produces `downloaders`; UI registration reads both. +struct PluginSelection { + QSet disabled; + // Downloader names, e.g. "steam". Empty when no downloader is available. + QStringList downloaders; +}; + +// Names from PX_DISABLED_PLUGINS (comma-separated) plus the plugins the baked +// workshop variant omits. `manifestPath` is the workshop-variant.txt file; a missing +// file bakes nothing. Fails on an unknown variant name. +std::expected, px::core::Error> parseDisabledPlugins(const QString& envValue, + const QString& manifestPath); + +// Registers core plugins in legacy _app.py order and returns the downloaders that came +// up. `organizerDb` is not owned and must outlive `ctx`. +QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigService& config, + px::core::organizer::OrganizerDb& organizerDb, const QSet& disabled); + +// Registers the UI plugins and rail views. Add one line per plugin here, guarded the +// same way the legacy _app.py guarded it; see plugins.cpp for the pattern. +void registerUiPlugins(px::ui::AppContext& app, const PluginSelection& selection); + +} // namespace px::app diff --git a/src/core/context.h b/src/core/context.h index f371c64..62c7ade 100644 --- a/src/core/context.h +++ b/src/core/context.h @@ -104,13 +104,14 @@ class CoreContext { // Shared by providers and the network; declared before the services so they outlive them. QThreadPool pool_; QNetworkAccessManager network_; - PluginRegistry plugins_; - // Services are declared last, so they are destroyed before what they use. + // Services are declared before the plugins, so plugins (which disconnect from service + // events in their destructors) are destroyed first. std::unique_ptr mod_service_; std::unique_ptr diagnostics_service_; std::unique_ptr sort_service_; std::unique_ptr game_launcher_; std::unique_ptr activation_; + PluginRegistry plugins_; }; } // namespace px::core diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index 542cbcf..1a37a55 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -54,7 +54,7 @@ Result write_cached(const QString& directory, const QString& path, const s WorkshopCatalog::WorkshopCatalog() : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), settings_(nullptr, QStringLiteral("workshop_catalog")), - base_url_(QString::fromStdString(settings_.value().url)), favourites_(nullptr) { + base_url_(normalize_base_url(QString::fromStdString(settings_.value().url))), favourites_(nullptr) { settings_.changed.connect([this](const CatalogSettings& value) { change_url(QString::fromStdString(value.url)); }); } diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 77843f7..acc5cc2 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -1,9 +1,53 @@ +# Every C++ source/header, every QML file and the embedded resources are globbed, so +# new UI files need no CMake edit. QML files must sit directly in this directory +# (the module root): Qt does not reliably register types from subfolders, and plain +# type names only resolve within one flat module. + +file(GLOB_RECURSE PX_UI_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +file(GLOB_RECURSE PX_UI_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.h) +file(GLOB PX_UI_QML CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.qml ${CMAKE_CURRENT_SOURCE_DIR}/*.js) + +# Resources are addressed relative to this directory: the stylesheet is +# :/qt/qml/PxModRim/theme/style.qss, logos are :/qt/qml/PxModRim/assets/*, and a +# plugin's injected script/style is :/qt/qml/PxModRim/plugins//inject.*. +file(GLOB_RECURSE PX_UI_RESOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/theme/*.qss + ${CMAKE_CURRENT_SOURCE_DIR}/assets/* + ${CMAKE_CURRENT_SOURCE_DIR}/plugins/*/inject.css + ${CMAKE_CURRENT_SOURCE_DIR}/plugins/*/inject.js) + +# The Steam Workshop browser imports QtWebEngine. Without WebEngine it cannot be +# compiled as QML, so it ships as a plain resource and is loaded only when enabled. +set(PX_UI_WEB_QML SteamWorkshop.qml) +set(PX_UI_HAS_WEBENGINE OFF) +if(TARGET Qt6::WebEngineQuick) + set(PX_UI_HAS_WEBENGINE ON) +endif() +if(NOT PX_UI_HAS_WEBENGINE) + list(FILTER PX_UI_QML EXCLUDE REGEX "/${PX_UI_WEB_QML}$") + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${PX_UI_WEB_QML}) + list(APPEND PX_UI_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/${PX_UI_WEB_QML}) + endif() +endif() + +list(TRANSFORM PX_UI_RESOURCES REPLACE "^${CMAKE_CURRENT_SOURCE_DIR}/" "") +list(TRANSFORM PX_UI_QML REPLACE "^${CMAKE_CURRENT_SOURCE_DIR}/" "") + qt_add_library(pxui STATIC) qt_add_qml_module(pxui URI PxModRim VERSION 1.0 - QML_FILES Main.qml - SOURCES placeholder.cpp + QML_FILES ${PX_UI_QML} + SOURCES ${PX_UI_SOURCES} ${PX_UI_HEADERS} + RESOURCES ${PX_UI_RESOURCES} ) target_include_directories(pxui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) -target_link_libraries(pxui PUBLIC pxcore Qt6::Gui Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Widgets QCoro6::Qml QCoro6::Quick) +target_link_libraries(pxui PUBLIC + pxcore + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::QuickWidgets + Qt6::Svg Qt6::Widgets + QCoro6::Core QCoro6::Qml QCoro6::Quick) +if(PX_UI_HAS_WEBENGINE) + target_link_libraries(pxui PUBLIC Qt6::WebEngineQuick) + target_compile_definitions(pxui PUBLIC PX_HAS_WEBENGINE=1) +endif() diff --git a/src/ui/CatalogCard.qml b/src/ui/CatalogCard.qml new file mode 100644 index 0000000..f1476b5 --- /dev/null +++ b/src/ui/CatalogCard.qml @@ -0,0 +1,135 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: card + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property var collage + required property string summary + required property string compatLabel + required property string state + required property string actionLabel + required property bool incompatible + required property bool queued + required property int memberCount + required property string votes + required property string fileSize + required property bool active + + readonly property bool isMod: kind === "mod" + readonly property string meta: [ + isMod ? author : memberCount + " mods", + isMod ? "" : author, + isMod && votes !== "Unrated" ? votes + " liked" : "", + fileSize !== "Unknown" ? fileSize : "" + ].filter(part => part.length > 0).join(" · ") + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: hover.hovered ? Theme.elevate4 : Theme.border + clip: true + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } + Accessible.role: Accessible.Button + Accessible.name: card.title + Accessible.onPressAction: workshopPanel.openItem(card.itemId, card.kind) + + FontMetrics { id: bodyMetrics; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeMd } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + CatalogThumb { + Layout.fillWidth: true + Layout.preferredHeight: 100 + previewUrl: card.previewUrl + collage: card.collage + } + Text { + Layout.fillWidth: true + Layout.preferredHeight: bodyMetrics.lineSpacing * 2 + text: card.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: true + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + Text { + Layout.fillWidth: true + text: card.meta + textFormat: Text.PlainText + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + Layout.preferredHeight: bodyMetrics.lineSpacing * 2 + text: card.summary + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + Layout.fillWidth: true + visible: card.compatLabel.length > 0 + text: card.compatLabel + color: card.incompatible ? Theme.warning : card.compatLabel.indexOf("Works") === 0 ? Theme.success : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + PxBadge { + visible: card.isMod && card.state !== "missing" + compact: true + text: card.state === "outdated" ? "UPDATE" : "INSTALLED" + textColor: card.state === "outdated" ? Theme.primary : Theme.success + fillColor: card.state === "outdated" ? Theme.primaryBg : Theme.successBg + } + PxBadge { + visible: !card.isMod + compact: true + text: "COLLECTION" + textColor: Theme.textMuted + fillColor: Theme.elevate3 + } + } + Item { Layout.fillHeight: true } + PxButton { + Layout.fillWidth: true + visible: card.state !== "installed" + text: card.actionLabel + variant: card.state === "outdated" ? "warning" : "primary" + enabled: !card.queued + onClicked: workshopPanel.downloadItem(card.itemId, card.kind) + } + PxButton { + Layout.fillWidth: true + visible: card.isMod && card.state === "installed" + text: card.active ? "Deactivate" : "Activate" + variant: card.active ? "danger" : "success" + onClicked: workshopPanel.toggleActivation(card.itemId) + } + } +} diff --git a/src/ui/CatalogImage.qml b/src/ui/CatalogImage.qml new file mode 100644 index 0000000..9f99994 --- /dev/null +++ b/src/ui/CatalogImage.qml @@ -0,0 +1,18 @@ +import QtQuick +import "thumbs.js" as Thumbs + +Image { + property string previewUrl: "" + property int widthStep: 64 + property int heightStep: 32 + // Qt updates sourceSize during reloads; URL dimensions must stay independent. + readonly property int pixelWidth: Thumbs.bucket(width * Screen.devicePixelRatio, widthStep) + readonly property int pixelHeight: Thumbs.bucket(height * Screen.devicePixelRatio, heightStep) + + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: pixelWidth + sourceSize.height: pixelHeight + source: visible && width > 0 && height > 0 + ? Thumbs.sized(previewUrl, pixelWidth, pixelHeight) : "" +} diff --git a/src/ui/CatalogSettings.qml b/src/ui/CatalogSettings.qml new file mode 100644 index 0000000..c67c66b --- /dev/null +++ b/src/ui/CatalogSettings.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Layouts +import PxModRim + +ColumnLayout { + id: root + required property QtObject section + spacing: 10 + + RowLayout { + Layout.fillWidth: true + Text { + Layout.preferredWidth: 150 + text: "Workshop catalog URL" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + objectName: "catalogUrlField" + Layout.fillWidth: true + text: root.section.initial.url + placeholderText: "https://api.modrim.pyxiion.dev" + Accessible.name: "Workshop catalog URL" + onTextEdited: root.section.set("url", text.trim()) + } + } + Text { + Layout.fillWidth: true + text: "Catalog service used by the native Workshop browser. Leave empty to disable catalog network requests. Applies on Save." + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } +} diff --git a/src/ui/CatalogThumb.qml b/src/ui/CatalogThumb.qml new file mode 100644 index 0000000..5e7275f --- /dev/null +++ b/src/ui/CatalogThumb.qml @@ -0,0 +1,45 @@ +import QtQuick +import PxModRim + +Rectangle { + id: thumb + property string previewUrl: "" + property var collage: [] + property int widthStep: 64 + property int heightStep: 32 + + color: Theme.elevate3 + radius: Theme.radiusMd + clip: true + + CatalogImage { + id: preview + anchors.fill: parent + previewUrl: thumb.previewUrl + widthStep: thumb.widthStep + heightStep: thumb.heightStep + } + Grid { + id: collageGrid + anchors.fill: parent + visible: thumb.previewUrl === "" && thumb.collage.length > 0 + columns: thumb.collage.length === 1 ? 1 : 2 + readonly property int rowCount: thumb.collage.length > 2 ? 2 : 1 + Repeater { + model: collageGrid.visible ? thumb.collage : [] + CatalogImage { + required property string modelData + width: collageGrid.width / collageGrid.columns + height: collageGrid.height / collageGrid.rowCount + previewUrl: modelData + } + } + } + Image { + anchors.centerIn: parent + width: Math.min(32, parent.height * 0.6) + height: width + visible: preview.status !== Image.Ready && !collageGrid.visible + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } +} diff --git a/src/ui/CollectionBanner.qml b/src/ui/CollectionBanner.qml new file mode 100644 index 0000000..185beb0 --- /dev/null +++ b/src/ui/CollectionBanner.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: banner + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property var collage + required property string summary + required property string sourceLabel + required property string compatLabel + required property bool incompatible + required property bool queued + required property int memberCount + required property string fileSize + required property string actionLabel + required property bool favourite + required property string state + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: hover.hovered ? Theme.elevate4 : Theme.border + clip: true + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(banner.itemId, banner.kind) } + Accessible.role: Accessible.Button + Accessible.name: banner.title + Accessible.onPressAction: workshopPanel.openItem(banner.itemId, banner.kind) + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 12 + CatalogThumb { + Layout.fillHeight: true + Layout.preferredWidth: Math.min(180, banner.width * 0.4) + previewUrl: banner.previewUrl + collage: banner.collage + widthStep: 128 + heightStep: 64 + } + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 4 + Text { + Layout.fillWidth: true + text: banner.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.bold: true + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: banner.memberCount + " mods" + (banner.fileSize !== "Unknown" ? " · " + banner.fileSize : "") + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: banner.sourceLabel + " · " + banner.author + textFormat: Text.PlainText + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + Layout.fillHeight: true + text: banner.summary + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: banner.compatLabel + color: banner.incompatible ? Theme.warning : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + PxButton { + objectName: "favouriteToggle" + variant: "ghost" + iconName: banner.favourite ? "star-filled" : "star" + iconColor: banner.favourite ? Theme.warning : "transparent" + ToolTip.text: banner.favourite ? "Remove from favourites" : "Add to favourites" + onClicked: workshopPanel.toggleFavourite(banner.itemId) + } + Text { + visible: banner.state === "installed" + text: "Installed" + color: Theme.success + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxButton { + visible: banner.state !== "installed" + text: banner.actionLabel + variant: "primary" + enabled: !banner.queued + onClicked: workshopPanel.downloadItem(banner.itemId, banner.kind) + } + } + } + } +} diff --git a/src/ui/DiagnosticBadge.qml b/src/ui/DiagnosticBadge.qml new file mode 100644 index 0000000..9b4ea51 --- /dev/null +++ b/src/ui/DiagnosticBadge.qml @@ -0,0 +1,49 @@ +import QtQuick + +// "error" | "warning" glyph with an optional count and hover tooltip. +Item { + id: root + + property string level: "error" + property int count: 0 + property string tooltip: "" + + readonly property color tint: level === "error" ? Theme.danger : Theme.warning + + implicitWidth: countText.visible ? 16 + 3 + countText.implicitWidth : 16 + implicitHeight: 16 + + Accessible.role: Accessible.Graphic + Accessible.name: tooltip.length > 0 ? tooltip : level + + Image { + width: 16 + height: 16 + sourceSize.width: 16 + sourceSize.height: 16 + source: "image://icons/" + (root.level === "error" ? "error" : "warning") + "?color=" + encodeURIComponent(root.tint) + } + + Text { + id: countText + + visible: root.count > 0 + x: 19 + anchors.verticalCenter: parent.verticalCenter + text: root.count + color: root.tint + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + font.weight: Font.DemiBold + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + PxToolTip { + visible: hover.hovered && root.tooltip.length > 0 + text: root.tooltip + } +} diff --git a/src/ui/DownloadSidebar.qml b/src/ui/DownloadSidebar.qml new file mode 100644 index 0000000..a7c1721 --- /dev/null +++ b/src/ui/DownloadSidebar.qml @@ -0,0 +1,150 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate2 + property bool downloadEnabled: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 0 + + Text { + id: titleLabel + text: "Download queue" + (listView.count > 0 ? " (" + listView.count + ")" : "") + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: Font.Bold + Layout.bottomMargin: 8 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.bottomMargin: 8 + clip: true + + EmptyState { + anchors.fill: parent + visible: listView.count === 0 + title: "Download queue is empty" + detail: "Click + on a mod to add it" + } + + ListView { + id: listView + objectName: "downloadList" + anchors.fill: parent + visible: listView.count > 0 + spacing: 2 + model: downloadQueueModel + + ScrollBar.vertical: PxScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Rectangle { + width: listView.width + height: 28 + radius: Theme.radiusMd + color: model.status === "downloading" + ? Theme.elevate3 : removeArea.containsMouse + ? Theme.elevate3 : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 4 + clip: true + + Text { + Layout.fillWidth: true + text: model.display + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + maximumLineCount: 1 + } + + Image { + readonly property string iconName: model.status === "downloading" ? "refresh" + : model.status === "success" ? "check" + : model.status === "error" ? "close" : "" + visible: iconName.length > 0 + sourceSize.width: 12 + sourceSize.height: 12 + source: visible ? "image://icons/" + iconName + "?color=" + + encodeURIComponent(model.status === "error" ? Theme.danger + : model.status === "success" ? Theme.success + : Theme.textDim) : "" + } + + Image { + visible: removeArea.containsMouse && removeArea.enabled + sourceSize.width: 12 + sourceSize.height: 12 + source: visible ? "image://icons/close?color=" + encodeURIComponent(Theme.textDim) : "" + } + } + + Rectangle { + anchors.left: parent.left + anchors.bottom: parent.bottom + width: parent.width * model.progress + height: 2 + radius: Theme.radiusXs + color: Theme.primary + visible: model.status === "downloading" + opacity: 0.5 + } + + MouseArea { + id: removeArea + anchors.fill: parent + enabled: model.status !== "queued" && model.status !== "downloading" + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: downloadSidebar.removeItem(model.id) + } + } + } + } + + PxButton { + id: clearButton + Layout.fillWidth: true + variant: "danger" + text: "Clear queue" + visible: listView.count > 0 && root.downloadEnabled + onClicked: downloadSidebar.clearQueue() + } + + PxButton { + id: downloadButton + Layout.fillWidth: true + Layout.topMargin: visible ? 4 : 0 + variant: "success" + text: "Download all" + visible: root.downloadEnabled + enabled: listView.count > 0 + onClicked: downloadSidebar.downloadRequested() + } + + PxButton { + id: stopButton + Layout.fillWidth: true + Layout.topMargin: 4 + variant: "dangerSolid" + text: "Stop" + visible: !root.downloadEnabled + onClicked: downloadSidebar.stopRequested() + } + } +} diff --git a/src/ui/Downloads.qml b/src/ui/Downloads.qml new file mode 100644 index 0000000..7077f05 --- /dev/null +++ b/src/ui/Downloads.qml @@ -0,0 +1,278 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate0 + + readonly property var stateLabels: ({ + queued: "Queued", + checking: "Checking", + downloading: "Downloading", + updated: "Updated", + unchanged: "Up to date", + failed: "Failed", + cancelled: "Cancelled" + }) + + function stateColor(state) { + if (state === "updated") return Theme.success + if (state === "failed") return Theme.danger + if (state === "downloading" || state === "checking") return Theme.primary + return Theme.textDim + } + + function summary() { + var m = downloadsModel + if (m.total === 0) + return "No downloads yet" + var parts = [m.updated + " updated", m.unchanged + " up to date"] + if (m.failed > 0) + parts.push(m.failed + " failed") + return (m.busy ? "Working: " : "Last run: ") + m.finished + " / " + m.total + + " · " + parts.join(" · ") + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ColumnLayout { + spacing: 2 + + Text { + text: "Downloads" + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.weight: Font.Bold + } + Text { + text: root.summary() + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + Item { Layout.fillWidth: true } + + PxButton { + visible: downloadsModel.busy + variant: "danger" + iconName: "close" + text: "Stop" + onClicked: downloadsPanel.stop() + } + PxButton { + visible: !downloadsModel.busy && downloadsModel.failed > 0 + text: "Retry failed (" + downloadsModel.failed + ")" + iconName: "refresh" + onClicked: downloadsPanel.retryFailed() + } + PxButton { + visible: !downloadsModel.busy && downloadsModel.total > 0 + variant: "ghost" + text: "Clear" + onClicked: downloadsModel.clear() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 96 + visible: downloadsModel.total > 0 + radius: Theme.radiusMd + color: Theme.elevate2 + + function formatBytes(n) { + if (n >= 1073741824) return (n / 1073741824).toFixed(2) + " GB" + if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB" + return Math.round(n / 1024) + " KB" + } + + Canvas { + id: graph + anchors.fill: parent + anchors.margins: 1 + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var h = downloadsModel.speedHistory + if (h.length < 2) return + var peak = Math.max.apply(null, h.concat([1])) + var stepX = width / (90 - 1) + var x0 = width - (h.length - 1) * stepX + ctx.beginPath() + ctx.moveTo(x0, height) + for (var i = 0; i < h.length; i++) + ctx.lineTo(x0 + i * stepX, height - (h[i] / peak) * (height - 28) - 2) + ctx.lineTo(width, height) + ctx.closePath() + ctx.fillStyle = Qt.alpha(Theme.primary, 0.25) + ctx.fill() + ctx.beginPath() + for (var j = 0; j < h.length; j++) { + var y = height - (h[j] / peak) * (height - 28) - 2 + if (j === 0) ctx.moveTo(x0, y) + else ctx.lineTo(x0 + j * stepX, y) + } + ctx.strokeStyle = Theme.primary + ctx.lineWidth = 2 + ctx.stroke() + } + Connections { + target: downloadsModel + function onSpeed_changed() { graph.requestPaint() } + } + } + + Text { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 10 + text: parent.formatBytes(downloadsModel.speed) + "/s" + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.weight: Font.Bold + } + Text { + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: 10 + visible: text.length > 0 + text: downloadsModel.phase === "login" ? "Connecting\u2026" + : downloadsModel.phase === "query" + ? "Querying mod info\u2026 " + downloadsModel.resolved + " / " + downloadsModel.total + : "" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + Text { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + text: "Downloaded " + parent.formatBytes(downloadsModel.bytesDone) + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + PxProgressBar { + Layout.fillWidth: true + visible: downloadsModel.total > 0 + thickness: 6 + from: 0 + to: Math.max(downloadsModel.total, 1) + value: downloadsModel.finished + } + + RowLayout { + Layout.fillWidth: true + visible: downloadsModel.total > 0 + spacing: 6 + + Repeater { + model: [ + { key: "all", label: "All", count: downloadsModel.total }, + { key: "active", label: "Active", count: downloadsModel.active }, + { key: "updated", label: "Updated", count: downloadsModel.updated }, + { key: "failed", label: "Failed", count: downloadsModel.failed } + ] + PxButton { + required property var modelData + variant: downloadsModel.filter === modelData.key ? "primary" : "ghost" + text: modelData.label + " " + modelData.count + implicitHeight: 28 + onClicked: downloadsModel.setFilter(modelData.key) + } + } + Item { Layout.fillWidth: true } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + EmptyState { + anchors.fill: parent + visible: listView.count === 0 + title: downloadsModel.total === 0 ? "No downloads yet" : "Nothing in this filter" + detail: downloadsModel.total === 0 + ? "Updates from the mod list, organizer, header, auto-update and download-source tabs show up here." + : "" + } + + ListView { + id: listView + objectName: "downloadsList" + anchors.fill: parent + spacing: 2 + model: downloadsModel + visible: count > 0 + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + width: listView.width + height: 36 + radius: Theme.radiusMd + color: model.state === "downloading" ? Theme.elevate3 : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 12 + spacing: 10 + + Text { + Layout.fillWidth: true + text: model.title + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + Text { + visible: model.state === "failed" && model.error.length > 0 + Layout.maximumWidth: 320 + text: model.error + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + elide: Text.ElideRight + } + Text { + Layout.preferredWidth: 90 + horizontalAlignment: Text.AlignRight + text: root.stateLabels[model.state] || model.state + color: root.stateColor(model.state) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + Rectangle { + anchors.left: parent.left + anchors.bottom: parent.bottom + width: parent.width * model.progress + height: 2 + radius: Theme.radiusXs + color: Theme.primary + visible: model.state === "downloading" + } + } + } + } + } +} diff --git a/src/ui/EmptyState.qml b/src/ui/EmptyState.qml new file mode 100644 index 0000000..4ce3c6f --- /dev/null +++ b/src/ui/EmptyState.qml @@ -0,0 +1,54 @@ +import QtQuick + +Item { + id: root + + property string iconName: "" + property string title: "" + property string detail: "" + + implicitWidth: column.implicitWidth + implicitHeight: column.implicitHeight + + Accessible.role: Accessible.StaticText + Accessible.name: title + + Column { + id: column + + anchors.centerIn: parent + width: Math.min(root.width, 320) + spacing: 8 + + Image { + visible: root.iconName.length > 0 + anchors.horizontalCenter: parent.horizontalCenter + width: 32 + height: 32 + sourceSize.width: 32 + sourceSize.height: 32 + source: visible ? "image://icons/" + root.iconName + "?color=" + encodeURIComponent(Theme.textDim) : "" + } + + Text { + width: parent.width + text: root.title + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Text { + visible: root.detail.length > 0 + width: parent.width + text: root.detail + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } +} diff --git a/src/ui/ErrorBanner.qml b/src/ui/ErrorBanner.qml new file mode 100644 index 0000000..bc01f91 --- /dev/null +++ b/src/ui/ErrorBanner.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Layouts + +// Inline error strip; hidden while `message` is empty. +Rectangle { + id: root + + property string text: "" + + visible: text !== "" + color: Theme.dangerBg + border.color: Theme.danger + border.width: 1 + radius: Theme.radiusSm + implicitHeight: errorText.implicitHeight + 14 + + Accessible.role: Accessible.AlertMessage + Accessible.name: text + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + + Text { + id: errorText + + Layout.fillWidth: true + text: root.text + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + wrapMode: Text.Wrap + } + } +} diff --git a/src/ui/FilterSidebar.qml b/src/ui/FilterSidebar.qml new file mode 100644 index 0000000..b8d346a --- /dev/null +++ b/src/ui/FilterSidebar.qml @@ -0,0 +1,127 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +// Shared filter list (Mods and Organizer). The model exposes: filterLabel, count, +// iconName, sectionName and optionally iconColor, badgeBg, badgeFg. +Rectangle { + id: root + + property var model: null + property alias currentIndex: filters.currentIndex + signal activated(int index) + + color: Theme.elevate2 + + ListView { + id: filters + objectName: "listView" + anchors.fill: parent + anchors.margins: 12 + anchors.topMargin: 8 + clip: true + spacing: 2 + model: root.model + currentIndex: 0 + section.property: "sectionName" + section.delegate: Item { + width: filters.width + height: 26 + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.border + } + Text { + x: 12 + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -1 + text: section + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Bold + font.capitalization: Font.AllUppercase + font.letterSpacing: 0.5 + } + } + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + readonly property bool selected: filters.currentIndex === index + width: filters.width + height: 36 + radius: Theme.radiusMd + color: selected ? Theme.elevate4 + : (mouse.containsMouse ? Theme.elevate3 : "transparent") + Rectangle { + visible: row.selected + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: 18 + radius: Theme.radiusXs + color: Theme.primary + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: row.selected ? 13 : 16 + anchors.rightMargin: 10 + spacing: 8 + Image { + source: "image://icons/" + (model.iconName || "folder") + "?color=" + + encodeURIComponent(row.selected ? Theme.primary + : (model.iconColor || Theme.textDim)) + sourceSize.width: 14 + sourceSize.height: 14 + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + fillMode: Image.PreserveAspectFit + horizontalAlignment: Image.AlignHCenter + verticalAlignment: Image.AlignVCenter + } + Text { + text: model.filterLabel || "" + Layout.fillWidth: true + color: row.selected ? Theme.primary : Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: row.selected ? Font.Medium : Font.Normal + elide: Text.ElideRight + maximumLineCount: 1 + } + Rectangle { + visible: (model.count || 0) > 0 + Layout.preferredWidth: countText.contentWidth + 12 + Layout.preferredHeight: 20 + radius: Theme.radiusPill + color: model.badgeBg || Theme.elevate4 + Text { + id: countText + anchors.centerIn: parent + text: String(model.count || 0) + color: model.badgeFg || Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Medium + } + } + } + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + filters.currentIndex = index + root.activated(index) + } + } + } + } +} diff --git a/src/ui/Header.qml b/src/ui/Header.qml new file mode 100644 index 0000000..9352c51 --- /dev/null +++ b/src/ui/Header.qml @@ -0,0 +1,368 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + height: 80 + color: Theme.elevate1 + property var controller: headerController + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 8 + spacing: 12 + + // ── Logo (easter egg: poke it) ── + Item { + id: logoBox + z: 10 + Layout.preferredWidth: 128 + Layout.fillHeight: true + + property int pokes: 0 + property string currentLogo: Theme.logoFileDataUri + + function swapLogo() { + var others = Theme.logoVariants.filter(function (u) { return u !== currentLogo }) + if (others.length > 0) + currentLogo = others[Math.floor(Math.random() * others.length)] + } + readonly property var quips: [ + "Oh, hello.", "Ow.", "Hey!", "Stop poking me.", + "Load order: sorted.", "Nothing to see here." + ] + + Timer { + id: pokeReset + interval: 2500 + onTriggered: logoBox.pokes = 0 + } + + Image { + id: logo + anchors.fill: parent + source: logoBox.currentLogo + fillMode: Image.PreserveAspectFit + scale: logoArea.pressed ? 0.92 : logoArea.containsMouse ? 1.08 : 1.0 + Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + + transform: Rotation { + id: flip + origin.x: logo.width / 2 + origin.y: logo.height / 2 + axis { x: 0; y: 1; z: 0 } + angle: 0 + } + + SequentialAnimation { + id: spin + NumberAnimation { + target: flip + property: "angle" + from: 0 + to: 90 + duration: 300 + easing.type: Easing.InCubic + } + ScriptAction { script: logoBox.swapLogo() } + NumberAnimation { + target: flip + property: "angle" + from: 90 + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + id: logoArea + anchors.centerIn: parent + width: logo.paintedWidth + height: logo.paintedHeight + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + logoBox.pokes += 1 + pokeReset.restart() + if (logoBox.pokes >= 10) { + bubble.show("Fine. Have a crab. 🦀") + logoBox.pokes = 0 + } else { + bubble.show(logoBox.quips[Math.min(logoBox.pokes - 1, logoBox.quips.length - 1)]) + } + spin.restart() + } + } + + Rectangle { + id: bubble + property alias text: bubbleText.text + function show(message) { + bubbleText.text = message + opacity = 1 + bubbleFade.restart() + } + anchors.left: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 0 + width: bubbleText.implicitWidth + 20 + height: bubbleText.implicitHeight + 12 + radius: Theme.radiusXs + color: Theme.elevate3 + border.color: Theme.primary + border.width: 1 + opacity: 0 + z: 10 + visible: opacity > 0 + + Text { + id: bubbleText + anchors.centerIn: parent + color: Theme.textMain + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontFamily + } + + SequentialAnimation { + id: bubbleFade + PauseAnimation { duration: 1400 } + NumberAnimation { target: bubble; property: "opacity"; to: 0; duration: 300 } + } + } + } + + + // ── Text column ── + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 0 + + Text { + text: "RimWorld Mod Manager" + color: Theme.textMuted + font.pixelSize: Theme.fontSizeXs + font.family: Theme.fontFamily + } + + Text { + text: "v" + root.controller.appVersion + color: Theme.textDim + font.pixelSize: Theme.fontSizeXs + font.family: Theme.fontFamily + } + } + + // ── Workshop progress (centre; idle = empty) ── + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ColumnLayout { + id: downloadsProgress + visible: root.controller.downloadsBusy && root.controller.downloadsProgressShown + anchors.centerIn: parent + width: Math.min(parent.width, 360) + spacing: 6 + + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: root.controller.downloadsText + color: Theme.textMuted + elide: Text.ElideRight + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontFamily + } + + PxProgressBar { + Layout.fillWidth: true + from: 0 + to: Math.max(root.controller.downloadsTotal, 1) + value: root.controller.downloadsValue + indeterminate: root.controller.downloadsTotal <= 0 + } + } + + ColumnLayout { + visible: !downloadsProgress.visible && root.controller.taskText !== "" + anchors.centerIn: parent + width: Math.min(parent.width, 360) + spacing: 6 + + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: root.controller.taskText + color: Theme.textMuted + elide: Text.ElideRight + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontFamily + } + + PxProgressBar { + Layout.fillWidth: true + indeterminate: true + } + } + + MouseArea { + anchors.fill: downloadsProgress + visible: downloadsProgress.visible + cursorShape: Qt.PointingHandCursor + onClicked: root.controller.openDownloads() + ToolTip.text: "Open Downloads" + ToolTip.visible: containsMouse + hoverEnabled: true + } + } + + // ── Buttons column ── + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 8 + + // Window controls (top row) + Row { + Layout.alignment: Qt.AlignRight + spacing: 6 + visible: root.controller.is_frameless + + PxButton { + variant: "ghost" + iconName: "minimize" + ToolTip.text: "Minimize" + onClicked: root.controller.minimize() + } + + PxButton { + variant: "ghost" + iconName: root.controller.maximized ? "restore" : "maximize" + ToolTip.text: root.controller.maximized ? "Restore" : "Maximize" + onClicked: root.controller.maximize() + } + + PxButton { + variant: "danger" + iconName: "close" + ToolTip.text: "Close" + onClicked: root.controller.closeWindow() + } + } + + // Action buttons (bottom row) + Row { + Layout.alignment: Qt.AlignRight + spacing: 6 + + PxButton { + iconName: "save" + iconColor: root.controller.unsavedChanges ? Theme.warning : "transparent" + ToolTip.text: root.controller.tooltips.save || "Save" + onClicked: root.controller.save() + + Rectangle { + visible: root.controller.unsavedChanges + width: 8; height: 8 + radius: Theme.radiusXs + color: Theme.warning + border.width: 2 + border.color: Theme.elevate3 + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 + anchors.rightMargin: 4 + } + } + + PxButton { + variant: "primary" + iconName: "sort" + ToolTip.text: root.controller.tooltips.sort || "Auto-sort" + onClicked: root.controller.autoSort() + } + + PxButton { + iconName: "refresh" + ToolTip.text: root.controller.tooltips.refresh || "Refresh" + onClicked: root.controller.refresh() + } + + PxButton { + visible: root.controller.downloadsAvailable + variant: root.controller.downloadsBusy ? "danger" : "secondary" + iconName: root.controller.downloadsBusy ? "close" : "download" + ToolTip.text: root.controller.downloadsBusy ? "Stop download" : (root.controller.tooltips.update_mods || "Update downloaded mods") + onClicked: root.controller.updateMods() + } + + // ── Launch split-button ── + Row { + spacing: 1 + + PxButton { + variant: "success" + iconName: "play" + text: root.controller.launchState === "launching" ? "Launching\u2026" + : root.controller.launchState === "running" ? "Running" : "Play" + enabled: root.controller.launchState === "idle" + ToolTip.text: root.controller.launchState === "running" ? "Game is running" : "Launch game" + onClicked: root.controller.launch() + } + + PxButton { + id: chevronBtn + objectName: "launchStrategyButton" + variant: "success" + iconName: "chevron-down" + ToolTip.text: "Launch strategy" + onClicked: strategyMenu.popup(chevronBtn, 0, chevronBtn.height + 4) + } + } + } + } + } + + // ── Dropdown menu (children of root, outside layouts) ── + PxMenu { + id: strategyMenu + objectName: "launchStrategyMenu" + + Repeater { + model: root.controller.strategies + + PxMenuItem { + required property var modelData + text: modelData.label + checkable: true + checked: root.controller.strategyIndex === modelData.index + onTriggered: root.controller.setStrategy(modelData.index) + } + } + } + + // ── Bottom border ── + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.border + } + + // ── Drag region (behind buttons, z: -1) ── + MouseArea { + anchors.fill: parent + z: -1 + acceptedButtons: Qt.LeftButton + onPressed: root.controller.dragStarted() + onDoubleClicked: root.controller.maximize() + + cursorShape: root.controller.is_frameless ? Qt.OpenHandCursor : Qt.ArrowCursor + } +} diff --git a/src/ui/IconRail.qml b/src/ui/IconRail.qml new file mode 100644 index 0000000..255b8ff --- /dev/null +++ b/src/ui/IconRail.qml @@ -0,0 +1,246 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate2 + + property int currentIndex: 0 + property bool collapsed: root.width < Theme.railCollapseWidth + property var tabModel: railModel + + signal tabSelected(int index) + signal tabHovered(int index) + signal settingsRequested() + signal helpActionRequested(string actionId) + + component RailButton: Item { + id: button + property string iconName + property string label + property bool collapsed + signal clicked() + + width: parent ? parent.width : 0 + height: 44 + + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: Theme.radiusMd + color: area.containsMouse ? Theme.elevate3 : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: button.collapsed ? 0 : 12 + anchors.rightMargin: button.collapsed ? 0 : 8 + spacing: 10 + + Item { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Layout.alignment: button.collapsed ? Qt.AlignCenter : Qt.AlignVCenter + Layout.fillWidth: button.collapsed + + Image { + anchors.centerIn: parent + source: "image://icons/" + button.iconName + "?color=" + encodeURIComponent(Theme.textMuted) + sourceSize.width: 18; sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + } + } + + Text { + visible: !button.collapsed + Layout.fillWidth: true + text: button.label + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: button.clicked() + } + + Loader { + active: button.collapsed && area.containsMouse + sourceComponent: PxToolTip { + visible: true + text: button.label + } + } + } + + Accessible.role: Accessible.Button + Accessible.name: button.label + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ListView { + id: listView + objectName: "railList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 4 + model: root.tabModel + currentIndex: root.currentIndex + + delegate: Item { + id: delegate + width: listView.width + height: 44 + property bool active: index === listView.currentIndex + + Rectangle { + id: bg + anchors.fill: parent + anchors.margins: 4 + radius: Theme.radiusMd + color: delegate.active + ? Theme.elevate4 + : (hoverArea.containsMouse ? Theme.elevate3 : "transparent") + + Rectangle { + visible: delegate.active + width: 3 + height: 18 + radius: Theme.radiusXs + color: Theme.primary + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: root.collapsed ? 0 : 12 + anchors.rightMargin: 8 + spacing: 10 + visible: !root.collapsed + + Item { + width: 18; height: 18 + Layout.alignment: Qt.AlignVCenter + + Image { + anchors.centerIn: parent + source: "image://icons/" + (modelData.icon || "") + "?color=" + + encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) + sourceSize.width: 18; sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + opacity: 1 + } + } + + Text { + id: labelText + Layout.fillWidth: true + text: modelData.label || "" + color: delegate.active ? Theme.primary : Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: delegate.active ? Font.Medium : Font.Normal + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + opacity: 1 + } + } + + Item { + anchors.fill: parent + visible: root.collapsed + + Image { + id: collapsedIcon + width: 18; height: 18 + anchors.centerIn: parent + source: "image://icons/" + (modelData.icon || "") + "?color=" + + encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) + sourceSize.width: 18; sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + opacity: 1 + } + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.tabHovered(index) + onClicked: { + root.currentIndex = index + root.tabSelected(index) + } + } + + Loader { + active: root.collapsed && hoverArea.containsMouse + sourceComponent: PxToolTip { + visible: true + text: modelData.label || "" + } + } + + Behavior on color { + ColorAnimation { duration: 120; easing.type: Easing.OutCubic } + } + } + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + } + + Column { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 4 + + RailButton { + iconName: "settings" + label: "Settings" + collapsed: root.collapsed + onClicked: root.settingsRequested() + } + + RailButton { + id: helpButton + iconName: "help" + label: "Help" + collapsed: root.collapsed + onClicked: helpMenu.popup(helpButton, helpButton.width, helpButton.height - helpMenu.implicitHeight) + } + } + } + + PxMenu { + id: helpMenu + objectName: "helpMenu" + + Repeater { + model: railHelpEntries + + PxMenuItem { + required property var modelData + text: modelData.label + onTriggered: root.helpActionRequested(modelData.id) + } + } + } +} diff --git a/src/ui/ModDescription.qml b/src/ui/ModDescription.qml new file mode 100644 index 0000000..8b3294f --- /dev/null +++ b/src/ui/ModDescription.qml @@ -0,0 +1,29 @@ +import QtQuick + +TextEdit { + id: root + + property string description: "" + + text: description.length > 0 + ? "" + + description.replace(/__IMG_WIDTH__/g, Math.max(1, Math.floor(width))) + : "" + textFormat: TextEdit.RichText + readOnly: true + selectByMouse: true + selectedTextColor: Theme.onAccent + selectionColor: Theme.primary + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: TextEdit.WordWrap + + HoverHandler { + cursorShape: root.hoveredLink.length > 0 ? Qt.PointingHandCursor : Qt.IBeamCursor + } +} diff --git a/src/ui/ModInfo.qml b/src/ui/ModInfo.qml new file mode 100644 index 0000000..e6992d1 --- /dev/null +++ b/src/ui/ModInfo.qml @@ -0,0 +1,539 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate2 + clip: true + + property var info: null + property string description: "" + property var startup: null + readonly property bool startupKnown: startup !== null && startup !== undefined + property bool descExpanded: false + property bool downloadsBusy: false + property bool updatingThis: false + property bool neededExpanded: false + + signal openFolder() + signal openUrl() + signal updateMods() + signal selectMod(string uuid) + signal copyText(string text) + signal openStartupDetails() + signal descToggled(bool expanded) + + readonly property bool hasInfo: info !== null && info !== undefined + readonly property var statusData: hasInfo ? info.status : null + readonly property color levelColor: !statusData ? Theme.textMuted + : statusData.level === "error" ? Theme.danger + : statusData.level === "warning" ? Theme.warning + : Theme.success + readonly property color levelBg: !statusData ? Theme.elevate3 + : statusData.level === "error" ? Theme.dangerBg + : statusData.level === "warning" ? Theme.warningBg + : Theme.successBg + + function stateColor(state) { + return state === "active" ? Theme.success : state === "inactive" ? Theme.warning : Theme.danger + } + function stateBg(state) { + return state === "active" ? Theme.successBg : state === "inactive" ? Theme.warningBg : Theme.dangerBg + } + function stateMark(state) { + return state === "active" ? "\u2713 " : state === "inactive" ? "\u25CF " : "\u2715 " + } + function stateHint(state) { + return state === "active" ? "Active" : state === "inactive" ? "Installed but not active" : "Not installed" + } + + component Card: Rectangle { + default property alias content: column.data + property color tint: "transparent" + property color edge: Theme.border + Layout.fillWidth: true + implicitHeight: column.implicitHeight + 24 + radius: Theme.radiusMd + color: Theme.elevate3 + border.width: 1 + border.color: edge + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: parent.tint + } + + ColumnLayout { + id: column + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + } + } + + // Badge that jumps to another mod when it has a uuid. + component ModBadge: PxBadge { + property string uuid: "" + readonly property bool linked: uuid.length > 0 + + HoverHandler { + id: badgeHover + enabled: parent.linked + cursorShape: Qt.PointingHandCursor + } + TapHandler { + enabled: parent.linked + onTapped: root.selectMod(parent.uuid) + } + Rectangle { + anchors.fill: parent + radius: parent.radius + color: Theme.textMain + opacity: badgeHover.hovered ? 0.12 : 0 + } + } + + component CardLabel: Text { + Layout.fillWidth: true + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Bold + font.capitalization: Font.AllUppercase + font.letterSpacing: 0.8 + } + + function resetScroll() { + flick.contentY = 0 + neededExpanded = false + } + + Flickable { + id: flick + objectName: "flick" + anchors.fill: parent + visible: root.hasInfo + contentHeight: page.implicitHeight + 28 + clip: true + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: page + x: 14 + y: 14 + width: parent.width - 28 + spacing: 12 + + // ── Status ── + Card { + tint: root.levelBg + edge: root.statusData && root.statusData.level !== "ok" ? root.levelColor : Theme.border + + Flow { + Layout.fillWidth: true + spacing: 6 + + PxBadge { + text: root.statusData ? root.statusData.title : "" + textColor: root.levelColor + fillColor: root.levelBg + } + PxBadge { + visible: root.hasInfo && root.info.version.known + text: root.hasInfo && root.info.version.ok + ? "\u2713 Supports " + root.info.version.target + : "\u2715 No " + (root.hasInfo ? root.info.version.target : "") + " support" + textColor: root.hasInfo && root.info.version.ok ? Theme.success : Theme.warning + fillColor: root.hasInfo && root.info.version.ok ? Theme.successBg : Theme.warningBg + } + PxBadge { + visible: root.statusData !== null && root.statusData.obsolete + text: "Obsolete" + textColor: Theme.warning + fillColor: Theme.warningBg + } + } + + Text { + Layout.fillWidth: true + text: !root.statusData ? "" + : root.statusData.active + ? "Active \u00B7 position #" + root.statusData.position + " of " + root.statusData.total + : "Inactive" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + + Repeater { + model: root.statusData ? root.statusData.issues : [] + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: modelData.title + color: modelData.isError ? Theme.danger : Theme.warning + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: Font.Bold + wrapMode: Text.WordWrap + } + Text { + Layout.fillWidth: true + visible: modelData.detail.length > 0 + text: modelData.detail + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + wrapMode: Text.WordWrap + } + } + } + } + + // ── Relationships ── + Card { + CardLabel { text: "Requires" } + Text { + visible: root.hasInfo && root.info.needs.length === 0 + text: "No dependencies" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + Flow { + Layout.fillWidth: true + spacing: 6 + visible: root.hasInfo && root.info.needs.length > 0 + + Repeater { + model: root.hasInfo ? root.info.needs : [] + delegate: ModBadge { + uuid: modelData.uuid + text: root.stateMark(modelData.state) + modelData.name + textColor: root.stateColor(modelData.state) + fillColor: root.stateBg(modelData.state) + tooltip: root.stateHint(modelData.state) + (modelData.uuid.length > 0 ? " \u2014 click to open" : "") + } + } + } + + CardLabel { + Layout.topMargin: 4 + visible: root.hasInfo && root.info.conflicts.length > 0 + text: "Conflicts with (active)" + } + Flow { + Layout.fillWidth: true + spacing: 6 + visible: root.hasInfo && root.info.conflicts.length > 0 + + Repeater { + model: root.hasInfo ? root.info.conflicts : [] + delegate: PxBadge { + text: "\u2715 " + modelData + textColor: Theme.danger + fillColor: Theme.dangerBg + } + } + } + + CardLabel { + Layout.topMargin: 4 + visible: root.hasInfo && root.info.neededBy.length > 0 + text: "Needed by" + } + Flow { + Layout.fillWidth: true + spacing: 6 + visible: root.hasInfo && root.info.neededBy.length > 0 + + Repeater { + model: !root.hasInfo ? [] : root.neededExpanded ? root.info.neededBy : root.info.neededBy.slice(0, 6) + delegate: ModBadge { + uuid: modelData.uuid + text: modelData.name + tooltip: "Click to open" + textColor: Theme.textMuted + fillColor: Theme.elevate4 + } + } + Text { + visible: root.hasInfo && root.info.neededBy.length > 6 + text: root.neededExpanded ? "Show less" : "+" + (root.hasInfo ? root.info.neededBy.length - 6 : 0) + " more" + color: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + height: 20 + verticalAlignment: Text.AlignVCenter + + TapHandler { onTapped: root.neededExpanded = !root.neededExpanded } + HoverHandler { cursorShape: Qt.PointingHandCursor } + } + } + } + + // ── Description ── + Card { + visible: root.description.length > 0 + + CardLabel { text: "Description" } + Item { + id: descClip + FontMetrics { id: descMetrics; font: descText.font } + readonly property real collapsedHeight: root.description.indexOf("= 0 ? 220 : descMetrics.lineSpacing * 4 + readonly property bool overflows: descText.implicitHeight > collapsedHeight + 1 + Layout.fillWidth: true + Layout.preferredHeight: root.descExpanded ? descText.implicitHeight + : Math.min(descText.implicitHeight, collapsedHeight) + clip: true + + Rectangle { + z: 1 + visible: descClip.overflows && !root.descExpanded + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 24 + gradient: Gradient { + GradientStop { position: 0; color: "transparent" } + GradientStop { position: 1; color: Theme.elevate3 } + } + } + + ModDescription { + id: descText + objectName: "descText" + width: parent.width + description: root.hasInfo ? root.description : "" + onLinkActivated: link => Qt.openUrlExternally(link) + } + } + Text { + visible: descClip.overflows + text: root.descExpanded ? "Show less" : "Show more" + color: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + + TapHandler { + onTapped: { + root.descExpanded = !root.descExpanded + root.descToggled(root.descExpanded) + } + } + HoverHandler { cursorShape: Qt.PointingHandCursor } + } + } + + // ── Startup impact ── + Card { + id: startupCard + readonly property bool ready: root.startupKnown && root.startup.available + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + CardLabel { text: "Startup impact" } + Text { + visible: startupCard.ready + text: "Full breakdown \u203A" + color: startupHover.hovered ? Theme.primaryHover : Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + } + + Text { + Layout.fillWidth: true + visible: !startupCard.ready + text: !root.startupKnown ? "Loading\u2026" : (root.startup.message || "") + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.WordWrap + } + + ColumnLayout { + visible: startupCard.ready + Layout.fillWidth: true + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + Text { + text: startupCard.ready ? root.startup.own : "" + color: startupCard.ready ? root.startup.color : Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXl + font.weight: Font.Bold + } + Item { Layout.fillWidth: true } + Text { + text: startupCard.ready ? root.startup.rank : "" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + Text { + Layout.fillWidth: true + text: startupCard.ready && root.startup.estimated + ? "Estimated extra game load time if you activate this mod" + : "Extra game load time caused by this mod" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + wrapMode: Text.WordWrap + } + + Repeater { + model: startupCard.ready ? root.startup.rows : [] + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: modelData.label + elide: Text.ElideRight + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + Text { + text: modelData.value + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + Rectangle { + Layout.fillWidth: true + height: 4 + radius: 2 + color: Theme.elevate4 + + Rectangle { + width: parent.width * modelData.fraction + height: parent.height + radius: 2 + color: root.startup.color + } + } + } + } + } + + HoverHandler { + id: startupHover + enabled: startupCard.ready + cursorShape: Qt.PointingHandCursor + } + TapHandler { + enabled: startupCard.ready + onTapped: root.openStartupDetails() + } + } + + // ── Details ── + Card { + RowLayout { + Layout.fillWidth: true + spacing: 4 + + CardLabel { text: "Details" } + PxButton { + visible: root.hasInfo && root.info.canOpenFolder + implicitHeight: 24 + implicitWidth: 24 + variant: "ghost" + iconName: "folder" + ToolTip.text: "Open mod folder" + onClicked: root.openFolder() + } + PxButton { + visible: root.hasInfo && root.info.url.length > 0 + implicitHeight: 24 + implicitWidth: 24 + variant: "ghost" + iconName: "link" + ToolTip.text: root.hasInfo ? "Open mod URL: " + root.info.url : "Open mod URL" + onClicked: root.openUrl() + } + + PxButton { + visible: root.hasInfo && root.info.canUpdate && !root.updatingThis + enabled: !root.downloadsBusy + implicitHeight: 24 + implicitWidth: 24 + variant: "ghost" + iconName: "download" + ToolTip.text: root.downloadsBusy ? "Download in progress" : "Update from " + (root.hasInfo ? root.info.updateSource : "source") + onClicked: root.updateMods() + } + + BusyIndicator { + visible: root.updatingThis + running: visible + implicitHeight: 24 + implicitWidth: 24 + ToolTip.text: "Updating\u2026" + ToolTip.visible: hovered + } + } + + Repeater { + model: root.hasInfo ? root.info.details : [] + + delegate: RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.preferredWidth: 80 + Layout.alignment: Qt.AlignTop + text: modelData.label + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + Text { + Layout.fillWidth: true + text: modelData.value + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.WrapAnywhere + } + PxButton { + visible: modelData.copy === true + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + implicitHeight: 24 + implicitWidth: 24 + variant: "ghost" + iconName: "copy" + ToolTip.text: "Copy" + onClicked: root.copyText(modelData.value) + } + } + } + } + } + } +} diff --git a/src/ui/ModList.qml b/src/ui/ModList.qml new file mode 100644 index 0000000..60b2595 --- /dev/null +++ b/src/ui/ModList.qml @@ -0,0 +1,764 @@ +import QtQuick +import QtQuick.Controls +import PxModRim + +Rectangle { + id: root + color: Theme.elevate1 + clip: true + + property bool keyboardActive: modListHasFocus && listView.activeFocus + readonly property bool compactMode: Boolean(modListPanel && modListPanel.compactMode) + ListView { + id: listView + objectName: "listView" + anchors.fill: parent + spacing: 2 + model: modListModel + delegate: dragDelegate + boundsBehavior: Flickable.StopAtBounds + flickDeceleration: 2000 + focus: true + currentIndex: -1 + Accessible.role: Accessible.List + Accessible.name: "Mods" + + reuseItems: true + cacheBuffer: 100 + + property int dragSourceIndex: -1 + property int dragTargetIndex: -1 + property real autoScrollSpeed: 0 + property var selectedIndices: [] + property var selectedUuids: [] + property int anchorIndex: -1 + property string anchorUuid: "" + property string currentUuid: "" + + ScrollBar.vertical: PxScrollBar { + id: scrollBar + policy: ScrollBar.AsNeeded + active: true + } + section.property: "sectionName" + section.criteria: ViewSection.FullString + section.delegate: Component { + Item { + width: listView.width + height: section !== "" ? 28 : 0 + visible: section !== "" + + Row { + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.right: parent.right + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + id: sectionLabel + text: section + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Bold + font.letterSpacing: 0.5 + font.capitalization: Font.AllUppercase + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - sectionLabel.width - parent.spacing + height: 1 + color: Theme.border + } + } + } + } + + moveDisplaced: Transition { + NumberAnimation { properties: "y"; duration: 120; easing.type: Easing.InOutQuad } + } + } + + EmptyState { + id: emptyStateText + objectName: "emptyStateText" + anchors.fill: parent + visible: listView.count === 0 + iconName: "mods" + title: "No mods to show" + Accessible.name: title + } + + Timer { + id: autoScrollTimer + interval: 16 + repeat: true + running: dragProxy.visible && listView.autoScrollSpeed !== 0 + onTriggered: { + var newContentY = listView.contentY + listView.autoScrollSpeed + var maxContentY = Math.max(0, listView.contentHeight - listView.height) + listView.contentY = Math.max(0, Math.min(maxContentY, newContentY)) + var center = dragProxy.mapToItem( + listView.contentItem, + dragProxy.width / 2, + dragProxy.height / 2 + ) + listView.dragTargetIndex = root.targetIndexForPoint(center.x, center.y) + } + } + + Connections { + target: modListModel + function onLayoutChanged() { root.reconcileSelection() } + function onModelReset() { root.reconcileSelection() } + function onRowsMoved() { root.reconcileSelection() } + } + + // ── Drag proxy ── + Rectangle { + id: dragProxy + objectName: "dragProxy" + visible: false + width: listView.width + height: root.compactMode ? 32 : 52 + radius: Theme.radiusMd + color: Theme.elevate3 + opacity: 0.92 + z: 1000 + + property int pressOffsetY: 0 + property string modName: "" + property string modPackageId: "" + property string providerColor: "" + + Rectangle { + x: 28; y: 10; width: 32; height: 32 + radius: Theme.radiusSm + color: dragProxy.providerColor || Theme.elevate3 + Text { + anchors.centerIn: parent + text: dragProxy.modName ? dragProxy.modName.charAt(0).toUpperCase() : "?" + color: Theme.elevate0 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.weight: Font.Bold + } + } + + Column { + x: 68; y: 8 + width: parent.width - 78 + Text { + width: parent.width + text: dragProxy.modName + color: Theme.textMain + font.family: Theme.fontFamily + font.bold: true + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + maximumLineCount: 1 + } + Text { + width: parent.width + text: dragProxy.modPackageId + visible: text !== "" + color: Theme.textDim + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontMono + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + + Rectangle { + visible: dragProxy.visible && listView.dragTargetIndex >= 0 && (modListModel ? listView.dragTargetIndex < modListModel.activeCount : true) + x: 0 + y: { + var targetItem = listView.itemAtIndex(listView.dragTargetIndex) + if (targetItem) { + return targetItem.y - listView.contentY + } + var rowH = root.compactMode ? 34 : 54 + return Math.max(0, Math.min( + listView.height - height, + listView.dragTargetIndex * rowH - listView.contentY + )) + } + width: listView.width + height: 2 + color: Theme.primary + z: 999 + } + // ── Delegate ── + Component { + id: dragDelegate + + Rectangle { + id: delegateRect + width: listView.width + height: root.compactMode ? 32 : 52 + radius: Theme.radiusMd + + color: { + var highlighted = false + if (modListPanel) { + modListPanel.highlightRevision // re-evaluate when highlights change + highlighted = modListPanel.isHighlighted(model.uuid) + } + if (listView.selectedIndices.indexOf(index) >= 0) + return highlighted + ? Qt.tint(Theme.elevate4, Qt.alpha(Theme.primary, 0.25)) + : Theme.elevate4 + if (highlighted) + return Qt.alpha(Theme.primary, 0.25) + if (mouseArea.containsMouse && !dragProxy.visible) + return Theme.elevate3 + return "transparent" + } + opacity: listView.dragSourceIndex === index && dragProxy.visible ? 0.0 : 1.0 + Accessible.role: Accessible.ListItem + Accessible.name: (model.loadIndex > 0 ? "#" + model.loadIndex + ", " : "") + + (model.name || "") + + (model.packageId ? ", " + model.packageId : "") + Accessible.selected: listView.selectedIndices.indexOf(index) >= 0 + + MouseArea { + objectName: "rowMouseArea" + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + if (listView.selectedUuids.indexOf(model.uuid) < 0) + root.selectRow(index, model.uuid, Qt.NoModifier) + modListPanel.showContextMenu(listView.selectedUuids) + return + } + root.selectRow(index, model.uuid, mouse.modifiers) + } + } + + Item { + id: dragHandle + x: 0 + width: 24 + height: parent.height + visible: model.loadIndex > 0 + + Image { + anchors.centerIn: parent + width: 16 + height: 16 + sourceSize.width: 16 + sourceSize.height: 16 + opacity: (modListModel && modListModel.isFiltered) ? Theme.disabledOpacity : 1.0 + source: "image://icons/grip?color=" + encodeURIComponent( + (modListModel && modListModel.isFiltered) ? Theme.textDim : Theme.textMuted) + } + + Loader { + active: Boolean(modListModel && modListModel.isFiltered) + sourceComponent: PxToolTip { + text: "Clear search and filters to reorder" + visible: dragArea.containsMouse + } + } + + MouseArea { + id: dragArea + objectName: "dragArea" + anchors.fill: parent + hoverEnabled: true + preventStealing: true + cursorShape: (modListModel && modListModel.isFiltered) + ? Qt.ArrowCursor + : (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor) + + property real pressRootX: 0 + property real pressRootY: 0 + + onPressed: (mouse) => { + root.selectRow(index, model.uuid, mouse.modifiers) + if (modListModel && modListModel.isFiltered) + return + var point = dragArea.mapToItem(root, mouse.x, mouse.y) + pressRootX = point.x + pressRootY = point.y + + var delegatePosition = delegateRect.mapToItem(root, 0, 0) + dragProxy.modName = model.name || "" + dragProxy.modPackageId = model.packageId || "" + dragProxy.providerColor = model.providerColor || "" + dragProxy.pressOffsetY = mouse.y + dragProxy.x = delegatePosition.x + dragProxy.y = delegatePosition.y + listView.dragSourceIndex = index + listView.dragTargetIndex = index + } + + onPositionChanged: (mouse) => { + if (!pressed) + return + var point = dragArea.mapToItem(root, mouse.x, mouse.y) + if (!dragProxy.visible) { + var distance = Math.abs(point.x - pressRootX) + + Math.abs(point.y - pressRootY) + if (distance < Qt.styleHints.startDragDistance) + return + dragProxy.visible = true + } + + dragProxy.y = point.y - dragProxy.pressOffsetY + var center = dragProxy.mapToItem( + listView.contentItem, + dragProxy.width / 2, + dragProxy.height / 2 + ) + listView.dragTargetIndex = root.targetIndexForPoint(center.x, center.y) + + var edgeThreshold = 40 + var bottomEdge = listView.height - edgeThreshold + if (point.y < edgeThreshold) { + var topDistance = edgeThreshold - point.y + listView.autoScrollSpeed = -Math.min( + 15, + Math.max(3, topDistance / 2) + ) + } else if (point.y > bottomEdge) { + var bottomDistance = point.y - bottomEdge + listView.autoScrollSpeed = Math.min( + 15, + Math.max(3, bottomDistance / 2) + ) + } else { + listView.autoScrollSpeed = 0 + } + } + + onReleased: { + var sourceIndex = listView.dragSourceIndex + var targetIndex = listView.dragTargetIndex + var shouldMove = dragProxy.visible && sourceIndex !== targetIndex + dragProxy.visible = false + listView.autoScrollSpeed = 0 + listView.dragSourceIndex = -1 + listView.dragTargetIndex = -1 + if (shouldMove) { + modListPanel.moveRow(sourceIndex, targetIndex) + modListPanel.dragEnded() + } + } + + onCanceled: { + dragProxy.visible = false + listView.autoScrollSpeed = 0 + listView.dragSourceIndex = -1 + listView.dragTargetIndex = -1 + } + } + } + + Item { + id: loadIndexItem + x: 24 + width: 32 + height: parent.height + + Text { + anchors.centerIn: parent + text: model.loadIndex > 0 ? ("#" + model.loadIndex) : "–" + color: model.loadIndex > 0 ? Theme.textMuted : Theme.textDim + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontMono + font.weight: model.loadIndex > 0 ? Font.Medium : Font.Normal + } + } + + Item { + x: 56 + width: 32 + height: parent.height + + PxCheckIndicator { + anchors.centerIn: parent + checkState: model.checkState + hovered: checkboxArea.containsMouse + } + + MouseArea { + id: checkboxArea + objectName: "checkboxArea" + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + Accessible.role: Accessible.CheckBox + Accessible.name: ( + model.checkState === Qt.Checked ? "Disable " : "Enable " + ) + (model.name || "mod") + Accessible.checked: model.checkState === Qt.Checked + onClicked: (mouse) => { + root.selectRow(index, model.uuid, mouse.modifiers) + modListPanel.toggleCheck(index) + } + Accessible.onPressAction: { + root.selectRow(index, model.uuid, Qt.NoModifier) + modListPanel.toggleCheck(index) + } + } + } + + // ── Avatar (hidden in compact mode) ── + Rectangle { + visible: !root.compactMode + x: 88; y: 8; width: 36; height: 36 + radius: Theme.radiusSm + color: model.providerColor || Theme.elevate3 + + Text { + anchors.centerIn: parent + text: model.name ? model.name.charAt(0).toUpperCase() : "?" + color: Theme.elevate0 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.weight: Font.Bold + } + } + + // ── Standard 2-line layout (name + packageId) ── + Column { + visible: !root.compactMode + x: 132; y: 8 + width: parent.width - badgesRow.width - 142 + spacing: 1 + + Text { + width: parent.width + text: model.name || "" + color: Theme.textMain + font.family: Theme.fontFamily + font.bold: true + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: model.packageId || "" + visible: text !== "" + color: Theme.textDim + font.pixelSize: Theme.fontSizeSm + font.family: Theme.fontMono + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + // ── Compact single-line layout ── + Row { + visible: root.compactMode + x: 92 + width: parent.width - badgesRow.width - 102 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + clip: true + + Text { + id: compactNameText + text: model.name || "" + color: Theme.textMain + font.family: Theme.fontFamily + font.bold: true + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + maximumLineCount: 1 + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width - (compactSubText.visible ? Math.min(compactSubText.implicitWidth + 6, parent.width * 0.45) : 0)) + } + + Text { + id: compactSubText + text: (model.modVersion ? model.modVersion + " " : "") + (model.packageId ? "(" + model.packageId + ")" : "") + visible: text !== "" && (parent.width - compactNameText.width - 6) >= 40 + width: Math.min(implicitWidth, Math.max(0, parent.width - compactNameText.width - 6)) + color: Theme.textDim + font.pixelSize: Theme.fontSizeXs + font.family: Theme.fontMono + elide: Text.ElideRight + maximumLineCount: 1 + anchors.verticalCenter: parent.verticalCenter + } + } + + // ── Badges row (right-aligned) ── + Row { + id: badgesRow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + // Startup impact pill + Loader { + id: startupImpactLoader + active: model.startupImpact > 0 + visible: active + anchors.verticalCenter: parent.verticalCenter + sourceComponent: PxBadge { + compact: root.compactMode + text: model.startupImpactText + textColor: Theme.onAccent + fillColor: model.startupImpactColor + tooltip: "Startup impact: adds " + model.startupImpactText + " to game load" + } + } + + PxBadge { + visible: !!model.providerLabel + anchors.verticalCenter: parent.verticalCenter + compact: root.compactMode + outlined: true + text: model.providerLabel || "" + accent: model.providerColor || Theme.border + textColor: model.providerColor || Theme.textMuted + } + + PxBadge { + visible: !!model.modVersion + anchors.verticalCenter: parent.verticalCenter + compact: root.compactMode + text: model.modVersion || "" + } + + // Error badge + Loader { + active: !!model.hasError + visible: active + anchors.verticalCenter: parent.verticalCenter + sourceComponent: DiagnosticBadge { + level: "error" + tooltip: model.errorTooltip || "" + } + } + + // Warning badge + Loader { + active: !!model.hasWarning + visible: active + anchors.verticalCenter: parent.verticalCenter + sourceComponent: DiagnosticBadge { + level: "warning" + tooltip: model.warningTooltip || "" + } + } + } + } + } + + // ── Keyboard navigation ── + Shortcut { + sequence: "Up" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: navigate(false, -1) + } + Shortcut { + sequence: "Down" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: navigate(false, 1) + } + Shortcut { + sequence: "Shift+Up" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: navigate(true, -1) + } + Shortcut { + sequence: "Shift+Down" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: navigate(true, 1) + } + Shortcut { + sequence: "Return" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: modListPanel.toggleChecked(listView.selectedIndices) + } + Shortcut { + sequence: "Space" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: modListPanel.toggleChecked(listView.selectedIndices) + } + Shortcut { + sequence: "Ctrl+A" + context: Qt.WindowShortcut + enabled: root.keyboardActive + onActivated: { + var indices = [] + var uuids = [] + for (var i = 0; i < listView.count; ++i) { + indices.push(i) + uuids.push(modListPanel.uuidAt(i)) + } + listView.selectedIndices = indices + listView.selectedUuids = uuids + listView.anchorIndex = listView.count - 1 + listView.anchorUuid = uuids.length > 0 ? uuids[uuids.length - 1] : "" + listView.currentIndex = listView.count - 1 + listView.currentUuid = listView.anchorUuid + modListPanel.selectionChanged(uuids) + if (listView.currentUuid) + modListPanel.modSelected(listView.currentUuid) + } + } + + function selectRow(index, uuid, modifiers) { + listView.forceActiveFocus() + if (modifiers & Qt.ControlModifier) { + var indices = listView.selectedIndices.slice() + var uuids = listView.selectedUuids.slice() + var selectedPosition = uuids.indexOf(uuid) + if (selectedPosition >= 0) { + uuids.splice(selectedPosition, 1) + indices.splice(selectedPosition, 1) + } else { + indices.push(index) + uuids.push(uuid) + } + listView.selectedIndices = indices + listView.selectedUuids = uuids + listView.anchorIndex = index + listView.anchorUuid = uuid + } else if (modifiers & Qt.ShiftModifier && listView.anchorIndex >= 0) { + var start = Math.min(listView.anchorIndex, index) + var end = Math.max(listView.anchorIndex, index) + var range = [] + var rangeUuids = [] + for (var row = start; row <= end; ++row) { + range.push(row) + rangeUuids.push(modListPanel.uuidAt(row)) + } + listView.selectedIndices = range + listView.selectedUuids = rangeUuids + } else { + listView.selectedIndices = [index] + listView.selectedUuids = [uuid] + listView.anchorIndex = index + listView.anchorUuid = uuid + } + + listView.currentIndex = index + listView.currentUuid = uuid + modListPanel.selectionChanged(listView.selectedUuids) + modListPanel.modSelected(uuid) + } + + function revealRow(index, uuid) { + selectRow(index, uuid, 0) + listView.positionViewAtIndex(index, ListView.Center) + } + + function navigate(extend, direction) { + var newIndex = listView.currentIndex + direction + if (newIndex < 0 || newIndex >= listView.count) + return + + var uuid = modListPanel.uuidAt(newIndex) + if (extend) { + if (listView.anchorIndex < 0) { + listView.anchorIndex = newIndex + listView.anchorUuid = uuid + } + var start = Math.min(listView.anchorIndex, newIndex) + var end = Math.max(listView.anchorIndex, newIndex) + var range = [] + var rangeUuids = [] + for (var row = start; row <= end; ++row) { + range.push(row) + rangeUuids.push(modListPanel.uuidAt(row)) + } + listView.selectedIndices = range + listView.selectedUuids = rangeUuids + } else { + listView.selectedIndices = [newIndex] + listView.selectedUuids = [uuid] + listView.anchorIndex = newIndex + listView.anchorUuid = uuid + } + + listView.currentIndex = newIndex + listView.currentUuid = uuid + listView.positionViewAtIndex(newIndex, ListView.Contain) + modListPanel.selectionChanged(listView.selectedUuids) + modListPanel.modSelected(uuid) + } + + function reconcileSelection() { + var indices = [] + var uuids = [] + for (var i = 0; i < listView.selectedUuids.length; ++i) { + var uuid = listView.selectedUuids[i] + var row = modListPanel.rowForUuid(uuid) + if (row >= 0) { + indices.push(row) + uuids.push(uuid) + } + } + listView.selectedIndices = indices + listView.selectedUuids = uuids + + var currentRow = listView.currentUuid ? modListPanel.rowForUuid(listView.currentUuid) : -1 + if (currentRow < 0 && indices.length > 0) { + currentRow = indices[0] + listView.currentUuid = uuids[0] + modListPanel.modSelected(listView.currentUuid) + } else if (currentRow < 0) { + listView.currentIndex = -1 + listView.currentUuid = "" + listView.anchorIndex = -1 + listView.anchorUuid = "" + modListPanel.selectionChanged([]) + modListPanel.modSelected("") + return + } + listView.currentIndex = currentRow + + var anchorRow = listView.anchorUuid ? modListPanel.rowForUuid(listView.anchorUuid) : -1 + if (anchorRow < 0) { + anchorRow = currentRow + listView.anchorUuid = listView.currentUuid + } + listView.anchorIndex = anchorRow + modListPanel.selectionChanged(uuids) + } + + function targetIndexForPoint(x, y) { + if (listView.count === 0 || !modListModel || modListModel.activeCount <= 0) + return -1 + var maxActiveTarget = modListModel.activeCount - 1 + var idx = listView.indexAt(x, y) + if (idx >= 0) + return Math.max(0, Math.min(idx, maxActiveTarget)) + if (y <= 0) + return 0 + if (y >= listView.contentHeight) + return maxActiveTarget + idx = listView.indexAt(x, y + 16) + if (idx >= 0) + return Math.max(0, Math.min(idx, maxActiveTarget)) + idx = listView.indexAt(x, y - 16) + if (idx >= 0) + return Math.max(0, Math.min(idx, maxActiveTarget)) + var rowH = root.compactMode ? 34 : 54 + return Math.max(0, Math.min(Math.floor(y / rowH), maxActiveTarget)) + } +} diff --git a/src/ui/ModRow.qml b/src/ui/ModRow.qml new file mode 100644 index 0000000..d721521 --- /dev/null +++ b/src/ui/ModRow.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: row + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property string state + required property string actionLabel + required property bool incompatible + required property bool queued + required property bool active + required property string fileSize + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: row.active ? Theme.success : Theme.border + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(row.itemId, row.kind) } + Accessible.role: Accessible.Button + Accessible.name: row.title + Accessible.onPressAction: workshopPanel.openItem(row.itemId, row.kind) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 10 + Item { + implicitWidth: activeBox.implicitWidth + implicitHeight: activeBox.implicitHeight + PxCheckBox { + id: activeBox + objectName: "activeToggle" + anchors.centerIn: parent + visible: row.kind === "mod" && row.state !== "missing" + checked: row.active + Accessible.name: "Active" + Accessible.description: "Enable or disable " + row.title + onToggled: { + workshopPanel.toggleActivation(row.itemId) + checked = Qt.binding(() => row.active) + } + } + } + CatalogThumb { + Layout.fillHeight: true + Layout.preferredWidth: height + previewUrl: row.previewUrl + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: row.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: true + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: row.incompatible ? "Not for this game version" : row.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") + textFormat: Text.PlainText + color: row.incompatible ? Theme.warning : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + } + PxButton { + visible: row.state !== "installed" + text: row.actionLabel + variant: row.state === "outdated" ? "warning" : "primary" + enabled: !row.queued + onClicked: workshopPanel.downloadItem(row.itemId, row.kind) + } + } +} diff --git a/src/ui/ModTree.qml b/src/ui/ModTree.qml new file mode 100644 index 0000000..0989f9e --- /dev/null +++ b/src/ui/ModTree.qml @@ -0,0 +1,410 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate1 + clip: true + + property int anchorRow: -1 + property int focusedRow: -1 + property int dropRow: -1 + property var dropTarget: null + + function openTags() { tagEditor.open() } + + function expandAll() { + tree.expandRecursively() + organizerPanel.persistAllCollapsed(false) + } + + function collapseAll() { + tree.collapseRecursively() + root.anchorRow = -1 + root.focusedRow = -1 + organizerPanel.persistAllCollapsed(true) + } + + function restoreExpansion() { + var indexes = organizerPanel.expandedIndexes() + for (var i = 0; i < indexes.length; ++i) { + var row = tree.rowAtIndex(indexes[i]) + if (row >= 0 && !tree.isExpanded(row)) + tree.expand(row) + } + } + + function clickRow(index, row, modifiers) { + var range = [] + if ((modifiers & Qt.ShiftModifier) && root.anchorRow >= 0) { + var from = Math.min(root.anchorRow, row) + var to = Math.max(root.anchorRow, row) + for (var r = from; r <= to; ++r) + range.push(tree.index(r, 0)) + } else { + root.anchorRow = row + } + root.focusedRow = row + organizerPanel.selectRow(index, modifiers, range) + tree.forceActiveFocus() + } + + function toggleRow(index, row) { + if (tree.isExpanded(row)) { + tree.collapse(row) + organizerPanel.persistCollapsed(index, true) + } else { + tree.expand(row) + organizerPanel.persistCollapsed(index, false) + root.restoreExpansion() + } + } + + function navigateRow(row, modifiers) { + if (row < 0 || row >= tree.rows) + return + clickRow(tree.index(row, 0), row, modifiers) + tree.positionViewAtRow(row, TableView.Contain) + } + + function activeRow() { + if (root.focusedRow >= 0 && root.focusedRow < tree.rows) + return root.focusedRow + return -1 + } + + property int modelRevision: 0 + readonly property bool noModsInstalled: { + modelRevision + if (tree.rows !== 1) + return false + var idx = tree.index(0, 0) + return modTreeModel.data(idx, Qt.UserRole + 1) === "ungrouped" + && modTreeModel.data(idx, Qt.UserRole + 7) === 0 + } + + function selectAll() { + if (tree.rows === 0) + return + var all = [] + for (var r = 0; r < tree.rows; ++r) + all.push(tree.index(r, 0)) + organizerPanel.selectRow(all[0], Qt.NoModifier, []) + organizerPanel.selectRow(all[all.length - 1], Qt.ShiftModifier, all) + root.anchorRow = 0 + root.focusedRow = tree.rows - 1 + } + + Connections { + target: modTreeModel + function onDataChanged() { root.modelRevision++ } + function onRowsInserted() { root.modelRevision++ } + function onRowsRemoved() { root.modelRevision++ } + function onModelReset() { + root.modelRevision++ + root.anchorRow = -1 + Qt.callLater(root.restoreExpansion) + root.focusedRow = -1 + } + } + + TreeView { + id: tree + objectName: "organizerTreeView" + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + clip: true + model: modTreeModel + columnWidthProvider: function() { return tree.width } + onWidthChanged: forceLayout() + boundsBehavior: Flickable.StopAtBounds + reuseItems: false + focus: true + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + Keys.onPressed: function(event) { + var row = root.activeRow() + if (event.key === Qt.Key_Down || event.key === Qt.Key_Up) { + root.navigateRow(row < 0 ? 0 : row + (event.key === Qt.Key_Down ? 1 : -1), event.modifiers) + event.accepted = true + } else if (event.key === Qt.Key_Right && row >= 0) { + if (!tree.isExpanded(row)) + root.toggleRow(tree.index(row, 0), row) + event.accepted = true + } else if (event.key === Qt.Key_Left && row >= 0) { + if (tree.isExpanded(row)) { + root.toggleRow(tree.index(row, 0), row) + } else { + var depth = tree.depth(row) + for (var parentRow = row - 1; parentRow >= 0; --parentRow) { + if (tree.depth(parentRow) < depth) { + root.navigateRow(parentRow, Qt.NoModifier) + break + } + } + } + event.accepted = true + } else if (event.key === Qt.Key_Space && row >= 0) { + organizerPanel.toggleCheck(tree.index(row, 0)) + event.accepted = true + } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && row >= 0) { + var enterIndex = tree.index(row, 0) + if (tree.hasChildren(row)) + root.toggleRow(enterIndex, row) + else + organizerPanel.toggleCheck(enterIndex) + event.accepted = true + } else if (event.key === Qt.Key_A && (event.modifiers & Qt.ControlModifier)) { + root.selectAll() + event.accepted = true + } + } + + delegate: Rectangle { + id: item + required property int row + required property int column + required property int depth + required property bool expanded + required property bool hasChildren + required property var model + + implicitWidth: tree.width + implicitHeight: item.model.kind === "mod" ? 30 : 32 + radius: Theme.radiusSm + color: root.dropRow === item.row + ? Theme.primaryBg + : (item.model.selected ? Theme.primaryBg + : (rowMouse.containsMouse ? Theme.elevate3 : "transparent")) + border.width: root.dropRow === item.row ? 1 : 0 + border.color: Theme.primary + Accessible.role: Accessible.TreeItem + Accessible.name: item.model.name || "" + Accessible.selected: !!item.model.selected + + Repeater { + model: item.depth + delegate: Rectangle { + required property int index + x: 12 + index * 20 + width: 1 + height: item.height + color: Theme.border + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + item.depth * 20 + anchors.rightMargin: 10 + spacing: 7 + Item { + Layout.preferredWidth: 18 + Layout.preferredHeight: 28 + Image { + anchors.centerIn: parent + visible: item.hasChildren + source: "image://icons/chevron?color=" + encodeURIComponent(Theme.textDim) + sourceSize.width: 13 + sourceSize.height: 13 + rotation: item.expanded ? 90 : 0 + } + MouseArea { + anchors.fill: parent + enabled: item.hasChildren + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleRow(tree.index(item.row, 0), item.row) + } + } + + Item { + Layout.preferredWidth: 20 + Layout.preferredHeight: 28 + PxCheckIndicator { + anchors.centerIn: parent + checkState: item.model.checkState + hovered: checkMouse.containsMouse + } + MouseArea { + id: checkMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + Accessible.role: Accessible.CheckBox + Accessible.name: (item.model.checkState === Qt.Checked ? "Disable " : "Enable ") + item.model.name + Accessible.checked: item.model.checkState === Qt.Checked + onClicked: organizerPanel.toggleCheck(tree.index(item.row, 0)) + Accessible.onPressAction: organizerPanel.toggleCheck(tree.index(item.row, 0)) + } + } + + Image { + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + source: "image://icons/" + + (item.model.kind === "mod" ? (item.model.providerIcon || "folder") : "folder") + + "?color=" + encodeURIComponent( + item.model.kind === "mod" ? (item.model.providerColor || Theme.textDim) + : item.model.kind === "ungrouped" ? Theme.textDim : Theme.primary) + sourceSize.width: 16 + sourceSize.height: 16 + fillMode: Image.PreserveAspectFit + } + + Text { + text: item.model.name || "" + Layout.fillWidth: true + color: item.model.kind === "ungrouped" || (item.model.kind === "mod" && item.model.checkState !== Qt.Checked) + ? Theme.textMuted : Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: item.model.kind !== "mod" + font.italic: item.model.kind === "ungrouped" + elide: Text.ElideRight + } + + PxBadge { + visible: !!item.model.hasRule + text: "Auto" + textColor: Theme.warning + fillColor: Theme.warningBg + } + + PxBadge { + visible: item.model.kind === "mod" && !!item.model.providerLabel + outlined: true + text: item.model.providerLabel || "" + accent: item.model.providerColor || Theme.border + textColor: item.model.providerColor || Theme.textMuted + } + + PxBadge { + visible: item.model.kind === "mod" && !!item.model.modVersion + text: item.model.modVersion || "" + } + + Repeater { + model: item.model.kind === "mod" ? (item.model.tags || []) : [] + delegate: PxBadge { + required property var modelData + property color tagColor: modelData.color + text: modelData.name + textColor: tagColor + fillColor: Qt.rgba(tagColor.r, tagColor.g, tagColor.b, 0.14) + } + } + + DiagnosticBadge { + visible: !!item.model.hasError + level: "error" + tooltip: item.model.errorTooltip || "" + } + + DiagnosticBadge { + visible: !!item.model.hasWarning + level: "warning" + tooltip: item.model.warningTooltip || "" + } + + PxBadge { + visible: item.model.kind !== "mod" + textColor: Theme.textMuted + fillColor: Theme.elevate3 + text: item.model.filtering + ? item.model.visibleCount + " of " + item.model.totalCount + : item.model.enabledCount + "/" + item.model.totalCount + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + z: -1 + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + preventStealing: true + property real pressX: 0 + property real pressY: 0 + property bool dragging: false + property bool pendingSingle: false + onPressed: function(mouse) { + pressX = mouse.x + pressY = mouse.y + dragging = false + pendingSingle = false + if (mouse.button === Qt.RightButton) { + organizerPanel.contextMenu(tree.index(item.row, 0)) + return + } + if (mouse.modifiers === Qt.NoModifier && item.model.selected) + pendingSingle = true + else + root.clickRow(tree.index(item.row, 0), item.row, mouse.modifiers) + } + onPositionChanged: function(mouse) { + if (!(pressedButtons & Qt.LeftButton)) + return + if (!dragging && Math.abs(mouse.x - pressX) + Math.abs(mouse.y - pressY) < Qt.styleHints.startDragDistance) + return + dragging = true + var pos = rowMouse.mapToItem(tree.contentItem, mouse.x, mouse.y) + var cell = tree.cellAtPosition(Qt.point(pos.x, pos.y)) + var target = cell.y >= 0 ? tree.index(cell.y, 0) : null + root.dropTarget = target !== null && organizerPanel.canDrop(tree.index(item.row, 0), target) ? target : null + root.dropRow = root.dropTarget !== null ? cell.y : -1 + } + onReleased: function(mouse) { + if (dragging && root.dropTarget !== null) + organizerPanel.dropOn(tree.index(item.row, 0), root.dropTarget) + else if (!dragging && pendingSingle) + root.clickRow(tree.index(item.row, 0), item.row, Qt.NoModifier) + dragging = false + pendingSingle = false + root.dropTarget = null + root.dropRow = -1 + } + onCanceled: { + dragging = false + pendingSingle = false + root.dropTarget = null + root.dropRow = -1 + } + onDoubleClicked: { + if (item.model.kind !== "mod") + root.toggleRow(tree.index(item.row, 0), item.row) + } + } + } + } + + TagEditor { + id: tagEditor + tagRows: organizerPanel ? organizerPanel.tagRows : [] + selectedCount: organizerPanel ? organizerPanel.selectedTagCount : 0 + errorMessage: organizerPanel ? organizerPanel.editorError : "" + onCreateRequested: (name, color) => organizerPanel.createTag(name, color) + onUpdateRequested: (id, name, color) => organizerPanel.updateTag(id, name, color) + onDeleteRequested: id => organizerPanel.deleteTag(id) + onAssignmentRequested: (id, add) => organizerPanel.assignTag(id, add) + } + + + Connections { + target: organizerPanel + function onTagCreated() { tagEditor.clearInputs() } + } + + EmptyState { + anchors.centerIn: parent + width: parent.width - 48 + visible: organizerPanel && (!organizerPanel.ready || tree.rows === 0 || root.noModsInstalled) + iconName: organizerPanel && organizerPanel.ready ? "empty" : "" + title: !organizerPanel || !organizerPanel.ready ? "Loading organizer…" + : root.noModsInstalled ? "No mods installed" + : "No mods match this filter" + detail: organizerPanel && organizerPanel.ready && !root.noModsInstalled + ? "Try a different filter or search." : "" + } +} diff --git a/src/ui/PxBadge.qml b/src/ui/PxBadge.qml new file mode 100644 index 0000000..73f0603 --- /dev/null +++ b/src/ui/PxBadge.qml @@ -0,0 +1,57 @@ +import QtQuick + +// Small rounded label used for mod metadata (version, provider, tags, startup impact). +// `outlined` adds a 1px border in `accent` over a tinted fill (provider style). +Rectangle { + id: root + + property string text: "" + property color textColor: Theme.primary + property color fillColor: Theme.primaryBg + property color accent: "transparent" + property bool outlined: false + property bool compact: false + property string tooltip: "" + + implicitHeight: compact ? 18 : 20 + implicitWidth: label.implicitWidth + 12 + radius: Theme.radiusSm + color: outlined ? Theme.elevate3 : fillColor + border.width: outlined ? 1 : 0 + border.color: accent + + Accessible.role: Accessible.StaticText + Accessible.name: text + + Rectangle { + visible: root.outlined + anchors.fill: parent + radius: parent.radius + color: root.accent + opacity: 0.18 + } + + Text { + id: label + anchors.centerIn: parent + text: root.text + color: root.textColor + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Bold + } + + HoverHandler { + id: hover + enabled: root.tooltip.length > 0 + } + + Loader { + active: hover.hovered && root.tooltip.length > 0 + sourceComponent: PxToolTip { + parent: root + visible: true + text: root.tooltip + } + } +} diff --git a/src/ui/PxButton.qml b/src/ui/PxButton.qml new file mode 100644 index 0000000..f404145 --- /dev/null +++ b/src/ui/PxButton.qml @@ -0,0 +1,132 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: control + + // "secondary" | "primary" | "success" | "warning" | "danger" (soft, fills on + // hover) | "dangerSolid" | "ghost" + property string variant: "secondary" + // Name from the image://icons provider; icon-only when text is empty. + property string iconName: "" + property real iconRotation: 0 + + readonly property bool isPrimary: variant === "primary" + readonly property bool isSuccess: variant === "success" + readonly property bool isDangerSolid: variant === "dangerSolid" + readonly property bool isDanger: variant === "danger" + readonly property bool isWarning: variant === "warning" + readonly property bool isGhost: variant === "ghost" + readonly property bool isSolid: isPrimary || isSuccess || isDangerSolid + readonly property bool iconOnly: text.length === 0 && iconName.length > 0 + + implicitHeight: 32 + implicitWidth: iconOnly ? 32 : Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + leftPadding: iconOnly ? 8 : 12 + rightPadding: leftPadding + topPadding: 0 + bottomPadding: 0 + hoverEnabled: true + focusPolicy: Qt.StrongFocus + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: isSolid ? Font.DemiBold : Font.Medium + + HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } + + // Optional override for the icon/text colour (e.g. warning-tinted Save). + property color iconColor: "transparent" + + readonly property color foreground: iconColor.a > 0 ? iconColor + : isSolid ? Theme.onAccent + : (isDanger || isWarning) && hovered ? Theme.onAccent + : isDanger ? Theme.danger + : isWarning ? Theme.warning + : hovered ? Theme.textMain + : Theme.textMuted + + Accessible.name: text.length > 0 ? text : ToolTip.text + + Loader { + active: control.hovered && control.ToolTip.text.length > 0 + sourceComponent: PxToolTip { + parent: control + visible: true + text: control.ToolTip.text + } + } + + contentItem: Item { + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + opacity: control.enabled ? 1 : Theme.disabledOpacity + + Row { + id: content + anchors.centerIn: parent + spacing: 6 + + Image { + visible: control.iconName.length > 0 + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + sourceSize.width: 16 + sourceSize.height: 16 + rotation: control.iconRotation + source: visible ? "image://icons/" + control.iconName + "?color=" + encodeURIComponent(control.foreground) : "" + } + + Text { + visible: control.text.length > 0 + anchors.verticalCenter: parent.verticalCenter + text: control.text + font: control.font + color: control.foreground + } + } + } + + background: Rectangle { + implicitWidth: 32 + implicitHeight: 32 + radius: Theme.radiusMd + opacity: control.enabled ? 1 : Theme.disabledOpacity + color: { + if (control.isPrimary) + return control.down || control.hovered ? Theme.primaryHover : Theme.primary + if (control.isSuccess) + return control.down || control.hovered ? Theme.successHover : Theme.success + if (control.isDangerSolid) + return control.down || control.hovered ? Theme.dangerHover : Theme.danger + if (control.isDanger) + return control.down || control.hovered ? Theme.danger : Theme.dangerBg + if (control.isWarning) + return control.down || control.hovered ? Theme.warning : Theme.warningBg + if (control.isGhost) + return control.down || control.hovered ? Theme.elevate3 : "transparent" + return control.down || control.hovered ? Theme.elevate4 : Theme.elevate3 + } + border.width: 1 + border.color: { + if (control.visualFocus) + return Theme.primary + if (control.isPrimary) + return control.hovered ? Theme.primaryHover : Theme.primary + if (control.isSuccess) + return control.hovered ? Theme.successHover : Theme.success + if (control.isDangerSolid) + return control.hovered ? Theme.dangerHover : Theme.danger + if (control.isDanger) + return Theme.danger + if (control.isWarning) + return Theme.warning + if (control.isGhost) + return control.hovered ? Theme.border : "transparent" + return control.hovered ? Theme.elevate4 : Theme.border + } + + Behavior on color { ColorAnimation { duration: 100 } } + } +} diff --git a/src/ui/PxCheckBox.qml b/src/ui/PxCheckBox.qml new file mode 100644 index 0000000..3fb27ab --- /dev/null +++ b/src/ui/PxCheckBox.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Controls + +CheckBox { + id: control + + hoverEnabled: true + spacing: 8 + padding: 0 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } + + indicator: PxCheckIndicator { + x: control.leftPadding + y: (control.height - height) / 2 + checkState: control.checkState + hovered: control.hovered + focused: control.visualFocus + opacity: control.enabled ? 1 : Theme.disabledOpacity + } + + contentItem: Text { + leftPadding: control.text.length > 0 ? control.indicator.width + control.spacing : control.indicator.width + text: control.text + font: control.font + color: control.enabled ? Theme.textMain : Theme.textDim + verticalAlignment: Text.AlignVCenter + } +} diff --git a/src/ui/PxCheckIndicator.qml b/src/ui/PxCheckIndicator.qml new file mode 100644 index 0000000..4bb3a05 --- /dev/null +++ b/src/ui/PxCheckIndicator.qml @@ -0,0 +1,48 @@ +import QtQuick + +// Visual-only box; the owning control supplies state and handles input. +Item { + id: root + + property int checkState: Qt.Unchecked + property bool hovered: false + property bool focused: false + + readonly property bool isChecked: checkState === Qt.Checked + readonly property bool isPartial: checkState === Qt.PartiallyChecked + + implicitWidth: 16 + implicitHeight: 16 + + Rectangle { + anchors.fill: parent + radius: Theme.radiusXs + color: root.isChecked ? Theme.primary + : root.isPartial ? Theme.primaryBg + : "transparent" + border.width: 1 + border.color: root.isChecked || root.isPartial || root.focused ? Theme.primary + : root.hovered ? Theme.textDim + : Theme.border + } + + Image { + anchors.centerIn: parent + visible: root.isChecked + width: 12 + height: 12 + sourceSize.width: 12 + sourceSize.height: 12 + source: visible ? "image://icons/check?color=" + encodeURIComponent(Theme.onAccent) : "" + } + + Text { + anchors.centerIn: parent + visible: root.isPartial + text: "\u2212" + color: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: true + } +} diff --git a/src/ui/PxComboBox.qml b/src/ui/PxComboBox.qml new file mode 100644 index 0000000..c6071e1 --- /dev/null +++ b/src/ui/PxComboBox.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Controls + +ComboBox { + id: control + + implicitHeight: 32 + leftPadding: 10 + rightPadding: 28 + hoverEnabled: true + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + contentItem: Text { + text: control.displayText + font: control.font + color: control.enabled ? Theme.textMain : Theme.textDim + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + indicator: Image { + x: control.width - width - 10 + y: (control.height - height) / 2 + width: 12 + height: 12 + source: "image://icons/chevron-down?color=" + encodeURIComponent(control.hovered ? Theme.textMain : Theme.textDim) + sourceSize.width: 12 + sourceSize.height: 12 + rotation: control.popup.visible ? 180 : 0 + } + + background: Rectangle { + implicitWidth: 120 + implicitHeight: 32 + radius: Theme.radiusMd + opacity: control.enabled ? 1 : Theme.disabledOpacity + color: Theme.elevate0 + border.width: 1 + border.color: control.activeFocus || control.popup.visible ? Theme.primary + : control.hovered ? Theme.elevate4 + : Theme.border + } + + delegate: ItemDelegate { + id: option + required property int index + required property var model + width: ListView.view ? ListView.view.width : control.width + height: 28 + hoverEnabled: true + leftPadding: 8 + rightPadding: 8 + topPadding: 0 + bottomPadding: 0 + highlighted: control.highlightedIndex === index + contentItem: Text { + text: option.model[control.textRole] !== undefined ? option.model[control.textRole] : option.model.modelData + font: control.font + color: control.currentIndex === option.index ? Theme.primary : Theme.textMain + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: Theme.radiusSm + color: option.highlighted ? Theme.elevate3 : "transparent" + } + } + + popup: Popup { + popupType: Popup.Window + y: control.height + 4 + width: control.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) + padding: 4 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + ScrollBar.vertical: PxScrollBar {} + } + + background: Rectangle { + radius: Theme.radiusMd + color: Theme.elevate2 + border.width: 1 + border.color: Theme.border + } + } +} diff --git a/src/ui/PxDialog.qml b/src/ui/PxDialog.qml new file mode 100644 index 0000000..3e0e00e --- /dev/null +++ b/src/ui/PxDialog.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls + +// Themed modal shell. Footer buttons: `standardButtons` render as PxButton +// (accept role = primary); custom footers can assign their own DialogButtonBox. +Dialog { + id: dialog + + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + padding: 16 + topPadding: 12 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + Overlay.modal: Rectangle { color: Theme.overlay } + + background: Rectangle { + color: Theme.elevate2 + border.color: Theme.border + radius: Theme.radiusLg + } + + header: Item { + implicitHeight: 48 + visible: dialog.title.length > 0 + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 16 + anchors.rightMargin: 16 + text: dialog.title + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.border + } + } + + footer: PxDialogFooter { + visible: buttonBox.count > 0 + + DialogButtonBox { + id: buttonBox + + standardButtons: dialog.standardButtons + alignment: Qt.AlignRight + spacing: 8 + padding: 0 + background: null + + onAccepted: dialog.accept() + onRejected: dialog.reject() + onApplied: dialog.applied() + onDiscarded: dialog.discarded() + onHelpRequested: dialog.helpRequested() + onReset: dialog.reset() + + delegate: PxButton { + variant: DialogButtonBox.buttonRole === DialogButtonBox.AcceptRole + || DialogButtonBox.buttonRole === DialogButtonBox.YesRole ? "primary" : "ghost" + } + } + } +} diff --git a/src/ui/PxDialogFooter.qml b/src/ui/PxDialogFooter.qml new file mode 100644 index 0000000..65c7e3c --- /dev/null +++ b/src/ui/PxDialogFooter.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Layouts + +// Dialog footer strip; children are laid out in a right-aligned row. +Rectangle { + id: root + + default property alias content: row.data + + implicitHeight: 56 + radius: Theme.radiusLg + color: Theme.elevate1 + border.width: 1 + border.color: Theme.border + + // Squares the top corners so only the bottom follows the dialog's radius. + Rectangle { + width: parent.width + height: Theme.radiusLg + color: Theme.elevate1 + + Rectangle { width: 1; height: parent.height; color: Theme.border } + Rectangle { x: parent.width - 1; width: 1; height: parent.height; color: Theme.border } + Rectangle { width: parent.width; height: 1; color: Theme.border } + } + + RowLayout { + id: row + + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 8 + + Item { Layout.fillWidth: true } + } +} diff --git a/src/ui/PxMenu.qml b/src/ui/PxMenu.qml new file mode 100644 index 0000000..9521513 --- /dev/null +++ b/src/ui/PxMenu.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Controls + +Menu { + id: control + popupType: Popup.Window + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + contentItem.implicitWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, + contentItem.implicitHeight + topPadding + bottomPadding) + padding: 4 + margins: 0 + overlap: 1 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + delegate: PxMenuItem {} + + contentItem: ListView { + implicitHeight: contentHeight + model: control.contentModel + interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false + clip: true + currentIndex: control.currentIndex + ScrollBar.vertical: PxScrollBar {} + } + + background: Rectangle { + implicitWidth: 160 + implicitHeight: 36 + radius: Theme.radiusMd + color: Theme.elevate2 + border.width: 1 + border.color: Theme.border + } +} diff --git a/src/ui/PxMenuItem.qml b/src/ui/PxMenuItem.qml new file mode 100644 index 0000000..974df03 --- /dev/null +++ b/src/ui/PxMenuItem.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls + +MenuItem { + id: control + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, + implicitContentWidth + leftPadding + rightPadding) + implicitHeight: 28 + leftPadding: 8 + rightPadding: 8 + topPadding: 0 + bottomPadding: 0 + spacing: 8 + hoverEnabled: true + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } + + indicator: PxCheckIndicator { + x: control.leftPadding + y: (control.height - height) / 2 + visible: control.checkable + checkState: control.checked ? Qt.Checked : Qt.Unchecked + hovered: control.highlighted + opacity: control.enabled ? 1 : Theme.disabledOpacity + } + + arrow: Image { + x: control.width - width - control.rightPadding + y: (control.height - height) / 2 + visible: control.subMenu !== null + width: 12 + height: 12 + sourceSize.width: 12 + sourceSize.height: 12 + rotation: -90 + opacity: control.enabled ? 1 : Theme.disabledOpacity + source: visible ? "image://icons/chevron-down?color=" + encodeURIComponent(Theme.textDim) : "" + } + + contentItem: Text { + leftPadding: control.checkable ? control.indicator.width + control.spacing : 0 + rightPadding: control.subMenu !== null ? control.arrow.width + control.spacing : 0 + text: control.text + font: control.font + color: Theme.textMain + opacity: control.enabled ? 1 : Theme.disabledOpacity + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + implicitWidth: 160 + implicitHeight: 28 + radius: Theme.radiusSm + color: control.highlighted && control.enabled + ? (control.down ? Theme.primaryBg : Theme.elevate3) + : "transparent" + } +} diff --git a/src/ui/PxProgressBar.qml b/src/ui/PxProgressBar.qml new file mode 100644 index 0000000..41c329a --- /dev/null +++ b/src/ui/PxProgressBar.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Controls + +ProgressBar { + id: control + + property int thickness: 4 + + implicitWidth: 200 + implicitHeight: thickness + + background: Rectangle { + implicitWidth: 200 + implicitHeight: control.thickness + radius: Theme.radiusXs + color: Theme.elevate3 + } + + contentItem: Item { + implicitWidth: 200 + implicitHeight: control.thickness + clip: true + + Rectangle { + visible: !control.indeterminate + width: control.visualPosition * parent.width + height: parent.height + radius: Theme.radiusXs + color: Theme.primary + } + + Rectangle { + id: sweep + visible: control.indeterminate + width: parent.width * 0.3 + height: parent.height + radius: Theme.radiusXs + color: Theme.primary + + XAnimator on x { + running: control.indeterminate && control.visible + loops: Animation.Infinite + from: -sweep.width + to: sweep.parent.width + duration: 1200 + } + } + } +} diff --git a/src/ui/PxScrollBar.qml b/src/ui/PxScrollBar.qml new file mode 100644 index 0000000..ca6c1c2 --- /dev/null +++ b/src/ui/PxScrollBar.qml @@ -0,0 +1,21 @@ +import QtQuick +import QtQuick.Controls + +ScrollBar { + id: control + + implicitWidth: Theme.scrollbarWidth + implicitHeight: Theme.scrollbarWidth + padding: 0 + hoverEnabled: true + visible: policy !== ScrollBar.AlwaysOff && size < 1.0 + + contentItem: Rectangle { + implicitWidth: Theme.scrollbarWidth + implicitHeight: Theme.scrollbarWidth + radius: Theme.radiusPill + color: control.pressed || control.hovered ? Theme.textMuted : Theme.textDim + } + + background: Item {} +} diff --git a/src/ui/PxSpinBox.qml b/src/ui/PxSpinBox.qml new file mode 100644 index 0000000..1713bb6 --- /dev/null +++ b/src/ui/PxSpinBox.qml @@ -0,0 +1,71 @@ +import QtQuick +import QtQuick.Controls + +SpinBox { + id: control + + property string toolTipText: "" + + implicitHeight: 32 + implicitWidth: 110 + editable: true + leftPadding: 28 + rightPadding: 28 + hoverEnabled: true + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + contentItem: TextInput { + text: control.textFromValue(control.value, control.locale) + font: control.font + color: control.enabled ? Theme.textMain : Theme.textDim + selectionColor: Theme.primary + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !control.editable + validator: control.validator + inputMethodHints: Qt.ImhFormattedNumbersOnly + } + + up.indicator: Item { + x: control.width - width + width: 28 + height: control.height + opacity: control.value < control.to ? 1 : Theme.disabledOpacity + + Text { + anchors.centerIn: parent + text: "+" + font: control.font + color: control.up.pressed || control.up.hovered ? Theme.textMain : Theme.textDim + } + } + + down.indicator: Item { + width: 28 + height: control.height + opacity: control.value > control.from ? 1 : Theme.disabledOpacity + + Text { + anchors.centerIn: parent + text: "\u2212" + font: control.font + color: control.down.pressed || control.down.hovered ? Theme.textMain : Theme.textDim + } + } + + background: Rectangle { + radius: Theme.radiusMd + opacity: control.enabled ? 1 : Theme.disabledOpacity + color: Theme.elevate0 + border.width: 1 + border.color: control.activeFocus ? Theme.primary + : control.hovered ? Theme.elevate4 + : Theme.border + } + + PxToolTip { + visible: control.toolTipText !== "" && control.hovered + text: control.toolTipText + } +} diff --git a/src/ui/PxTextField.qml b/src/ui/PxTextField.qml new file mode 100644 index 0000000..7a46add --- /dev/null +++ b/src/ui/PxTextField.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Controls + +TextField { + id: control + + property bool invalid: false + property bool monospace: false + + implicitHeight: 32 + leftPadding: 10 + rightPadding: 10 + topPadding: 0 + bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + hoverEnabled: true + selectByMouse: true + color: readOnly ? Theme.textMuted : Theme.textMain + placeholderTextColor: Theme.textDim + selectionColor: Theme.primary + selectedTextColor: Theme.elevate0 + font.family: monospace ? Theme.fontMono : Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + + background: Rectangle { + implicitWidth: 120 + implicitHeight: 32 + radius: Theme.radiusMd + opacity: control.enabled ? 1 : Theme.disabledOpacity + color: control.readOnly ? "transparent" : Theme.elevate0 + border.width: 1 + border.color: control.invalid ? Theme.danger + : control.activeFocus ? Theme.primary + : control.readOnly ? "transparent" + : control.hovered ? Theme.elevate4 + : Theme.border + + Behavior on border.color { ColorAnimation { duration: 100 } } + } +} diff --git a/src/ui/PxToolTip.qml b/src/ui/PxToolTip.qml new file mode 100644 index 0000000..77177e9 --- /dev/null +++ b/src/ui/PxToolTip.qml @@ -0,0 +1,28 @@ +import QtQuick +import QtQuick.Controls + +ToolTip { + id: control + popupType: Popup.Window + + delay: Theme.tooltipDelay + padding: 6 + leftPadding: 8 + rightPadding: 8 + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + + contentItem: Text { + text: control.text + font: control.font + color: Theme.textMain + wrapMode: Text.Wrap + } + + background: Rectangle { + radius: Theme.radiusSm + color: Theme.elevate3 + border.width: 1 + border.color: Theme.border + } +} diff --git a/src/ui/RuleEditor.qml b/src/ui/RuleEditor.qml new file mode 100644 index 0000000..c9ce362 --- /dev/null +++ b/src/ui/RuleEditor.qml @@ -0,0 +1,481 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +FocusScope { + id: root + objectName: "organizerRuleEditor" + implicitWidth: 900 + implicitHeight: 640 + property bool opened: false + visible: opened + focus: opened + + property var ruleRows: organizerPanel ? organizerPanel.ruleRows : [] + property var ruleFolders: organizerPanel ? organizerPanel.ruleFolders : [] + property string errorMessage: organizerPanel ? organizerPanel.editorError : "" + + + readonly property var fieldOptions: [ + { text: "Package ID", value: "package_id" }, + { text: "Name", value: "name" }, + { text: "Author", value: "author" } + ] + + readonly property var opOptions: [ + { text: "Prefix", value: "prefix" }, + { text: "Contains", value: "contains" }, + { text: "Equals", value: "equals" } + ] + + readonly property bool hasFolders: ruleFolders.length > 0 + property var preview: ({ counts: [], assignable: 0 }) + property int previewRevision: 0 + property bool previewPending: false + + function schedulePreview() { + if (!opened || loadingDraft || !organizerPanel || !organizerPanel.visible || !organizerPanel.ready) + return + previewRevision++ + previewPending = true + previewTimer.restart() + } + + Timer { + id: previewTimer + interval: 100 + onTriggered: { + if (root.opened && organizerPanel && organizerPanel.visible && organizerPanel.ready) + organizerPanel.requestRulePreview(root.draftRules(), root.previewRevision) + } + } + + property bool loadingDraft: false + property int draftRevision: 0 + property var loadedRows: [] + property bool draftDirty: false + + function refreshDirty() { + draftDirty = !draftMatches(ruleRows) + } + + + function draftMatches(rows) { + if (draftModel.count !== rows.length) + return false + for (var i = 0; i < rows.length; ++i) { + var d = draftModel.get(i) + var r = rows[i] + if (d.field !== r.field || d.op !== r.op || d.pattern !== r.pattern + || d.folder_id !== r.folder_id) + return false + } + return true + } + + function findFieldIndex(val) { + for (var i = 0; i < fieldOptions.length; ++i) { + if (fieldOptions[i].value === val) + return i + } + return 0 + } + + function findOpIndex(val) { + for (var i = 0; i < opOptions.length; ++i) { + if (opOptions[i].value === val) + return i + } + return 0 + } + + function findFolderIndex(fid) { + for (var i = 0; i < ruleFolders.length; ++i) { + if (ruleFolders[i].id === fid) + return i + } + return 0 + } + + ListModel { + id: draftModel + onDataChanged: { if (!root.loadingDraft) root.draftRevision++ } + onRowsInserted: { if (!root.loadingDraft) root.draftRevision++ } + onRowsRemoved: { if (!root.loadingDraft) root.draftRevision++ } + onRowsMoved: { if (!root.loadingDraft) root.draftRevision++ } + onModelReset: { if (!root.loadingDraft) root.draftRevision++ } + } + + function loadDraft() { + loadingDraft = true + draftModel.clear() + for (var i = 0; i < ruleRows.length; ++i) { + var r = ruleRows[i] + draftModel.append({ + field: r.field, + op: r.op, + pattern: r.pattern, + folder_id: r.folder_id + }) + } + loadedRows = ruleRows + loadingDraft = false + draftRevision++ + refreshDirty() + } + + onRuleRowsChanged: { + if (!opened && !draftMatches(ruleRows)) + loadDraft() + else if (opened && draftMatches(loadedRows) && !draftMatches(ruleRows)) + loadDraft() + refreshDirty() + schedulePreview() + } + onDraftRevisionChanged: { + refreshDirty() + schedulePreview() + } + + function addRule() { + if (!hasFolders) + return + draftModel.append({ + field: "package_id", + op: "prefix", + pattern: "", + folder_id: ruleFolders[0].id + }) + } + + function removeRule(index) { + if (index >= 0 && index < draftModel.count) + draftModel.remove(index) + } + function setRulePattern(index, pattern) { + draftModel.setProperty(index, "pattern", pattern) + } + + function moveUp(index) { + if (index > 0 && index < draftModel.count) + draftModel.move(index, index - 1, 1) + } + + function moveDown(index) { + if (index >= 0 && index < draftModel.count - 1) + draftModel.move(index, index + 1, 1) + } + + function draftRules() { + var rules = [] + for (var i = 0; i < draftModel.count; ++i) { + var row = draftModel.get(i) + rules.push({ + field: String(row.field), + op: String(row.op), + pattern: String(row.pattern), + folder_id: parseInt(row.folder_id, 10) + }) + } + return rules + } + + function submitRules() { + organizerPanel.saveRules(draftRules()) + } + + + Connections { + target: organizerPanel + function onRulesSaved() { + organizerPanel.closeRules() + } + function onRulePreviewReady(revision, result) { + if (root.opened && revision === root.previewRevision) { + root.preview = result + root.previewPending = false + } + } + } + + function prepareForOpen() { + if (!draftMatches(ruleRows)) + loadDraft() + opened = true + schedulePreview() + refreshDirty() + } + + function closeEditor() { + opened = false + previewTimer.stop() + previewRevision++ + previewPending = false + } + + Rectangle { + anchors.fill: parent + color: Theme.elevate2 + } + + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: footer.top + anchors.margins: 16 + spacing: 10 + + Text { + Layout.fillWidth: true + text: "Evaluated top to bottom: first match wins. Rules choose folders only for mods without a manual placement. Moving a mod manually always takes priority; saving updates rule-based folders immediately." + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + PxButton { + objectName: "organizerAddRule" + text: "Add rule" + variant: "secondary" + enabled: root.hasFolders + ToolTip.text: root.hasFolders ? "" : "Create a folder first" + onClicked: root.addRule() + } + + PxButton { + objectName: "organizerAddStandardRules" + text: "Add standard rules" + variant: "secondary" + enabled: !root.draftDirty + ToolTip.text: "Add the built-in set of common folders and rules" + onClicked: organizerPanel.addStandardRules() + } + + Item { Layout.fillWidth: true } + + Text { + text: draftModel.count === 1 ? "1 rule" : draftModel.count + " rules" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + ErrorBanner { + objectName: "organizerRuleError" + Layout.fillWidth: true + text: root.errorMessage + } + + ScrollView { + id: scrollArea + ScrollBar.vertical: PxScrollBar {} + objectName: "organizerRuleScrollArea" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + contentHeight: rulesColumn.height + + Column { + id: rulesColumn + width: scrollArea.availableWidth + height: childrenRect.height + spacing: 6 + + Repeater { + objectName: "organizerRuleRepeater" + model: draftModel + + delegate: Rectangle { + id: rowItem + objectName: "organizerRuleRow" + required property int index + required property string field + required property string op + required property string pattern + required property int folder_id + + width: rulesColumn.width + height: implicitHeight + implicitHeight: rowCol.implicitHeight + 12 + color: Theme.elevate1 + border.color: Theme.border + border.width: 1 + radius: Theme.radiusSm + + ColumnLayout { + id: rowCol + anchors.fill: parent + anchors.margins: 6 + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Text { + text: String(rowItem.index + 1) + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + Layout.preferredWidth: 16 + horizontalAlignment: Text.AlignHCenter + } + + PxComboBox { + objectName: "organizerRuleField" + Layout.preferredWidth: 76 + model: root.fieldOptions + textRole: "text" + valueRole: "value" + currentIndex: root.findFieldIndex(rowItem.field) + onActivated: draftModel.setProperty(rowItem.index, "field", currentValue) + } + + PxComboBox { + objectName: "organizerRuleOperator" + Layout.preferredWidth: 72 + model: root.opOptions + textRole: "text" + valueRole: "value" + currentIndex: root.findOpIndex(rowItem.op) + onActivated: draftModel.setProperty(rowItem.index, "op", currentValue) + } + + PxTextField { + objectName: "organizerRulePattern" + Layout.fillWidth: true + Layout.minimumWidth: 60 + text: rowItem.pattern + placeholderText: "Pattern…" + onTextEdited: root.setRulePattern(rowItem.index, text) + } + + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 24 + spacing: 5 + Text { + text: "→" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + + PxComboBox { + objectName: "organizerRuleFolder" + Layout.preferredWidth: 120 + Layout.minimumWidth: 90 + model: root.ruleFolders + textRole: "name" + valueRole: "id" + currentIndex: root.findFolderIndex(rowItem.folder_id) + onActivated: draftModel.setProperty(rowItem.index, "folder_id", currentValue) + } + + PxButton { + objectName: "organizerRuleUp" + variant: "ghost" + iconName: "chevron-down" + iconRotation: 180 + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + ToolTip.text: "Move rule up" + enabled: rowItem.index > 0 + onClicked: root.moveUp(rowItem.index) + } + + PxButton { + objectName: "organizerRuleDown" + variant: "ghost" + iconName: "chevron-down" + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + ToolTip.text: "Move rule down" + enabled: rowItem.index < draftModel.count - 1 + onClicked: root.moveDown(rowItem.index) + } + + PxButton { + objectName: "organizerRuleRemove" + variant: "danger" + iconName: "trash" + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + ToolTip.text: "Remove rule" + onClicked: root.removeRule(rowItem.index) + } + } + + Text { + objectName: "organizerRuleHits" + Layout.leftMargin: 24 + text: { + if (root.previewPending) + return "Updating matches…" + var hits = root.preview.counts[rowItem.index] || 0 + return hits + (hits === 1 ? " mod matches first here" : " mods match first here") + } + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + } + } + } + } + + Text { + objectName: "organizerRuleAssignable" + Layout.fillWidth: true + text: root.previewPending ? "Updating matches…" : + root.preview.assignable + " currently ungrouped " + + (root.preview.assignable === 1 ? "mod would" : "mods would") + + " be assigned by these rules" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + EmptyState { + anchors.centerIn: parent + width: parent.width - 64 + visible: draftModel.count === 0 + iconName: "folder" + title: root.hasFolders ? "No rules configured" : "No folders available" + detail: root.hasFolders ? "Click “Add rule” to create one." : "Create a folder first." + } + + PxDialogFooter { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + PxButton { + objectName: "organizerCancelRules" + text: "Cancel" + variant: "ghost" + onClicked: organizerPanel.closeRules() + } + + PxButton { + objectName: "organizerSaveRules" + text: "Save" + variant: "primary" + onClicked: root.submitRules() + } + } +} diff --git a/src/ui/Settings.qml b/src/ui/Settings.qml new file mode 100644 index 0000000..9edcbc4 --- /dev/null +++ b/src/ui/Settings.qml @@ -0,0 +1,419 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + objectName: "settingsRoot" + + color: Theme.elevate2 + + property int tab: 0 + readonly property var initial: settings.initial + + function values() { + return { + game: game.text.trim(), + local: local.text.trim(), + workshop: workshop.text.trim(), + config: configFolder.text.trim(), + compact: compact.checked, + launchArgs: launchArgs.text.trim(), + launchWrapper: launchWrapper.text.trim(), + afterLaunch: afterLaunch.currentValue, + confirmErrors: confirmErrors.checked, + confirmUnsaved: confirmUnsaved.checked, + confirmRunning: confirmRunning.checked, + useAltIds: useAltIds.checked, + checkMissing: checkMissing.checked, + useCommunity: useCommunity.checked + } + } + + Connections { + target: settings + function onPathPicked(key, path) { + const fields = { game: game, local: local, workshop: workshop, config: configFolder } + if (fields[key]) + fields[key].text = path + if (key === "game" && local.text === "") { + const candidate = settings.localCandidate(path) + if (candidate) + local.text = candidate + } + } + } + + component Group: Rectangle { + default property alias content: body.data + property string title: "" + + Layout.fillWidth: true + implicitHeight: body.implicitHeight + 52 + color: "transparent" + + Text { + x: 16 + y: 12 + text: parent.title + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.Bold + font.capitalization: Font.AllUppercase + font.letterSpacing: 0.5 + } + + ColumnLayout { + id: body + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 + anchors.topMargin: 36 + spacing: 10 + } + } + + component PathRow: RowLayout { + property string label: "" + property string key: "" + property alias text: field.text + + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.preferredWidth: 150 + text: parent.label + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + id: field + Layout.fillWidth: true + monospace: true + Accessible.name: parent.label + } + PxButton { + text: "Browse…" + onClicked: settings.browse(parent.key) + } + } + + component Tab: Item { + id: tabItem + property string label: "" + property int page: 0 + readonly property bool current: root.tab === page + + implicitWidth: tabText.implicitWidth + 32 + implicitHeight: 40 + + Text { + id: tabText + anchors.centerIn: parent + text: tabItem.label + color: tabItem.current ? Theme.primary : tabMouse.containsMouse ? Theme.textMain : Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: tabItem.current ? Font.Medium : Font.Normal + } + Rectangle { + visible: tabItem.current + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 2 + color: Theme.primary + } + MouseArea { + id: tabMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + Accessible.role: Accessible.PageTab + Accessible.name: tabItem.label + onClicked: root.tab = tabItem.page + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 41 + color: "transparent" + + Row { + x: 8 + Tab { label: "General"; page: 0 } + Tab { label: "Sorting"; page: 1 } + Repeater { + model: settings.sections + delegate: Tab { + required property var modelData + required property int index + label: modelData.title + page: index + 2 + } + } + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Theme.border + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: root.tab + + ScrollView { + id: generalScroll + clip: true + contentWidth: availableWidth + ScrollBar.vertical: PxScrollBar {} + + ColumnLayout { + width: generalScroll.availableWidth - 32 + x: 16 + y: 16 + spacing: 12 + + Group { + title: "Folders" + PathRow { id: game; label: "Game folder"; key: "game"; text: root.initial.game } + PathRow { id: local; label: "Local mods folder"; key: "local"; text: root.initial.local } + PathRow { id: workshop; label: "Workshop mods folder"; key: "workshop"; text: root.initial.workshop } + PathRow { id: configFolder; label: "Config folder"; key: "config"; text: root.initial.config } + PxButton { + text: "Auto-detect" + onClicked: settings.autoDetect() + } + } + + Group { + title: "Launch" + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + Layout.preferredWidth: 150 + text: "Arguments" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + id: launchArgs + Layout.fillWidth: true + monospace: true + text: root.initial.launchArgs + placeholderText: "e.g. -popupwindow -logfile" + Accessible.name: "Game launch arguments" + } + } + Text { + Layout.fillWidth: true + text: "Passed to the game. With the Steam strategy they go through steam://run." + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + Layout.preferredWidth: 150 + text: "Wrapper" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + id: launchWrapper + Layout.fillWidth: true + monospace: true + text: root.initial.launchWrapper + placeholderText: "e.g. gamemoderun %command%" + Accessible.name: "Launch wrapper command" + } + } + Text { + Layout.fillWidth: true + text: "Direct launch only. Leading KEY=VALUE tokens set environment variables; %command% marks where the game goes, otherwise the wrapper is a prefix." + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + Layout.preferredWidth: 150 + text: "After launch" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxComboBox { + id: afterLaunch + Layout.preferredWidth: 220 + Accessible.name: "Window behaviour after launch" + textRole: "text" + valueRole: "value" + model: [ + { text: "Keep PxModRim open", value: 0 }, + { text: "Minimize while the game runs", value: 1 }, + { text: "Close PxModRim", value: 2 } + ] + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.afterLaunch)) + } + } + PxCheckBox { + id: confirmErrors + text: "Confirm launch when the mod list has errors" + checked: root.initial.confirmErrors + } + PxCheckBox { + id: confirmUnsaved + text: "Confirm launch when the mod list is unsaved" + checked: root.initial.confirmUnsaved + } + PxCheckBox { + id: confirmRunning + text: "Confirm launch when the game is already running" + checked: root.initial.confirmRunning + } + } + + Group { + title: "Appearance" + PxCheckBox { + id: compact + text: "Compact mod list" + checked: root.initial.compact + } + } + } + } + + ScrollView { + id: sortingScroll + clip: true + contentWidth: availableWidth + ScrollBar.vertical: PxScrollBar {} + + ColumnLayout { + width: sortingScroll.availableWidth - 32 + x: 16 + y: 16 + spacing: 12 + + Group { + title: "Sorting options" + PxCheckBox { + id: useAltIds + text: "Use alternative package IDs" + checked: root.initial.useAltIds + } + PxCheckBox { + id: checkMissing + text: "Check missing dependencies" + checked: root.initial.checkMissing + } + PxCheckBox { + id: useCommunity + text: "Use community rules database" + checked: root.initial.useCommunity + } + } + + Group { + title: "Community rules database" + Text { + Layout.fillWidth: true + text: settings.communityStatus + wrapMode: Text.Wrap + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxButton { + text: "Download or update" + enabled: !settings.communityBusy + onClicked: settings.downloadCommunityRules() + } + } + + Group { + title: "Startup impact cache" + Text { + Layout.fillWidth: true + text: settings.cacheStatus + wrapMode: Text.Wrap + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxButton { + text: "Clear cache" + enabled: settings.cacheAvailable && !settings.cacheBusy + onClicked: settings.clearCache() + } + } + } + } + + Repeater { + model: settings.sections + delegate: ScrollView { + id: sectionScroll + required property var modelData + clip: true + contentWidth: availableWidth + ScrollBar.vertical: PxScrollBar {} + + ColumnLayout { + width: sectionScroll.availableWidth - 32 + x: 16 + y: 16 + spacing: 12 + + Group { + title: sectionScroll.modelData.title + Loader { + Layout.fillWidth: true + Component.onCompleted: setSource(sectionScroll.modelData.source, { section: sectionScroll.modelData.section }) + } + } + } + } + } + } + + PxDialogFooter { + Layout.fillWidth: true + PxButton { + text: "Cancel" + onClicked: settings.cancel() + } + PxButton { + objectName: "settingsSave" + text: "Save" + variant: "primary" + onClicked: settings.save(root.values()) + } + } + } +} diff --git a/src/ui/SteamSettings.qml b/src/ui/SteamSettings.qml new file mode 100644 index 0000000..42bacbd --- /dev/null +++ b/src/ui/SteamSettings.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Layouts +import PxModRim + +ColumnLayout { + id: root + required property QtObject section + readonly property var initial: section.initial + spacing: 10 + + component Label: Text { + Layout.preferredWidth: 150 + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + component Hint: Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Auto-update mods" } + PxComboBox { + Layout.preferredWidth: 180 + Accessible.name: "Auto-update Workshop mods" + textRole: "text" + valueRole: "value" + model: [ + { text: "Off", value: 0 }, + { text: "Every 6 hours", value: 6 }, + { text: "Every 12 hours", value: 12 }, + { text: "Daily", value: 24 }, + { text: "Every 3 days", value: 72 }, + { text: "Weekly", value: 168 } + ] + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.auto_update_hours)) + onActivated: root.section.set("auto_update_hours", currentValue) + } + } + Hint { text: "Re-syncs mods downloaded by PxModRim while the app is running." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Parallel mods" } + PxSpinBox { + from: 1 + to: 8 + value: root.initial.parallel_items + Accessible.name: "Parallel Workshop mod downloads" + toolTipText: "How many mods are downloaded at the same time. Raise it for many small mods; lower it if Steam throttles or drops the connection." + onValueModified: root.section.set("parallel_items", value) + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Threads per mod" } + PxSpinBox { + from: 1 + to: 16 + value: root.initial.threads_per_item + Accessible.name: "Download threads per Workshop mod" + toolTipText: "Connections used to fetch chunks of a single mod. Raise it for large mods on a fast link; total load is parallel mods \u00d7 threads." + onValueModified: root.section.set("threads_per_item", value) + } + } + Hint { text: "Total connections = parallel mods \u00d7 threads per mod. Applies to the next download." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Proxy" } + PxTextField { + Layout.fillWidth: true + monospace: true + text: root.initial.proxy + placeholderText: "Use system proxy" + Accessible.name: "Workshop download proxy" + onTextEdited: root.section.set("proxy", text.trim()) + } + } + Hint { text: "URL such as http://host:3128 or socks5h://host:1080. Empty uses https_proxy / all_proxy from the environment." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Connect timeout (s)" } + PxSpinBox { + from: 1 + to: 120 + value: root.initial.connect_timeout + Accessible.name: "Workshop connect timeout in seconds" + toolTipText: "How long to wait for a connection before trying another server." + onValueModified: root.section.set("connect_timeout", value) + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Stall timeout (s)" } + PxSpinBox { + from: 1 + to: 300 + value: root.initial.stall_timeout + Accessible.name: "Workshop stall timeout in seconds" + toolTipText: "How long a transfer may stay below 1 byte/s before it is abandoned and retried." + onValueModified: root.section.set("stall_timeout", value) + } + } +} diff --git a/src/ui/SteamWorkshop.qml b/src/ui/SteamWorkshop.qml new file mode 100644 index 0000000..f9e353b --- /dev/null +++ b/src/ui/SteamWorkshop.qml @@ -0,0 +1,220 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtWebEngine +import QtWebChannel +import PxModRim + +Rectangle { + id: root + color: Theme.elevate0 + + property bool _firstLoad: true + property string _placeholderText: "Initializing Steam Workshop browser\u2026" + property bool _showError: false + property bool _loadedOnce: false + property bool _pendingReload: false + + function runScript(code) { + webView.runJavaScript(code, WebEngineScript.MainWorld) + } + + function _navigate() { + if (!root._loadedOnce) { + root._loadedOnce = true + webView.url = "https://steamcommunity.com/workshop/browse/?appid=294100" + } else if (root._pendingReload) { + root._pendingReload = false + webView.reload() + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: Theme.elevate2 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + spacing: 4 + + PxButton { + id: homeBtn + variant: "ghost" + iconName: "home" + implicitWidth: 28 + implicitHeight: 28 + ToolTip.text: "Home" + onClicked: steamWorkshopPanel.navigateHome() + } + + PxButton { + id: backBtn + variant: "ghost" + iconName: "chevron-left" + implicitWidth: 28 + implicitHeight: 28 + ToolTip.text: "Back" + enabled: webView.canGoBack + onClicked: webView.goBack() + } + + PxButton { + id: fwdBtn + variant: "ghost" + iconName: "chevron" + implicitWidth: 28 + implicitHeight: 28 + ToolTip.text: "Forward" + enabled: webView.canGoForward + onClicked: webView.goForward() + } + + PxButton { + id: clearCacheBtn + variant: "ghost" + iconName: "trash" + implicitWidth: 28 + implicitHeight: 28 + ToolTip.text: "Clear cache" + onClicked: { + // Stale Steam SSR JS in the disk cache fails SRI and gets + // blocked; drop it and reload once the clear completes. + root._pendingReload = true + webView.profile.clearHttpCache() + } + } + + PxButton { + id: reloadBtn + variant: "ghost" + iconName: "refresh" + implicitWidth: 28 + implicitHeight: 28 + ToolTip.text: "Reload" + onClicked: webView.reload() + } + + PxTextField { + id: urlInput + Layout.fillWidth: true + implicitHeight: 24 + font.pixelSize: Theme.fontSizeSm + text: webView.url.toString() + onAccepted: steamWorkshopPanel.navigateToUrl(text) + } + + PxProgressBar { + Layout.preferredWidth: 48 + indeterminate: true + visible: webView.loading + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + WebChannel { + id: channel + + Component.onCompleted: { + if (_bridge) { + channel.registerObject("bridge", _bridge) + } + } + } + + WebEngineView { + id: webView + objectName: "workshopWeb" + webChannel: channel + anchors.fill: parent + + profile: WebEngineProfile { + id: steamProfile + storageName: "pxmodrim-steam" + httpCacheType: WebEngineProfile.DiskHttpCache + httpCacheMaximumSize: 52428800 + offTheRecord: false + } + + // Steam redeploys its SSR JS with new SRI hashes, so the persistent + // disk cache can hold stale bytes that fail the integrity check and + // get blocked. Clear it before the first navigation; url is set only + // after clearHttpCacheCompleted to avoid navigating mid-clear. + + settings { + pluginsEnabled: false + pdfViewerEnabled: false + fullScreenSupportEnabled: false + hyperlinkAuditingEnabled: false + errorPageEnabled: false + localStorageEnabled: true + } + + Component.onCompleted: { + if (_injectCode.length > 0) { + var inj = WebEngine.script() + inj.name = "inject" + inj.sourceCode = _injectCode + inj.injectionPoint = WebEngineScript.DocumentCreation + inj.worldId = WebEngineScript.MainWorld + inj.runsOnSubFrames = false + webView.userScripts.insert(inj) + } + + webView.profile.clearHttpCache() + } + + Connections { + target: steamProfile + + function onClearHttpCacheCompleted() { + root._navigate() + } + } + + onLoadingChanged: function(loadRequest) { + if (loadRequest.status === WebEngineView.LoadStartedStatus) { + root._placeholderText = "Initializing Steam Workshop browser\u2026" + root._showError = false + } else if (loadRequest.status === WebEngineView.LoadSucceededStatus) { + root._firstLoad = false + } else if (loadRequest.status === WebEngineView.LoadFailedStatus) { + console.warn("[steam] load failed:", loadRequest.url, loadRequest.errorString, + "domain:", loadRequest.errorDomain, "code:", loadRequest.errorCode) + root._placeholderText = "Failed to load Steam Workshop" + root._showError = true + } + } + + onLoadProgressChanged: { + if (root._firstLoad && webView.loadProgress < 100) { + root._placeholderText = "Initializing Steam Workshop browser\u2026 " + webView.loadProgress + "%" + } + } + } + + Rectangle { + anchors.fill: parent + color: Theme.elevate0 + visible: root._firstLoad || root._showError + + EmptyState { + anchors.fill: parent + iconName: root._showError ? "warning" : "steam" + title: root._placeholderText + detail: root._showError ? "Check your network connection and try again." : "" + } + } + } + } +} diff --git a/src/ui/TagEditor.qml b/src/ui/TagEditor.qml new file mode 100644 index 0000000..a6e149e --- /dev/null +++ b/src/ui/TagEditor.qml @@ -0,0 +1,208 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +PxDialog { + id: editor + objectName: "organizerTagEditor" + width: Math.min(520, parent ? parent.width - 32 : 520) + height: Math.min(570, parent ? parent.height - 32 : 570) + title: "Manage Tags" + standardButtons: Dialog.Close + + property var tagRows: [] + property int selectedCount: 0 + function clearInputs() { + nameInput.text = "" + colorInput.text = String(Theme.primary) + } + property string errorMessage: "" + signal createRequested(string name, string color) + signal updateRequested(int id, string name, string color) + signal deleteRequested(int id) + signal assignmentRequested(int id, bool add) + + onOpened: clearInputs() + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + Text { + Layout.fillWidth: true + text: editor.selectedCount + ? "Toggle tags for " + editor.selectedCount + " selected " + + (editor.selectedCount === 1 ? "mod" : "mods") + ". Changes apply immediately." + : "Create and edit tags. Select mods to assign tags." + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + wrapMode: Text.WordWrap + } + + ScrollView { + ScrollBar.vertical: PxScrollBar {} + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ColumnLayout { + width: parent.width + spacing: 6 + Repeater { + objectName: "organizerTagRepeater" + model: editor.tagRows.length + delegate: RowLayout { + id: tagRow + required property int index + readonly property var row: editor.tagRows[index] + Layout.fillWidth: true + property bool editing: false + property string editName: tagRow.row ? tagRow.row.name : "" + property string editColor: tagRow.row ? tagRow.row.color : "" + + PxCheckBox { + objectName: "organizerTagAssignment" + visible: editor.selectedCount > 0 + tristate: true + checkState: tagRow.row.checkState === 2 ? Qt.PartiallyChecked : tagRow.row.checkState === 1 ? Qt.Checked : Qt.Unchecked + nextCheckState: function() { return checkState === Qt.Checked ? Qt.Unchecked : Qt.Checked } + onClicked: editor.assignmentRequested(tagRow.row.id, checkState === Qt.Checked) + PxToolTip { + visible: parent.hovered + text: parent.checkState === Qt.PartiallyChecked ? "Assigned to some selected mods" : "Assign or remove for selected mods" + } + } + Rectangle { + Layout.preferredWidth: 14 + Layout.preferredHeight: 14 + radius: 7 + color: tagRow.row.color + } + PxTextField { + id: tagName + objectName: "organizerTagName" + Layout.fillWidth: true + text: tagRow.editing ? tagRow.editName : (tagRow.row ? tagRow.row.name : "") + onTextEdited: tagRow.editName = text + readOnly: !tagRow.editing + } + PxTextField { + id: tagColor + objectName: "organizerTagColor" + visible: tagRow.editing + Layout.preferredWidth: 90 + monospace: true + text: tagRow.editing ? tagRow.editColor : (tagRow.row ? tagRow.row.color : "") + onTextEdited: tagRow.editColor = text + placeholderText: "#RRGGBB" + } + PxButton { + objectName: "organizerEditTag" + text: tagRow.editing ? "Save" : "Edit" + variant: tagRow.editing ? "primary" : "secondary" + onClicked: { + if (tagRow.editing) { + editor.updateRequested(tagRow.row.id, tagName.text, tagColor.text) + } else { + tagRow.editName = tagRow.row.name + tagRow.editColor = tagRow.row.color + tagRow.editing = true + } + } + } + PxButton { + objectName: "organizerDeleteTag" + text: "Delete" + variant: "danger" + onClicked: { + deleteConfirm.tagId = tagRow.row.id + deleteConfirm.tagName = tagRow.row.name + deleteConfirm.open() + } + } + Connections { + target: organizerPanel + function onTagUpdated(id) { + if (id === tagRow.row.id) + tagRow.editing = false + } + } + } + } + } + } + + Text { + text: "New tag" + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: Font.Medium + } + RowLayout { + Layout.fillWidth: true + PxTextField { + id: nameInput + objectName: "organizerNewTagName" + Layout.fillWidth: true + placeholderText: "Tag name" + onAccepted: addTag.clicked() + } + PxTextField { + id: colorInput + objectName: "organizerNewTagColor" + Layout.preferredWidth: 100 + monospace: true + placeholderText: "#RRGGBB" + } + PxButton { + id: addTag + objectName: "organizerAddTag" + text: "Add" + variant: "secondary" + enabled: nameInput.text.trim().length > 0 + onClicked: editor.createRequested(nameInput.text, colorInput.text) + } + } + ErrorBanner { + objectName: "organizerTagError" + Layout.fillWidth: true + text: editor.errorMessage + } + } + + PxDialog { + id: deleteConfirm + objectName: "organizerDeleteTagConfirm" + width: Math.min(360, parent ? parent.width - 32 : 360) + title: "Delete Tag?" + property int tagId: -1 + property string tagName: "" + + Text { + width: parent.width + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + text: "Delete ‘" + deleteConfirm.tagName + "’ from all mods?" + color: Theme.textMain + wrapMode: Text.WordWrap + } + + footer: PxDialogFooter { + PxButton { + text: "Cancel" + variant: "ghost" + onClicked: deleteConfirm.reject() + } + PxButton { + objectName: "organizerDeleteTagConfirmButton" + text: "Delete" + variant: "dangerSolid" + onClicked: deleteConfirm.accept() + } + } + + onAccepted: editor.deleteRequested(tagId) + } +} diff --git a/src/ui/TagPicker.qml b/src/ui/TagPicker.qml new file mode 100644 index 0000000..89aa3b8 --- /dev/null +++ b/src/ui/TagPicker.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +PxComboBox { + id: control + property var options: [] + property string selectedTag: "" + displayText: selectedTag || "All tags" + model: options.filter(function (tag) { + return tag === "All tags" || tag.toLowerCase().indexOf(tagSearch.text.toLowerCase()) >= 0 + }) + Accessible.name: "Tag filter" + onActivated: selectedTag = currentText === "All tags" ? "" : currentText + delegate: ItemDelegate { + id: option + required property int index + required property var model + width: ListView.view ? ListView.view.width : control.width + height: 32 + font: control.font + text: model.modelData + highlighted: optionsList.currentIndex === index + onClicked: control.choose(index) + contentItem: Text { + text: option.text + font: option.font + color: option.text === control.displayText ? Theme.primary : Theme.textMain + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: Theme.radiusSm + color: option.highlighted ? Theme.elevate3 : "transparent" + } + } + + function choose(index) { + if (index < 0 || index >= count) + return + currentIndex = index + activated(index) + popup.close() + } + + popup: Popup { + popupType: Popup.Window + y: control.height + 4 + width: control.width + padding: 8 + implicitHeight: contents.implicitHeight + 16 + onOpened: { + tagSearch.text = "" + tagSearch.forceActiveFocus() + } + onClosed: control.forceActiveFocus() + contentItem: ColumnLayout { + id: contents + spacing: 8 + PxTextField { + id: tagSearch + Layout.fillWidth: true + placeholderText: "Find a tag…" + Accessible.name: "Search known tags" + onAccepted: control.choose(control.count > 1 && text.length > 0 ? 1 : 0) + Keys.onDownPressed: { + optionsList.currentIndex = 0 + optionsList.forceActiveFocus() + } + Keys.onEscapePressed: control.popup.close() + } + ListView { + id: optionsList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, 240) + clip: true + model: control.popup.visible ? control.delegateModel : null + currentIndex: 0 + keyNavigationEnabled: true + Keys.onReturnPressed: control.choose(currentIndex) + Keys.onSpacePressed: control.choose(currentIndex) + Keys.onEscapePressed: control.popup.close() + ScrollBar.vertical: PxScrollBar {} + } + } + background: Rectangle { + radius: Theme.radiusMd + color: Theme.elevate2 + border.width: 1 + border.color: Theme.border + } + } +} diff --git a/src/ui/TimeAnalytics.qml b/src/ui/TimeAnalytics.qml new file mode 100644 index 0000000..7856041 --- /dev/null +++ b/src/ui/TimeAnalytics.qml @@ -0,0 +1,832 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate1 + clip: true + + property var sourceData: null + + readonly property bool hasData: sourceData !== null && sourceData !== undefined + + property string view: "mods" + property string filterText: "" + property bool activeOnly: false + property bool slowOnly: false + property string sortBy: "time" + property bool includeBase: true + + // JS objects are not observable; bump the revision to re-evaluate bindings. + property var expandedMods: ({}) + property var expandedPhases: ({}) + property var showAllPhases: ({}) + property int expandRev: 0 + + readonly property real slowThreshold: 3.0 + readonly property int phasePreview: 6 + + readonly property var visibleMods: { + if (!hasData) + return [] + const q = filterText.trim().toLowerCase() + const rows = sourceData.mods.filter(m => + (!activeOnly || m.active) + && (!slowOnly || m.seconds >= slowThreshold) + && (q.length === 0 || m.name.toLowerCase().indexOf(q) !== -1)) + if (sortBy === "name") + rows.sort((a, b) => a.name.localeCompare(b.name)) + return rows + } + + readonly property var phases: hasData + ? (includeBase ? sourceData.phases_with_base : sourceData.phases_mods_only) + : [] + + onSourceDataChanged: { + if (!sourceData) + return + const key = sourceData.selected_key + const selectedRow = sourceData.mods.find(m => m.key === key) + expandedMods = key ? { [key]: true } : {} + expandedPhases = {} + showAllPhases = {} + expandRev++ + filterText = "" + activeOnly = sourceData.active_count > 0 && (!selectedRow || selectedRow.active) + Qt.callLater(scrollToMod, key) + } + + function toggle(map, key) { + map[key] = !map[key] + expandRev++ + } + + function scrollToMod(key) { + if (!key) + return + const idx = visibleMods.findIndex(m => m.key === key) + if (idx >= 0) + modsList.positionViewAtIndex(idx, ListView.Center) + } + + function openMod(key) { + const row = sourceData.mods.find(m => m.key === key) + if (!row) + return + filterText = "" + slowOnly = false + if (!row.active) + activeOnly = false + expandedMods[key] = true + expandRev++ + view = "mods" + Qt.callLater(scrollToMod, key) + } + + component Chip: Rectangle { + id: chip + property string text + property bool checked: false + signal clicked() + + implicitHeight: 28 + implicitWidth: chipLabel.implicitWidth + 24 + radius: Theme.radiusPill + color: checked ? Qt.alpha(Theme.primary, 0.14) + : chipMouse.containsMouse ? Theme.elevate4 : Theme.elevate3 + + Text { + id: chipLabel + anchors.centerIn: parent + text: chip.text + color: chip.checked ? Theme.primary : Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + font.weight: Font.Medium + } + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: chip.clicked() + } + } + + component Caption: Text { + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + font.weight: Font.DemiBold + font.letterSpacing: 0.6 + font.capitalization: Font.AllUppercase + color: Theme.textDim + } + + component Bar: Rectangle { + property real fraction: 0 + property color fill: Theme.primary + implicitHeight: 8 + radius: height / 2 + color: Theme.elevate4 + Rectangle { + width: Math.max(parent.fraction > 0 ? parent.height : 0, parent.width * Math.min(1, parent.fraction)) + height: parent.height + radius: parent.radius + color: parent.fill + } + } + + component Divider: Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Theme.border + } + + component Card: Rectangle { + id: card + property string title + property string value + property string detail + Layout.fillWidth: true + implicitHeight: cardCol.implicitHeight + 24 + radius: Theme.radiusLg + color: Theme.elevate2 + border.width: 1 + border.color: Theme.border + ColumnLayout { + id: cardCol + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + Caption { text: card.title } + Text { + Layout.fillWidth: true + text: card.value + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXl + font.weight: Font.DemiBold + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: card.detail + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + wrapMode: Text.Wrap + } + } + } + + EmptyState { + anchors.fill: parent + visible: !hasData + iconName: "clock" + title: "No startup impact data" + detail: "Launch the game with the loading progress mod to record it." + } + + ColumnLayout { + anchors.fill: parent + visible: hasData + spacing: 0 + + // -- Summary -- + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.bottomMargin: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + Text { + text: hasData ? sourceData.last_launch : "" + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: 28 + font.weight: Font.Bold + } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 0 + Text { + text: "last launch" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + Text { + visible: text.length > 0 + text: hasData && sourceData.estimated + ? "≈ " + sourceData.estimated + " estimated for your current mod list" : "" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + Item { Layout.fillWidth: true } + Text { + visible: text.length > 0 + text: hasData && sourceData.timestamp ? "report from " + sourceData.timestamp : "" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + + Item { + id: summaryBar + Layout.fillWidth: true + implicitHeight: 10 + readonly property var parts: hasData ? sourceData.summary : [] + Row { + anchors.fill: parent + spacing: 2 + Repeater { + model: summaryBar.parts + delegate: Rectangle { + width: Math.max(2, modelData.fraction * (summaryBar.width - 2 * (summaryBar.parts.length - 1))) + height: summaryBar.height + radius: 3 + color: modelData.color + opacity: modelData.parallel ? 0.45 : 1 + } + } + } + } + + Flow { + Layout.fillWidth: true + spacing: 18 + Repeater { + model: summaryBar.parts + delegate: Row { + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 10; height: 10; radius: 2 + color: modelData.color + opacity: modelData.parallel ? 0.45 : 1 + } + Text { + text: modelData.label + " " + modelData.value + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + } + } + } + + Divider {} + + // -- Toolbar -- + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.topMargin: 10 + Layout.bottomMargin: 10 + spacing: 8 + + Chip { text: "Mods"; checked: view === "mods"; onClicked: view = "mods" } + Chip { text: "Phases"; checked: view === "phases"; onClicked: view = "phases" } + Item { Layout.fillWidth: true } + + PxTextField { + visible: view === "mods" + Layout.preferredWidth: 220 + implicitHeight: 28 + placeholderText: "Filter mods…" + text: root.filterText + onTextEdited: root.filterText = text + } + Chip { + visible: view === "mods" && hasData && sourceData.active_count > 0 + text: "Active only" + checked: activeOnly + onClicked: activeOnly = !activeOnly + } + Chip { + visible: view === "mods" + text: "Slow > 3 s" + checked: slowOnly + onClicked: slowOnly = !slowOnly + } + Chip { + visible: view === "mods" + text: sortBy === "time" ? "Sort: Time" : "Sort: Name" + onClicked: sortBy = sortBy === "time" ? "name" : "time" + } + Chip { + visible: view === "phases" + text: "Include base game" + checked: includeBase + onClicked: includeBase = !includeBase + } + } + + Divider {} + + // -- Mods view -- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: view === "mods" + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Theme.scrollbarWidth + Layout.topMargin: 10 + Layout.bottomMargin: 4 + spacing: 12 + Item { Layout.preferredWidth: 28 } + Caption { Layout.fillWidth: true; text: "Mod" } + Caption { Layout.preferredWidth: modsList.width * 0.36; text: "Phase breakdown" } + Caption { Layout.preferredWidth: 72; horizontalAlignment: Text.AlignRight; text: "Time" } + } + + ListView { + id: modsList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.visibleMods + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Item { + id: modRow + required property var modelData + readonly property bool expanded: root.expandRev >= 0 && root.expandedMods[modelData.key] === true + + width: ListView.view.width + height: rowContent.implicitHeight + + ColumnLayout { + id: rowContent + width: parent.width + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Theme.scrollbarWidth + implicitHeight: 36 + radius: Theme.radiusMd + color: modRow.modelData.selected ? Qt.alpha(Theme.primary, 0.10) + : rowMouse.containsMouse ? Theme.elevate3 : "transparent" + + Rectangle { + visible: modRow.modelData.selected + width: 3; height: parent.height - 12 + anchors.verticalCenter: parent.verticalCenter + radius: 2 + color: Theme.primary + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 12 + opacity: modRow.modelData.active || !root.hasData || root.sourceData.active_count === 0 ? 1 : 0.55 + + Text { + Layout.preferredWidth: 28 + horizontalAlignment: Text.AlignRight + text: modRow.modelData.rank + color: Theme.textDim + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSm + } + Text { + Layout.fillWidth: true + text: modRow.modelData.name + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + Rectangle { + Layout.preferredWidth: modsList.width * 0.36 + implicitHeight: 8 + radius: 4 + color: Theme.elevate4 + clip: true + Row { + height: parent.height + Repeater { + model: modRow.modelData.segments + delegate: Rectangle { + required property var modelData + width: modelData.fraction * parent.parent.width + height: parent.height + color: modelData.color + } + } + } + } + Text { + Layout.preferredWidth: 72 + horizontalAlignment: Text.AlignRight + text: modRow.modelData.time + color: modRow.modelData.color + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeMd + font.weight: Font.Bold + } + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggle(root.expandedMods, modRow.modelData.key) + } + } + + Rectangle { + visible: modRow.expanded + Layout.fillWidth: true + Layout.leftMargin: 60 + Layout.rightMargin: 12 + Theme.scrollbarWidth + Layout.topMargin: 2 + Layout.bottomMargin: 8 + implicitHeight: visible ? detailCol.implicitHeight + 24 : 0 + radius: Theme.radiusMd + color: Theme.elevate0 + border.width: 1 + border.color: Theme.border + + ColumnLayout { + id: detailCol + anchors.fill: parent + anchors.margins: 12 + spacing: 6 + + Caption { + Layout.fillWidth: true + text: "Where " + modRow.modelData.name + " spends its time" + elide: Text.ElideRight + } + Repeater { + model: modRow.expanded ? modRow.modelData.phases : [] + delegate: RowLayout { + id: phaseLine + required property var modelData + Layout.fillWidth: true + spacing: 10 + Rectangle { width: 10; height: 10; radius: 2; color: phaseLine.modelData.color } + Text { + Layout.preferredWidth: 150 + text: phaseLine.modelData.label + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + HoverHandler { id: phaseHover } + PxToolTip { + visible: phaseHover.hovered && phaseLine.modelData.tooltip.length > 0 + text: phaseLine.modelData.tooltip + } + } + Bar { + Layout.fillWidth: true + implicitHeight: 6 + fraction: phaseLine.modelData.fraction + fill: phaseLine.modelData.color + } + Text { + Layout.preferredWidth: 72 + horizontalAlignment: Text.AlignRight + text: phaseLine.modelData.value + color: Theme.textMain + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSm + } + } + } + Text { + visible: modRow.modelData.phases.length === 0 + text: "Under 1 ms in every phase" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + Text { + visible: modRow.modelData.off_thread.length > 0 + text: "Plus " + modRow.modelData.off_thread + " off the main thread" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + } + } + } + } + } + + EmptyState { + anchors.centerIn: parent + width: parent.width + visible: modsList.count === 0 + iconName: "search" + title: "No mods match" + } + } + } + + // -- Phases view -- + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: view === "phases" + spacing: 0 + + ListView { + id: phasesList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.phases + topMargin: 8 + bottomMargin: 8 + ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + + header: RowLayout { + width: ListView.view.width + spacing: 12 + Item { Layout.preferredWidth: 32 } + Caption { Layout.fillWidth: true; text: "Phase" } + Caption { Layout.preferredWidth: 72; horizontalAlignment: Text.AlignRight; text: "Time" } + Caption { Layout.preferredWidth: 44; Layout.rightMargin: 20 + Theme.scrollbarWidth; horizontalAlignment: Text.AlignRight; text: "Share" } + } + + delegate: Item { + id: phaseRow + required property var modelData + readonly property bool expanded: root.expandRev >= 0 && root.expandedPhases[modelData.label] === true + readonly property bool showAll: root.expandRev >= 0 && root.showAllPhases[modelData.label] === true + readonly property var shownMods: showAll ? modelData.mods : modelData.mods.slice(0, root.phasePreview) + + width: ListView.view.width + height: phaseContent.implicitHeight + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + Theme.scrollbarWidth + anchors.bottomMargin: 2 + radius: Theme.radiusMd + color: phaseRow.expanded ? Theme.elevate2 : "transparent" + } + + ColumnLayout { + id: phaseContent + width: parent.width + spacing: 0 + + Item { + Layout.fillWidth: true + implicitHeight: 38 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + Theme.scrollbarWidth + spacing: 12 + + Text { + Layout.preferredWidth: 10 + text: phaseRow.expanded ? "▾" : "▸" + color: Theme.textDim + font.pixelSize: Theme.fontSizeSm + } + Rectangle { width: 10; height: 10; radius: 2; color: phaseRow.modelData.color } + Text { + Layout.fillWidth: true + Layout.minimumWidth: 120 + text: phaseRow.modelData.label + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + Bar { + Layout.preferredWidth: phasesList.width * 0.28 + fraction: phaseRow.modelData.fraction + fill: phaseRow.modelData.color + } + Text { + Layout.preferredWidth: 72 + horizontalAlignment: Text.AlignRight + text: phaseRow.modelData.value + color: Theme.textMain + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeMd + font.weight: Font.Bold + } + Text { + Layout.preferredWidth: 44 + horizontalAlignment: Text.AlignRight + text: phaseRow.modelData.share + color: Theme.textMuted + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSm + } + } + + MouseArea { + id: phaseMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggle(root.expandedPhases, phaseRow.modelData.label) + } + PxToolTip { + visible: phaseMouse.containsMouse && phaseRow.modelData.tooltip.length > 0 + text: phaseRow.modelData.tooltip + } + } + + ColumnLayout { + visible: phaseRow.expanded + Layout.fillWidth: true + Layout.leftMargin: 54 + Layout.rightMargin: 20 + Theme.scrollbarWidth + Layout.bottomMargin: 10 + spacing: 2 + + Repeater { + model: phaseRow.expanded ? phaseRow.shownMods : [] + delegate: RowLayout { + id: contributor + required property var modelData + Layout.fillWidth: true + implicitHeight: 26 + spacing: 12 + + Text { + Layout.fillWidth: true + text: contributor.modelData.name + color: contributor.modelData.selected ? Theme.primary + : contributor.modelData.base ? Theme.textDim + : nameMouse.containsMouse ? Theme.textMain : Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + font.italic: contributor.modelData.base + font.weight: contributor.modelData.selected ? Font.DemiBold : Font.Normal + font.underline: nameMouse.containsMouse && !contributor.modelData.base + elide: Text.ElideRight + MouseArea { + id: nameMouse + anchors.fill: parent + enabled: !contributor.modelData.base + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.openMod(contributor.modelData.key) + } + } + Bar { + Layout.preferredWidth: phasesList.width * 0.22 + implicitHeight: 5 + fraction: contributor.modelData.fraction + fill: Qt.alpha(phaseRow.modelData.color, 0.7) + } + Text { + Layout.preferredWidth: 72 + 44 + 12 + horizontalAlignment: Text.AlignRight + text: contributor.modelData.value + color: Theme.textMuted + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSm + } + } + } + + Text { + readonly property int hidden: phaseRow.modelData.mods.length - root.phasePreview + visible: hidden > 0 + text: phaseRow.showAll ? "Show fewer" : "+ " + hidden + " more mods…" + color: moreMouse.containsMouse ? Theme.primary : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + topPadding: 4 + MouseArea { + id: moreMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggle(root.showAllPhases, phaseRow.modelData.label) + } + } + } + } + } + } + + Rectangle { + Layout.fillHeight: true + implicitWidth: 1 + color: Theme.border + } + + ColumnLayout { + Layout.fillWidth: false + Layout.preferredWidth: 260 + Layout.maximumWidth: 260 + Layout.fillHeight: true + Layout.margins: 16 + spacing: 12 + + Card { + title: "Last launch" + value: hasData ? sourceData.last_launch : "" + detail: hasData ? "Base game " + sourceData.base_value + " · Mods " + sourceData.mods_value : "" + } + Card { + visible: root.phases.length > 0 + title: "Costliest phase" + value: visible ? root.phases[0].label : "" + detail: visible ? root.phases[0].value + " · " + root.phases[0].share + " of measured time" : "" + } + Card { + visible: hasData && sourceData.mods.length > 0 + title: "Costliest mod" + value: visible ? sourceData.mods[0].name : "" + detail: visible ? sourceData.mods[0].time + + (sourceData.mods[0].dominant ? " · mostly " + sourceData.mods[0].dominant.toLowerCase() : "") + : "" + } + Card { + title: "Off main thread" + value: hasData ? sourceData.off_value : "" + detail: "Runs in parallel with loading" + } + Item { Layout.fillHeight: true } + Text { + Layout.fillWidth: true + text: "Hover a phase to see the game's own step names. Click a mod to jump to it." + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + wrapMode: Text.Wrap + } + } + } + + Divider {} + + // -- Footer -- + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + spacing: 16 + + Flow { + Layout.fillWidth: true + spacing: 14 + Repeater { + model: hasData ? sourceData.legend : [] + delegate: Row { + required property var modelData + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 10; height: 10; radius: 2 + color: parent.modelData.color + } + Text { + text: parent.modelData.label + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + } + } + } + Text { + visible: view === "mods" + text: hasData ? root.visibleMods.length + " of " + sourceData.mods.length + " mods shown" : "" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + } + } +} diff --git a/src/ui/Workshop.qml b/src/ui/Workshop.qml new file mode 100644 index 0000000..032f010 --- /dev/null +++ b/src/ui/Workshop.qml @@ -0,0 +1,582 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate0 + + readonly property var detail: workshopPanel.detail + + function applyFilters() { + workshopPanel.filter(search.text.trim(), version.currentIndex === 0 ? "" : version.currentText, + source.currentValue, sort.currentValue, tag.selectedTag) + } + + component Copy: Text { + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + Layout.fillWidth: true + } + component Heading: Copy { + color: Theme.textMain + font.pixelSize: Theme.fontSizeLg + font.bold: true + } + component CatalogGrid: GridView { + property int columns: Math.max(1, Math.floor(width / 230)) + Layout.fillWidth: true + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + cellWidth: width / columns + cellHeight: 312 + interactive: false + clip: true + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + component CollectionGrid: GridView { + property int columns: Math.max(1, Math.floor(width / 400)) + Layout.fillWidth: true + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + cellWidth: width / columns + cellHeight: 162 + interactive: false + clip: true + delegate: CollectionBanner { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + component ListingHeader: Item { + height: pageHeading.implicitHeight + 16 + Heading { id: pageHeading; width: parent.width; text: workshopPanel.tab } + } + component ListingFooter: ColumnLayout { + required property var listing + height: implicitHeight + spacing: 12 + Copy { + visible: !workshopPanel.busy && !workshopPanel.error && listing.count === 0 + text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." + : workshopPanel.tab === "Favourites" ? (workshopPanel.searchQuery ? "No favourite matches this search." : "No favourites yet. Star a collection to keep it here.") + : "No matching items. Try another search or filter." + } + PxButton { + Layout.alignment: Qt.AlignHCenter + text: "Load more" + visible: listing.hasMore + enabled: !workshopPanel.busy + onClicked: workshopPanel.loadMore() + } + Item { implicitHeight: 12 } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 12 + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 2 + Heading { text: "Workshop" } + Copy { text: "Mods & collections for RimWorld" } + } + PxTextField { + id: search + Layout.fillWidth: true + Layout.maximumWidth: 540 + placeholderText: "Search mods or collections…" + Accessible.name: "Search Workshop" + maximumLength: 200 + enabled: workshopPanel.configured + onAccepted: root.applyFilters() + } + PxButton { text: "Search"; variant: "primary"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } + PxProgressBar { + Layout.preferredWidth: 48 + Layout.alignment: Qt.AlignVCenter + thickness: 3 + indeterminate: true + opacity: workshopPanel.busy || workshopPanel.planning ? 1 : 0 + Accessible.name: "Loading" + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: 4 + Repeater { + model: ["Discover", "Mods", "Collections", "Favourites", "Installed"] + Item { + id: tabItem + required property string modelData + readonly property bool current: workshopPanel.tab === modelData + activeFocusOnTab: true + Keys.onReturnPressed: workshopPanel.selectTab(modelData) + Keys.onSpacePressed: workshopPanel.selectTab(modelData) + Rectangle { + anchors.fill: parent + visible: tabItem.activeFocus + color: "transparent" + border.color: Theme.primary + radius: Theme.radiusSm + } + implicitWidth: tabLabel.implicitWidth + 32 + implicitHeight: 36 + Text { + id: tabLabel + anchors.centerIn: parent + text: tabItem.modelData + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: tabItem.current ? Font.DemiBold : Font.Medium + color: tabItem.current ? Theme.primary : tabHover.hovered ? Theme.textMain : Theme.textMuted + } + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + color: tabItem.current ? Theme.primary : "transparent" + } + HoverHandler { id: tabHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.selectTab(tabItem.modelData) } + Accessible.role: Accessible.PageTab + Accessible.name: modelData + Accessible.selected: current + Accessible.onPressAction: workshopPanel.selectTab(modelData) + } + } + Item { Layout.fillWidth: true } + PxButton { + visible: workshopPanel.tab === "Installed" + text: "Update all (" + workshopPanel.updateCount + ")" + variant: "warning" + enabled: workshopPanel.updateCount > 0 && !workshopPanel.planning + onClicked: workshopPanel.updateAll() + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.border } + } + RowLayout { + Layout.fillWidth: true + visible: workshopPanel.configured && !workshopPanel.hasDetail && (workshopPanel.tab === "Mods" || workshopPanel.tab === "Collections") + Copy { Layout.fillWidth: false; text: "Game version" } + PxComboBox { + id: version + model: workshopPanel.versionOptions + Accessible.name: "Game version filter" + Component.onCompleted: currentIndex = Math.max(0, model.indexOf(workshopPanel.gameVersion)) + onActivated: root.applyFilters() + } + PxComboBox { + id: source + visible: workshopPanel.tab === "Collections" + textRole: "text" + valueRole: "value" + model: [ {text: "All sources", value: "all"}, {text: "Steam collections", value: "steam"}, {text: "PxModRim picks", value: "picked"} ] + Accessible.name: "Collection source" + onActivated: root.applyFilters() + } + TagPicker { + id: tag + Layout.fillWidth: true + Layout.maximumWidth: 230 + options: workshopPanel.tagOptions + onSelectedTagChanged: root.applyFilters() + } + Item { Layout.fillWidth: true } + PxComboBox { + id: sort + textRole: "text" + valueRole: "value" + model: [ {text: "Popular", value: "popular"}, {text: "Recently updated", value: "updated"}, {text: "Newest", value: "newest"}, {text: "Trending", value: "trending"}, {text: "Relevance", value: "relevance"} ] + Accessible.name: "Catalog sort order" + onActivated: root.applyFilters() + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: errorRow.implicitHeight + 24 + visible: workshopPanel.error.length > 0 + color: Theme.elevate2 + radius: Theme.radiusMd + RowLayout { + id: errorRow + anchors.fill: parent + anchors.margins: 12 + Copy { text: workshopPanel.error; color: Theme.danger } + PxButton { text: "Retry"; enabled: !workshopPanel.busy; onClicked: workshopPanel.retry() } + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: noticeColumn.implicitHeight + 24 + visible: workshopPanel.notice.length > 0 + color: Theme.elevate2 + radius: Theme.radiusMd + ColumnLayout { + id: noticeColumn + anchors.fill: parent + anchors.margins: 12 + Copy { text: workshopPanel.notice; color: workshopPanel.pendingPlan ? Theme.warning : Theme.textMuted } + RowLayout { + visible: workshopPanel.pendingPlan + PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable; onClicked: workshopPanel.downloadAvailable() } + PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } + } + } + } + ScrollView { + id: contentScroll + objectName: "discoverScroll" + visible: !workshopPanel.hasDetail && (!workshopPanel.configured || workshopPanel.tab === "Discover") + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ColumnLayout { + width: contentScroll.availableWidth + spacing: 16 + ColumnLayout { + Layout.fillWidth: true + visible: !workshopPanel.configured + Heading { text: "Workshop catalog is disabled" } + Copy { text: "Set a Workshop catalog URL in Settings to browse and download mods." } + PxButton { text: "Open Settings"; onClicked: workshopPanel.openSettings() } + } + ColumnLayout { + Layout.fillWidth: true + visible: workshopPanel.configured + Heading { text: "Discover your next colony" } + Copy { text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } + RowLayout { + Heading { text: "Collections" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } + } + CollectionGrid { model: visible ? catalogCollections : null } + RowLayout { + Heading { text: "Popular mods" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } + } + CatalogGrid { model: visible ? catalogMods : null } + } + } + } + GridView { + id: listingGrid + objectName: "listingScroll" + readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab === "Mods" + property int columns: Math.max(1, Math.floor(width / 230)) + visible: !workshopPanel.hasDetail && listingTab + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 312 + model: listingTab ? catalogMods : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: listingGrid.width } + footer: ListingFooter { width: listingGrid.width; listing: catalogMods } + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + GridView { + id: collectionsGrid + objectName: "collectionsScroll" + readonly property bool listingTab: workshopPanel.configured && (workshopPanel.tab === "Collections" || workshopPanel.tab === "Favourites") + property int columns: Math.max(1, Math.floor(width / 400)) + visible: !workshopPanel.hasDetail && listingTab + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 162 + model: listingTab ? catalogCollections : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: collectionsGrid.width } + footer: ListingFooter { width: collectionsGrid.width; listing: catalogCollections } + delegate: CollectionBanner { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + GridView { + id: installedGrid + objectName: "installedScroll" + readonly property bool active: workshopPanel.configured && workshopPanel.tab === "Installed" + property int columns: Math.max(1, Math.floor(width / 340)) + visible: !workshopPanel.hasDetail && active + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 62 + model: active ? catalogMods : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: installedGrid.width } + footer: ListingFooter { width: installedGrid.width; listing: catalogMods } + delegate: ModRow { + width: GridView.view.cellWidth - 8 + height: GridView.view.cellHeight - 8 + } + } + ScrollView { + id: detailScroll + objectName: "detailScroll" + visible: workshopPanel.hasDetail + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + function jumpToMembers() { + const flickable = contentItem + const target = membersHeading.mapToItem(flickable.contentItem, 0, 0).y + flickable.contentY = Math.max(0, Math.min(target, flickable.contentHeight - flickable.height)) + } + ColumnLayout { + width: detailScroll.availableWidth + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: 1200 + Layout.alignment: Qt.AlignHCenter + visible: workshopPanel.configured && workshopPanel.hasDetail + spacing: 16 + PxButton { + text: workshopPanel.backLabel + iconName: "chevron-left" + Accessible.description: "Return to the previous page, keeping the listing's scroll position and filters" + onClicked: workshopPanel.back() + } + RowLayout { + Layout.fillWidth: true + spacing: 16 + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 3 + Layout.alignment: Qt.AlignTop + Layout.preferredHeight: Math.max(220, Math.min(340, width * 0.5)) + color: Theme.elevate2 + border.color: Theme.border + radius: Theme.radiusMd + clip: true + CatalogImage { + id: detailImage + anchors.fill: parent + previewUrl: root.detail.previewUrl || "" + widthStep: 128 + heightStep: 64 + fillMode: Image.PreserveAspectFit + } + Grid { + id: detailCollage + anchors.fill: parent + visible: !root.detail.previewUrl && (root.detail.collage || []).length > 0 + columns: (root.detail.collage || []).length === 1 ? 1 : 2 + readonly property int rowCount: (root.detail.collage || []).length > 2 ? 2 : 1 + Repeater { + model: detailCollage.visible ? root.detail.collage : [] + CatalogImage { + required property string modelData + width: detailCollage.width / detailCollage.columns + height: detailCollage.height / detailCollage.rowCount + previewUrl: modelData + } + } + } + Image { + anchors.centerIn: parent + width: 48 + height: 48 + visible: detailImage.status !== Image.Ready && !detailCollage.visible + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.alignment: Qt.AlignTop + spacing: 10 + Flow { + Layout.fillWidth: true + spacing: 6 + PxBadge { text: (root.detail.sourceLabel || "").toUpperCase(); textColor: Theme.textMuted } + PxBadge { visible: root.detail.state !== "missing"; text: "INSTALLED"; textColor: Theme.success } + PxBadge { visible: root.detail.state === "outdated"; text: "UPDATE AVAILABLE" } + PxBadge { visible: !!root.detail.incompatible; text: "INCOMPATIBLE"; textColor: Theme.warning } + } + Heading { Layout.fillWidth: true; text: root.detail.title || ""; font.pixelSize: 26; wrapMode: Text.Wrap } + Copy { text: "by " + (root.detail.author || "") } + Copy { + objectName: "detailTags" + Layout.fillWidth: true + text: [root.detail.tags, root.detail.versions === "Not specified" ? "" : root.detail.versions].filter(s => !!s).join(" · ") + visible: !!text + } + Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version."; color: Theme.warning } + Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } + Flow { + Layout.fillWidth: true + spacing: 8 + PxButton { + readonly property bool pack: root.detail.kind === "collection" + visible: root.detail.state !== "missing" + text: (root.detail.active ? "Deactivate" : "Activate") + (pack ? " all" : "") + variant: root.detail.active ? "danger" : "success" + onClicked: pack ? workshopPanel.toggleCollection() : workshopPanel.toggleActivation(root.detail.itemId) + } + PxButton { + visible: root.detail.kind === "collection" && root.detail.state === "installed" + text: "Activate only this pack" + variant: "warning" + ToolTip.text: "Activate this collection and its dependencies, deactivate every other Workshop mod" + onClicked: workshopPanel.activateOnlyCollection() + } + PxButton { + visible: root.detail.state !== "installed" + text: root.detail.actionLabel || "Download" + variant: root.detail.state === "outdated" ? "warning" : "primary" + enabled: root.detail.state !== "installed" && !root.detail.queued + onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) + } + PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; variant: "ghost"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } + PxButton { + objectName: "detailFavourite" + visible: root.detail.kind === "collection" + text: root.detail.favourite ? "Favourited" : "Favourite" + variant: "ghost" + iconName: root.detail.favourite ? "star-filled" : "star" + iconColor: root.detail.favourite ? Theme.warning : "transparent" + onClicked: workshopPanel.toggleFavourite(root.detail.itemId) + } + } + Copy { Layout.fillWidth: true; text: "Local mods · downloading does not enable or subscribe." } + RowLayout { + visible: root.detail.kind === "mod" + spacing: 8 + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votes || "Unrated" } + Copy { Layout.fillWidth: false; text: (root.detail.fileSize || "Unknown") } + } + RowLayout { + visible: root.detail.kind === "collection" + spacing: 8 + Copy { Layout.fillWidth: false; text: root.detail.memberCount + " mods" } + Copy { Layout.fillWidth: false; visible: root.detail.fileSize !== "Unknown"; text: "· " + root.detail.fileSize + " total" } + } + RowLayout { + visible: !!root.detail.votesUp + spacing: 8 + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votesUp || ""; Accessible.name: text + " positive votes" } + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + rotation: 180 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votesDown || ""; Accessible.name: text + " negative votes" } + } + } + } + GridLayout { + id: detailBody + readonly property bool pack: root.detail.kind === "collection" + Layout.fillWidth: true + columns: pack ? 1 : 2 + columnSpacing: 16 + rowSpacing: 16 + ColumnLayout { + id: descColumn + objectName: "detailDescription" + Layout.row: 0 + Layout.column: 0 + Layout.fillWidth: true + Layout.preferredWidth: 3 + Layout.alignment: Qt.AlignTop + spacing: 10 + readonly property bool tall: descriptionText.implicitHeight > detailScroll.availableHeight + RowLayout { + Layout.fillWidth: true + Heading { text: "Description" } + PxButton { + objectName: "jumpToMembers" + visible: descColumn.tall && catalogMembers.count > 0 + text: detailBody.pack ? "Jump to mods" : "Jump to required mods" + variant: "ghost" + font.pixelSize: Theme.fontSizeSm + onClicked: detailScroll.jumpToMembers() + } + } + ModDescription { + id: descriptionText + Layout.fillWidth: true + description: root.detail.description || "" + onLinkActivated: link => workshopPanel.openLink(link) + } + } + ColumnLayout { + objectName: "detailMembers" + Layout.row: detailBody.pack ? 1 : 0 + Layout.column: detailBody.pack ? 0 : 1 + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.alignment: Qt.AlignTop + spacing: 8 + RowLayout { + id: membersHeading + objectName: "membersHeading" + Layout.fillWidth: true + Heading { text: (detailBody.pack ? "Collection members · " : "Required mods · ") + catalogMembers.count } + PxButton { + objectName: "detailBackToTop" + visible: detailScroll.contentItem.contentY > 0 + text: "Back to top" + variant: "ghost" + font.pixelSize: Theme.fontSizeSm + onClicked: detailScroll.contentItem.contentY = 0 + } + } + Copy { visible: catalogMembers.count === 0; text: "None listed." } + GridLayout { + objectName: "membersGrid" + Layout.fillWidth: true + columns: detailBody.pack ? Math.max(1, Math.floor(Math.min(1200, detailScroll.availableWidth) / 340)) : 1 + columnSpacing: 8 + rowSpacing: 8 + Repeater { + model: catalogMembers + ModRow { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 54 + } + } + } + } + } + } + } + } + } + Component.onCompleted: workshopPanel.refresh() +} diff --git a/src/ui/assets/logo.png b/src/ui/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..94645a8b7a2bbf5004ae1a400a334a4b01162d56 GIT binary patch literal 36953 zcmZ^KWmuHm_w`VMfPj+H9U`I9H3-rT3eu$#10vE4FrbvAG)RL8k|GRUN{0i|-60YK zLkuy@yf;3-|Ht>@19M$--{6(egXWuVW*|`1cbZ(%WEx80)f~;8c!ZS_08H{@XNAr@LRrIx@_CZD*da{JZ@Mv zk$9hpypDsyhN;Hhm{^H~>ihitBn@HZ$;_9D+6s+H>zl6<`*rq*vI8F8q+)n?_|wil z<^gn?!1e0)sX~&mi6V5zv;hQijyVi8#d)re+inr6t zVZOYI+4{3!+uwR;sNEH#V%=X3v@VhcroM7GK6sf0_FmWx{lou<89p;QH2SwD`13rlfp)7AF& zro?0SEKNbXv+XdE>I*&6Al5zymUt?q$hfD3i?PpO4+7Q{D*ziF7;_&2Hao_0mf-G|{LN*P_dm9n z{9z%Si$t_aHM6kZ}5hU>IF*zFis^czNGB22&K!cMNVeWrsT@EL75Wx-|5@I zEAp4tcz@gR^oC{V0PkAWeso$Ln%k?f;XEg+%T;>+vx$%cwwHpNDYw6^M8csM*zs(l zDlVQ)F3F3#?6-jts96o&u5p)D@o@gXCk&_f*I-UIW2qh)1KYR>mL zflJ+ks-b^#t*&BvZymNHLl^LO+CbCdZY|*p6cAm?`nM`GcG*aB0?g6gLbQ#BzC?@bAvG0}p`j&Gni=LkE4d zunLp z>gp=2F;z?S!~5ogT(o@VpnxwYxbqw^BU0k5?(Ky*pDwdp&P%EcI*>Ay!UA%@Mx`|rQzItF%)8NP34D#Na z!6^~tCajA9Xp3+~nZcaViv%LSRySQmK;E&;z9~w9%+n+LPyi%UJ43-fSgz*NT?n-7 zvQ8Iomr?rQUJ<5Mu`(M?3q*Rd@!=U8i|lC^-<(%x9WAJ{kT@p-oeR2{^U?!eFL+Or z?Nh}H4mvy9YcMq@4Ngc1l$5CehdF#>K?4!ukQtA8pBBpo#q3nH84QvIdzLNH2mh*Q zOQQlTCql>+S&#e`dM9K_*l-<`p3BRBW-FDC)9o#&)g$NXYf>bgOz}kJH?3%( z=3bQ7^J?vpRkXnz9ZO#BY??g-F?eDFNnbnuJ_!TD`5Jwn`eZ>j`mL9iE&(%Z>#9fy zY$^(J?QW~GrTjN{Em292{oKr;jaSxhe1}u22+;Y)7Un>t|2$rVE_$NQ)_f$73UqWv zJ3B9PqYSK-n=k;SQ2i=QKN(o`MfD{h#di{QGEJu2u0G+_HqmQf0Z*TJ_}xf(#KyqN zx(}J;$utIqp0C2x+`Vcl`L5TPr}D=-uE~@hGC@LG&$M_r^ z5P|mM?X{%>!M}h1{#7j*-(Lp?UbVB7E(_c!1!_gE<+ezTeQ#WB#riKnR=g6KBQ~tc_@sw67UbK>#@TNT`1Tz6mH~Gu z=(onB1_}?S*cD9x$B^T$@se$W$F$4BborwzsdYP%3>xqx~;X#ea-?+D;7J{6}vG z2VS0T2pY592>(Qz^3UlZ4>O|~AI#a|YWE`QGZ*NkLlDJ(uQ?d(z6k{%y>Rmz#aHyh zrEaQnU!$W=|F4F}nF?93CA894;-FK9QXR01YVTlorPag=R?TKr3mRF(y@jk^lk5|7 zDKt{#w2({~pP7;IucQHv;amR8$N$!1;!AbWN_BO0*ao;^nIxFv(klKRmlce;cF|Zt z^VuxEf%Hox>m{!1l~Qd)yNxc`jnrnTEim(*aXeQA>|X}y4@B$+T$fEIYdJTx<)+C*FmNfFJTn+v+Wb^W^|IUy1#Dj49 z)EH2*kciTMz;(Tdeuiu5p--g&&F+}gOa5obd-VRZ*d-AGP|N1CYu5{Q_^VRo)~BXG zyE8Qo(|2+lVd{YEp^g!y?#&2h1v!daTp(Sd;bCF!e~y?@Wo}KjS!LnB`MarI@L!bp zRRTF|T0sZ5L`bhk6rAvA#lNoFQnWe*wy!w;GxJ`8I};#1EHu?XKX*P}lcthqbII)H}wIP_3=ar@+;+1jbX8ocbC5R<aVwwb|#`C!ma5cX-W8s00AcP;(Bbc z{y$A_g@xd&=)-bHw|5334#v<>lS`|W4~Ot3yBVXPt2Rxcwqo0LTk%H}t8$GXQByTw zB2HO)O>A_(ydR^9^Y$=fQrl?WD0wyw$=`xP5Ub+lvD4yO%8%w3(@SNa8A{|2lY%Oe zty|Kq%?KmKA3dJD4aE96A0u@@=ksPmEwI>^oa3Y^8cn`O=o7o(qa#>gx^)6?;DOZ& zyjPNjKzD}c2scJScs`BozeklY5KEa9Mh7CIicsFirNVKxK-WJ7883^k>Jnlj5jO!u6&W{T&yYCLr;8Wni=qA#dl24oFQl$1(*vft7x z8|Fb*_EgT7ruqp;*K}gVuzTF$L}Fg|$EV zMP+m@oDxQ)yg<>)XN#9F9GCoBh|4;h-@CI$cRF{3c(a!Mkk%gn(dA-Ff?CaPv$M=i zsa_|}p9iseI5VN3g@&Kjg7LXP@Ks$*XP=_Bf?+Q$YOo6Tzf&HZ8Fb1s2SNs>Wu zhoX&G2tKthev#;n+R=WqkXc0VOCu11&*KsyRUS4CEq~a>Y6mg!6^nAt5^Px>R=(vS3V{=~jxA0afmO-SV|s!E;lf{|U+CWb?O zii9CM!1(hVKAzqvrdK-NYdMQoBC4aOi!_FyNX$&Slp>PW+4T0Z*Tr~lrMUjKzM^Yd zFgd6^?%yIp##M5#8g&QTo{rX#6!J?+Zk)QZ4f*5emyAmGqO;U@yZ42niesPbE-?Pi zHF4S1dM*7dG^}8&??o6gyW~y4r~#a{?3S5qEm19JPu?31?ztth%9SV~t~UAUUZsJtupY$sCad8Rgz zlwG-z)Ko*K;siZol-57q@Q=|oIagLjnXpzQt>+HyD3|EStRp|XTh{=SvXjauJP5_x zozL^@^thaEb#mPrU;#rKFCiXTId#eQiJW<*CF5;z`G0BL$)7n?Kj_-FQ`)jCx4w_h zZfVbM>Z&rd-17^T=yfd+W{VZTRdi&aiXAubp87(Ch;)Sr#;5<(;wpXP}yVI20AXb!@e zB#O9hQ8DOIamR-Lox1~fl5$hzkp($(fgX~7yZ0YI8J@(yz_u;+TJ!K-+}-=_MBnkb zUoTk0vaoOM#GQiy62!6*uk3*FXuubKbggy$VR=*Hk2MfCrG zlWHml7h%CwWfSMsDmi8EQ>f=OsnwOzB9Gdc=PqtF*Z;XlYM;y-O(KTu(EP17gh;}#{T!gxZUVBMR1+USoSqNk+|pd=!TH$TRoVb zYLh=@xr8$$e z5qj)WqWL|Q%@Bb#=2?zNq=iAS8R82by4+Xv2Y?d`Z>%0%v8pnh=cf<>9*t%?@w5F zI?N(l3s&WF0~<~R@xVuK5=!4owbpxS+(#sJO+IfJFBURmHJq>CI#K>riO){9`(zL^+}kqWChzn{P2Ve}yGN&RHepJq`Ae_9TXRSrC!^P` zG@;}|>t6nClDGPEwV$3DA@_qRRngD*|8uDKtTx-qm0>|TlcI|6XfO4}Eydl$eBAWZ zo$rN=P~BmfMI4-Hm6w9Bi*EnIIV0&&s`G9V)HE`X@x7Lvrk%OP=Rd8MiI~<}Qi})w zA`~&^na-T zmbpr;DB|$m#)0Fjs8lsQc%&qErkzCA7An zgmBT3&>ICE)QrkD?DYJVEVumn;Y4*OnY7)a(vb5ZE)`YXDy@+IZbC5z<*au zLK36frW+kP;usS?(y=e}sVu@H=Cvd_9XS(OzkG4D26V~ZK`uUq_O5KE+=6);_obAj z4MVXWmNqTG(urHV2U2ic7Q0XvEOC)&WKY)JZ(;2y+Q@h3DTal>L z5i$`RHVvxoELI7gqVkP63Rir*%%M*KQ7qme(XIg5IF&7$S;Mw0WvX)Jw#|oEMcX01 zDD-{AWpj79%1K<9!qp>oJ!~1zzj9IXf~Qp}p>E>YHm0$L|K+#17b?r&IoZ{N2)0;s z{mLZ)@j~>Hd6tg|bYEDaoc3-%CeYA@n+2N~h?S5(=JSr4_M88{-u_%_x`+#;&es9| zUrK#C7}EeHqfWU0f>dI7J1)3qi0SMKMJGcZNmYn4;#WkrcJjv?+<3`EclUcOsDx*4 zwx#t%V-+o&fnOr@w`P&Y12f5|5^SO|#>OU+a|@K07zW#BQws+fYy2ls;VZDwK%5MV zvfEHDMOd$0)(?S5afZy(wJEx_4oK_-X<_0ve#L!FK{0GU8HjFR)(VlyR_Ba`fN`ZdT(TFP6B|bW9ol z781nIs}lqOnRU|7`B-lDeLSR}t=2;$Gw)&*92it(nzqYc!sAXH{b32Lq~pE^GMtOAOj+x;6&l&xZ~ub{E48DexVmO7aqG|DbQ+Fv2xI3?`Op^&z zf>F0LtP_Ha>E)9|H0Fa2Ycww@^9~fOJ{?2!u42os(yH(rQQ&AR&g<7BRbo|uV;&>? zlNT1UTI{-uzxByM{MkWPso8<9YKqD`i)Nz{FFP?>bQfQFR3aTI{*}f1armE?#-W7c zsswooLW;_X#^Z}jQzCiyx8iYAK~?VR?S~q<-Bplw^EtI|+~8l^&TG4L*EWQ>e8ARgB|7H%v>k`40z&uV6QRaC~St{a0|SyHG&=`igy%LxJJK~S@P9lA4r!=u|i^p@wA!4mn$C|zX zerm zq<2Io;Zkt|bkU%vWe>=@IemwRcJGgkjSUSAJ$&@2v(dvSOD6DB5?`;hv;v|(Y3=E~ zl$b&{uq(MiGT+b!NddDhgWXSNgJk83wMH*@cldT{z*0xzl&eLN;XOlP+Oc7>X(

    ;OLpj2CcbS6~y2~Cf@BNXFf^GYZkFQ9~mge>* zs$VUcYhZ)qC$r|)@=j`3yLTUdDyrMsC||)F48J_J$g{{QuKlJ6HyjXEs;0ZHvI;xw zPj>oA6fb`v5U4$*TvvVD2@5M`vfl5{3i3|XSTS#rxIwa9zuLLdr$0yO?8#3=jJtXs zY>eU;h52ccc|0B9IY_jcd?Wz!52XRk2&QNTTrn-JlUbkL$;JIlOHa=l;-;VuJt1VH zC4F+P@KZGLiO8n`Gc7;c)Kv^Kna87tFTYV};Eiy720gbfhX<@>;(FA;7w!s2Gs6>8 ztYT@L16xT?az&_AhzbHITBBTFDv49Gj8{QL-`roWIhx1!~kJy_95CyFB4e~;hvsnn-0YpnHdF! z7JV{VIsbdNh8g^zM|SDtwZcV5?;8Ylk>2TfTTp=#Se0=jQut{_a(bpr@BFcJd5Sne z1@Hgn%VuEOLQpRDnzS9VTlHwD3?JMd`t?xKD>dxgbVg@{(-3q_A^?(^vyW%nbj9sZ zuz~}`cJxCX%Nh)AH|GgG#vEkZs(Spn{u}N{8hL)a)z$^wZ{gcElxhYORRQoX``?$PMbHK zThh7}bfK96Vwt$R*WE1iN<$!Ql(B+*)=`_PAog)4`}rcFqbtczDwa^f$Ea3yUt~t6 z%wXdqzGhB?`lu{Y&L{MKR}d==>->&6+%_UExA=JRcJBj;N;`lJnW=l9SuoqXUNptJ zPkLi=y`+)yi)PGpc6k;}>qvlFA74|eXb0hKTZY?DZa@AQu%ANBmSRZ$dQmgT=HrKI z`{d{&(}-GOpjtdh^V<*-AtZ8xp)Z8+ThJOxRmR`kQ=E%pV#~VRQyR>=yC*#REX$M8 zaSdh~bv>4j$L0dsR8--&ZIiiT-Vz$*h^BFoH+x8GFsSY_%Xf^gOFc?*nA~fpGzElE zr10@TTHD94FuN~fc!-{-f+^gMCS_08NZpIxRT!5TmsvP^!5uk}NeRi$DWg9}`Rt0s zZIiX);wo9#vPC?*Yu_1u^PAHpy?U>mB{ez8tF)61)IZO!Q|+56S(%?Uv3j+y_abUi zo#EP9ycZ3h0xFH~Nl6bC+52vlBu70>70M#}8Ogt-R{4=HZHMok=l<8Eb0u)|sb&8* zb%em+*Z0BK!@;f3G9({7@QbSoTKh5B6bFn~^rXmww=y}2+gjMbRhdsBA$vCY=T8?@ zt6`LA6u>9_CnrfFzqW;R(%lH zj_4E0qeY{v+>N2>+i@%yRtLRY3wr307!Lk<0zMup@1(+a z5PIY={AP6oE$y-DxG4m!r`Y37Lk^0CdjrZTlJ(fU{Tw1cMsHOPTkOQ_((uJI+hO|h zk7Q`;q79?=xC$M|&RM8TlmOcDAAwIDvbDFdTdM}3VIfsQ24B!i2UM~E{RMmmL zV%r?^8-Oe+P`jF~Y9Siq(vvATN~YVC@>{1W)??PCka8|m!m%?+xIuR0Yp&&oyz_9* zZL5n!dIy$pDm`bQ6jAG8g17sPT;+ecjZJcBk4U@5P5qmEdKfo9{DS2jRoZ&?+I)sl zjQrvII+pdq*!>GE=-Tbh9Bgfw-(*eYunake@n?0;9WHk7>at3Yr`8D8?M^Si$?&f^x3~3An8?Td;DWVl&Xarl z@$FZ*5x#-2o;Az|d&zNax^NVy)p=CfOThB7S8Mmy)Yu$?yqk*9GvA72eF(3`>oOdS z`dh9{WvA2Pp&i5WGU!$5OLQOl7S-nDl2dq^RvZBx%gXOw)~!mY!ss}}9~oIjTU*ai z!N2YS941EmmWNKuHbo>PEasMR813U0|E7p$7`o_jeh`RuV2PrFtjcGx%KohUE!ao*2S3!<&ZO7^m)DNbxLZ~J-Gk>JBwItuDx1D{-*#B@ zlhndvm&rnBDYz%ET$$KvdyHioehRP+4r|Q#VkN2d{f43^50Ztf=F#;N_QY%}L(>ME z4pTwcGYkgb2ll5EGmbkY!)$@iAL-^QjIBr3_>AdDQW1_aKKGPhGP#DfR;49_1s&YhWr4nH-1hKwF!vv}tM1FaI=4pU z;?9QoQ-E!diL9m10^Br${fSYQy!hlXj)KruJ$X?4ia(5ORF_Ih&s0;gA`$5MYgt!3WAK7b*;4 z{Uc{+NAE$I5bv6V=zJ4ex{z&tJv`Nhy@hSZ9r~C?xX-An zdQ%gAmsa_^{v!Gd@hy+*oNfxN2sX+shO zV9Bfjx*D{Lu^sMH2iW1aSa;(l9mtkG7Nq`l>1E8#nc6_&_nCE|1;2L_{u`}Z?N~5Q zL=ueV{V6&mlO;aa>_e%?tPrrm=B)CqzLP9eGv%zym$%;d{Z+cF`NKw^M`xO}nZLo! zaOYDJMFtgqm9b*F@9ad1X(MJe?t>IJ>Rpr}cc=RY&dvlDaX+AE8M*!*llkQ{fBxh| z<3{{Qs~W@Re+|F3R@B+dw*QO9YVm$>Ij8_@c?NFn*QP^zSy)U!Pw&yTpi926Y?p~X zo=z={G}n2kQfw6UL&tnNaM@Yn^&Ce(jKz1@)yKvaQqNI0ipbB_yEr=cfq^6!=8H?c z4b8^T;pTHeO9y`iWo6DyfQ2d=4&vDnH6Dh#%NGbqm5xhyL%1cirZ$KDlBS*gAA_fYWt2WcC2&-zfYQ^cnPreb!L| zIo-W4SozAPGYMSu!Nm(j(y^%(0~#M{-3dIScpXf0b>QWyF)-kGG56d>%zPtrDgU^; z>Lp9hOQ)cwRD8u+gQaCvZzS{{Q@xAZsvX_$d%bZi$m#uHrW+=8uH)I6cGyaU0%bmq z&m4R|7rK2U+MF9;+3mmaYAXZUn|+POnB%$zVE;eaC>k^U;Cn7hGulqZ7h+oFj&MI% z&M)tLo-}{YKRZp~il>wkJp0qpOfX#BV{8g8gmDg|f{}nHrF24;A67XYEG* z%a6L^*<1~tR7~MEr{2BZ=T}@}daXkL>GF6+*u$-zdMO_UOM`d|9>2beB?jUc+ZJ+b zw{2B`+$DM9YJoFtXDDZ75n67!&`7o@v$^O_?CD6r{YXZ8{|npe*^^_$-nyHg)mY_} zZqaaH%D{U{2f#pn*FLnQA7S1-E>MAnHr2O4-((i9OVN<~s2$$-f)+aMi(T$9*2<;^4 zC8A{rdQk%Fy5qJ|`Of%W;NR${ZC3z`m49gJP!qP!8=11kwcTBH zCSvNyo(!{Dr zR-B^?9TZ{@W{V{r6r6r@;7xBo>I9IjvBl$`GHJFQ;e0k->&mpe!$z8mahVY}F0374 zA>)DolTa7Yn0xQqZRK1Mj*b-XSbF>#1CaC!p=%X{dv`92?99n=Q4-smLfcopgU1Xx zsVwPX9ghf{#kVppFVU-7klYKW6fz!Yk>bv=G7p=oU(D&BW3II5KWy_2>+%{XG=B;m zL2MYFpdd+;(-|8uV>Ex713>UI-(Eu1C~iefK+gwdJaCd?#UTkIX+U&YSp5967V9!F zT5eV+$cgCJ*nR;M3n!mJG}=>J;Jg|K zGBYl>9ZBGPBRN>C!VSsS9sFrQ_gPp*oVOzk6&lmN*gjpxDB0M)zTwd9fW7woZjtz0 zgxZXPT+}EkzA+8cS5akWllQ0|q~!4hBV?*yePoIn<& ztd~BXc`4ce9X;)w3+2AMeMC9@!9wQsBTMwR@9H@QT+G}dic{p{!V5VioVBTa3^B@O z)Q885#r1G@%rxwTLRqGhKT}^F>tP9%m1VzE ztbJ!z7@bdT+o-}a{r)w~iOzD6IBBZ*LVPLEf>F6YkM8FSv|f28qU5$)ogPiTJAN*}cbS8W4og>D0 zZ5)SbcR0_@AsGx{L_ZhtPQP{S;_@;(>~IS=RjM~??gnW6jBHn|esSifSJ*fxpb3qQ z0*Oegf(}(+R=y`%`UJoB#Fw0pd|)wWA2#Xz%DnRVVBM?u?TU9}C~gvv=*Y~o37|qa z*zS@h6n`8$-VkdJulr^zssdNIAI+Axl3MWXm!V<{^RZ7L7FGh;i{(+S3}$I^i1jA#xcI;Qln>l3=9m*Fzg=8 z-m~_oE?5ZN$F&5RfotVnkv3)qr5`kvnR~|@^Uq%xM&+LU^ebNb`XdMh9yOhlU+e7U zUqk~P+6Eic)V3z(g|31+-K9YM11e_{eC50(zpD;@C3})vG#8iCO0y>5RK~a#V`Co9@;~F zBizFENrxjU?4zJs{o78*uwoaHBr*isc#g%0oag94MKyExb82&~T^ia-BqpiKk`{n z3&6bi@<7fBHKky>U6r)PptScPhJ%eC#X+N#M1H6?k~`+IJJzx(ZGIlMMbr4&fg(a7_K>ajxYI)UB`ngnkRO1whRrb*GhycCDi`HZ60xh5A1MbcpL z>6Sox^YCzkyWS^)LPPGT4{V|yr`nRjx|UNnZrnKgWsQ4=oswNbRA$T8P!v0r!-cZ( zO9TRUYj$2;&S-wMi2Dhlr@y2Hh>b-&${_HVsyjYVVvIpJ9kTpt7~@Snia|t|VrhtPa%@NNeo>?kK$PaBWKH%`e2~=aDg7593B0mG*AJ zdmXbS7*O|z5Qaj(a!txTsRcmBj|HZiJVIm5Yh>5EWe3;K+5dG6)icY|``uUoH=1s+N+U4#`bO25Lbw2s(9o}olwc>bL>quhT|QzUN= zd;*H*Wd!<_ztWmYQoCkiyD`UOP>Au95I$0J15hIs>Gr$!HvO^O_dnik_PQ~ze^oBa z9S~)-a?k5pjD66^E+iJiHNvfjo3&3?UdfP$GFH6nCWv6fiY^!u_b~?3YRX*h6}hoD zU$u2Nn)2$g@KsjEBP?6lNyw)xp+fbG7x%1AqSD5eZ0Dwp*9NVznsfUv^k1`fuPCo6PUV*KRdY>?{eB=EsL5}K4?WL5s zEsyZFk&p3mBCsCS%EiW08W-}G)xQtx!6eDI5KKy2?u*M=y?1|Mx&&IvcppID_ws&N zf`F`>G1i(`|Gbi$>jNPy=Do{$c#vnetPiU&D_c1l4ZkUbwZa zNYDEqi2*9+Cl{bWkyCgZ^VPp%0?woC*i+8irW_+Y`ydxZD|)wO$-O$ zy?@79`*)f)7Vin;Pf$Tel3o%_VU(tzTOfGL0-nHx7I!)9FK!*eJdo1FIfS}qq1;;>hYBs@Z%v%fC5BA&M3KcCwC?KQisCiqMboB#FKIrMGF zSMUja!HW$T2WtLc{PF9>Dt(=DYDs!#0%^U8kVy@Kyw!Q>(v9FT`j#=U;(cVIh^d+9uf zY<|(!&T$Tzm|oE1DcgtWVYU~s0gj`5K|0oifeBwkC?(~+s3nn-IyzW5Lp=>MW30jQ z>O&8sBrQFn@ey%BtS~??WTKDz5UnOu8?)efNm;MdYCZ}>0m}w-1J$q75oJn`v%Z9( zedHkRtEGfr`d*SKGL_9mKa)9tFEwH9yJFy%iIqV&-ckKjOrpep%i#oB5<8EGQc5mA zYu_%7l|zGc^swRvHapFCULkTcuoU;;+*>~G*fe|_X;*UPLX+B7c$G&SaCo2{$JPs- znuJC#dLskT))zWL##j6H$qGwYzRA~OXhidyq&;)d^5;#{dV;xf=Tp?}y?`hjy#eNR zI!GdWj#^(z18Q=fNY$U=AJJ6{eLBQuOwDkOb&p45PX#w>7m%Y$wV8v40w6~IqPvVU zYBX?kf4iBm2deM!=Brt-HJ|_7OwHEW9|AU@mkP*|~apZ>KQ- zsj>3Z!p~nr572S4bKdPmmzPYNmrP0R#-aBzki$i{f>0dW5gNMtgG9$0;#Yp1J?}QX zBwdyV@+XVGglTrnNDl*0w%e)x?0|Q6{HWB5W}NJu=ZbYbGdVYrqVo}NUfaQHkpyhBU1qO{AXje%0VqkH?TKcTK`UFcpdP~oOa#P2C zv8}eIQbITubbKU}Ki4-RfskEkk1k5S+hgKf^c@3nWzHs*yNBvLMV`jGj|Y z3ILbPk#FF2Fonvk%hzV5oO7U%Uvm)*(+!QT=p)42SG)XV9u>H>XOYp3S8>hh85#XD zF-OKY{D+L-BbG^qBG zQv5tz9t*AC@|$>cbgU+X;{xe>W@>dE>0wVPiHGgVz=?;<0Iw5Owmj zLMI9Qd<(eykk#pTfLN>Ue;Q1TY=-D5>d}L2g@-4zp|jrYqd%GL)hG-q`U2YfCc!ON zY;$i9hRssIxM%m4g17-h{+ypfb_~P!af7oTVS!pp-#KuKe7#$YMUkGaZPwyLXb4JL zg(Hpm&lr#qTVn5tx}z)auBVm}XfwsnTw)v6%9e8T4dG9Px~hTOjL)_rvpG*G90DKS z%1eBd%ouUi>_nR9Kn8Nf_m0_BuK!B3jTxd@>&@wyLCE0gc;>z*rgTR}Ab}p2)+gB3 z7e}0k+(o%r{Ba;xB|aet_(@fuSX_olnz*_Gng@!Akz7gg zb=w`At{u1ezLUbHmr#tz`XAG70!oBoT>jHA&E1mck@jlL@8SKEiaSXHpe@E6?3hZ( z2umk!q>L!o&}9xDA?h-mJ~q^N6Oko1X<1bGN8R1!d<=Xk(slSZ-q2O@TDqYFfmlrn z0UyujkWT&Z;hjHIqU&yqCg)|Iec0aN_=GN(> z?_YWHt;e@#(kWqqQDG00@5MQjZ%i9~5Tea2CyUJPmX1Fm)eFFm!Jx{o8*?lO8=Q9H z>R5K5`$HOE_-bUMj3QN$fbwm->_Y<^@9wBvjNEUXDQX3HH;b)IEP(61)-yW~HT6`_ z4)eMZk-J#EFQ8fqQJL+(X)%P3oQ0#OZQBYsQKzH1={a0@nS^^Mc3m{j`;%30bGxx^ z9Z6AX(Ven&k7oweQKdvo=H*6Ri))tR0{E%mD<4y$5f3hqL4K7`G`0n;oh8THZ(Ys= z^n-`y7O@DR%f|vV1TR@e){1R~O)%?QUxU!qU#vXNjbLjQur(u?jnYqc(?9fo*Z(Xk zmGFnQwS9AAPa{94T)#!&_bW9dk}VLyn*(KU)Ront2U-z?){+p+yKOTwR7I1ivoD^DhPIo7DtPu|AES$^;IknZbzpTF+DJ9pxg! z6-wM6J^nOVH{FG}Y&)#-PNDgVzm1-xkMp{n$CcLUN#^J5DqtU@_ktae*9PB5MChGJ zrIlfbvD~rktrvZ`ffYG$pa=M}z7Fh#NTu zE!U#Enh3{QR&eOgak<6ke8LAhD(T<4U4tNc*rP{#JKaKEMVNDI3~ju^#d{!HHKT#K zoIaf=gqNz{DR|5gkI?9P@t5xFNw?4r)8f>^qo9%kIxqQ)h{n1K9Y%@t0z#> zhVjpw`ivQo%FP_bQvcsw07&lRveV*`ubcq~kRxsFUYfxiy9uMxCeOp%qC3jK&lW7c zpNV*rs?nN2FMRsJkv;ji*MfxpuL$$pY(4_5b2EQR4XmAf)0Q&ZCKnp~(TgFi+q`#v zZZQ*+))YYtzHlmC0{hNPHIBRi$Ld|(z+W5Vz|G<*^c%s1H8anj^eeSvFkGR-!0R_$A89R8>h5`AF#l@Dk42{Y(~s(N11@ZtovOM@_}_ICfX zBsc+A#)->6z|Tv5bu@C#g1cX-6EWRQs1vtj=n_YYl8Er1aiD4 zZoHnSd`y?YCM8ShgWI{E4QA0+X5 zVG$(+Gjiz3$d;KKg_2Y=5>v9S`e4^!oN>*?=q}79lFxqR4C-VJN>F&y%QdW>msI{dPhve+;^LawceQ@L!mf2G_uSQhdm?6yq0|W#KV}O{1FoAd1wyeRpLdEu??hj8rtS%~_U<-n zeBsrrKW0@b@2R%$Jx{T|1gB?a4$35NM%iy-4+G_=j0(^4po0;YaNs8f*d6Ev6Y!C< zLz_B1mK1uGZbm%=h{PoC zxl{U4e7+siCMLVrA8LiFVnNLA^|Lj#fcnrUW0KQN-bN{g36J>V(eq=G&)ajP0@CYS zP9)RQ(}BA;srMFxL>k$eZnVM!i+x{l#${YxqRX8R*OGo62{M|Xp|unbX6-#)`<8D1 z4Q5w2yWGTA7G~KWR{2Ot-biwTv*GaTdpQq1#sA0Nd%rdHd{LvJcj+DJ0!mk^^bXQ3 zfC7mS0R^RZNU%_)3kXOP6p$jJh|~}|NJo$kp@*JOLrKD$&-cCekGRise>>02DeKJa znX~quHT!(6GbI3Tw}p-!XBdq>td#i3Zd&7f`w3gmWiH}@?Uk%p#O|#Ny-ZF(&hPrF zmE<~k;=*UgG@+L|wT23D`UZ;9B9J_KG(l`E-zq!jv%H|1L+sL*010WuYeKSfa@Fg| z!=ktXx}js%@57Mr<_3ZH*9CB8#!s(XcGa{Heh0&q@aA&tLBo&Y(wm74)HEV-06H)? zJv~>oTrvpoJI;74r|J@X#DW!Wzb@2pbn`{A2yS}xg+pUKFpdxk`3N?}6k5l1Xp|PNljGP;a<4^~tXIwM>!5!Z!q{sF6}_;y$- zsJVID1A7NIECY=@#fK2KP8(QX7Rw_GLVN!*kp%Coi=xB90}C^Rls;KUc1>K9y;P91 zEdnTUx$D|-j9yRKNZFvs7CAW!vTB@JfsHS5a=ki?A#(?qjqZ>9(n9gk*~+{eE~rI? zZ*?L1w9fxT3?0=P7Bvf8fqUCletrkxH~+)ZpC1u-ip1j?uF&S^G!)P&a8F+uROh_w zFFZvo`ZEK6gygNEeaont&))teJ^yuDc2~8)y1cA<1SY%#1$^eDkybu{P$2!Xh_5XY z4AQj;{z#gO1}&84ZYtk-g#W$P&J7TbF3X;EY&zSIs(1l6@{(uYSgx+FZr8%2w0dGR zaj8TgZO#{t0aoA1su!;`%JJ9HPb!ePpqHuMpz($&O`hEf;F~r8Da^(K{k*LTibI zaN<#N(cJfF?k9di^{B@q2}!`w4tiAW>`f2H&rI2FZ6pE30%}0=V{_Ams|W_F5kS_L z#k`Q~hAPn11z{i`b7-kPJc`@93L#vqR9!<;xUM$6RG3zi8E3Z-gCfY_mXOc5c+hNi zpOf)fqH@Z?)h5X zppQkw%_G<`KB(`;Bo{(!l%1}-L9ji=;=c zZAj}l1UAR^G6wbF&%3(-u@cv<(JT#>rLNWYHh4)!Dzrwy`MSViyukUbe8M-&{ZsEV zEnzeKIiqH6Qv`SHod+{wy{ymn7S3YiPwSD$zc zbok%K{EIn^(X7kV^z_9~B*9S=BM_0D3Dj(u?h1Lqb>)XHIxSuLHQm*y{AH<)F<*m4 z4@Z8;sZKe%PSm6wSZc&H~VIYOM0+X+f62+GuNFg6;5! ze!{2DfB73-psl0M4EFokW7|zL1JmPwbRQLXu54`-VhwDJB~B_hPHwxeOov}5sajFC zd-@aXo15fLJnqm&cDA``T~k2Ul%erR!XhKlpnp&6g?_I#N|w7IL)K4s&8U8Gg1()x zwrr&{t?SLWObFsqcc%B#Is$&aUO}`ss{h(&uci?dGg~ut{(*4mqJ_S4uCR(wi%P2_ zTGXlZ2wy#~@Qtf?ehun~t0CbA+kqWu*)cxk=Fuo^Kdlik^3}U0GL-rVrxWzL-5q*| z5QJ*y0=$|NHkl{{*46#ih0SyCC}xa{jnZjio7~PnBtvqO!`Ht#wE4QgVoqKM;dbH;K*IHk@WePl-T(%xY=c0FMTRY;J6w_Blj|%Wk=j{VBAK!U_2qyxiWU9)`3o zhXX?hUoQw@8$^uzaVs&`ndk+cOCr2A+y$Nb@gC};TfAVCwE!pbX6YVzFJgc3yH~h7 zET%?rKI(!h64cta>^y^XZtc6|`_OgyJ~B7X3nHEe3#Vub%n%(}V_a$*vltkQU?XJ9 zFA<`Z+#akTWA2_GnV*l<4K4ux*p03(5a>g*zwx55ZU(V{^^nRVtkcJW+?J^ zeK_H2Wtj^AX4Qk|`z4)b6c+*?sh%&i2E^C7f!3oqM~;T-K+D-~`5V3F`vUT2MAB{+ zj%+}m$|hNn6*{|=hiQy0Q z1lKPuhZ=(CJ$hCACJ*da(CeSI!mGMaqp;{FR|B-yt+8-i*8)K*rV{fa>f2Vukyj+n z)DqNl-KHu>sr~79bSQjej@Bac@(JbEX{eNpjPLdldzC}eKt_dalkdhIP8o;ws5-YM z$khmt@lac2N)FG8)_c}vj@6L(YbB8Cj@opBI%grNW;vNFN2@i1aZ_q6Xp>fvj?~NC z&!Z+umaA=^Ej44ZQj7Nb zqo97j{@mn5$om;ihL!1*Yi|`f%C|S0NxmKvu&kiVBR?DtaTOJF2Cb;5P(k${=k^re z`uzE`-Lq#U6XRb};4g43Z5iujw)>}Yr1Ko7Zs}g>>yQ=0i7W zKkAn2Y9*I?8!V2GzO#izNbqryJ)fa?yI7x52(%25;KWTNr zllrk<*`Lu;jRH@?aZN5Z_p84AwcWf_dzyzDr4^E;tu4=!tx5E{*46yFs%3T~lH_@1 zPu5D6!}#t_k=DmrFJq~D5yvpyySM<&z|N8kT(9lbVAIuxI)PB6DYwpru2!k^lW!1c zB}}@BYVUmLFJ%)Q89#fYaZWY)w&;~g%%4Z! zhPb36ZGaIg?JE^g;lGbAb$YIKRGEmjM2`itg(P9WT-&DOJG()bN_(uO0tY#s9IG*j zOKl2tm*E@xbo7spIf>y%cF~8g>5s1}&(0w_gKm5y(--BZbn5?FA|d4Kp3-&UxQiPF z5ia%a$d-KOXIR(5R7+WvdoP zwfa;`S56BIV}}l`XBvZ+cw>qO^OH3ix{pGE(n}MAQJx$huR;wRek;=vtw1*_V*k{w zunx4h0TQzYNT1B#x)Dtxw@%5qgO?o86f()W%!!be@@j}z(8FbrSVf;ly^*{~KJD?I5>4w;oEo}Sa<2#MC5Ci|z zH11ntKAh``k8Zo<0}sIBT9)}OjiCZQ?#NzE!q=O4eoCrN07fqfK(AKxRbl`ye@ccl zIhy&i*mUkHL@>`e)we`cu3{;fxnIt#IVWjtp5?_KW8GubE@MWf87ZEE`y4- z-!RWJZ7m6l^DhY4olml_=oHXIuJsI7)RZG__rrF*5#MYl4WJ(ats?2~bQe($wO=tep#c8fzNmIm$TWm;n z9%s#4f<3hk^CRE?9yC7uZjdbRqdp7o5O;DLH0{JWmV~U2=k0=q4E$e=98rHF~Io>7epLqWA`EL-eq; zmM(N=Y`ctvS$^S=m5bF|=iPLJQm|9qA6hY1B z#THxQ`wNcV4X=BSB4K8J_Vcp^#p84hXCmiHRP{M&8%4U4xVx_jq}7aa&1%mX~VdvRzkBBT&a7@ed)8+tpciW1RZPOQzL^^rbfOLj$QT zzh4d(t9&u>>`PMa z5yij`K|T7iLH3C5Dm~^8i9A4XHz39xkYH(E=YQ2ZR#hr7u$UU>QxdTXQKLh>xo4@R z{yKjG%?fcJ&+MJlSYt3@gGd%*Vq7(745$xVTJHin`3Si_Ak5qWd;Sg(^%5G^AB0-* zaT!0N8vW^eXc*%u=6{bEn0!K30Oao^Xi$ZSz0eewjck2Bu>(a@AB(rrUPD)jBzY}u zLa$|Mrdz?}%O|(lv8pnOfipAw7}oj5=o?787t<)-;-zJgm4|_z zQ>%tc3cz%oTbJ(pVy@N8)YK-A^w0;0{|a z@}+1a8s16;tw)vGvavO#sOhb!hYVrW3Mi71jM3OG&7TKI0ph1a5PcdQs==I023_ZIgR{CNY7>KOI;F%?K*Cl1+Jw^Zt<>PKIohn4N?g^wE#!8HK$tI&_w6} zwP8#jg*na@k8x0NzQE)z5=KPwwT>5Am!t9N5c~F?8`#|YRf>DIj@h< z^iQ?d(5BUVLHxv7`A9em-IeYZ;`U`(kX=DfuSN&J7*6*OCj;(<9nOtB89Cv|lI6#< z>v4Y8uq==cUK#j`QP#!RK)enHibHov5uM*c2u4|CV8BW9%_Aw^o$|NNQht3FzH0+f zg^rJ`(-Y0E`>|lR63Jq&y%xl)m&h=EXH%d+$u2iKwQ)7S@qVjUD@`GOYdfYaY(5@p z_r3J_ozY-(G!Jas5oH`GKFO-SDpk3T8#h%Q1?@eifaLMjN=4#Zq8Ojxn?#Z`@CMAZ zIESapC<=6~3Ji3NOBSZ;1rT>8IyzsDtrPoYoYMg;y8G(0mM zVIE!x!l3!*GPe%h{yD4ns#XRkh=<~eQ6L$3PG3ImVzUTM*9++d!*g1H2W3pa?Q782 z?d<|Oo|U2bn)4eMrg@@8#06u z8z_$G!}#0SMtcGPd>W_ZnIKKmR1cl6MGK6){*Rn3pst_F1PDOUXmniD<(*%Mj=|FM z1?A!JwjetnE?Mg>N8D}au%+eGOw)a_HtORO-h4u%q{;+*prV$-!ZpY^{IJ?TxHC_(m91QfNoM1n!AQK?~dYyN}mU6mbykRtyHCnYeYa z)s#~ud>VLi+fl^#MG|%ws@J#9UH-M{7Z+TPqs8;G6XN$2d@q(nmW25esK}_f4Y%m_ z?PYyN+WH!O|16Gu&{C(=6FjZ)VKk6fKUOUp%<`tNd@&u;8nl)QTNdaRYIWW`U+;r? zpH7A5B0!txT?66p_tjRGw{gSycngVvd_0k7Pm>}(bQ2xUT)#>g>AQ+8!wN-LFJ6a# z0+;5$SUFOyCt-7Wn@0=v+gX}XZc99bh0*E0JFKBAsNkGqKC?{=*Ak@i1zF~LgHP0Emgxc!zUzyeTxN@~x)@=7_G) zwsEX#+WN_v8mv?64xb9y*XgIFb+4kx5x$#DvE(A{O^zXq2D6s0Z=F@wFPnxq zu!VnMoY&MzkUQ=|-+Ge_4(IrC7)(?WK6jXn(USTNmT;C_THfGA8-w))e=Rd);5SIs zX(-B$`34U#FMhX!FU&WOKiGGNsb#O4?oX&I2Y#yhwH-H>mY)Cpg>%1X2OKq8cNuYg z7cP}NapArK(;#$+Y2Lw6<&n7o08)#<8AUR{bKlU|K>Z8`GdexJa{tKUonOl0qeqgX#dhPUJOs?4NS%uF)r&&r4 zikFroK)UKjzInkoFO6xyf;<+V1a?fSNbD_&f8;Q~c41aewz; z#oJ9>ccrqwpP1<$L@n6)*02F0Rum1#A%KqEcZINlFvmCVDK=qSQ=-q z?@E9KuF9xcEJXb0WD47^{JQMSOhW0}p8Z=c&}Cv@<1JlGp>i|h{W zMPAgKwe65k1-W;+8qVS1IMZCJ@|NihI#vtIXdt4NldZ#ft+H&**Je?-f z>sNf9A}8%7s#hIyZTgImdo_$s(6S;D4!PcFOdEMegIJpwzehWlp0nusC4=3Xd~Zrg z=THz-M0%o&qkB_5gt&`;Xk9XQh*Nd3<0no!M|Cr+7>i) z#(thloE2Bo+0e%^^ojje0Q@*BR??^LMP5EJ;7fcex0rKR;qC!K!IKFJeW z{9}}?7Wj#CPCU2@&s8B-EKbvu#dToWPws1?cMB0I7qOyOk6^Vtc+-!@MqSdWYf?vq zONSd7$MS^=T|DwCIZbL9zv!|gxr6&Q@J<IStgxAxHmXD*BK0OUD$`gdu8Klm#o-U&e| z8h12!x|ZJZP!UFIx-U55NIO?e>Lv3gEpBGOpBL4YU{Dk)(h7)ae$2j!doHjDk-ss|J zX3fFG^^f@jTb8?#3y}*@CY(a7CQB=Xwfur^g8JBKT(*0;nEp5h7J!JuC_~=;&#qIVbVkOy7-{{u5-hsd7 z%^vaNyPU5A8UJ4`K#>xuncZR~@_TH=&l`N2Qr%88y>FB0{HRqjm%pXH_2MF*G|=pQ zzr6EOm)j?WB&rML2ViqxCoXFERvAP%|WuIWn>%8?D`k!`u2;+nyjFg$>FF0b;(cV*IUZ67$BAPD zA{beg=xircfqZJcvnQgvO0?=5xp>j&(+{Wu@Smavfk>^#$7(ey;C}fPFaD1BZ%oc6 zE~mV;#h5T^gxS4EX83+=&50~we`$fuWt5wS4DfaS>Dp$!DWB@$j8#=b@ww?6a=V%u z8d}k*@q8X|=%uKp*1+YC@UCQhtZX@$9F%8Yd<3zZ>}uKSQY*xaQ+wY)eXTA`jR{&& zN2RI2#d&ByrN#EORNAo08dq{l2y{`g?37JH{{D*~3*@@@n*TS5Av%ozpIGM&{3<^X z30%&Z$B%!z#QAzcLd=*#LG}L_`0=i~jP#M1MB@a?3@4mj6DahIWu&eboS2x*A|O6cxUCrA!p*4rq6A8ELe`5l#FJR`NeH+l!I>q?3bmFWHz-Ezs+@d~fx!`5fW980&qF@8kEMMT@Yr+UI+{M4sVc#c}hJS$&4K?9m~ za7jBmclW8+&j(Zrvgo4VbbM2u?E5V>iPj!9%avOv2Z8yWs(dc#+8CKg@(EFDlb0aA zwr%+6yN9@M?0*&Lc@)W4KE$AMc&X?2;|2OIu9Qhz5kZAZH)j3ou2al`Tf0J9f_wAP z$L5d+1?_ak7jAZVnT+x5xg1WO6+-R&l9;Z5?xp6)u-E3nnnSqW$=2GpU>4TRE#6nL z@Y~4@#Gw{-XwzACp(MT;Ma&)7^@wKeRU00{uM_I5l9|6M3GQq9G?d26TC&qaOIQ$n z!GAaB6S&Lrfmg@(gcX{*ZB6fo+f|ET73kl+FN$4k%}u--kkGN~Q#04lL`Ql-8J(U4 zJPM;It`K~mDICl=+!P}m3)`^#@r~*hZ!Y{{|K%C7AR)7G->F)s+f*qbf>kzRIk%)5 zyRo&_BTCySiyG4l01dpf3^Ajg&AbYCk>aXZi5Rfj*fytM6DbZ7fJdah4DYRmutDPH zO%(Vm%eu}x=BYe1Y(yiE?4#5Cq%DS?CJ8)HQI|1Gz99K#+=CDe%8#EFmE~9MlKi>m zU%f82j=5*nzmfUqJfZld%~2*hZhU@}w+TNtkL9hnfGCV9(;X{I54Svbb;}AzVyln* z5OO%)Z+jkwBLF~S0G?Jjdb_C$3rW1sxXTOL)WLn27ErGc)hy{vwG*mOF%ri{Gfdj3*<=t$(X%^%35qMrT_fIoVf-o{E=T3Jtu?DX7>S}lF)%`3iG{Z|8$qhvfwQO1z)kmOc+mHvmr&!|iH z_UOu`0@$`~x31)gY5fe>%4Ttm!}>RWl<(y9WfYVV;MA~;!ca^6)x%2$v4u2p#d`46Ed*_>Mddq`jx(O8>NSYLwYGtrw%|vjhBb=4Eqqb#-{Q zQsqR*lRGm9S)#W`$HF#EyP7oyMB0ss^h+7$NcwJD59>-!o~#rMenQ^fU2K~8leR)P zX>#Ij(qwe_t3j2&`Y(A$bCTU!*UUy9hkfG=c)4)g%@>ZZ5VzYO_9nQFG)3{tI_{7E zIg-G!L?Kg+zI~Nocz5T)vfl7pNn;kqo;yo)!W^=cK0d+?vIsLXYDwd!0_fUTGgcLl zm#rdsTfP@@8&!F%b!U;(lX9WjS1*0zWn~KmYp|GzUrxxkhhuDE1@!z2a)6!f>?iNU ziCHJw3KUv{%Aci3;7Elz5{yz*=tPyK;-UeahN+x1OA*^0;PQ_+tNv-~ZKFbEN-0W@ zuKLbhAJ4Y>Al2_}=tPUI8ZfQ9wXIvX4U-mM%mlJr+&w zK^9^meT1r6sa7AW+U2fC2KJ<3$X1Zj#f#s(Z%X-bnMn*8L`uxxA&*HwhL>+b? zqCm)v0HdJaAD3T9q7ubd62W?hZ?O6!6&!IdAQYI_p=G7fy6u1uMZ<_VQ<_ zw$y`(f?Ht+dsXn5+6b-?Vg%t$NNNS@UQ!;EU8!kMdUo=vly>o%yq%Az(nmyqJgF7t zw|M*&$Mvvr;>u(tYO)fem@Llb`DNb28!}s6J-N2C|30cXK?#82c`#c4Go8_mBT%>+0a6Z!+7BDk&OS z$Kj9z-{g%`nWwievrAEe$wdXHacv@!6P`CdT1KS)@d~Fpnt7F5<21YIsw2QJuKRS% zmZytxVjjsNLeyb^Rz|K*k}>c+gWDNKoQeS9#Z$$q)+16Q3(bz1?)6Qo4B+#NUq=K8 zoXFD%>0or5)nucZo|!(v25VcLS*lhSt(- z9;@&|i%~-sBM$#n}5KpqsmidnXFY3_kjp-tbH`J32#kBu2qn+TR zy_krX+4eltQ(VtXIv3Ji0{?oyEb8&+brp{+qsj7YlGcaD=M*onYREf$(rZ15qR2oMYQfqTjnOUmg>3T7iCt<0>WV|b@caXe(wJu51T&B(+ z-JAc}Ct;0;-oDi!uVhX#dzNIz(YRYs4l*uD@#jH)(S8s#e)UiS%_COnl0J<`(p@K6 z;M$D8Oa3wtjg^PoGNysKbld8%?|n^wzc_7wu&jeZv!yuxogFRz@uEj#(cuX;YdVlwC6W(N_7(VF>wM0==t_9$87Z%+?!+i3tPr+2L<1nNLZR^Q0y ztXKrnJNF%K(~C+QyM0xp`NNi_^s9s^O-cRIN|{Oh`r%McJt?JkV!F0sb`^g-z5lK6 z$TUvDfSa!3X4lzd$TVTzhTSYZ`n5-l#T*cOUP^g-0U)nkosPC>F#0FCSRYG?_?+4A zsJPEgMHFjq1|QGMj~^v8JnVhe)9&Bw`&eGK&~H2FW^#2(f=^jMZsuRsg@(0QdEX zmlNJL!tFnkU~vb${vgRmq-?Gshe}3C1+K5d!{^s3m}4OsH)mzXnGpZ|v;TZmRnP(qvky|6Z+%wp^7Gcv?`Tn)VIShC zkOvySPO8xz)LoVCJu`N`5B3<)O4OWKl_{{Am2749>=(`aD?-fp-n8^Zq^sK^jD|bK zlm)d+ry5;98vZULUU}{DGu!w!NnWpis`!?;Hu2wmKU}LLkr^4F%K+Ew6MLq4Id^Q$ z7USIu4W!{&-{>b$Ywfh2bs&i%AW$1tHmAA60`z&KC^D5Q}_eLM_eZc`A5s^04vh?xSJmann z!+#Q;aiTzA{9-tH@6;+?=o@9b#_GrS_H?#(&G7a@;$X0&Bvz8E7+2?YyOx}YQf z(NBfauu4mxKO~BX<6SyNd{y&S9(v?cs2RD62A_W-M=w3tD=wG>YAVrc6f#?WyUJ&^ zM9)bd;5q~zvGJoLNd+{Z$=DG3L#?bWWuG99ZQLeWp0J3X{dZNLV+OqjeaB{xu2sqW z4^R;ikQJdOUON@F&;*UDS5wZ`3Qcdim~F(#sEGK)0XaWkNG;=Tt}rnErmZRBdr3>L zoJLdi{n)`|voUvM;+_( zk72(>GrwZR(To>L>sy1)8b!-S$(o$;T@Q^N3_0Uj1@v%2ztF1>o1JR%ycxXA{w2b$ z_WUZ zL@n?@iWS*1@JA%5{i-q6$c{#K^qwcOvG?*X!;RtLyINZDiqfx{F98nrchw{AZJeq( zkQ-ay;30mREjJs#nE9z@qYUpGjZ#yHMPlUFPf&**9m!4M?~MxL8Stmfv|yj57|kn| ztG&LSz}Cp6OAGRk{PD!>qc5tqh$2Ncyxs2E*CMU{%NRn+AvT z!-}n+4Px4`9n_e&$~Vw@NC;zP1ZK@&R{AynoUFmM%mt20Sk?}oaXoN?yj*btT@*U? z_GJ-ju(juwO9&ARF`tNA=V!EOAHP^caoerW&n zwCTp@PBarO&A$IlG1>^~-LO=NBhr!|HZx@L${i9CpKYFA{A0A)efXMVPGDw%y}96= zwHJY3B+9Fh8$oj#e__yj`$4T42Yp&_B2s=P)zkBe`l2;{3AH~N=Fowj=dT^66io!> z$7)lanMy_lI&+zRUA${%`CCO@d87q7wmK|M_uBzwV10JG>3HGkhbWi9PUqfYd)4X8 z`|DB3gI09e$Bmt7Y9Q>e>{m>Z6yq@}p41oJIoVQsktEJB7TW3;aGa+ii^%J8BP)sC{3R0^YqzW%h?5cIF$g@3Mwz=c7zN7Q>;wCM_V z+2LjNTI|n2({XI^mNJD_@}rZ1i}S>$i&R$`5CzCOFuDYA@L|*4fB3Ps9a5tTFQeE4%Nc%nlTw`i29b_MNHWHI@bR_%{UeR?H!gjvEo`K5mqwvwSh0hpHH*qbMe(^v% z_iL#OP;Wo79G2{BEnD_tnPP-Ve)LtI1T^Ae2UoT$>6`_7Q^+beyu%!^l18A>A`V4g<4#~>vu)e)cneZA7fsc5p&6@{Lj%3{ z9R%fI4>#bx|An;ClJXJBa43Mf$wX3hL|jEcm~7FOs`}Qq-J^=}bMm-G+U@#m}AO z!A@mgGf_{d+az>zc7(AiXuTh8Otk1bPC>-D8KZ}ChBG>^6r<^x*_YmQmBQlP%>?B> zfJLhF8J%D%o}3JTbBdPwS8T7ws@sb8DBS_-0CSOcGQ-Y~XCnq-+T>u`*18qb8QLjg z%7bq9bptv2S}u9xqnBLNGv5Uj-`D4NN3Q)PXJP)VrZIx-H-4?+{%4i>R992#yFxgv2+g79=J_+Cw&S9teY>{OAhMG&=@ROY^(+*iBHBsa) zDcGB0Ont#Cl41RvUbMH@$T*>oix-%da#y0^XMK&9j$^Khs;jEf{A#~{CyvdmK5AP& z%U$hRTr_=PyoKk}QNCSAyM6Y`Kn+t5Cr$jSduGqSgh~m&^?J^O+XihvY^o%?j)%c{ zvgPVL&zL0w8xpx|hfJ3C$V-lI@lerS*NY|tX3vUlDJkHT$&EJkoKb*Yx?SQ@{)4Nlz~X+N{4mGX)&jzVwGzaRIMg6(V3AK9>#!@ z9B3m>szMXzGL@ZQSvZ+KHP)RZ@fD{Mlcm2sR-w7R^^mrXB<+J8=U>XFOri2()Gcon zWxjKX8nV!5y<{`a1~SS0R+2E|z9rEBuYPftiUwi)3KR$XzuEks3vPCVw8cCKd zvR>*}?he>v`4Fw$ zWO+wVaPJUM^KvXskQ}V79vf_y8kzpSqLk~4x;}L|ioa6cD*BQHOsoIh{n6_IO^OZG zUU14CM{NcEgtBDChrG0>ZSSLQ$+3J^;z09mCq9>oos>?~yVNuOIp)bdS2kuLU1nmu z=HGkxnE^YXt;R6_Sx(YfoGq%&6h=$9%PJ}6{F_=cQj?_l5`;YA5VKXLEwu&n;8O_N((WZl#x z){#Ge@#F9Q3a0!O1W=!nWi0c^g~XC7B$9%q^a~l!-gRZAq%uUsRtfFBkA0pf$dX9s zF7h?>I{0UKRY#;eugKfous0a_g-CBlW_!p;V+%ToVdVMDYePMx`l04VYFij6E;l)k zdl0n|q8W@bmuF0>pX}8+=|431Ch1ri=w>xDh^^>4(RmHl;SV@RG#hd+!8W_DG%c4* z{a)8A3nY9p+{!O8Vq8L{hXf;WL;WSXjCamH-o5jK1jBS_uTDexP9IkAAou$cagj#b zj60Stzm+XuPH^bH1RNtwTL(Lfx+IP7_;5Q4!FDA~{p&V114ZquE?koTk13I=JI3eY z{)4exnw3H9>mP9LSf=h_Osmidq-aUycQfShlmVq6EHey-q-+j+kTuZC70<8*0ZSJ= zX+!I7yK-amFU}vO_5A}SIX|XKM&M2KUXC!R_|sRIqo*_`hZ=G* z%ApG)G?&dt4!rzI+0I|<;tzPg+#|bn7i>jTLllWuC9y1<>UD^G64Z21IT5E0p#W*3M0Vog}t zc@8;%lKu{8Kdr+ma4kg>UGR|HQSQc<@wdI#MzmY1Jk-rn*3^lUO{6f zf560*7j<}Ddlg@dHZu2e)2$2&deHT?$ubIPo&Ls(?0G@;N_>!BBmna;?)AkjM47c9 z>F^}UMvcbdv zJmg)4Cr74fhO2^iLOyMFwcnf-BdtFC9KzEObr;Sur*Mj}i5lkZd=@(VB%JvBC+)T* zas_?(jT+@8@o3alH=H>dZFO;t>3FP<`a68!bzQb`vv>}gZfU#}}yDc~CM%I8| zxq{)EUffAC?Edefe2wK{Lo)44ck;#FFiJ=fgHDQa>r{B9?q)#cLd*STt9-O0B(XgB zMqDRQvOcr*ej5vx0WJBj=gvDXC|6m7a;$p51Mt1a>+A-zw2A<60f2ccwO!(RM%{2zFhxznf)@32 z9)@u&9$SXa+H%4sHjbLg{!x}ERboDK5Bt`bsdsSoe4ZM&sD9DX`d#8a==TQf!Nl(%G4#l%{hKXbN45#w^cpje^D4?a z5{-4BD%~D*&2J_q-oA;9y?Y{XpHJ!Zglg^D9$8^d5z=nYn8Qo8c(TyYI^~KkmH2qX zWKH(Gd&cJFZ$+lBWtbdQ6xj`Qy9e39k*hPJu1!k~JfEv`;U5+c?m{6&g{xT~FAy6t z`3K3@6Cc}}ITQV`iBAJqlp=MuDqvf9$Z)qfXlA1D|6W#oPWqOF=^VF|^dC&(eLtwv5%rkCih%#U6*&`) z1%kwzrIO@-)i;!YJj`#qv2B1CVfTgqR}`27G(D2lQvk>STN@8n>cZ5-{%<+^Xz6I4 z+BAvr0EAL=W#vtn@*QG@%cNW{W-_}^C16!zxyyH77SyfUFs-MS#dUq?UN?99)Z)LZ zL%9B*9{Lz4S-j}(vl;h@SWpA!pX%tDPo&fyJx|^re)%6>>s!HuAY>^$1?U7PJ}90+ zQtf@P@I-xde>m&E>hW%`g*D}aN70=qm}6MY4$k8XEEYrj&4D_xMFxKO{-2(%J)Ft* zk3Tl1$YH5Si0LG6MkPO!6qCHvThfVo(UOv5<{TS2j79ZQ>di4NNk)_zW0O-cn^a~_ z6KTW`Lt`0xpUv<6J%2sd^?APE`}6sHzxRD#*KQ=Fyql5Y2_C^NQ<@p0R;@-!qDGqh8i^hg!saNJdGe z*`Er__>xj(c}qCS4WJexAwl4cNiHvXal}Ptrl!dAwAhOQ0P2SH*_oC?=`d|}LpCZ? zJ)XcT$~QWYle|}FHPe$5MgFmvAU+H_)XlAQ(x)#4wSbE{7!ZJX7bx#P?Q>fWz%S5g z6Na+nqXy;F=YhU}Ruo;SpZi51t8PW#Z@*}@24XdLEg(3Q;n6!_CT+UZirV+M>RY1O z-m7JPxe(yaoWyql_P^|gOBCeBF*Vbel`4S*6`QmER&qN>I_WyCON<*L$a0e zd>}zu4ts9MSz?nU&&oQY6~-_wquo^aw!C2R|L&#JR%Y8dVNr|eDgYi`ktZL}KbOB6 zXnl~=>LxCXwrn13Zy*whIpbXFnIZ7}Ezec5+IkWSk~TZLb81wFdUxSe;cK6q}!mk9m+t_U%6%q-D(19Tt^kA?xRL)o!LNosi4? zw$^6dWVHY<3sq~feOOIj$=8hZgz95;_u@v5l&!Y!B>_S5Ix&P#J~Wm%RSV1iFxypH z#$@WC65n%FN1|kU{gT9!qsqu-qSwXoI7482WR)-+k5rjhMdt;t1OrRtUt6zSgE8LT z;G9k4(-k%)haQ(r1fUv26O)LVNV3{Qyxl$kTxM>KE6m_jW*TW^DYrYOgVCaoL`{!_ zR!pKQNyZ=tWWahG7p}4--ThPd-drOYYD>%-v=O&YsR}=jl!@3w^pGsF%s558ILWQ? zcTPOTYA=uI7e;tkqF`Vl{$QOG=bY4-+bjuhuugpROtP1bq~0gVMg2%I0$ZNhRjqC= z2*_;Ol7b+l$e&B{+;0}_b$MNilg5CSrpf+O8c6g&#M<9x$jZ|#?yk;+zph*P7wy}i zv>~ahiFD;x^21r+KL_-5d(n{gt$!H<-{wSLBbeP#UB?b} zIQ;SRnzrj693Iam^E19l31H+}b7NlHPsBkTS#S_O3Om+JN%MGeS@ zhG#x01j1)(qv{PMYJZud+V3;~nEyE%i>P?ZxbMr#jxJ#9Y?h63hL? z(Lj0$mE^yXWyKJy&)K8VzD)+q?oCms`d7jseFebNu|2iDV>xCh`{ZUc8Vx~dMbdWU zv12qYN1V9}>@XH$#p>=@>wC%%j-b)ok5tE9&I{V{vD^Yu7o*8Qi=90A_{W+$di0*gSw6cyV^jf#{G z_i|JJ5gdG-Fd&{lI0n%X3v2^9P?F2_{aCFR4fanibXRp_{)DoWazOG|lyt%<9nH5| zg`G!slu#kSy$^3L4Z-{H4+<&?1cF%2%8;TWy5(>Hdyig{Vu+_3KTw_WFqPcN${BNJ zlAj(WrV1y|f3NQH7j|KQ(!ZwGV^#s+RDlAey-B1=yatboCQ)@2eKlU~b znC;1fn5>nmy{bP_v@2b&jbE9UkbV>OqY4lkw7q#%-G{-sv_cRF7u|YZhaOm>Lr_=S zBFjsmq4G0G8g^`meOy_Sb~0{54~g-rvj7~(P5WKs%hwGDgx^s%3J0rA$lzm`>6Q99%V*CRaKQjqxXR)l?v|@ScV8hD1+5leP5|5a$#?*skBK_J$-y18F0Q95RmKS%Zi!--+Ud$j5&jbY;1&0 zd#%(oW2|OH;-Im^2FxCaDfgXFD5`YWJ<;I@E!TdeiOL`loQn+^4*v;EOK0|)na;Qy zVe>m(ft9aG*bpbSc@uc>p2}F87dXwXL=;&CYH-u3Dj)g#zFw5LUVELxh~$@^&-`20 zLnX|T70TF+y{M0HxzWa8SL3e3Ju>gw=Lr|WCslM`J}d^ZpLTb5w_t*ONmP&Fp!;bO zqUZMVZP)|w`;QxxhH7=fj`kq+xGb3Qbld1n!-9-28XNIO71;4E%h4CXk*IWc%-rbF z&yy{P{wMey`5$I1(>r{`O5ZPxU4p{z8#1&`nu$$&P9;Mbu}m0~%a8E*_HB3Z?;O8M dlRdwR1y;Ds+HBXq6A1u#Ioh8 + + + + + + +Px + + + + + + + diff --git a/src/ui/assets/logo_nobg_1.svg b/src/ui/assets/logo_nobg_1.svg new file mode 100644 index 0000000..c618163 --- /dev/null +++ b/src/ui/assets/logo_nobg_1.svg @@ -0,0 +1,14 @@ + + + + + + + +PxModRim + + + + + + diff --git a/src/ui/assets/logo_nobg_2.svg b/src/ui/assets/logo_nobg_2.svg new file mode 100644 index 0000000..5b099d5 --- /dev/null +++ b/src/ui/assets/logo_nobg_2.svg @@ -0,0 +1,15 @@ + + + + + + + +Px +ModRim + + + + + + diff --git a/src/ui/assets/logo_nobg_3.svg b/src/ui/assets/logo_nobg_3.svg new file mode 100644 index 0000000..e7e3f79 --- /dev/null +++ b/src/ui/assets/logo_nobg_3.svg @@ -0,0 +1,14 @@ + + + + + +PxModRim + + + + + + + + diff --git a/src/ui/assets/logo_nobg_4.svg b/src/ui/assets/logo_nobg_4.svg new file mode 100644 index 0000000..3d7fcb4 --- /dev/null +++ b/src/ui/assets/logo_nobg_4.svg @@ -0,0 +1,15 @@ + + + + + + + +Px +ModRim + + + + + + diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp new file mode 100644 index 0000000..511a0cf --- /dev/null +++ b/src/ui/components/banner_widget.cpp @@ -0,0 +1,135 @@ +#include "ui/components/banner_widget.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +constexpr int kFallbackWidth = 300; +constexpr int kOverlayPad = 16; + +// Floor division by two, so a negative offset rounds the way the legacy `//` did. +int floorHalf(int value) { + return value >= 0 ? value / 2 : -((-value + 1) / 2); +} + +} // namespace + +AspectRatioBanner::AspectRatioBanner(QWidget* parent, int maxHeight) + : QWidget(parent), max_height_(maxHeight) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void AspectRatioBanner::setPixmap(const QPixmap& pixmap) { + pixmap_ = pixmap; + recomputeScaledPixmap(); + updateGeometry(); + update(); +} + +void AspectRatioBanner::setTitle(const QString& title) { + title_ = title; + update(); +} + +void AspectRatioBanner::setSubtitle(const QString& subtitle) { + subtitle_ = subtitle; + update(); +} + +void AspectRatioBanner::setShowOverlay(bool show) { + show_overlay_ = show; + update(); +} + +bool AspectRatioBanner::hasHeightForWidth() const { + return true; +} + +int AspectRatioBanner::heightForWidth(int w) const { + return std::min(w * BANNER_ASPECT_H / BANNER_ASPECT_W, max_height_); +} + +QSize AspectRatioBanner::sizeHint() const { + const int w = width() > 0 ? width() : kFallbackWidth; + return QSize(w, heightForWidth(w)); +} + +void AspectRatioBanner::recomputeScaledPixmap() { + if (pixmap_.isNull() || width() <= 0) { + scaled_pixmap_ = QPixmap(); + return; + } + + scaled_pixmap_ = pixmap_.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); +} + +void AspectRatioBanner::paintEvent(QPaintEvent*) { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + + if (scaled_pixmap_.isNull()) { + painter.fillRect(rect(), panelBgQ()); + } else { + const int x = floorHalf(width() - scaled_pixmap_.width()); + const int y = floorHalf(height() - scaled_pixmap_.height()); + painter.drawPixmap(x, y, scaled_pixmap_); + } + + if (title_.isEmpty() || !show_overlay_) { + return; + } + + const int overlay_top = height() - BANNER_OVERLAY_HEIGHT; + QLinearGradient gradient(0, overlay_top, 0, height()); + gradient.setColorAt(0.0, QColor(0, 0, 0, 0)); + gradient.setColorAt(1.0, QColor(0, 0, 0, 180)); + painter.fillRect(0, overlay_top, width(), BANNER_OVERLAY_HEIGHT, gradient); + + const int text_area_top = overlay_top + 12; + const int text_area_w = width() - kOverlayPad * 2; + + QFont font = painter.font(); + font.setPointSize(18); + font.setBold(true); + painter.setFont(font); + painter.setPen(textMainQ()); + + int title_h = BANNER_OVERLAY_HEIGHT - 24; + if (!subtitle_.isEmpty()) { + title_h = title_h * 3 / 5; + } + painter.drawText(kOverlayPad, text_area_top, text_area_w, title_h, + Qt::AlignLeft | Qt::AlignBottom, title_); + + if (!subtitle_.isEmpty()) { + font.setPointSize(12); + font.setBold(false); + painter.setFont(font); + painter.setPen(textMutedQ()); + const int subtitle_top = text_area_top + title_h + 2; + const int subtitle_h = BANNER_OVERLAY_HEIGHT - title_h - 26; + painter.drawText(kOverlayPad, subtitle_top, text_area_w, subtitle_h, + Qt::AlignLeft | Qt::AlignTop, subtitle_); + } +} + +void AspectRatioBanner::resizeEvent(QResizeEvent* event) { + recomputeScaledPixmap(); + QWidget::resizeEvent(event); +} + +} // namespace px::ui diff --git a/src/ui/components/banner_widget.h b/src/ui/components/banner_widget.h new file mode 100644 index 0000000..834387a --- /dev/null +++ b/src/ui/components/banner_widget.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include "ui/theme/constants.h" + +class QPaintEvent; +class QResizeEvent; + +namespace px::ui { + +// Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it, with an optional +// title and subtitle over a gradient at the bottom. +class AspectRatioBanner : public QWidget { + Q_OBJECT + +public: + explicit AspectRatioBanner(QWidget* parent = nullptr, int maxHeight = BANNER_MAX_HEIGHT); + + void setPixmap(const QPixmap& pixmap); + void setTitle(const QString& title); + void setSubtitle(const QString& subtitle); + void setShowOverlay(bool show); + + bool hasHeightForWidth() const override; + int heightForWidth(int width) const override; + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + +private: + void recomputeScaledPixmap(); + + QPixmap pixmap_; + QPixmap scaled_pixmap_; + QString title_; + QString subtitle_; + int max_height_; + bool show_overlay_ = true; +}; + +} // namespace px::ui diff --git a/src/ui/components/button.cpp b/src/ui/components/button.cpp new file mode 100644 index 0000000..7bc5180 --- /dev/null +++ b/src/ui/components/button.cpp @@ -0,0 +1,12 @@ +#include "ui/components/button.h" + +#include + +namespace px::ui { + +AppButton::AppButton(const QString& text, QWidget* parent) : QPushButton(text, parent) { + setObjectName(QStringLiteral("appBtn")); + setCursor(Qt::PointingHandCursor); +} + +} // namespace px::ui diff --git a/src/ui/components/button.h b/src/ui/components/button.h new file mode 100644 index 0000000..6c4e88c --- /dev/null +++ b/src/ui/components/button.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +class QWidget; + +namespace px::ui { + +// Push button in the application's standard look (QSS `appBtn`) with a pointing-hand cursor. +class AppButton : public QPushButton { +public: + explicit AppButton(const QString& text = {}, QWidget* parent = nullptr); +}; + +} // namespace px::ui diff --git a/src/ui/components/components.h b/src/ui/components/components.h new file mode 100644 index 0000000..63b0087 --- /dev/null +++ b/src/ui/components/components.h @@ -0,0 +1,13 @@ +#pragma once + +#include "ui/components/banner_widget.h" +#include "ui/components/button.h" +#include "ui/components/header_controller.h" +#include "ui/components/header_panel.h" +#include "ui/components/icon_button.h" +#include "ui/components/icons.h" +#include "ui/components/procedural_preview.h" +#include "ui/components/progress_dialog.h" +#include "ui/components/svg_provider.h" +#include "ui/components/toast.h" +#include "ui/components/view_rail_panel.h" diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp new file mode 100644 index 0000000..c139d3c --- /dev/null +++ b/src/ui/components/dialog_chrome.cpp @@ -0,0 +1,179 @@ +#include "ui/components/dialog_chrome.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/icons.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +constexpr int kTitleBarHeight = 36; +constexpr int kBorder = 1; +constexpr int kGripSize = 16; +constexpr int kCloseSize = 26; + +// Every icon name used here is in the icon table; a miss is a programming error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +// Soft-red close button that fills solid on hover, like the main window's. +class CloseButton : public QPushButton { +public: + explicit CloseButton(QWidget* parent) : QPushButton(parent) { + setObjectName(QStringLiteral("dialogCloseBtn")); + setFixedSize(kCloseSize, kCloseSize); + // A global QPushButton min-height would otherwise beat setFixedSize. + constexpr int inner = kCloseSize - 2; // the 1px QSS border sits outside the content box + setStyleSheet(QStringLiteral("min-width: %1px; max-width: %1px; min-height: %1px; max-height: %1px;") + .arg(inner)); + setToolTip(QStringLiteral("Close")); + setCursor(Qt::PointingHandCursor); + setFocusPolicy(Qt::NoFocus); + normal_icon_ = requireIcon(u"close", 12, paletteValue(u"DANGER")); + hover_icon_ = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); + setIcon(normal_icon_); + } + +protected: + void enterEvent(QEnterEvent* event) override { + setIcon(hover_icon_); + QPushButton::enterEvent(event); + } + + void leaveEvent(QEvent* event) override { + setIcon(normal_icon_); + QPushButton::leaveEvent(event); + } + +private: + QIcon normal_icon_; + QIcon hover_icon_; +}; + +// Closes the dialog the way Escape does: a message box clicks its escape button +// (or Cancel), anything else rejects. +void closeLikeEscape(QDialog* dialog) { + if (auto* box = qobject_cast(dialog)) { + if (QAbstractButton* button = box->escapeButton()) { + button->click(); + return; + } + if (box->button(QMessageBox::Cancel) != nullptr) { + box->done(static_cast(QMessageBox::Cancel)); + return; + } + } + dialog->reject(); +} + +class TitleBar : public QWidget { +public: + TitleBar(QDialog* dialog, bool closable) : QWidget(dialog) { + setObjectName(QStringLiteral("dialogTitleBar")); + setAttribute(Qt::WA_StyledBackground, true); + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(14, 0, 6, 0); + layout->setSpacing(6); + + auto* title = new QLabel(dialog->windowTitle(), this); + title->setObjectName(QStringLiteral("dialogTitleText")); + layout->addWidget(title, 1); + connect(dialog, &QWidget::windowTitleChanged, title, &QLabel::setText); + + if (closable) { + auto* close = new CloseButton(this); + connect(close, &QPushButton::clicked, dialog, [dialog] { closeLikeEscape(dialog); }); + layout->addWidget(close); + } + } + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + if (QWindow* handle = window()->windowHandle()) { + handle->startSystemMove(); + event->accept(); + return; + } + } + QWidget::mousePressEvent(event); + } +}; + +// Keeps the overlay title bar and size grip pinned to the dialog frame. +class Chrome : public QObject { +public: + Chrome(QDialog* dialog, bool closable, bool resizable) : QObject(dialog), dialog_(dialog) { + bar_ = new TitleBar(dialog, closable); + grip_ = resizable ? new QSizeGrip(dialog) : nullptr; + dialog->installEventFilter(this); + place(); + } + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == dialog_ && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + place(); + } + return false; + } + +private: + void place() { + const int width = dialog_->width(); + bar_->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); + bar_->raise(); + if (grip_) { + grip_->setGeometry(width - kGripSize - kBorder, dialog_->height() - kGripSize - kBorder, + kGripSize, kGripSize); + grip_->raise(); + } + } + + QDialog* dialog_; + TitleBar* bar_ = nullptr; + QSizeGrip* grip_ = nullptr; +}; + +} // namespace + +void installDialogChrome(QDialog* dialog, bool closable, std::optional resizable) { + if (qobject_cast(dialog) || dialog->property("chromed").toBool()) { + return; + } + const bool grip = resizable.value_or( + !(qobject_cast(dialog) || dialog->isSizeGripEnabled())); + + const Qt::WindowFlags keep = dialog->windowFlags() & Qt::WindowStaysOnTopHint; + dialog->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | keep); + dialog->setProperty("chromed", true); + + if (QLayout* layout = dialog->layout()) { + const QMargins m = layout->contentsMargins(); + layout->setContentsMargins(m.left() + kBorder, m.top() + kTitleBarHeight + kBorder, + m.right() + kBorder, m.bottom() + kBorder); + } + new Chrome(dialog, closable, grip); +} + +} // namespace px::ui diff --git a/src/ui/components/dialog_chrome.h b/src/ui/components/dialog_chrome.h new file mode 100644 index 0000000..43c9f7d --- /dev/null +++ b/src/ui/components/dialog_chrome.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class QDialog; + +namespace px::ui { + +// Gives `dialog` the frameless title-bar look of the main window. Call after the +// dialog's layout is built and before it is shown. Native file pickers keep the +// OS look. The title bar is an overlay, so the dialog's own layout only needs +// extra top margin, which is added here. +void installDialogChrome(QDialog* dialog, bool closable = true, + std::optional resizable = std::nullopt); + +} // namespace px::ui diff --git a/src/ui/components/dialogs.h b/src/ui/components/dialogs.h new file mode 100644 index 0000000..0125c69 --- /dev/null +++ b/src/ui/components/dialogs.h @@ -0,0 +1,34 @@ +#pragma once + +// Modal dialogs as coroutines: `co_await awaitDialog(args...)` shows the dialog and +// resumes with its result code and pointer. The dialog is scheduled for deletion, so +// read what you need from it before the next event-loop pass (the coroutine resumes +// synchronously from `finished`, so that holds for any code after the co_await). + +#include +#include + +#include +#include +#include + +#include "ui/components/dialog_chrome.h" + +namespace px::ui { + +template T, class... Args> +QCoro::Task> awaitDialog(Args&&... args) { + auto* dialog = new T(std::forward(args)...); + installDialogChrome(dialog); + dialog->setModal(true); + + // Subscribe before show(), so a finish that happens immediately is not missed. + auto finished = qCoro(dialog, &QDialog::finished); + dialog->show(); + const int code = co_await std::move(finished); + + dialog->deleteLater(); + co_return std::pair{code, dialog}; +} + +} // namespace px::ui diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp new file mode 100644 index 0000000..433e676 --- /dev/null +++ b/src/ui/components/filter_sidebar.cpp @@ -0,0 +1,21 @@ +#include "ui/components/filter_sidebar.h" +#include "ui/theme/palette.h" +#include +#include +#include +namespace px::ui { +FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + rootContext()->setContextProperty(QStringLiteral("filterSidebarModel"), model); + setInitialProperties({{QStringLiteral("model"), QVariant::fromValue(model)}}); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/FilterSidebar.qml"))); + if (auto* root = rootObject()) connect(root, SIGNAL(activated(int)), this, SIGNAL(activated(int))); +} +int FilterSidebar::current_index() const { + const auto* root = rootObject(); + const auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; + return view ? view->property("currentIndex").toInt() : -1; +} +} diff --git a/src/ui/components/filter_sidebar.h b/src/ui/components/filter_sidebar.h new file mode 100644 index 0000000..70d74e2 --- /dev/null +++ b/src/ui/components/filter_sidebar.h @@ -0,0 +1,13 @@ +#pragma once +#include +class QAbstractItemModel; +namespace px::ui { +class FilterSidebar : public QQuickWidget { + Q_OBJECT +public: + FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent = nullptr); + int current_index() const; +signals: + void activated(int index); +}; +} diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp new file mode 100644 index 0000000..9739877 --- /dev/null +++ b/src/ui/components/header_controller.cpp @@ -0,0 +1,118 @@ +#include "ui/components/header_controller.h" + +#include +#include + +#include "core/constants.h" + +namespace px::ui { + +namespace { + +QString strategyLabel(core::LaunchStrategy strategy) { + switch (strategy) { + case core::LaunchStrategy::Direct: + return QStringLiteral("Direct executable"); + case core::LaunchStrategy::Steam: + return QStringLiteral("Steam"); + } + return {}; +} + +} // namespace + +HeaderController::HeaderController(bool isFrameless, int initialStrategy, QString appVersion, + QVariantMap tooltips, bool downloadsAvailable, QObject* parent) + : QObject(parent), + is_frameless_(isFrameless), + app_version_(std::move(appVersion)), + tooltips_(std::move(tooltips)), + downloads_available_(downloadsAvailable), + strategy_index_(initialStrategy) {} + +QVariantList HeaderController::strategies() const { + QVariantList list; + for (const auto strategy : {core::LaunchStrategy::Direct, core::LaunchStrategy::Steam}) { + list.append(QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, + {QStringLiteral("label"), strategyLabel(strategy)}}); + } + return list; +} + +void HeaderController::setMaximized(bool value) { + if (maximized_ != value) { + maximized_ = value; + emit maximizedChanged(); + } +} + +void HeaderController::setUnsavedChanges(bool value) { + if (unsaved_changes_ != value) { + unsaved_changes_ = value; + emit unsavedChangesChanged(); + } +} + +void HeaderController::setLaunchState(const QString& state) { + if (launch_state_ != state) { + launch_state_ = state; + emit launchStateChanged(); + } +} + +void HeaderController::setTaskText(const QString& text) { + if (task_text_ != text) { + task_text_ = text; + emit taskTextChanged(); + } +} + +void HeaderController::setDownloadsBusy(bool busy) { + if (downloads_busy_ != busy) { + downloads_busy_ = busy; + emit downloadsBusyChanged(); + } +} + +void HeaderController::setDownloadsProgressShown(bool shown) { + if (downloads_progress_shown_ != shown) { + downloads_progress_shown_ = shown; + emit downloadsProgressShownChanged(); + } +} + +void HeaderController::setDownloadsProgress(const QString& text, int value, int total) { + downloads_text_ = text; + downloads_value_ = value; + downloads_total_ = total; + emit downloadsProgressChanged(); +} + +void HeaderController::refresh() { emit refreshRequested(); } + +void HeaderController::autoSort() { emit sortRequested(); } + +void HeaderController::openDownloads() { emit downloadsRequested(); } + +void HeaderController::updateMods() { emit updateModsRequested(); } + +void HeaderController::save() { emit saveRequested(); } + +void HeaderController::launch() { emit launchRequested(); } + +void HeaderController::minimize() { emit minimizeRequested(); } + +void HeaderController::maximize() { emit maximizeRequested(); } + +void HeaderController::closeWindow() { emit closeRequested(); } + +void HeaderController::setStrategy(int index) { + if (strategy_index_ != index) { + strategy_index_ = index; + emit strategyChanged(index); + } +} + +void HeaderController::dragStarted() { emit dragStartRequested(); } + +} // namespace px::ui diff --git a/src/ui/components/header_controller.h b/src/ui/components/header_controller.h new file mode 100644 index 0000000..c13bde6 --- /dev/null +++ b/src/ui/components/header_controller.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::ui { + +// State and commands behind Header.qml, exposed to it as `headerController`. The +// QML-facing names (properties and invokables) match the legacy HeaderController; +// the C++ side uses camelCase signals and setters. Owned by the caller. +class HeaderController : public QObject { + Q_OBJECT + + Q_PROPERTY(bool is_frameless READ isFrameless CONSTANT) + Q_PROPERTY(QString appVersion READ appVersion CONSTANT) + Q_PROPERTY(QVariantMap tooltips READ tooltips CONSTANT) + Q_PROPERTY(bool downloadsAvailable READ downloadsAvailable CONSTANT) + Q_PROPERTY(QVariantList strategies READ strategies CONSTANT) + + Q_PROPERTY(bool maximized READ maximized WRITE setMaximized NOTIFY maximizedChanged) + Q_PROPERTY(int strategyIndex READ strategyIndex NOTIFY strategyChanged) + Q_PROPERTY(bool unsavedChanges READ unsavedChanges WRITE setUnsavedChanges NOTIFY unsavedChangesChanged) + Q_PROPERTY(QString launchState READ launchState WRITE setLaunchState NOTIFY launchStateChanged) + Q_PROPERTY(QString taskText READ taskText NOTIFY taskTextChanged) + Q_PROPERTY(bool downloadsBusy READ downloadsBusy WRITE setDownloadsBusy NOTIFY downloadsBusyChanged) + Q_PROPERTY(bool downloadsProgressShown READ downloadsProgressShown NOTIFY downloadsProgressShownChanged) + Q_PROPERTY(QString downloadsText READ downloadsText NOTIFY downloadsProgressChanged) + Q_PROPERTY(int downloadsValue READ downloadsValue NOTIFY downloadsProgressChanged) + Q_PROPERTY(int downloadsTotal READ downloadsTotal NOTIFY downloadsProgressChanged) + +public: + HeaderController(bool isFrameless, int initialStrategy, QString appVersion, QVariantMap tooltips, + bool downloadsAvailable, QObject* parent = nullptr); + + bool isFrameless() const { return is_frameless_; } + const QString& appVersion() const { return app_version_; } + const QVariantMap& tooltips() const { return tooltips_; } + bool downloadsAvailable() const { return downloads_available_; } + // [{index, label}] for each launch strategy, in enum order. + QVariantList strategies() const; + + bool maximized() const { return maximized_; } + int strategyIndex() const { return strategy_index_; } + bool unsavedChanges() const { return unsaved_changes_; } + const QString& launchState() const { return launch_state_; } + const QString& taskText() const { return task_text_; } + bool downloadsBusy() const { return downloads_busy_; } + bool downloadsProgressShown() const { return downloads_progress_shown_; } + const QString& downloadsText() const { return downloads_text_; } + int downloadsValue() const { return downloads_value_; } + int downloadsTotal() const { return downloads_total_; } + + void setMaximized(bool value); + void setUnsavedChanges(bool value); + void setLaunchState(const QString& state); + void setTaskText(const QString& text); + void setDownloadsBusy(bool busy); + void setDownloadsProgressShown(bool shown); + void setDownloadsProgress(const QString& text, int value, int total); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void autoSort(); + Q_INVOKABLE void openDownloads(); + Q_INVOKABLE void updateMods(); + Q_INVOKABLE void save(); + Q_INVOKABLE void launch(); + Q_INVOKABLE void minimize(); + Q_INVOKABLE void maximize(); + Q_INVOKABLE void closeWindow(); + Q_INVOKABLE void setStrategy(int index); + // Called by QML when a drag starts on the header background. Emits dragStartRequested. + Q_INVOKABLE void dragStarted(); + +signals: + void refreshRequested(); + void sortRequested(); + void saveRequested(); + void launchRequested(); + void strategyChanged(int index); + void minimizeRequested(); + void maximizeRequested(); + void closeRequested(); + void dragStartRequested(); + void updateModsRequested(); + void downloadsRequested(); + + void maximizedChanged(); + void unsavedChangesChanged(); + void launchStateChanged(); + void taskTextChanged(); + void downloadsBusyChanged(); + void downloadsProgressShownChanged(); + void downloadsProgressChanged(); + +private: + bool is_frameless_; + QString app_version_; + QVariantMap tooltips_; + bool downloads_available_; + + bool maximized_ = false; + int strategy_index_; + bool unsaved_changes_ = false; + QString launch_state_ = QStringLiteral("idle"); + QString task_text_; + bool downloads_busy_ = false; + bool downloads_progress_shown_ = true; + QString downloads_text_; + int downloads_value_ = 0; + int downloads_total_ = 0; +}; + +} // namespace px::ui diff --git a/src/ui/components/header_panel.cpp b/src/ui/components/header_panel.cpp new file mode 100644 index 0000000..e2c4c38 --- /dev/null +++ b/src/ui/components/header_panel.cpp @@ -0,0 +1,24 @@ +#include "ui/components/header_panel.h" + +#include +#include +#include +#include + +#include "ui/components/header_controller.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_1")); + rootContext()->setContextProperty(QStringLiteral("headerController"), &controller); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Header.qml"))); + if (rootObject() == nullptr) { + spdlog::error("Header.qml failed to load: rootObject is null"); + } +} + +} // namespace px::ui diff --git a/src/ui/components/header_panel.h b/src/ui/components/header_panel.h new file mode 100644 index 0000000..4a4ef23 --- /dev/null +++ b/src/ui/components/header_panel.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +class QQmlEngine; +class QWidget; + +namespace px::ui { + +class HeaderController; + +// Hosts Header.qml, the main window's top bar. The controller is injected and exposed +// to QML as `headerController`; the caller owns it and must outlive this panel. +class HeaderPanel : public QQuickWidget { +public: + HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent = nullptr); +}; + +} // namespace px::ui diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp new file mode 100644 index 0000000..9bf475f --- /dev/null +++ b/src/ui/components/icon_button.cpp @@ -0,0 +1,46 @@ +#include "ui/components/icon_button.h" + +#include + +#include + +#include "ui/components/icons.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +// Every icon name used here is in the icon table; a miss is a programming error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +IconButton::IconButton(const QString& iconName, const QString& tooltip, bool primary, int size, + QWidget* parent) + : QPushButton(parent), icon_name_(iconName), icon_px_(std::max(16, size / 2)) { + setIconColor(paletteValue(u"TEXT_MUTED")); + setFixedSize(size, size); + setObjectName(QStringLiteral("iconBtn")); + // QSS min-height on QPushButton overrides setFixedSize; the 1px border sits outside the + // QSS content box, so subtract it. + const int inner = size - 2; + setStyleSheet(QStringLiteral("QPushButton#iconBtn { min-width: %1px; max-width: %1px;" + " min-height: %1px; max-height: %1px; }") + .arg(inner)); + if (primary) { + setProperty("primary", true); + } + setCursor(Qt::PointingHandCursor); + setToolTip(tooltip); +} + +void IconButton::setIconColor(const QString& color) { + setIcon(requireIcon(icon_name_, icon_px_, color)); +} + +} // namespace px::ui diff --git a/src/ui/components/icon_button.h b/src/ui/components/icon_button.h new file mode 100644 index 0000000..25cacb4 --- /dev/null +++ b/src/ui/components/icon_button.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +class QWidget; + +namespace px::ui { + +// Square icon-only button (QSS `iconBtn`). `primary` paints it in the accent color. +class IconButton : public QPushButton { +public: + IconButton(const QString& iconName, const QString& tooltip = {}, bool primary = false, + int size = 32, QWidget* parent = nullptr); + + void setIconColor(const QString& color); + +private: + QString icon_name_; + int icon_px_; +}; + +} // namespace px::ui diff --git a/src/ui/components/icon_table.inc b/src/ui/components/icon_table.inc new file mode 100644 index 0000000..a6387cc --- /dev/null +++ b/src/ui/components/icon_table.inc @@ -0,0 +1,43 @@ +// Generated once from legacy/pxmodrim/ui/components/icons.py (_ICONS, same order). +// Markup keeps `currentColor`; iconSvg() substitutes the requested color. +{QLatin1StringView{"thumbs-up"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"logo"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"download"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"refresh"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"sort"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"save"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"settings"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"search"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"check"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"warning"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"error"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"close"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"minimize"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"maximize"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"restore"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron-left"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"folder"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"tag"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"steam"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"local"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"git"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"grid"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"home"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"mods"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"check-circle"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"ban"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"help"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"info"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"play"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"empty"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"link"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"copy"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron-down"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"clock"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"trash"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"donut"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"bars"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"grip"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"star"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"star-filled"}, QLatin1StringView{R"SVG()SVG"}}, diff --git a/src/ui/components/icons.cpp b/src/ui/components/icons.cpp new file mode 100644 index 0000000..3a43090 --- /dev/null +++ b/src/ui/components/icons.cpp @@ -0,0 +1,84 @@ +#include "ui/components/icons.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace px::ui { + +namespace { + +struct IconEntry { + QLatin1StringView name; + QLatin1StringView svg; +}; + +constexpr IconEntry kIcons[]{ +#include "ui/components/icon_table.inc" +}; + +constexpr QLatin1StringView kSvgNamespace{"xmlns=\"http://www.w3.org/2000/svg\""}; + +const IconEntry* findIcon(QStringView name) { + for (const auto& entry : kIcons) { + if (QString(entry.name) == name) { + return &entry; + } + } + return nullptr; +} + +} // namespace + +std::optional iconSvg(QStringView name, QStringView color) { + const IconEntry* entry = findIcon(name); + if (!entry) { + return std::nullopt; + } + QString svg(entry->svg); + if (!svg.contains(QString(kSvgNamespace))) { + svg.replace(QStringLiteral(" iconPixmap(QStringView name, int size, QStringView color) { + const auto svg = iconSvg(name, color); + if (!svg) { + return std::nullopt; + } + + const QScreen* screen = QGuiApplication::primaryScreen(); + const qreal scale = std::max(2.0, screen ? screen->devicePixelRatio() : 1.0); + const int pixels = qRound(size * scale); + + QPixmap pixmap(pixels, pixels); + pixmap.setDevicePixelRatio(scale); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); + QSvgRenderer renderer(svg->toUtf8()); + if (renderer.isValid()) { + renderer.render(&painter, QRectF(0, 0, size, size)); + } + painter.end(); + return pixmap; +} + +std::optional icon(QStringView name, int size, QStringView color) { + auto pixmap = iconPixmap(name, size, color); + if (!pixmap) { + return std::nullopt; + } + return QIcon(*pixmap); +} + +} // namespace px::ui diff --git a/src/ui/components/icons.h b/src/ui/components/icons.h new file mode 100644 index 0000000..b7f7e0d --- /dev/null +++ b/src/ui/components/icons.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace px::ui { + +// The SVG markup of a named icon with `currentColor` replaced by `color`, or +// nullopt when no icon has that name. +std::optional iconSvg(QStringView name, QStringView color); + +// Rasterised at >= 2x so small icons stay crisp on 1x screens; the pixmap's +// device pixel ratio carries the scale. nullopt for an unknown name. +std::optional iconPixmap(QStringView name, int size, QStringView color); + +std::optional icon(QStringView name, int size, QStringView color); + +} // namespace px::ui diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp new file mode 100644 index 0000000..866a388 --- /dev/null +++ b/src/ui/components/mod_activation.cpp @@ -0,0 +1,43 @@ +#include "ui/components/mod_activation.h" +#include "ui/components/dialogs.h" +#include "core/context.h" +#include "core/services/activation_service.h" +#include +namespace px::ui { +DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* parent) : QMessageBox(parent) { + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Disable Dependent Mods")); + QStringList details; + for (const auto& dependent : dependents) details.append(QStringLiteral("• %1").arg(dependent)); + setText(QStringLiteral("These active mods depend on the mod you are disabling:\n\n") + details.join('\n')); + setInformativeText(QStringLiteral("Choose whether to disable only the selected mod or these dependents too.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); + setDefaultButton(QMessageBox::No); + button(QMessageBox::Yes)->setText(QStringLiteral("Disable selected + dependents")); + button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +} +QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { + // Copy before suspension: callers often pass temporary batches. + const QStringList enabling = enable; + QStringList disabling = disable; + const auto dependents = ctx.activation().dependents_of(disabling, enabling); + if (!dependents.isEmpty()) { + QStringList details; + for (const auto& uuid : dependents) { + const auto& mod = ctx.allMods().value(uuid); + const auto* about = dynamic_cast(mod.get()); + details.append(about ? QStringLiteral("%1 (%2)").arg(mod->name, about->package_id.str()) : mod->name); + } + const auto [result, dialog] = co_await awaitDialog(details, parent); + if (result == QMessageBox::Cancel) co_return false; + if (result == QMessageBox::Yes) disabling.append(dependents); + } + co_return ctx.activation().apply(enabling, disabling); +} +QCoro::Task toggle_mods(core::CoreContext& ctx, QWidget* parent, const QStringList& uuids) { + QStringList enable, disable; + for (const auto& uuid : uuids) (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); + co_return co_await apply_activation(ctx, parent, enable, disable); +} +} diff --git a/src/ui/components/mod_activation.h b/src/ui/components/mod_activation.h new file mode 100644 index 0000000..e3c7d98 --- /dev/null +++ b/src/ui/components/mod_activation.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include +namespace px::core { class CoreContext; } +namespace px::ui { +class DependentModsDialog : public QMessageBox { +public: + DependentModsDialog(const QStringList& dependents, QWidget* parent); +}; +QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); +QCoro::Task toggle_mods(core::CoreContext& ctx, QWidget* parent, const QStringList& uuids); +} diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp new file mode 100644 index 0000000..5f41422 --- /dev/null +++ b/src/ui/components/mod_updates.cpp @@ -0,0 +1,57 @@ +#include "ui/components/mod_updates.h" +#include "ui/components/dialogs.h" +#include "core/context.h" +#include "core/downloads/manager.h" +#include +#include +#include +#include +namespace px::ui { +QStringList updatable_ids(core::CoreContext& ctx, const QStringList& uuids) { + QList mods; + mods.reserve(uuids.size()); + for (const auto& uuid : uuids) { + const auto it = ctx.allMods().constFind(uuid); + if (it != ctx.allMods().cend()) mods.append(it.value()); + } + return ctx.downloads().updatable_ids(mods); +} +UpdateState update_state(core::CoreContext& ctx, const QStringList& uuids) { + const auto ids = updatable_ids(ctx, uuids); + QString label = QStringLiteral("Update"); + if (uuids.size() > 1) label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); + const auto& downloads = ctx.downloads(); + if (!downloads.available()) return {label, false, QStringLiteral("No download sources are enabled")}; + if (downloads.is_downloading()) return {label, false, QStringLiteral("A download is in progress")}; + if (ids.isEmpty()) return {label, false, QStringLiteral("None of these mods were downloaded by PxModRim")}; + return {label, true, {}}; +} +QAction* add_update_action(QMenu* menu, core::CoreContext& ctx, const QStringList& uuids, std::function handler) { + if (!ctx.downloads().available()) return nullptr; + const auto state = update_state(ctx, uuids); + auto* action = menu->addAction(state.label); + QObject::connect(action, &QAction::triggered, menu, [handler = std::move(handler)] { handler(); }); + action->setEnabled(state.enabled); action->setToolTip(state.tooltip); + return action; +} +ConfirmUpdateDialog::ConfirmUpdateDialog(int count, QWidget* parent) : QMessageBox(parent) { + setIcon(QMessageBox::Question); setWindowTitle(QStringLiteral("Update Mods")); + setText(QStringLiteral("Update %1 mods?").arg(count)); + setInformativeText(QStringLiteral("Every mod downloaded by PxModRim is checked against its source and changed files are downloaded. Mods managed by other tools, such as Steam's own Workshop folder, are not included. This can take a long time.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + button(QMessageBox::Yes)->setText(QStringLiteral("Update")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +} +QCoro::Task confirm_update(int count, QWidget* parent) { + if (count < CONFIRM_THRESHOLD) co_return true; + const auto [result, dialog] = co_await awaitDialog(count, parent); + co_return result == QMessageBox::Yes; +} +QCoro::Task update_mods(core::CoreContext& ctx, const QStringList& uuids, QWidget* parent) { + const auto ids = updatable_ids(ctx, uuids); + if (ids.isEmpty() || !(co_await confirm_update(static_cast(ids.size()), parent))) co_return; + const auto result = co_await ctx.downloads().download_mods(ids); + if (!result) qWarning() << "Update not started:" << result.error().message; +} +} diff --git a/src/ui/components/mod_updates.h b/src/ui/components/mod_updates.h new file mode 100644 index 0000000..1f31e92 --- /dev/null +++ b/src/ui/components/mod_updates.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include +#include +#include +class QMenu; +class QAction; +namespace px::core { class CoreContext; } +namespace px::ui { +inline constexpr int CONFIRM_THRESHOLD = 25; +struct UpdateState { QString label; bool enabled; QString tooltip; }; +QStringList updatable_ids(core::CoreContext& ctx, const QStringList& uuids); +UpdateState update_state(core::CoreContext& ctx, const QStringList& uuids); +QAction* add_update_action(QMenu* menu, core::CoreContext& ctx, const QStringList& uuids, std::function handler); +class ConfirmUpdateDialog : public QMessageBox { +public: + ConfirmUpdateDialog(int count, QWidget* parent); +}; +QCoro::Task confirm_update(int count, QWidget* parent); +QCoro::Task update_mods(core::CoreContext& ctx, const QStringList& uuids, QWidget* parent); +} diff --git a/src/ui/components/popup_parent.cpp b/src/ui/components/popup_parent.cpp new file mode 100644 index 0000000..5454de4 --- /dev/null +++ b/src/ui/components/popup_parent.cpp @@ -0,0 +1,24 @@ +#include "ui/components/popup_parent.h" + +#include +#include +#include +#include +#include + +namespace px::ui { + +QuickPopupParenting::QuickPopupParenting(QObject* parent) : QObject(parent) {} + +bool QuickPopupParenting::eventFilter(QObject* watched, QEvent* event) { + if (event->type() == QEvent::Show) { + if (auto* quick = qobject_cast(watched)) { + if (QWindow* top = quick->window()->windowHandle()) { + quick->quickWindow()->setTransientParent(top); + } + } + } + return false; +} + +} // namespace px::ui diff --git a/src/ui/components/popup_parent.h b/src/ui/components/popup_parent.h new file mode 100644 index 0000000..7de618c --- /dev/null +++ b/src/ui/components/popup_parent.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +class QEvent; + +namespace px::ui { + +// Gives every QQuickWidget's offscreen window its top-level window as transient +// parent when shown. Without it Wayland refuses Popup.Window popups (menus, +// combo box lists, tooltips): "Failed to create popup … transientParent". +// Install on the QApplication, parented to it. +class QuickPopupParenting : public QObject { + Q_OBJECT + +public: + explicit QuickPopupParenting(QObject* parent); + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; +}; + +} // namespace px::ui diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp new file mode 100644 index 0000000..7e81ec8 --- /dev/null +++ b/src/ui/components/procedural_preview.cpp @@ -0,0 +1,98 @@ +#include "ui/components/procedural_preview.h" +#include "ui/theme/constants.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::ui { +QString initials(const QString& title) { + static const QRegularExpression tags(R"([\[(][^\])]*[\])])"); + static const QRegularExpression words(R"([^\W_]+)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression camel(R"([A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+)", QRegularExpression::UseUnicodePropertiesOption); + static const QSet stop{"a", "an", "and", "for", "of", "the", "to", "in", "on"}; + QString stripped = title; + stripped.replace(tags, " "); + const bool use_stripped = words.match(stripped).hasMatch(); + QStringList all, meaningful; + auto chunks = words.globalMatch(use_stripped ? stripped : title); + while (chunks.hasNext()) { + const QString chunk = chunks.next().captured(); + auto parts = camel.globalMatch(chunk); + if (!parts.hasNext()) all.append(chunk); + while (parts.hasNext()) all.append(parts.next().captured()); + } + for (const auto& word : all) if (!stop.contains(word.toLower())) meaningful.append(word); + if (meaningful.isEmpty()) meaningful = all; + if (meaningful.isEmpty()) return title.trimmed().isEmpty() ? QStringLiteral("?") : title.trimmed().left(2).toUpper(); + if (meaningful.size() == 1) { + auto tag_matches = tags.globalMatch(title); + QString tag_text; + while (tag_matches.hasNext()) tag_text += tag_matches.next().captured() + " "; + const auto tag_word = words.match(tag_text); + if (use_stripped && tag_word.hasMatch()) return (tag_word.captured().left(1) + meaningful.front().left(1)).toUpper(); + return meaningful.front().left(2).toUpper(); + } + return (meaningful[0].left(1) + meaningful[1].left(1)).toUpper(); +} +namespace { +double seedrand(const QString& seed) { + const auto hash = QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); + unsigned remainder = 0; + for (char byte : hash) remainder = (remainder * 256 + static_cast(byte)) % 10000; + return remainder / 10000.0; +} +QColor hsl(double hue, double saturation, double lightness) { + return QColor::fromHslF(hue - std::floor(hue), std::clamp(saturation, 0.0, 1.0), std::clamp(lightness, 0.0, 1.0)); +} +void blob(QPainter& painter, double x, double y, double radius, const QColor& color) { + QRadialGradient gradient(QPointF(x, y), radius); + QColor outer = color; + outer.setAlpha(0); + gradient.setColorAt(0, color); + gradient.setColorAt(1, outer); + painter.setBrush(gradient); + painter.drawEllipse(QPointF(x, y), radius, radius); +} +} +QImage generate_preview(const QString& title, int width, int height) { + static QMutex mutex; + static QCache cache(16); + const QString key = QString::number(width) + ":" + QString::number(height) + ":" + title; + { + QMutexLocker lock(&mutex); + if (auto* cached = cache.object(key)) return *cached; + } + QImage image(width, height, QImage::Format_ARGB32_Premultiplied); + const double hue = seedrand(title); + image.fill(hsl(hue, .40, .12)); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + const int span = std::max(width, height); + const double jx = seedrand(title + "jx") * .2, jy = seedrand(title + "jy") * .2; + QColor warm = hsl(hue, .7, .5), cool = hsl(hue + .14, .7, .5); + warm.setAlphaF(.75); cool.setAlphaF(.65); + blob(painter, width * (.15 + jx), height * (.1 + jy), span * .6, warm); + blob(painter, width * (.85 - jx), height * (.9 - jy), span * .55, cool); + QFont font = painter.font(); + font.setBold(true); font.setWeight(QFont::ExtraBold); + font.setPixelSize(std::max(32, static_cast(height * .36))); + font.setLetterSpacing(QFont::PercentageSpacing, 96); + painter.setFont(font); + painter.setPen(QColor(255, 255, 255, 210)); + const int visible = std::max(height - BANNER_OVERLAY_HEIGHT, height / 2); + painter.drawText(QRectF(0, 0, width, visible), Qt::AlignCenter, initials(title)); + painter.end(); + QMutexLocker lock(&mutex); + cache.insert(key, new QImage(image)); + return image; +} +} diff --git a/src/ui/components/procedural_preview.h b/src/ui/components/procedural_preview.h new file mode 100644 index 0000000..3176fe4 --- /dev/null +++ b/src/ui/components/procedural_preview.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +namespace px::ui { +QString initials(const QString& title); +QImage generate_preview(const QString& title, int width, int height); +} diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp new file mode 100644 index 0000000..014ddcd --- /dev/null +++ b/src/ui/components/progress_dialog.cpp @@ -0,0 +1,148 @@ +#include "ui/components/progress_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/dialog_chrome.h" +#include "ui/components/icons.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +constexpr int kHideDelayMs = 300; +constexpr int kFadeInMs = 150; +constexpr int kFadeOutMs = 200; + +// Every icon name used here is in the icon table; a miss is a programming error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +ProgressDialog::ProgressDialog(core::LoadingState& loading, QWidget* parent) + : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | Qt::WindowTitleHint), + loading_(loading) { + setWindowTitle(QStringLiteral("Loading")); + setMinimumWidth(420); + setModal(true); + + crumbs_ = new QWidget(this); + crumbs_layout_ = new QHBoxLayout(crumbs_); + crumbs_layout_->setContentsMargins(0, 0, 0, 8); + crumbs_layout_->setSpacing(4); + + progress_ = new QProgressBar(this); + progress_->setTextVisible(false); + progress_->setRange(0, 100); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(0); + layout->addWidget(crumbs_); + layout->addWidget(progress_); + installDialogChrome(this, false); + + hide_timer_ = new QTimer(this); + hide_timer_->setSingleShot(true); + hide_timer_->setInterval(kHideDelayMs); + connect(hide_timer_, &QTimer::timeout, this, &ProgressDialog::beginHide); + + fade_ = new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); + connect(fade_, &QPropertyAnimation::finished, this, &ProgressDialog::onFadeFinished); + + connections_.push_back(QObject::connect( + &loading_, &core::LoadingState::changed, this, + [this](const QString&, int pct, int, int) { onChanged(pct); })); + connections_.push_back( + QObject::connect(&loading_, &core::LoadingState::finished, this, &ProgressDialog::onFinished)); +} + +ProgressDialog::~ProgressDialog() { + for (const auto& connection : connections_) { + QObject::disconnect(connection); + } +} + +void ProgressDialog::onChanged(int pct) { + hide_timer_->stop(); + if (!shown_) { + shown_ = true; + if (!isVisible()) { + setWindowOpacity(0.0); + show(); + } + fadeTo(1.0, kFadeInMs); + } + + const QStringList statuses = loading_.statuses(); + if (!statuses.isEmpty()) { + setWindowTitle(statuses.first()); + } + updateBreadcrumbs(statuses); + progress_->setValue(pct); +} + +void ProgressDialog::onFinished() { + hide_timer_->start(); +} + +void ProgressDialog::updateBreadcrumbs(const QStringList& statuses) { + while (QLayoutItem* item = crumbs_layout_->takeAt(0)) { + if (QWidget* widget = item->widget()) { + widget->hide(); + widget->deleteLater(); + } + delete item; + } + + const qsizetype last = statuses.size() - 1; + for (qsizetype i = 0; i < statuses.size(); ++i) { + auto* label = new QLabel(statuses[i], crumbs_); + if (i == last) { + QFont font = label->font(); + font.setBold(true); + label->setFont(font); + } + crumbs_layout_->addWidget(label); + + if (i < last) { + auto* separator = new QLabel(crumbs_); + separator->setPixmap(requireIcon(u"chevron", 12, paletteValue(u"TEXT_DIM")).pixmap(12, 12)); + crumbs_layout_->addWidget(separator); + } + } + crumbs_layout_->addStretch(); +} + +void ProgressDialog::beginHide() { + shown_ = false; + fadeTo(0.0, kFadeOutMs); +} + +void ProgressDialog::fadeTo(qreal end, int durationMs) { + fade_->stop(); + fade_->setDuration(durationMs); + fade_->setStartValue(windowOpacity()); + fade_->setEndValue(end); + fade_->start(); +} + +void ProgressDialog::onFadeFinished() { + if (!shown_) { + hide(); + } +} + +} // namespace px::ui diff --git a/src/ui/components/progress_dialog.h b/src/ui/components/progress_dialog.h new file mode 100644 index 0000000..4213b24 --- /dev/null +++ b/src/ui/components/progress_dialog.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include +#include +#include + +#include "core/loading.h" + +class QHBoxLayout; +class QProgressBar; +class QPropertyAnimation; +class QTimer; +class QWidget; + +namespace px::ui { + +// Modal progress dialog showing the stacked loading tasks as breadcrumbs. Shows when the first +// task reports, and fades out shortly after the last one finishes unless a new task starts. +class ProgressDialog : public QDialog { + Q_OBJECT + +public: + ProgressDialog(core::LoadingState& loading, QWidget* parent); + ~ProgressDialog() override; + + core::LoadingState& loading() const noexcept { return loading_; } + +private: + void onChanged(int pct); + void onFinished(); + void updateBreadcrumbs(const QStringList& statuses); + void beginHide(); + void fadeTo(qreal end, int durationMs); + void onFadeFinished(); + + core::LoadingState& loading_; + bool shown_ = false; + + QWidget* crumbs_ = nullptr; + QHBoxLayout* crumbs_layout_ = nullptr; + QProgressBar* progress_ = nullptr; + QTimer* hide_timer_ = nullptr; + QPropertyAnimation* fade_ = nullptr; + + std::vector connections_; +}; + +} // namespace px::ui diff --git a/src/ui/components/svg_provider.cpp b/src/ui/components/svg_provider.cpp new file mode 100644 index 0000000..65c1fa5 --- /dev/null +++ b/src/ui/components/svg_provider.cpp @@ -0,0 +1,66 @@ +#include "ui/components/svg_provider.h" + +#include +#include +#include +#include +#include + +#include "ui/components/icons.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +constexpr QLatin1StringView kEmptySvg{R"()"}; + +// The color parameter, percent-decoded. QML URL-encodes "#" and some paths +// deliver the digits without it, so a leading "23" is taken as an encoded "#". +QString colorFromQuery(const QString& query) { + QString color = paletteValue(u"TEXT_MUTED"); + for (const QString& param : query.split(QLatin1Char('&'))) { + if (!param.startsWith(QLatin1StringView("color="))) { + continue; + } + color = QUrl::fromPercentEncoding(param.mid(6).toUtf8()); + if (!color.startsWith(QLatin1Char('#')) && color.startsWith(QLatin1StringView("23"))) { + color = QStringLiteral("#") + color.mid(2); + } + } + return color; +} + +} // namespace + +SvgIconProvider::SvgIconProvider() : QQuickImageProvider(QQuickImageProvider::Pixmap) {} + +QPixmap SvgIconProvider::requestPixmap(const QString& id, QSize*, const QSize& requestedSize) { + const qsizetype questionMark = id.indexOf(QLatin1Char('?')); + const QString name = questionMark < 0 ? id : id.left(questionMark); + const QString query = questionMark < 0 ? QString{} : id.mid(questionMark + 1); + + const QString svg = iconSvg(name, colorFromQuery(query)).value_or(QString::fromLatin1(kEmptySvg)); + + const QSize size = requestedSize.isValid() ? requestedSize : QSize(16, 16); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + QSvgRenderer renderer(svg.toUtf8()); + renderer.render(&painter); + painter.end(); + return pixmap; +} + +QQmlEngine* createQmlEngine(QObject* parent) { + if (QQuickStyle::name().isEmpty()) { + QQuickStyle::setStyle(QStringLiteral("Basic")); + } + auto* engine = new QQmlEngine(parent); + // The engine takes ownership of image providers (it holds them in a shared + // pointer), so the provider is created without a parent. + engine->addImageProvider(QStringLiteral("icons"), new SvgIconProvider()); + return engine; +} + +} // namespace px::ui diff --git a/src/ui/components/svg_provider.h b/src/ui/components/svg_provider.h new file mode 100644 index 0000000..4795804 --- /dev/null +++ b/src/ui/components/svg_provider.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +class QObject; +class QQmlEngine; + +namespace px::ui { + +// Serves named SVG icons to QML via image://icons/?color=. +// An unknown name renders an empty SVG, so the image is transparent. +class SvgIconProvider : public QQuickImageProvider { +public: + SvgIconProvider(); + + QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override; +}; + +// The one way to build a QML engine: every QML surface shares it, and it always +// carries the `icons` provider, so image://icons/… URLs resolve. +QQmlEngine* createQmlEngine(QObject* parent); + +} // namespace px::ui diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp new file mode 100644 index 0000000..00d3f71 --- /dev/null +++ b/src/ui/components/toast.cpp @@ -0,0 +1,155 @@ +#include "ui/components/toast.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/icons.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +struct LevelStyle { + QStringView icon; + QStringView accent; +}; + +// Indexed by ToastLevel. +constexpr LevelStyle kLevelStyles[]{ + {u"info", u"PRIMARY"}, + {u"check-circle", u"SUCCESS"}, + {u"warning", u"WARNING"}, + {u"error", u"DANGER"}, +}; + +// Every icon name used here is in the icon table; a miss is a programming error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) : QWidget(parent) { + setObjectName(QStringLiteral("toast")); + setFixedWidth(320); + setAttribute(Qt::WA_StyledBackground, true); + + const LevelStyle& style = kLevelStyles[static_cast(level)]; + + opacity_effect_ = new QGraphicsOpacityEffect(this); + opacity_effect_->setOpacity(0.0); + setGraphicsEffect(opacity_effect_); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(12, 10, 8, 10); + layout->setSpacing(8); + + auto* icon_label = new QLabel(this); + icon_label->setPixmap(requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); + icon_label->setFixedSize(16, 16); + layout->addWidget(icon_label); + + auto* message_label = new QLabel(message, this); + message_label->setObjectName(QStringLiteral("toastMessage")); + message_label->setWordWrap(true); + layout->addWidget(message_label, 1); + + auto* close = new QPushButton(this); + close->setObjectName(QStringLiteral("toastClose")); + close->setIcon(requireIcon(u"close", 10, paletteValue(u"TEXT_DIM"))); + close->setFixedSize(20, 20); + close->setToolTip(QStringLiteral("Dismiss")); + connect(close, &QPushButton::clicked, this, &Toast::dismiss); + layout->addWidget(close); + + opacity_anim_ = new QPropertyAnimation(opacity_effect_, QByteArrayLiteral("opacity"), this); + opacity_anim_->setDuration(200); + opacity_anim_->setStartValue(0.0); + opacity_anim_->setEndValue(1.0); + + fade_out_anim_ = new QPropertyAnimation(opacity_effect_, QByteArrayLiteral("opacity"), this); + fade_out_anim_->setDuration(150); + fade_out_anim_->setEndValue(0.0); + connect(fade_out_anim_, &QPropertyAnimation::finished, this, &Toast::dismissed); +} + +void Toast::showWithAnimation() { + show(); + raise(); + opacity_anim_->setDirection(QAbstractAnimation::Direction::Forward); + opacity_anim_->start(); +} + +void Toast::dismiss() { + if (dismissing_) { + return; + } + dismissing_ = true; + opacity_anim_->stop(); + fade_out_anim_->setStartValue(opacity_effect_->opacity()); + fade_out_anim_->start(); +} + +ToastManager::ToastManager(QWidget* parent) : QWidget(parent) { + setObjectName(QStringLiteral("toastManager")); + setAttribute(Qt::WA_TransparentForMouseEvents, true); + + layout_ = new QVBoxLayout(this); + layout_->setContentsMargins(0, 0, 16, 16); + layout_->setSpacing(8); + layout_->setAlignment(Qt::AlignBottom | Qt::AlignRight); +} + +void ToastManager::showToast(const QString& message, ToastLevel level, int durationMs) { + auto* toast = new Toast(message, level, this); + layout_->addWidget(toast); + connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); + + toast->showWithAnimation(); + + if (durationMs > 0) { + QTimer::singleShot(durationMs, toast, &Toast::dismiss); + } +} + +void ToastManager::info(const QString& message, int durationMs) { + showToast(message, ToastLevel::Info, durationMs); +} + +void ToastManager::success(const QString& message, int durationMs) { + showToast(message, ToastLevel::Success, durationMs); +} + +void ToastManager::warning(const QString& message, int durationMs) { + showToast(message, ToastLevel::Warning, durationMs); +} + +void ToastManager::error(const QString& message, int durationMs) { + showToast(message, ToastLevel::Error, durationMs); +} + +void ToastManager::resizeToParent() { + if (const QWidget* parent = parentWidget()) { + setGeometry(parent->rect()); + } +} + +void ToastManager::remove(Toast* toast) { + toast->hide(); + layout_->removeWidget(toast); + toast->deleteLater(); +} + +} // namespace px::ui diff --git a/src/ui/components/toast.h b/src/ui/components/toast.h new file mode 100644 index 0000000..d8cec47 --- /dev/null +++ b/src/ui/components/toast.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +#include "ui/context.h" + +class QGraphicsOpacityEffect; +class QPropertyAnimation; +class QVBoxLayout; + +namespace px::ui { + +enum class ToastLevel { Info, Success, Warning, Error }; + +// One message card. Fades in on show; dismiss() fades it out and then emits dismissed(). +class Toast : public QWidget { + Q_OBJECT + +public: + Toast(const QString& message, ToastLevel level, QWidget* parent = nullptr); + + void showWithAnimation(); + void dismiss(); + +Q_SIGNALS: + void dismissed(); + +private: + QGraphicsOpacityEffect* opacity_effect_ = nullptr; + QPropertyAnimation* opacity_anim_ = nullptr; + QPropertyAnimation* fade_out_anim_ = nullptr; + bool dismissing_ = false; +}; + +// Stacks toasts bottom-right over its parent. Durations are milliseconds; 0 keeps the toast +// until its close button is clicked. +class ToastManager : public QWidget, public Notifier { + Q_OBJECT + +public: + explicit ToastManager(QWidget* parent = nullptr); + + void showToast(const QString& message, ToastLevel level = ToastLevel::Info, int durationMs = 3000); + + void info(const QString& message, int durationMs = 3000) override; + void success(const QString& message, int durationMs = 3000) override; + void warning(const QString& message, int durationMs = 4000) override; + void error(const QString& message, int durationMs = 5000) override; + + // Covers the parent's rectangle; call on every parent resize. + void resizeToParent(); + +private: + void remove(Toast* toast); + + QVBoxLayout* layout_ = nullptr; +}; + +} // namespace px::ui diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp new file mode 100644 index 0000000..0c61701 --- /dev/null +++ b/src/ui/components/unity_rich_text.cpp @@ -0,0 +1,135 @@ +#include "ui/components/unity_rich_text.h" +#include +#include +#include +#include +#include +#include + +namespace px::ui { +QString escape_html(const QString& text) { + QString out = text.toHtmlEscaped(); + return out.replace('\'', QStringLiteral("'")); +} +namespace { +QString unquote(QString value) { + while (!value.isEmpty() && (value.front() == '\'' || value.front() == '"')) value.remove(0, 1); + while (!value.isEmpty() && (value.back() == '\'' || value.back() == '"')) value.chop(1); + return value; +} +QString color(QString value) { + if (value.startsWith('#')) { + const QString hex = value.mid(1); + if (hex.size() == 3 || hex.size() == 4) { + QString expanded = "#"; + for (QChar c : hex) { expanded += c; expanded += c; } + return expanded; + } + if (hex.size() == 6) return value; + if (hex.size() == 8) return value.left(7); + } + static const QHash names{{"red", "#ff0000"}, {"green", "#00ff00"}, + {"blue", "#0000ff"}, {"white", "#ffffff"}, {"black", "#000000"}, + {"yellow", "#ffff00"}, {"cyan", "#00ffff"}, {"magenta", "#ff00ff"}, + {"gray", "#808080"}, {"grey", "#808080"}, {"orange", "#ffa500"}, {"purple", "#800080"}}; + return names.value(value.toLower(), "#ffffff"); +} +QString close_tag(const QString& tag) { + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return ""; + if (tag == "u" || tag == "underline" || tag == "size") return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "color") return ""; + if (tag == "align" || tag == "indent") return ""; + if (tag == "a" || tag == "link") return ""; + if (tag == "mark" || tag == "sub" || tag == "sup") return ""; + return {}; +} +QString open_tag(const QString& tag, QString attr) { + attr = unquote(attr); + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return ""; + if (tag == "u" || tag == "underline") return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "br" || tag == "sub" || tag == "sup") return "<" + tag + ">"; + if (tag == "color") { + static const QRegularExpression valid("^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$"); + return ""; + } + if (tag == "size" || tag == "indent") { + static const QRegularExpression number("^([+-]?\\d+(?:\\.\\d+)?)(px|em|%)?$"); + auto match = number.match(attr); + double value = tag == "size" ? 13.0 : 20.0; + if (match.hasMatch()) { + bool ok = false; + const double raw = match.captured(1).toDouble(&ok); + if (ok && std::isfinite(raw)) { + const QString unit = match.captured(2); + if (tag == "size") { + if (unit == "%") value = 12 * raw / 100; + else if (unit == "em") value = 12 * raw; + else if (attr.startsWith('+') || attr.startsWith('-')) value = 12 + raw; + else value = raw; + if (!std::isfinite(value)) value = 13; + } else if (unit == "%") value = 4 * raw; + else if (unit == "em") value = 16 * raw; + else value = raw; + } + } + if (tag == "size") return ""; + return "
    (std::clamp(value, -10000.0, 10000.0))) + "px;\">"; + } + if (tag == "align") { + attr = attr.toLower(); + if (attr == "justified" || attr == "flush") attr = "justify"; + if (attr != "left" && attr != "right" && attr != "center" && attr != "justify") attr = "left"; + return "
    "; + } + if (tag == "mark") { + QString background = attr.isEmpty() ? QStringLiteral("#ffff00") : color(attr); + if (attr.size() == 9 && attr.startsWith('#')) { + bool ok; + int alpha = attr.right(2).toInt(&ok, 16); + if (ok) background += QString::number(alpha / 255.0, 'f', 2); + } + return ""; + } + if (tag == "a" || tag == "link") { + const QUrl url(attr); + const QString scheme = url.scheme().toLower(); + if ((scheme == "http" || scheme == "https") && !url.authority().isEmpty()) + return ""; + } + return {}; +} +} +QString unity_rich_text_to_html(const QString& text) { + static const QRegularExpression tags("\\s]+))?\\s*>"); + QString result; + result.reserve(text.size()); + QStringList stack; + qsizetype end = 0; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + result += escape_html(text.mid(end, match.capturedStart() - end)); + const QString tag = match.captured(1).toLower(); + if (match.captured().startsWith("= 0) { + while (stack.size() > index) stack.removeLast(); + result += close_tag(tag); + } + } else { + const QString html = open_tag(tag, match.captured(2)); + if (!html.isEmpty()) { stack.append(tag); result += html; } + } + end = match.capturedEnd(); + } + result += escape_html(text.mid(end)); + result.replace('\n', "
    "); + result.replace("\\n", "
    "); + for (auto it = stack.crbegin(); it != stack.crend(); ++it) result += close_tag(*it); + return result; +} +} diff --git a/src/ui/components/unity_rich_text.h b/src/ui/components/unity_rich_text.h new file mode 100644 index 0000000..a4dcab6 --- /dev/null +++ b/src/ui/components/unity_rich_text.h @@ -0,0 +1,6 @@ +#pragma once +#include +namespace px::ui { +QString escape_html(const QString& text); +QString unity_rich_text_to_html(const QString& text); +} diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp new file mode 100644 index 0000000..203338a --- /dev/null +++ b/src/ui/components/view_rail_panel.cpp @@ -0,0 +1,42 @@ +#include "ui/components/view_rail_panel.h" + +#include +#include +#include +#include + +#include "ui/theme/constants.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +ViewRailPanel::ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, QQmlEngine* engine, + QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + setMinimumWidth(RAIL_MIN_WIDTH); + setMaximumWidth(RAIL_MAX_WIDTH); + + rootContext()->setContextProperty(QStringLiteral("railModel"), tabs); + rootContext()->setContextProperty(QStringLiteral("railHelpEntries"), helpEntries); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/IconRail.qml"))); + + QObject* root = rootObject(); + if (root == nullptr) { + spdlog::error("IconRail.qml failed to load: rootObject is null"); + return; + } + connect(root, SIGNAL(tabSelected(int)), this, SIGNAL(currentChanged(int))); + connect(root, SIGNAL(tabHovered(int)), this, SIGNAL(hovered(int))); + connect(root, SIGNAL(settingsRequested()), this, SIGNAL(settingsRequested())); + connect(root, SIGNAL(helpActionRequested(QString)), this, SIGNAL(helpActionRequested(QString))); +} + +void ViewRailPanel::setCurrent(int index) { + if (QObject* root = rootObject()) { + root->setProperty("currentIndex", index); + } +} + +} // namespace px::ui diff --git a/src/ui/components/view_rail_panel.h b/src/ui/components/view_rail_panel.h new file mode 100644 index 0000000..c26a747 --- /dev/null +++ b/src/ui/components/view_rail_panel.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +class QQmlEngine; +class QWidget; + +namespace px::ui { + +// Hosts IconRail.qml, the left navigation rail. `tabs` are maps with `icon` and `label` +// keys; `helpEntries` are maps with `id` and `label` keys for the Help menu. +class ViewRailPanel : public QQuickWidget { + Q_OBJECT + +public: + ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, QQmlEngine* engine, + QWidget* parent = nullptr); + + // Moves the highlighted tab without emitting currentChanged. + void setCurrent(int index); + +signals: + void currentChanged(int index); + void hovered(int index); + void settingsRequested(); + void helpActionRequested(const QString& actionId); +}; + +} // namespace px::ui diff --git a/src/ui/config.cpp b/src/ui/config.cpp new file mode 100644 index 0000000..cc18df0 --- /dev/null +++ b/src/ui/config.cpp @@ -0,0 +1,40 @@ +#include "ui/config.h" + +#include + +namespace px::ui { + +namespace { + +const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); + +} // namespace + +core::Result saveUiPrefs(const UiPrefs& prefs, const core::ConfigService& svc) { + return svc.save(kUiPrefsFile, prefs); +} + +UiPrefsService::UiPrefsService(core::ConfigService& svc) : svc_(svc) {} + +QCoro::Task> UiPrefsService::setup() { + auto loaded = svc_.load(kUiPrefsFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + prefs_ = std::move(*loaded); + co_return {}; +} + +const UiPrefs& UiPrefsService::prefs() const { + Q_ASSERT(prefs_.has_value()); + return *prefs_; +} + +core::Result UiPrefsService::savePrefs() const { + if (!prefs_) { + return {}; + } + return saveUiPrefs(*prefs_, svc_); +} + +} // namespace px::ui diff --git a/src/ui/config.h b/src/ui/config.h new file mode 100644 index 0000000..f03d1e4 --- /dev/null +++ b/src/ui/config.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +#include "core/config.h" +#include "core/error.h" +#include "ui/ui_prefs.h" + +namespace px::ui { + +// Writes ui_prefs.json. Other UI code calls this after changing prefs. +core::Result saveUiPrefs(const UiPrefs& prefs, const core::ConfigService& svc); + +// Owns the UiPrefs lifecycle: loaded once in setup(), saved on request. The +// ConfigService must outlive this object. +class UiPrefsService { +public: + explicit UiPrefsService(core::ConfigService& svc); + + QCoro::Task> setup(); + + // Valid only after a successful setup(). + const UiPrefs& prefs() const; + + core::Result savePrefs() const; + +private: + core::ConfigService& svc_; + std::optional prefs_; +}; + +} // namespace px::ui diff --git a/src/ui/context.cpp b/src/ui/context.cpp new file mode 100644 index 0000000..31998f5 --- /dev/null +++ b/src/ui/context.cpp @@ -0,0 +1,70 @@ +#include "ui/context.h" + +#include +#include + +#include "core/mod_service.h" + +namespace px::ui { + +RailView::RailView(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : QWidget(parent), ctx_(ctx), engine_(engine), app_(app) {} + +void RailView::openRoute(const QStringList&) {} + +void RailView::preload() {} + +void RailView::teardown() {} + +AppContext::AppContext(core::CoreContext& core, UiPrefs prefs) + : core_(core), ui_prefs_(std::move(prefs)) {} + +AppContext::~AppContext() = default; + +void AppContext::registerPlugin(std::unique_ptr plugin) { + plugins_.register_plugin(std::move(plugin)); +} + +void AppContext::addSettingsSection(SettingsSectionFactory factory) { + settings_sections_.push_back(std::move(factory)); +} + +void AppContext::navigate(const QString& url) const { + if (navigate_) { + navigate_(url); + } +} + +void AppContext::setNavigator(std::function navigate) { + navigate_ = std::move(navigate); +} + +core::Result AppContext::setupAll() { + if (auto core = core_.plugins().setup_all(core_); !core) { + return std::unexpected(core.error()); + } + return plugins_.setup_all(*this, core_.plugins().names()); +} + +QCoro::Task> AppContext::initAll() { + if (auto core = co_await core_.plugins().init_all(core_); !core) { + co_return std::unexpected(core.error()); + } + co_return co_await plugins_.init_all(*this); +} + +QCoro::Task> AppContext::refreshMods(bool full) { + if (auto reloaded = co_await core_.modService().reload(full); !reloaded) { + co_return std::unexpected(reloaded.error()); + } + co_return static_cast(core_.allMods().size()); +} + +QCoro::Task> AppContext::shutdownAll() { + if (auto ui = co_await plugins_.shutdown_all(); !ui) { + co_return std::unexpected(ui.error()); + } + co_return co_await core_.plugins().shutdown_all(); +} + +} // namespace px::ui diff --git a/src/ui/context.h b/src/ui/context.h new file mode 100644 index 0000000..c8ba182 --- /dev/null +++ b/src/ui/context.h @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "core/context.h" +#include "core/error.h" +#include "core/plugin.h" +#include "ui/settings_section.h" +#include "ui/ui_prefs.h" + +class QQmlEngine; + +namespace px::ui { + +class AppContext; + +// UI-layer plugins: same lifecycle as core plugins, but their context is the AppContext. +using UiPlugin = core::BasicPlugin; +using UiPluginRegistry = core::BasicPluginRegistry; + +// Toast sink. Durations are milliseconds; the defaults match the legacy toast manager. +class Notifier { +public: + virtual ~Notifier() = default; + + virtual void info(const QString& message, int durationMs = 3000) = 0; + virtual void success(const QString& message, int durationMs = 3000) = 0; + virtual void warning(const QString& message, int durationMs = 4000) = 0; + virtual void error(const QString& message, int durationMs = 5000) = 0; +}; + +// Class-level metadata of a rail view, read before the view is constructed. Each +// RailView subclass declares `static constexpr RailViewMeta kRailMeta`. +struct RailViewMeta { + QLatin1StringView viewId; + QLatin1StringView iconName; + QLatin1StringView label; + // The download queue: the header's downloads button opens it, and the header + // hides its own download progress while it is shown. + bool listsDownloads = false; +}; + +// Base of widgets registered on the navigation rail. +class RailView : public QWidget { + Q_OBJECT + +public: + RailView(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + + // Handles the part of a `modrim:///…` link after the view id. + virtual void openRoute(const QStringList& path); + + // Called when the rail reveals the view ahead of its first use, and when the window + // closes. Views that hold resources (e.g. web profiles) release them in teardown(). + virtual void preload(); + virtual void teardown(); + +protected: + core::CoreContext& ctx_; + QQmlEngine* engine_; + AppContext& app_; +}; + +using RailViewFactory = std::function; + +struct RailViewSpec { + RailViewMeta meta; + RailViewFactory create; +}; + +template +concept RailViewType = std::derived_from && requires { + V::kRailMeta; +}; + +// The UI composition context: owns the UI plugin registry, the rail and settings +// contributions, the toast sink and the navigator. Core state is read through +// coreContext(), which must outlive this object. +class AppContext { +public: + AppContext(core::CoreContext& core, UiPrefs prefs = {}); + AppContext(const AppContext&) = delete; + AppContext& operator=(const AppContext&) = delete; + AppContext(AppContext&&) = delete; + AppContext& operator=(AppContext&&) = delete; + ~AppContext(); + + // ── Plugin system (UI layer) ────────────────────── + void registerPlugin(std::unique_ptr plugin); + UiPluginRegistry& plugins() noexcept { return plugins_; } + + // ── Rail views ──────────────────────────────────── + // Appends, or inserts at `position` when given. + template + void addRailView(std::optional position = std::nullopt); + const std::vector& railViews() const noexcept { return rail_views_; } + + // ── Settings sections ───────────────────────────── + // Each section is built per dialog, parented to that dialog. + void addSettingsSection(SettingsSectionFactory factory); + const std::vector& settingsSections() const noexcept { return settings_sections_; } + + // ── Toasts ──────────────────────────────────────── + // Not owned; nullptr when no toast surface is shown. + Notifier* toasts() const noexcept { return toasts_; } + void setToasts(Notifier* notifier) noexcept { toasts_ = notifier; } + + // ── Navigation ──────────────────────────────────── + // Opens a `modrim://[/…]` link; a no-op until a navigator is set. + void navigate(const QString& url) const; + void setNavigator(std::function navigate); + + // ── UI prefs ────────────────────────────────────── + UiPrefs& uiPrefs() noexcept { return ui_prefs_; } + const UiPrefs& uiPrefs() const noexcept { return ui_prefs_; } + + // ── Core access ─────────────────────────────────── + core::CoreContext& coreContext() const noexcept { return core_; } + + // ── Lifecycle ───────────────────────────────────── + // Core plugins first, then UI plugins; UI plugins may depend on core plugin names. + core::Result setupAll(); + QCoro::Task> initAll(); + // Reloads mods and resolves to how many are now loaded. + QCoro::Task> refreshMods(bool full = false); + // UI plugins shut down before core plugins. + QCoro::Task> shutdownAll(); + +private: + core::CoreContext& core_; + UiPluginRegistry plugins_; + std::vector rail_views_; + std::vector settings_sections_; + Notifier* toasts_ = nullptr; + std::function navigate_; + UiPrefs ui_prefs_; +}; + +template +void AppContext::addRailView(std::optional position) { + RailViewSpec spec{ + V::kRailMeta, + [](core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) -> RailView* { + return new V(ctx, engine, parent, app); + }, + }; + if (position) { + rail_views_.insert(rail_views_.begin() + static_cast(*position), std::move(spec)); + } else { + rail_views_.push_back(std::move(spec)); + } +} + +} // namespace px::ui diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp new file mode 100644 index 0000000..a646ab8 --- /dev/null +++ b/src/ui/mod_selection.cpp @@ -0,0 +1,59 @@ +#include "ui/mod_selection.h" + +#include + +#include "core/services/diagnostics_service.h" + +namespace px::ui { + +ModSelectionPresenter::ModSelectionPresenter(core::CoreContext& ctx, ModInfoSink& sink) + : ctx_(ctx), sink_(sink) {} + +QCoro::Task ModSelectionPresenter::show(QString uuid) { + const std::uint64_t generation = ++generation_; + + if (uuid.isEmpty()) { + sink_.clear(); + co_return; + } + + const auto& mods = ctx_.allMods(); + const auto found = mods.constFind(uuid); + if (found == mods.cend()) { + sink_.clear(); + co_return; + } + + const core::ListedMod& mod = *found.value(); + sink_.showMod(mod, ctx_.diagnosticsService().issues_for(uuid)); + + std::optional packageId; + if (const auto* about = dynamic_cast(&mod)) { + packageId = about->package_id.str(); + } + const QStringList activePackageIds = resolveActivePackageIds(); + + if (generation != generation_) { + co_return; + } + co_await sink_.setTimeAnalytics(packageId, activePackageIds); +} + +void ModSelectionPresenter::clear() { + ++generation_; + sink_.clear(); +} + +QStringList ModSelectionPresenter::resolveActivePackageIds() const { + const QSet active(ctx_.activeUuids().cbegin(), ctx_.activeUuids().cend()); + QStringList packageIds; + for (const core::ListedModPtr& mod : ctx_.allMods()) { + const auto* about = dynamic_cast(mod.get()); + if (about && active.contains(about->uuid())) { + packageIds.append(about->package_id.str()); + } + } + return packageIds; +} + +} // namespace px::ui diff --git a/src/ui/mod_selection.h b/src/ui/mod_selection.h new file mode 100644 index 0000000..1e6f039 --- /dev/null +++ b/src/ui/mod_selection.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "core/context.h" +#include "core/models/metadata/structures.h" +#include "core/models/view/diagnostics.h" + +namespace px::ui { + +// The mod-details surface the presenter drives. The mod info panel implements it. +class ModInfoSink { +public: + virtual ~ModInfoSink() = default; + + virtual void clear() = 0; + virtual void showMod(const core::ListedMod& mod, const QList& issues) = 0; + // `packageId` is nullopt for mods that have no package id (non-About.xml mods). + virtual QCoro::Task setTimeAnalytics(const std::optional& packageId, + const QStringList& activePackageIds) = 0; +}; + +// Loads a selected mod's details into a ModInfoSink for any view. +class ModSelectionPresenter { +public: + ModSelectionPresenter(core::CoreContext& ctx, ModInfoSink& sink); + + // A stale show() (superseded by a later one or clear()) stops before its analytics await. + QCoro::Task show(QString uuid); + void clear(); + +private: + QStringList resolveActivePackageIds() const; + + core::CoreContext& ctx_; + ModInfoSink& sink_; + std::uint64_t generation_ = 0; +}; + +} // namespace px::ui diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp new file mode 100644 index 0000000..7a718f5 --- /dev/null +++ b/src/ui/models/impact.cpp @@ -0,0 +1,19 @@ +#include "ui/models/impact.h" +#include "core/services/startup_impact_service/models.h" +#include "ui/theme/palette.h" +#include +namespace px::ui { +QString format_duration(double seconds) { + if (seconds < 0.001) return QStringLiteral("0 ms"); + if (seconds < 1) return QStringLiteral("%1 ms").arg(static_cast(std::nearbyint(seconds * 1000))); + if (seconds < 60) return QStringLiteral("%1 s").arg(seconds, 0, 'f', 2); + const auto rounded = static_cast(std::nearbyint(seconds)); + return QStringLiteral("%1m %2s").arg(rounded / 60).arg(rounded % 60, 2, 10, QLatin1Char('0')); +} +QString impact_color(double seconds) { + using namespace core::startup_impact; + if (seconds < IMPACT_WARN_THRESHOLD_S) return paletteValue(u"SUCCESS"); + if (seconds < IMPACT_HIGH_THRESHOLD_S) return paletteValue(u"WARNING"); + return paletteValue(u"DANGER"); +} +} diff --git a/src/ui/models/impact.h b/src/ui/models/impact.h new file mode 100644 index 0000000..ddefd82 --- /dev/null +++ b/src/ui/models/impact.h @@ -0,0 +1,6 @@ +#pragma once +#include +namespace px::ui { +QString format_duration(double seconds); +QString impact_color(double seconds); +} diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp new file mode 100644 index 0000000..b4a7377 --- /dev/null +++ b/src/ui/models/mod_list_model.cpp @@ -0,0 +1,200 @@ +#include "ui/models/mod_list_model.h" +#include "core/services/diagnostics_service.h" +#include "ui/models/impact.h" +#include "ui/theme/palette.h" +#include +#include + +namespace px::ui { +QString provider_label(const QString& provider_id, const QString& package_id) { + const auto pid = provider_id.trimmed().toLower(); + const auto pkg = package_id.trimmed().toLower(); + if (pid == "steam" || pid == "downloaded") return QStringLiteral("Workshop"); + if (pid == "local") return QStringLiteral("Local"); + if (pid == "core") return !pkg.isEmpty() && pkg != "ludeon.rimworld" ? QStringLiteral("DLC") : QStringLiteral("Core"); + if (pkg.startsWith("ludeon.rimworld.")) return QStringLiteral("DLC"); + if (pkg == "ludeon.rimworld") return QStringLiteral("Core"); + return pid.isEmpty() ? pid : pid.first(1).toUpper() + pid.sliced(1); +} +QString section_name(bool checked, int active_count, int inactive_count) { + return checked || active_count == 0 || inactive_count == 0 ? QString{} : QStringLiteral("Inactive (%1)").arg(inactive_count); +} +ModListModel::ModListModel(QHash colors, core::DiagnosticsService* diagnostics, QObject* parent) + : QAbstractListModel(parent), provider_colors_(std::move(colors)), diagnostics_(diagnostics) { + if (diagnostics_) diagnostics_connection_ = diagnostics_->diagnostics_summary_changed.connect( + [this](const auto& summaries) { diagnostics_changed(summaries); }); +} +ModListModel::~ModListModel() { + if (diagnostics_) diagnostics_->diagnostics_summary_changed.disconnect(diagnostics_connection_); +} +int ModListModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } +const ModItem* ModListModel::get_item(int row) const { return row >= 0 && row < items_.size() ? &items_[row] : nullptr; } +const ModItem* ModListModel::get_item_by_uuid(const QString& uuid) const { + for (const auto& item : items_) if (item.uuid == uuid) return &item; + return nullptr; +} +QVariant ModListModel::data(const QModelIndex& index, int role) const { + const auto* item = index.isValid() ? get_item(index.row()) : nullptr; + if (!item) return {}; + const auto* about = dynamic_cast(item->mod.get()); + switch (role) { + case Qt::DisplayRole: return item->mod->name; + case Qt::ToolTipRole: return item->mod->description; + case CheckStateRole: return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); + case PackageIdRole: return about ? about->package_id.str() : QString{}; + case ModVersionRole: return about ? about->mod_version : QString{}; + case ProviderColorRole: return item->provider_color; + case UuidRole: return item->uuid; + case StartupImpactRole: return item->startup_impact_s; + case StartupImpactColorRole: return impact_color(item->startup_impact_s); + case StartupImpactTextRole: return format_duration(item->startup_impact_s); + case LoadIndexRole: return item->load_index; + case IsActiveRole: return item->checked; + case SectionNameRole: return section_name(item->checked, active_count_, static_cast(items_.size()) - active_count_); + case ProviderLabelRole: return item->provider_label; + case HasErrorRole: case HasWarningRole: case ErrorTooltipRole: case WarningTooltipRole: { + const auto diag = diagnostics_ ? diagnostics_->summary_for(item->uuid) : std::nullopt; + if (role == HasErrorRole) return diag && diag->has_errors ? 1 : 0; + if (role == HasWarningRole) return diag && diag->has_warnings ? 1 : 0; + if (role == ErrorTooltipRole) return diag ? diag->error_tooltip : QString{}; + return diag ? diag->warning_tooltip : QString{}; + } + default: return {}; + } +} +bool ModListModel::setData(const QModelIndex& index, const QVariant& value, int role) { + if (!index.isValid() || !get_item(index.row()) || role != CheckStateRole) return false; + set_check_states({index.row()}, value.toInt() == Qt::Checked); + return true; +} +Qt::ItemFlags ModListModel::flags(const QModelIndex& index) const { + if (!index.isValid()) return Qt::ItemIsDropEnabled; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | Qt::ItemIsUserCheckable; +} +QHash ModListModel::roleNames() const { + return {{Qt::DisplayRole,"name"},{CheckStateRole,"checkState"},{PackageIdRole,"packageId"}, + {ModVersionRole,"modVersion"},{ProviderColorRole,"providerColor"},{UuidRole,"uuid"}, + {HasErrorRole,"hasError"},{HasWarningRole,"hasWarning"},{ErrorTooltipRole,"errorTooltip"}, + {WarningTooltipRole,"warningTooltip"},{StartupImpactRole,"startupImpact"},{LoadIndexRole,"loadIndex"}, + {IsActiveRole,"isActive"},{SectionNameRole,"sectionName"},{ProviderLabelRole,"providerLabel"}, + {StartupImpactColorRole,"startupImpactColor"},{StartupImpactTextRole,"startupImpactText"},{Qt::ToolTipRole,"toolTip"}}; +} +void ModListModel::update_provider_colors(QHash colors) { provider_colors_ = std::move(colors); } +ModItem ModListModel::make_item(const QString& uuid, core::ListedModPtr mod, bool checked) const { + const auto* about = dynamic_cast(mod.get()); + auto label = provider_label(mod->provider_id, about ? about->package_id.str() : QString{}); + auto color = provider_colors_.value(mod->provider_id, paletteValue(u"TEXT_DIM")); + return {std::move(mod), uuid, checked, std::move(color), 0, -1, std::move(label)}; +} +void ModListModel::load_mods(const QHash& mods, const QStringList& active_uuids) { + beginResetModel(); + items_.clear(); + items_.reserve(mods.size()); + const QSet active(active_uuids.begin(), active_uuids.end()); + for (const auto& uuid : active_uuids) { + const auto it = mods.constFind(uuid); + if (it != mods.cend()) items_.append(make_item(uuid, it.value(), true)); + } + QList inactive; + inactive.reserve(mods.size() - items_.size()); + for (auto it = mods.cbegin(); it != mods.cend(); ++it) + if (!active.contains(it.key())) inactive.append(make_item(it.key(), it.value(), false)); + std::stable_sort(inactive.begin(), inactive.end(), [](const auto& a, const auto& b) { return a.mod->name.toLower() < b.mod->name.toLower(); }); + items_.append(std::move(inactive)); + update_load_indices(); + endResetModel(); +} +QStringList ModListModel::active_uuids() const { + QStringList result; + result.reserve(active_count_); + for (const auto& item : items_) if (item.checked) result.append(item.uuid); + return result; +} +QList ModListModel::update_load_indices() { + QList changed; + int count = 0; + for (int row = 0; row < items_.size(); ++row) { + auto& item = items_[row]; + const int index = item.checked ? ++count : -1; + if (item.load_index != index) { item.load_index = index; changed.append(row); } + } + if (active_count_ != count) { active_count_ = count; emit active_count_changed(); } + return changed; +} +bool ModListModel::move_row(int source, int target) { + if (source == target || !get_item(source) || !get_item(target)) return false; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + items_.move(source, target); + endMoveRows(); + const auto changed = update_load_indices(); + if (!changed.isEmpty()) emit dataChanged(index(changed.first()), index(changed.last()), {LoadIndexRole,SectionNameRole}); + return true; +} +void ModListModel::commitOrder(const QStringList& uuids) { + QHash old_rows; + for (int row = 0; row < items_.size(); ++row) old_rows.insert(items_[row].uuid, row); + QList order; + order.reserve(items_.size()); + QSet ordered; + for (const auto& uuid : uuids) if (old_rows.contains(uuid)) { order.append(old_rows.value(uuid)); ordered.insert(uuid); } + QList inactive; + for (int row = 0; row < items_.size(); ++row) if (!ordered.contains(items_[row].uuid)) { + if (items_[row].checked) order.append(row); else inactive.append(row); + } + std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { return items_[a].mod->name.toLower() < items_[b].mod->name.toLower(); }); + order.append(inactive); + bool same = order.size() == items_.size(); + for (int i = 0; same && i < order.size(); ++i) same = order[i] == i; + if (same) return; + emit layoutAboutToBeChanged(); + const auto persistent = persistentIndexList(); + QList next; + next.reserve(order.size()); + QHash new_rows; + for (int row = 0; row < order.size(); ++row) { new_rows.insert(order[row], row); next.append(items_[order[row]]); } + items_ = std::move(next); + update_load_indices(); + QModelIndexList replacements; + replacements.reserve(persistent.size()); + for (const auto& previous : persistent) replacements.append(index(new_rows.value(previous.row(), -1))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); +} +void ModListModel::set_checkable(const QSet& uuids, bool checked) { + QList rows; + for (int row = 0; row < items_.size(); ++row) + if (uuids.contains(items_[row].uuid) && items_[row].checked != checked) rows.append(row); + set_check_states(rows, checked); +} +void ModListModel::set_check_states(const QList& rows, bool checked) { + int first = static_cast(items_.size()); + for (int row : rows) if (get_item(row) && items_[row].checked != checked) { + items_[row].checked = checked; first = std::min(first, row); + } + if (first == items_.size()) return; + const auto load_changed = update_load_indices(); + if (!load_changed.isEmpty()) first = std::min(first, load_changed.first()); + emit dataChanged(index(first), index(rowCount()-1), {CheckStateRole,LoadIndexRole,IsActiveRole,SectionNameRole}); + emit active_mods_changed(); +} +void ModListModel::set_startup_impact(const QHash& impacts) { + if (items_.isEmpty()) return; + for (auto& item : items_) { + const auto* about = dynamic_cast(item.mod.get()); + item.startup_impact_s = about ? impacts.value(about->package_id.str()) : 0; + } + emit dataChanged(index(0), index(rowCount()-1), {StartupImpactRole,StartupImpactColorRole,StartupImpactTextRole}); +} +void ModListModel::diagnostics_changed(const QHash& summaries) { + if (items_.isEmpty()) return; + int first = rowCount(), last = -1; + for (int row = 0; row < items_.size(); ++row) { + const auto& uuid = items_[row].uuid; + if (last_diagnostics_.contains(uuid) != summaries.contains(uuid) || last_diagnostics_.value(uuid) != summaries.value(uuid)) { + first = std::min(first, row); last = row; + } + } + last_diagnostics_ = summaries; + if (last >= 0) emit dataChanged(index(first), index(last), {HasErrorRole,HasWarningRole,ErrorTooltipRole,WarningTooltipRole}); +} +} diff --git a/src/ui/models/mod_list_model.h b/src/ui/models/mod_list_model.h new file mode 100644 index 0000000..6d67f01 --- /dev/null +++ b/src/ui/models/mod_list_model.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "core/models/metadata/structures.h" +#include "core/models/view/diagnostics.h" + +namespace px::core { class DiagnosticsService; } +namespace px::ui { +QString provider_label(const QString& provider_id, const QString& package_id = {}); +QString section_name(bool checked, int active_count, int inactive_count); + +struct ModItem { + core::ListedModPtr mod; + QString uuid; + bool checked = false; + QString provider_color; + double startup_impact_s = 0; + int load_index = -1; + QString provider_label; +}; + +class ModListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int activeCount READ active_count NOTIFY active_count_changed) +public: + enum Role { + CheckStateRole = Qt::UserRole + 1, PackageIdRole, ModVersionRole, ProviderColorRole, + UuidRole, HasErrorRole, HasWarningRole, ErrorTooltipRole, WarningTooltipRole, + StartupImpactRole, LoadIndexRole, IsActiveRole, SectionNameRole, ProviderLabelRole, + StartupImpactColorRole, StartupImpactTextRole + }; + Q_ENUM(Role) + explicit ModListModel(QHash provider_colors = {}, + core::DiagnosticsService* diagnostics = nullptr, QObject* parent = nullptr); + ~ModListModel() override; + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + void update_provider_colors(QHash colors); + void load_mods(const QHash& mods, const QStringList& active_uuids); + QStringList active_uuids() const; + int active_count() const { return active_count_; } + const ModItem* get_item(int row) const; + const ModItem* get_item_by_uuid(const QString& uuid) const; + bool move_row(int source_row, int target_row); + void commitOrder(const QStringList& uuids); + void set_checkable(const QSet& uuids, bool checked); + void set_check_states(const QList& rows, bool checked); + void set_startup_impact(const QHash& impacts = {}); +signals: + void active_mods_changed(); + void active_count_changed(); +private: + QList update_load_indices(); + ModItem make_item(const QString& uuid, core::ListedModPtr mod, bool checked) const; + void diagnostics_changed(const QHash& summaries); + QHash provider_colors_; + QList items_; + int active_count_ = 0; + core::DiagnosticsService* diagnostics_; + std::uint64_t diagnostics_connection_ = 0; + QHash last_diagnostics_; +}; +} diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp new file mode 100644 index 0000000..9523796 --- /dev/null +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -0,0 +1,138 @@ +#include "ui/models/mod_list_proxy_model.h" +#include +#include +namespace px::ui { +ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), source_(source) { + connect(source_, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::source_data_changed); + connect(source_, &QAbstractItemModel::layoutChanged, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); + connect(source_, &QAbstractItemModel::modelReset, this, [this] { beginResetModel(); rebuild(); endResetModel(); }); + connect(source_, &QAbstractItemModel::rowsMoved, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); + connect(source_, &QAbstractItemModel::rowsInserted, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); + connect(source_, &QAbstractItemModel::rowsRemoved, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); + rebuild(); +} +int ModListProxyModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(visible_uuids_.size()); } +void ModListProxyModel::rebuild_source_mapping() { + source_rows_.clear(); + for (int row = 0; row < source_->rowCount(); ++row) source_rows_.insert(source_->get_item(row)->uuid, row); +} +bool ModListProxyModel::accept_item(const ModItem& item) const { + if (sidebar_uuids_ && !sidebar_uuids_->contains(item.uuid)) return false; + if (search_text_.isEmpty() || item.mod->name.trimmed().toCaseFolded().contains(search_text_)) return true; + const auto* about = dynamic_cast(item.mod.get()); + return about && about->package_id.str().trimmed().toCaseFolded().contains(search_text_); +} +void ModListProxyModel::rebuild() { + rebuild_source_mapping(); + visible_uuids_.clear(); proxy_rows_.clear(); + for (int row = 0; row < source_->rowCount(); ++row) { + const auto& item = *source_->get_item(row); + if (accept_item(item)) { proxy_rows_.insert(item.uuid, static_cast(visible_uuids_.size())); visible_uuids_.append(item.uuid); } + } + update_visible_counts(); +} +void ModListProxyModel::update_visible_counts() { + int active = 0; + for (const auto& uuid : visible_uuids_) { + const auto* item = source_->get_item(source_rows_.value(uuid, -1)); + if (item && item->checked) ++active; + } + const bool changed = active != visible_active_count_; + visible_active_count_ = active; + visible_inactive_count_ = static_cast(visible_uuids_.size()) - active; + if (changed) emit active_count_changed(); +} +int ModListProxyModel::proxy_row_for_uuid(const QString& uuid) const { return proxy_rows_.value(uuid, -1); } +QModelIndex ModListProxyModel::mapToSource(const QModelIndex& index) const { + if (!index.isValid() || index.row() < 0 || index.row() >= visible_uuids_.size()) return {}; + return source_->index(source_rows_.value(visible_uuids_[index.row()], -1)); +} +QModelIndex ModListProxyModel::mapFromSource(const QModelIndex& source_index) const { + const auto* item = source_index.isValid() ? source_->get_item(source_index.row()) : nullptr; + return item ? index(proxy_row_for_uuid(item->uuid)) : QModelIndex{}; +} +QVariant ModListProxyModel::data(const QModelIndex& proxy_index, int role) const { + const auto source_index = mapToSource(proxy_index); + if (!source_index.isValid()) return {}; + if (role == ModListModel::SectionNameRole) { + return section_name(source_->get_item(source_index.row())->checked, visible_active_count_, visible_inactive_count_); + } + return source_->data(source_index, role); +} +bool ModListProxyModel::setData(const QModelIndex& index, const QVariant& value, int role) { + const auto source_index = mapToSource(index); + return source_index.isValid() && source_->setData(source_index, value, role); +} +Qt::ItemFlags ModListProxyModel::flags(const QModelIndex& index) const { + if (!index.isValid()) return Qt::ItemIsDropEnabled; + const auto source_index = mapToSource(index); + return source_index.isValid() ? source_->flags(source_index) : Qt::NoItemFlags; +} +QHash ModListProxyModel::roleNames() const { return source_->roleNames(); } +void ModListProxyModel::layout_rebuild() { + const auto persistent = persistentIndexList(); + QStringList uuids; + uuids.reserve(persistent.size()); + for (const auto& index : persistent) uuids.append(index.isValid() && index.row() < visible_uuids_.size() ? visible_uuids_[index.row()] : QString{}); + emit layoutAboutToBeChanged(); + rebuild(); + QModelIndexList replacements; + replacements.reserve(uuids.size()); + for (const auto& uuid : uuids) replacements.append(index(proxy_row_for_uuid(uuid))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); +} +void ModListProxyModel::set_search_filter(const QString& text) { + const auto normalized = text.trimmed().toCaseFolded(); + if (normalized == search_text_) return; + const bool was_filtered = is_filtered(); + search_text_ = normalized; + layout_rebuild(); + if (was_filtered != is_filtered()) emit is_filtered_changed(); +} +void ModListProxyModel::set_sidebar_filter(std::optional> uuids) { + if (sidebar_uuids_ == uuids) return; + const bool was_filtered = is_filtered(); + sidebar_uuids_ = std::move(uuids); + layout_rebuild(); + if (was_filtered != is_filtered()) emit is_filtered_changed(); +} +bool ModListProxyModel::move_row(int source, int target, bool clamp_to_active) { + if (source < 0 || source >= visible_uuids_.size()) return false; + const auto uuid = visible_uuids_[source]; + const int source_row = source_rows_.value(uuid, -1); + if (clamp_to_active) { + const auto* item = source_->get_item(source_row); + if (is_filtered() || !item || !item->checked) return false; + target = std::max(0, std::min(target, visible_active_count_ - 1)); + } + if (source == target || target < 0 || target >= visible_uuids_.size()) return false; + const int target_row = source_rows_.value(visible_uuids_[target], -1); + if (source_row < 0 || target_row < 0) return false; + // Source data signals refer to post-move rows, while the proxy is still moving. + in_proxy_move_ = true; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + visible_uuids_.move(source, target); + proxy_rows_.clear(); + for (int row = 0; row < visible_uuids_.size(); ++row) proxy_rows_.insert(visible_uuids_[row], row); + source_->move_row(source_row, target_row); + rebuild_source_mapping(); + endMoveRows(); + in_proxy_move_ = false; + emit dataChanged(index(std::min(source,target)), index(std::max(source,target)), {ModListModel::LoadIndexRole,ModListModel::SectionNameRole}); + return true; +} +void ModListProxyModel::source_data_changed(const QModelIndex& top, const QModelIndex& bottom, const QList& roles) { + if (in_proxy_move_) return; + if (roles.isEmpty() || roles.contains(ModListModel::CheckStateRole) || roles.contains(ModListModel::IsActiveRole)) update_visible_counts(); + int first = rowCount(), last = -1; + for (int row = top.row(); row <= bottom.row(); ++row) { + const auto* item = source_->get_item(row); + if (!item) continue; + const int proxy_row = proxy_row_for_uuid(item->uuid); + if (proxy_row < 0) continue; + first = std::min(first, proxy_row); last = std::max(last, proxy_row); + } + if (last >= 0) emit dataChanged(index(first), index(last), roles); +} +} diff --git a/src/ui/models/mod_list_proxy_model.h b/src/ui/models/mod_list_proxy_model.h new file mode 100644 index 0000000..9c58827 --- /dev/null +++ b/src/ui/models/mod_list_proxy_model.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include "ui/models/mod_list_model.h" +namespace px::ui { +class ModListProxyModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(bool isFiltered READ is_filtered NOTIFY is_filtered_changed) + Q_PROPERTY(int activeCount READ active_count NOTIFY active_count_changed) +public: + explicit ModListProxyModel(ModListModel* source, QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + QModelIndex mapToSource(const QModelIndex& index) const; + QModelIndex mapFromSource(const QModelIndex& index) const; + int proxy_row_for_uuid(const QString& uuid) const; + void set_search_filter(const QString& text); + void set_sidebar_filter(std::optional> uuids); + bool is_filtered() const { return !search_text_.isEmpty() || sidebar_uuids_.has_value(); } + int active_count() const { return visible_active_count_; } + bool move_row(int source, int target, bool clamp_to_active = false); +signals: + void is_filtered_changed(); + void active_count_changed(); +private: + void rebuild(); + void rebuild_source_mapping(); + void layout_rebuild(); + void update_visible_counts(); + bool accept_item(const ModItem& item) const; + void source_data_changed(const QModelIndex& top, const QModelIndex& bottom, const QList& roles); + ModListModel* source_; + QString search_text_; + std::optional> sidebar_uuids_; + QStringList visible_uuids_; + QHash source_rows_, proxy_rows_; + bool in_proxy_move_ = false; + int visible_active_count_ = 0; + int visible_inactive_count_ = 0; +}; +} diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp new file mode 100644 index 0000000..7bf32c5 --- /dev/null +++ b/src/ui/models/sidebar_model.cpp @@ -0,0 +1,74 @@ +#include "ui/models/sidebar_model.h" +#include "ui/theme/palette.h" +namespace px::ui { +SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} +int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } +QVariant SidebarModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; + const auto& item = items_[index.row()]; + switch (role) { + case Qt::DisplayRole: case LabelRole: return item.entry.label; + case CountRole: return item.entry.count(); + case BadgeBgRole: return item.badge_bg; + case BadgeFgRole: return item.badge_fg; + case IconRole: return item.icon_name; + case IconColorRole: return item.icon_color; + case SectionRole: return item.section_name; + default: return {}; + } +} +QHash SidebarModel::roleNames() const { + return {{LabelRole,"filterLabel"},{CountRole,"count"},{BadgeBgRole,"badgeBg"},{BadgeFgRole,"badgeFg"}, + {IconRole,"iconName"},{IconColorRole,"iconColor"},{SectionRole,"sectionName"}}; +} +SidebarItem SidebarModel::make_item(const core::view::SidebarEntry& entry) { + using Kind = core::view::SidebarEntry::Kind; + SidebarItem item{entry,paletteValue(u"ELEVATE_4"),paletteValue(u"TEXT_MUTED"),QStringLiteral("grid"),paletteValue(u"TEXT_DIM"),QStringLiteral("Status")}; + switch (entry.kind) { + case Kind::All: break; + case Kind::Active: item.icon_name = QStringLiteral("check-circle"); break; + case Kind::Inactive: item.icon_name = QStringLiteral("ban"); break; + case Kind::WithErrors: + item.badge_bg = paletteValue(u"DANGER_BG_QML"); item.badge_fg = item.icon_color = paletteValue(u"DANGER"); item.icon_name = QStringLiteral("error"); break; + case Kind::WithWarnings: + item.badge_bg = paletteValue(u"WARNING_BG_QML"); item.badge_fg = item.icon_color = paletteValue(u"WARNING"); item.icon_name = QStringLiteral("warning"); break; + case Kind::Provider: { + item.section_name = QStringLiteral("Providers"); + item.icon_name = QStringLiteral("folder"); + const auto label = entry.label.toLower(); + if (label.contains("steam") || label.contains("downloaded")) item.icon_name = QStringLiteral("steam"); + else if (label.contains("local")) item.icon_name = QStringLiteral("folder"); + else if (label.contains("core")) item.icon_name = QStringLiteral("grid"); + else if (label.contains("git")) item.icon_name = QStringLiteral("git"); + break; + } + } + return item; +} +void SidebarModel::set_entries(const QList& entries) { + beginResetModel(); items_.clear(); items_.reserve(entries.size()); + for (const auto& entry : entries) items_.append(make_item(entry)); + endResetModel(); +} +void SidebarModel::update_entries(const QList& entries) { + if (entries.size() != items_.size()) { set_entries(entries); return; } + QSet changed; + for (int row = 0; row < entries.size(); ++row) { + auto next = make_item(entries[row]); + const auto& old = items_[row]; + if (old.entry.label != next.entry.label) changed.insert(LabelRole); + if (old.entry.count() != next.entry.count()) changed.insert(CountRole); + if (old.badge_bg != next.badge_bg) changed.insert(BadgeBgRole); + if (old.badge_fg != next.badge_fg) changed.insert(BadgeFgRole); + if (old.icon_name != next.icon_name) changed.insert(IconRole); + if (old.icon_color != next.icon_color) changed.insert(IconColorRole); + if (old.section_name != next.section_name) changed.insert(SectionRole); + items_[row] = std::move(next); + } + if (!changed.isEmpty() && !items_.isEmpty()) emit dataChanged(index(0), index(rowCount()-1), changed.values()); +} +std::optional SidebarModel::get_entry(int row) const { + if (row < 0 || row >= items_.size()) return std::nullopt; + return items_[row].entry; +} +} diff --git a/src/ui/models/sidebar_model.h b/src/ui/models/sidebar_model.h new file mode 100644 index 0000000..dbbff8e --- /dev/null +++ b/src/ui/models/sidebar_model.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include "core/models/view/sidebar.h" +namespace px::ui { +struct SidebarItem { + core::view::SidebarEntry entry; + QString badge_bg, badge_fg, icon_name, icon_color, section_name; +}; +class SidebarModel : public QAbstractListModel { + Q_OBJECT +public: + enum Role { LabelRole = Qt::UserRole + 1, CountRole, BadgeBgRole, BadgeFgRole, IconRole, IconColorRole, SectionRole }; + Q_ENUM(Role) + explicit SidebarModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void set_entries(const QList& entries); + void update_entries(const QList& entries); + std::optional get_entry(int row) const; +private: + static SidebarItem make_item(const core::view::SidebarEntry& entry); + QList items_; +}; +} diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp new file mode 100644 index 0000000..3a8d23e --- /dev/null +++ b/src/ui/navigation.cpp @@ -0,0 +1,34 @@ +#include "ui/navigation.h" + +#include +#include + +namespace px::ui { + +QString Route::url() const { return buildRoute(view_id, path); } + +QString buildRoute(const QString& viewId, const QStringList& path) { + QString url = QStringLiteral("%1://%2").arg(QString(kRouteScheme), viewId); + for (const QString& part : path) { + // An empty exclusion set encodes everything but the RFC 3986 unreserved characters. + url += QLatin1Char('/') + QString::fromLatin1(part.toUtf8().toPercentEncoding()); + } + return url; +} + +std::optional parseRoute(const QString& url) { + const QUrl parsed(url, QUrl::StrictMode); + if (!parsed.isValid() || parsed.scheme() != kRouteScheme || parsed.authority().isEmpty()) { + return std::nullopt; + } + + // Split the encoded path first, then decode each part, so an encoded '/' stays inside its segment. + Route route{parsed.authority(), {}}; + const QStringList encodedParts = parsed.path(QUrl::FullyEncoded).split(QLatin1Char('/'), Qt::SkipEmptyParts); + for (const QString& part : encodedParts) { + route.path.append(QUrl::fromPercentEncoding(part.toUtf8())); + } + return route; +} + +} // namespace px::ui diff --git a/src/ui/navigation.h b/src/ui/navigation.h new file mode 100644 index 0000000..7740494 --- /dev/null +++ b/src/ui/navigation.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include + +namespace px::ui { + +// modrim://[/…] links that open a view and optionally a sub-route inside it. +inline constexpr QLatin1StringView kRouteScheme{"modrim"}; +inline constexpr QLatin1StringView kSettingsViewId{"settings"}; + +struct Route { + QString view_id; + QStringList path; + + QString url() const; + + bool operator==(const Route&) const = default; +}; + +// Each path part is percent-encoded with nothing left unescaped, so ids containing +// '/' or ':' round-trip. +QString buildRoute(const QString& viewId, const QStringList& path = {}); + +// Parses `modrim://[/…]`; anything else is not a route. +std::optional parseRoute(const QString& url); + +} // namespace px::ui diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp new file mode 100644 index 0000000..e76d160 --- /dev/null +++ b/src/ui/panels/about_panel.cpp @@ -0,0 +1,233 @@ +#include "ui/panels/about_panel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/version.h" +#include "ui/components/dialog_chrome.h" +#include "ui/context.h" +#include "ui/panels/upload_report_dialog.h" + +namespace px::ui { +QStringList AboutPanel::environment() { +#if defined(__clang__) + const QString compiler = QStringLiteral("Clang " __clang_version__); +#elif defined(__GNUC__) + const QString compiler = QStringLiteral("GCC " __VERSION__); +#elif defined(_MSC_VER) + const QString compiler = QStringLiteral("MSVC %1").arg(_MSC_VER); +#else + const QString compiler = QStringLiteral("C++23"); +#endif + return {QStringLiteral("C++23 (%1)").arg(compiler), QStringLiteral("Qt %1").arg(qVersion()), + QStringLiteral("%1 %2 (%3)").arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture())}; +} + +QString AboutPanel::systemInformation() const { + return (QStringList{QStringLiteral("PxModRim %1").arg(core::appVersion())} + environment()).join(u'\n'); +} + +QString AboutPanel::creditsHtml() { + return QStringLiteral( + "

    PxModRim

    Created and maintained by PyXiion. " + "Licensed under GPL-3.0-or-later.

    " + "

    Inspiration

    RimSort

    " + "

    C++ dependencies

    " + "" + "" + "" + "" + "" + "" + "" + "
    LibraryVersionLicense
    Qt%1LGPL-3.0 / GPL-3.0 / commercial
    QCoroBuild-linkedMIT
    pugixml%2.%3.%4MIT
    libzip%5BSD-3-Clause
    spdlog%6.%7.%8MIT
    Glaze5.5.4 (build dependency)MIT
    ") + .arg(QString::fromLatin1(qVersion())) + .arg(PUGIXML_VERSION / 1000).arg(PUGIXML_VERSION / 10 % 100).arg(PUGIXML_VERSION % 10) + .arg(QString::fromLatin1(zip_libzip_version())) + .arg(SPDLOG_VER_MAJOR).arg(SPDLOG_VER_MINOR).arg(SPDLOG_VER_PATCH); +} + +AboutPanel::AboutPanel(QWidget* parent, core::CoreContext& ctx, Notifier& toast) + : QDialog(parent), ctx_(ctx), toast_(toast) { + setObjectName(QStringLiteral("aboutPanel")); + setWindowTitle(tr("About PxModRim")); + setModal(true); + resize(700, 740); + setMinimumSize(640, 620); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 20); + layout->setSpacing(18); + auto* hero = new QFrame(this); + hero->setObjectName(QStringLiteral("aboutHero")); + auto* heroLayout = new QHBoxLayout(hero); + heroLayout->setContentsMargins(20, 20, 20, 20); + heroLayout->setSpacing(20); + auto* logo = new QLabel(hero); + logo->setPixmap(QPixmap(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg")) + .scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + heroLayout->addWidget(logo, 0, Qt::AlignTop); + auto* identity = new QVBoxLayout; + identity->setSpacing(6); + auto* titleRow = new QHBoxLayout; + auto* title = new QLabel(QStringLiteral("PxModRim"), hero); + title->setObjectName(QStringLiteral("aboutTitle")); + titleRow->addWidget(title); + titleRow->addStretch(); + auto* version = new QLabel(QStringLiteral("v%1").arg(core::appVersion()), hero); + version->setObjectName(QStringLiteral("aboutVersionBadge")); + titleRow->addWidget(version, 0, Qt::AlignVCenter); + identity->addLayout(titleRow); + auto* tagline = new QLabel(tr("A friendly, modern mod manager for RimWorld."), hero); + tagline->setObjectName(QStringLiteral("aboutTagline")); + identity->addWidget(tagline); + auto* description = new QLabel(tr("Scan, sort, resolve dependencies, and manage large mod lists easily."), hero); + description->setObjectName(QStringLiteral("aboutDescription")); + description->setWordWrap(true); + identity->addWidget(description); + identity->addStretch(); + heroLayout->addLayout(identity, 1); + layout->addWidget(hero); + + auto* section = new QWidget(this); + auto* sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(0, 0, 0, 0); + sectionLayout->setSpacing(6); + auto* heading = new QLabel(tr("THE PROJECT"), section); + heading->setObjectName(QStringLiteral("aboutSectionTitle")); + sectionLayout->addWidget(heading); + auto* story = new QLabel(tr( + "I used RimPy and RimSort for a long time myself, but with recent updates it kept getting " + "slower and worse — some features even broke my game 🙁

    " + "I initially started working on a PR to fix most of the parallelism issues in RimSort, " + "but realized it didn't make much sense. So I decided to try creating my own alternative " + "with the help of AI. The result is what you see on your screen.

    " + "This is all just my personal opinion based on my experience with RimSort at the time, " + "and things may have changed for the better since then (I hope so!).

    — PyXiion"), section); + story->setObjectName(QStringLiteral("aboutStory")); + story->setWordWrap(true); + story->setAlignment(Qt::AlignTop | Qt::AlignLeft); + auto* scroll = new QScrollArea(section); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(story); + scroll->viewport()->setAutoFillBackground(false); + scroll->viewport()->setBackgroundRole(QPalette::NoRole); + story->setAutoFillBackground(false); + story->setBackgroundRole(QPalette::NoRole); + sectionLayout->addWidget(scroll, 1); + layout->addWidget(section, 1); + + auto* details = new QFrame(this); + details->setObjectName(QStringLiteral("aboutDetails")); + auto* grid = new QGridLayout(details); + grid->setContentsMargins(16, 14, 16, 14); + grid->setHorizontalSpacing(14); + grid->setVerticalSpacing(8); + const QList> rows{ + {tr("CREATED BY"), QStringLiteral("PyXiion")}, + {tr("LICENSE"), QStringLiteral("GPL-3.0-or-later")}, + {tr("INSPIRED BY"), QStringLiteral("RimSort")}, + {tr("ENVIRONMENT"), environment().join(QStringLiteral(" • "))}}; + for (qsizetype row = 0; row < rows.size(); ++row) { + auto* label = new QLabel(rows[row].first, details); + label->setObjectName(QStringLiteral("aboutMetaLabel")); + auto* value = new QLabel(rows[row].second, details); + value->setObjectName(QStringLiteral("aboutMetaValue")); + value->setWordWrap(true); + grid->addWidget(label, static_cast(row), 0); + grid->addWidget(value, static_cast(row), 1); + } + grid->setColumnStretch(1, 1); + layout->addWidget(details); + + auto* credits = new QDialog(this); + credits->setObjectName(QStringLiteral("creditsDialog")); + credits->setWindowTitle(tr("Open-Source Credits")); + credits->setModal(true); + credits->resize(560, 500); + auto* creditsLayout = new QVBoxLayout(credits); + creditsLayout->setContentsMargins(20, 20, 20, 16); + creditsLayout->setSpacing(14); + auto* creditsTitle = new QLabel(tr("Open-source credits"), credits); + creditsTitle->setObjectName(QStringLiteral("creditsTitle")); + creditsLayout->addWidget(creditsTitle); + auto* browser = new QTextBrowser(credits); + browser->setObjectName(QStringLiteral("creditsBrowser")); + browser->setOpenExternalLinks(true); + browser->setHtml(creditsHtml()); + creditsLayout->addWidget(browser); + auto* creditsFooter = new QHBoxLayout; + creditsFooter->addStretch(); + auto* creditsClose = new QPushButton(tr("Close"), credits); + creditsClose->setObjectName(QStringLiteral("appBtn")); + connect(creditsClose, &QPushButton::clicked, credits, &QDialog::reject); + creditsFooter->addWidget(creditsClose); + creditsLayout->addLayout(creditsFooter); + installDialogChrome(credits); + + auto* actions = new QHBoxLayout; + actions->setSpacing(8); + auto* github = new QPushButton(tr("GitHub"), this); + github->setObjectName(QStringLiteral("appBtn")); + connect(github, &QPushButton::clicked, this, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim"))); }); + actions->addWidget(github); + auto* issues = new QPushButton(tr("Report issue"), this); + issues->setObjectName(QStringLiteral("appBtn")); + connect(issues, &QPushButton::clicked, this, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); + actions->addWidget(issues); + auto* copy = new QPushButton(tr("Copy system info"), this); + copy->setObjectName(QStringLiteral("appBtn")); + connect(copy, &QPushButton::clicked, this, [this] { + QApplication::clipboard()->setText(systemInformation()); + toast_.success(tr("System info copied")); + }); + actions->addWidget(copy); + upload_ = new QPushButton(tr("Upload log && system info…"), this); + upload_->setObjectName(QStringLiteral("appBtn")); + connect(upload_, &QPushButton::clicked, this, [this] { uploadReport(); }); + actions->addWidget(upload_); + auto* showCredits = new QPushButton(tr("Open-source credits"), this); + showCredits->setObjectName(QStringLiteral("appBtn")); + connect(showCredits, &QPushButton::clicked, credits, &QDialog::open); + actions->addWidget(showCredits); + actions->addStretch(); + layout->addLayout(actions); + auto* disclaimer = new QLabel(tr("PxModRim is an unofficial fan-made tool. RimWorld is a trademark of Ludeon Studios."), this); + disclaimer->setObjectName(QStringLiteral("aboutDisclaimer")); + disclaimer->setWordWrap(true); + layout->addWidget(disclaimer); + auto* footer = new QHBoxLayout; + footer->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + footer->addWidget(close); + layout->addLayout(footer); +} + +QCoro::Task AboutPanel::uploadReport() { + QPointer button(upload_); + upload_->setEnabled(false); + co_await handle_upload_report(this, ctx_, toast_); + if (button) button->setEnabled(true); +} +} // namespace px::ui diff --git a/src/ui/panels/about_panel.h b/src/ui/panels/about_panel.h new file mode 100644 index 0000000..653c365 --- /dev/null +++ b/src/ui/panels/about_panel.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +class QPushButton; +namespace px::core { class CoreContext; } +namespace px::ui { +class Notifier; +class AboutPanel : public QDialog { +public: + AboutPanel(QWidget* parent, core::CoreContext& ctx, Notifier& toast); + static QStringList environment(); + QString systemInformation() const; + static QString creditsHtml(); + +private: + QCoro::Task uploadReport(); + core::CoreContext& ctx_; + Notifier& toast_; + QPushButton* upload_; +}; +} // namespace px::ui diff --git a/src/ui/panels/keyboard_shortcuts_dialog.cpp b/src/ui/panels/keyboard_shortcuts_dialog.cpp new file mode 100644 index 0000000..6d226f5 --- /dev/null +++ b/src/ui/panels/keyboard_shortcuts_dialog.cpp @@ -0,0 +1,36 @@ +#include "ui/panels/keyboard_shortcuts_dialog.h" + +#include +#include +#include +#include +#include + +namespace px::ui { +KeyboardShortcutsDialog::KeyboardShortcutsDialog(const QList>& rows, + QWidget* parent) : QDialog(parent) { + setWindowTitle(tr("Keyboard Shortcuts")); + resize(520, 600); + auto* layout = new QVBoxLayout(this); + auto* table = new QTableWidget(static_cast(rows.size()), 2, this); + table->setHorizontalHeaderLabels({tr("Shortcut"), tr("Action")}); + table->verticalHeader()->hide(); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionMode(QAbstractItemView::NoSelection); + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + for (qsizetype row = 0; row < rows.size(); ++row) { + table->setItem(static_cast(row), 0, new QTableWidgetItem(rows[row].first)); + table->setItem(static_cast(row), 1, new QTableWidgetItem(rows[row].second)); + } + layout->addWidget(table); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + close->setDefault(true); + connect(close, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(close); + layout->addLayout(buttons); +} +} // namespace px::ui diff --git a/src/ui/panels/keyboard_shortcuts_dialog.h b/src/ui/panels/keyboard_shortcuts_dialog.h new file mode 100644 index 0000000..4444d44 --- /dev/null +++ b/src/ui/panels/keyboard_shortcuts_dialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::ui { +inline const QList> QML_SHORTCUTS{ + {QStringLiteral("Up / Down"), QStringLiteral("Navigate mod list")}, + {QStringLiteral("Shift+Up / Shift+Down"), QStringLiteral("Extend mod selection")}, + {QStringLiteral("Return / Space"), QStringLiteral("Toggle selected mods")}, + {QStringLiteral("Ctrl+A"), QStringLiteral("Select all mods")}, +}; + +class KeyboardShortcutsDialog : public QDialog { +public: + explicit KeyboardShortcutsDialog(const QList>& rows, + QWidget* parent = nullptr); +}; +} // namespace px::ui diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp new file mode 100644 index 0000000..c38a4a5 --- /dev/null +++ b/src/ui/panels/mod_info_data.cpp @@ -0,0 +1,175 @@ +#include "ui/panels/mod_info_data.h" +#include "ui/components/unity_rich_text.h" +#include "ui/models/mod_list_model.h" +#include "core/downloads/manager.h" +#include +#include +#include +#include +#include + +namespace px::ui { +namespace { +template QString substitute(const QString& text, const QRegularExpression& re, F replacement) { + QString out; + out.reserve(text.size()); + qsizetype end = 0; + auto matches = re.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + out += text.mid(end, match.capturedStart() - end); + out += replacement(match); + end = match.capturedEnd(); + } + out += text.mid(end); + return out; +} +QRegularExpression insensitive(const QString& pattern) { + return QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); +} +} +QString bbcode_images(QString text) { + static const auto re = insensitive(R"(\[img\]((https?)://[^\s\[<"]+)\[/img\])"); + return substitute(text, re, [](const auto& match) { + const QString url = match.captured(1); + return match.captured(2).compare("https", Qt::CaseInsensitive) == 0 + ? "" + : "" + url + ""; + }); +} +QString restore_entities(QString text) { + static const QRegularExpression re(R"(&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});)"); + return text.replace(re, "&\\1;"); +} +QString bbcode_markup(QString text) { + static const auto named_url = insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); + static const auto bare_url = insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); + static const auto quote = insensitive(R"(\[quote=([^\]]+)\])"); + static const auto table = insensitive(R"(\[table[^\]]*\])"); + static const auto strip = insensitive(R"(\[/?(?:spoiler|noparse)\])"); + static const auto hr = insensitive(R"(\[hr\])"); + static const auto paired = insensitive(R"(\[(/?)(b|i|u|s|strike|h1|h2|h3|code|quote|list|olist|table|tr|td|th)\])"); + static const QHash names{{"strike", "s"}, {"code", "pre"}, {"quote", "blockquote"}, {"list", "ul"}, {"olist", "ol"}}; + text.replace(named_url, "\\2"); + text.replace(bare_url, "\\1"); + text.replace(quote, "
    \\1 wrote:
    "); + text.replace(table, ""); text.replace(strip, ""); text.replace(hr, "
    "); text.replace("[*]", "
  • "); + text = substitute(text, paired, [](const auto& match) { + const QString tag = match.captured(2).toLower(); + return "<" + match.captured(1) + names.value(tag, tag) + ">"; + }); + static const QRegularExpression before(R"((?:
    \s*)+(?=)(?:
    \s*)+)"); + text.replace(before, ""); + return text.replace(after, "\\1"); +} +QString autolink(const QString& text) { + static const QRegularExpression tags("<[^>]+>"); + static const QRegularExpression urls(R"(https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>"])+)"); + const auto link_text = [](const QString& part) { + return substitute(part, urls, [](const auto& match) { + const QString url = match.captured(); + QString stripped = url; + while (!stripped.isEmpty() && QStringLiteral(".,;:!?)").contains(stripped.back())) stripped.chop(1); + return "" + stripped + "" + url.mid(stripped.size()); + }); + }; + QString out; + qsizetype end = 0; + bool anchor = false; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + const QString plain = text.mid(end, match.capturedStart() - end); + out += anchor ? plain : link_text(plain); + const QString tag = match.captured(), lowered = tag.toLower(); + if (lowered.startsWith("") anchor = true; + else if (lowered == "") anchor = false; + out += tag; + end = match.capturedEnd(); + } + const QString tail = text.mid(end); + return out + (anchor ? tail : link_text(tail)); +} +QString description_to_html(const QString& text, const QString& description_format) { + if (description_format != "bbcode") return escape_html(text).replace('\n', "
    "); + return autolink(bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text))))); +} +QString format_age(double mtime, std::optional now) { + if (mtime <= 0) return {}; + const double delta = now.value_or(QDateTime::currentMSecsSinceEpoch() / 1000.0) - mtime; + static constexpr std::array, 5> units{{{"year", 365 * 86400}, {"month", 30 * 86400}, {"week", 7 * 86400}, {"day", 86400}, {"hour", 3600}}}; + for (const auto& [name, seconds] : units) if (delta >= seconds) { + const auto count = static_cast(delta / seconds); + return QString::number(count) + " " + name + (count == 1 ? "" : "s") + " ago"; + } + return "just now"; +} +QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, const QList& issues) { + const auto* about = dynamic_cast(&mod); + const auto& active = ctx.activeUuids(); + const auto position = active.indexOf(mod.uuid()); + QVariantList issue_rows, needs, needed_by, details; + QStringList conflicts; + int errors = 0; + for (const auto& issue : issues) { + errors += issue.is_error; + issue_rows.append(QVariantMap{{"title", issue.category_display_name}, {"detail", issue.detail.value_or(QString{})}, {"isError", issue.is_error}}); + } + const auto warnings = issues.size() - errors; + const QString level = errors ? "error" : warnings ? "warning" : "ok"; + const auto count = errors ? errors : warnings; + const QString title = count ? QString::number(count) + (errors ? " error" : " warning") + (count == 1 ? "" : "s") : QStringLiteral("No issues"); + if (about) { + QHash index; + for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get())) + if (!index.contains(other->package_id.str())) index.insert(other->package_id.str(), other); + for (const auto& dep : about->about_rules.dependencies) { + auto* found = index.value(dep.package_id.str(), nullptr); + if (!found) for (const auto& alternative : dep.alternative_package_ids) { + found = index.value(alternative.str(), nullptr); + if (found) break; + } + needs.append(QVariantMap{{"name", dep.name.isEmpty() ? dep.package_id.str() : dep.name}, + {"state", !found ? "missing" : active.contains(found->uuid()) ? "active" : "inactive"}, {"uuid", found ? found->uuid() : QString{}}}); + } + for (const auto& pid : about->about_rules.incompatible_with) { + const auto* other = index.value(pid.str(), nullptr); + if (other && active.contains(other->uuid())) conflicts.append(other->name); + } + std::sort(conflicts.begin(), conflicts.end(), [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); + QList dependents; + for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get()); other && other->uuid() != mod.uuid()) { + for (const auto& dep : other->about_rules.dependencies) + if (dep.package_id == about->package_id || dep.alternative_package_ids.contains(about->package_id)) { dependents.append(other); break; } + } + std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { return a->name.toLower() < b->name.toLower(); }); + for (const auto* other : dependents) needed_by.append(QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + } + auto& downloads = ctx.downloads(); + auto* source = downloads.source_of(mod); + const auto row = [&details](const QString& label, const QString& value, bool copy = false) { + QVariantMap item{{"label", label}, {"value", value}}; + if (copy) item.insert("copy", true); + details.append(item); + }; + QString provider; + if (about) { + if (!about->authors.isEmpty()) row("Author", about->authors.join(", ")); + if (!about->mod_version.isEmpty()) row("Version", about->mod_version); + provider = provider_label(mod.provider_id, about->package_id.str()); + if (!provider.isEmpty()) row("Source", provider); + if (about->mtime > 0) row("Updated", format_age(about->mtime)); + if (const auto synced = downloads.last_synced(mod); synced && *synced != 0) row("Last synced", format_age(*synced)); + row("Package ID", about->package_id.str(), true); + } + if (mod.mod_path()) row("Path", *mod.mod_path(), true); + const QString target = ctx.targetVersion(); + const bool known = !mod.supported_versions.isEmpty() && target != "Unknown"; + return {{"name", mod.name}, {"author", about ? about->authors.join(", ") : QString{}}, {"provider", provider}, + {"status", QVariantMap{{"level", level}, {"title", title}, {"active", position >= 0}, {"position", position >= 0 ? position + 1 : 0}, {"total", active.size()}, {"issues", issue_rows}, {"obsolete", mod.obsolete}, {"valid", mod.valid}}}, + {"version", QVariantMap{{"known", known}, {"ok", known && mod.supported_versions.contains(target)}, {"target", target}}}, + {"needs", needs}, {"conflicts", conflicts}, {"neededBy", needed_by}, {"description", description_to_html(mod.description)}, {"details", details}, + {"canOpenFolder", mod.mod_path() && QFileInfo::exists(*mod.mod_path())}, {"canUpdate", source != nullptr}, {"updateSource", source ? source->label() : QString{}}, {"url", about ? about->url : QString{}}}; +} +} diff --git a/src/ui/panels/mod_info_data.h b/src/ui/panels/mod_info_data.h new file mode 100644 index 0000000..c1b80de --- /dev/null +++ b/src/ui/panels/mod_info_data.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include "core/context.h" +#include "core/models/view/diagnostics.h" +namespace px::ui { +inline const QString IMG_WIDTH_TOKEN = QStringLiteral("__IMG_WIDTH__"); +QString bbcode_images(QString text); +QString restore_entities(QString text); +QString bbcode_markup(QString text); +QString autolink(const QString& text); +QString description_to_html(const QString& text, const QString& description_format = QStringLiteral("bbcode")); +QString format_age(double mtime, std::optional now = std::nullopt); +QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, const QList& issues); +} diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp new file mode 100644 index 0000000..57dd43b --- /dev/null +++ b/src/ui/panels/mod_info_panel.cpp @@ -0,0 +1,212 @@ +#include "ui/panels/mod_info_panel.h" +#include "ui/panels/mod_info_data.h" +#include "ui/panels/time_analytics_panel.h" +#include "ui/components/procedural_preview.h" +#include "ui/config.h" +#include "ui/models/impact.h" +#include "ui/theme/constants.h" +#include "ui/theme/palette.h" +#include "core/downloads/manager.h" +#include "core/mod_service.h" +#include "core/services/startup_impact_service/labels.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::ui { +class ModInfoBanner final : public QWidget { +public: + explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } + bool hasHeightForWidth() const override { return true; } + int heightForWidth(int width) const override { return std::min(width * BANNER_ASPECT_H / BANNER_ASPECT_W, BANNER_MAX_HEIGHT); } + QSize sizeHint() const override { const int w = width() ? width() : 300; return {w, heightForWidth(w)}; } + void setTitle(QString title, QString subtitle) { title_ = std::move(title); subtitle_ = std::move(subtitle); update(); } + void setImage(QImage image, bool overlay) { image_ = std::move(image); overlay_ = overlay; rescale(); update(); } +protected: + void resizeEvent(QResizeEvent* event) override { rescale(); QWidget::resizeEvent(event); } + void paintEvent(QPaintEvent*) override { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + if (scaled_.isNull()) painter.fillRect(rect(), panelBgQ()); + else painter.drawImage((width() - scaled_.width()) / 2, (height() - scaled_.height()) / 2, scaled_); + if (overlay_ && !title_.isEmpty()) { + QLinearGradient gradient(0, height() - BANNER_OVERLAY_HEIGHT, 0, height()); + gradient.setColorAt(0, QColor(0, 0, 0, 0)); gradient.setColorAt(1, QColor(0, 0, 0, 180)); + painter.fillRect(0, height() - BANNER_OVERLAY_HEIGHT, width(), BANNER_OVERLAY_HEIGHT, gradient); + const int top = height() - BANNER_OVERLAY_HEIGHT + 12; + int title_height = BANNER_OVERLAY_HEIGHT - 24; + if (!subtitle_.isEmpty()) title_height = title_height * 3 / 5; + auto font = painter.font(); font.setPointSize(18); font.setBold(true); + painter.setFont(font); painter.setPen(textMainQ()); + painter.drawText(QRect(16, top, width() - 32, title_height), Qt::AlignLeft | Qt::AlignBottom, title_); + if (!subtitle_.isEmpty()) { + font.setPointSize(12); font.setBold(false); painter.setFont(font); painter.setPen(textMutedQ()); + painter.drawText(QRect(16, top + title_height + 2, width() - 32, BANNER_OVERLAY_HEIGHT - title_height - 26), Qt::AlignLeft | Qt::AlignTop, subtitle_); + } + } + } +private: + void rescale() { + scaled_ = image_.isNull() || width() <= 0 ? QImage{} : image_.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + } + QImage image_, scaled_; + QString title_, subtitle_; + bool overlay_ = true; +}; +namespace { +QString first_sentence(QString text) { + static const QRegularExpression markup(R"(\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\])", QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); + text.replace(markup, " "); + text = QTextDocumentFragment::fromHtml(text).toPlainText().simplified(); + const auto end = text.indexOf('.'); + if (end >= 0) text.truncate(end + 1); + if (text.size() > 80) { + text.truncate(80); + const auto space = text.lastIndexOf(' '); + if (space >= 0) text.truncate(space); + text += QChar(0x2026); + } + return text; +} +} +ModInfoPanel::ModInfoPanel(core::CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent) + : QWidget(parent), ctx_(ctx), engine_(engine), prefs_(prefs), banner_(new ModInfoBanner(this)), + placeholder_(new QLabel("Select a mod to view details", this)), qml_(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); + banner_->hide(); layout->addWidget(banner_, 0, Qt::AlignTop); + placeholder_->setAlignment(Qt::AlignCenter); placeholder_->setObjectName("placeholder"); + layout->addWidget(placeholder_, 0, Qt::AlignTop); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setClearColor(paletteColor(u"ELEVATE_2")); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); + qml_->hide(); layout->addWidget(qml_, 1); + if (auto* root = qml_->rootObject()) { + root->setProperty("descExpanded", prefs_.desc_expanded); + connect(root, SIGNAL(openFolder()), this, SLOT(openFolder())); + connect(root, SIGNAL(openUrl()), this, SLOT(openUrl())); + connect(root, SIGNAL(selectMod(QString)), this, SIGNAL(modRequested(QString))); + connect(root, SIGNAL(copyText(QString)), this, SLOT(copyText(QString))); + connect(root, SIGNAL(openStartupDetails()), this, SLOT(openStartupDetails())); + connect(root, SIGNAL(descToggled(bool)), this, SLOT(descToggled(bool))); + connect(root, SIGNAL(updateMods()), this, SLOT(updateMod())); + } + setQml("downloadsBusy", ctx_.downloads().is_downloading()); + busyConnection_ = ctx_.downloads().busy_changed.connect([this](bool busy) { setQml("downloadsBusy", busy); syncUpdating(); }); +} +ModInfoPanel::~ModInfoPanel() { ctx_.downloads().busy_changed.disconnect(busyConnection_); } +void ModInfoPanel::setQml(const char* name, const QVariant& value) { if (auto* root = qml_->rootObject()) root->setProperty(name, value); } +void ModInfoPanel::showMod(const core::ListedMod& mod, const QList& issues) { + const auto token = ++previewToken_; + mod_ = ctx_.allMods().value(mod.uuid()); + if (!mod_) { + if (const auto* about = dynamic_cast(&mod)) mod_ = std::make_shared(*about); + else mod_ = std::make_shared(mod); + } + placeholder_->hide(); banner_->show(); qml_->show(); + const auto* about = dynamic_cast(&mod); + banner_->setTitle(mod.name, !mod.description.isEmpty() ? first_sentence(mod.description) : about ? about->package_id.str() : QString{}); + ++startupToken_; + setQml("startup", QVariant{}); setIssues(issues); syncUpdating(); + if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, "resetScroll"); + loadPreview(mod.mod_path(), mod.name, token); +} +void ModInfoPanel::setIssues(const QList& issues) { + if (!mod_) return; + auto data = build_mod_info(ctx_, *mod_, issues); + const QString description = data.take("description").toString(); + setQml("info", data); + if (description != description_) { description_ = description; setQml("description", description); } +} +void ModInfoPanel::clear() { + mod_.reset(); ++previewToken_; ++startupToken_; + placeholder_->show(); banner_->hide(); qml_->hide(); + description_.clear(); startupPid_.reset(); startupActivePids_.clear(); + setQml("info", QVariant{}); setQml("description", QString{}); setQml("startup", QVariant{}); +} +void ModInfoPanel::syncUpdating() { + const auto id = mod_ ? ctx_.downloads().updatable_id(*mod_) : std::nullopt; + setQml("updatingThis", id && ctx_.downloads().active_ids().contains(*id)); +} +void ModInfoPanel::openFolder() { if (mod_ && mod_->mod_path()) QDesktopServices::openUrl(QUrl::fromLocalFile(*mod_->mod_path())); } +void ModInfoPanel::openUrl() { + const auto* about = dynamic_cast(mod_.get()); + if (!about) return; + const QUrl url(about->url); + const auto scheme = url.scheme().toLower(); + if (scheme == "http" || scheme == "https") QDesktopServices::openUrl(url); +} +void ModInfoPanel::copyText(const QString& text) { QGuiApplication::clipboard()->setText(text); } +void ModInfoPanel::descToggled(bool expanded) { prefs_.desc_expanded = expanded; if (ctx_.hasConfigService()) saveUiPrefs(prefs_, ctx_.configService()); } +void ModInfoPanel::updateMod() { + if (!mod_) return; + if (auto id = ctx_.downloads().updatable_id(*mod_)) performUpdate(*id); +} +QCoro::Task ModInfoPanel::performUpdate(QString id) { + const auto result = co_await ctx_.downloads().download_mods({id}); + if (!result) qWarning().noquote() << "Mod update not started:" << result.error().message; +} +QCoro::Task ModInfoPanel::loadPreview(std::optional path, QString title, quint64 token) { + QPointer guard(this); + const int width = banner_->width() ? banner_->width() : 300; + const int height = banner_->heightForWidth(width); + auto future = QtConcurrent::run([path = std::move(path), title = std::move(title), width, height] { + QImage image; + if (path) image.load(*path + "/About/Preview.png"); + const bool overlay = image.isNull(); + if (overlay) image = generate_preview(title, width, height); + return std::pair{image, overlay}; + }); + auto [image, overlay] = co_await future; + if (guard && token == guard->previewToken_) guard->banner_->setImage(std::move(image), overlay); +} +QCoro::Task ModInfoPanel::setTimeAnalytics(const std::optional& pid, const QStringList& activePids) { + const auto token = ++startupToken_; + startupPid_ = pid; startupActivePids_ = activePids; + if (!pid) { setQml("startup", QVariantMap{{"available", false}, {"message", "Startup impact is unavailable."}}); co_return; } + QPointer guard(this); + const auto selectedPid = *pid; + const auto active = activePids; + auto result = co_await ctx_.modService().startup_impact().snapshot(active, selectedPid); + if (!guard || token != guard->startupToken_) co_return; + if (!result || !result->report) { + guard->setQml("startup", QVariantMap{{"available", false}, {"message", result ? QStringLiteral("No startup data recorded yet. Launch the game to generate it.") : result.error().message}}); + co_return; + } + const auto& report = *result->report; + const auto* mod = report.find(selectedPid, std::nullopt); + const double own = result->selected_avg; + if (!mod || own < .001) { guard->setQml("startup", QVariantMap{{"available", false}, {"message", "This mod wasn't measured in the last recorded launch."}}); co_return; } + int rank = 1; + for (const auto& entry : report.mods) rank += entry.total_impact_s > own; + QList> metrics; + for (auto it = mod->metrics.cbegin(); it != mod->metrics.cend(); ++it) metrics.append({it.key(), it.value()}); + std::stable_sort(metrics.begin(), metrics.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); + QVariantList rows; + for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) if (metrics[i].second >= .001) + rows.append(QVariantMap{{"label", core::startup_impact::metric_label(metrics[i].first)}, {"value", format_duration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); + guard->setQml("startup", QVariantMap{{"available", true}, {"own", format_duration(own)}, {"rank", QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, + {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, {"color", impact_color(own)}, {"rows", rows}}); +} +void ModInfoPanel::openStartupDetails() { showStartupDetails(); } +QCoro::Task ModInfoPanel::showStartupDetails() { + if (!startupDialog_) startupDialog_ = new StartupImpactDialog(&ctx_.modService().startup_impact(), engine_, this); + startupDialog_->show(); startupDialog_->raise(); startupDialog_->activateWindow(); + co_await startupDialog_->panel->setData(startupPid_, startupActivePids_); +} +} diff --git a/src/ui/panels/mod_info_panel.h b/src/ui/panels/mod_info_panel.h new file mode 100644 index 0000000..537f9e0 --- /dev/null +++ b/src/ui/panels/mod_info_panel.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include +#include "ui/mod_selection.h" +#include "ui/ui_prefs.h" +class QQmlEngine; +class QQuickWidget; +class QLabel; +namespace px::ui { +class ModInfoBanner; +class StartupImpactDialog; +class ModInfoPanel : public QWidget, public ModInfoSink { + Q_OBJECT +public: + ModInfoPanel(core::CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent = nullptr); + ~ModInfoPanel() override; + void showMod(const core::ListedMod& mod, const QList& issues) override; + void setIssues(const QList& issues); + void clear() override; + QCoro::Task setTimeAnalytics(const std::optional& pid, const QStringList& activePids) override; +signals: + void modRequested(const QString& uuid); +private slots: + void openFolder(); + void openUrl(); + void copyText(const QString& text); + void descToggled(bool expanded); + void updateMod(); + void openStartupDetails(); +private: + void setQml(const char* name, const QVariant& value); + void syncUpdating(); + QCoro::Task loadPreview(std::optional path, QString title, quint64 token); + QCoro::Task performUpdate(QString id); + QCoro::Task showStartupDetails(); + core::CoreContext& ctx_; + QQmlEngine* engine_; + UiPrefs& prefs_; + core::ListedModPtr mod_; + ModInfoBanner* banner_; + QLabel* placeholder_; + QQuickWidget* qml_; + QString description_; + quint64 previewToken_ = 0; + quint64 startupToken_ = 0; + std::optional startupPid_; + QStringList startupActivePids_; + QPointer startupDialog_; + core::Event::Id busyConnection_; +}; +} diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp new file mode 100644 index 0000000..efafacd --- /dev/null +++ b/src/ui/panels/mod_list_panel.cpp @@ -0,0 +1,151 @@ +#include "ui/panels/mod_list_panel.h" +#include "core/context.h" +#include "core/mod_service.h" +#include "core/downloads/manager.h" +#include "ui/components/icons.h" +#include "ui/components/mod_activation.h" +#include "ui/components/mod_updates.h" +#include "ui/theme/palette.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace px::ui { +ModListPanel::ModListPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent) + : QWidget(parent), ctx_(ctx), model_(new ModListModel({}, &ctx.diagnosticsService(), this)), + proxy_(new ModListProxyModel(model_, this)), qml_(new QQuickWidget(engine, this)), + compact_mode_(ctx.config().compact_mod_list), highlight_timer_(new QTimer(this)) { + auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); + auto* search = new QWidget(this); search->setObjectName(QStringLiteral("searchBox")); search->setFixedHeight(56); + auto* search_layout = new QHBoxLayout(search); search_layout->setContentsMargins(16,8,16,8); + search_input = new QLineEdit(search); + search_input->setObjectName(QStringLiteral("searchInput")); + search_input->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); + search_input->setClearButtonEnabled(true); + connect(search_input, &QLineEdit::textChanged, this, &ModListPanel::set_search_filter); + if (const auto search_icon = icon(u"search",16,paletteValue(u"TEXT_DIM"))) search_input->addAction(*search_icon,QLineEdit::LeadingPosition); + search_layout->addWidget(search_input); layout->addWidget(search); + connect(model_, &ModListModel::active_mods_changed, this, &ModListPanel::active_mods_changed); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setAttribute(Qt::WA_AlwaysStackOnTop,false); + qml_->setClearColor(paletteColor(u"ELEVATE_1")); qml_->setAcceptDrops(true); + qml_->rootContext()->setContextProperty(QStringLiteral("modListPanel"),this); + qml_->rootContext()->setContextProperty(QStringLiteral("modListModel"),proxy_); + qml_->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),false); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); + layout->addWidget(qml_); + qml_->setFocusPolicy(Qt::StrongFocus); qml_->installEventFilter(this); + highlight_timer_->setSingleShot(true); highlight_timer_->setInterval(1200); + connect(highlight_timer_, &QTimer::timeout, this, [this] { set_highlighted({}); }); + active_connection_ = ctx_.activeStateChanged().connect([this](const auto& active) { core_state_changed(active); }); + config_connection_ = ctx_.configChanged().connect([this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); + mods_connection_ = ctx_.modService().mods_changed.connect([this](const auto&) { refresh(); }); +} +ModListPanel::~ModListPanel() { + ctx_.activeStateChanged().disconnect(active_connection_); + ctx_.configChanged().disconnect(config_connection_); + ctx_.modService().mods_changed.disconnect(mods_connection_); + // QML bindings refer to the models and panel during destruction. + delete qml_; +} +void ModListPanel::load_mods(const QHash& mods, const QStringList& active) { model_->load_mods(mods,active); } +QStringList ModListPanel::active_uuids() const { return model_->active_uuids(); } +void ModListPanel::set_sidebar_filter(std::optional> uuids) { proxy_->set_sidebar_filter(std::move(uuids)); } +void ModListPanel::set_search_filter(const QString& text) { proxy_->set_search_filter(text); } +void ModListPanel::setCompactMode(bool enabled) { + if (compact_mode_ == enabled) return; + compact_mode_ = enabled; emit compact_mode_changed(enabled); +} +bool ModListPanel::isHighlighted(const QString& uuid) const { return highlighted_.contains(uuid); } +void ModListPanel::modSelected(const QString& uuid) { emit mod_selected(uuid); } +int ModListPanel::proxy_to_source_row(int row) const { return proxy_->mapToSource(proxy_->index(row)).row(); } +QString ModListPanel::uuidAt(int row) const { const auto* item = model_->get_item(proxy_to_source_row(row)); return item ? item->uuid : QString{}; } +int ModListPanel::rowForUuid(const QString& uuid) const { return proxy_->proxy_row_for_uuid(uuid); } +bool ModListPanel::isChecked(int row) const { const auto* item = model_->get_item(proxy_to_source_row(row)); return item && item->checked; } +bool ModListPanel::select_uuid(const QString& uuid) { + const int row = rowForUuid(uuid); + auto* root = qml_->rootObject(); + if (row < 0 || !root) return false; + return QMetaObject::invokeMethod(root,"revealRow",Qt::DirectConnection,Q_ARG(QVariant,QVariant(row)),Q_ARG(QVariant,QVariant(uuid))); +} +void ModListPanel::clearSelection() { + auto* root = qml_->rootObject(); + auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (view) { + view->setProperty("currentIndex",-1); view->setProperty("anchorIndex",-1); + view->setProperty("selectedIndices",QVariantList{}); view->setProperty("selectedUuids",QVariantList{}); + view->setProperty("currentUuid",QString{}); view->setProperty("anchorUuid",QString{}); + } + emit selection_changed({}); emit mod_selected({}); +} +QCoro::Task ModListPanel::refresh() { + model_->update_provider_colors(ctx_.modService().provider_colors()); + model_->load_mods(ctx_.allMods(),ctx_.activeUuids()); + QPointer self(this); + const auto impacts = co_await ctx_.modService().startup_impact().get_all_averages(); + if (self && impacts) self->model_->set_startup_impact(*impacts); +} +QCoro::QmlTask ModListPanel::toggleChecked(const QVariantList& rows) { + QList selected; selected.reserve(rows.size()); + for (const auto& row : rows) selected.append(row.toInt()); + return toggle_rows(std::move(selected)); +} +QCoro::Task ModListPanel::toggle_rows(QList rows) { + QStringList selected; + for (const int row : rows) { const auto uuid = uuidAt(row); if (!uuid.isEmpty()) selected.append(uuid); } + if (selected.isEmpty()) co_return; + const QSet previous(ctx_.activeUuids().begin(),ctx_.activeUuids().end()); + QPointer self(this); + co_await toggle_mods(ctx_,this,selected); + if (!self) co_return; + QStringList newly_enabled; + for (const auto& uuid : selected) if (!previous.contains(uuid) && ctx_.activeUuids().contains(uuid)) newly_enabled.append(uuid); + if (!newly_enabled.isEmpty()) highlight_rows(newly_enabled); +} +void ModListPanel::set_highlighted(const QStringList& uuids) { + highlighted_ = QSet(uuids.begin(),uuids.end()); ++highlight_revision_; emit highlighted_changed(); +} +void ModListPanel::highlight_rows(const QStringList& uuids) { + int last = -1; + for (const auto& uuid : uuids) if (const int row = rowForUuid(uuid); row >= 0) last = row; + auto* root = qml_->rootObject(); + if (last < 0 || !root) return; + if (auto* view = root->findChild(QStringLiteral("listView"))) + QMetaObject::invokeMethod(view,"positionViewAtIndex",Qt::DirectConnection,Q_ARG(int,last),Q_ARG(int,4)); + set_highlighted(uuids); highlight_timer_->start(); +} +void ModListPanel::showContextMenu(const QVariantList& values) { + if (!ctx_.downloads().available()) return; + QStringList uuids; uuids.reserve(values.size()); + for (const auto& value : values) uuids.append(value.toString()); + auto* menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); menu->setToolTipsVisible(true); + add_update_action(menu,ctx_,uuids,[this,uuids] { update_mods(ctx_,uuids,this); }); + menu->popup(QCursor::pos()); +} +void ModListPanel::core_state_changed(const QStringList& uuids) { + const auto current_list = model_->active_uuids(); + const QSet current(current_list.begin(),current_list.end()), next(uuids.begin(),uuids.end()); + model_->set_checkable(next - current,true); model_->set_checkable(current - next,false); model_->commitOrder(uuids); +} +void ModListPanel::commitOrder(const QStringList& uuids) { model_->commitOrder(uuids); ctx_.setActive(model_->active_uuids()); emit order_changed(); } +void ModListPanel::moveRow(int source, int target) { proxy_->move_row(source,target,true); } +void ModListPanel::dragEnded() { ctx_.setActive(model_->active_uuids()); emit order_changed(); } +void ModListPanel::selectionChanged(const QVariantList& values) { + QStringList uuids; + for (const auto& value : values) if (!value.toString().isEmpty()) uuids.append(value.toString()); + emit selection_changed(uuids); +} +bool ModListPanel::eventFilter(QObject* object, QEvent* event) { + if (object == qml_ && (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + qml_->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),event->type() == QEvent::FocusIn); + return QWidget::eventFilter(object,event); +} +} diff --git a/src/ui/panels/mod_list_panel.h b/src/ui/panels/mod_list_panel.h new file mode 100644 index 0000000..c6affdd --- /dev/null +++ b/src/ui/panels/mod_list_panel.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include +#include "ui/models/mod_list_proxy_model.h" +class QQmlEngine; +class QQuickWidget; +class QLineEdit; +namespace px::core { class CoreContext; } +namespace px::ui { +class ModListPanel : public QWidget { + Q_OBJECT + Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY compact_mode_changed) + Q_PROPERTY(int highlightRevision READ highlightRevision NOTIFY highlighted_changed) +public: + ModListPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~ModListPanel() override; + void load_mods(const QHash& mods, const QStringList& active); + QStringList active_uuids() const; + ModListModel* model() const { return model_; } + ModListProxyModel* proxy_model() const { return proxy_; } + QQuickWidget* qml_widget() const { return qml_; } + void set_sidebar_filter(std::optional> uuids); + void set_search_filter(const QString& text); + bool select_uuid(const QString& uuid); + bool compactMode() const { return compact_mode_; } + int highlightRevision() const { return highlight_revision_; } + void highlight_rows(const QStringList& uuids); + QCoro::Task toggle_rows(QList rows); + QCoro::Task refresh(); + QLineEdit* search_input; + Q_INVOKABLE void setCompactMode(bool enabled); + Q_INVOKABLE bool isHighlighted(const QString& uuid) const; + Q_INVOKABLE void modSelected(const QString& uuid); + Q_INVOKABLE void clearSelection(); + Q_INVOKABLE QString uuidAt(int row) const; + Q_INVOKABLE int rowForUuid(const QString& uuid) const; + Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggle_rows({row}); } + Q_INVOKABLE QCoro::QmlTask toggleChecked(const QVariantList& rows); + Q_INVOKABLE void showContextMenu(const QVariantList& uuids); + Q_INVOKABLE bool isChecked(int row) const; + Q_INVOKABLE void commitOrder(const QStringList& uuids); + Q_INVOKABLE void moveRow(int source, int target); + Q_INVOKABLE void dragEnded(); + Q_INVOKABLE void selectionChanged(const QVariantList& uuids); +signals: + void mod_selected(const QString& uuid); + void active_mods_changed(); + void order_changed(); + void selection_changed(const QStringList& uuids); + void compact_mode_changed(bool enabled); + void highlighted_changed(); +protected: + bool eventFilter(QObject* object, QEvent* event) override; +private: + int proxy_to_source_row(int row) const; + void core_state_changed(const QStringList& uuids); + void set_highlighted(const QStringList& uuids); + core::CoreContext& ctx_; + ModListModel* model_; + ModListProxyModel* proxy_; + QQuickWidget* qml_; + bool compact_mode_; + QSet highlighted_; + int highlight_revision_ = 0; + QTimer* highlight_timer_; + std::uint64_t active_connection_, config_connection_, mods_connection_; +}; +} diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp new file mode 100644 index 0000000..7c3871a --- /dev/null +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -0,0 +1,197 @@ +#include "ui/panels/restore_snapshot_dialog.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mods_config.h" + +namespace px::ui { + +namespace { + +constexpr QStringView kSnapshotPrefix = u"ModsConfig_"; + +QString snapshotStem(const QString& path) { + const QString name = QFileInfo(path).fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + return dot > 0 ? name.left(dot) : name; +} + +bool allAsciiDigits(QStringView text) { + return std::all_of(text.begin(), text.end(), [](QChar c) { return c >= u'0' && c <= u'9'; }); +} + +// The UTC time encoded in a snapshot name such as ModsConfig_20260922_120000[_suffix].xml. +// Non-digit or out-of-range fields are rejected like Python's strptime rejected them. +std::optional timestampFromStem(const QString& stem) { + if (!stem.startsWith(kSnapshotPrefix)) { + return std::nullopt; + } + const QStringList parts = stem.mid(kSnapshotPrefix.size()).split(u'_'); + if (parts.size() < 2 || parts[0].size() != 8 || parts[1].size() < 6) { + return std::nullopt; + } + const QString date = parts[0]; + const QString time = parts[1].left(6); + if (!allAsciiDigits(date) || !allAsciiDigits(time)) { + return std::nullopt; + } + const QDate day(date.left(4).toInt(), date.mid(4, 2).toInt(), date.mid(6, 2).toInt()); + const QTime time_of_day(time.left(2).toInt(), time.mid(2, 2).toInt(), time.mid(4, 2).toInt()); + if (!day.isValid() || !time_of_day.isValid()) { + return std::nullopt; + } + return QDateTime(day, time_of_day, QTimeZone::utc()); +} + +QString formatUtc(const QDateTime& when) { + return when.toUTC().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) + QStringLiteral(" UTC"); +} + +// The name's timestamp when it has one, otherwise the file's modification time, otherwise the stem. +QString snapshotTimestamp(const QString& path) { + const QString stem = snapshotStem(path); + if (const auto named = timestampFromStem(stem)) { + return formatUtc(*named); + } + const QDateTime modified = QFileInfo(path).lastModified(); + return modified.isValid() ? formatUtc(modified) : stem; +} + +} // namespace + +RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, QWidget* parent) : QDialog(parent) { + setObjectName(QStringLiteral("restoreSnapshotDialog")); + setWindowTitle(QStringLiteral("Restore Mod List")); + resize(560, 420); + + snapshots_.reserve(static_cast(snapshots.size())); + for (const QString& path : snapshots) { + snapshots_.push_back(Snapshot{path, core::parse_mods_config(path)}); + } + + auto* layout = new QVBoxLayout(this); + + auto* instruction = new QLabel( + QStringLiteral("Choose a saved mod list. Your current list will be backed up before it is replaced."), this); + instruction->setWordWrap(true); + layout->addWidget(instruction); + + list_ = new QListWidget(this); + populateList(); + layout->addWidget(list_); + + details_ = new QLabel(this); + details_->setWordWrap(true); + layout->addWidget(details_); + + auto* buttons = new QHBoxLayout(); + buttons->addStretch(); + + restore_ = new QPushButton(QStringLiteral("Restore"), this); + restore_->setObjectName(QStringLiteral("primaryAction")); + restore_->setEnabled(false); + connect(restore_, &QPushButton::clicked, this, &RestoreSnapshotDialog::onRestoreClicked); + + auto* cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + + buttons->addWidget(cancel); + buttons->addWidget(restore_); + layout->addLayout(buttons); + + connect(list_, &QListWidget::currentItemChanged, this, &RestoreSnapshotDialog::onCurrentItemChanged); + connect(list_, &QListWidget::itemDoubleClicked, this, &RestoreSnapshotDialog::onRestoreClicked); + + // Connected before selecting, so the initial selection updates the details and the button. + if (list_->count() > 0) { + list_->setCurrentRow(0); + } +} + +std::optional RestoreSnapshotDialog::selectedSnapshot() const { + return selected_; +} + +void RestoreSnapshotDialog::populateList() { + for (std::size_t i = 0; i < snapshots_.size(); ++i) { + const Snapshot& snapshot = snapshots_[i]; + auto* item = new QListWidgetItem(list_); + const QString timestamp = snapshotTimestamp(snapshot.path); + + if (snapshot.parsed) { + const qsizetype active = snapshot.parsed->activeMods.size(); + const QString noun = active == 1 ? QStringLiteral("mod") : QStringLiteral("mods"); + item->setText(QStringLiteral(u"%1 \u2014 %2 active %3, RimWorld %4") + .arg(timestamp) + .arg(QString::number(active)) + .arg(noun) + .arg(snapshot.parsed->version)); + } else { + item->setText(QStringLiteral(u"%1 \u2014 Cannot be read").arg(timestamp)); + } + item->setData(Qt::UserRole, static_cast(i)); + item->setToolTip(QFileInfo(snapshot.path).fileName()); + } +} + +void RestoreSnapshotDialog::onCurrentItemChanged(QListWidgetItem* current) { + if (current == nullptr) { + selected_.reset(); + restore_->setEnabled(false); + details_->clear(); + return; + } + + const Snapshot& snapshot = snapshots_[static_cast(current->data(Qt::UserRole).toInt())]; + const QString name = QFileInfo(snapshot.path).fileName(); + if (snapshot.parsed) { + selected_ = snapshot.path; + restore_->setEnabled(true); + details_->setText(QStringLiteral("File: %1
    Active mods: %2 | Game version: %3") + .arg(name) + .arg(QString::number(snapshot.parsed->activeMods.size())) + .arg(snapshot.parsed->version)); + } else { + selected_.reset(); + restore_->setEnabled(false); + details_->setText(QStringLiteral("This saved mod list cannot be read. %1 will not be restored.") + .arg(name)); + } +} + +void RestoreSnapshotDialog::onRestoreClicked() { + if (selected_) { + accept(); + } +} + +ConfirmRestoreDialog::ConfirmRestoreDialog(const QString& snapshotName, QWidget* parent) : QMessageBox(parent) { + setIcon(QMessageBox::Question); + // The QMessageBox setter is a no-op on macOS; the QWidget setter applies there too. + QDialog::setWindowTitle(QStringLiteral("Restore Mod List")); + setText(QStringLiteral("Restore the mod list saved in %1?").arg(snapshotName)); + setInformativeText(QStringLiteral( + "This replaces your current active mod list. PxModRim will save a backup of the current list first.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Restore")); + yes->setObjectName(QStringLiteral("primaryAction")); + } +} + +} // namespace px::ui diff --git a/src/ui/panels/restore_snapshot_dialog.h b/src/ui/panels/restore_snapshot_dialog.h new file mode 100644 index 0000000..8a4ea71 --- /dev/null +++ b/src/ui/panels/restore_snapshot_dialog.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "core/models/metadata/structures.h" + +class QLabel; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +namespace px::ui { + +// Lists saved mod lists (ModsConfig snapshots, in the order given, newest first) and +// lets the user pick one to restore. Snapshots that cannot be parsed are listed but +// cannot be selected. +class RestoreSnapshotDialog : public QDialog { + Q_OBJECT + +public: + explicit RestoreSnapshotDialog(const QStringList& snapshots, QWidget* parent = nullptr); + + // The selected snapshot path, or nullopt while the selection is unreadable or absent. + std::optional selectedSnapshot() const; + +private slots: + void onCurrentItemChanged(QListWidgetItem* current); + void onRestoreClicked(); + +private: + struct Snapshot { + QString path; + std::optional parsed; + }; + + void populateList(); + + std::vector snapshots_; + std::optional selected_; + QListWidget* list_ = nullptr; + QLabel* details_ = nullptr; + QPushButton* restore_ = nullptr; +}; + +// Asks before the current mod list is replaced by a snapshot. +class ConfirmRestoreDialog : public QMessageBox { +public: + explicit ConfirmRestoreDialog(const QString& snapshotName, QWidget* parent = nullptr); +}; + +} // namespace px::ui diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp new file mode 100644 index 0000000..35f9e86 --- /dev/null +++ b/src/ui/panels/settings_panel.cpp @@ -0,0 +1,159 @@ +#include "ui/panels/settings_panel.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "core/mod_service.h" +#include "core/sort/community_service.h" +#include "ui/components/dialogs.h" +#include "ui/context.h" +#include "ui/theme/palette.h" +namespace px::ui { +namespace { +class FolderDialog : public QFileDialog { +public: + FolderDialog(QWidget* parent, const QString& title) : QFileDialog(parent, title) { + setFileMode(QFileDialog::Directory); + setOption(QFileDialog::ShowDirsOnly); + } +}; +} +SettingsBackend::SettingsBackend(core::CoreContext& ctx, QDialog* dialog, const std::vector& sections) + : QObject(dialog), ctx_(ctx), dialog_(dialog) { + for (auto* section : sections) + sections_.append(QVariantMap{{"title", section->title()}, {"source", section->source().toString()}, + {"section", QVariant::fromValue(static_cast(section))}}); + const auto& cfg = ctx.config(); + const auto text = [](const std::string& value) { return QString::fromStdString(value); }; + initial_ = {{"game", text(cfg.paths.game)}, {"local", text(cfg.paths.local)}, + {"workshop", text(cfg.paths.workshop)}, {"config", text(cfg.paths.config_folder)}, + {"compact", cfg.compact_mod_list}, {"launchArgs", text(cfg.launch_args)}, {"launchWrapper", text(cfg.launch_wrapper)}, + {"afterLaunch", static_cast(cfg.after_launch)}, {"confirmErrors", cfg.launch_confirm_errors}, + {"confirmUnsaved", cfg.launch_confirm_unsaved}, {"confirmRunning", cfg.launch_confirm_running}, + {"useAltIds", cfg.sort.use_alternative_package_ids}, {"checkMissing", cfg.sort.check_missing_dependencies}, + {"useCommunity", cfg.sort.use_community_rules}}; + const auto path = core::community_rules_file(); + communityStatus_ = QFileInfo::exists(path) ? "Found: " + path : QStringLiteral("Not downloaded"); + cacheAvailable_ = !cfg.paths.config_folder.empty(); + cacheStatus_ = cacheAvailable_ ? QStringLiteral("Cache ready") : QStringLiteral("No config folder — not available"); +} +QCoro::Task SettingsBackend::browse(QString key) { + static const QMap titles{{"game", "Select RimWorld Game Folder"}, {"local", "Select Local Mods Folder"}, + {"workshop", "Select Workshop Mods Folder"}, {"config", "Select RimWorld Config Folder"}}; + if (!titles.contains(key)) co_return; + QPointer guard(this); + const auto [result, picker] = co_await awaitDialog(dialog_, titles.value(key)); + const auto files = picker->selectedFiles(); + if (guard && result == QDialog::Accepted && !files.isEmpty()) emit guard->pathPicked(key, files.front()); +} +QString SettingsBackend::localCandidate(const QString& gamePath) const { + const auto path = QDir(gamePath).filePath(QStringLiteral("Mods")); + return QFileInfo(path).isDir() ? path : QString{}; +} +void SettingsBackend::autoDetect() { + const auto paths = core::detect_game_paths(); + const std::pair values[]{{"game", &paths.game}, {"local", &paths.local}, + {"workshop", &paths.workshop}, {"config", &paths.config_folder}}; + for (const auto& [key, value] : values) + if (!value->empty()) emit pathPicked(QString::fromLatin1(key), QString::fromStdString(*value)); +} +QCoro::Task SettingsBackend::downloadCommunityRules() { + communityStatus_ = QStringLiteral("Downloading…"); + communityBusy_ = true; + emit communityChanged(); + if (!ctx_.hasConfigService()) { + communityStatus_ = QStringLiteral("Download failed"); + communityBusy_ = false; + emit communityChanged(); + co_return; + } + core::LoadingState loading; + auto* progress = new QProgressDialog(QStringLiteral("Downloading community rules…"), QString{}, 0, 100, dialog_); + progress->setWindowModality(Qt::WindowModal); + progress->setCancelButton(nullptr); + progress->setMinimumDuration(0); + connect(&loading, &core::LoadingState::changed, progress, + [progress](const QString& status, int percent, int, int) { progress->setLabelText(status); progress->setValue(percent); }); + progress->show(); + core::sort::CommunityRulesService service(ctx_.configService(), ctx_.network()); + QPointer guard(this); + QPointer progressGuard(progress); + const auto path = co_await service.ensure_rules(loading, true); + if (progressGuard) { progressGuard->close(); progressGuard->deleteLater(); } + if (!guard) co_return; + guard->communityStatus_ = path ? "Downloaded: " + *path : QStringLiteral("Download failed"); + guard->communityBusy_ = false; + emit guard->communityChanged(); +} +QCoro::Task SettingsBackend::clearCache() { + cacheStatus_ = QStringLiteral("Clearing…"); + cacheBusy_ = true; + emit cacheChanged(); + QPointer guard(this); + const auto result = co_await ctx_.modService().startup_impact().clear(); + if (!guard) co_return; + guard->cacheStatus_ = result ? QStringLiteral("Cache cleared") : "Error: " + result.error().message; + guard->cacheBusy_ = false; + emit guard->cacheChanged(); +} +void SettingsBackend::reportSaveError(const QString& message) { + auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), message, QMessageBox::Ok, dialog_); + box->setAttribute(Qt::WA_DeleteOnClose); + box->open(); +} +SettingsPanel::SettingsPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& factories) + : QDialog(parent), config_(ctx.config()), backend_(nullptr), qml_(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("settingsPanel")); + setWindowTitle(QStringLiteral("Settings")); + setModal(true); + resize(700, 560); + for (const auto& factory : factories) sections_.push_back(factory(this)); + backend_ = new SettingsBackend(ctx, this, sections_); + connect(backend_, &SettingsBackend::saveRequested, this, &SettingsPanel::save); + connect(backend_, &SettingsBackend::cancelRequested, this, &QDialog::reject); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setAttribute(Qt::WA_AlwaysStackOnTop, false); + qml_->setClearColor(paletteColor(u"ELEVATE_2")); + qml_->rootContext()->setContextProperty(QStringLiteral("settings"), backend_); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); + layout->addWidget(qml_); + // A QML click handler must finish before its root object is destroyed. + connect(this, &QDialog::finished, this, [this] { qml_->setSource(QUrl{}); }, Qt::QueuedConnection); +} +SettingsPanel::SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent) + : SettingsPanel(app.coreContext(), engine, parent, app.settingsSections()) {} +SettingsPanel::~SettingsPanel() { qml_->setSource(QUrl{}); } +void SettingsPanel::save(const QVariantMap& values) { + auto updated = config_; + const auto text = [&](const char* key) { return values.value(QLatin1String(key)).toString().trimmed().toStdString(); }; + updated.paths.game = text("game"); + updated.paths.local = text("local"); + updated.paths.workshop = text("workshop"); + updated.paths.config_folder = text("config"); + updated.sort.use_alternative_package_ids = values.value("useAltIds").toBool(); + updated.sort.check_missing_dependencies = values.value("checkMissing").toBool(); + updated.sort.use_community_rules = values.value("useCommunity").toBool(); + updated.compact_mod_list = values.value("compact").toBool(); + updated.launch_args = text("launchArgs"); + updated.launch_wrapper = text("launchWrapper"); + updated.after_launch = static_cast(values.value("afterLaunch").toInt()); + updated.launch_confirm_errors = values.value("confirmErrors").toBool(); + updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); + updated.launch_confirm_running = values.value("confirmRunning").toBool(); + for (auto* section : sections_) { + const auto result = section->apply(); + if (!result) { backend_->reportSaveError(result.error().message); return; } + } + config_ = std::move(updated); + accept(); +} +} // namespace px::ui diff --git a/src/ui/panels/settings_panel.h b/src/ui/panels/settings_panel.h new file mode 100644 index 0000000..f66b834 --- /dev/null +++ b/src/ui/panels/settings_panel.h @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include +#include +#include "core/context.h" +#include "ui/settings_section.h" +class QQmlEngine; +class QQuickWidget; +namespace px::ui { +class AppContext; +class SettingsBackend : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + Q_PROPERTY(QVariantList sections READ sections CONSTANT) + Q_PROPERTY(QString communityStatus READ communityStatus NOTIFY communityChanged) + Q_PROPERTY(bool communityBusy READ communityBusy NOTIFY communityChanged) + Q_PROPERTY(QString cacheStatus READ cacheStatus NOTIFY cacheChanged) + Q_PROPERTY(bool cacheAvailable READ cacheAvailable CONSTANT) + Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) +public: + SettingsBackend(core::CoreContext& ctx, QDialog* dialog, const std::vector& sections); + QVariantMap initial() const { return initial_; } + QVariantList sections() const { return sections_; } + QString communityStatus() const { return communityStatus_; } + bool communityBusy() const { return communityBusy_; } + QString cacheStatus() const { return cacheStatus_; } + bool cacheAvailable() const { return cacheAvailable_; } + bool cacheBusy() const { return cacheBusy_; } + Q_INVOKABLE QCoro::Task browse(QString key); + Q_INVOKABLE QString localCandidate(const QString& gamePath) const; + Q_INVOKABLE void autoDetect(); + Q_INVOKABLE QCoro::Task downloadCommunityRules(); + Q_INVOKABLE QCoro::Task clearCache(); + Q_INVOKABLE void save(const QVariantMap& values) { emit saveRequested(values); } + Q_INVOKABLE void cancel() { emit cancelRequested(); } + void reportSaveError(const QString& message); +signals: + void pathPicked(const QString& key, const QString& path); + void saveRequested(const QVariantMap& values); + void cancelRequested(); + void communityChanged(); + void cacheChanged(); +private: + core::CoreContext& ctx_; + QDialog* dialog_; + QVariantMap initial_; + QVariantList sections_; + QString communityStatus_, cacheStatus_; + bool communityBusy_ = false, cacheBusy_ = false, cacheAvailable_ = false; +}; +class SettingsPanel : public QDialog { + Q_OBJECT +public: + SettingsPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& sections = {}); + SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); + ~SettingsPanel() override; + const core::AppConfig& getConfig() const { return config_; } +private: + void save(const QVariantMap& values); + core::AppConfig config_; + std::vector sections_; + SettingsBackend* backend_; + QQuickWidget* qml_; +}; +} // namespace px::ui diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp new file mode 100644 index 0000000..c2736c9 --- /dev/null +++ b/src/ui/panels/sidebar_panel.cpp @@ -0,0 +1,33 @@ +#include "ui/panels/sidebar_panel.h" +#include "core/context.h" +#include "core/services/diagnostics_service.h" +#include +namespace px::ui { +SidebarPanel::SidebarPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent) + : FilterSidebar(engine, nullptr, parent), ctx_(ctx), model_(new SidebarModel(this)) { + if (rootObject()) rootObject()->setProperty("model", QVariant::fromValue(model_)); + connect(this, &FilterSidebar::activated, this, &SidebarPanel::on_activated); + sidebar_connection_ = ctx_.diagnosticsService().sidebar_entries_changed.connect([this](const auto& entries) { set_entries(entries); }); +} +SidebarPanel::~SidebarPanel() { ctx_.diagnosticsService().sidebar_entries_changed.disconnect(sidebar_connection_); } +void SidebarPanel::set_entries(const QList& entries) { + const bool reset = entries.size() != entries_.size(); + entries_ = entries; + model_->update_entries(entries); + if (reset) if (const auto current = current_entry()) emit entry_selected(*current); +} +std::optional SidebarPanel::current_entry() const { + const int selected = current_index(); + if (selected >= 0 && selected < entries_.size()) return entries_[selected]; + return std::nullopt; +} +void SidebarPanel::select_all_entry() { + auto* root = rootObject(); + auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (!view) return; + for (int row = 0; row < entries_.size(); ++row) if (entries_[row].kind == core::view::SidebarEntry::Kind::All) { + view->setProperty("currentIndex", row); on_activated(row); return; + } +} +void SidebarPanel::on_activated(int row) { if (row >= 0 && row < entries_.size()) emit entry_selected(entries_[row]); } +} diff --git a/src/ui/panels/sidebar_panel.h b/src/ui/panels/sidebar_panel.h new file mode 100644 index 0000000..9077825 --- /dev/null +++ b/src/ui/panels/sidebar_panel.h @@ -0,0 +1,25 @@ +#pragma once +#include "ui/components/filter_sidebar.h" +#include "ui/models/sidebar_model.h" +#include +namespace px::core { class CoreContext; } +namespace px::ui { +class SidebarPanel : public FilterSidebar { + Q_OBJECT +public: + SidebarPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~SidebarPanel() override; + void set_entries(const QList& entries); + std::optional current_entry() const; + void select_all_entry(); + SidebarModel* model() const { return model_; } +signals: + void entry_selected(const px::core::view::SidebarEntry& entry); +private: + void on_activated(int row); + core::CoreContext& ctx_; + SidebarModel* model_; + QList entries_; + std::uint64_t sidebar_connection_; +}; +} diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp new file mode 100644 index 0000000..5539b3c --- /dev/null +++ b/src/ui/panels/startup_impact_data.cpp @@ -0,0 +1,142 @@ +#include "ui/panels/startup_impact_data.h" + +#include +#include +#include +#include +#include +#include +#include +#include "core/services/startup_impact_service/labels.h" +#include "core/services/startup_impact_service/phases.h" +#include "ui/theme/palette.h" + +namespace px::ui { +using namespace core::startup_impact; +namespace { +constexpr double minSeconds = 0.001; +QString phaseColor(std::size_t i) { + static constexpr std::array colors{ + "#b57bee", "#f06ba8", "#43b581", "#f0883e", "#4fb3e8", "#e8c547"}; + return i < colors.size() ? QString::fromLatin1(colors[i]) : paletteValue(u"TEXT_DIM"); +} +QString tooltip(const QMap& metrics) { + QList> sorted; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) sorted.append({it.key(), it.value()}); + std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); + QStringList lines; + for (qsizetype i = 0; i < std::min(8, sorted.size()); ++i) + if (sorted[i].second >= minSeconds) lines.append(sorted[i].first + ": " + format_duration(sorted[i].second)); + if (sorted.size() > 8) lines.append(QStringLiteral("+ %1 more").arg(sorted.size() - 8)); + return lines.join('\n'); +} +using Entry = std::tuple*>; +QVariantList phaseRows(const QList& entries, const std::optional& selected) { + using Contributor = std::tuple; + std::array, PHASE_COUNT> contributors; + std::array, PHASE_COUNT> metrics; + PhaseTotals sums{}; + for (const auto& [name, key, base, values] : entries) { + const auto totals = phase_totals(*values); + for (std::size_t p = 0; p < PHASE_COUNT; ++p) { + if (totals[p] < minSeconds) continue; + contributors[p].append({totals[p], name, key, base}); + sums[p] += totals[p]; + } + for (auto it = values->cbegin(); it != values->cend(); ++it) + metrics[phase_index(metric_phase(it.key()))][metric_label(it.key().section('|', 0, 0))] += it.value(); + } + double grand = 0; + for (double value : sums) grand += value; + const double maximum = *std::max_element(sums.begin(), sums.end()); + std::array order{0, 1, 2, 3, 4, 5, 6}; + std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return sums[a] > sums[b]; }); + QVariantList rows; + for (auto p : order) { + if (contributors[p].isEmpty()) continue; + auto& items = contributors[p]; + std::sort(items.begin(), items.end(), std::greater{}); + QVariantList mods; + const double top = std::get<0>(items.front()); + for (const auto& [seconds, name, key, base] : items) + mods.append(QVariantMap{{"name", name}, {"key", key}, {"value", format_duration(seconds)}, + {"fraction", seconds / top}, {"base", base}, {"selected", selected && key == *selected}}); + rows.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}, + {"seconds", sums[p]}, {"value", format_duration(sums[p])}, + {"share", grand > 0 ? QString::number(static_cast(std::nearbyint(100 * sums[p] / grand))) + '%' : QString{}}, + {"fraction", sums[p] / maximum}, {"tooltip", tooltip(metrics[p])}, {"mods", mods}}); + } + return rows; +} +} +QVariantMap build_startup_impact(const StartupImpactReport& report, const QStringList& active_pids, + const std::optional& selected_pid, double estimated_s) { + QSet active; + for (const auto& pid : active_pids) active.insert(normalize_package_id(pid)); + const auto selected = selected_pid && !selected_pid->isEmpty() + ? std::optional{normalize_package_id(*selected_pid)} : std::nullopt; + const StartupImpactMod* base = nullptr; + QList mods; + double modsSeconds = 0, offSeconds = 0; + for (const auto& mod : report.mods) { + offSeconds += mod.off_thread_total_impact_s; + if (mod.package_id && *mod.package_id == BASE_GAME_PID) { if (!base) base = &mod; } + else { mods.append(&mod); modsSeconds += mod.total_impact_s; } + } + std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { return a->total_impact_s > b->total_impact_s; }); + const double baseSeconds = base ? base->total_impact_s : 0; + const double barTotal = baseSeconds + modsSeconds + offSeconds; + const double maxMod = mods.isEmpty() ? 0 : mods.front()->total_impact_s; + QVariantList rows, legend, summary; + QList entries; + int activeCount = 0; + QString selectedKey; + for (qsizetype index = 0; index < mods.size(); ++index) { + const auto& mod = *mods[index]; + const QString key = mod.package_id && !mod.package_id->isEmpty() ? *mod.package_id : mod.mod_name; + const bool isActive = mod.package_id && active.contains(*mod.package_id); + const bool isSelected = mod.package_id && selected && *mod.package_id == *selected; + if (isActive) ++activeCount; + if (isSelected && selectedKey.isEmpty()) selectedKey = key; + const auto totals = phase_totals(mod.metrics); + std::array, PHASE_COUNT> grouped; + for (auto it = mod.metrics.cbegin(); it != mod.metrics.cend(); ++it) + grouped[phase_index(metric_phase(it.key()))][metric_label(it.key())] += it.value(); + QList order; + QVariantList segments, phases; + for (std::size_t p = 0; p < PHASE_COUNT; ++p) { + if (totals[p] < minSeconds) continue; + order.append(p); + segments.append(QVariantMap{{"color", phaseColor(p)}, {"fraction", maxMod > 0 ? totals[p] / maxMod : 0}}); + } + std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return totals[a] > totals[b]; }); + for (auto p : order) + phases.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}, + {"value", format_duration(totals[p])}, {"fraction", totals[p] / totals[order.front()]}, {"tooltip", tooltip(grouped[p])}}); + rows.append(QVariantMap{{"key", key}, {"name", mod.mod_name}, {"rank", index + 1}, + {"seconds", mod.total_impact_s}, {"time", format_duration(mod.total_impact_s)}, {"color", impact_color(mod.total_impact_s)}, + {"active", isActive}, {"selected", isSelected}, + {"off_thread", mod.off_thread_total_impact_s >= minSeconds ? format_duration(mod.off_thread_total_impact_s) : QString{}}, + {"dominant", order.isEmpty() ? QString{} : phase_label(static_cast(order.front()))}, {"segments", segments}, {"phases", phases}}); + entries.append({mod.mod_name, key, false, &mod.metrics}); + } + for (std::size_t p = 0; p < PHASE_COUNT; ++p) + legend.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}}); + const std::array labels{QStringLiteral("Base game"), QStringLiteral("Mods, main thread"), QStringLiteral("Off-thread (parallel)")}; + const std::array values{baseSeconds, modsSeconds, offSeconds}; + for (std::size_t i = 0; i < values.size(); ++i) + if (values[i] >= minSeconds) summary.append(QVariantMap{{"label", labels[i]}, {"value", format_duration(values[i])}, + {"fraction", barTotal > 0 ? values[i] / barTotal : 0}, {"color", paletteValue(i == 0 ? u"TEXT_DIM" : u"PRIMARY")}, {"parallel", i == 2}}); + const auto modsPhases = phaseRows(entries, selected); + const QMap empty; + entries.prepend({QStringLiteral("Base game"), QString(BASE_GAME_PID), true, base ? &base->metrics : &empty}); + auto timestamp = QDateTime::fromString(report.timestamp, Qt::ISODateWithMs); + if (!timestamp.isValid()) timestamp = QDateTime::fromString(report.timestamp, Qt::ISODate); + return {{"timestamp", timestamp.isValid() ? timestamp.toLocalTime().toString("yyyy-MM-dd HH:mm") : report.timestamp}, + {"last_launch", format_duration(report.loading_time_s != 0 ? report.loading_time_s : baseSeconds + modsSeconds)}, + {"estimated", active_pids.isEmpty() ? QString{} : format_duration(estimated_s)}, {"summary", summary}, + {"base_value", format_duration(baseSeconds)}, {"mods_value", format_duration(modsSeconds)}, {"off_value", format_duration(offSeconds)}, + {"legend", legend}, {"mods", rows}, {"active_count", activeCount}, {"selected_key", selectedKey}, + {"phases_with_base", phaseRows(entries, selected)}, {"phases_mods_only", modsPhases}}; +} +} // namespace px::ui diff --git a/src/ui/panels/startup_impact_data.h b/src/ui/panels/startup_impact_data.h new file mode 100644 index 0000000..92d8aa8 --- /dev/null +++ b/src/ui/panels/startup_impact_data.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include +#include "core/services/startup_impact_service/models.h" +#include "ui/models/impact.h" + +namespace px::ui { +QVariantMap build_startup_impact(const core::startup_impact::StartupImpactReport& report, + const QStringList& active_pids, + const std::optional& selected_pid, double estimated_s); +} // namespace px::ui diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp new file mode 100644 index 0000000..9d15143 --- /dev/null +++ b/src/ui/panels/time_analytics_panel.cpp @@ -0,0 +1,48 @@ +#include "ui/panels/time_analytics_panel.h" +#include +#include +#include +#include +#include +#include "ui/components/dialog_chrome.h" +#include "ui/panels/startup_impact_data.h" +#include "ui/theme/palette.h" +namespace px::ui { +TimeAnalyticsPanel::TimeAnalyticsPanel(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) + : QWidget(parent), service_(service), qml_(new QQuickWidget(engine, this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setAttribute(Qt::WA_AlwaysStackOnTop, false); + qml_->setClearColor(paletteColor(u"ELEVATE_1")); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); + layout->addWidget(qml_); +} +QCoro::Task TimeAnalyticsPanel::setData(std::optional pid, QStringList activePids) { + const auto token = ++requestToken_; + QPointer root = qml_->rootObject(); + if (!root) co_return; + if (!service_) { root->setProperty("sourceData", QVariant{}); co_return; } + QPointer guard(this); + auto result = co_await service_->snapshot(activePids); + if (!guard || !root || token != guard->requestToken_) co_return; + if (!result || !result->report) { root->setProperty("sourceData", QVariant{}); co_return; } + double estimated = result->base_game_avg; + for (const auto& total : result->totals) estimated += total.on_thread + total.off_thread; + root->setProperty("sourceData", build_startup_impact(*result->report, activePids, pid, estimated)); +} +void TimeAnalyticsPanel::clear() { + ++requestToken_; + if (auto* root = qml_->rootObject()) root->setProperty("sourceData", QVariant{}); +} +StartupImpactDialog::StartupImpactDialog(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) + : QDialog(parent), panel(new TimeAnalyticsPanel(service, engine, this)) { + setWindowTitle(QStringLiteral("Startup impact")); + setAttribute(Qt::WA_DeleteOnClose); + resize(900, 720); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(panel); + installDialogChrome(this); +} +} // namespace px::ui diff --git a/src/ui/panels/time_analytics_panel.h b/src/ui/panels/time_analytics_panel.h new file mode 100644 index 0000000..53ae29c --- /dev/null +++ b/src/ui/panels/time_analytics_panel.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include +#include +#include +#include "core/services/startup_impact_service/service.h" +class QQmlEngine; +class QQuickWidget; +namespace px::ui { +class TimeAnalyticsPanel : public QWidget { + Q_OBJECT +public: + TimeAnalyticsPanel(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); + QCoro::Task setData(std::optional pid, QStringList activePids); + void clear(); +private: + core::startup_impact::StartupImpactService* service_; + QQuickWidget* qml_; + quint64 requestToken_ = 0; +}; +class StartupImpactDialog : public QDialog { + Q_OBJECT +public: + StartupImpactDialog(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); + TimeAnalyticsPanel* panel; +}; +} // namespace px::ui diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp new file mode 100644 index 0000000..209b299 --- /dev/null +++ b/src/ui/panels/update_dialog.cpp @@ -0,0 +1,52 @@ +#include "ui/panels/update_dialog.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace px::ui { +UpdateDialog::UpdateDialog(const core::services::ReleaseInfo& release, + const QString& currentVersion, QWidget* parent) : QDialog(parent) { + setWindowTitle(tr("Update Available")); + resize(560, 460); + auto* layout = new QVBoxLayout(this); + auto* title = new QLabel(tr("PxModRim %1 is available").arg(release.tag), this); + title->setStyleSheet(QStringLiteral("font-size: 16px; font-weight: 600;")); + layout->addWidget(title); + layout->addWidget(new QLabel(tr("You are running %1.").arg(currentVersion), this)); + auto* notes = new QTextBrowser(this); + notes->setOpenLinks(false); + notes->setOpenExternalLinks(false); + connect(notes, &QTextBrowser::anchorClicked, this, [](const QUrl& url) { + const QString scheme = url.scheme().toLower(); + if (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) + QDesktopServices::openUrl(url); + }); + notes->setMarkdown(release.notes.isEmpty() ? tr("No release notes.") : release.notes); + layout->addWidget(notes, 1); + auto* note = new QLabel(tr("Automatic updating may be added later. For now, downloading the release " + "yourself is the safest way to avoid update bugs."), this); + note->setWordWrap(true); + layout->addWidget(note); + auto* buttons = new QHBoxLayout; + auto* skip = new QPushButton(tr("Skip This Version"), this); + skip->setObjectName(QStringLiteral("appBtn")); + connect(skip, &QPushButton::clicked, this, [this] { done(SKIP_RESULT); }); + auto* later = new QPushButton(tr("Later"), this); + later->setObjectName(QStringLiteral("appBtn")); + connect(later, &QPushButton::clicked, this, &QDialog::reject); + auto* download = new QPushButton(tr("Download"), this); + download->setObjectName(QStringLiteral("primaryAction")); + download->setDefault(true); + connect(download, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(skip); + buttons->addStretch(); + buttons->addWidget(later); + buttons->addWidget(download); + layout->addLayout(buttons); +} +} // namespace px::ui diff --git a/src/ui/panels/update_dialog.h b/src/ui/panels/update_dialog.h new file mode 100644 index 0000000..967b51c --- /dev/null +++ b/src/ui/panels/update_dialog.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include "core/services/update_service.h" + +namespace px::ui { +inline constexpr int SKIP_RESULT = 2; + +class UpdateDialog : public QDialog { +public: + UpdateDialog(const core::services::ReleaseInfo& release, const QString& currentVersion, + QWidget* parent = nullptr); +}; +} // namespace px::ui diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp new file mode 100644 index 0000000..390f78c --- /dev/null +++ b/src/ui/panels/upload_report_dialog.cpp @@ -0,0 +1,149 @@ +#include "ui/panels/upload_report_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/providers/base.h" +#include "core/support/report.h" +#include "ui/components/dialogs.h" +#include "ui/context.h" + +namespace px::ui { +UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { + setObjectName(QStringLiteral("uploadConfirmDialog")); + setWindowTitle(tr("Upload Log & System Info")); + setModal(true); + resize(520, 320); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* description = new QLabel(tr( + "This action generates and uploads an anonymized diagnostic report to help diagnose issues.\n\n" + "The report includes:\n" + " • Recent application log contents (last ~2 MB)\n" + " • System details (OS, architecture, Qt)\n" + " • RimWorld version and configured directory paths\n" + " • Mod collection count and active mod list in load order\n\n" + "Privacy & security:\n" + " • Your home directory path is replaced with '~'\n" + " • Your username in other user-directory paths is replaced with ''\n\n" + "Notice: The uploaded report will be publicly viewable by anyone with the link."), this); + description->setWordWrap(true); + layout->addWidget(description); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->setSpacing(10); + auto* save = new QPushButton(tr("Save to file instead…"), this); + save->setObjectName(QStringLiteral("appBtn")); + connect(save, &QPushButton::clicked, this, [this] { done(static_cast(UploadChoice::SAVE_TO_FILE)); }); + buttons->addWidget(save); + buttons->addStretch(); + auto* cancel = new QPushButton(tr("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(cancel); + auto* upload = new QPushButton(tr("Upload"), this); + upload->setObjectName(QStringLiteral("primaryAction")); + connect(upload, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(upload); + layout->addLayout(buttons); +} + +UploadFailedDialog::UploadFailedDialog(const QString& errorMessage, QWidget* parent) : QDialog(parent) { + setObjectName(QStringLiteral("uploadFailedDialog")); + setWindowTitle(tr("Upload Failed")); + setModal(true); + resize(460, 220); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* title = new QLabel(tr("Couldn't upload the report"), this); + title->setObjectName(QStringLiteral("dialogTitle")); + layout->addWidget(title); + auto* message = new QLabel(tr("An error occurred while uploading:\n\n%1\n\n" + "Would you like to save the report to a file instead?").arg(errorMessage), this); + message->setTextFormat(Qt::PlainText); + message->setWordWrap(true); + layout->addWidget(message); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("appBtn")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(close); + auto* save = new QPushButton(tr("Save to file…"), this); + save->setObjectName(QStringLiteral("primaryAction")); + connect(save, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(save); + layout->addLayout(buttons); +} + +namespace { +class SaveReportFileDialog : public QFileDialog { +public: + explicit SaveReportFileDialog(QWidget* parent) + : QFileDialog(parent, tr("Save Log & System Info"), QStringLiteral("pxmodrim-report.txt"), + tr("Text Files (*.txt);;All Files (*)")) { + setAcceptMode(QFileDialog::AcceptSave); + } +}; + +QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { + auto [result, dialog] = co_await awaitDialog(parent); + const QStringList files = dialog->selectedFiles(); + if (result != QDialog::Accepted || files.isEmpty()) co_return; + const QString path = files.first(); + const QString error = co_await core::providers::runOnPool(nullptr, + [path, report = std::move(report)] { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) return file.errorString(); + const QByteArray bytes = report.toUtf8(); + if (file.write(bytes) != bytes.size() || !file.commit()) return file.errorString(); + return QString{}; + }); + if (!error.isEmpty()) toast.error(QObject::tr("Failed to save file: %1").arg(error), 5000); + else toast.success(QObject::tr("Report saved to %1").arg(QFileInfo(path).fileName()), 3000); +} +} // namespace + +QCoro::Task> handle_upload_report(QWidget* parent, core::CoreContext& ctx, + Notifier& toast) { + QPointer owner(parent); + auto [choice, dialog] = co_await awaitDialog(parent); + Q_UNUSED(dialog); + if (choice == static_cast(UploadChoice::CANCEL)) co_return std::nullopt; + core::support::ReportSource source{ctx.gameVersion(), ctx.config().paths, + ctx.allMods(), ctx.activeUuids()}; + const QString report = co_await core::providers::runOnPool(nullptr, + [source = std::move(source)] { return core::support::build_report(source); }); + if (parent && !owner) co_return std::nullopt; + if (choice == static_cast(UploadChoice::SAVE_TO_FILE)) { + co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + toast.info(QObject::tr("Uploading log & system info…"), 2000); + QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); + if (endpoint.isEmpty()) endpoint = QString(core::support::DEFAULT_UPLOAD_ENDPOINT); + core::support::PasteUploader uploader(ctx.network(), std::move(endpoint)); + auto uploaded = co_await uploader.upload(report); + if (parent && !owner) co_return std::nullopt; + if (!uploaded) { + auto [result, failed] = co_await awaitDialog(uploaded.error().message, owner.data()); + Q_UNUSED(failed); + if (result == QDialog::Accepted) co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + QGuiApplication::clipboard()->setText(*uploaded); + toast.success(QObject::tr("Link copied: %1").arg(*uploaded), 5000); + co_return *uploaded; +} +} // namespace px::ui diff --git a/src/ui/panels/upload_report_dialog.h b/src/ui/panels/upload_report_dialog.h new file mode 100644 index 0000000..e99c079 --- /dev/null +++ b/src/ui/panels/upload_report_dialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +namespace px::core { class CoreContext; } +namespace px::ui { +class Notifier; +enum class UploadChoice { CANCEL = QDialog::Rejected, UPLOAD = QDialog::Accepted, SAVE_TO_FILE = 2 }; +class UploadConfirmDialog : public QDialog { +public: + explicit UploadConfirmDialog(QWidget* parent = nullptr); +}; +class UploadFailedDialog : public QDialog { +public: + explicit UploadFailedDialog(const QString& errorMessage, QWidget* parent = nullptr); +}; +QCoro::Task> handle_upload_report(QWidget* parent, core::CoreContext& ctx, + Notifier& toast); +} // namespace px::ui diff --git a/src/ui/placeholder.cpp b/src/ui/placeholder.cpp deleted file mode 100644 index d6c38d4..0000000 --- a/src/ui/placeholder.cpp +++ /dev/null @@ -1 +0,0 @@ -// Keeps the pxui library non-empty until UI slices land. diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp new file mode 100644 index 0000000..2b99fab --- /dev/null +++ b/src/ui/plugins/downloads/model.cpp @@ -0,0 +1,202 @@ +#include "ui/plugins/downloads/model.h" + +namespace px::ui { +namespace { +bool is_active(const QString& state) { + return state == QLatin1String("queued") || state == QLatin1String("checking") || state == QLatin1String("downloading"); +} +QString final_state(qint64 total) { + return total > 0 ? QStringLiteral("updated") : QStringLiteral("unchanged"); +} +} + +DownloadsModel::DownloadsModel(QObject* parent) + : QAbstractListModel(parent), timer_(this), speed_timer_(this) { + timer_.setSingleShot(true); + timer_.setInterval(150); + connect(&timer_, &QTimer::timeout, this, &DownloadsModel::flush); + speed_timer_.setInterval(1000); + connect(&speed_timer_, &QTimer::timeout, this, &DownloadsModel::sample_speed); +} + +void DownloadsModel::begin(const QStringList& ids, const QHash& titles) { + timer_.stop(); + dirty_.clear(); + bytes_ = sampled_bytes_ = 0; + speed_ = 0; + history_.clear(); + emit speed_changed(); + set_phase({}); + beginResetModel(); + rows_.clear(); + by_id_.clear(); + rows_.reserve(ids.size()); + for (const auto& id : ids) { + by_id_.insert(id, rows_.size()); + rows_.append(Row{id, titles.value(id, id)}); + } + recount(); + select(); + endResetModel(); + emit summary_changed(); +} + +void DownloadsModel::apply(const core::DownloadItemStatus& status) { + const auto found = by_id_.constFind(status.mod_id); + if (found == by_id_.cend()) return; + auto& row = rows_[*found]; + const auto done = status.status == QLatin1String("error") ? row.bytes_done : status.bytes_done; + bytes_ += done - row.bytes_done; + row.bytes_done = done; + if (status.status == QLatin1String("error")) { + row.state = QStringLiteral("failed"); + row.error = status.error; + row.progress = 0; + } else if (status.status == QLatin1String("success")) { + row.state = final_state(status.bytes_total); + row.progress = 1; + } else if (status.bytes_total > 0 && status.bytes_done < status.bytes_total) { + row.state = QStringLiteral("downloading"); + row.progress = static_cast(status.bytes_done) / status.bytes_total; + } else { + row.state = final_state(status.bytes_total); + row.progress = 1; + } + mark(row); +} + +void DownloadsModel::set_title(const core::DownloadItemTitle& item) { + const auto found = by_id_.constFind(item.mod_id); + if (found == by_id_.cend()) return; + auto& row = rows_[*found]; + if (row.title == row.id) row.title = item.title; + if (row.state == QLatin1String("queued")) row.state = QStringLiteral("checking"); + mark(row); +} + +void DownloadsModel::set_phase(const QString& phase) { + if (phase_ == phase) return; + phase_ = phase; + emit phase_changed(); +} + +void DownloadsModel::finish(const core::DownloadResult&) { + for (auto& row : rows_) { + if (is_active(row.state)) { + row.state = QStringLiteral("cancelled"); + row.progress = 0; + dirty_.insert(row.id); + } + } + timer_.stop(); + flush(); +} + +void DownloadsModel::set_busy(bool busy) { + if (busy_ == busy) return; + busy_ = busy; + if (busy) { + sampled_bytes_ = bytes_; + speed_timer_.start(); + } else { + speed_timer_.stop(); + speed_ = 0; + emit speed_changed(); + } + emit summary_changed(); +} + +QStringList DownloadsModel::failed_ids() const { + QStringList ids; + for (const auto& row : rows_) { + if (row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled")) ids.append(row.id); + } + return ids; +} + +void DownloadsModel::clear() { + if (!busy_) begin({}, {}); +} + +void DownloadsModel::setFilter(const QString& name) { + if (filter_ == name) return; + filter_ = name; + beginResetModel(); + select(); + endResetModel(); + emit summary_changed(); +} + +void DownloadsModel::sample_speed() { + speed_ = static_cast(bytes_ - sampled_bytes_); + sampled_bytes_ = bytes_; + if (history_.size() == HistoryLength) history_.removeFirst(); + history_.append(speed_); + emit speed_changed(); +} + +void DownloadsModel::mark(const Row& row) { + dirty_.insert(row.id); + if (!timer_.isActive()) timer_.start(); +} + +bool DownloadsModel::matches(const Row& row) const { + if (filter_ == QLatin1String("active")) return is_active(row.state); + if (filter_ == QLatin1String("updated")) return row.state == QLatin1String("updated"); + if (filter_ == QLatin1String("failed")) return row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled"); + return true; +} + +void DownloadsModel::select() { + visible_.clear(); + for (qsizetype pos = 0; pos < rows_.size(); ++pos) { + if (matches(rows_[pos])) visible_.append(pos); + } +} + +void DownloadsModel::recount() { + counts_.clear(); + for (const auto& row : rows_) ++counts_[row.state]; +} + +void DownloadsModel::flush() { + if (dirty_.isEmpty()) return; + recount(); + if (filter_ == QLatin1String("all")) { + for (qsizetype pos = 0; pos < visible_.size(); ++pos) { + if (dirty_.contains(rows_[visible_[pos]].id)) { + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx); + } + } + } else { + beginResetModel(); + select(); + endResetModel(); + } + dirty_.clear(); + emit summary_changed(); +} + +int DownloadsModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(visible_.size()); +} + +QVariant DownloadsModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= visible_.size()) return {}; + const auto& row = rows_[visible_[index.row()]]; + switch (role) { + case IdRole: return row.id; + case TitleRole: return row.title; + case StateRole: return row.state; + case ProgressRole: return row.progress; + case ErrorRole: return row.error; + default: return {}; + } +} + +QHash DownloadsModel::roleNames() const { + return {{IdRole, "id"}, {TitleRole, "title"}, {StateRole, "state"}, {ProgressRole, "progress"}, {ErrorRole, "error"}}; +} + +} // namespace px::ui diff --git a/src/ui/plugins/downloads/model.h b/src/ui/plugins/downloads/model.h new file mode 100644 index 0000000..dbf5fa8 --- /dev/null +++ b/src/ui/plugins/downloads/model.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/downloads/types.h" + +namespace px::ui { + +class DownloadsModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int total READ total NOTIFY summary_changed) + Q_PROPERTY(int finished READ finished NOTIFY summary_changed) + Q_PROPERTY(int updated READ updated NOTIFY summary_changed) + Q_PROPERTY(int unchanged READ unchanged NOTIFY summary_changed) + Q_PROPERTY(int failed READ failed NOTIFY summary_changed) + Q_PROPERTY(int active READ active NOTIFY summary_changed) + Q_PROPERTY(int resolved READ resolved NOTIFY summary_changed) + Q_PROPERTY(bool busy READ busy NOTIFY summary_changed) + Q_PROPERTY(QString filter READ filter NOTIFY summary_changed) + Q_PROPERTY(QString phase READ phase NOTIFY phase_changed) + Q_PROPERTY(double speed READ speed NOTIFY speed_changed) + Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speed_changed) + Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speed_changed) + +public: + enum Role { IdRole = Qt::UserRole + 1, TitleRole, StateRole, ProgressRole, ErrorRole }; + static constexpr qsizetype HistoryLength = 90; + + explicit DownloadsModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void begin(const QStringList& ids, const QHash& titles); + void apply(const core::DownloadItemStatus& status); + void set_title(const core::DownloadItemTitle& item); + void set_phase(const QString& phase); + void finish(const core::DownloadResult& result); + void set_busy(bool busy); + QStringList failed_ids() const; + void flush(); + Q_INVOKABLE void clear(); + Q_INVOKABLE void setFilter(const QString& name); + + int total() const { return static_cast(rows_.size()); } + int finished() const { return updated() + unchanged() + failed(); } + int updated() const { return counts_.value(QStringLiteral("updated")); } + int unchanged() const { return counts_.value(QStringLiteral("unchanged")); } + int failed() const { return counts_.value(QStringLiteral("failed")) + counts_.value(QStringLiteral("cancelled")); } + int active() const { return counts_.value(QStringLiteral("queued")) + counts_.value(QStringLiteral("checking")) + counts_.value(QStringLiteral("downloading")); } + int resolved() const { return total() - counts_.value(QStringLiteral("queued")); } + bool busy() const { return busy_; } + QString filter() const { return filter_; } + QString phase() const { return phase_; } + double speed() const { return speed_; } + double bytesDone() const { return static_cast(bytes_); } + QVariantList speedHistory() const { return history_; } + +signals: + void summary_changed(); + void speed_changed(); + void phase_changed(); + +private: + struct Row { + QString id; + QString title; + QString state = QStringLiteral("queued"); + double progress = 0; + QString error; + qint64 bytes_done = 0; + }; + void mark(const Row& row); + bool matches(const Row& row) const; + void select(); + void recount(); + void sample_speed(); + QList rows_; + QHash by_id_; + QList visible_; + QString filter_ = QStringLiteral("all"); + QString phase_; + bool busy_ = false; + QHash counts_; + QSet dirty_; + QTimer timer_; + QTimer speed_timer_; + qint64 bytes_ = 0; + qint64 sampled_bytes_ = 0; + double speed_ = 0; + QVariantList history_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/downloads/plugin.cpp b/src/ui/plugins/downloads/plugin.cpp new file mode 100644 index 0000000..c641857 --- /dev/null +++ b/src/ui/plugins/downloads/plugin.cpp @@ -0,0 +1,10 @@ +#include "ui/plugins/downloads/plugin.h" + +#include "ui/plugins/downloads/view.h" + +namespace px::ui { + +DownloadsUiPlugin::DownloadsUiPlugin() : UiPlugin(QStringLiteral("downloads_ui"), {QStringLiteral("downloads")}) {} +void DownloadsUiPlugin::setup(AppContext& ctx) { ctx.addRailView(); } + +} // namespace px::ui diff --git a/src/ui/plugins/downloads/plugin.h b/src/ui/plugins/downloads/plugin.h new file mode 100644 index 0000000..347b738 --- /dev/null +++ b/src/ui/plugins/downloads/plugin.h @@ -0,0 +1,13 @@ +#pragma once + +#include "ui/context.h" + +namespace px::ui { + +class DownloadsUiPlugin final : public UiPlugin { +public: + DownloadsUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp new file mode 100644 index 0000000..c890b03 --- /dev/null +++ b/src/ui/plugins/downloads/view.cpp @@ -0,0 +1,52 @@ +#include "ui/plugins/downloads/view.h" + +#include +#include +#include + +#include "ui/theme/palette.h" + +namespace px::ui { + +DownloadsViewPanel::DownloadsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), downloads_(ctx.downloads()), model_(new DownloadsModel(this)) { + auto* qml = new QQuickWidget(engine, this); + qml->setObjectName(QStringLiteral("downloadsView")); + qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsPanel"), this); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), model_); + qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Downloads.qml"))); + root_->addWidget(qml, 1); + model_->set_busy(downloads_.is_downloading()); + batch_id_ = downloads_.batch_started.connect([this](const QStringList& ids) { + model_->begin(ids, downloads_.names_by_id(ctx_.allMods().values())); + }); + status_id_ = downloads_.download_item_status_changed.connect([this](const core::DownloadItemStatus& status) { model_->apply(status); }); + title_id_ = downloads_.download_item_titled.connect([this](const core::DownloadItemTitle& item) { model_->set_title(item); }); + phase_id_ = downloads_.download_phase_changed.connect([this](const QString& phase) { model_->set_phase(phase); }); + finished_id_ = downloads_.download_finished.connect([this](const core::DownloadResult& result) { model_->finish(result); }); + busy_id_ = downloads_.busy_changed.connect([this](bool busy) { model_->set_busy(busy); }); +} + +DownloadsViewPanel::~DownloadsViewPanel() { + downloads_.batch_started.disconnect(batch_id_); + downloads_.download_item_status_changed.disconnect(status_id_); + downloads_.download_item_titled.disconnect(title_id_); + downloads_.download_phase_changed.disconnect(phase_id_); + downloads_.download_finished.disconnect(finished_id_); + downloads_.busy_changed.disconnect(busy_id_); +} + +void DownloadsViewPanel::stop() { downloads_.cancel(); } + +QCoro::QmlTask DownloadsViewPanel::retryFailed() { return retry_failed(); } + +QCoro::Task DownloadsViewPanel::retry_failed() { + auto ids = model_->failed_ids(); + if (ids.isEmpty()) co_return; + auto result = co_await downloads_.download_mods(std::move(ids)); + if (!result) spdlog::warn("downloads retry not started: {}", result.error().message.toStdString()); +} + +} // namespace px::ui diff --git a/src/ui/plugins/downloads/view.h b/src/ui/plugins/downloads/view.h new file mode 100644 index 0000000..87f944a --- /dev/null +++ b/src/ui/plugins/downloads/view.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +#include "core/downloads/manager.h" +#include "ui/plugins/downloads/model.h" +#include "ui/views/base.h" + +namespace px::ui { + +class DownloadsViewPanel final : public BaseViewPanel { + Q_OBJECT +public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("downloads"), QLatin1StringView("download"), QLatin1StringView("Downloads"), true}; + DownloadsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + ~DownloadsViewPanel() override; + Q_INVOKABLE void stop(); + Q_INVOKABLE QCoro::QmlTask retryFailed(); + +private: + QCoro::Task retry_failed(); + core::DownloadManager& downloads_; + DownloadsModel* model_; + core::Event::Id batch_id_; + core::Event::Id status_id_; + core::Event::Id title_id_; + core::Event::Id phase_id_; + core::Event::Id finished_id_; + core::Event::Id busy_id_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp new file mode 100644 index 0000000..100df25 --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -0,0 +1,144 @@ +#include "ui/plugins/organizer/dialogs.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/organizer/models.h" +#include "core/organizer/resolve.h" + +namespace px::ui { + +namespace { + +using px::core::organizer::FolderNode; +using px::core::organizer::ROOT_ID; + +QPushButton* makeButton(const QString& text, QWidget* parent, const QString& object_name) { + auto* button = new QPushButton(text, parent); + button->setObjectName(object_name); + button->setCursor(Qt::PointingHandCursor); + return button; +} + +void addButtonRow(QVBoxLayout* layout, QWidget* cancel, QWidget* confirm) { + auto* row = new QHBoxLayout; + row->addStretch(1); + row->addWidget(cancel); + row->addWidget(confirm); + layout->addLayout(row); +} + +void addFolder(QTreeWidgetItem* parent, const FolderNode& node, const QSet& allowed) { + auto* item = new QTreeWidgetItem(parent, QStringList{node.folder.name}); + item->setData(0, Qt::UserRole, node.folder.id); + if (!allowed.contains(node.folder.id)) { + item->setDisabled(true); + } + for (const FolderNode& child : node.children) { + addFolder(item, child, allowed); + } +} + +} // namespace + +FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, QWidget* parent, + const QString& verb) + : QDialog(parent) { + setWindowTitle(title); + setMinimumWidth(360); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Folder name"), this)); + edit_ = new QLineEdit(initial, this); + layout->addWidget(edit_); + + const QString primary_text = + verb.isEmpty() ? title.section(u' ', 0, 0, QString::SectionSkipEmpty) : verb; + auto* ok = makeButton(primary_text, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + connect(ok, &QPushButton::clicked, this, &QDialog::accept); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); + + auto validate = [ok](const QString& text) { ok->setEnabled(!text.trimmed().isEmpty()); }; + connect(edit_, &QLineEdit::textChanged, this, validate); + connect(edit_, &QLineEdit::returnPressed, ok, &QPushButton::click); + validate(initial); + edit_->selectAll(); +} + +QString FolderNameDialog::textValue() const { + return edit_->text().trimmed(); +} + +DeleteFolderDialog::DeleteFolderDialog(const QString& name, QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Delete Folder")); + setText(QStringLiteral("Delete ‘%1’ and its subfolders?").arg(name)); + setInformativeText( + QStringLiteral("Their mods become ungrouped. Load order is not affected.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + setEscapeButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Delete")); + yes->setObjectName(QStringLiteral("primaryAction")); + } +} + +FolderPickerDialog::FolderPickerDialog(const FolderNode& tree, const QString& title, + const QSet& allowed, QWidget* parent, + const QString& verb) + : QDialog(parent) { + setWindowTitle(title); + resize(380, 450); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Destination folder"), this)); + tree_ = new QTreeWidget(this); + tree_->setHeaderHidden(true); + tree_->setRootIsDecorated(true); + layout->addWidget(tree_); + + auto* top = new QTreeWidgetItem(tree_, QStringList{QStringLiteral("Ungrouped (top level)")}); + top->setData(0, Qt::UserRole, ROOT_ID); + if (!allowed.contains(ROOT_ID)) { + top->setDisabled(true); + } + for (const FolderNode& folder : tree.children) { + addFolder(top, folder, allowed); + } + tree_->expandAll(); + + auto* ok = makeButton(verb, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + ok->setEnabled(false); + auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + + auto accept_valid = [this, ok] { + if (ok->isEnabled()) { + accept(); + } + }; + connect(tree_, &QTreeWidget::currentItemChanged, this, [ok](QTreeWidgetItem* item) { + ok->setEnabled(item != nullptr && !item->isDisabled()); + }); + connect(tree_, &QTreeWidget::itemDoubleClicked, this, accept_valid); + connect(ok, &QPushButton::clicked, this, accept_valid); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); +} + +qint64 FolderPickerDialog::folder_id() const { + QTreeWidgetItem* item = tree_->currentItem(); + Q_ASSERT(item != nullptr); + return item->data(0, Qt::UserRole).toLongLong(); +} + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/dialogs.h b/src/ui/plugins/organizer/dialogs.h new file mode 100644 index 0000000..76218ce --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QLineEdit; +class QTreeWidget; + +namespace px::core::organizer { +struct FolderNode; +} + +namespace px::ui { + +// Asks for a folder name. Enter confirms; the confirm button is disabled while the +// name is blank. Without `verb`, the first word of `title` names the confirm button. +class FolderNameDialog : public QDialog { +public: + FolderNameDialog(const QString& title, const QString& initial, QWidget* parent, + const QString& verb = {}); + + // The entered name with surrounding whitespace removed. + QString textValue() const; + +private: + QLineEdit* edit_ = nullptr; +}; + +// Confirms deleting a folder. Its mods become ungrouped; load order is unchanged. +// Yes deletes; Cancel and Escape keep the folder. +class DeleteFolderDialog : public QMessageBox { +public: + DeleteFolderDialog(const QString& name, QWidget* parent); +}; + +// Picks a destination folder from `tree`'s children, with a top-level "Ungrouped" +// entry for ROOT_ID. Folders not in `allowed` are shown but cannot be chosen. +class FolderPickerDialog : public QDialog { +public: + FolderPickerDialog(const px::core::organizer::FolderNode& tree, const QString& title, + const QSet& allowed, QWidget* parent, + const QString& verb = QStringLiteral("Move")); + + // The id of the highlighted folder. Only meaningful once the confirm button is enabled. + qint64 folder_id() const; + +private: + QTreeWidget* tree_ = nullptr; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp new file mode 100644 index 0000000..f85af53 --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -0,0 +1,69 @@ +#include "ui/plugins/organizer/filter_model.h" + +namespace px::ui { + +OrganizerFilterModel::OrganizerFilterModel(QObject* parent) : QAbstractListModel(parent) {} + +int OrganizerFilterModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(items_.size()); +} + +QVariant OrganizerFilterModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; + const auto& entry = items_[index.row()]; + const bool tag = entry.tag_id.has_value() || entry.key.startsWith("tag:"); + switch (role) { + case Qt::DisplayRole: + case LabelRole: return entry.label; + case CountRole: return entry.count; + case KeyRole: return entry.key; + case SectionRole: + if (tag) return QStringLiteral("Tags"); + if (entry.provider_id || entry.key.startsWith("provider:")) return QStringLiteral("Providers"); + return QStringLiteral("Status"); + case IconRole: + if (tag) return QStringLiteral("tag"); + if (entry.key == "all") return QStringLiteral("grid"); + if (entry.key == "active") return QStringLiteral("check-circle"); + if (entry.key == "inactive") return QStringLiteral("ban"); + if (entry.provider_id == "steam" || entry.provider_id == "downloaded") return QStringLiteral("steam"); + if (entry.provider_id == "core") return QStringLiteral("grid"); + if (entry.provider_id == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); + default: return {}; + } +} + +QHash OrganizerFilterModel::roleNames() const { + return {{Qt::DisplayRole, "filterLabel"}, {CountRole, "count"}, + {IconRole, "iconName"}, {SectionRole, "sectionName"}, {KeyRole, "key"}}; +} + +void OrganizerFilterModel::update(const QList& entries) { + bool same_keys = entries.size() == items_.size(); + for (qsizetype i = 0; same_keys && i < entries.size(); ++i) + same_keys = entries[i].key == items_[i].key; + if (!same_keys) { + beginResetModel(); + items_ = entries; + endResetModel(); + } else { + items_ = entries; + if (!items_.isEmpty()) + emit dataChanged(index(0), index(static_cast(items_.size()) - 1), + {CountRole, LabelRole, Qt::DisplayRole}); + } +} + +std::optional OrganizerFilterModel::key_at(int row) const { + if (row < 0 || row >= items_.size()) return std::nullopt; + return items_[row].key; +} + +core::organizer::TreeFilter OrganizerFilterModel::for_key(const QString& key) const { + for (const auto& entry : items_) if (entry.key == key) return entry; + if (!items_.isEmpty()) return items_.first(); + return {QStringLiteral("all"), QStringLiteral("All"), 0}; +} + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/filter_model.h b/src/ui/plugins/organizer/filter_model.h new file mode 100644 index 0000000..54f9924 --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "core/organizer/resolve.h" + +namespace px::ui { + +class OrganizerFilterModel : public QAbstractListModel { + Q_OBJECT +public: + enum Role { LabelRole = Qt::UserRole + 1, CountRole, IconRole, SectionRole, KeyRole }; + Q_ENUM(Role) + explicit OrganizerFilterModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void update(const QList& entries); + std::optional key_at(int row) const; + core::organizer::TreeFilter for_key(const QString& key) const; + +private: + QList items_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/plugin.h b/src/ui/plugins/organizer/plugin.h new file mode 100644 index 0000000..9570efe --- /dev/null +++ b/src/ui/plugins/organizer/plugin.h @@ -0,0 +1,14 @@ +#pragma once + +#include "ui/context.h" +#include "ui/plugins/organizer/view.h" + +namespace px::ui { + +class OrganizerUiPlugin final : public UiPlugin { +public: + OrganizerUiPlugin() : UiPlugin(QStringLiteral("organizer_ui"), {QStringLiteral("organizer")}) {} + void setup(AppContext& ctx) override { ctx.addRailView(0); } +}; + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp new file mode 100644 index 0000000..19e3380 --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -0,0 +1,315 @@ +#include "ui/plugins/organizer/tree_model.h" + +#include + +#include "ui/models/mod_list_model.h" +#include "ui/theme/palette.h" + +namespace px::ui { +namespace { +using namespace core::organizer; + +QString provider_icon(const QString& id) { + if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); + if (id == "core") return QStringLiteral("grid"); + if (id == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); +} + +int check_state(CheckState state) { + switch (state) { + case CheckState::On: return Qt::Checked; + case CheckState::Partial: return Qt::PartiallyChecked; + case CheckState::Off: return Qt::Unchecked; + } + return Qt::Unchecked; +} + +std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, bool filtering) { + auto top = std::make_unique(); + top->kind = QStringLiteral("root"); + auto add_mods = [&](TreeNode* parent, const QList& leaves) { + for (const auto& leaf : leaves) { + auto item = std::make_unique(); + item->kind = QStringLiteral("mod"); + item->key = "m:" + leaf.uuid; + item->leaf = &leaf; + const auto it = mods.constFind(leaf.uuid); + if (it != mods.cend() && *it) { + const auto& mod = **it; + item->provider_id = mod.provider_id; + item->provider_label = provider_label(mod.provider_id, mod_package_id(mod).value_or(QString{})); + if (const auto* about = dynamic_cast(&mod)) + item->mod_version = about->mod_version; + } + parent->add(std::move(item)); + } + }; + auto add_folder = [&](auto&& self, TreeNode* parent, const FolderNode& folder) -> void { + auto item = std::make_unique(); + item->kind = QStringLiteral("folder"); + item->key = "f:" + QString::number(folder.folder.id); + item->folder = &folder; + TreeNode* ptr = item.get(); + parent->add(std::move(item)); + for (const auto& child : folder.children) self(self, ptr, child); + add_mods(ptr, folder.mods); + }; + for (const auto& child : root.children) add_folder(add_folder, top.get(), child); + if (!root.mods.isEmpty() || !filtering) { + auto item = std::make_unique(); + item->kind = QStringLiteral("ungrouped"); + item->key = UNGROUPED_KEY; + item->folder = &root; + add_mods(item.get(), root.mods); + top->add(std::move(item)); + } + return top; +} + +bool same_shape(const TreeNode& a, const TreeNode& b) { + if (a.key != b.key || a.children.size() != b.children.size()) return false; + for (size_t i = 0; i < a.children.size(); ++i) + if (!same_shape(*a.children[i], *b.children[i])) return false; + return true; +} + +void copy_payload(TreeNode& dst, TreeNode& src) { + dst.folder = src.folder; + dst.leaf = src.leaf; + dst.provider_id = std::move(src.provider_id); + dst.provider_label = std::move(src.provider_label); + dst.mod_version = std::move(src.mod_version); + for (size_t i = 0; i < dst.children.size(); ++i) + copy_payload(*dst.children[i], *src.children[i]); +} + +const QList diagnostic_roles{ModTreeModel::HasErrorRole, ModTreeModel::ErrorTooltipRole, + ModTreeModel::HasWarningRole, ModTreeModel::WarningTooltipRole}; +} // namespace + +void TreeNode::add(std::unique_ptr child) { + child->parent = this; + child->row = static_cast(children.size()); + children.push_back(std::move(child)); +} + +QList TreeNode::walk() const { + QList result; + auto visit = [&](auto&& self, const TreeNode& current) -> void { + for (const auto& child : current.children) { + result.append(child.get()); + self(self, *child); + } + }; + visit(visit, *this); + return result; +} +TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, + const core::organizer::FolderNode* folder, const core::organizer::ModLeaf* leaf, + QString provider_id) + : kind(std::move(kind)), key(std::move(key)), parent(parent), folder(folder), leaf(leaf), + provider_id(std::move(provider_id)) {} + + +qint64 TreeNode::folder_id() const { + if (kind == "folder" && folder) return folder->folder.id; + if (kind == "mod" && parent) return parent->folder_id(); + return core::organizer::ROOT_ID; +} + +bool TreeNode::is_container() const { return kind == "folder" || kind == "ungrouped"; } + +ModTreeModel::ModTreeModel(SummaryFor summary_for, QObject* parent) + : QAbstractItemModel(parent), summary_for_(std::move(summary_for)), root_(std::make_unique()) { + root_->kind = QStringLiteral("root"); +} + +TreeNode* ModTreeModel::node(const QModelIndex& index) const { + return index.isValid() ? static_cast(index.internalPointer()) : root_.get(); +} + +QModelIndex ModTreeModel::index(int row, int column, const QModelIndex& parent) const { + const auto* current = node(parent); + if (column != 0 || row < 0 || static_cast(row) >= current->children.size()) return {}; + return createIndex(row, 0, current->children[row].get()); +} + +QModelIndex ModTreeModel::parent(const QModelIndex& child) const { + if (!child.isValid()) return {}; + const auto* up = node(child)->parent; + return !up || up == root_.get() ? QModelIndex{} : index_of(up); +} + +int ModTreeModel::rowCount(const QModelIndex& parent) const { + return parent.column() > 0 ? 0 : static_cast(node(parent)->children.size()); +} +int ModTreeModel::columnCount(const QModelIndex&) const { return 1; } + +QVariant ModTreeModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) return {}; + const auto* item = node(index); + if (role == Qt::DisplayRole) { + if (item->kind == "ungrouped") return QStringLiteral("Ungrouped"); + if (item->kind == "folder" && item->folder) return item->folder->folder.name; + return item->leaf ? item->leaf->name : QString{}; + } + if (role == KindRole) return item->kind; + if (role == KeyRole) return item->key; + if (role == SelectedRole) return selected_.contains(item->key); + if (role == FilteringRole) return filtering_; + if (item->kind != "mod") { + if (!item->folder) return {}; + const auto& folder = *item->folder; + const bool own = item->kind == "ungrouped"; + switch (role) { + case CheckStateRole: return check_state(own ? folder.own_check() : folder.check); + case EnabledCountRole: return own ? folder.own_enabled : folder.enabled; + case TotalCountRole: return own ? folder.own_total : folder.total; + case VisibleCountRole: return own ? static_cast(folder.mods.size()) : folder.visible_total; + case HasRuleRole: return folder.has_rules && !own; + default: return {}; + } + } + if (!item->leaf) return {}; + const auto& leaf = *item->leaf; + switch (role) { + case UuidRole: return leaf.uuid; + case PackageIdRole: return leaf.package_id.value_or(QString{}); + case CheckStateRole: return leaf.enabled ? Qt::Checked : Qt::Unchecked; + case ProviderLabelRole: return item->provider_label; + case ModVersionRole: return item->mod_version; + case ProviderIconRole: return provider_icon(item->provider_id); + case ProviderColorRole: { + const auto it = provider_colors_.constFind(item->provider_id); + return it != provider_colors_.cend() ? *it : paletteValue(u"TEXT_DIM"); + } + case CanPlaceRole: return leaf.package_id.has_value(); + case TagsRole: return tag_rows_.value(leaf.package_id.value_or(QString{})); + default: break; + } + if (diagnostic_roles.contains(role)) { + const auto summary = leaf.enabled ? summary_for_(leaf.uuid) : std::nullopt; + if (role == HasErrorRole) return summary && summary->has_errors; + if (role == HasWarningRole) return summary && summary->has_warnings; + if (role == ErrorTooltipRole) return summary ? summary->error_tooltip : QString{}; + return summary ? summary->warning_tooltip : QString{}; + } + return {}; +} + +QHash ModTreeModel::roleNames() const { + return {{Qt::DisplayRole, "name"}, {KindRole, "kind"}, {KeyRole, "key"}, + {UuidRole, "uuid"}, {PackageIdRole, "packageId"}, {CheckStateRole, "checkState"}, + {EnabledCountRole, "enabledCount"}, {TotalCountRole, "totalCount"}, + {VisibleCountRole, "visibleCount"}, {FilteringRole, "filtering"}, {HasRuleRole, "hasRule"}, + {ProviderIconRole, "providerIcon"}, {ProviderLabelRole, "providerLabel"}, + {ModVersionRole, "modVersion"}, {ProviderColorRole, "providerColor"}, + {HasErrorRole, "hasError"}, {ErrorTooltipRole, "errorTooltip"}, + {HasWarningRole, "hasWarning"}, {WarningTooltipRole, "warningTooltip"}, + {CanPlaceRole, "canPlace"}, {SelectedRole, "selected"}, {TagsRole, "tags"}}; +} + +bool ModTreeModel::filtering() const { return filtering_; } + +void ModTreeModel::set_tree(const core::organizer::FolderNode& root, + const core::organizer::ModsByUuid& mods, const QHash& provider_colors, + bool filtering, const QHash& tags) { + auto payload = std::make_unique(root); + auto fresh = build(*payload, mods, filtering); + provider_colors_ = provider_colors; + tag_rows_.clear(); + for (const auto* item : fresh->walk()) { + if (!item->leaf || !item->leaf->package_id || item->leaf->package_id->isEmpty() || item->leaf->tag_ids.isEmpty()) continue; + QList ids(item->leaf->tag_ids.begin(), item->leaf->tag_ids.end()); + std::sort(ids.begin(), ids.end()); + QVariantList rows; + for (qint64 id : ids) { + const auto tag = tags.constFind(id); + if (tag != tags.cend()) rows.append(QVariantMap{{"name", tag->name}, {"color", tag->color}}); + } + tag_rows_.insert(*item->leaf->package_id, rows); + } + if (filtering_ == filtering && same_shape(*root_, *fresh)) { + copy_payload(*root_, *fresh); + payload_ = std::move(payload); + emit_all_changed(root_.get()); + } else { + beginResetModel(); + root_ = std::move(fresh); + payload_ = std::move(payload); + filtering_ = filtering; + endResetModel(); + } + QSet live; + for (const auto* item : root_->walk()) live.insert(item->key); + selected_.intersect(live); +} + +QModelIndex ModTreeModel::index_of(const TreeNode* item) const { + return item == root_.get() ? QModelIndex{} : createIndex(item->row, 0, const_cast(item)); +} + +void ModTreeModel::emit_all_changed(TreeNode* item, const QList& roles) { + if (item->children.empty()) return; + const auto parent = index_of(item); + emit dataChanged(index(0, 0, parent), index(static_cast(item->children.size()) - 1, 0, parent), roles); + for (const auto& child : item->children) emit_all_changed(child.get(), roles); +} + +void ModTreeModel::refresh_diagnostics() { emit_all_changed(root_.get(), diagnostic_roles); } +TreeNode* ModTreeModel::node_at(const QModelIndex& index) const { return index.isValid() ? node(index) : nullptr; } +QModelIndex ModTreeModel::index_for_key(const QString& key) const { + for (const auto* item : root_->walk()) if (item->key == key) return index_of(item); + return {}; +} +QModelIndex ModTreeModel::index_for_uuid(const QString& uuid) const { + for (const auto* item : root_->walk()) + if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) return index_of(item); + return {}; +} +QList ModTreeModel::expanded_indexes(const QModelIndex& parent) const { + QList result; + auto visit = [&](auto&& self, const TreeNode* item) -> void { + for (const auto& child : item->children) { + if (child->is_container() && (filtering_ || (child->folder && !child->folder->folder.collapsed))) { + result.append(index_of(child.get())); + self(self, child.get()); + } + } + }; + visit(visit, node(parent)); + return result; +} +QList ModTreeModel::selected_nodes() const { + QList result; + for (auto* item : root_->walk()) if (selected_.contains(item->key)) result.append(item); + return result; +} +bool ModTreeModel::is_selected(const QModelIndex& index) const { + const auto* item = node_at(index); + return item && selected_.contains(item->key); +} +void ModTreeModel::select(const QList& indexes, const QString& mode) { + QSet keys; + for (const auto& index : indexes) if (const auto* item = node_at(index)) keys.insert(item->key); + const auto before = selected_; + if (mode == "replace") selected_ = keys; + else if (mode == "toggle") selected_ = (selected_ - keys) | (keys - selected_); + else selected_.unite(keys); + notify_selection((before - selected_) | (selected_ - before)); +} +void ModTreeModel::clear_selection() { + const auto before = selected_; + selected_.clear(); + notify_selection(before); +} +void ModTreeModel::notify_selection(const QSet& changed) { + if (changed.isEmpty()) return; + for (const auto* item : root_->walk()) if (changed.contains(item->key)) { + const auto index = index_of(item); + emit dataChanged(index, index, {SelectedRole}); + } +} + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/tree_model.h b/src/ui/plugins/organizer/tree_model.h new file mode 100644 index 0000000..8663733 --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "core/models/view/diagnostics.h" +#include "core/organizer/resolve.h" + +namespace px::ui { + +inline const QString UNGROUPED_KEY = QStringLiteral("ungrouped"); + +struct TreeNode { + QString kind; + QString key; + TreeNode* parent = nullptr; + const core::organizer::FolderNode* folder = nullptr; + const core::organizer::ModLeaf* leaf = nullptr; + QString provider_id; + QString provider_label; + QString mod_version; + int row = 0; + std::vector> children; + + void add(std::unique_ptr child); + QList walk() const; + qint64 folder_id() const; + TreeNode(QString kind = QStringLiteral("root"), QString key = {}, TreeNode* parent = nullptr, + const core::organizer::FolderNode* folder = nullptr, + const core::organizer::ModLeaf* leaf = nullptr, QString provider_id = {}); + bool is_container() const; +}; + +class ModTreeModel : public QAbstractItemModel { + Q_OBJECT +public: + enum Role { + KindRole = Qt::UserRole + 1, KeyRole, UuidRole, PackageIdRole, + CheckStateRole, EnabledCountRole, TotalCountRole, VisibleCountRole, + FilteringRole, HasRuleRole, ProviderIconRole, ProviderColorRole, + HasErrorRole, ErrorTooltipRole, CanPlaceRole, SelectedRole, + HasWarningRole, WarningTooltipRole, TagsRole, ProviderLabelRole, ModVersionRole + }; + Q_ENUM(Role) + using SummaryFor = std::function(const QString&)>; + explicit ModTreeModel(SummaryFor summary_for, QObject* parent = nullptr); + QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + bool filtering() const; + void set_tree(const core::organizer::FolderNode& root, + const core::organizer::ModsByUuid& mods, + const QHash& provider_colors, bool filtering, + const QHash& tags = {}); + void refresh_diagnostics(); + TreeNode* node_at(const QModelIndex& index) const; + QModelIndex index_for_key(const QString& key) const; + QModelIndex index_for_uuid(const QString& uuid) const; + QList expanded_indexes(const QModelIndex& parent = {}) const; + QList selected_nodes() const; + bool is_selected(const QModelIndex& index) const; + void select(const QList& indexes, const QString& mode = QStringLiteral("replace")); + void clear_selection(); + +private: + TreeNode* node(const QModelIndex& index) const; + QModelIndex index_of(const TreeNode* node) const; + void emit_all_changed(TreeNode* node, const QList& roles = {}); + void notify_selection(const QSet& changed); + SummaryFor summary_for_; + std::unique_ptr root_; + std::unique_ptr payload_; + bool filtering_ = false; + QHash provider_colors_; + QHash tag_rows_; + QSet selected_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp new file mode 100644 index 0000000..e47d812 --- /dev/null +++ b/src/ui/plugins/organizer/view.cpp @@ -0,0 +1,586 @@ +#include "ui/plugins/organizer/view.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "core/downloads/manager.h" +#include "core/mod_service.h" +#include "core/services/diagnostics_service.h" +#include "ui/components/dialog_chrome.h" +#include "ui/components/dialogs.h" +#include "ui/components/filter_sidebar.h" +#include "ui/components/mod_activation.h" +#include "ui/components/mod_updates.h" +#include "ui/mod_selection.h" +#include "ui/panels/mod_info_panel.h" +#include "ui/plugins/organizer/dialogs.h" +#include "ui/plugins/organizer/filter_model.h" +#include "ui/plugins/organizer/tree_model.h" +#include "ui/theme/constants.h" +#include "ui/theme/palette.h" + +namespace px::ui { +using namespace core::organizer; +namespace { +QString plural(qsizetype count, const QString& word) { + return QStringLiteral("%1 %2%3").arg(count).arg(word, count == 1 ? QString{} : QStringLiteral("s")); +} +OrganizerService& organizer(core::CoreContext& ctx) { + auto* service = dynamic_cast(ctx.plugins().get(QStringLiteral("organizer"))); + Q_ASSERT(service); + return *service; +} +QPushButton* button(const QString& text, QWidget* parent, QHBoxLayout* layout) { + auto* result = new QPushButton(text, parent); + result->setObjectName(QStringLiteral("appBtn")); + result->setCursor(Qt::PointingHandCursor); + layout->addWidget(result); + return result; +} +} + +OrganizerViewPanel::OrganizerViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), service_(organizer(ctx)), + model_(new ModTreeModel([&ctx](const QString& uuid) { return ctx.diagnosticsService().summary_for(uuid); }, this)), + filters_(new OrganizerFilterModel(this)) { + auto* content = new QWidget(this); + content->setObjectName(QStringLiteral("contentArea")); + auto* row = new QHBoxLayout(content); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(0); + auto* sidebar = new FilterSidebar(engine, filters_, content); + sidebar->setFixedWidth(SIDEBAR_WIDTH); + row->addWidget(sidebar); + connect(sidebar, &FilterSidebar::activated, this, [this](int index) { + const auto key = filters_->key_at(index); + if (key && *key != filter_key_) { filter_key_ = *key; invalidate(); } + }); + auto* main = new QWidget(content); + main->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + auto* layout = new QVBoxLayout(main); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + auto* toolbar = new QWidget(main); + toolbar->setObjectName(QStringLiteral("searchBox")); + toolbar->setFixedHeight(56); + auto* tools = new QHBoxLayout(toolbar); + tools->setContentsMargins(16, 8, 16, 8); + search_ = new QLineEdit(toolbar); + search_->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); + search_->setClearButtonEnabled(true); + tools->addWidget(search_, 1); + auto* create = button(QStringLiteral("New folder"), toolbar, tools); + create->setObjectName(QStringLiteral("primaryAction")); + connect(create, &QPushButton::clicked, this, &OrganizerViewPanel::newFolder); + auto* rules = button(QStringLiteral("Auto-folder rules"), toolbar, tools); + rules->setObjectName(QStringLiteral("organizerRulesButton")); + connect(rules, &QPushButton::clicked, this, &OrganizerViewPanel::openRules); + connect(button(QStringLiteral("Expand all"), toolbar, tools), &QPushButton::clicked, this, [this] { treeCommand("expandAll"); }); + connect(button(QStringLiteral("Collapse all"), toolbar, tools), &QPushButton::clicked, this, [this] { treeCommand("collapseAll"); }); + layout->addWidget(toolbar); + + qml_ = new QQuickWidget(engine, main); + qml_->setObjectName(QStringLiteral("organizerTree")); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setClearColor(paletteColor(u"ELEVATE_1")); + qml_->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); + qml_->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), model_); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); + rules_dialog_ = new QDialog(this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); + rules_dialog_->setObjectName(QStringLiteral("organizerRuleEditorWindow")); + rules_dialog_->setWindowTitle(QStringLiteral("Auto-Folder Rules")); + rules_dialog_->setWindowModality(Qt::WindowModal); + rules_dialog_->setMinimumSize(720, 520); + rules_dialog_->setSizeGripEnabled(true); + auto* rules_layout = new QVBoxLayout(rules_dialog_); + rules_layout->setContentsMargins(0, 0, 0, 0); + rules_qml_ = new QQuickWidget(engine, rules_dialog_); + rules_qml_->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); + rules_qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + rules_qml_->setClearColor(paletteColor(u"ELEVATE_2")); + rules_qml_->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); + rules_qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); + rules_layout->addWidget(rules_qml_); + installDialogChrome(rules_dialog_); + connect(rules_dialog_, &QDialog::finished, this, [this] { + if (auto* root = rules_qml_->rootObject()) QMetaObject::invokeMethod(root, "closeEditor"); + }); + auto* hint = new QWidget(main); + hint->setObjectName(QStringLiteral("organizerHint")); + auto* hints = new QHBoxLayout(hint); + hints->setContentsMargins(12, 5, 12, 5); + auto* hint_text = new QLabel(QStringLiteral("Folders and tags are for browsing only. Edit load order in Mods; checkboxes enable or disable the same mods."), hint); + hint_text->setWordWrap(true); + hints->addWidget(hint_text, 1); + auto* dismiss = button(QStringLiteral("×"), hint, hints); + dismiss->setToolTip(QStringLiteral("Dismiss")); + connect(dismiss, &QPushButton::clicked, hint, &QWidget::hide); + layout->addWidget(hint); + layout->addWidget(qml_, 1); + selection_bar_ = new QWidget(main); + auto* bar = new QHBoxLayout(selection_bar_); + bar->setContentsMargins(12, 6, 12, 6); + selection_label_ = new QLabel(selection_bar_); + bar->addWidget(selection_label_); + const QList> actions{{QStringLiteral("Enable"), QStringLiteral("enable")}, {QStringLiteral("Disable"), QStringLiteral("disable")}, {QStringLiteral("Move to folder…"), QStringLiteral("move")}, {QStringLiteral("Manage tags…"), QStringLiteral("tags")}, {QStringLiteral("Create folder from selection"), QStringLiteral("create")}, {QStringLiteral("Update"), QStringLiteral("update")}}; + for (const auto& [label, action] : actions) { + auto* item = button(label, selection_bar_, bar); + if (action == QStringLiteral("update")) update_button_ = item; + connect(item, &QPushButton::clicked, this, [this, action] { selectionAction(action); }); + } + selection_bar_->hide(); + layout->addWidget(selection_bar_); + row->addWidget(main, 3); + info_ = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + info_->setObjectName(QStringLiteral("modInfoPanel")); + info_->setMinimumWidth(300); + row->addWidget(info_, 2); + selection_ = std::make_unique(ctx, *info_); + connect(info_, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { + const auto index = model_->index_for_uuid(uuid); + if (index.isValid()) selectSingle(index); + }); + root_->addWidget(content, 1); + status_ = new QLabel(this); + status_->setObjectName(QStringLiteral("organizerStatus")); + root_->addWidget(status_); + menu_ = new QMenu(this); + menu_->setToolTipsVisible(true); + search_timer_ = new QTimer(this); + search_timer_->setSingleShot(true); + search_timer_->setInterval(220); + connect(search_, &QLineEdit::textChanged, this, [this] { search_timer_->start(); }); + connect(search_timer_, &QTimer::timeout, this, [this] { + if (search_text_ != search_->text()) { search_text_ = search_->text(); invalidate(); } + }); + connect(model_, &QAbstractItemModel::modelReset, this, [this] { selection_anchor_ = QPersistentModelIndex{}; }); + changed_id_ = service_.changed.connect([this](const auto&) { invalidate(); Q_EMIT editorChanged(); }); + active_id_ = ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); + mods_id_ = ctx.modService().mods_changed.connect([this](const auto&) { invalidate(); }); + diagnostics_id_ = ctx.diagnosticsService().diagnostics_summary_changed.connect([this](const auto&) { + model_->refresh_diagnostics(); + if (!selected_uuid_.isEmpty()) info_->setIssues(ctx_.diagnosticsService().issues_for(selected_uuid_)); + }); + busy_id_ = ctx.downloads().busy_changed.connect([this](bool) { syncInfo(); }); +} + +OrganizerViewPanel::~OrganizerViewPanel() { + service_.changed.disconnect(changed_id_); + ctx_.activeStateChanged().disconnect(active_id_); + ctx_.modService().mods_changed.disconnect(mods_id_); + ctx_.diagnosticsService().diagnostics_summary_changed.disconnect(diagnostics_id_); + ctx_.downloads().busy_changed.disconnect(busy_id_); +} +bool OrganizerViewPanel::ready() const { return service_.ready(); } +void OrganizerViewPanel::showEvent(QShowEvent* event) { + BaseViewPanel::showEvent(event); + if (dirty_) rebuild(); + Q_EMIT readyChanged(); + if (auto* root = rules_qml_->rootObject()) QMetaObject::invokeMethod(root, "schedulePreview"); +} +void OrganizerViewPanel::invalidate() { dirty_ = true; if (isVisible()) rebuild(); } +void OrganizerViewPanel::rebuild() { + if (!ready()) return; + dirty_ = false; + filters_->update(service_.tree_filters()); + const auto query = filters_->for_key(filter_key_).query(search_text_); + const auto tree = service_.tree(query); + model_->set_tree(tree, ctx_.allMods(), ctx_.modService().provider_colors(), !query.is_empty(), service_.state().tags); + const auto ungrouped = query.is_empty() ? tree.mods.size() : service_.tree().mods.size(); + status_->setText(QStringLiteral("%1 · %2 active · %3 · %4 ungrouped").arg(plural(ctx_.allMods().size(), QStringLiteral("mod"))).arg(ctx_.activeUuids().size()).arg(plural(service_.state().folders.size() - 1, QStringLiteral("folder"))).arg(ungrouped)); + syncInfo(true); + Q_EMIT readyChanged(); +} +QStringList OrganizerViewPanel::selectedPackageIds() const { + QStringList result; + for (const auto* node : model_->selected_nodes()) if (node->leaf && node->leaf->package_id && !result.contains(*node->leaf->package_id)) result.append(*node->leaf->package_id); + return result; +} +QStringList OrganizerViewPanel::selectedUuids() const { + QStringList result; + for (const auto* node : model_->selected_nodes()) if (node->leaf) result.append(node->leaf->uuid); + return result; +} +void OrganizerViewPanel::syncInfo(bool force) { + const auto nodes = model_->selected_nodes(); + const auto uuids = selectedUuids(); + Q_EMIT editorChanged(); + selection_label_->setText(plural(uuids.size(), QStringLiteral("mod")) + QStringLiteral(" selected")); + selection_bar_->setVisible(uuids.size() > 1); + const auto state = update_state(ctx_, uuids); + update_button_->setVisible(ctx_.downloads().available()); + update_button_->setText(state.label); + update_button_->setEnabled(state.enabled); + update_button_->setToolTip(state.tooltip); + const QString uuid = nodes.size() == 1 && nodes.front()->leaf ? nodes.front()->leaf->uuid : QString{}; + if (uuid == selected_uuid_ && !force) return; + selected_uuid_ = uuid; + if (uuid.isEmpty()) selection_->clear(); else selection_->show(uuid); +} +int OrganizerViewPanel::selectedTagCount() const { return selectedPackageIds().size(); } +QVariantList OrganizerViewPanel::tagRows() const { + QVariantList result; + if (!ready()) return result; + auto tags = service_.state().tags.values(); + std::ranges::sort(tags, [](const Tag& a, const Tag& b) { return a.name.toCaseFolded() < b.name.toCaseFolded(); }); + const auto pids = selectedPackageIds(); + for (const auto& tag : tags) { + int matched = 0; + for (const auto& pid : pids) if (service_.state().mod_tags.value(pid).contains(tag.id)) ++matched; + result.append(QVariantMap{{QStringLiteral("id"), tag.id}, {QStringLiteral("name"), tag.name}, {QStringLiteral("color"), tag.color}, {QStringLiteral("checkState"), matched > 0 && matched < pids.size() ? 2 : int(matched > 0)}}); + } + return result; +} +QVariantList OrganizerViewPanel::ruleRows() const { + QVariantList result; + if (!ready()) return result; + for (const auto& rule : service_.state().rules) result.append(QVariantMap{{QStringLiteral("field"), QString(rule_field_name(rule.field))}, {QStringLiteral("op"), QString(rule_op_name(rule.op))}, {QStringLiteral("pattern"), rule.pattern}, {QStringLiteral("folder_id"), rule.folder_id}}); + return result; +} +QVariantList OrganizerViewPanel::ruleFolders() const { + QVariantList result; + if (!ready()) return result; + const auto& folders = service_.state().folders; + QList> paths; + paths.reserve(folders.size() - 1); + for (const auto& folder : folders) { + if (folder.id == ROOT_ID) continue; + QStringList names; + qint64 id = folder.id; + while (id != ROOT_ID) { + const auto& ancestor = *folders.constFind(id); + names.prepend(ancestor.name); + id = *ancestor.parent_id; + } + paths.append({names.join(QStringLiteral(" / ")), folder.id}); + } + std::ranges::sort(paths, [](const auto& a, const auto& b) { + return QString::compare(a.first, b.first, Qt::CaseInsensitive) < 0; + }); + result.reserve(paths.size()); + for (const auto& [name, id] : paths) + result.append(QVariantMap{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); + return result; +} +core::Result> OrganizerViewPanel::ruleSpecs(const QVariantList& rows) const { + QList specs; + specs.reserve(rows.size()); + for (const auto& value : rows) { + const auto row = value.toMap(); + const auto field = parse_rule_field(row.value(QStringLiteral("field")).toString()); + const auto op = parse_rule_op(row.value(QStringLiteral("op")).toString()); + if (!field || !op) return std::unexpected(core::Error{QStringLiteral("Invalid rule field or operator")}); + specs.append(RuleSpec{*field, *op, row.value(QStringLiteral("pattern")).toString(), row.value(QStringLiteral("folder_id")).toLongLong()}); + } + return specs; +} +QCoro::QmlTask OrganizerViewPanel::requestRulePreview(const QVariantList& rows, int revision) { return calculateRulePreview(rows, revision); } +QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, int revision) { + if (!ready()) co_return; + const auto specs = ruleSpecs(rows); + if (!specs) co_return; + const QPointer alive(this); + auto future = QtConcurrent::run([state = service_.state(), mods = ctx_.allMods(), specs = *specs] { return preview_rule_matches(state, mods, specs); }); + const auto preview = co_await future; + if (!alive) co_return; + QVariantList counts; + counts.reserve(preview.counts.size()); + for (int count : preview.counts) counts.append(count); + QVariantMap result; + result.insert(QStringLiteral("counts"), counts); + result.insert(QStringLiteral("assignable"), preview.assignable); + Q_EMIT rulePreviewReady(revision, result); +} +void OrganizerViewPanel::setEditorError(QString message) { + if (message != editor_error_) { editor_error_ = std::move(message); Q_EMIT editorChanged(); } +} +void OrganizerViewPanel::reportError(const core::Error& error) { + if (auto* toasts = app_.toasts()) toasts->error(error.message); + else spdlog::warn("organizer: {}", error.message.toStdString()); +} +void OrganizerViewPanel::treeCommand(const char* method) { if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, method); } +void OrganizerViewPanel::openTags() { setEditorError(); treeCommand("openTags"); } +void OrganizerViewPanel::openRules() { + setEditorError(); + auto* root = rules_qml_->rootObject(); + if (!root) return; + auto* host = window(); + if (!rules_opened_once_) { + const auto available = host->screen()->availableGeometry(); + rules_dialog_->resize(std::min({960, std::max(800, host->width() - 48), available.width() - 32}), std::min({760, std::max(600, host->height() - 80), available.height() - 32})); + rules_opened_once_ = true; + } + rules_dialog_->move(host->frameGeometry().center() - rules_dialog_->rect().center()); + rules_dialog_->open(); + rules_dialog_->activateWindow(); + rules_qml_->setFocus(Qt::OtherFocusReason); + QMetaObject::invokeMethod(root, "prepareForOpen"); +} +void OrganizerViewPanel::closeRules() { rules_dialog_->close(); } +QCoro::QmlTask OrganizerViewPanel::createTag(QString name, QString color) { return mutateTag(QStringLiteral("create"), 0, std::move(name), std::move(color)); } +QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, QString color) { return mutateTag(QStringLiteral("update"), id, std::move(name), std::move(color)); } +QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { return mutateTag(QStringLiteral("delete"), id); } +QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { return mutateTag(QStringLiteral("assign"), id, {}, {}, add); } +QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, QString name, QString color, bool add) { + const QPointer alive(this); + if (operation == QStringLiteral("create")) { + const auto result = co_await service_.create_tag(std::move(name), std::move(color)); + if (!alive) co_return; + if (!result) { setEditorError(result.error().message); co_return; } + Q_EMIT tagCreated(); + } else { + core::Result result; + if (operation == QStringLiteral("update")) result = co_await service_.update_tag(id, std::move(name), std::move(color)); + else if (operation == QStringLiteral("delete")) result = co_await service_.delete_tag(id); + else result = co_await service_.set_mod_tags(selectedPackageIds(), add ? QSet{id} : QSet{}, add ? QSet{} : QSet{id}); + if (!alive) co_return; + if (!result) { setEditorError(result.error().message); co_return; } + if (operation == QStringLiteral("update")) Q_EMIT tagUpdated(id); + } + setEditorError(); +} +QCoro::QmlTask OrganizerViewPanel::saveRules(QVariantList rows) { return persistRules(std::move(rows)); } +QCoro::Task OrganizerViewPanel::persistRules(QVariantList rows) { + const QPointer alive(this); + auto specs = ruleSpecs(rows); + if (!specs) { setEditorError(specs.error().message); co_return; } + const auto result = co_await service_.set_rules(std::move(*specs)); + if (!alive) co_return; + if (!result) { setEditorError(result.error().message); co_return; } + setEditorError(); + Q_EMIT rulesSaved(); +} +QCoro::QmlTask OrganizerViewPanel::addStandardRules() { return standardRules(); } +QCoro::Task OrganizerViewPanel::standardRules() { + const QPointer alive(this); + const auto result = co_await service_.add_standard_rules(); + if (!alive) co_return; + setEditorError(result ? QString{} : result.error().message); +} +QVariantList OrganizerViewPanel::expandedIndexes() const { + QVariantList result; + for (const auto& index : model_->expanded_indexes()) result.append(QVariant::fromValue(index)); + return result; +} +void OrganizerViewPanel::selectRow(const QModelIndex& index, int modifiers, const QVariantList& range) { + if ((modifiers & Qt::ShiftModifier) && selection_anchor_.isValid()) { + QList indexes; + for (const auto& value : range) indexes.append(value.value()); + model_->select(indexes); + } else { + model_->select({index}, modifiers & Qt::ControlModifier ? QStringLiteral("toggle") : QStringLiteral("replace")); + selection_anchor_ = index; + } + syncInfo(); +} +void OrganizerViewPanel::selectSingle(const QModelIndex& index) { + if (!model_->is_selected(index)) model_->select({index}); + selection_anchor_ = index; + syncInfo(); +} +QCoro::QmlTask OrganizerViewPanel::persistAllCollapsed(bool collapsed) { return persistAllCollapse(collapsed); } +QCoro::Task OrganizerViewPanel::persistAllCollapse(bool collapsed) { + const QPointer alive(this); + const auto result = co_await service_.set_all_collapsed(collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::persistCollapsed(const QModelIndex& index, bool collapsed) { return persistCollapse(index, collapsed); } +QCoro::Task OrganizerViewPanel::persistCollapse(QModelIndex index, bool collapsed) { + const auto* node = model_->node_at(index); + if (!node || !node->is_container() || model_->filtering()) co_return; + const QPointer alive(this); + const auto result = co_await service_.set_collapsed(node->folder_id(), collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { return toggle(index); } +QCoro::Task OrganizerViewPanel::toggle(QModelIndex index) { + const auto* node = model_->node_at(index); + if (!node) co_return; + if (node->leaf) co_await toggle_mods(ctx_, this, {node->leaf->uuid}); + else if (node->is_container()) { + const auto change = service_.folder_toggle(node->folder_id(), node->kind != QStringLiteral("ungrouped")); + if (!change) { reportError(change.error()); co_return; } + if (!change->enable.isEmpty() || !change->disable.isEmpty()) co_await apply_activation(ctx_, this, change->enable, change->disable); + } +} +QCoro::QmlTask OrganizerViewPanel::selectionAction(const QString& action) { return runSelectionAction(action); } +QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { + const auto uuids = selectedUuids(); + if (uuids.isEmpty()) co_return; + if (action == QStringLiteral("tags")) openTags(); + else if (action == QStringLiteral("move") || action == QStringLiteral("create")) { + const auto pids = selectedPackageIds(); + if (!pids.isEmpty()) co_await editFolder(action, ROOT_ID, pids); + } else if (action == QStringLiteral("update")) co_await update_mods(ctx_, uuids, this); + else if (action == QStringLiteral("enable")) co_await apply_activation(ctx_, this, uuids); + else if (action == QStringLiteral("disable")) co_await apply_activation(ctx_, this, {}, uuids); +} +QCoro::QmlTask OrganizerViewPanel::newFolder() { return editFolder(QStringLiteral("new")); } +QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStringList pids) { + const QPointer alive(this); + core::Result result; + if (action == QStringLiteral("new") || action == QStringLiteral("create") || action == QStringLiteral("rename")) { + const bool rename = action == QStringLiteral("rename"); + const QString initial = rename ? service_.state().folders.value(id).name : QString{}; + QString title; + if (rename) title = QStringLiteral("Rename Folder"); + else if (action == QStringLiteral("create")) title = QStringLiteral("Create Folder from Selection"); + else title = QStringLiteral("New Folder"); + const auto nameResult = co_await awaitDialog(title, initial, this, rename ? QStringLiteral("Rename") : QStringLiteral("Create")); + const int code = nameResult.first; + auto* const dialog = nameResult.second; + if (!alive) co_return; + if (code != QDialog::Accepted) co_return; + const auto name = dialog->textValue(); + if (rename) result = co_await service_.rename_folder(id, name); + else { + core::Result created; + if (action == QStringLiteral("create")) created = co_await service_.create_folder_from(name, pids); + else created = co_await service_.create_folder(name, id); + if (!alive) co_return; + if (!created) reportError(created.error()); + co_return; + } + } else if (action == QStringLiteral("move") || action == QStringLiteral("moveFolder")) { + QSet allowed; + if (action == QStringLiteral("moveFolder")) { + const auto targets = service_.folder_move_targets(id); + if (!targets) { reportError(targets.error()); co_return; } + allowed = *targets; + } else { + for (auto it = service_.state().folders.cbegin(); it != service_.state().folders.cend(); ++it) allowed.insert(it.key()); + } + const auto pickerResult = co_await awaitDialog(service_.tree(), action == QStringLiteral("move") ? QStringLiteral("Move to Folder") : QStringLiteral("Move Folder"), allowed, this); + const int code = pickerResult.first; + auto* const dialog = pickerResult.second; + if (!alive) co_return; + if (code != QDialog::Accepted) co_return; + const auto destination = dialog->folder_id(); + if (action == QStringLiteral("moveFolder")) result = co_await service_.move_folder(id, destination); + else { co_await placeMods(pids, destination); co_return; } + } else if (action == QStringLiteral("delete")) { + const auto deleteResult = co_await awaitDialog(service_.state().folders.value(id).name, this); + const int code = deleteResult.first; + if (!alive) co_return; + if (code != QMessageBox::Yes) co_return; + result = co_await service_.delete_folder(id); + } + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, qint64 destination) { + const QPointer alive(this); + core::Result result; + if (destination == ROOT_ID) result = co_await service_.ungroup(pids); + else result = co_await service_.place(pids, destination); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::resetRules(QStringList pids) { + const QPointer alive(this); + const auto result = co_await service_.reset_to_rules(pids); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +void OrganizerViewPanel::contextMenu(const QModelIndex& index) { + const auto* node = model_->node_at(index); + menu_->clear(); + if (!node) menu_->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); + else if (node->leaf) { selectSingle(index); modMenu(); } + else { model_->select({index}); folderMenu(*node); } + syncInfo(); + menu_->popup(QCursor::pos()); +} +void OrganizerViewPanel::modMenu() { + const auto nodes = model_->selected_nodes(); + const auto uuids = selectedUuids(); + bool inactive = false, active = false; + for (const auto& uuid : uuids) { if (ctx_.activeUuids().contains(uuid)) active = true; else inactive = true; } + if (inactive) menu_->addAction(QStringLiteral("Enable"), this, [this, uuids] { apply_activation(ctx_, this, uuids); }); + if (active) menu_->addAction(QStringLiteral("Disable"), this, [this, uuids] { apply_activation(ctx_, this, {}, uuids); }); + add_update_action(menu_, ctx_, uuids, [this, uuids] { update_mods(ctx_, uuids, this); }); + menu_->addSeparator(); + const auto pids = selectedPackageIds(); + bool placeable = !pids.isEmpty(), grouped = false; + QStringList manual; + for (const auto* node : nodes) if (node->leaf) { + placeable &= node->leaf->package_id.has_value(); + grouped |= node->folder_id() != ROOT_ID; + if (node->leaf->package_id && node->leaf->placement == Placement::Manual && !manual.contains(*node->leaf->package_id)) manual.append(*node->leaf->package_id); + } + auto* move = menu_->addAction(QStringLiteral("Move to folder…"), this, [this, pids] { editFolder(QStringLiteral("move"), ROOT_ID, pids); }); + auto* remove = menu_->addAction(QStringLiteral("Remove from folder"), this, [this, pids] { placeMods(pids, ROOT_ID); }); + auto* create = menu_->addAction(QStringLiteral("Create folder from selection…"), this, [this, pids] { editFolder(QStringLiteral("create"), ROOT_ID, pids); }); + for (auto* action : {move, remove, create}) { action->setEnabled(placeable); action->setToolTip(placeable ? QString{} : QStringLiteral("Mods without a package ID cannot be placed in folders")); } + remove->setEnabled(placeable && grouped); + menu_->addAction(QStringLiteral("Manage tags…"), this, &OrganizerViewPanel::openTags)->setEnabled(placeable); + if (placeable && !manual.isEmpty()) menu_->addAction(QStringLiteral("Use auto rules"), this, [this, manual] { resetRules(manual); }); +} +void OrganizerViewPanel::folderMenu(const TreeNode& node) { + const auto id = node.folder_id(); + const auto result = service_.folder_mod_uuids(id, node.kind != QStringLiteral("ungrouped")); + if (!result) { reportError(result.error()); return; } + const auto uuids = *result; + menu_->addAction(QStringLiteral("Enable all"), this, [this, uuids] { apply_activation(ctx_, this, uuids); })->setEnabled(!uuids.isEmpty()); + menu_->addAction(QStringLiteral("Disable all"), this, [this, uuids] { apply_activation(ctx_, this, {}, uuids); })->setEnabled(!uuids.isEmpty()); + add_update_action(menu_, ctx_, uuids, [this, uuids] { update_mods(ctx_, uuids, this); }); + menu_->addSeparator(); + if (node.kind == QStringLiteral("ungrouped")) { menu_->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); return; } + if (node.folder && node.folder->depth < MAX_DEPTH) menu_->addAction(QStringLiteral("New subfolder…"), this, [this, id] { editFolder(QStringLiteral("new"), id); }); + menu_->addAction(QStringLiteral("Rename…"), this, [this, id] { editFolder(QStringLiteral("rename"), id); }); + const auto targets = service_.folder_move_targets(id); + menu_->addAction(QStringLiteral("Move to…"), this, [this, id] { editFolder(QStringLiteral("moveFolder"), id); })->setEnabled(targets && !targets->isEmpty()); + menu_->addAction(QStringLiteral("Delete…"), this, [this, id] { editFolder(QStringLiteral("delete"), id); }); +} +bool OrganizerViewPanel::canDrop(const QModelIndex& source, const QModelIndex& target) const { + const auto* src = model_->node_at(source); + const auto* dst = model_->node_at(target); + if (!src || !dst || !dst->is_container() || src == dst) return false; + if (src->leaf) { + if (!model_->is_selected(source)) return src->leaf->package_id.has_value(); + bool any = false; + for (const auto* node : model_->selected_nodes()) { + if (!node->leaf) continue; + any = true; + if (!node->leaf->package_id) return false; + } + return any; + } + if (src->kind == QStringLiteral("folder")) { const auto targets = service_.folder_move_targets(src->folder_id()); return targets && targets->contains(dst->folder_id()); } + return false; +} +QCoro::QmlTask OrganizerViewPanel::dropOn(const QModelIndex& source, const QModelIndex& target) { return performDrop(source, target); } +QCoro::Task OrganizerViewPanel::performDrop(QModelIndex source, QModelIndex target) { + const QPointer alive(this); + if (!canDrop(source, target)) co_return; + const auto* src = model_->node_at(source); + const auto* dst = model_->node_at(target); + const auto destination = dst->folder_id(); + if (src->kind == QStringLiteral("folder")) { + const auto result = co_await service_.move_folder(src->folder_id(), destination); + if (!alive) co_return; + if (!result) reportError(result.error()); + } else { + const auto pids = model_->is_selected(source) ? selectedPackageIds() : QStringList{*src->leaf->package_id}; + co_await placeMods(pids, destination); + } +} +} diff --git a/src/ui/plugins/organizer/view.h b/src/ui/plugins/organizer/view.h new file mode 100644 index 0000000..10039c9 --- /dev/null +++ b/src/ui/plugins/organizer/view.h @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "core/organizer/service.h" +#include "ui/views/base.h" + +class QDialog; +class QLabel; +class QLineEdit; +class QMenu; +class QPushButton; +class QQuickWidget; +class QTimer; +class QShowEvent; + +namespace px::ui { +class ModTreeModel; +class OrganizerFilterModel; +class ModInfoPanel; +class ModSelectionPresenter; +struct TreeNode; + +class OrganizerViewPanel final : public BaseViewPanel { + Q_OBJECT + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(int selectedTagCount READ selectedTagCount NOTIFY editorChanged) + Q_PROPERTY(QVariantList tagRows READ tagRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleRows READ ruleRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleFolders READ ruleFolders NOTIFY editorChanged) + Q_PROPERTY(QString editorError READ editorError NOTIFY editorChanged) +public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("organizer"), QLatin1StringView("folder"), QLatin1StringView("Organizer")}; + OrganizerViewPanel(core::CoreContext&, QQmlEngine*, QWidget*, AppContext&); + ~OrganizerViewPanel() override; + bool ready() const; + int selectedTagCount() const; + QVariantList tagRows() const; + QVariantList ruleRows() const; + QVariantList ruleFolders() const; + QString editorError() const { return editor_error_; } + Q_INVOKABLE QCoro::QmlTask requestRulePreview(const QVariantList&, int revision); + Q_INVOKABLE void openTags(); + Q_INVOKABLE void openRules(); + Q_INVOKABLE void closeRules(); + Q_INVOKABLE QCoro::QmlTask createTag(QString name, QString color); + Q_INVOKABLE QCoro::QmlTask updateTag(qint64 id, QString name, QString color); + Q_INVOKABLE QCoro::QmlTask deleteTag(qint64 id); + Q_INVOKABLE QCoro::QmlTask assignTag(qint64 id, bool add); + Q_INVOKABLE QCoro::QmlTask saveRules(QVariantList rows); + Q_INVOKABLE QCoro::QmlTask addStandardRules(); + Q_INVOKABLE QVariantList expandedIndexes() const; + Q_INVOKABLE void selectRow(const QModelIndex&, int modifiers, const QVariantList& range); + Q_INVOKABLE void selectSingle(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask persistAllCollapsed(bool); + Q_INVOKABLE QCoro::QmlTask persistCollapsed(const QModelIndex&, bool); + Q_INVOKABLE QCoro::QmlTask toggleCheck(const QModelIndex&); + Q_INVOKABLE void contextMenu(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask selectionAction(const QString&); + Q_INVOKABLE QCoro::QmlTask newFolder(); + Q_INVOKABLE bool canDrop(const QModelIndex&, const QModelIndex&) const; + Q_INVOKABLE QCoro::QmlTask dropOn(const QModelIndex&, const QModelIndex&); + void rebuild(); +Q_SIGNALS: + void readyChanged(); + void editorChanged(); + void tagCreated(); + void tagUpdated(qint64 id); + void rulesSaved(); + void rulePreviewReady(int revision, const QVariantMap& result); +protected: + void showEvent(QShowEvent*) override; +private: + void invalidate(); + void syncInfo(bool force = false); + void treeCommand(const char*); + void setEditorError(QString message = {}); + void reportError(const core::Error&); + QStringList selectedPackageIds() const; + QStringList selectedUuids() const; + core::Result> ruleSpecs(const QVariantList&) const; + QCoro::Task calculateRulePreview(QVariantList, int); + QCoro::Task mutateTag(QString operation, qint64 id, QString name = {}, QString color = {}, bool add = false); + QCoro::Task persistRules(QVariantList); + QCoro::Task standardRules(); + QCoro::Task persistCollapse(QModelIndex, bool); + QCoro::Task persistAllCollapse(bool); + QCoro::Task toggle(QModelIndex); + QCoro::Task runSelectionAction(QString); + QCoro::Task editFolder(QString action, qint64 id = core::organizer::ROOT_ID, QStringList pids = {}); + QCoro::Task placeMods(QStringList, qint64); + QCoro::Task resetRules(QStringList); + QCoro::Task performDrop(QModelIndex, QModelIndex); + void modMenu(); + void folderMenu(const TreeNode&); + + core::organizer::OrganizerService& service_; + ModTreeModel* model_; + OrganizerFilterModel* filters_; + ModInfoPanel* info_; + std::unique_ptr selection_; + QQuickWidget* qml_; + QQuickWidget* rules_qml_; + QDialog* rules_dialog_; + QLineEdit* search_; + QLabel* status_; + QLabel* selection_label_; + QWidget* selection_bar_; + QPushButton* update_button_; + QMenu* menu_; + QTimer* search_timer_; + QPersistentModelIndex selection_anchor_; + QString selected_uuid_; + QString filter_key_ = QStringLiteral("all"); + QString search_text_; + QString editor_error_; + bool dirty_ = true; + bool rules_opened_once_ = false; + std::uint64_t changed_id_ = 0; + std::uint64_t active_id_ = 0; + std::uint64_t mods_id_ = 0; + std::uint64_t diagnostics_id_ = 0; + std::uint64_t busy_id_ = 0; +}; +} diff --git a/src/ui/plugins/steam_downloader/plugin.cpp b/src/ui/plugins/steam_downloader/plugin.cpp new file mode 100644 index 0000000..0cfb802 --- /dev/null +++ b/src/ui/plugins/steam_downloader/plugin.cpp @@ -0,0 +1,20 @@ +#include "ui/plugins/steam_downloader/plugin.h" + +#ifdef PX_HAS_STEAM +namespace px::ui { + +SteamSettingsSection::SteamSettingsSection(core::PluginConfig& settings, QObject* parent) + : PluginConfigSection(settings, parent) {} +QString SteamSettingsSection::title() const { return QStringLiteral("Steam Workshop"); } +QUrl SteamSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamSettings.qml")); } + +SteamDownloaderUiPlugin::SteamDownloaderUiPlugin() + : UiPlugin(QStringLiteral("steam_downloader_ui"), {QStringLiteral("steam_downloader")}) {} + +void SteamDownloaderUiPlugin::setup(AppContext& ctx) { + auto& downloader = *static_cast(ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); + ctx.addSettingsSection([&downloader](QObject* parent) { return new SteamSettingsSection(downloader.settings(), parent); }); +} + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_downloader/plugin.h b/src/ui/plugins/steam_downloader/plugin.h new file mode 100644 index 0000000..42f9c6a --- /dev/null +++ b/src/ui/plugins/steam_downloader/plugin.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ui/context.h" + +#ifdef PX_HAS_STEAM +#include "core/downloads/steam.h" +#include "ui/settings_section.h" + +namespace px::ui { + +class SteamSettingsSection final : public PluginConfigSection { +public: + SteamSettingsSection(core::PluginConfig& settings, QObject* parent); + QString title() const override; + QUrl source() const override; +}; + +class SteamDownloaderUiPlugin final : public UiPlugin { +public: + SteamDownloaderUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp new file mode 100644 index 0000000..7d73017 --- /dev/null +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -0,0 +1,99 @@ +#include "ui/plugins/steam_workshop/bridge_obj.h" + +#ifdef PX_HAS_STEAM + +#include + +#include +#include +#include +#include +#include + +#include "ui/plugins/steam_workshop/plugin.h" + +namespace px::ui { +namespace { +bool is_published_file_id(const QJsonValue& value) { + if (!value.isString()) return false; + const auto text = value.toString(); + return !text.isEmpty() && std::ranges::all_of(text, [](QChar c) { return c >= QLatin1Char('0') && c <= QLatin1Char('9'); }); +} +} + +PxModRimBridge::PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent) + : QObject(parent), plugin_(plugin) {} + +void PxModRimBridge::call(const QString& request_id, const QString& method, const QString& payload) { + handle(request_id, method, payload); +} + +void PxModRimBridge::respond(const QString& request_id, bool ok, const QJsonValue& result, const QString& error) { + QJsonObject response{{QStringLiteral("ok"), ok}}; + if (ok) response.insert(QStringLiteral("result"), result); + else response.insert(QStringLiteral("error"), error); + emit result_ready(request_id, QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact))); +} + +QCoro::Task PxModRimBridge::handle(QString request_id, QString method, QString payload) { + QJsonParseError parse_error; + const auto document = QJsonDocument::fromJson(payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parse_error); + if (parse_error.error != QJsonParseError::NoError || !document.isObject()) { + respond(request_id, false, {}, QStringLiteral("Invalid request payload")); + co_return; + } + const auto params = document.object(); + const auto mod_id = params.value(QStringLiteral("mod_id")); + if (method == QLatin1String("init_ready")) { + plugin_.sync_all(); + } else if (method == QLatin1String("toggle_download_checked")) { + if (is_published_file_id(mod_id)) plugin_.toggle_download_checked(mod_id.toString(), params.value(QStringLiteral("title")).toString(), params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("batch_toggle_download_checked")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + const auto titles = params.value(QStringLiteral("titles")); + if (!ids.isArray() || !titles.isArray() || ids.toArray().size() != titles.toArray().size()) { + respond(request_id, false, {}, QStringLiteral("mod_ids and titles must have equal lengths")); + co_return; + } + const auto id_array = ids.toArray(); + const auto title_array = titles.toArray(); + QStringList valid_ids; + QStringList valid_titles; + for (qsizetype pos = 0; pos < id_array.size(); ++pos) { + if (!is_published_file_id(id_array[pos])) continue; + valid_ids.append(id_array[pos].toString()); + valid_titles.append(title_array[pos].toString()); + } + if (!valid_ids.isEmpty()) plugin_.batch_toggle_download_checked(valid_ids, valid_titles, params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("toggle_active")) { + if (is_published_file_id(mod_id)) plugin_.toggle_active(mod_id.toString(), params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("batch_toggle_active")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + if (!ids.isArray()) { + respond(request_id, false, {}, QStringLiteral("mod_ids must be an array")); + co_return; + } + QStringList valid_ids; + for (const auto& id : ids.toArray()) { + if (is_published_file_id(id)) valid_ids.append(id.toString()); + } + if (!valid_ids.isEmpty()) plugin_.batch_toggle_active(valid_ids, params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("fetch_mod_deps")) { + if (is_published_file_id(mod_id)) { + QPointer guard(this); + auto result = co_await plugin_.fetch_mod_deps(mod_id.toString()); + if (guard) { + if (result) guard->respond(request_id, true, *result); + else guard->respond(request_id, false, {}, result.error().message); + } + co_return; + } + } else { + respond(request_id, false, {}, QStringLiteral("Unknown method: %1").arg(method)); + co_return; + } + respond(request_id, true); +} + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.h b/src/ui/plugins/steam_workshop/bridge_obj.h new file mode 100644 index 0000000..5f9a796 --- /dev/null +++ b/src/ui/plugins/steam_workshop/bridge_obj.h @@ -0,0 +1,27 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include +#include +#include + +namespace px::ui { + +class SteamWorkshopUiPlugin; + +class PxModRimBridge final : public QObject { + Q_OBJECT +public: + PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); + Q_INVOKABLE void call(const QString& request_id, const QString& method, const QString& payload); +signals: + void result_ready(const QString& request_id, const QString& payload); +private: + QCoro::Task handle(QString request_id, QString method, QString payload); + void respond(const QString& request_id, bool ok, const QJsonValue& result = QJsonValue(QJsonValue::Null), const QString& error = {}); + SteamWorkshopUiPlugin& plugin_; +}; + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp new file mode 100644 index 0000000..5ec2513 --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -0,0 +1,51 @@ +#include "ui/plugins/steam_workshop/download_queue_model.h" + +namespace px::ui { + +DownloadQueueModel::DownloadQueueModel(QObject* parent) : QAbstractListModel(parent) {} + +int DownloadQueueModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(items_.size()); +} + +QVariant DownloadQueueModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; + const auto& item = items_[index.row()]; + switch (role) { + case IdRole: return item.id; + case TitleRole: return item.title; + case DisplayRole: return item.display; + case StatusRole: return item.status; + case ProgressRole: return item.progress; + default: return {}; + } +} + +QHash DownloadQueueModel::roleNames() const { + return {{IdRole, "id"}, {TitleRole, "title"}, {DisplayRole, "display"}, {StatusRole, "status"}, {ProgressRole, "progress"}}; +} + +void DownloadQueueModel::sync_from(const CheckedDownloads& checked, const QHash& statuses) { + beginResetModel(); + items_.clear(); + items_.reserve(checked.size()); + for (const auto& [id, title] : checked) { + items_.append(Item{id, title.isEmpty() ? id : title, + title.isEmpty() ? id : QStringLiteral("%1 (%2)").arg(title, id), statuses.value(id)}); + } + endResetModel(); +} + +void DownloadQueueModel::update_status(const QString& id, const QString& status, qint64 done, qint64 total) { + for (qsizetype pos = 0; pos < items_.size(); ++pos) { + auto& item = items_[pos]; + if (item.id != id) continue; + item.status = status; + item.progress = total ? static_cast(done) / total : 0; + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx, {StatusRole, ProgressRole}); + return; + } +} + +} // namespace px::ui diff --git a/src/ui/plugins/steam_workshop/download_queue_model.h b/src/ui/plugins/steam_workshop/download_queue_model.h new file mode 100644 index 0000000..de12d01 --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::ui { + +using CheckedDownloads = QList>; + +class DownloadQueueModel final : public QAbstractListModel { + Q_OBJECT +public: + enum Role { IdRole = Qt::UserRole + 1, TitleRole, DisplayRole, StatusRole, ProgressRole }; + explicit DownloadQueueModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void sync_from(const CheckedDownloads& checked, const QHash& statuses = {}); + void update_status(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); + +private: + struct Item { + QString id; + QString title; + QString display; + QString status; + double progress = 0; + }; + QList items_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp new file mode 100644 index 0000000..9b317bd --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -0,0 +1,42 @@ +#include "ui/plugins/steam_workshop/download_sidebar.h" + +#include +#include +#include +#include + +#include "ui/theme/constants.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +DownloadSidebar::DownloadSidebar(QQmlEngine* engine, QWidget* parent) + : QWidget(parent), model_(new DownloadQueueModel(this)), qml_(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("downloadSidebar")); + setFixedWidth(SIDEBAR_WIDTH); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); + qml_->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), this); + qml_->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), model_); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); + layout->addWidget(qml_); +} + +void DownloadSidebar::downloadRequested() { emit download_requested(); } +void DownloadSidebar::stopRequested() { emit stop_requested(); } +void DownloadSidebar::removeItem(const QString& id) { emit item_removed(id); } +void DownloadSidebar::clearQueue() { emit clear_requested(); } +void DownloadSidebar::set_download_enabled(bool enabled) { + if (auto* root = qml_->rootObject()) root->setProperty("downloadEnabled", enabled); +} +void DownloadSidebar::sync_from(const CheckedDownloads& checked, const QHash& statuses) { + model_->sync_from(checked, statuses); +} +void DownloadSidebar::update_status(const QString& id, const QString& status, qint64 done, qint64 total) { + model_->update_status(id, status, done, total); +} + +} // namespace px::ui diff --git a/src/ui/plugins/steam_workshop/download_sidebar.h b/src/ui/plugins/steam_workshop/download_sidebar.h new file mode 100644 index 0000000..9f41e1a --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_sidebar.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "ui/plugins/steam_workshop/download_queue_model.h" + +class QQmlEngine; +class QQuickWidget; + +namespace px::ui { + +class DownloadSidebar final : public QWidget { + Q_OBJECT +public: + DownloadSidebar(QQmlEngine* engine, QWidget* parent); + Q_INVOKABLE void downloadRequested(); + Q_INVOKABLE void stopRequested(); + Q_INVOKABLE void removeItem(const QString& id); + Q_INVOKABLE void clearQueue(); + void set_download_enabled(bool enabled); + void sync_from(const CheckedDownloads& checked, const QHash& statuses = {}); + void update_status(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); +signals: + void download_requested(); + void stop_requested(); + void item_removed(const QString& id); + void clear_requested(); +private: + DownloadQueueModel* model_; + QQuickWidget* qml_; +}; + +} // namespace px::ui diff --git a/src/ui/plugins/steam_workshop/inject.css b/src/ui/plugins/steam_workshop/inject.css new file mode 100644 index 0000000..406fb9d --- /dev/null +++ b/src/ui/plugins/steam_workshop/inject.css @@ -0,0 +1,393 @@ +/* Instant-hide styles to prevent flicker */ + +/* stylelint-disable-next-line selector-id-pattern, selector-class-pattern */ +#global_header, +header, +.sharedfiles_item_page_header { + display: none !important; +} + +/* stylelint-disable-next-line selector-id-pattern */ +#footer [style*='--grid-area'] { + display: none !important; +} + +/* Loading placeholder button -- replaces Steam button while bridge data loads */ + +.rimsort-loading-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 36px; + border-radius: 6px; + pointer-events: none; + background: linear-gradient( + 90deg, + rgb(255 255 255 / 4%) 0%, + rgb(255 255 255 / 10%) 50%, + rgb(255 255 255 / 4%) 100% + ); + background-size: 200% 100%; + animation: rimsort-loading-shimmer 1.2s ease-in-out infinite; +} + +.rimsort-error-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 36px; + border-radius: 6px; + pointer-events: none; + background-color: #d32f2f; + color: white; + font-weight: bold; + font-size: 14px; +} + +@keyframes rimsort-loading-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Grid badge styles */ + +.rimsort-modstatus-badge { + position: absolute; + top: 5px; + right: 5px; + color: white; + min-width: 28px; + height: 28px; + padding: 0 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 16px; + box-shadow: 0 0 4px black; + user-select: none; + transition: transform 0.1s ease, box-shadow 0.1s ease, opacity 0.2s ease, visibility 0.2s ease, background-color 0.2s ease; + z-index: 10; + opacity: 0; + visibility: hidden; +} + +.rimsort-modstatus-badge.pressed { + transform: scale(0.9); +} + +.rimsort-mod-active { + background-color: #4CAF50; + cursor: pointer; + opacity: 1; + visibility: visible; +} + +.rimsort-mod-inactive { + background-color: #666; + cursor: pointer; + opacity: 1; + visibility: visible; +} + +.rimsort-mod-checked { + background-color: #FFA500; + cursor: pointer; + opacity: 1; + visibility: visible; +} + +.rimsort-mod-default { + background-color: #2196F3; + cursor: pointer; +} + +.rimsort-mod-resolving { + background-color: #555; + cursor: wait; + opacity: 1; + visibility: visible; + pointer-events: none; +} + +.collectionItem { + position: relative; +} + +.rimsort-tile:hover .rimsort-modstatus-badge, +.collectionItem:hover .rimsort-modstatus-badge { + opacity: 1; + visibility: visible; +} + +/* Detail page button styles */ + +.rimsort-detail-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 130px; + height: 36px; + padding: 0 16px; + border-radius: 6px; + font-weight: bold; + font-size: 14px; + user-select: none; + cursor: pointer; + color: white; + border: none; + text-decoration: none; + box-shadow: 0 0 4px rgb(0 0 0 / 30%); + transition: transform 0.1s ease, box-shadow 0.1s ease, background-color 0.2s ease; +} + +.rimsort-detail-btn.rimsort-mod-active { + background-color: #4CAF50; + cursor: pointer; +} + +.rimsort-detail-btn.rimsort-mod-inactive { + background-color: #666; + cursor: pointer; +} + +.rimsort-detail-btn.rimsort-mod-checked { + background-color: #FFA500; + cursor: pointer; +} + +.rimsort-detail-btn.rimsort-mod-default { + background-color: #2196F3; + cursor: pointer; +} + +.rimsort-detail-btn.pressed { + transform: scale(0.95); + box-shadow: 0 0 2px rgb(0 0 0 / 50%); +} + +/* Dependency tree styles */ + +.rimsort-deps-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgb(255 255 255 / 10%); + font-size: 13px; +} + +.rimsort-deps-header { + font-weight: bold; + margin-bottom: 8px; + color: #ccc; +} + +.rimsort-dep-node { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + white-space: nowrap; +} + +.rimsort-dep-expand { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + cursor: pointer; + color: #888; + user-select: none; + transition: transform 0.15s ease, color 0.15s ease; +} + +.rimsort-dep-expand.expanded { + transform: rotate(90deg); + color: #ccc; +} + +.rimsort-dep-expand:empty { + visibility: hidden; +} + +.rimsort-dep-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 4px; + font-weight: bold; + font-size: 11px; + color: white; + user-select: none; + cursor: pointer; + transition: transform 0.1s ease, box-shadow 0.1s ease, background-color 0.2s ease; + flex-shrink: 0; +} + +.rimsort-dep-badge.pressed { + transform: scale(0.9); +} + +.rimsort-dep-title { + color: #fff; + text-decoration: none; + transition: color 0.15s ease; + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; +} + +.rimsort-dep-title:hover { + color: #4CAF50; +} + +.rimsort-dep-children { + margin-left: 24px; + border-left: 1px solid rgb(255 255 255 / 10%); + padding-left: 8px; +} + +.rimsort-dep-loading-container { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgb(255 255 255 / 10%); + color: #888; + font-size: 13px; +} + +.rimsort-dep-spinner { + width: 18px; + height: 18px; + border: 2px solid rgb(255 255 255 / 15%); + border-top-color: #4CAF50; + border-radius: 50%; + flex-shrink: 0; + animation: rimsort-spin 0.7s linear infinite; +} + +@keyframes rimsort-spin { + to { transform: rotate(360deg); } +} + +.rimsort-dep-loading { + color: #888; + font-style: italic; + font-size: 12px; + padding: 4px 0; +} + +.rimsort-dep-error { + color: #f44336; + font-size: 12px; + padding: 4px 0; + cursor: pointer; +} + +.rimsort-dep-circular { + color: #888; + font-size: 12px; + font-style: italic; + padding: 4px 0; +} + +.rimsort-dep-maxdepth { + color: #888; + font-size: 12px; + padding: 4px 0; + border-top: 1px dashed rgb(255 255 255 / 10%); + margin-top: 4px; +} + +.rimsort-deps-resolving { + position: relative; + pointer-events: none; + opacity: 0.8; +} + +.rimsort-deps-resolving::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 15%), transparent); + animation: rimsort-shimmer 1.5s infinite; +} + +@keyframes rimsort-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.rimsort-solo-link { + display: block; + margin-top: 8px; + padding: 6px 12px; + background: rgb(255 255 255 / 5%); + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 4px; + color: #aaa; + text-decoration: none; + font-size: 12px; + text-align: center; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + cursor: pointer; +} + +.rimsort-solo-link:hover { + background: rgb(255 255 255 / 10%); + color: #fff; + border-color: rgb(255 255 255 / 20%); +} + +/* Hide native Steam collection buttons — replaced by RimSort badges */ + +.subscribeCollection { + display: none !important; +} + +.collectionItem .subscriptionControls { + display: none !important; +} + +/* Collection page batch bar */ + +.rimsort-collection-bar { + display: flex; + gap: 8px; + margin: 0 0 12px; + padding: 8px 0; + border-bottom: 1px solid rgb(255 255 255 / 10%); +} + +.rimsort-only-btn { + padding: 6px 14px; + border: 1px solid #FFA500; + border-radius: 4px; + background: transparent; + color: #FFA500; + font-size: 13px; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.rimsort-only-btn:hover { + background: rgb(255 165 0 / 10%); +} + +.rimsort-only-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp new file mode 100644 index 0000000..00fe1e9 --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -0,0 +1,237 @@ +#include "ui/plugins/steam_workshop/plugin.h" + +#ifdef PX_HAS_STEAM + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/mod_service.h" +#include "ui/navigation.h" +#include "ui/plugins/steam_workshop/view.h" + +namespace px::ui { + +SteamWorkshopUiPlugin::SteamWorkshopUiPlugin() + : UiPlugin(QStringLiteral("steamworkshop"), {QStringLiteral("steam_downloader")}) {} +SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnect_events(); } + +void SteamWorkshopUiPlugin::setup(AppContext& ctx) { + core_ = &ctx.coreContext(); + app_ = &ctx; + service_ = static_cast(core_->plugins().get(QStringLiteral("steam_downloader"))); + ctx.addRailView(); + mods_id_ = core_->modService().mods_changed.connect([this](const std::monostate&) { + refresh_cached_ids(); + badges_refresh_requested.notify(installed_ids_.values()); + active_refresh_requested.notify(active_ids_.values()); + }); + active_id_ = core_->activeStateChanged().connect([this](const QStringList&) { + refresh_cached_ids(); + badges_refresh_requested.notify(installed_ids_.values()); + active_refresh_requested.notify(active_ids_.values()); + }); + status_id_ = service_->download_item_status_changed.connect([this](const core::DownloadItemStatus& status) { on_item_status(status); }); + finished_id_ = service_->download_finished.connect([this](const core::DownloadResult& result) { on_download_finished(result); }); + busy_id_ = service_->busy_changed.connect([this](bool busy) { download_busy_changed.notify(busy); }); +} + +QCoro::Task SteamWorkshopUiPlugin::init(AppContext&) { + refresh_cached_ids(); + co_return; +} + +QCoro::Task SteamWorkshopUiPlugin::shutdown() { + disconnect_events(); + co_return; +} + +void SteamWorkshopUiPlugin::disconnect_events() { + if (!core_) return; + core_->modService().mods_changed.disconnect(mods_id_); + core_->activeStateChanged().disconnect(active_id_); + service_->download_item_status_changed.disconnect(status_id_); + service_->download_finished.disconnect(finished_id_); + service_->busy_changed.disconnect(busy_id_); + core_ = nullptr; + app_ = nullptr; +} + +void SteamWorkshopUiPlugin::toggle_download_checked(const QString& id, const QString& title, bool checked) { + const auto it = std::ranges::find(checked_ids_, id, &CheckedDownloads::value_type::first); + if (checked) { + if (it == checked_ids_.end()) checked_ids_.append({id, title}); + else it->second = title; + } else if (it != checked_ids_.end()) { + checked_ids_.erase(it); + } + notify_sidebar(); +} + +void SteamWorkshopUiPlugin::batch_toggle_download_checked(const QStringList& ids, const QStringList& titles, bool checked) { + for (qsizetype pos = 0; pos < ids.size(); ++pos) { + const auto it = std::ranges::find(checked_ids_, ids[pos], &CheckedDownloads::value_type::first); + if (checked) { + if (it == checked_ids_.end()) checked_ids_.append({ids[pos], titles[pos]}); + else it->second = titles[pos]; + } else if (it != checked_ids_.end()) { + checked_ids_.erase(it); + } + } + if (!ids.isEmpty()) notify_sidebar(); +} + +void SteamWorkshopUiPlugin::toggle_active(const QString& id, bool active) { + if (!core_) return; + for (auto it = core_->allMods().cbegin(); it != core_->allMods().cend(); ++it) { + if (it.value()->published_file_id() != id) continue; + auto current = core_->activeUuids(); + if (active) { + if (!current.contains(it.key())) current.append(it.key()); + } else { + current.removeAll(it.key()); + } + core_->setActive(std::move(current)); + return; + } +} + +void SteamWorkshopUiPlugin::batch_toggle_active(const QStringList& ids, bool active) { + if (!core_) return; + QHash by_published_id; + for (auto it = core_->allMods().cbegin(); it != core_->allMods().cend(); ++it) { + if (auto id = it.value()->published_file_id()) by_published_id.insert(*id, it.key()); + } + auto current = core_->activeUuids(); + for (const auto& id : ids) { + const auto it = by_published_id.constFind(id); + if (it == by_published_id.cend()) continue; + if (active) { + if (!current.contains(*it)) current.append(*it); + } else { + current.removeAll(*it); + } + } + core_->setActive(std::move(current)); +} + +QCoro::Task SteamWorkshopUiPlugin::request_download() { + if (checked_ids_.isEmpty()) co_return; + if (!core_ || core_->config().paths.local.empty()) { + service_->status_message_changed.notify(QStringLiteral("Local mods path is not configured")); + co_return; + } + if (service_->is_downloading()) { + service_->status_message_changed.notify(QStringLiteral("A download is already running")); + co_return; + } + QStringList ids; + ids.reserve(checked_ids_.size()); + for (const auto& [id, title] : checked_ids_) { + ids.append(id); + statuses_.insert(id, QStringLiteral("queued")); + } + own_download_ = true; + if (app_) app_->navigate(buildRoute(QStringLiteral("downloads"))); + notify_sidebar(); + auto result = co_await service_->download_mods(std::move(ids)); + own_download_ = false; + if (!result) service_->status_message_changed.notify(result.error().message); +} + +void SteamWorkshopUiPlugin::stop_download() { service_->cancel(); } +bool SteamWorkshopUiPlugin::is_downloading() const { return service_->is_downloading(); } + +void SteamWorkshopUiPlugin::remove_item(const QString& id) { + checked_ids_.removeIf([&id](const auto& item) { return item.first == id; }); + statuses_.remove(id); + notify_sidebar(); + uncheck_mod_requested.notify(id); +} + +void SteamWorkshopUiPlugin::clear_queue() { + checked_ids_.clear(); + statuses_.clear(); + notify_sidebar(); + clear_checked_requested.notify(std::monostate{}); +} + +void SteamWorkshopUiPlugin::notify_sidebar() { sidebar_sync_requested.notify(SidebarSync{checked_ids_, statuses_}); } + +void SteamWorkshopUiPlugin::sync_all() { + refresh_cached_ids(); + badges_refresh_requested.notify(installed_ids_.values()); + active_refresh_requested.notify(active_ids_.values()); + notify_sidebar(); +} + +void SteamWorkshopUiPlugin::on_item_status(const core::DownloadItemStatus& status) { + if (!own_download_) return; + statuses_.insert(status.mod_id, status.status); + item_status_changed.notify(status); +} + +void SteamWorkshopUiPlugin::on_download_finished(const core::DownloadResult& result) { + if (!own_download_) return; + for (const auto& id : result.succeeded) { + checked_ids_.removeIf([&id](const auto& item) { return item.first == id; }); + statuses_.remove(id); + } + for (auto it = statuses_.begin(); it != statuses_.end();) { + if (it.value() == QLatin1String("queued") || it.value() == QLatin1String("downloading")) it = statuses_.erase(it); + else ++it; + } + notify_sidebar(); +} + +void SteamWorkshopUiPlugin::refresh_cached_ids() { + if (!core_) return; + installed_ids_.clear(); + active_ids_.clear(); + const QSet active(core_->activeUuids().cbegin(), core_->activeUuids().cend()); + for (const auto& mod : core_->allMods()) { + if (auto id = mod->published_file_id()) { + installed_ids_.insert(*id); + if (active.contains(mod->uuid())) active_ids_.insert(*id); + } + } +} + +QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id) { + QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("id"), id); + url.setQuery(query); + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "PxModRim/1.0"); + request.setTransferTimeout(30000); + auto* reply = core_->network().get(request); + if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { + reply->abort(); + reply->deleteLater(); + co_return std::unexpected(core::Error{QStringLiteral("Timeout")}); + } + const auto error = reply->error(); + const auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const auto bytes = reply->readAll(); + const auto message = reply->errorString(); + reply->deleteLater(); + if (error == QNetworkReply::TimeoutError) co_return std::unexpected(core::Error{QStringLiteral("Timeout")}); + if (status && status != 200) co_return QJsonValue(QJsonValue::Null); + if (error != QNetworkReply::NoError) co_return std::unexpected(core::Error{message}); + if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); + QJsonParseError parse_error; + const auto document = QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parse_error); + if (parse_error.error != QJsonParseError::NoError) co_return std::unexpected(core::Error{parse_error.errorString()}); + co_return document.array().at(0); +} + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/plugin.h b/src/ui/plugins/steam_workshop/plugin.h new file mode 100644 index 0000000..9109d68 --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.h @@ -0,0 +1,71 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include + +#include +#include + +#include "core/downloads/steam.h" +#include "ui/context.h" +#include "ui/plugins/steam_workshop/download_queue_model.h" + +namespace px::ui { + +struct SidebarSync { + CheckedDownloads checked_ids; + QHash statuses; +}; + +class SteamWorkshopUiPlugin final : public UiPlugin { +public: + SteamWorkshopUiPlugin(); + ~SteamWorkshopUiPlugin() override; + void setup(AppContext& ctx) override; + QCoro::Task init(AppContext& ctx) override; + QCoro::Task shutdown() override; + + void toggle_download_checked(const QString& id, const QString& title, bool checked); + void batch_toggle_download_checked(const QStringList& ids, const QStringList& titles, bool checked); + void toggle_active(const QString& id, bool active); + void batch_toggle_active(const QStringList& ids, bool active); + QCoro::Task request_download(); + void stop_download(); + void remove_item(const QString& id); + void clear_queue(); + void sync_all(); + bool is_downloading() const; + QCoro::Task> fetch_mod_deps(QString id); + + core::Event badges_refresh_requested; + core::Event active_refresh_requested; + core::Event sidebar_sync_requested; + core::Event item_status_changed; + core::Event download_busy_changed; + core::Event uncheck_mod_requested; + core::Event clear_checked_requested; + +private: + void disconnect_events(); + void refresh_cached_ids(); + void notify_sidebar(); + void on_item_status(const core::DownloadItemStatus& status); + void on_download_finished(const core::DownloadResult& result); + core::CoreContext* core_ = nullptr; + AppContext* app_ = nullptr; + core::SteamDownloader* service_ = nullptr; + QSet installed_ids_; + QSet active_ids_; + CheckedDownloads checked_ids_; + QHash statuses_; + bool own_download_ = false; + core::Event::Id mods_id_ = 0; + core::Event::Id active_id_ = 0; + core::Event::Id status_id_ = 0; + core::Event::Id finished_id_ = 0; + core::Event::Id busy_id_ = 0; +}; + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/ts/api.ts b/src/ui/plugins/steam_workshop/ts/api.ts new file mode 100644 index 0000000..7b04244 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/api.ts @@ -0,0 +1,69 @@ +import type { DepItemsResult } from "./types"; +import { PxModRimRPC } from "./bridge"; + +let _rpc: PxModRimRPC | null = null; + +export function initAPI(rpc: PxModRimRPC): void { + _rpc = rpc; +} + +export namespace PxModRimAPI { + export async function initReady(): Promise { + await _rpc!.call("init_ready"); + } + + export async function toggleDownloadChecked( + modId: string, + title: string, + checked: boolean, + ): Promise { + await _rpc!.call("toggle_download_checked", { + mod_id: modId, + title, + checked, + }); + } + + export async function batchToggleDownloadChecked( + modIds: string[], + titles: string[], + checked: boolean, + ): Promise { + await _rpc!.call("batch_toggle_download_checked", { + mod_ids: modIds, + titles, + checked, + }); + } + + export async function toggleActive( + modId: string, + active: boolean, + ): Promise { + await _rpc!.call("toggle_active", { + mod_id: modId, + active, + }); + } + + export async function batchToggleActive( + modIds: string[], + active: boolean, + ): Promise { + await _rpc!.call("batch_toggle_active", { + mod_ids: modIds, + active, + }); + } + + export async function fetchModDeps( + modId: string, + ): Promise { + const r = await _rpc!.call("fetch_mod_deps", { mod_id: modId }); + if (!r.ok) { + console.error("[pxmodrim] fetchModDeps failed:", r.error); + return null; + } + return r.result ?? null; + } +} diff --git a/src/ui/plugins/steam_workshop/ts/badges.ts b/src/ui/plugins/steam_workshop/ts/badges.ts new file mode 100644 index 0000000..50919c1 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/badges.ts @@ -0,0 +1,74 @@ +import { ModState } from "./types"; +import { log, getModId, getModTitle, findItemCard } from "./utils"; +import { getModState, applyModState, handleClick } from "./controls"; +import { isBridgeDataReady } from "./bridge"; +import { isPageKind } from "./lifecycle"; + +let _badgeTimer: ReturnType | null = null; +const BADGE_DEBOUNCE_MS = 300; + +export function updateModBadge(modId: string): void { + if (!isPageKind("grid")) return; + const state = getModState(modId); + + const link = document.querySelector( + `a[href*="sharedfiles/filedetails/?id=${modId}"]`, + ); + if (!link) return; + + const tile = findItemCard(link); + if (!tile) return; + + let badge = tile.querySelector(".rimsort-modstatus-badge"); + if (!badge) { + badge = document.createElement("div"); + badge.className = "rimsort-modstatus-badge"; + if (getComputedStyle(tile).position === "static") { + (tile as HTMLElement).style.position = "relative"; + } + tile.classList.add("rimsort-tile"); + tile.appendChild(badge); + + badge.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + await handleClick(badge!, modId, () => getModTitle(tile)); + updateAllModBadges(); + }); + } + + applyModState(badge, state); +} + +export function updateAllModBadges(): void { + log("updateAllModBadges ENTER"); + const links = document.querySelectorAll( + 'a[href*="sharedfiles/filedetails/?id="]', + ); + let badged = 0; + links.forEach((link) => { + const modId = getModId(link); + if (!modId) return; + updateModBadge(modId); + badged++; + }); + log(`Badges updated: ${badged}`); +} + +export function scheduleBadgeUpdate(): void { + if (_badgeTimer !== null) return; + if (!isPageKind("grid")) return; + _badgeTimer = setTimeout(() => { + _badgeTimer = null; + if (isBridgeDataReady()) updateAllModBadges(); + }, BADGE_DEBOUNCE_MS); +} + +export function refreshAllDepsBadges(): void { + document.querySelectorAll(".rimsort-dep-badge").forEach((badge) => { + const el = badge as HTMLElement; + const modId = el.dataset.modid; + if (!modId) return; + applyModState(el, getModState(modId)); + }); +} diff --git a/src/ui/plugins/steam_workshop/ts/bridge.ts b/src/ui/plugins/steam_workshop/ts/bridge.ts new file mode 100644 index 0000000..dce12ee --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/bridge.ts @@ -0,0 +1,138 @@ +import "./qwebchannel.js"; +import { getModState, applyModState } from "./controls"; +import { refreshAllDepsBadges, updateAllModBadges } from "./badges"; +import { log } from "./utils"; + +// Ensure __pxmodrim exists (modules may initialize before main.ts guard) +if (!window.__pxmodrim) { + window.__pxmodrim = { + installedIds: new Set(), + checkedIds: new Set(), + activeIds: new Set(), + onStateChange: null, + }; +} + +let _bridgeDataReady = false; +let _bridgeDataResolve: (() => void) | null = null; +let _bridgeDataReject: ((reason: Error) => void) | null = null; +let _bridgeDataPromise: Promise | null = null; + +export function isBridgeDataReady(): boolean { + return _bridgeDataReady; +} + +export function setBridgeDataReady(): void { + _bridgeDataReady = true; + _bridgeDataResolve?.(); +} + +export function waitForBridgeData(timeoutMs = 15000): Promise { + if (!_bridgeDataPromise) { + _bridgeDataPromise = new Promise((resolve, reject) => { + _bridgeDataResolve = resolve; + _bridgeDataReject = reject; + setTimeout(() => { + _bridgeDataPromise = null; + _bridgeDataResolve = null; + _bridgeDataReject = null; + reject(new Error("Bridge data timeout")); + }, timeoutMs); + }); + } + if (_bridgeDataReady) { + _bridgeDataResolve?.(); + } + return _bridgeDataPromise; +} + +// ── QWebChannel RPC ──────────────────────────────────── + +export class PxModRimRPC { + private bridge: any; + private pending = new Map) => void>(); + + constructor(channel: any) { + this.bridge = channel.objects.bridge; + this.bridge.result_ready.connect((id: string, json: string) => { + const resolve = this.pending.get(id); + if (resolve) { + this.pending.delete(id); + resolve(JSON.parse(json)); + } + }); + } + + call( + method: string, + payload: Record = {}, + ): Promise> { + return new Promise(resolve => { + const id = crypto.randomUUID(); + this.pending.set(id, resolve); + this.bridge.call(id, method, JSON.stringify(payload)); + }); + } +} + +export function initRPC(): Promise { + return new Promise(resolve => { + const qt = (window as any).qt; + if (qt && qt.webChannelTransport) { + new (window as any).QWebChannel( + qt.webChannelTransport, + (channel: any) => { + resolve(new PxModRimRPC(channel)); + }, + ); + } else { + setTimeout(() => initRPC().then(resolve), 100); + } + }); +} + +// ── Python integration points ───────────────────────────────────── + +export function __pxmSetInstalled(modIds: string[]): void { + window.__pxmodrim.installedIds.clear(); + (modIds || []).forEach((id) => window.__pxmodrim.installedIds.add(id)); + if (_bridgeDataReady) { + updateAllModBadges(); + refreshAllDepsBadges(); + window.__pxmodrim.onStateChange?.(); + } +} + +export function __pxmUncheckMod(modId: string): void { + window.__pxmodrim.checkedIds.delete(modId); + window.updateModBadge(modId); + refreshAllDepsBadges(); + window.__pxmodrim.onStateChange?.(); +} + +export function __pxmClearChecked(): void { + window.__pxmodrim.checkedIds.clear(); + updateAllModBadges(); + refreshAllDepsBadges(); + window.__pxmodrim.onStateChange?.(); +} + +export function __pxmSetActive(modIds: string[]): void { + window.__pxmodrim.activeIds.clear(); + (modIds || []).forEach((id) => window.__pxmodrim.activeIds.add(id)); + if (_bridgeDataReady) { + updateAllModBadges(); + refreshAllDepsBadges(); + window.__pxmodrim.onStateChange?.(); + } +} + +// Render detail page button on state changes from Python +window.__pxmodrim.onStateChange = function () { + const modId = (window.location.href.match(/[?&]id=(\d+)/) || [])[1]; + const btn = document.getElementById("pxmodrim-subscribe-btn"); + if (btn && modId) { + applyModState(btn, getModState(modId)); + } + refreshAllDepsBadges(); +}; diff --git a/src/ui/plugins/steam_workshop/ts/collection.ts b/src/ui/plugins/steam_workshop/ts/collection.ts new file mode 100644 index 0000000..f847ef1 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/collection.ts @@ -0,0 +1,275 @@ +import { ModState, type ModStatus } from "./types"; +import { log, warn, getModId } from "./utils"; +import { getModState, applyModState, handleClick } from "./controls"; +import { PxModRimAPI } from "./api"; + +function scrapeChildIds(): string[] { + const ids: string[] = []; + document.querySelectorAll('[id^="sharedfile_"]').forEach((el) => { + const id = el.id.replace("sharedfile_", ""); + if (id) ids.push(id); + }); + return ids; +} + +function getCollectionState(): ModStatus { + const ids = scrapeChildIds(); + if (!ids.length) return ModState.DEFAULT; + + const installed = window.__pxmodrim.installedIds; + const checked = window.__pxmodrim.checkedIds; + const active = window.__pxmodrim.activeIds; + + if (ids.some((id) => checked.has(id))) return ModState.CHECKED; + if (ids.some((id) => !installed.has(id) && !checked.has(id))) return ModState.DEFAULT; + if (ids.every((id) => active.has(id))) return ModState.ACTIVE; + return ModState.INACTIVE; +} + +function addBatchBar(childrenEl: Element): void { + const existing = document.querySelector(".rimsort-collection-bar"); + if (existing) return; + + const bar = document.createElement("div"); + bar.className = "rimsort-collection-bar"; + + let resolving = false; + + const btn = document.createElement("button"); + btn.className = "rimsort-detail-btn"; + btn.dataset.rimsortVariant = "detail"; + + btn.addEventListener("click", async () => { + if (resolving) return; + resolving = true; + + const state = getCollectionState(); + + btn.classList.add("pressed"); + requestAnimationFrame(() => btn.classList.remove("pressed")); + + if (state === ModState.DEFAULT) { + applyModState(btn, ModState.RESOLVING); + } + + try { + switch (state) { + case ModState.DEFAULT: { + const collectionId = new URL(window.location.href).searchParams.get("id"); + if (!collectionId) return; + + const result = await PxModRimAPI.fetchModDeps(collectionId); + if (!result?.items) return; + + const idsToQueue = new Set(); + const titles: Record = {}; + const installed = window.__pxmodrim.installedIds; + const checked = window.__pxmodrim.checkedIds; + + for (const item of Object.values(result.items)) { + if (!installed.has(item.id) && !checked.has(item.id)) { + idsToQueue.add(item.id); + titles[item.id] = item.title || item.id; + } + for (const depId of item.deps || []) { + if (!installed.has(depId) && !checked.has(depId)) { + idsToQueue.add(depId); + titles[depId] = result.items[depId]?.title || depId; + } + } + } + + const queueList = [...idsToQueue]; + if (queueList.length) { + for (const id of queueList) window.__pxmodrim.checkedIds.add(id); + await PxModRimAPI.batchToggleDownloadChecked( + queueList, + queueList.map((id) => titles[id] || id), + true, + ); + } + break; + } + case ModState.CHECKED: { + const checkedIds = scrapeChildIds().filter((id) => + window.__pxmodrim.checkedIds.has(id), + ); + if (checkedIds.length) { + for (const id of checkedIds) window.__pxmodrim.checkedIds.delete(id); + await PxModRimAPI.batchToggleDownloadChecked( + checkedIds, + checkedIds.map(() => ""), + false, + ); + } + break; + } + case ModState.INACTIVE: { + const toActivate = scrapeChildIds().filter( + (id) => + window.__pxmodrim.installedIds.has(id) && + !window.__pxmodrim.activeIds.has(id), + ); + if (toActivate.length) { + for (const id of toActivate) window.__pxmodrim.activeIds.add(id); + await PxModRimAPI.batchToggleActive(toActivate, true); + } + break; + } + case ModState.ACTIVE: { + const toDeactivate = scrapeChildIds().filter((id) => + window.__pxmodrim.activeIds.has(id), + ); + if (toDeactivate.length) { + for (const id of toDeactivate) window.__pxmodrim.activeIds.delete(id); + await PxModRimAPI.batchToggleActive(toDeactivate, false); + } + break; + } + } + } catch (e) { + warn("Collection batch action failed", e); + } finally { + resolving = false; + updateCollectionUI(); + } + }); + + const onlyBtn = document.createElement("button"); + onlyBtn.className = "rimsort-only-btn"; + onlyBtn.textContent = "Activate only this pack"; + onlyBtn.title = "Activate this modpack + deps, deactivate everything else"; + + onlyBtn.addEventListener("click", async () => { + if (resolving) return; + resolving = true; + + const hasInstalled = scrapeChildIds().some((id) => + window.__pxmodrim.installedIds.has(id), + ); + if (!hasInstalled) { + resolving = false; + return; + } + + onlyBtn.disabled = true; + onlyBtn.textContent = "Resolving..."; + + try { + const collectionId = new URL(window.location.href).searchParams.get("id"); + if (!collectionId) return; + + const result = await PxModRimAPI.fetchModDeps(collectionId); + if (!result?.items) return; + + const packIds = new Set(); + for (const item of Object.values(result.items)) { + packIds.add(item.id); + for (const depId of item.deps || []) packIds.add(depId); + } + + const installedPack = [...packIds].filter((id) => + window.__pxmodrim.installedIds.has(id), + ); + + const toDeactivate = [...window.__pxmodrim.activeIds].filter( + (id) => !installedPack.includes(id), + ); + + onlyBtn.textContent = "Applying..."; + + if (toDeactivate.length) { + for (const id of toDeactivate) window.__pxmodrim.activeIds.delete(id); + await PxModRimAPI.batchToggleActive(toDeactivate, false); + } + + const needActivate = installedPack.filter( + (id) => !window.__pxmodrim.activeIds.has(id), + ); + if (needActivate.length) { + for (const id of needActivate) window.__pxmodrim.activeIds.add(id); + await PxModRimAPI.batchToggleActive(needActivate, true); + } + } catch (e) { + warn("Activate-only action failed", e); + } finally { + resolving = false; + updateCollectionUI(); + onlyBtn.disabled = false; + onlyBtn.textContent = "Activate only this pack"; + } + }); + + bar.appendChild(btn); + bar.appendChild(onlyBtn); + updateCollectionUI(); + childrenEl.before(bar); +} + +function updateCollectionButton(btn: HTMLElement, state: ModStatus): void { + applyModState(btn, state); + switch (state) { + case ModState.ACTIVE: + btn.textContent = "✓ Deactivate all"; + btn.title = "Deactivate all active mods in this collection"; + break; + case ModState.INACTIVE: + btn.textContent = "⊗ Activate all"; + btn.title = "Activate all inactive mods in this collection"; + break; + } +} + +function updateCollectionUI(): void { + document.querySelectorAll(".collectionItem .rimsort-modstatus-badge").forEach((badge) => { + const modId = (badge as HTMLElement).dataset.modid; + if (modId) applyModState(badge as HTMLElement, getModState(modId)); + }); + const btn = document.querySelector(".rimsort-collection-bar .rimsort-detail-btn"); + if (btn) updateCollectionButton(btn, getCollectionState()); +} + +function addChildBadges(childrenEl: Element): void { + const items = childrenEl.querySelectorAll(".collectionItem"); + items.forEach((item) => { + const link = item.querySelector( + 'a[href*="sharedfiles/filedetails/?id="]', + ); + if (!link) return; + const modId = getModId(link); + if (!modId) return; + + const state = getModState(modId); + + let badge = item.querySelector(".rimsort-modstatus-badge"); + if (!badge) { + badge = document.createElement("div"); + badge.className = "rimsort-modstatus-badge"; + badge.dataset.modid = modId; + const titleEl = item.querySelector(".workshopItemTitle"); + badge.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + await handleClick(badge!, modId, () => titleEl?.textContent?.trim() || ""); + }); + item.appendChild(badge); + } + applyModState(badge, state); + }); +} + +export function initCollectionPage(): void { + log("initCollectionPage ENTER"); + const container = document.querySelector(".collectionChildren"); + if (!container) return; + addBatchBar(container); + addChildBadges(container); + log("initCollectionPage DONE"); +} + +export function updateCollectionBadges(): void { + const container = document.querySelector(".collectionChildren"); + if (!container) return; + addChildBadges(container); + updateCollectionUI(); +} diff --git a/src/ui/plugins/steam_workshop/ts/controls.ts b/src/ui/plugins/steam_workshop/ts/controls.ts new file mode 100644 index 0000000..d0a0a97 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/controls.ts @@ -0,0 +1,165 @@ +import { ModState, type ModStatus } from "./types"; +import { log, warn } from "./utils"; +import { getDepsFor, flattenDepTree } from "./deps"; +import { PxModRimAPI } from "./api"; + +export function getModState(modId: string): ModStatus { + if (window.__pxmodrim.activeIds.has(modId)) return ModState.ACTIVE; + if (window.__pxmodrim.installedIds.has(modId)) return ModState.INACTIVE; + if (window.__pxmodrim.checkedIds.has(modId)) return ModState.CHECKED; + return ModState.DEFAULT; +} + +export function applyModState(el: HTMLElement, state: ModStatus): void { + if (el.dataset.state === state && state !== ModState.RESOLVING) return; + el.className = el.className + .split(" ") + .filter((c) => !c.startsWith("rimsort-mod-")) + .join(" "); + el.classList.add(`rimsort-mod-${state}`); + el.dataset.state = state; + el.style.pointerEvents = ""; + + if (state === ModState.RESOLVING) { + el.title = "Resolving dependencies\u2026"; + el.innerHTML = + '
    '; + el.style.pointerEvents = "none"; + return; + } + + if (el.dataset.rimsortVariant === "detail") { + switch (state) { + case ModState.ACTIVE: + el.title = "Active \u2014 click to deactivate"; + el.textContent = "\u2713 Active"; + break; + case ModState.INACTIVE: + el.title = "Inactive \u2014 click to activate"; + el.textContent = "\u2298 Inactive"; + break; + case ModState.CHECKED: + el.title = "In download queue \u2014 click to remove"; + el.textContent = "\u2713 In Queue"; + break; + default: + el.title = el.dataset.rimsortTitle ?? "Add to download queue"; + el.textContent = el.dataset.rimsortLabel || "Add to Queue"; + break; + } + } else { + switch (state) { + case ModState.ACTIVE: + el.title = "Active \u2014 click to deactivate"; + el.textContent = "\u2713"; + break; + case ModState.INACTIVE: + el.title = "Inactive \u2014 click to activate"; + el.textContent = "\u2298"; + break; + case ModState.CHECKED: + el.title = "In download queue \u2014 click to remove"; + el.textContent = "\u2212"; + break; + default: + el.title = "Add to download queue"; + el.textContent = "+"; + break; + } + } +} + +export async function performModAction( + modId: string, + getTitle: () => string, + opts?: { skipDeps?: boolean }, +): Promise { + const state = getModState(modId); + + switch (state) { + case ModState.ACTIVE: + window.__pxmodrim.activeIds.delete(modId); + try { + await PxModRimAPI.toggleActive(modId, false); + } catch (e) { + window.__pxmodrim.activeIds.add(modId); + throw e; + } + break; + case ModState.INACTIVE: + window.__pxmodrim.activeIds.add(modId); + try { + await PxModRimAPI.toggleActive(modId, true); + } catch (e) { + window.__pxmodrim.activeIds.delete(modId); + throw e; + } + break; + case ModState.CHECKED: + window.__pxmodrim.checkedIds.delete(modId); + try { + await PxModRimAPI.toggleDownloadChecked(modId, "", false); + } catch (e) { + window.__pxmodrim.checkedIds.add(modId); + throw e; + } + break; + case ModState.DEFAULT: { + const title = getTitle(); + const addedChecked = new Set(); + addedChecked.add(modId); + window.__pxmodrim.checkedIds.add(modId); + try { + if (opts?.skipDeps) { + await PxModRimAPI.toggleDownloadChecked(modId, title, true); + } else { + const toggles: { id: string; title: string }[] = [{ id: modId, title }]; + const tree = await getDepsFor(modId); + if (tree) { + for (const dep of flattenDepTree(tree, new Set([modId]))) { + addedChecked.add(dep.id); + window.__pxmodrim.checkedIds.add(dep.id); + toggles.push(dep); + } + } else { + warn(`Failed to resolve deps for ${modId}`); + } + await PxModRimAPI.batchToggleDownloadChecked( + toggles.map((m) => m.id), + toggles.map((m) => m.title), + true, + ); + } + } catch (e) { + for (const id of addedChecked) window.__pxmodrim.checkedIds.delete(id); + throw e; + } + break; + } + case ModState.RESOLVING: + break; + } +} + +export async function handleClick( + el: HTMLElement, + modId: string, + getTitle: () => string, + opts?: { skipDeps?: boolean }, +): Promise { + const state = getModState(modId); + if (state === ModState.RESOLVING) return; + + el.classList.add("pressed"); + requestAnimationFrame(() => el.classList.remove("pressed")); + + if (state === ModState.DEFAULT) { + applyModState(el, ModState.RESOLVING); + } + + try { + await performModAction(modId, getTitle, opts); + } finally { + applyModState(el, getModState(modId)); + } +} diff --git a/src/ui/plugins/steam_workshop/ts/deps.ts b/src/ui/plugins/steam_workshop/ts/deps.ts new file mode 100644 index 0000000..37a30e4 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/deps.ts @@ -0,0 +1,216 @@ +import type { DepNode, DepItemsResult } from "./types"; +import { Config } from "./types"; +import { log, warn, parseHTML, scrapeDepsFromContainer } from "./utils"; +import { PxModRimAPI } from "./api"; + +export const DepState: { + cache: Map; + fetching: Set; + aborters: Map; +} = { + cache: new Map(), + fetching: new Set(), + aborters: new Map(), +}; + +const _resolveTreeLocks = new Set(); +const _fetchResolvers = new Map void>>(); + +function convertItemsToTree( + items: Record, + rootId: string, + maxDepth = Config.DEPTH_MAX, +): DepNode { + function buildNode( + id: string, + depth: number, + seen: Set, + ): DepNode { + if (seen.has(id)) { + return { id, title: items[id]?.title || `Mod ${id}`, deps: [] }; + } + if (depth >= maxDepth) { + const item = items[id]; + if (!item) return { id, title: `Mod ${id}`, deps: [] }; + return { id: item.id, title: item.title || `Mod ${id}`, deps: [] }; + } + seen.add(id); + const item = items[id]; + if (!item) return { id, title: `Mod ${id}`, deps: [] }; + return { + id: item.id, + title: item.title || `Mod ${id}`, + deps: (item.deps || []).map((depId) => + buildNode(depId, depth + 1, new Set(seen)), + ), + }; + } + return buildNode(rootId, 0, new Set()); +} + +const apiStrategy = { + name: "api", + async fetch(modId: string): Promise { + try { + const result = await PxModRimAPI.fetchModDeps(modId); + if (!result || !result.items || !result.rootId) { + warn(`apiStrategy: invalid response for ${modId}`, result); + return null; + } + if (!result.isComplete) { + warn(`apiStrategy: incomplete for ${modId}`); + return null; + } + log(`apiStrategy: complete for ${modId}`); + return convertItemsToTree(result.items, result.rootId); + } catch (e) { + warn(`apiStrategy: failed for ${modId}:`, e); + return null; + } + }, +}; + +const domStrategy = { + name: "dom", + async fetch(modId: string): Promise { + log(`domStrategy: building tree for ${modId}`); + return await buildDomDepTree(modId, 0, new Set()); + }, +}; + +async function buildDomDepTree( + modId: string, + depth: number, + seen: Set, +): Promise { + if (depth >= Config.DEPTH_MAX) { + log(`buildDomDepTree depth limit for ${modId}`); + return null; + } + if (seen.has(modId)) { + log(`buildDomDepTree circular for ${modId}`); + return null; + } + seen.add(modId); + + let tree = DepState.cache.get(modId); + if (tree) { + log(`buildDomDepTree cache hit for ${modId}`); + return tree; + } + + if (DepState.fetching.has(modId)) { + log(`buildDomDepTree waiting for ${modId}`); + await new Promise((resolve) => { + const resolvers = _fetchResolvers.get(modId) || []; + resolvers.push(resolve); + _fetchResolvers.set(modId, resolvers); + }); + tree = DepState.cache.get(modId); + if (tree) return tree; + } + + DepState.fetching.add(modId); + const controller = new AbortController(); + DepState.aborters.set(modId, controller); + try { + const resp = await fetch( + `https://steamcommunity.com/sharedfiles/filedetails/?id=${modId}`, + { signal: controller.signal }, + ); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const html = await resp.text(); + const doc = parseHTML(html); + + const titleEl = doc.querySelector(".workshopItemTitle"); + const title = titleEl + ? titleEl.textContent?.trim() ?? `Mod ${modId}` + : `Mod ${modId}`; + + const container = doc.getElementById("RequiredItems"); + const deps = container ? scrapeDepsFromContainer(container) : []; + + tree = { id: modId, title, deps: [] }; + for (const dep of deps) { + const child = await buildDomDepTree(dep.id, depth + 1, new Set(seen)); + if (child) tree.deps.push(child); + else tree.deps.push({ id: dep.id, title: dep.title, deps: [] }); + } + + DepState.cache.set(modId, tree); + return tree; + } catch (e) { + if ((e as Error).name === "AbortError") return null; + warn(`DOM dep fetch failed for ${modId}:`, e); + return null; + } finally { + DepState.fetching.delete(modId); + DepState.aborters.delete(modId); + const resolvers = _fetchResolvers.get(modId); + if (resolvers) { + _fetchResolvers.delete(modId); + resolvers.forEach((r) => r()); + } + } +} + +export async function getDepsFor(modId: string): Promise { + const cached = DepState.cache.get(modId); + if (cached) { + log(`getDepsFor cache hit for ${modId}`); + return cached; + } + + if (_resolveTreeLocks.has(modId)) { + log(`getDepsFor waiting for concurrent fetch of ${modId}`); + while (_resolveTreeLocks.has(modId)) { + await new Promise((r) => setTimeout(r, 50)); + } + return DepState.cache.get(modId) || null; + } + + _resolveTreeLocks.add(modId); + try { + for (const strategy of [apiStrategy, domStrategy]) { + try { + log(`getDepsFor trying ${strategy.name} for ${modId}`); + const tree = await strategy.fetch(modId); + if (tree) { + log(`getDepsFor ${strategy.name} succeeded for ${modId}`); + DepState.cache.set(modId, tree); + return tree; + } + log(`getDepsFor ${strategy.name} returned null for ${modId}`); + } catch (e) { + warn(`${strategy.name} strategy failed for ${modId}:`, e); + } + } + warn(`getDepsFor all strategies failed for ${modId}`); + return null; + } finally { + _resolveTreeLocks.delete(modId); + } +} + +export function flattenDepTree( + node: DepNode, + seen: Set, +): { id: string; title: string }[] { + const result: { id: string; title: string }[] = []; + if (!node.deps) return result; + for (const dep of node.deps) { + if (seen.has(dep.id)) continue; + if (window.__pxmodrim.installedIds.has(dep.id)) continue; + if (window.__pxmodrim.checkedIds.has(dep.id)) continue; + seen.add(dep.id); + result.push({ id: dep.id, title: dep.title }); + result.push(...flattenDepTree(dep, new Set(seen))); + } + return result; +} + +export function cancelPendingDepFetches(): void { + DepState.aborters.forEach((controller) => controller.abort()); + DepState.aborters.clear(); + DepState.fetching.clear(); +} diff --git a/src/ui/plugins/steam_workshop/ts/detail.ts b/src/ui/plugins/steam_workshop/ts/detail.ts new file mode 100644 index 0000000..b5e0766 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/detail.ts @@ -0,0 +1,237 @@ +import { ModState, Config } from "./types"; +import { log, warn } from "./utils"; +import { getModState, applyModState, handleClick, performModAction } from "./controls"; +import { getDepsFor } from "./deps"; +import { refreshAllDepsBadges } from "./badges"; +import { + waitForBridgeData, + isBridgeDataReady, +} from "./bridge"; +import { buildDepSection } from "./rendering"; + +export const DetailState: { + injectedUrl: string | null; + depsInjected: boolean; +} = { + injectedUrl: null, + depsInjected: false, +}; + +let _depsLoading = false; + +function updateSoloLinkVisibility(): void { + const soloLink = document.getElementById(Config.DEP_SOLO_LINK_ID); + const requiredContainer = document.getElementById("RequiredItems"); + const btn = document.getElementById("pxmodrim-subscribe-btn"); + if (!soloLink) return; + const isInstalled = btn?.classList.contains("rimsort-mod-active") || btn?.classList.contains("rimsort-mod-inactive"); + const isChecked = btn?.classList.contains("rimsort-mod-checked"); + if (requiredContainer && !isInstalled && !isChecked) { + soloLink.classList.remove("hidden"); + } else { + soloLink.classList.add("hidden"); + } +} + +function finalizeButton( + btn: HTMLElement, + container: HTMLElement, + modId: string, + title: string, + hasDeps: boolean, +): void { + btn.id = "pxmodrim-subscribe-btn"; + btn.className = "rimsort-detail-btn"; + btn.style.textAlign = "center"; + + btn.dataset.rimsortVariant = "detail"; + btn.dataset.rimsortLabel = hasDeps ? "Add to Queue (with deps)" : "Add to Queue"; + btn.dataset.rimsortTitle = "Add to download queue"; + + function updateButtonVisuals(): void { + applyModState(btn, getModState(modId)); + } + + btn.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + + (async () => { + await handleClick(btn, modId, () => title); + updateButtonVisuals(); + updateSoloLinkVisibility(); + })(); + }); + + updateButtonVisuals(); + + if (hasDeps) { + const soloLink = document.createElement("a"); + soloLink.id = Config.DEP_SOLO_LINK_ID; + soloLink.className = "rimsort-solo-link"; + soloLink.textContent = "Queue only this mod"; + soloLink.href = "#"; + soloLink.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + (async () => { + await performModAction(modId, () => title, { skipDeps: true }); + updateButtonVisuals(); + updateSoloLinkVisibility(); + })(); + }); + container.appendChild(soloLink); + updateSoloLinkVisibility(); + } +} + +function createLoadingPlaceholder( + loadingFactory: () => HTMLElement, + afterLoadedFactory: ( + loadingEl: HTMLElement, + container: HTMLElement, + ) => void, + event: () => Promise, +): HTMLElement { + const loadingEl = loadingFactory(); + const container = document.createElement("div"); + container.id = "pxmodrim-loading-container"; + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.gap = "8px"; + container.appendChild(loadingEl); + + event().then( + () => { + if (!document.contains(container)) return; + afterLoadedFactory(loadingEl, container); + }, + () => { + if (!document.contains(container)) return; + warn("Bridge data timeout \u2014 showing error state"); + loadingEl.className = "rimsort-error-btn"; + loadingEl.textContent = "ERROR"; + }, + ); + + return container; +} + +export async function createDetailButton(): Promise { + if ((createDetailButton as Record)._running) return; + (createDetailButton as Record)._running = true; + try { + log("createDetailButton ENTER url=" + window.location.href); + const currentUrl = window.location.href; + if (DetailState.injectedUrl !== currentUrl) { + DetailState.injectedUrl = currentUrl; + DetailState.depsInjected = false; + document.getElementById("pxmodrim-subscribe-btn")?.remove(); + document.getElementById(Config.DEP_SECTION_ID)?.remove(); + document.getElementById("pxmodrim-loading-container")?.remove(); + } + + let btn = document.getElementById( + "pxmodrim-subscribe-btn", + ) as HTMLAnchorElement | null; + let container: HTMLElement | null = null; + + if (btn) { + const modId = ( + window.location.href.match(/[?&]id=(\d+)/) || [] + )[1]; + if (modId) { + applyModState(btn, getModState(modId)); + } + updateSoloLinkVisibility(); + container = + btn.closest("div[style*='flex']") || btn.parentElement; + } else if (document.getElementById("pxmodrim-loading-container")) { + return; + } else { + const steamBtn = document.getElementById("SubscribeItemBtn"); + if (!steamBtn) return; + + const modId = ( + window.location.href.match(/[?&]id=(\d+)/) || [] + )[1]; + if (!modId) return; + + const h1 = document.querySelector( + ".game_area_purchase_game h1", + ); + const title = h1 + ? h1.textContent + ?.replace(/Subscribe to download\s*/i, "") + .trim() || "" + : ""; + const hasDeps = !!document.getElementById("RequiredItems"); + + if (isBridgeDataReady()) { + container = document.createElement("div"); + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.gap = "8px"; + btn = document.createElement("a"); + container.appendChild(btn); + finalizeButton(btn, container, modId, title, hasDeps); + steamBtn.replaceWith(container); + } else { + container = createLoadingPlaceholder( + () => { + const el = document.createElement("a"); + el.className = "rimsort-loading-btn"; + el.textContent = "Loading..."; + return el; + }, + (loadingEl: HTMLElement, ctr: HTMLElement) => { + finalizeButton(loadingEl, ctr, modId, title, hasDeps); + createDetailButton(); + }, + () => waitForBridgeData(10000), + ); + steamBtn.replaceWith(container); + return; + } + } + + if (!document.getElementById("RequiredItems")) return; + + const modId = ( + window.location.href.match(/[?&]id=(\d+)/) || [] + )[1]; + if (!modId) return; + + if (document.getElementById(Config.DEP_SECTION_ID)) { + refreshAllDepsBadges(); + updateSoloLinkVisibility(); + return; + } + + if (_depsLoading) return; + _depsLoading = true; + const loading = document.createElement("div"); + loading.className = "rimsort-dep-loading-container"; + loading.innerHTML = + '
    Resolving dependencies...'; + container?.insertAdjacentElement("afterend", loading); + + try { + const tree = await getDepsFor(modId); + + loading.remove(); + const section = buildDepSection(tree); + if (section && container) { + container.insertAdjacentElement("afterend", section); + DetailState.depsInjected = true; + } + updateSoloLinkVisibility(); + } finally { + _depsLoading = false; + } + } finally { + (createDetailButton as Record)._running = false; + } +} + + diff --git a/src/ui/plugins/steam_workshop/ts/globals.d.ts b/src/ui/plugins/steam_workshop/ts/globals.d.ts new file mode 100644 index 0000000..f292860 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/globals.d.ts @@ -0,0 +1,24 @@ +declare const CSS_STYLES: string; +declare const qt: { webChannelTransport: any }; +declare class QWebChannel { + constructor(transport: any, initCallback: (channel: any) => void); +} + +interface PxmodrimState { + installedIds: Set; + checkedIds: Set; + activeIds: Set; + onStateChange: (() => void) | null; +} + +interface Window { + __pxmodrim: PxmodrimState; + __pxmodrimInited?: boolean; + QWebChannel: typeof QWebChannel; + updateModBadge(modId: string): void; + updateAllModBadges(): void; + __pxmSetInstalled(modIds: string[]): void; + __pxmSetActive(modIds: string[]): void; + __pxmUncheckMod(modId: string): void; + __pxmClearChecked(): void; +} diff --git a/src/ui/plugins/steam_workshop/ts/lifecycle.ts b/src/ui/plugins/steam_workshop/ts/lifecycle.ts new file mode 100644 index 0000000..cd73175 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/lifecycle.ts @@ -0,0 +1,230 @@ +import { Config } from "./types"; +import { log, findItemCard } from "./utils"; +import { + refreshAllDepsBadges, + scheduleBadgeUpdate, + updateAllModBadges, +} from "./badges"; +import { createDetailButton, DetailState } from "./detail"; +import { initCollectionPage, updateCollectionBadges } from "./collection"; +import { setBridgeDataReady } from "./bridge"; +import { PxModRimAPI } from "./api"; + +type PageKindKey = "collection" | "mod-details" | "grid"; + +interface PageKind { + init(): void; + onMutation(mutations: MutationRecord[]): void; +} + +let _pageKind: PageKindKey | null = null; + +function handleGridMutations(mutations: MutationRecord[]): void { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as Element; + const link = el.querySelector('a[href*="sharedfiles/filedetails/?id="]'); + if (!link || !el.querySelector("img")) continue; + if (!findItemCard(link)) continue; + scheduleBadgeUpdate(); + return; + } + } +} + +function handleCollectionMutations(mutations: MutationRecord[]): void { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as Element; + if (el.matches?.(".collectionItem") || el.querySelector?.(".collectionItem")) { + updateCollectionBadges(); + return; + } + } + } +} + +function handleModDetailMutations(mutations: MutationRecord[]): void { + const RELEVANT_IDS = new Set([ + "SubscribeItemBtn", + "RequiredItems", + "pxmodrim-subscribe-btn", + Config.DEP_SECTION_ID, + "pxmodrim-loading-container", + "pxmodrim-solo-link", + ]); + const hasRelevant = mutations.some((m) => { + if (m.type === "childList") { + for (const list of [m.addedNodes, m.removedNodes]) { + for (const node of list) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as Element; + if (RELEVANT_IDS.has(el.id)) return true; + if (el.querySelector?.("[id]")) { + const nested = el.querySelector( + "#SubscribeItemBtn,#RequiredItems,#pxmodrim-subscribe-btn,#" + + CSS.escape(Config.DEP_SECTION_ID), + ); + if (nested) return true; + } + } + } + } + if (m.type === "attributes" && RELEVANT_IDS.has((m.target as Element).id)) return true; + return false; + }); + if (!hasRelevant) return; + + const currentUrl = window.location.href; + if (document.getElementById("SubscribeItemBtn") && !document.getElementById("pxmodrim-subscribe-btn")) { + createDetailButton(); + return; + } + if (DetailState.injectedUrl !== currentUrl) return; + if ( + document.getElementById("RequiredItems") && + !document.getElementById(Config.DEP_SECTION_ID) && + !DetailState.depsInjected + ) { + createDetailButton(); + } +} + +const PAGE_KINDS: Record = { + collection: { + init: initCollectionPage, + onMutation: handleCollectionMutations, + }, + "mod-details": { + init: createDetailButton, + onMutation: handleModDetailMutations, + }, + grid: { + init: updateAllModBadges, + onMutation: handleGridMutations, + }, +}; + +function resolveKind(mutations: MutationRecord[]): PageKindKey | null { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as Element; + if (el.matches?.(".collectionChildren") || el.querySelector?.(".collectionChildren")) { + return "collection"; + } + if (el.matches?.("#SubscribeItemBtn") || el.querySelector?.("#SubscribeItemBtn")) { + return "mod-details"; + } + } + } + return null; +} + +function activateKind(kind: PageKindKey): void { + _pageKind = kind; + PAGE_KINDS[kind].init(); +} + +function runDomCleanup() { + const footer = document.getElementById("footer"); + footer + ?.querySelectorAll("[style*='--grid-area']") + .forEach((el) => { + const area = el.style.getPropertyValue("--grid-area"); + if (area && area !== "main") el.remove(); + }); +} + +function injectStyles() { + const style = document.createElement("style"); + style.id = "pxmodrim-styles"; + style.textContent = CSS_STYLES; + document.documentElement.appendChild(style); +} + +function initObservers() { + log("initObservers START"); + const isSharedFile = window.location.href.startsWith( + "https://steamcommunity.com/sharedfiles/filedetails/?id=", + ); + if (!isSharedFile) { + activateKind("grid"); + log("initObservers grid page"); + } + + const mainObserver = new MutationObserver((mutations) => { + if (!_pageKind) { + const kind = resolveKind(mutations); + if (kind) { + activateKind(kind); + log("initObservers resolved pageKind=%s", kind); + } + } + if (_pageKind) PAGE_KINDS[_pageKind].onMutation(mutations); + runDomCleanup(); + }); + + mainObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); + log("initObservers OBSERVER ATTACHED"); + + runDomCleanup(); + + if (!_pageKind) { + if (document.querySelector(".collectionChildren")) { + activateKind("collection"); + log("initObservers immediate: collection"); + } else if (document.querySelector("#SubscribeItemBtn")) { + activateKind("mod-details"); + log("initObservers immediate: mod-details"); + } + } + + log("initObservers DONE pageKind=%s", _pageKind); +} + +function initState() { + log("initState ENTER"); + log( + "initState installed=" + + window.__pxmodrim.installedIds.size + + " checked=" + + window.__pxmodrim.checkedIds.size, + ); + + setBridgeDataReady(); + if (_pageKind) PAGE_KINDS[_pageKind].init(); + refreshAllDepsBadges(); + PxModRimAPI.initReady(); + log("initState DONE"); +} + +function waitForRoot() { + const rootWaiter = new MutationObserver(() => { + if (document.documentElement) { + rootWaiter.disconnect(); + startScript(); + } + }); + rootWaiter.observe(document, { childList: true }); +} + +function startScript() { + log("startScript ENTER"); + injectStyles(); + log("Base styles injected at Document Creation. Starting lifecycle..."); + initObservers(); + initState(); + log("startScript DONE"); +} + +export function isPageKind(kind: PageKindKey): boolean { + return _pageKind === kind; +} + +export { startScript, waitForRoot }; diff --git a/src/ui/plugins/steam_workshop/ts/main.ts b/src/ui/plugins/steam_workshop/ts/main.ts new file mode 100644 index 0000000..2000136 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/main.ts @@ -0,0 +1,37 @@ +import { startScript, waitForRoot } from "./lifecycle"; +import { + __pxmSetInstalled, + __pxmSetActive, + __pxmUncheckMod, + __pxmClearChecked, + initRPC, +} from "./bridge"; +import { initAPI } from "./api"; +import { updateAllModBadges, updateModBadge } from "./badges"; + +if (!window.__pxmodrimInited) { + window.__pxmodrimInited = true; + + window.__pxmSetInstalled = __pxmSetInstalled; + window.__pxmSetActive = __pxmSetActive; + window.__pxmUncheckMod = __pxmUncheckMod; + window.__pxmClearChecked = __pxmClearChecked; + window.updateAllModBadges = updateAllModBadges; + window.updateModBadge = updateModBadge; + + (async () => { + try { + const rpc = await initRPC(); + initAPI(rpc); + } catch (e) { + console.error("[pxmodrim] QWebChannel init failed:", e); + return; + } + + if (document.documentElement) { + startScript(); + } else { + waitForRoot(); + } + })(); +} diff --git a/src/ui/plugins/steam_workshop/ts/qwebchannel.js b/src/ui/plugins/steam_workshop/ts/qwebchannel.js new file mode 100644 index 0000000..05716d6 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/qwebchannel.js @@ -0,0 +1,457 @@ +// Copyright (C) 2016 The Qt Company Ltd. +// Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +"use strict"; + +var QWebChannelMessageTypes = { + signal: 1, + propertyUpdate: 2, + init: 3, + idle: 4, + debug: 5, + invokeMethod: 6, + connectToSignal: 7, + disconnectFromSignal: 8, + setProperty: 9, + response: 10, +}; + +var QWebChannel = function(transport, initCallback, converters) +{ + if (typeof transport !== "object" || typeof transport.send !== "function") { + console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + + " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send)); + return; + } + + var channel = this; + this.transport = transport; + + var converterRegistry = + { + Date : function(response) { + if (typeof response === "string" + && response.match( + /^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+\u2212](\d{2}):(\d{2})|Z)?$/)) { + var date = new Date(response); + if (!isNaN(date)) + return date; + } + return undefined; // Return undefined if current converter is not applicable + } + }; + + this.usedConverters = []; + + this.addConverter = function(converter) + { + if (typeof converter === "string") { + if (converterRegistry.hasOwnProperty(converter)) + this.usedConverters.push(converterRegistry[converter]); + else + console.error("Converter '" + converter + "' not found"); + } else if (typeof converter === "function") { + this.usedConverters.push(converter); + } else { + console.error("Invalid converter object type " + typeof converter); + } + } + + if (Array.isArray(converters)) { + for (const converter of converters) + this.addConverter(converter); + } else if (converters !== undefined) { + this.addConverter(converters); + } + + this.send = function(data) + { + if (typeof(data) !== "string") { + data = JSON.stringify(data); + } + channel.transport.send(data); + } + + this.transport.onmessage = function(message) + { + var data = message.data; + if (typeof data === "string") { + data = JSON.parse(data); + } + switch (data.type) { + case QWebChannelMessageTypes.signal: + channel.handleSignal(data); + break; + case QWebChannelMessageTypes.response: + channel.handleResponse(data); + break; + case QWebChannelMessageTypes.propertyUpdate: + channel.handlePropertyUpdate(data); + break; + default: + console.error("invalid message received:", message.data); + break; + } + } + + this.execCallbacks = {}; + this.execId = 0; + this.exec = function(data, callback) + { + if (!callback) { + // if no callback is given, send directly + channel.send(data); + return; + } + if (channel.execId === Number.MAX_VALUE) { + // wrap + channel.execId = Number.MIN_VALUE; + } + if (data.hasOwnProperty("id")) { + console.error("Cannot exec message with property id: " + JSON.stringify(data)); + return; + } + data.id = channel.execId++; + channel.execCallbacks[data.id] = callback; + channel.send(data); + }; + + this.objects = {}; + + this.handleSignal = function(message) + { + var object = channel.objects[message.object]; + if (object) { + object.signalEmitted(message.signal, message.args); + } else { + console.warn("Unhandled signal: " + message.object + "::" + message.signal); + } + } + + this.handleResponse = function(message) + { + if (!message.hasOwnProperty("id")) { + console.error("Invalid response message received: ", JSON.stringify(message)); + return; + } + channel.execCallbacks[message.id](message.data); + delete channel.execCallbacks[message.id]; + } + + this.handlePropertyUpdate = function(message) + { + message.data.forEach(data => { + var object = channel.objects[data.object]; + if (object) { + object.propertyUpdate(data.signals, data.properties); + } else { + console.warn("Unhandled property update: " + data.object + "::" + data.signal); + } + }); + channel.exec({type: QWebChannelMessageTypes.idle}); + } + + this.debug = function(message) + { + channel.send({type: QWebChannelMessageTypes.debug, data: message}); + }; + + channel.exec({type: QWebChannelMessageTypes.init}, function(data) { + for (const objectName of Object.keys(data)) { + new QObject(objectName, data[objectName], channel); + } + + // now unwrap properties, which might reference other registered objects + for (const objectName of Object.keys(channel.objects)) { + channel.objects[objectName].unwrapProperties(); + } + + if (initCallback) { + initCallback(channel); + } + channel.exec({type: QWebChannelMessageTypes.idle}); + }); +}; + +function QObject(name, data, webChannel) +{ + this.__id__ = name; + webChannel.objects[name] = this; + + // List of callbacks that get invoked upon signal emission + this.__objectSignals__ = {}; + + // Cache of all properties, updated when a notify signal is emitted + this.__propertyCache__ = {}; + + var object = this; + + // ---------------------------------------------------------------------- + + this.unwrapQObject = function(response) + { + for (const converter of webChannel.usedConverters) { + var result = converter(response); + if (result !== undefined) + return result; + } + + if (response instanceof Array) { + // support list of objects + return response.map(qobj => object.unwrapQObject(qobj)) + } + if (!(response instanceof Object)) + return response; + + if (!response["__QObject*__"] || response.id === undefined) { + var jObj = {}; + for (const propName of Object.keys(response)) { + jObj[propName] = object.unwrapQObject(response[propName]); + } + return jObj; + } + + var objectId = response.id; + if (webChannel.objects[objectId]) + return webChannel.objects[objectId]; + + if (!response.data) { + console.error("Cannot unwrap unknown QObject " + objectId + " without data."); + return; + } + + var qObject = new QObject( objectId, response.data, webChannel ); + qObject.destroyed.connect(function() { + if (webChannel.objects[objectId] === qObject) { + delete webChannel.objects[objectId]; + // reset the now deleted QObject to an empty {} object + // just assigning {} though would not have the desired effect, but the + // below also ensures all external references will see the empty map + // NOTE: this detour is necessary to workaround QTBUG-40021 + Object.keys(qObject).forEach(name => delete qObject[name]); + } + }); + // here we are already initialized, and thus must directly unwrap the properties + qObject.unwrapProperties(); + return qObject; + } + + this.unwrapProperties = function() + { + for (const propertyIdx of Object.keys(object.__propertyCache__)) { + object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]); + } + } + + function addSignal(signalData, isPropertyNotifySignal) + { + var signalName = signalData[0]; + var signalIndex = signalData[1]; + object[signalName] = { + connect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to connect to signal " + signalName); + return; + } + + object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || []; + object.__objectSignals__[signalIndex].push(callback); + + // only required for "pure" signals, handled separately for properties in propertyUpdate + if (isPropertyNotifySignal) + return; + + // also note that we always get notified about the destroyed signal + if (signalName === "destroyed" || signalName === "destroyed()" || signalName === "destroyed(QObject*)") + return; + + // and otherwise we only need to be connected only once + if (object.__objectSignals__[signalIndex].length == 1) { + webChannel.exec({ + type: QWebChannelMessageTypes.connectToSignal, + object: object.__id__, + signal: signalIndex + }); + } + }, + disconnect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to disconnect from signal " + signalName); + return; + } + // This makes a new list. This is important because it won't interfere with + // signal processing if a disconnection happens while emittig a signal + object.__objectSignals__[signalIndex] = (object.__objectSignals__[signalIndex] || []).filter(function(c) { + return c != callback; + }); + if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) { + // only required for "pure" signals, handled separately for properties in propertyUpdate + webChannel.exec({ + type: QWebChannelMessageTypes.disconnectFromSignal, + object: object.__id__, + signal: signalIndex + }); + } + } + }; + } + + /** + * Invokes all callbacks for the given signalname. Also works for property notify callbacks. + */ + function invokeSignalCallbacks(signalName, signalArgs) + { + var connections = object.__objectSignals__[signalName]; + if (connections) { + connections.forEach(function(callback) { + callback.apply(callback, signalArgs); + }); + } + } + + this.propertyUpdate = function(signals, propertyMap) + { + // update property cache + for (const propertyIndex of Object.keys(propertyMap)) { + var propertyValue = propertyMap[propertyIndex]; + object.__propertyCache__[propertyIndex] = this.unwrapQObject(propertyValue); + } + + for (const signalName of Object.keys(signals)) { + // Invoke all callbacks, as signalEmitted() does not. This ensures the + // property cache is updated before the callbacks are invoked. + invokeSignalCallbacks(signalName, signals[signalName]); + } + } + + this.signalEmitted = function(signalName, signalArgs) + { + invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs)); + } + + function addMethod(methodData) + { + var methodName = methodData[0]; + var methodIdx = methodData[1]; + + // Fully specified methods are invoked by id, others by name for host-side overload resolution + var invokedMethod = methodName[methodName.length - 1] === ')' ? methodIdx : methodName + + object[methodName] = function() { + var args = []; + var callback; + var errCallback; + for (var i = 0; i < arguments.length; ++i) { + var argument = arguments[i]; + if (typeof argument === "function") + callback = argument; + else + args.push(argument); + } + + var result; + // during test, webChannel.exec synchronously calls the callback + // therefore, the promise must be constucted before calling + // webChannel.exec to ensure the callback is set up + if (!callback && (typeof(Promise) === 'function')) { + result = new Promise(function(resolve, reject) { + callback = resolve; + errCallback = reject; + }); + } + + webChannel.exec({ + "type": QWebChannelMessageTypes.invokeMethod, + "object": object.__id__, + "method": invokedMethod, + "args": args + }, function(response) { + if (response !== undefined) { + var result = object.unwrapQObject(response); + if (callback) { + (callback)(result); + } + } else if (errCallback) { + (errCallback)(); + } + }); + + return result; + }; + } + + function bindGetterSetter(propertyInfo) + { + var propertyIndex = propertyInfo[0]; + var propertyName = propertyInfo[1]; + var notifySignalData = propertyInfo[2]; + // initialize property cache with current value + // NOTE: if this is an object, it is not directly unwrapped as it might + // reference other QObject that we do not know yet + object.__propertyCache__[propertyIndex] = propertyInfo[3]; + + if (notifySignalData) { + if (notifySignalData[0] === 1) { + // signal name is optimized away, reconstruct the actual name + notifySignalData[0] = propertyName + "Changed"; + } + addSignal(notifySignalData, true); + } + + Object.defineProperty(object, propertyName, { + configurable: true, + get: function () { + var propertyValue = object.__propertyCache__[propertyIndex]; + if (propertyValue === undefined) { + // This shouldn't happen + console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__); + } + + return propertyValue; + }, + set: function(value) { + if (value === undefined) { + console.warn("Property setter for " + propertyName + " called with undefined value!"); + return; + } + object.__propertyCache__[propertyIndex] = value; + var valueToSend = value; + webChannel.exec({ + "type": QWebChannelMessageTypes.setProperty, + "object": object.__id__, + "property": propertyIndex, + "value": valueToSend + }); + } + }); + + } + + // ---------------------------------------------------------------------- + + data.methods.forEach(addMethod); + + data.properties.forEach(bindGetterSetter); + + data.signals.forEach(function(signal) { addSignal(signal, false); }); + + Object.assign(object, data.enums); +} + +QObject.prototype.toJSON = function() { + if (this.__id__ === undefined) return {}; + return { + id: this.__id__, + "__QObject*__": true + }; +}; + +//required for use with nodejs +if (typeof module === 'object') { + module.exports = { + QWebChannel: QWebChannel + }; +} + +window.QWebChannel = QWebChannel; diff --git a/src/ui/plugins/steam_workshop/ts/rendering.ts b/src/ui/plugins/steam_workshop/ts/rendering.ts new file mode 100644 index 0000000..955373a --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/rendering.ts @@ -0,0 +1,165 @@ +import { Config } from "./types"; +import type { DepNode } from "./types"; +import { scrapeDepsFromContainer } from "./utils"; +import { getModState, applyModState, handleClick } from "./controls"; + +export function buildDepSection( + tree: DepNode | null, +): HTMLDivElement | null { + const section = document.createElement("div"); + section.id = Config.DEP_SECTION_ID; + section.className = "rimsort-deps-section"; + + if (tree && tree.deps?.length) { + const header = document.createElement("div"); + header.className = "rimsort-deps-header"; + header.textContent = `Dependencies (${tree.deps.length}):`; + section.appendChild(header); + const list = document.createElement("div"); + list.className = "rimsort-dep-list"; + renderDepTree(list, tree.deps, 0, new Set()); + section.appendChild(list); + return section; + } + + if (tree) { + const header = document.createElement("div"); + header.className = "rimsort-deps-header"; + header.textContent = "No dependencies found"; + section.appendChild(header); + const list = document.createElement("div"); + list.className = "rimsort-dep-list"; + const empty = document.createElement("div"); + empty.className = "rimsort-dep-empty"; + empty.textContent = "This mod has no required items"; + empty.style.padding = "8px"; + empty.style.color = "#888"; + list.appendChild(empty); + section.appendChild(list); + return section; + } + + const required = document.getElementById("RequiredItems"); + if (!required) return null; + const deps = scrapeDepsFromContainer(required); + const header = document.createElement("div"); + header.className = "rimsort-deps-header"; + header.textContent = deps.length + ? `Dependencies (${deps.length}):` + : "No dependencies found"; + section.appendChild(header); + const list = document.createElement("div"); + list.className = "rimsort-dep-list"; + if (deps.length) { + deps.forEach((d) => { + const node = createDepNode({ id: d.id, title: d.title, deps: [] }, 0); + list.appendChild(node); + }); + } else { + const empty = document.createElement("div"); + empty.className = "rimsort-dep-empty"; + empty.textContent = "This mod has no required items"; + empty.style.padding = "8px"; + empty.style.color = "#888"; + list.appendChild(empty); + } + section.appendChild(list); + return section; +} + +function createDepNode(dep: DepNode, depth: number): HTMLDivElement { + const node = document.createElement("div"); + node.className = "rimsort-dep-node"; + node.style.paddingLeft = `${depth * 16}px`; + + const hasChildren = + dep.deps && dep.deps.length > 0 && depth + 1 < Config.DEPTH_MAX; + + const expand = document.createElement("span"); + expand.className = "rimsort-dep-expand"; + expand.textContent = hasChildren ? "\u25b8" : ""; + if (!hasChildren) expand.classList.add("empty"); + + expand.addEventListener("click", (e) => { + e.stopPropagation(); + if (expand.classList.contains("empty")) return; + expand.classList.toggle("expanded"); + const children = node.nextElementSibling; + if ( + children && + children.classList.contains("rimsort-dep-children") + ) { + children.style.display = expand.classList.contains("expanded") + ? "block" + : "none"; + } + }); + + const badge = document.createElement("span"); + badge.className = "rimsort-dep-badge"; + badge.dataset.modid = dep.id; + badge.title = dep.id; + applyModState(badge, getModState(dep.id)); + + badge.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + await handleClick(badge, dep.id, () => dep.title); + }); + + const titleLink = document.createElement("a"); + titleLink.className = "rimsort-dep-title"; + titleLink.href = `https://steamcommunity.com/sharedfiles/filedetails/?id=${dep.id}`; + titleLink.target = "_blank"; + titleLink.rel = "noopener noreferrer"; + titleLink.textContent = dep.title; + titleLink.title = dep.title; + + node.appendChild(expand); + node.appendChild(badge); + node.appendChild(titleLink); + return node; +} + +function renderDepTree( + container: HTMLElement, + deps: DepNode[], + depth: number, + seenIds: Set, +): void { + deps.forEach((dep) => { + if (seenIds.has(dep.id)) { + const circ = document.createElement("div"); + circ.className = "rimsort-dep-circular"; + circ.textContent = `\u21bb ${dep.title} (circular)`; + circ.style.paddingLeft = `${depth * 16}px`; + container.appendChild(circ); + return; + } + seenIds.add(dep.id); + + const node = createDepNode(dep, depth); + container.appendChild(node); + + if (dep.deps && dep.deps.length) { + if (depth + 1 >= Config.DEPTH_MAX) { + const maxDepth = document.createElement("div"); + maxDepth.className = "rimsort-dep-maxdepth"; + maxDepth.style.paddingLeft = `${(depth + 1) * 16}px`; + maxDepth.textContent = `${dep.deps.length} required item(s) (max depth ${Config.DEPTH_MAX})`; + container.appendChild(maxDepth); + } else { + const childrenContainer = document.createElement("div"); + childrenContainer.className = "rimsort-dep-children"; + childrenContainer.style.display = "none"; + renderDepTree( + childrenContainer, + dep.deps, + depth + 1, + new Set(seenIds), + ); + container.appendChild(childrenContainer); + } + } + }); +} diff --git a/src/ui/plugins/steam_workshop/ts/tsconfig.json b/src/ui/plugins/steam_workshop/ts/tsconfig.json new file mode 100644 index 0000000..dd91096 --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./**/*.ts"] +} diff --git a/src/ui/plugins/steam_workshop/ts/types.ts b/src/ui/plugins/steam_workshop/ts/types.ts new file mode 100644 index 0000000..305adea --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/types.ts @@ -0,0 +1,32 @@ +export const ModState = { + ACTIVE: "active", + INACTIVE: "inactive", + CHECKED: "checked", + DEFAULT: "default", + RESOLVING: "resolving", +} as const; + +export type ModStatus = (typeof ModState)[keyof typeof ModState]; + +export const Config = { + DEPTH_MAX: 3, + DEP_SECTION_ID: "pxmodrim-deps", + DEP_SOLO_LINK_ID: "pxmodrim-solo-link", +} as const; + +export interface ModToggle { + id: string; + title: string; +} + +export interface DepNode { + id: string; + title: string; + deps: DepNode[]; +} + +export interface DepItemsResult { + items: Record; + rootId: string; + isComplete: boolean; +} diff --git a/src/ui/plugins/steam_workshop/ts/utils.ts b/src/ui/plugins/steam_workshop/ts/utils.ts new file mode 100644 index 0000000..428dd3a --- /dev/null +++ b/src/ui/plugins/steam_workshop/ts/utils.ts @@ -0,0 +1,59 @@ +import type { ModToggle } from "./types"; + +const DEBUG = false; + +export const log = DEBUG + ? console.log.bind(console, "[pxmodrim]") + : () => {}; +export const warn = console.warn.bind(console, "[pxmodrim]"); + +export function getModId(link: HTMLAnchorElement): string | null { + const m = link.href.match(/[?&]id=(\d+)/); + return m ? m[1] : null; +} + +export function getModTitle(tile: Element): string { + const links = tile.querySelectorAll( + 'a[href*="sharedfiles/filedetails/?id="]', + ); + for (const link of links) { + const text = link.textContent?.trim(); + if (text) return text; + } + return ""; +} + +export function findItemCard(link: HTMLAnchorElement): Element | null { + let el = link.parentElement; + while (el) { + if ( + el.querySelector("img") && + el.querySelectorAll('a[href*="sharedfiles/filedetails/?id="]').length === 2 + ) { + return el; + } + el = el.parentElement; + } + return null; +} + +export function parseHTML(htmlText: string): Document { + return new DOMParser().parseFromString(htmlText, "text/html"); +} + +export function scrapeDepsFromContainer(containerEl: Element): ModToggle[] { + const deps: ModToggle[] = []; + const links = containerEl.querySelectorAll( + 'a[href*="filedetails/?id="]', + ); + links.forEach((link) => { + const match = link.href.match(/[?&]id=(\d+)/); + const modId = match ? match[1] : null; + const titleEl = link.querySelector(".requiredItem"); + const title = titleEl + ? titleEl.textContent?.trim() + : link.textContent?.trim(); + if (modId) deps.push({ id: modId, title: title || `Mod ${modId}` }); + }); + return deps; +} diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp new file mode 100644 index 0000000..85ee2cf --- /dev/null +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -0,0 +1,164 @@ +#include "ui/plugins/steam_workshop/view.h" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/plugins/steam_workshop/bridge_obj.h" +#include "ui/plugins/steam_workshop/download_sidebar.h" +#include "ui/theme/palette.h" + +namespace px::ui { +namespace { +const QUrl workshop_url(QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); + +QString json_argument(const QJsonValue& value) { + const auto json = QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); + return QString::fromUtf8(json.constData() + 1, json.size() - 2); +} + +QString injection() { + QFile js(QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.js")); + if (!js.open(QIODevice::ReadOnly)) { + spdlog::warn("Steam Workshop injection bundle is absent; browser download controls are unavailable"); + return {}; + } + QFile css(QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.css")); + QString styles; + if (css.open(QIODevice::ReadOnly)) styles = QString::fromUtf8(css.readAll()); + return QStringLiteral("var CSS_STYLES = %1;\n%2").arg(json_argument(styles), QString::fromUtf8(js.readAll())); +} +} + +SteamWorkshopViewPanel::SteamWorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app) { + auto* content = new QWidget(this); + auto* layout = new QHBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + sidebar_ = new DownloadSidebar(engine, content); + layout->addWidget(sidebar_); + qml_ = new QQuickWidget(engine, content); + qml_->setObjectName(QStringLiteral("workshopView")); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); +#ifdef PX_HAS_WEBENGINE + layout->addWidget(qml_, 1); +#else + qml_->hide(); + auto* unavailable = new QLabel(QStringLiteral("This build does not include the Steam Workshop web browser."), content); + unavailable->setAlignment(Qt::AlignCenter); + unavailable->setWordWrap(true); + layout->addWidget(unavailable, 1); +#endif + root_->addWidget(content, 1); +} + +SteamWorkshopViewPanel::~SteamWorkshopViewPanel() { teardown(); } + +void SteamWorkshopViewPanel::ensure_initialized() { + if (initialized_) return; + initialized_ = true; + plugin_ = static_cast(app_.plugins().get(QStringLiteral("steamworkshop"))); + if (plugin_) { + connect(sidebar_, &DownloadSidebar::download_requested, this, [this] { plugin_->request_download(); }); + connect(sidebar_, &DownloadSidebar::stop_requested, this, [this] { plugin_->stop_download(); }); + connect(sidebar_, &DownloadSidebar::item_removed, this, [this](const QString& id) { plugin_->remove_item(id); }); + connect(sidebar_, &DownloadSidebar::clear_requested, this, [this] { plugin_->clear_queue(); }); + badges_id_ = plugin_->badges_refresh_requested.connect([this](const QStringList& ids) { + run_js(QStringLiteral("if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)").arg(json_argument(QJsonArray::fromStringList(ids)))); + }); + active_id_ = plugin_->active_refresh_requested.connect([this](const QStringList& ids) { + run_js(QStringLiteral("if (window.__pxmSetActive) window.__pxmSetActive(%1)").arg(json_argument(QJsonArray::fromStringList(ids)))); + }); + sidebar_id_ = plugin_->sidebar_sync_requested.connect([this](const SidebarSync& sync) { sidebar_->sync_from(sync.checked_ids, sync.statuses); }); + status_id_ = plugin_->item_status_changed.connect([this](const core::DownloadItemStatus& status) { + sidebar_->update_status(status.mod_id, status.status, status.bytes_done, status.bytes_total); + }); + busy_id_ = plugin_->download_busy_changed.connect([this](bool busy) { sidebar_->set_download_enabled(!busy); }); + uncheck_id_ = plugin_->uncheck_mod_requested.connect([this](const QString& id) { + run_js(QStringLiteral("if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);").arg(json_argument(id))); + }); + clear_id_ = plugin_->clear_checked_requested.connect([this](const std::monostate&) { + run_js(QStringLiteral("if (window.__pxmClearChecked) window.__pxmClearChecked();")); + }); + sidebar_->set_download_enabled(!plugin_->is_downloading()); + plugin_->sync_all(); + } +#ifdef PX_HAS_WEBENGINE + auto* bridge = plugin_ ? new PxModRimBridge(*plugin_, qml_) : nullptr; + if (bridge) bridge->setObjectName(QStringLiteral("workshopBridge")); + auto* context = qml_->rootContext(); + context->setContextProperty(QStringLiteral("steamWorkshopPanel"), this); + context->setContextProperty(QStringLiteral("_bridge"), bridge); + context->setContextProperty(QStringLiteral("_injectCode"), injection()); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); +#endif +} + +QObject* SteamWorkshopViewPanel::web() const { +#ifdef PX_HAS_WEBENGINE + if (initialized_ && qml_->rootObject()) return qml_->rootObject()->findChild(QStringLiteral("workshopWeb")); +#endif + return nullptr; +} + +void SteamWorkshopViewPanel::run_js(const QString& code) { +#ifdef PX_HAS_WEBENGINE + if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); +#else + Q_UNUSED(code); +#endif +} + +void SteamWorkshopViewPanel::navigateHome() { + if (auto* browser = web()) browser->setProperty("url", workshop_url); +} + +void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { + if (text.isEmpty()) return; + auto url = QUrl::fromUserInput(text); + if (url.scheme() == QLatin1String("http")) url.setScheme(QStringLiteral("https")); + if (!url.isValid()) return; + if (auto* browser = web()) browser->setProperty("url", url); +} + +void SteamWorkshopViewPanel::preload() { ensure_initialized(); } +void SteamWorkshopViewPanel::showEvent(QShowEvent* event) { + BaseViewPanel::showEvent(event); + ensure_initialized(); +} + +void SteamWorkshopViewPanel::teardown() { + if (!initialized_) return; + if (auto* browser = web()) { + QMetaObject::invokeMethod(browser, "stop"); + browser->setProperty("url", QUrl(QStringLiteral("about:blank"))); + } + qml_->setSource({}); + if (auto* bridge = qml_->findChild(QStringLiteral("workshopBridge"))) delete bridge; + disconnect(sidebar_, nullptr, this, nullptr); + if (plugin_) { + plugin_->badges_refresh_requested.disconnect(badges_id_); + plugin_->active_refresh_requested.disconnect(active_id_); + plugin_->sidebar_sync_requested.disconnect(sidebar_id_); + plugin_->item_status_changed.disconnect(status_id_); + plugin_->download_busy_changed.disconnect(busy_id_); + plugin_->uncheck_mod_requested.disconnect(uncheck_id_); + plugin_->clear_checked_requested.disconnect(clear_id_); + } + plugin_ = nullptr; + initialized_ = false; +} + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/steam_workshop/view.h b/src/ui/plugins/steam_workshop/view.h new file mode 100644 index 0000000..541bbe5 --- /dev/null +++ b/src/ui/plugins/steam_workshop/view.h @@ -0,0 +1,45 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include "ui/plugins/steam_workshop/plugin.h" +#include "ui/views/base.h" + +class QQuickWidget; +class QShowEvent; + +namespace px::ui { + +class DownloadSidebar; + +class SteamWorkshopViewPanel final : public BaseViewPanel { + Q_OBJECT +public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("steam_workshop"), QLatin1StringView("steam"), QLatin1StringView("Steam Workshop")}; + SteamWorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + ~SteamWorkshopViewPanel() override; + void preload(); + void teardown(); + Q_INVOKABLE void navigateHome(); + Q_INVOKABLE void navigateToUrl(const QString& text); +protected: + void showEvent(QShowEvent* event) override; +private: + void ensure_initialized(); + QObject* web() const; + void run_js(const QString& code); + DownloadSidebar* sidebar_; + QQuickWidget* qml_; + SteamWorkshopUiPlugin* plugin_ = nullptr; + bool initialized_ = false; + core::Event::Id badges_id_ = 0; + core::Event::Id active_id_ = 0; + core::Event::Id sidebar_id_ = 0; + core::Event::Id status_id_ = 0; + core::Event::Id busy_id_ = 0; + core::Event::Id uncheck_id_ = 0; + core::Event::Id clear_id_ = 0; +}; + +} // namespace px::ui +#endif diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp new file mode 100644 index 0000000..e707adf --- /dev/null +++ b/src/ui/plugins/workshop/details.cpp @@ -0,0 +1,59 @@ +#include "ui/plugins/workshop/details.h" + +namespace px::ui::workshop { +namespace { +QString joined(const std::vector& ids) { + QStringList values; + values.reserve(static_cast(ids.size())); + for (const auto& id : ids) values.append(QString::fromStdString(id)); + return values.join(QStringLiteral(", ")); +} +} + +QCoro::Task> fetch_detail( + core::workshop::WorkshopCatalog& catalog, QString item_id, QString kind) { + using namespace core::workshop; + if (kind == QStringLiteral("collection")) { + auto result = co_await catalog.collection(item_id); + if (!result) co_return std::unexpected(result.error()); + if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This collection is no longer available."), 404}); + auto& value = **result; + QString warning; + if (!value.is_complete) + warning = QStringLiteral("This collection is incomplete. Some members are unavailable or not listed."); + if (!value.unavailable_ids.empty()) warning += QStringLiteral(" Unavailable: ") + joined(value.unavailable_ids); + co_return Detail{std::move(value.collection), std::move(value.members), warning.toStdString(), value.is_complete}; + } + auto result = co_await catalog.mod(item_id); + if (!result) co_return std::unexpected(result.error()); + if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This mod is no longer available."), 404}); + auto mod = std::move(**result); + std::vector members; + QStringList missing; + // Start every dependency request before waiting for the first one. + std::vector>>> tasks; + tasks.reserve(mod.dependencies.size()); + for (const auto& id : mod.dependencies) tasks.push_back(catalog.mod(QString::fromStdString(id))); + for (std::size_t index = 0; index < tasks.size(); ++index) { + auto dependency = co_await tasks[index]; + if (!dependency) co_return std::unexpected(dependency.error()); + if (*dependency) members.emplace_back(std::move(**dependency)); + else missing.append(QString::fromStdString(mod.dependencies[index])); + } + const auto warning = missing.isEmpty() ? QString{} : QStringLiteral("Unavailable dependencies: ") + missing.join(QStringLiteral(", ")); + co_return Detail{std::move(mod), std::move(members), warning.toStdString(), missing.isEmpty()}; +} + +QString incomplete_plan_notice(const core::workshop::DownloadPlan& plan) { + QStringList parts{QStringLiteral("The download plan is incomplete.")}; + if (!plan.unavailable_ids.isEmpty()) parts.append(QStringLiteral("Unavailable: ") + plan.unavailable_ids.join(QStringLiteral(", "))); + if (!plan.incomplete_collection_ids.isEmpty()) parts.append(QStringLiteral("Incomplete collections: ") + plan.incomplete_collection_ids.join(QStringLiteral(", "))); + return parts.join(QLatin1Char(' ')); +} + +QString queued_notice(qsizetype count) { + return QStringLiteral("Queued %1 mod%2. Progress is shown in the header and results in Downloads. Mods are not activated.") + .arg(count).arg(count == 1 ? QString{} : QStringLiteral("s")); +} + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/details.h b/src/ui/plugins/workshop/details.h new file mode 100644 index 0000000..af9fb8e --- /dev/null +++ b/src/ui/plugins/workshop/details.h @@ -0,0 +1,21 @@ +#pragma once + +#include "core/workshop/catalog.h" + +namespace px::ui::workshop { + +struct Detail { + core::workshop::CatalogEntry item; + std::vector members; + std::string warning; + bool complete = false; + + bool operator==(const Detail&) const = default; +}; + +QCoro::Task> fetch_detail( + core::workshop::WorkshopCatalog& catalog, QString item_id, QString kind); +QString incomplete_plan_notice(const core::workshop::DownloadPlan& plan); +QString queued_notice(qsizetype count); + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp new file mode 100644 index 0000000..4216fa8 --- /dev/null +++ b/src/ui/plugins/workshop/installed.cpp @@ -0,0 +1,60 @@ +#include "ui/plugins/workshop/installed.h" + +#include +#include + +#include + +namespace px::ui::workshop { + +void InstalledList::replace(std::vector mods) { + mods_ = std::move(mods); + rematch(); +} + +void InstalledList::search(QString query) { + query_ = std::move(query); + reset_paging(); + rematch(); +} + +void InstalledList::reset_paging() { + shown_ = PAGE; +} + +std::vector InstalledList::visible() const { + return slice(0, std::min(shown_, matches_.size())); +} + +std::vector InstalledList::next_page() { + const auto start = shown_; + shown_ = std::min(start + PAGE, matches_.size()); + return slice(start, shown_); +} + +std::vector InstalledList::slice(std::size_t start, std::size_t end) const { + std::vector result; + if (start >= end) return result; + result.reserve(end - start); + for (auto i = start; i < end; ++i) result.push_back(mods_[matches_[i]]); + return result; +} + +void InstalledList::rematch() { + matches_.clear(); + const QString needle = query_.toCaseFolded(); + for (std::size_t i = 0; i < mods_.size(); ++i) { + const auto& mod = mods_[i]; + if (needle.isEmpty()) { + matches_.push_back(i); + continue; + } + QStringList fields{QString::fromStdString(mod.title), + mod.author.name ? QString::fromStdString(*mod.author.name) : QString{}, + QString::fromStdString(mod.id)}; + for (const auto& tag : mod.tags) fields.append(QString::fromStdString(tag)); + if (fields.join(' ').toCaseFolded().contains(needle)) matches_.push_back(i); + } +} + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/installed.h b/src/ui/plugins/workshop/installed.h new file mode 100644 index 0000000..454d34c --- /dev/null +++ b/src/ui/plugins/workshop/installed.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +#include "core/workshop/types.h" + +namespace px::ui::workshop { + +inline constexpr int PAGE = 48; + +class InstalledList { +public: + const std::vector& mods() const { return mods_; } + bool loaded() const { return !mods_.empty(); } + int total() const { return static_cast(matches_.size()); } + bool has_more() const { return shown_ < matches_.size(); } + + void replace(std::vector mods); + void search(QString query); + void reset_paging(); + std::vector visible() const; + std::vector next_page(); + +private: + void rematch(); + std::vector slice(std::size_t start, std::size_t end) const; + + std::vector mods_; + std::vector matches_; + QString query_; + std::size_t shown_ = PAGE; +}; + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp new file mode 100644 index 0000000..5f964a0 --- /dev/null +++ b/src/ui/plugins/workshop/models.cpp @@ -0,0 +1,225 @@ +#include "ui/plugins/workshop/models.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "ui/panels/mod_info_data.h" + +namespace px::ui::workshop { +namespace { +using namespace core::workshop; + +constexpr std::array roles = { + "itemId", "kind", "title", "author", "previewUrl", "collage", "sourceLabel", "versions", "tags", + "state", "active", "stateLabel", "actionLabel", "incompatible", "memberCount", "description", + "workshopUrl", "fileSize", "votes", "votesUp", "votesDown", "queued", "favourite", "summary", "compatLabel"}; + +QString size_label(const std::optional& bytes) { + return bytes ? QString::number(QString::fromStdString(*bytes).toDouble() / 1048576.0, 'f', 1) + " MB" + : QStringLiteral("Unknown"); +} + +QString compat_label(const QSet& versions, const QString& game, bool incompatible, bool no_common) { + if (game.isEmpty()) return {}; + if (no_common) return QStringLiteral("No common version"); + if (incompatible) return "Not for " + game; + if (versions.isEmpty()) return QStringLiteral("Version not stated"); + return "Works with " + game; +} +} // namespace + +QString safe_url(const QString& value) { + for (const QChar ch : value) { + if (ch.isSpace() || ch.unicode() < 32) return {}; + } + const QUrl url(value); + const QString scheme = url.scheme().toLower(); + return url.isValid() && (scheme == "http" || scheme == "https") && !url.authority().isEmpty() ? value : QString{}; +} + +QString summarize(const QString& text, const QString& format) { + static const QRegularExpression media(QStringLiteral(R"(\[(img|video|previewimage)[^\]]*\].*?\[/\1\])"), + QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); + static const QRegularExpression tags(QStringLiteral(R"(\[/?[a-zA-Z*][^\]]*\])")); + QString result = text; + if (format == "bbcode") result.replace(media, " ").replace(tags, " "); + result = result.simplified(); + const auto codepoints = result.toUcs4(); + if (codepoints.size() <= 140) return result; + result = QString::fromUcs4(codepoints.constData(), 140); + const auto space = result.lastIndexOf(' '); + if (space >= 0) result.truncate(space); + const QString punctuation = QStringLiteral(".,;:-–— "); + while (!result.isEmpty() && punctuation.contains(result.back())) result.chop(1); + return result + QChar(0x2026); +} + +QVariantMap item_row(const CatalogEntry& entry, + const std::function& install_state, + const QString& game_version, bool detail) { + return std::visit([&](const auto& item) { + using Item = std::decay_t; + constexpr bool collection = std::is_same_v; + QSet versions; + for (const auto& version : item.supported_versions) versions.insert(QString::fromStdString(version)); + if constexpr (collection) { + static const QRegularExpression version_tag(QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); + for (const auto& tag : item.tags) { + const QString value = QString::fromStdString(tag); + if (version_tag.match(value).hasMatch()) versions.insert(value); + } + } + bool incompatible = !game_version.isEmpty() && !versions.isEmpty() && !versions.contains(game_version); + bool no_common = false; + InstallState state = InstallState::missing; + QString source, action, size, votes = QStringLiteral("Unrated"), up = "0", down = "0"; + QString workshop_url, format = QStringLiteral("bbcode"); + QStringList collage; + qlonglong member_count = 0; + if constexpr (collection) { + source = item.source == CollectionSource::picked ? "PxModRim pick" : "Steam collection"; + action = QStringLiteral("Download all"); + member_count = item.member_count; + size = size_label(item.total_size); + no_common = item.no_common_version && versions.isEmpty(); + incompatible = incompatible || no_common; + if (item.workshop_url) workshop_url = QString::fromStdString(*item.workshop_url); + if (item.description_format == CollectionDescriptionFormat::text) format = QStringLiteral("text"); + for (const auto& preview : item.member_previews) { + const QString url = safe_url(QString::fromStdString(preview)); + if (!url.isEmpty()) collage.append(url); + } + } else { + state = install_state(item); + incompatible = incompatible || item.incompatible; + source = QStringLiteral("Steam Workshop"); + switch (state) { + case InstallState::missing: action = QStringLiteral("Download"); break; + case InstallState::installed: action = QStringLiteral("Installed"); break; + case InstallState::outdated: action = QStringLiteral("Update"); break; + } + size = size_label(item.file_size); + workshop_url = QString::fromStdString(item.workshop_url); + if (item.votes) { + const QLocale locale(QLocale::English, QLocale::UnitedStates); + up = locale.toString(static_cast(item.votes->up)); + down = locale.toString(static_cast(item.votes->down)); + const double total = static_cast(item.votes->up) + static_cast(item.votes->down); + if (total != 0) votes = QString::number(std::nearbyint(100.0 * item.votes->up / total), 'f', 0) + '%'; + } + } + QString state_name, state_label; + switch (state) { + case InstallState::missing: state_name = "missing"; state_label = "Not installed"; break; + case InstallState::installed: state_name = "installed"; state_label = "Installed"; break; + case InstallState::outdated: state_name = "outdated"; state_label = "Update available"; break; + } + QString preview = item.preview_url ? QString::fromStdString(*item.preview_url) : QString{}; + if (preview.isEmpty()) { + for (const auto& image : item.previews) { + if (image.type == PreviewType::image) { + preview = QString::fromStdString(image.url); + break; + } + } + } + QString author; + if (item.author.name) author = QString::fromStdString(*item.author.name); + if (author.isEmpty() && item.author.id) author = QString::fromStdString(*item.author.id); + if (author.isEmpty()) author = QStringLiteral("Unknown author"); + std::vector, QString>> sorted; + sorted.reserve(versions.size()); + for (const auto& version : versions) { + std::vector parts; + for (const auto& part : version.split('.')) parts.push_back(part.toInt()); + sorted.emplace_back(std::move(parts), version); + } + std::sort(sorted.begin(), sorted.end()); + QStringList version_labels, tag_labels; + for (const auto& version : sorted) version_labels.append(version.second); + for (const auto& tag : item.tags) { + const QString value = QString::fromStdString(tag); + if (!versions.contains(value)) tag_labels.append(value); + } + const QString description = QString::fromStdString(item.description); + return QVariantMap{ + {"itemId", QString::fromStdString(item.id)}, {"kind", collection ? "collection" : "mod"}, + {"title", QString::fromStdString(item.title)}, {"author", author}, {"previewUrl", safe_url(preview)}, + {"collage", collage}, {"sourceLabel", source}, + {"versions", version_labels.isEmpty() ? QStringLiteral("Not specified") : version_labels.join(", ")}, + {"tags", tag_labels.join(QStringLiteral(" · "))}, {"state", state_name}, {"active", false}, + {"queued", false}, {"favourite", false}, {"stateLabel", state_label}, {"actionLabel", action}, + {"incompatible", incompatible}, {"memberCount", member_count}, + {"description", detail ? px::ui::description_to_html(description, format) : QString{}}, + {"workshopUrl", safe_url(workshop_url)}, {"fileSize", size}, {"votes", votes}, + {"votesUp", up}, {"votesDown", down}, {"summary", summarize(description, format)}, + {"compatLabel", compat_label(versions, game_version, incompatible, no_common)}}; + }, entry); +} + +CatalogListModel::CatalogListModel(QObject* parent) : QAbstractListModel(parent) {} + +void CatalogListModel::set_page(QList rows, int total, std::optional cursor, bool append) { + if (append) { + QSet known; + for (const auto& row : rows_) known.insert(row.value("itemId").toString()); + QList additions; + for (auto& row : rows) { + const QString id = row.value("itemId").toString(); + if (!known.contains(id)) { + known.insert(id); + additions.append(std::move(row)); + } + } + if (!additions.isEmpty()) { + beginInsertRows({}, count(), count() + static_cast(additions.size()) - 1); + rows_.append(std::move(additions)); + endInsertRows(); + } + } else if (rows.size() == rows_.size() && std::equal(rows.cbegin(), rows.cend(), rows_.cbegin(), + [](const auto& a, const auto& b) { return a.value("itemId") == b.value("itemId"); })) { + for (qsizetype i = 0; i < rows.size(); ++i) { + if (rows.at(i) != rows_.at(i)) { + rows_[i] = std::move(rows[i]); + emit dataChanged(index(static_cast(i)), index(static_cast(i))); + } + } + } else { + beginResetModel(); + rows_ = std::move(rows); + endResetModel(); + } + total_ = total; + cursor_ = std::move(cursor); + emit changed(); +} + +void CatalogListModel::refresh_rows(const std::function& convert) { + for (auto& row : rows_) row = convert(row); + if (!rows_.isEmpty()) emit dataChanged(index(0), index(count() - 1)); +} + +int CatalogListModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : count(); +} + +QVariant CatalogListModel::data(const QModelIndex& index, int role) const { + const int offset = role - Qt::UserRole - 1; + if (!index.isValid() || index.row() < 0 || index.row() >= count() || offset < 0 || offset >= static_cast(roles.size())) return {}; + return rows_.at(index.row()).value(QString::fromLatin1(roles[offset])); +} + +QHash CatalogListModel::roleNames() const { + QHash names; + for (std::size_t i = 0; i < roles.size(); ++i) names.insert(Qt::UserRole + static_cast(i) + 1, roles[i]); + return names; +} + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/models.h b/src/ui/plugins/workshop/models.h new file mode 100644 index 0000000..18d6bae --- /dev/null +++ b/src/ui/plugins/workshop/models.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include +#include + +#include "core/workshop/types.h" + +namespace px::ui::workshop { + +using core::workshop::CatalogEntry; + +QString safe_url(const QString& value); +QString summarize(const QString& text, const QString& format = QStringLiteral("bbcode")); +QVariantMap item_row(const CatalogEntry& item, + const std::function& install_state, + const QString& game_version, bool detail = false); + +class CatalogListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) + Q_PROPERTY(int count READ count NOTIFY changed) + Q_PROPERTY(int total READ total NOTIFY changed) + +public: + explicit CatalogListModel(QObject* parent = nullptr); + + void set_page(QList rows, int total, std::optional cursor, bool append = false); + void refresh_rows(const std::function& convert); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + const std::optional& cursor() const { return cursor_; } + bool hasMore() const { return cursor_.has_value(); } + int count() const { return static_cast(rows_.size()); } + int total() const { return total_; } + const QList& rows() const { return rows_; } + +signals: + void changed(); + +private: + QList rows_; + std::optional cursor_; + int total_ = 0; +}; + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp new file mode 100644 index 0000000..ee69e5a --- /dev/null +++ b/src/ui/plugins/workshop/plugin.cpp @@ -0,0 +1,24 @@ +#include "ui/plugins/workshop/plugin.h" + +#include "ui/plugins/workshop/view.h" + +namespace px::ui::workshop { + +CatalogSettingsSection::CatalogSettingsSection(core::workshop::WorkshopCatalog& catalog, QObject* parent) + : PluginConfigSectionBase({{QStringLiteral("url"), QString::fromStdString(catalog.settings().url)}}, parent), catalog_(catalog) {} +QString CatalogSettingsSection::title() const { return QStringLiteral("Workshop catalog"); } +QUrl CatalogSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); } +core::Result CatalogSettingsSection::commit(const QJsonObject& merged) { + catalog_.set_base_url(merged.value(QStringLiteral("url")).toString()); + return {}; +} + +WorkshopUiPlugin::WorkshopUiPlugin() + : UiPlugin(QStringLiteral("workshop_ui"), {QStringLiteral("workshop_catalog"), QStringLiteral("downloads")}) {} +void WorkshopUiPlugin::setup(AppContext& ctx) { + ctx.addRailView(); + auto* catalog = static_cast(ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); + ctx.addSettingsSection([catalog](QObject* parent) { return new CatalogSettingsSection(*catalog, parent); }); +} + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/plugin.h b/src/ui/plugins/workshop/plugin.h new file mode 100644 index 0000000..3453e00 --- /dev/null +++ b/src/ui/plugins/workshop/plugin.h @@ -0,0 +1,28 @@ +#pragma once + +#include "core/workshop/catalog.h" +#include "ui/context.h" +#include "ui/settings_section.h" + +namespace px::ui::workshop { + +class CatalogSettingsSection : public PluginConfigSectionBase { +public: + CatalogSettingsSection(core::workshop::WorkshopCatalog& catalog, QObject* parent); + QString title() const override; + QUrl source() const override; + +protected: + core::Result commit(const QJsonObject& merged) override; + +private: + core::workshop::WorkshopCatalog& catalog_; +}; + +class WorkshopUiPlugin : public UiPlugin { +public: + WorkshopUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp new file mode 100644 index 0000000..2c18589 --- /dev/null +++ b/src/ui/plugins/workshop/view.cpp @@ -0,0 +1,452 @@ +#include "ui/plugins/workshop/view.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/mod_activation.h" +#include "ui/navigation.h" +#include "ui/theme/palette.h" + +namespace px::ui::workshop { +using namespace core::workshop; +namespace { +QString id_of(const CatalogEntry& item) { + return std::visit([](const auto& value) { return QString::fromStdString(value.id); }, item); +} +QSet ids_of(const std::vector& ids) { + QSet result; + for (const auto& id : ids) result.insert(QString::fromStdString(id)); + return result; +} +std::optional cursor_of(const std::optional& cursor) { + return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; +} +WorkshopCatalog& catalog_of(core::CoreContext& ctx) { + return *static_cast(ctx.plugins().get(QStringLiteral("workshop_catalog"))); +} +} + +WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : WorkshopViewPanel(ctx, engine, parent, app, catalog_of(ctx)) {} + +WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, + WorkshopCatalog& catalog) + : BaseViewPanel(ctx, engine, parent, app), mods_model(new CatalogListModel(this)), + collections_model(new CatalogListModel(this)), members_model(new CatalogListModel(this)), + catalog_(catalog), downloads_(ctx.downloads()), favourites_(catalog.favourites()) { + if (!gameVersion().isEmpty()) criteria_.version = gameVersion(); + refresh_mod_ids(); + auto* qml = new QQuickWidget(engine, this); + qml->setObjectName(QStringLiteral("workshopView")); + qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml->setClearColor(px::ui::paletteColor(u"ELEVATE_0")); + qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); + qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), mods_model); + qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collections_model); + qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), members_model); + busy_id_ = downloads_.busy_changed.connect([this](bool) { refresh_rows(); emit changed(); }); + queue_id_ = catalog_.queue_changed.connect([this](const std::monostate&) { refresh_rows(); emit changed(); }); + url_id_ = catalog_.catalog_url_changed.connect([this](const QString&) { url_changed(); }); + installed_id_ = catalog_.installed_changed.connect([this](const std::monostate&) { installed_changed(); }); + favourites_id_ = favourites_.changed().connect([this](const FavouritesState&) { + if (tab_ == QStringLiteral("Favourites") && !detail_data_) show_favourites(); + else { refresh_rows(); emit changed(); } + }); + active_id_ = ctx_.activeStateChanged().connect([this](const QStringList&) { + refresh_mod_ids(); refresh_rows(); emit changed(); + }); + qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + root_->addWidget(qml, 1); +} + +WorkshopViewPanel::~WorkshopViewPanel() { teardown(); } +void WorkshopViewPanel::teardown() { + if (torn_down_) return; + torn_down_ = true; + ++generation_; + retry_ = {}; + downloads_.busy_changed.disconnect(busy_id_); + catalog_.queue_changed.disconnect(queue_id_); + catalog_.catalog_url_changed.disconnect(url_id_); + catalog_.installed_changed.disconnect(installed_id_); + favourites_.changed().disconnect(favourites_id_); + ctx_.activeStateChanged().disconnect(active_id_); +} + +QString WorkshopViewPanel::gameVersion() const { + const auto version = ctx_.targetVersion(); + return version == QStringLiteral("Unknown") ? QString{} : version; +} +void WorkshopViewPanel::refresh_mod_ids() { + installed_ids_.clear(); active_ids_.clear(); + const auto& mods = ctx_.allMods(); + for (const auto& mod : mods) if (auto id = mod->published_file_id()) installed_ids_.insert(*id); + for (const auto& uuid : ctx_.activeUuids()) { + auto mod = mods.value(uuid); + if (mod) if (auto id = mod->published_file_id()) active_ids_.insert(*id); + } +} + +QVariantMap WorkshopViewPanel::row(const CatalogEntry& item, bool detail) { + const auto id = id_of(item); + items_.insert(id, item); + std::visit([this](const auto& value) { + auto tags = ids_of(value.tags); + tags.subtract(ids_of(value.supported_versions)); + known_tags_.unite(tags); + }, item); + auto result = item_row(item, [this](const CatalogMod& mod) { return catalog_.install_state(mod); }, gameVersion(), detail); + const auto* mod = std::get_if(&item); + const auto* collection = std::get_if(&item); + result[QStringLiteral("active")] = mod && active_ids_.contains(id); + if (collection) { + if (!detail) { + qsizetype count = 0; + for (const auto& member : collection->member_ids) count += installed_ids_.contains(QString::fromStdString(member)); + if (!collection->member_ids.empty() && count == static_cast(collection->member_ids.size())) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + } else if (count) result[QStringLiteral("actionLabel")] = QStringLiteral("Complete download"); + } + if (favourites_.contains(collection->id)) { + result[QStringLiteral("favourite")] = true; + favourites_.remember(*collection); + } + } + if (collection || result.value(QStringLiteral("state")).toString() != QStringLiteral("installed")) { + if (preparing_.contains(id)) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Preparing…"); + } else { + QSet ids = mod ? QSet{id} : ids_of(collection->member_ids) | collection_mods_.value(id); + if (collection && detail_data_ && id_of(detail_data_->item) == id) ids.unite(pack_ids_); + if (ids.intersects(downloads_.active_ids())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Downloading"); + } else if (ids.intersects(catalog_.queued_ids())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Queued"); + } + } + } + return result; +} + +QVariantMap WorkshopViewPanel::detail_row(const Detail& detail) { + auto result = row(detail.item, true); + result[QStringLiteral("complete")] = detail.complete; + result[QStringLiteral("warning")] = QString::fromStdString(detail.warning); + if (std::holds_alternative(detail.item)) { + auto collage = result.value(QStringLiteral("collage")).toStringList(); + if (result.value(QStringLiteral("previewUrl")).toString().isEmpty() && collage.isEmpty()) { + for (const auto& member : detail.members) { + const auto* mod = std::get_if(&member); + if (!mod) continue; + QString preview = QString::fromStdString(mod->preview_url.value_or("")); + if (preview.isEmpty()) for (const auto& image : mod->previews) { + if (image.type == PreviewType::image) { preview = QString::fromStdString(image.url); break; } + } + preview = safe_url(preview); + if (!preview.isEmpty()) collage.append(preview); + if (collage.size() == 4) break; + } + result[QStringLiteral("collage")] = collage; + } + bool started = false; + for (const auto& member : detail.members) if (auto* mod = std::get_if(&member)) + started |= catalog_.install_state(*mod) != InstallState::missing; + if (!result.value(QStringLiteral("queued")).toBool()) + result[QStringLiteral("actionLabel")] = started ? QStringLiteral("Complete download") : QStringLiteral("Download all"); + if (auto copies = pack_copies()) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + if (!result.value(QStringLiteral("queued")).toBool()) result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("active")] = std::ranges::all_of(*copies, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); + } + } + return result; +} +void WorkshopViewPanel::refresh_rows() { + for (auto* model : {mods_model, collections_model, members_model}) model->refresh_rows([this](const QVariantMap& value) { + return row(items_.value(value.value(QStringLiteral("itemId")).toString())); + }); + if (detail_data_) detail_ = detail_row(*detail_data_); +} +void WorkshopViewPanel::clear_detail() { + detail_.clear(); detail_data_.reset(); current_.reset(); trail_.clear(); pack_ids_.clear(); +} +std::optional WorkshopViewPanel::pack_copies() const { + if (torn_down_ || pack_ids_.isEmpty()) return std::nullopt; + QHash installed; + for (auto it = ctx_.allMods().cbegin(); it != ctx_.allMods().cend(); ++it) + if (auto id = it.value()->published_file_id(); id && pack_ids_.contains(*id)) installed[*id].append(it.key()); + if (installed.size() < pack_ids_.size()) return std::nullopt; + QStringList copies; + for (const auto& uuids : installed) { + auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); + copies.append(active == uuids.cend() ? uuids.front() : *active); + } + return copies; +} +QCoro::Task WorkshopViewPanel::load_pack() { + if (!detail_data_ || torn_down_) co_return; + QPointer self(this); + const int generation = generation_; + auto result = co_await catalog_.plan({}, {id_of(detail_data_->item)}); + if (!self || self->torn_down_ || generation != self->generation_) co_return; + if (!result) { + spdlog::warn("[workshop] cannot resolve collection mods: {}", result.error().message.toStdString()); + co_return; + } + self->pack_ids_.clear(); + if (result->is_complete) { + self->pack_ids_ = QSet(result->to_download.cbegin(), result->to_download.cend()); + self->pack_ids_.unite(QSet(result->already_current.cbegin(), result->already_current.cend())); + } + self->refresh_rows(); emit self->changed(); +} + +QCoro::Task WorkshopViewPanel::load(bool append) { + if (torn_down_) co_return; + if (!configured()) { ++generation_; busy_ = false; emit changed(); co_return; } + clear_detail(); + auto* catalog = &catalog_; + if (tab_ == QStringLiteral("Discover")) { + co_await run_cached(QStringLiteral("discover"), [catalog] { return catalog->discover(); }, [this](const Discover& value) { + mods_model->set_page(rows(value.mods.items), static_cast(value.mods.total), std::nullopt); + collections_model->set_page(rows(value.collections.items), static_cast(value.collections.total), std::nullopt); + }); + } else if (tab_ == QStringLiteral("Favourites")) { + ++generation_; busy_ = false; error_.clear(); show_favourites(); + } else if (tab_ == QStringLiteral("Installed")) { + if (append) { + mods_model->set_page(rows(installed_.next_page()), installed_.total(), installed_cursor(), true); + co_return; + } + installed_dirty_ = false; + auto ids = installed_ids_.values(); + std::ranges::sort(ids); + const auto fingerprint = QCryptographicHash::hash(ids.join(QLatin1Char(',')).toUtf8(), QCryptographicHash::Sha256).toHex(); + co_await run_cached>(QStringLiteral("installed|") + QString::fromLatin1(fingerprint), + [catalog] { return catalog->installed_with_updates(); }, [this](const std::vector& value) { + installed_.replace(value); recount(); show_installed(); + }); + } else { + const bool collections = tab_ == QStringLiteral("Collections"); + auto* model = collections ? collections_model : mods_model; + auto query = criteria_; + query.cursor = append ? model->cursor() : std::nullopt; + const QString key = tab_ + QLatin1Char('|') + QString::fromLatin1(QCryptographicHash::hash( + QJsonDocument(QJsonArray{query.query, query.tag.value_or(QString{}), query.version.value_or(QString{}), + query.sort, query.limit, query.source}).toJson(QJsonDocument::Compact), QCryptographicHash::Sha256).toHex()); + if (collections) { + co_await run_cached>(key, [catalog, query] { return catalog->collections(query); }, + [this, model, append](const CatalogPage& value) { + model->set_page(rows(value.items), static_cast(value.total), cursor_of(value.next_cursor), append); + }, !append); + } else { + co_await run_cached>(key, [catalog, query] { return catalog->mods(query); }, + [this, model, append](const CatalogPage& value) { + model->set_page(rows(value.items), static_cast(value.total), cursor_of(value.next_cursor), append); + }, !append); + } + } +} + +void WorkshopViewPanel::show_favourites() { + std::vector matches; + for (const auto& item : favourites_.items()) + if (QString::fromStdString(item.title).toCaseFolded().contains(criteria_.query.toCaseFolded())) matches.push_back(item); + collections_model->set_page(rows(matches), static_cast(matches.size()), std::nullopt); + emit changed(); +} +std::optional WorkshopViewPanel::installed_cursor() const { + return installed_.has_more() ? std::optional(QStringLiteral("more")) : std::nullopt; +} +void WorkshopViewPanel::show_installed() { + clear_detail(); + mods_model->set_page(rows(installed_.visible()), installed_.total(), installed_cursor()); + emit changed(); +} +void WorkshopViewPanel::recount() { + update_count_ = 0; + for (const auto& mod : installed_.mods()) update_count_ += catalog_.install_state(mod) == InstallState::outdated; +} + +QCoro::Task WorkshopViewPanel::select_tab(QString tab) { + tab_ = std::move(tab); notice_.clear(); pending_.reset(); clear_detail(); installed_.reset_paging(); + co_await load(); +} +QCoro::Task WorkshopViewPanel::filter_items(QString query, QString version, QString source, QString sort, QString tag) { + criteria_ = CatalogQuery{.query = query, .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), + .version = version.isEmpty() ? std::nullopt : std::optional(version), .sort = sort, .source = source}; + installed_.search(query); + if (tab_ == QStringLiteral("Installed") && installed_.loaded()) { show_installed(); co_return; } + if (tab_ == QStringLiteral("Discover")) tab_ = QStringLiteral("Mods"); + co_await load(); +} +QCoro::Task WorkshopViewPanel::retry_load() { + if (retry_ && !torn_down_) { + auto action = retry_; + co_await action(); + } + co_return; +} +void WorkshopViewPanel::go(const QStringList& path) { app_.navigate(buildRoute(QStringLiteral("workshop"), path)); } +void WorkshopViewPanel::selectTab(const QString& tab) { go({tab.toLower()}); } +void WorkshopViewPanel::openItem(const QString& item_id, const QString& kind) { go({kind, item_id}); } +void WorkshopViewPanel::openRoute(const QStringList& path) { follow_route(path); } +QCoro::Task WorkshopViewPanel::follow_route(QStringList path) { + if (torn_down_ || path.isEmpty()) co_return; + const QStringList tabs{QStringLiteral("Discover"), QStringLiteral("Mods"), QStringLiteral("Collections"), QStringLiteral("Favourites"), QStringLiteral("Installed")}; + if (path.size() == 1) { + for (const auto& tab : tabs) if (tab.compare(path.front(), Qt::CaseInsensitive) == 0) { + co_await select_tab(tab); co_return; + } + } else if (path.size() == 2 && (path.front() == QStringLiteral("mod") || path.front() == QStringLiteral("collection"))) { + co_await open_item(path[1], path[0]); co_return; + } + spdlog::warn("[workshop] unknown route {}", buildRoute(QStringLiteral("workshop"), path).toStdString()); +} +QCoro::Task WorkshopViewPanel::open_item(QString item_id, QString kind, bool record) { + pack_ids_.clear(); + QPointer self(this); + auto* catalog = &catalog_; + co_await run_cached(QStringLiteral("detail|") + kind + QLatin1Char('|') + item_id, + [catalog, item_id, kind] { return fetch_detail(*catalog, item_id, kind); }, + [this, item_id, kind, record](const Detail& value) { + const auto ref = std::pair(kind, item_id); + if (current_ != ref) { + if (detail_.isEmpty()) trail_.clear(); + else if (record && current_) trail_.emplace_back(current_->first, current_->second, detail_.value(QStringLiteral("title")).toString()); + } + current_ = ref; + auto members = rows(value.members); + detail_data_ = value; + detail_ = detail_row(value); + members_model->set_page(std::move(members), static_cast(value.members.size()), std::nullopt); + }); + if (self && !self->torn_down_ && kind == QStringLiteral("collection") && self->current_ == std::pair(kind, item_id)) + co_await self->load_pack(); +} +QCoro::Task WorkshopViewPanel::go_back() { + if (torn_down_) co_return; + if (!trail_.empty()) { + auto [kind, item_id, title] = trail_.back(); + QPointer self(this); + co_await open_item(item_id, kind, false); + if (self && !self->torn_down_ && self->current_ == std::pair(kind, item_id) && !self->trail_.empty()) { + self->trail_.pop_back(); emit self->changed(); + } + co_return; + } + ++generation_; clear_detail(); error_.clear(); busy_ = false; emit changed(); + if (tab_ == QStringLiteral("Favourites")) show_favourites(); + else if (tab_ == QStringLiteral("Installed") && installed_dirty_) co_await load(); +} + +QCoro::Task WorkshopViewPanel::toggle_activation(QString item_id) { + if (torn_down_) co_return; + QStringList copies, active; + for (auto it = ctx_.allMods().cbegin(); it != ctx_.allMods().cend(); ++it) { + if (it.value()->published_file_id() != item_id) continue; + copies.append(it.key()); + if (ctx_.activeUuids().contains(it.key())) active.append(it.key()); + } + if (copies.isEmpty()) co_return; + if (active.isEmpty()) co_await apply_activation(ctx_, this, {copies.front()}, {}); + else co_await apply_activation(ctx_, this, {}, active); +} +QCoro::Task WorkshopViewPanel::toggle_collection(bool only) { + auto copies = pack_copies(); + if (!copies) co_return; + QStringList disable; + const bool all_active = std::ranges::all_of(*copies, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); + for (const auto& uuid : ctx_.activeUuids()) { + auto mod = ctx_.allMods().value(uuid); + if (!mod) continue; + auto id = mod->published_file_id(); + if (id && ((only && !copies->contains(uuid)) || (!only && all_active && pack_ids_.contains(*id)))) disable.append(uuid); + } + if (!only && all_active) co_await apply_activation(ctx_, this, {}, disable); + else co_await apply_activation(ctx_, this, *copies, disable); +} +QCoro::Task WorkshopViewPanel::download_item(QString item_id, QString kind) { + co_await download(kind == QStringLiteral("mod") ? QStringList{item_id} : QStringList{}, + kind == QStringLiteral("collection") ? QStringList{item_id} : QStringList{}); +} +QCoro::Task WorkshopViewPanel::update_all() { + QStringList ids; + for (const auto& mod : installed_.mods()) if (catalog_.install_state(mod) == InstallState::outdated) ids.append(QString::fromStdString(mod.id)); + if (!ids.isEmpty()) co_await download(ids, {}); +} +void WorkshopViewPanel::queue_plan(const DownloadPlan& plan) { catalog_.enqueue(plan); notice_ = queued_notice(plan.to_download.size()); } +QCoro::Task WorkshopViewPanel::download(QStringList ids, QStringList collections) { + if (torn_down_) co_return; + QPointer self(this); + notice_ = QStringLiteral("Preparing download…"); pending_.reset(); ++planning_; + auto requested = QSet(ids.cbegin(), ids.cend()); + requested.unite(QSet(collections.cbegin(), collections.cend())); + preparing_.unite(requested); refresh_rows(); emit changed(); + auto result = co_await catalog_.plan(ids, collections); + if (!self || self->torn_down_) co_return; + --self->planning_; self->preparing_.subtract(requested); self->refresh_rows(); emit self->changed(); + if (!result) { self->notice_ = QStringLiteral("Could not prepare the download: ") + result.error().message; emit self->changed(); co_return; } + for (const auto& collection : collections) + self->collection_mods_[collection] = QSet(result->to_download.cbegin(), result->to_download.cend()); + if (!result->is_complete) { self->pending_ = *result; self->notice_ = incomplete_plan_notice(*result); } + else if (result->to_download.isEmpty()) self->notice_ = QStringLiteral("All available mods are already installed and up to date."); + else self->queue_plan(*result); + emit self->changed(); +} +void WorkshopViewPanel::downloadAvailable() { + if (torn_down_) return; + auto plan = std::exchange(pending_, std::nullopt); + if (plan && !plan->to_download.isEmpty()) queue_plan(*plan); + emit changed(); +} +void WorkshopViewPanel::dismissPlan() { pending_.reset(); notice_.clear(); emit changed(); } +void WorkshopViewPanel::openLink(const QString& url) { if (!safe_url(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); } +void WorkshopViewPanel::openSettings() { app_.navigate(buildRoute(QString(kSettingsViewId))); } +void WorkshopViewPanel::toggleFavourite(const QString& item_id) { + const auto found = items_.constFind(item_id); + if (found != items_.cend()) if (const auto* collection = std::get_if(&*found)) + favourites_.set(*collection, !favourites_.contains(collection->id)); +} +QCoro::Task WorkshopViewPanel::url_changed() { + if (torn_down_) co_return; + clear_detail(); error_.clear(); notice_.clear(); pending_.reset(); items_.clear(); known_tags_.clear(); + for (auto* model : {mods_model, collections_model, members_model}) model->set_page({}, 0, std::nullopt); + co_await load(); +} +QCoro::Task WorkshopViewPanel::installed_changed() { + if (torn_down_) co_return; + installed_dirty_ = true; refresh_mod_ids(); recount(); refresh_rows(); emit changed(); + if (tab_ == QStringLiteral("Installed") && !busy_ && !detail_data_) co_await load(); +} +QString WorkshopViewPanel::url() const { + return buildRoute(QStringLiteral("workshop"), current_ ? QStringList{current_->first, current_->second} : QStringList{tab_.toLower()}); +} +QString WorkshopViewPanel::backLabel() const { + return QStringLiteral("Back to ") + (trail_.empty() ? tab_ : std::get<2>(trail_.back())); +} +QStringList WorkshopViewPanel::tagOptions() const { + auto tags = known_tags_.values(); std::ranges::sort(tags); tags.prepend(QStringLiteral("All tags")); return tags; +} +QStringList WorkshopViewPanel::versionOptions() const { + QStringList versions{QStringLiteral("All versions")}; + if (criteria_.version) versions.append(*criteria_.version); + for (const auto& version : {QStringLiteral("1.6"), QStringLiteral("1.5"), QStringLiteral("1.4"), QStringLiteral("1.3")}) + if (!versions.contains(version)) versions.append(version); + return versions; +} + +} // namespace px::ui::workshop diff --git a/src/ui/plugins/workshop/view.h b/src/ui/plugins/workshop/view.h new file mode 100644 index 0000000..2fcf6ae --- /dev/null +++ b/src/ui/plugins/workshop/view.h @@ -0,0 +1,199 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +#include "ui/plugins/workshop/details.h" +#include "ui/plugins/workshop/installed.h" +#include "ui/plugins/workshop/models.h" +#include "ui/views/base.h" + +namespace px::ui::workshop { + +class WorkshopViewPanel : public BaseViewPanel { + Q_OBJECT + Q_PROPERTY(QString tab READ tab NOTIFY changed) + Q_PROPERTY(QString url READ url NOTIFY changed) + Q_PROPERTY(QString backLabel READ backLabel NOTIFY changed) + Q_PROPERTY(bool configured READ configured NOTIFY changed) + Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(QString error READ error NOTIFY changed) + Q_PROPERTY(QString notice READ notice NOTIFY changed) + Q_PROPERTY(QVariantMap detail READ detail NOTIFY changed) + Q_PROPERTY(bool hasDetail READ hasDetail NOTIFY changed) + Q_PROPERTY(bool pendingPlan READ pendingPlan NOTIFY changed) + Q_PROPERTY(bool canDownloadAvailable READ canDownloadAvailable NOTIFY changed) + Q_PROPERTY(QString searchQuery READ searchQuery NOTIFY changed) + Q_PROPERTY(QStringList tagOptions READ tagOptions NOTIFY changed) + Q_PROPERTY(QString gameVersion READ gameVersion CONSTANT) + Q_PROPERTY(QStringList versionOptions READ versionOptions CONSTANT) + Q_PROPERTY(int updateCount READ updateCount NOTIFY changed) + Q_PROPERTY(bool planning READ planning NOTIFY changed) + +public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("workshop"), QLatin1StringView("steam"), QLatin1StringView("Workshop")}; + WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, + core::workshop::WorkshopCatalog& catalog); + ~WorkshopViewPanel() override; + void openRoute(const QStringList& path) override; + Q_INVOKABLE void teardown() override; + QCoro::Task follow_route(QStringList path); + QCoro::Task load(bool append = false); + QCoro::Task select_tab(QString tab); + QCoro::Task open_item(QString item_id, QString kind, bool record = true); + QCoro::Task filter_items(QString query, QString version, QString source, QString sort, QString tag); + QCoro::Task go_back(); + QCoro::Task retry_load(); + QCoro::Task download_item(QString item_id, QString kind); + QCoro::Task toggle_activation(QString item_id); + QCoro::Task toggle_collection(bool only = false); + QCoro::Task update_all(); + + Q_INVOKABLE QCoro::QmlTask refresh() { return load(); } + Q_INVOKABLE void selectTab(const QString& tab); + Q_INVOKABLE QCoro::QmlTask filter(QString query, QString version, QString source, QString sort, QString tag) { + return filter_items(std::move(query), std::move(version), std::move(source), std::move(sort), std::move(tag)); + } + Q_INVOKABLE QCoro::QmlTask loadMore() { return load(true); } + Q_INVOKABLE QCoro::QmlTask retry() { return retry_load(); } + Q_INVOKABLE void openItem(const QString& item_id, const QString& kind); + Q_INVOKABLE QCoro::QmlTask back() { return go_back(); } + Q_INVOKABLE QCoro::QmlTask downloadItem(QString item_id, QString kind) { return download_item(std::move(item_id), std::move(kind)); } + Q_INVOKABLE QCoro::QmlTask toggleActivation(QString item_id) { return toggle_activation(std::move(item_id)); } + Q_INVOKABLE QCoro::QmlTask toggleCollection() { return toggle_collection(); } + Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { return toggle_collection(true); } + Q_INVOKABLE QCoro::QmlTask updateAll() { return update_all(); } + Q_INVOKABLE void downloadAvailable(); + Q_INVOKABLE void dismissPlan(); + Q_INVOKABLE void openLink(const QString& url); + Q_INVOKABLE void openSettings(); + Q_INVOKABLE void toggleFavourite(const QString& item_id); + + QString tab() const { return tab_; } + QString url() const; + QString backLabel() const; + bool configured() const { return catalog_.configured(); } + bool busy() const { return busy_; } + QString error() const { return error_; } + QString notice() const { return notice_; } + QVariantMap detail() const { return detail_; } + bool hasDetail() const { return !detail_.isEmpty(); } + bool pendingPlan() const { return pending_.has_value(); } + bool canDownloadAvailable() const { return pending_ && !pending_->to_download.isEmpty(); } + QString searchQuery() const { return criteria_.query; } + QStringList tagOptions() const; + QString gameVersion() const; + QStringList versionOptions() const; + int updateCount() const { return update_count_; } + bool planning() const { return planning_ > 0; } + + CatalogListModel* mods_model; + CatalogListModel* collections_model; + CatalogListModel* members_model; + +signals: + void changed(); + +private: + template + QCoro::Task run_cached(QString key, + std::function>()> produce, + std::function apply, bool cached = true); + QCoro::Task load_pack(); + QCoro::Task download(QStringList ids, QStringList collections); + QCoro::Task installed_changed(); + QCoro::Task url_changed(); + void refresh_mod_ids(); + QVariantMap row(const core::workshop::CatalogEntry& item, bool detail = false); + QVariantMap detail_row(const Detail& detail); + template QList rows(const std::vector& items) { + refresh_mod_ids(); + QList result; + result.reserve(static_cast(items.size())); + for (const auto& item : items) result.append(row(item)); + return result; + } + void refresh_rows(); + void clear_detail(); + std::optional pack_copies() const; + void show_favourites(); + void show_installed(); + void recount(); + std::optional installed_cursor() const; + void queue_plan(const core::workshop::DownloadPlan& plan); + void go(const QStringList& path); + + core::workshop::WorkshopCatalog& catalog_; + core::DownloadManager& downloads_; + core::workshop::FavouriteCollections& favourites_; + QString tab_ = QStringLiteral("Discover"); + core::workshop::CatalogQuery criteria_; + bool busy_ = false; + bool torn_down_ = false; + bool installed_dirty_ = false; + int generation_ = 0; + int planning_ = 0; + int update_count_ = 0; + QString error_, notice_; + std::function()> retry_; + QVariantMap detail_; + std::optional detail_data_; + InstalledList installed_; + std::optional> current_; + std::vector> trail_; + std::optional pending_; + QSet preparing_, pack_ids_, installed_ids_, active_ids_, known_tags_; + QHash> collection_mods_; + QHash items_; + core::Event::Id busy_id_ = 0; + core::Event::Id queue_id_ = 0, installed_id_ = 0; + core::Event::Id url_id_ = 0; + core::Event::Id favourites_id_ = 0; + core::Event::Id active_id_ = 0; +}; + +template +QCoro::Task WorkshopViewPanel::run_cached(QString key, + std::function>()> produce, + std::function apply, bool cached) { + if (torn_down_) co_return; + QPointer self(this); + const int generation = ++generation_; + busy_ = true; + error_.clear(); + retry_ = [self, key, produce, apply, cached] { + return self->run_cached(key, produce, apply, cached); + }; + emit changed(); + bool shown = false; + auto show = [self, generation, apply, &shown](const T& value) { + if (!self || self->torn_down_ || generation != self->generation_) return; + apply(value); + shown = true; + self->busy_ = false; + emit self->changed(); + }; + core::workshop::CatalogResult result; + if (cached) result = co_await catalog_.cached(key, std::move(produce), show, core::workshop::kFreshSeconds, true); + else { + auto fetched = co_await produce(); + if (fetched) show(*fetched); + else result = std::unexpected(fetched.error()); + } + if (!self || self->torn_down_ || generation != self->generation_) co_return; + if (!result && shown) spdlog::warn("[workshop] background refresh failed: {}", result.error().message.toStdString()); + else if (!result) self->error_ = result.error().message; + self->busy_ = false; + emit self->changed(); + if (self->installed_dirty_ && self->tab_ == QStringLiteral("Installed") && !self->detail_data_) + co_await self->load(); +} + +} // namespace px::ui::workshop diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp new file mode 100644 index 0000000..fd673f0 --- /dev/null +++ b/src/ui/settings_section.cpp @@ -0,0 +1,32 @@ +#include "ui/settings_section.h" + +#include +#include + +namespace px::ui { + +PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, QObject* parent) + : SettingsSection(parent), initial_(std::move(initial)) {} + +QVariantMap PluginConfigSectionBase::initial() const { return initial_; } + +bool PluginConfigSectionBase::set(const QString& key, const QVariant& value) { + if (!initial_.contains(key)) { + return false; + } + draft_.insert(key, value); + return true; +} + +core::Result PluginConfigSectionBase::apply() { + if (draft_.isEmpty()) { + return {}; + } + QVariantMap merged = initial_; + for (auto it = draft_.cbegin(); it != draft_.cend(); ++it) { + merged.insert(it.key(), it.value()); + } + return commit(QJsonObject::fromVariantMap(merged)); +} + +} // namespace px::ui diff --git a/src/ui/settings_section.h b/src/ui/settings_section.h new file mode 100644 index 0000000..06ab748 --- /dev/null +++ b/src/ui/settings_section.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/error.h" +#include "core/plugin_config.h" + +namespace px::ui { + +// A plugin's group on the Settings dialog. `source()` is loaded into a group titled +// `title()` with this object as its required `section` property. Edits stay on the +// object until the dialog is saved, when `apply()` commits them; a cancelled dialog +// just drops it. +class SettingsSection : public QObject { + Q_OBJECT + +public: + using QObject::QObject; + + virtual QString title() const = 0; + virtual QUrl source() const = 0; + + virtual core::Result apply() = 0; +}; + +// Builds a plugin's section for one dialog, parented to that dialog. +using SettingsSectionFactory = std::function; + +// The QML-facing half of PluginConfigSection, independent of the settings type: +// QML reads `initial` and reports edits via `set`, and apply() hands the merged +// values to the typed commit(). +class PluginConfigSectionBase : public SettingsSection { + Q_OBJECT + + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + +public: + QVariantMap initial() const; + + // Returns false when `key` is not a field of the settings, so QML can tell the edit was dropped. + Q_INVOKABLE bool set(const QString& key, const QVariant& value); + + core::Result apply() final; + +protected: + PluginConfigSectionBase(QVariantMap initial, QObject* parent); + + // Commits the merged settings; an error leaves the stored config unchanged. + virtual core::Result commit(const QJsonObject& merged) = 0; + +private: + QVariantMap initial_; + QVariantMap draft_; +}; + +// Edits a PluginConfig. Keys and values follow T's JSON layout. +template +class PluginConfigSection : public PluginConfigSectionBase { +public: + PluginConfigSection(core::PluginConfig& config, QObject* parent) + : PluginConfigSectionBase(toVariantMap(config.value()), parent), config_(config) {} + +protected: + core::Result commit(const QJsonObject& merged) override { + const std::string text = QJsonDocument(merged).toJson(QJsonDocument::Compact).toStdString(); + T value{}; + if (auto error = glz::read(value, text)) { + return std::unexpected(core::Error{QString::fromStdString(glz::format_error(error, text))}); + } + config_.update(std::move(value)); + return {}; + } + +private: + static QVariantMap toVariantMap(const T& value) { + auto text = glz::write_json(value); + if (!text) { + spdlog::warn("cannot encode plugin settings for the settings dialog"); + return {}; + } + return QJsonDocument::fromJson(QByteArray::fromStdString(*text)).object().toVariantMap(); + } + + core::PluginConfig& config_; +}; + +} // namespace px::ui diff --git a/src/ui/theme/constants.h b/src/ui/theme/constants.h new file mode 100644 index 0000000..6a5900c --- /dev/null +++ b/src/ui/theme/constants.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace px::ui { + +// Animation +inline constexpr int ACCORDION_ANIMATION_DURATION_MS = 200; +inline constexpr QEasingCurve::Type ACCORDION_EASING = QEasingCurve::Type::OutCubic; + +// Banner +inline constexpr int BANNER_MAX_HEIGHT = 260; +inline constexpr int BANNER_ASPECT_W = 16; +inline constexpr int BANNER_ASPECT_H = 9; +inline constexpr int BANNER_OVERLAY_HEIGHT = 120; + +// Layout +inline constexpr int RAIL_MIN_WIDTH = 52; +inline constexpr int RAIL_MAX_WIDTH = 180; +inline constexpr int RAIL_COLLAPSE_WIDTH = 116; +inline constexpr int SIDEBAR_WIDTH = 240; + +// Interaction +inline constexpr int TOOLTIP_DELAY_MS = 400; + +} // namespace px::ui diff --git a/src/ui/theme/palette.cpp b/src/ui/theme/palette.cpp new file mode 100644 index 0000000..ef3b05b --- /dev/null +++ b/src/ui/theme/palette.cpp @@ -0,0 +1,107 @@ +#include "ui/theme/palette.h" + + +#include +#include +#include +#include +#include +#include + +namespace px::ui { + +namespace { + +struct PaletteEntry { + QLatin1StringView name; + QLatin1StringView value; +}; +constexpr PaletteEntry kPaletteEntries[]{ +#include "ui/theme/palette_table.inc" +}; + +// Mirrors the legacy string.Template.safe_substitute: a `$NAME` token is replaced +// only when NAME is a palette key; anything else stays as written. +QString expandTokens(const QString& raw) { + static const QRegularExpression token(QStringLiteral(R"(\$([A-Z_][A-Z0-9_]*))")); + QString out; + out.reserve(raw.size()); + qsizetype last = 0; + auto it = token.globalMatch(raw); + while (it.hasNext()) { + const auto match = it.next(); + out.append(QStringView(raw).mid(last, match.capturedStart() - last)); + const QString value = paletteValue(match.capturedView(1)); + out.append(value.isEmpty() ? match.captured(0) : value); + last = match.capturedEnd(); + } + out.append(QStringView(raw).mid(last)); + return out; +} + +} // namespace + +QString paletteValue(QStringView name) { + for (const auto& entry : kPaletteEntries) { + if (QString(entry.name) == name) { + return QString(entry.value); + } + } + return {}; +} + +QColor paletteColor(QStringView name) { + const QString val = paletteValue(name); + return val.isEmpty() ? QColor{} : QColor(val); +} + +const QColor& panelBgQ() { + static const QColor color = paletteColor(QStringView{u"ELEVATE_2"}); + return color; +} + +const QColor& textMainQ() { + static const QColor color = paletteColor(QStringView{u"TEXT_MAIN"}); + return color; +} + +const QColor& textMutedQ() { + static const QColor color = paletteColor(QStringView{u"TEXT_MUTED"}); + return color; +} + +QString getStylesheet() { + QFile file(QStringLiteral(":/qt/qml/PxModRim/theme/style.qss")); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + spdlog::warn("failed to open the application stylesheet resource"); + return {}; + } + return expandTokens(QString::fromUtf8(file.readAll())); +} + +void applyAppTheme(QApplication& app) { + app.setStyle(QStringLiteral("Fusion")); + + QPalette palette; + palette.setColor(QPalette::Window, paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::WindowText, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Base, paletteColor(QStringView{u"ELEVATE_1"})); + palette.setColor(QPalette::AlternateBase, paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::Text, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Button, paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ButtonText, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::BrightText, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Highlight, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::HighlightedText, paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::Link, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::ToolTipBase, paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ToolTipText, paletteColor(QStringView{u"TEXT_MAIN"})); + app.setPalette(palette); + + const QString qss = getStylesheet(); + if (!qss.isEmpty()) { + app.setStyleSheet(qss); + } +} + +} // namespace px::ui diff --git a/src/ui/theme/palette.h b/src/ui/theme/palette.h new file mode 100644 index 0000000..7f2fed9 --- /dev/null +++ b/src/ui/theme/palette.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +class QApplication; + +namespace px::ui { + +// Look up a color token by its uppercase name, e.g. "ELEVATE_0", "PRIMARY". +// Returns an empty string if the name is unknown. +QString paletteValue(QStringView name); + +// Same as paletteValue, parsed into a QColor. +QColor paletteColor(QStringView name); + +// Pre-constructed QColors matching legacy panel_bg_q, text_main_q, text_muted_q. +const QColor& panelBgQ(); +const QColor& textMainQ(); +const QColor& textMutedQ(); + +// Returns the application stylesheet (style.qss with $KEY tokens expanded). +QString getStylesheet(); + +// Sets Fusion style, dark palette, and the parsed stylesheet on `app`, +// matching legacy pxmodrim/_app.py `_apply_theme`. +void applyAppTheme(QApplication& app); + +} // namespace px::ui diff --git a/src/ui/theme/palette_table.inc b/src/ui/theme/palette_table.inc new file mode 100644 index 0000000..df0bc6e --- /dev/null +++ b/src/ui/theme/palette_table.inc @@ -0,0 +1,28 @@ +// Generated once from legacy/pxmodrim/ui/theme/palette.py (PALETTE, same order). +{QLatin1StringView{"ELEVATE_0"}, QLatin1StringView{"#0b0d10"}}, +{QLatin1StringView{"ELEVATE_1"}, QLatin1StringView{"#121418"}}, +{QLatin1StringView{"ELEVATE_2"}, QLatin1StringView{"#1a1d21"}}, +{QLatin1StringView{"ELEVATE_3"}, QLatin1StringView{"#22262b"}}, +{QLatin1StringView{"ELEVATE_4"}, QLatin1StringView{"#2c3036"}}, +{QLatin1StringView{"SURFACE"}, QLatin1StringView{"#25282e"}}, +{QLatin1StringView{"BORDER"}, QLatin1StringView{"#2f333a"}}, +{QLatin1StringView{"TEXT_MAIN"}, QLatin1StringView{"#f2f3f5"}}, +{QLatin1StringView{"TEXT_MUTED"}, QLatin1StringView{"#949ba4"}}, +{QLatin1StringView{"TEXT_DIM"}, QLatin1StringView{"#6c737f"}}, +{QLatin1StringView{"TEXT_ON_ACCENT"}, QLatin1StringView{"#0b0d10"}}, +{QLatin1StringView{"NEUTRAL"}, QLatin1StringView{"#6b7280"}}, +{QLatin1StringView{"PRIMARY"}, QLatin1StringView{"#66c0f4"}}, +{QLatin1StringView{"PRIMARY_HOVER"}, QLatin1StringView{"#4aa8d8"}}, +{QLatin1StringView{"PRIMARY_BG"}, QLatin1StringView{"rgba(102,192,244,0.1)"}}, +{QLatin1StringView{"PRIMARY_BG_QML"}, QLatin1StringView{"#1a66c0f4"}}, +{QLatin1StringView{"SUCCESS"}, QLatin1StringView{"#3ba55d"}}, +{QLatin1StringView{"SUCCESS_HOVER"}, QLatin1StringView{"#2f8a4c"}}, +{QLatin1StringView{"SUCCESS_BG"}, QLatin1StringView{"rgba(59,165,93,0.15)"}}, +{QLatin1StringView{"SUCCESS_BG_QML"}, QLatin1StringView{"#263ba55d"}}, +{QLatin1StringView{"WARNING"}, QLatin1StringView{"#f9a825"}}, +{QLatin1StringView{"WARNING_BG"}, QLatin1StringView{"rgba(249,168,37,0.15)"}}, +{QLatin1StringView{"WARNING_BG_QML"}, QLatin1StringView{"#26f9a825"}}, +{QLatin1StringView{"DANGER"}, QLatin1StringView{"#ed4245"}}, +{QLatin1StringView{"DANGER_HOVER"}, QLatin1StringView{"#d1383b"}}, +{QLatin1StringView{"DANGER_BG"}, QLatin1StringView{"rgba(237,66,69,0.15)"}}, +{QLatin1StringView{"DANGER_BG_QML"}, QLatin1StringView{"#26ed4245"}}, diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp new file mode 100644 index 0000000..7ac3593 --- /dev/null +++ b/src/ui/theme/qml_theme.cpp @@ -0,0 +1,91 @@ +#include "ui/theme/qml_theme.h" + +#include +#include +#include +#include + +#include "ui/theme/constants.h" +#include "ui/theme/palette.h" + +namespace px::ui { + +namespace { + +const QString kLogoDir = QStringLiteral(":/qt/qml/PxModRim/assets/"); + +// Reads the four embedded logo variants and encodes each as an SVG data URI. +QStringList loadLogoVariants() { + QStringList uris; + for (int n = 1; n <= 4; ++n) { + const QString path = kLogoDir + QStringLiteral("logo_nobg_%1.svg").arg(n); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::error("missing embedded logo resource {}", path.toStdString()); + continue; + } + uris.append(QStringLiteral("data:image/svg+xml;base64,") + + QString::fromLatin1(file.readAll().toBase64())); + } + return uris; +} + +} // namespace + +Theme::Theme(QObject* parent) : QObject(parent), logo_variants_(loadLogoVariants()) { + if (!logo_variants_.isEmpty()) { + const int pick = QRandomGenerator::global()->bounded(static_cast(logo_variants_.size())); + logo_data_uri_ = logo_variants_.at(pick); + } +} + +QString Theme::elevate0() const { return paletteValue(u"ELEVATE_0"); } +QString Theme::elevate1() const { return paletteValue(u"ELEVATE_1"); } +QString Theme::elevate2() const { return paletteValue(u"ELEVATE_2"); } +QString Theme::elevate3() const { return paletteValue(u"ELEVATE_3"); } +QString Theme::elevate4() const { return paletteValue(u"ELEVATE_4"); } +QString Theme::border() const { return paletteValue(u"BORDER"); } + +QString Theme::textMain() const { return paletteValue(u"TEXT_MAIN"); } +QString Theme::textMuted() const { return paletteValue(u"TEXT_MUTED"); } +QString Theme::textDim() const { return paletteValue(u"TEXT_DIM"); } +QString Theme::onAccent() const { return paletteValue(u"TEXT_ON_ACCENT"); } +QString Theme::neutral() const { return paletteValue(u"NEUTRAL"); } + +QString Theme::primary() const { return paletteValue(u"PRIMARY"); } +QString Theme::primaryHover() const { return paletteValue(u"PRIMARY_HOVER"); } +QString Theme::primaryBg() const { return paletteValue(u"PRIMARY_BG_QML"); } +QString Theme::success() const { return paletteValue(u"SUCCESS"); } +QString Theme::successHover() const { return paletteValue(u"SUCCESS_HOVER"); } +QString Theme::successBg() const { return paletteValue(u"SUCCESS_BG_QML"); } +QString Theme::warning() const { return paletteValue(u"WARNING"); } +QString Theme::warningBg() const { return paletteValue(u"WARNING_BG_QML"); } +QString Theme::danger() const { return paletteValue(u"DANGER"); } +QString Theme::dangerHover() const { return paletteValue(u"DANGER_HOVER"); } +QString Theme::dangerBg() const { return paletteValue(u"DANGER_BG_QML"); } + +QString Theme::fontFamily() const { return QStringLiteral("Source Sans 3, Liberation Sans, sans-serif"); } +QString Theme::fontMono() const { return QStringLiteral("DejaVu Sans Mono, Liberation Mono, monospace"); } +int Theme::fontSizeXs() const { return 10; } +int Theme::fontSizeSm() const { return 11; } +int Theme::fontSizeMd() const { return 13; } +int Theme::fontSizeLg() const { return 14; } +int Theme::fontSizeXl() const { return 18; } +int Theme::radiusXs() const { return 3; } +int Theme::radiusSm() const { return 4; } +int Theme::radiusMd() const { return 6; } +int Theme::radiusLg() const { return 8; } +int Theme::radiusPill() const { return 999; } +int Theme::scrollbarWidth() const { return 6; } +int Theme::tooltipDelay() const { return TOOLTIP_DELAY_MS; } +double Theme::disabledOpacity() const { return 0.45; } +QString Theme::overlay() const { return QStringLiteral("#8c000000"); } +int Theme::railMinWidth() const { return RAIL_MIN_WIDTH; } +int Theme::railMaxWidth() const { return RAIL_MAX_WIDTH; } +int Theme::railCollapseWidth() const { return RAIL_COLLAPSE_WIDTH; } +int Theme::sidebarWidth() const { return SIDEBAR_WIDTH; } + +QStringList Theme::logoVariants() const { return logo_variants_; } +QString Theme::logoFileDataUri() const { return logo_data_uri_; } + +} // namespace px::ui diff --git a/src/ui/theme/qml_theme.h b/src/ui/theme/qml_theme.h new file mode 100644 index 0000000..7c09241 --- /dev/null +++ b/src/ui/theme/qml_theme.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include + +namespace px::ui { + +// Design tokens for QML, exposed as the `Theme` root context property (see +// createQmlEngine). Color values come from the same PALETTE as the stylesheet; +// *Bg values are #AARRGGBB because QML rejects rgba() strings. +class Theme : public QObject { + Q_OBJECT + + Q_PROPERTY(QString elevate0 READ elevate0 CONSTANT) + Q_PROPERTY(QString elevate1 READ elevate1 CONSTANT) + Q_PROPERTY(QString elevate2 READ elevate2 CONSTANT) + Q_PROPERTY(QString elevate3 READ elevate3 CONSTANT) + Q_PROPERTY(QString elevate4 READ elevate4 CONSTANT) + Q_PROPERTY(QString border READ border CONSTANT) + + Q_PROPERTY(QString textMain READ textMain CONSTANT) + Q_PROPERTY(QString textMuted READ textMuted CONSTANT) + Q_PROPERTY(QString textDim READ textDim CONSTANT) + Q_PROPERTY(QString onAccent READ onAccent CONSTANT) + Q_PROPERTY(QString neutral READ neutral CONSTANT) + + Q_PROPERTY(QString primary READ primary CONSTANT) + Q_PROPERTY(QString primaryHover READ primaryHover CONSTANT) + Q_PROPERTY(QString primaryBg READ primaryBg CONSTANT) + Q_PROPERTY(QString success READ success CONSTANT) + Q_PROPERTY(QString successHover READ successHover CONSTANT) + Q_PROPERTY(QString successBg READ successBg CONSTANT) + Q_PROPERTY(QString warning READ warning CONSTANT) + Q_PROPERTY(QString warningBg READ warningBg CONSTANT) + Q_PROPERTY(QString danger READ danger CONSTANT) + Q_PROPERTY(QString dangerHover READ dangerHover CONSTANT) + Q_PROPERTY(QString dangerBg READ dangerBg CONSTANT) + + Q_PROPERTY(QString fontFamily READ fontFamily CONSTANT) + Q_PROPERTY(QString fontMono READ fontMono CONSTANT) + Q_PROPERTY(int fontSizeXs READ fontSizeXs CONSTANT) + Q_PROPERTY(int fontSizeSm READ fontSizeSm CONSTANT) + Q_PROPERTY(int fontSizeMd READ fontSizeMd CONSTANT) + Q_PROPERTY(int fontSizeLg READ fontSizeLg CONSTANT) + Q_PROPERTY(int fontSizeXl READ fontSizeXl CONSTANT) + Q_PROPERTY(int radiusXs READ radiusXs CONSTANT) + Q_PROPERTY(int radiusSm READ radiusSm CONSTANT) + Q_PROPERTY(int radiusMd READ radiusMd CONSTANT) + Q_PROPERTY(int radiusLg READ radiusLg CONSTANT) + Q_PROPERTY(int radiusPill READ radiusPill CONSTANT) + Q_PROPERTY(int scrollbarWidth READ scrollbarWidth CONSTANT) + Q_PROPERTY(int tooltipDelay READ tooltipDelay CONSTANT) + Q_PROPERTY(double disabledOpacity READ disabledOpacity CONSTANT) + Q_PROPERTY(QString overlay READ overlay CONSTANT) + Q_PROPERTY(int railMinWidth READ railMinWidth CONSTANT) + Q_PROPERTY(int railMaxWidth READ railMaxWidth CONSTANT) + Q_PROPERTY(int railCollapseWidth READ railCollapseWidth CONSTANT) + Q_PROPERTY(int sidebarWidth READ sidebarWidth CONSTANT) + + Q_PROPERTY(QStringList logoVariants READ logoVariants CONSTANT) + Q_PROPERTY(QString logoFileDataUri READ logoFileDataUri CONSTANT) + +public: + // Picks one of the four logo variants at random, as the legacy Theme did. + explicit Theme(QObject* parent = nullptr); + + QString elevate0() const; + QString elevate1() const; + QString elevate2() const; + QString elevate3() const; + QString elevate4() const; + QString border() const; + + QString textMain() const; + QString textMuted() const; + QString textDim() const; + QString onAccent() const; + QString neutral() const; + + QString primary() const; + QString primaryHover() const; + QString primaryBg() const; + QString success() const; + QString successHover() const; + QString successBg() const; + QString warning() const; + QString warningBg() const; + QString danger() const; + QString dangerHover() const; + QString dangerBg() const; + + QString fontFamily() const; + QString fontMono() const; + int fontSizeXs() const; + int fontSizeSm() const; + int fontSizeMd() const; + int fontSizeLg() const; + int fontSizeXl() const; + int radiusXs() const; + int radiusSm() const; + int radiusMd() const; + int radiusLg() const; + int radiusPill() const; + int scrollbarWidth() const; + int tooltipDelay() const; + double disabledOpacity() const; + QString overlay() const; + int railMinWidth() const; + int railMaxWidth() const; + int railCollapseWidth() const; + int sidebarWidth() const; + + QStringList logoVariants() const; + QString logoFileDataUri() const; + +private: + QStringList logo_variants_; + QString logo_data_uri_; +}; + +} // namespace px::ui diff --git a/src/ui/theme/style.qss b/src/ui/theme/style.qss new file mode 100644 index 0000000..8071ea9 --- /dev/null +++ b/src/ui/theme/style.qss @@ -0,0 +1,663 @@ +/* ── Base ── */ +QMainWindow, +QDialog { + background-color: $ELEVATE_0; + color: $TEXT_MAIN; +} + +QWidget { + color: $TEXT_MAIN; + font-family: "Source Sans 3", "Liberation Sans", sans-serif; +} + +/* ── Panel containers ── */ +QWidget#outerContainer { + background-color: $ELEVATE_0; +} +QWidget#contentArea { + background-color: $ELEVATE_0; +} +QWidget#sidebarPanel { + background-color: $ELEVATE_2; +} +QWidget#modListPanel { + background-color: $ELEVATE_1; +} +QWidget#modInfoPanel { + background-color: $ELEVATE_2; +} + +/* ── Transparent overrides ── */ +QScrollArea, +QWidget#accordionContent, +QWidget#toastManager { + background-color: transparent; +} + +/* ── Menu Bar ── */ +QMenuBar { + background-color: $ELEVATE_1; + border-bottom: 1px solid $BORDER; + padding: 2px 4px; + color: $TEXT_MUTED; + font-size: 13px; +} +QMenuBar::item { + padding: 4px 10px; + border-radius: 4px; + background: transparent; +} +QMenuBar::item:selected { + background-color: $ELEVATE_3; + color: $TEXT_MAIN; +} + +QMenu { + background-color: $ELEVATE_2; + border: 1px solid $BORDER; + border-radius: 6px; + padding: 4px; + color: $TEXT_MAIN; +} +QMenu::item { + padding: 6px 28px 6px 12px; + border-radius: 4px; +} +QMenu::item:selected { + background-color: $ELEVATE_3; +} +QMenu::item:disabled { + color: $TEXT_DIM; +} +QMenu::item:disabled:selected { + background-color: transparent; +} +QMenu::separator { + height: 1px; + background-color: $BORDER; + margin: 4px 8px; +} + +/* ── Buttons ── */ +QPushButton { + background-color: $ELEVATE_3; + border: 1px solid $BORDER; + border-radius: 6px; + color: $TEXT_MAIN; + font-size: 13px; + font-weight: 500; +} +QPushButton#appBtn, +QPushButton#primaryAction { + padding: 6px 14px; + min-height: 18px; +} +QPushButton:hover { + background-color: $ELEVATE_4; + border-color: $ELEVATE_4; +} +QPushButton:pressed { + background-color: $ELEVATE_4; +} +QPushButton#primaryAction { + background-color: $PRIMARY; + border-color: $PRIMARY; + color: $ELEVATE_0; + font-weight: 600; +} +QPushButton#primaryAction:hover { + background-color: $PRIMARY_HOVER; + border-color: $PRIMARY_HOVER; +} +/* Platform themes (e.g. Breeze) add dark icons to standard dialog buttons that + vanish on our dark background; drop them and match AppButton sizing. */ +QDialogButtonBox { + dialogbuttonbox-buttons-have-icons: 0; +} +QMessageBox QPushButton, +QDialogButtonBox QPushButton { + padding: 6px 14px; + min-height: 18px; + min-width: 64px; +} + +/* ── Line Edit ── */ +QLineEdit { + background-color: $ELEVATE_0; + border: 1px solid $BORDER; + border-radius: 6px; + color: $TEXT_MAIN; + padding: 7px 10px; + font-size: 13px; + selection-background-color: $PRIMARY; +} +QLineEdit:focus { + border-color: $PRIMARY; +} +QWidget#searchBox { + background-color: $ELEVATE_1; + border-bottom: 1px solid $BORDER; +} + +/* ── Combo Box ── */ +QComboBox { + background-color: $ELEVATE_0; + border: 1px solid $BORDER; + border-radius: 6px; + color: $TEXT_MAIN; + padding: 6px 10px; + font-size: 13px; + min-width: 80px; +} +QComboBox:hover { + border-color: $ELEVATE_4; +} +QComboBox:focus { + border-color: $PRIMARY; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid $BORDER; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} +QComboBox::down-arrow { + width: 8px; + height: 8px; +} +QComboBox QAbstractItemView { + background-color: $ELEVATE_2; + border: 1px solid $BORDER; + border-radius: 6px; + selection-background-color: $ELEVATE_3; + selection-color: $TEXT_MAIN; + color: $TEXT_MAIN; + outline: none; + padding: 4px; +} + +/* ── Item Views ── */ +QTableWidget, +QTableView, +QListWidget, +QListView, +QTreeWidget, +QTreeView { + background-color: $ELEVATE_0; + alternate-background-color: $ELEVATE_1; + border: 1px solid $BORDER; + border-radius: 6px; + color: $TEXT_MAIN; + gridline-color: $BORDER; + selection-background-color: $PRIMARY; + selection-color: $ELEVATE_0; + outline: none; +} +QTableWidget::item, +QTableView::item { + padding: 4px 8px; +} +QListWidget::item:disabled, +QListView::item:disabled, +QTreeWidget::item:disabled, +QTreeView::item:disabled { + color: $TEXT_DIM; +} +QListWidget::item, +QListView::item, +QTreeWidget::item, +QTreeView::item { + padding: 4px 8px; + border-radius: 4px; +} +QListWidget::item:hover, +QListView::item:hover, +QTreeWidget::item:hover, +QTreeView::item:hover { + background-color: $ELEVATE_3; +} +QTableWidget::item:selected, +QTableView::item:selected, +QListWidget::item:selected, +QListView::item:selected, +QTreeWidget::item:selected, +QTreeView::item:selected { + background-color: $PRIMARY; + color: $ELEVATE_0; +} +QHeaderView { + background-color: $ELEVATE_2; +} +QHeaderView::section { + background-color: $ELEVATE_2; + color: $TEXT_DIM; + border: none; + border-bottom: 1px solid $BORDER; + padding: 6px 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} +QTableCornerButton::section { + background-color: $ELEVATE_2; + border: none; +} + +/* ── Scroll Area ── */ +QScrollArea { + border: none; + background-color: transparent; +} + +/* ── Scroll Bars ── */ +QScrollBar:vertical { + background-color: transparent; + width: 6px; + margin: 0; +} +QScrollBar::handle:vertical { + background-color: $ELEVATE_4; + border-radius: 3px; + min-height: 30px; + margin: 2px; +} +QScrollBar::handle:vertical:hover { + background-color: $TEXT_DIM; +} +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0; +} +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical { + background: none; +} +QScrollBar:horizontal { + background-color: transparent; + height: 6px; + margin: 0; +} +QScrollBar::handle:horizontal { + background-color: $ELEVATE_4; + border-radius: 3px; + min-width: 30px; + margin: 2px; +} +QScrollBar::handle:horizontal:hover { + background-color: $TEXT_DIM; +} +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0; +} +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal { + background: none; +} + +/* ── Dialog / Organizer labels ── */ +QLabel#dialogTitle { + font-size: 15px; + font-weight: 600; +} +QLabel#organizerStatus { + color: $TEXT_DIM; + padding: 5px 12px; +} + +/* ── Info Panel ── */ +QWidget#infoArea { + background-color: $ELEVATE_2; +} +QWidget#depsSection { + background-color: transparent; +} + +/* ── Placeholder ── */ +QLabel#placeholder { + color: $TEXT_DIM; + font-size: 14px; + padding: 32px; +} + +/* ── Description Browser ── */ +QTextBrowser#descriptionBrowser { + background: transparent; + border: none; + padding: 0; + font-size: 13px; + color: $TEXT_MUTED; + line-height: 1.5; +} + +/* ── Meta Chip ── */ +QWidget#metaChip { + background-color: $ELEVATE_3; + border: 1px solid $BORDER; + border-radius: 6px; + padding: 0; + min-width: 120px; +} +QLabel#metaChipLabel { + color: $TEXT_DIM; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} +QLabel#metaChipValue { + font-weight: 500; + font-size: 13px; + color: $TEXT_MAIN; +} + +/* ── Issues Label ── */ +QLabel#issuesLabel { + color: $TEXT_DIM; + font-style: italic; + font-size: 12px; +} + +/* ── Meta Labels ── */ +QLabel#metaLabel { + color: $TEXT_DIM; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} +QLabel#metaValue { + font-weight: 500; + font-size: 13px; + color: $TEXT_MAIN; +} +QWidget#metaWidget { + background-color: $ELEVATE_3; + border: 1px solid $BORDER; + border-radius: 6px; + padding: 8px 12px; +} + +/* ── Status Bar ── */ +QStatusBar { + background-color: $ELEVATE_2; + border-top: 1px solid $BORDER; + color: $TEXT_MUTED; + font-size: 11px; + padding: 2px 8px; +} + +/* ── Tool Tip ── */ +QToolTip { + background-color: $ELEVATE_2; + border: 1px solid $BORDER; + border-radius: 6px; + color: $TEXT_MAIN; + padding: 6px 10px; + font-size: 11px; +} + +/* ── Accordion ── */ +QWidget#accordionHeader { + background: transparent; + border: none; + border-bottom: 1px solid $BORDER; +} +QWidget#accordionHeader:hover { + background: $ELEVATE_3; +} +QLabel#accordionTitle { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $TEXT_DIM; +} +QLabel#accordionChevron { + font-size: 10px; + font-weight: 600; + color: $TEXT_DIM; + background: transparent; +} +QWidget#accordionContent { + background: transparent; +} + +/* ── Group Box ── */ +QGroupBox { + border: 1px solid $BORDER; + border-radius: 6px; + margin-top: 12px; + padding: 16px 12px 12px; + font-size: 12px; + font-weight: 600; + color: $TEXT_MAIN; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 8px; + color: $TEXT_DIM; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* ── Check Box ── */ +QCheckBox { + color: $TEXT_MAIN; + font-size: 13px; + spacing: 8px; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1.5px solid $BORDER; + border-radius: 3px; + background: transparent; +} +QCheckBox::indicator:hover { + border-color: $TEXT_DIM; +} +QCheckBox::indicator:checked { + background: $PRIMARY; + border-color: $PRIMARY; +} + +/* ── Tab Widget ── */ +QTabWidget::pane { + border: none; + background: transparent; +} +QTabBar::tab { + background: transparent; + color: $TEXT_MUTED; + padding: 8px 16px; + border-bottom: 2px solid transparent; + font-size: 12px; +} +QTabBar::tab:selected { + color: $PRIMARY; + border-bottom-color: $PRIMARY; +} +QTabBar::tab:hover { + color: $TEXT_MAIN; +} + + + +/* ── Dialog chrome (frameless dialogs) ── */ +QDialog[chromed="true"] { + border: 1px solid $BORDER; +} +QWidget#dialogTitleBar { + background-color: $ELEVATE_1; + border-bottom: 1px solid $BORDER; +} +QLabel#dialogTitleText { + background: transparent; + font-weight: 600; + font-size: 13px; +} +QPushButton#dialogCloseBtn { + background-color: $DANGER_BG; + border: 1px solid $DANGER_BG; + border-radius: 6px; + padding: 0; +} +QPushButton#dialogCloseBtn:hover { + background-color: $DANGER; + border-color: $DANGER; +} + +/* ── Icon Buttons ── */ +QPushButton#iconBtn { + background-color: $ELEVATE_3; + border: 1px solid $BORDER; + border-radius: 6px; + padding: 0; + min-height: 0; +} +QPushButton#iconBtn:hover { + background-color: $ELEVATE_4; + border-color: $ELEVATE_4; +} +QPushButton#iconBtn[primary="true"] { + background-color: $PRIMARY; + border-color: $PRIMARY; +} +QPushButton#iconBtn[primary="true"]:hover { + background-color: $PRIMARY_HOVER; + border-color: $PRIMARY_HOVER; +} + +/* ── Toast ── */ +QWidget#toast { + background-color: $SURFACE; + border: 1px solid $BORDER; + border-radius: 8px; +} +QLabel#toastMessage { + color: $TEXT_MAIN; + font-size: 12px; +} +QPushButton#toastClose { + background: transparent; + border: none; + border-radius: 3px; + padding: 0; + min-height: 0; +} +QPushButton#toastClose:hover { + background-color: $ELEVATE_3; +} +QWidget#toastManager { + background: transparent; + border: none; +} + +/* ── Settings Dialog ── */ +QWidget#settingsPanel { + background-color: $ELEVATE_1; +} +QWidget#settingsPanel QTabWidget::pane { + background-color: $ELEVATE_1; +} +QWidget#settingsPanel QGroupBox { + background-color: $ELEVATE_2; +} +QWidget#settingsPanel QFormLayout { + background-color: transparent; +} + +/* ── Progress Bar ── */ +QProgressBar { + border: 1px solid $BORDER; + border-radius: 4px; + background: $ELEVATE_0; + text-align: center; + font-size: 11px; + height: 18px; +} +QProgressBar::chunk { + background: $PRIMARY; + border-radius: 3px; +} +QProgressBar#thinProgress { + border: none; + border-radius: 2px; + background: $ELEVATE_3; + height: 4px; + max-height: 4px; + min-height: 4px; + text-align: left; +} +QProgressBar#thinProgress::chunk { + border-radius: 2px; +} + +/* ── About Dialog ── */ +QDialog#aboutPanel, +QDialog#creditsDialog { + background-color: $ELEVATE_1; +} +QFrame#aboutHero { + background-color: $ELEVATE_2; + border: 1px solid $BORDER; + border-radius: 10px; +} +QLabel#aboutTitle, +QLabel#creditsTitle { + font-size: 24px; + font-weight: 700; + color: $TEXT_MAIN; +} +QLabel#aboutVersionBadge { + background-color: $PRIMARY; + border-radius: 8px; + color: $ELEVATE_0; + font-size: 11px; + font-weight: 700; + padding: 3px 8px; +} +QLabel#aboutTagline { + color: $TEXT_MAIN; + font-size: 15px; + font-weight: 600; +} +QLabel#aboutDescription, +QLabel#aboutStory { + color: $TEXT_MUTED; + font-size: 13px; +} +QLabel#aboutSectionTitle, +QLabel#aboutMetaLabel { + color: $TEXT_DIM; + font-size: 10px; + font-weight: 700; +} +QFrame#aboutDetails { + background-color: $ELEVATE_0; + border: 1px solid $BORDER; + border-radius: 8px; +} +QLabel#aboutMetaValue { + color: $TEXT_MAIN; + font-size: 12px; +} +QLabel#aboutDisclaimer { + color: $TEXT_DIM; + font-size: 11px; + font-style: italic; +} +QTextBrowser#creditsBrowser { + background-color: $ELEVATE_0; + border: 1px solid $BORDER; + border-radius: 8px; + color: $TEXT_MAIN; + padding: 12px; +} diff --git a/src/ui/thumbs.js b/src/ui/thumbs.js new file mode 100644 index 0000000..fb16142 --- /dev/null +++ b/src/ui/thumbs.js @@ -0,0 +1,24 @@ +.pragma library + +// Steam's image CDN resizes on request. Asking for the size that is shown keeps +// both the download and the decoded pixmap small. +function sized(url, width, height) { + if (!/^https:\/\/(?:[a-z0-9-]+\.)*(?:steamusercontent\.com|steamstatic\.com)\//i.test(url)) + return url + + const fragmentAt = url.indexOf("#") + const fragment = fragmentAt < 0 ? "" : url.slice(fragmentAt) + const address = fragmentAt < 0 ? url : url.slice(0, fragmentAt) + const queryAt = address.indexOf("?") + const path = queryAt < 0 ? address : address.slice(0, queryAt) + const query = queryAt < 0 ? [] : address.slice(queryAt + 1).split("&").filter(function (part) { + return part !== "" && !/^(imw|imh|ima|impolicy)=/.test(part) + }) + query.push("imw=" + width, "imh=" + height, "ima=fit", "impolicy=Letterbox") + return path + "?" + query.join("&") + fragment +} + +// Round up to a step so that small layout changes do not reload the image. +function bucket(value, step) { + return Math.max(step, Math.ceil(value / step) * step) +} diff --git a/src/ui/ui_prefs.h b/src/ui/ui_prefs.h new file mode 100644 index 0000000..890f6e4 --- /dev/null +++ b/src/ui/ui_prefs.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "core/constants.h" + +namespace px::ui { + +// Persisted UI state (ui_prefs.json). Field names are the JSON keys. +struct UiPrefs { + bool desc_expanded = false; + core::LaunchStrategy launch_strategy = core::LaunchStrategy::Direct; + std::string skipped_update_tag; + bool rail_collapsed = false; + + bool operator==(const UiPrefs&) const = default; +}; + +} // namespace px::ui diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp new file mode 100644 index 0000000..daa20d2 --- /dev/null +++ b/src/ui/views/base.cpp @@ -0,0 +1,25 @@ +#include "ui/views/base.h" + +#include +#include + +#include "ui/theme/palette.h" + +namespace px::ui { + +BaseViewPanel::BaseViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : RailView(ctx, engine, parent, app) { + setAutoFillBackground(true); + root_ = new QVBoxLayout(this); + root_->setContentsMargins(0, 0, 0, 0); + root_->setSpacing(0); + setClearColor(paletteValue(u"ELEVATE_0")); +} + +void BaseViewPanel::setClearColor(const QString& color) { + QPalette palette = this->palette(); + palette.setColor(QPalette::Window, QColor(color)); + setPalette(palette); +} + +} // namespace px::ui diff --git a/src/ui/views/base.h b/src/ui/views/base.h new file mode 100644 index 0000000..a5e855e --- /dev/null +++ b/src/ui/views/base.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include "ui/context.h" + +namespace px::ui { + +// Whole-window view contributed to the left icon rail. Core never includes this: +// views are discovered entirely within ui/. Subclasses declare `kRailMeta`. +class BaseViewPanel : public RailView { + Q_OBJECT + +public: + BaseViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + +protected: + void setClearColor(const QString& color); + + QVBoxLayout* root_ = nullptr; +}; + +} // namespace px::ui diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp new file mode 100644 index 0000000..b206426 --- /dev/null +++ b/src/ui/views/mods_view.cpp @@ -0,0 +1,26 @@ +#include "ui/views/mods_view.h" +#include "ui/theme/constants.h" +#include +namespace px::ui { +ModsViewPanel::ModsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : BaseViewPanel(ctx,engine,parent,app) { + auto* content = new QWidget(this); content->setObjectName(QStringLiteral("contentArea")); + auto* layout = new QHBoxLayout(content); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); + sidebar = new SidebarPanel(ctx,engine,content); sidebar->setObjectName(QStringLiteral("sidebarPanel")); sidebar->setFixedWidth(SIDEBAR_WIDTH); + connect(sidebar,&SidebarPanel::entry_selected,this,&ModsViewPanel::entry_selected); layout->addWidget(sidebar); + mod_list = new ModListPanel(ctx,engine,content); mod_list->setObjectName(QStringLiteral("modListPanel")); + connect(mod_list,&ModListPanel::mod_selected,this,&ModsViewPanel::mod_selected); + connect(mod_list,&ModListPanel::active_mods_changed,this,&ModsViewPanel::active_mods_changed,Qt::QueuedConnection); + connect(mod_list,&ModListPanel::order_changed,this,&ModsViewPanel::order_changed,Qt::QueuedConnection); + layout->addWidget(mod_list,3); + mod_info = new ModInfoPanel(ctx,engine,app.uiPrefs(),content); mod_info->setObjectName(QStringLiteral("modInfoPanel")); mod_info->setMinimumWidth(300); + layout->addWidget(mod_info,2); root_->addWidget(content,1); +} +void ModsViewPanel::apply_sidebar_entry(const core::view::SidebarEntry& entry) { + mod_list->clearSelection(); + mod_list->set_sidebar_filter(entry.visible_uuids); +} +void ModsViewPanel::apply_current_sidebar_filter() { + if (const auto entry = sidebar->current_entry()) mod_list->set_sidebar_filter(entry->visible_uuids); +} +} diff --git a/src/ui/views/mods_view.h b/src/ui/views/mods_view.h new file mode 100644 index 0000000..94ef1c6 --- /dev/null +++ b/src/ui/views/mods_view.h @@ -0,0 +1,23 @@ +#pragma once +#include "ui/views/base.h" +#include "ui/panels/sidebar_panel.h" +#include "ui/panels/mod_list_panel.h" +#include "ui/panels/mod_info_panel.h" +namespace px::ui { +class ModsViewPanel : public BaseViewPanel { + Q_OBJECT +public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), QLatin1StringView("mods"), QLatin1StringView("Mods")}; + ModsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + void apply_sidebar_entry(const core::view::SidebarEntry& entry); + void apply_current_sidebar_filter(); + SidebarPanel* sidebar; + ModListPanel* mod_list; + ModInfoPanel* mod_info; +signals: + void entry_selected(const px::core::view::SidebarEntry& entry); + void mod_selected(const QString& uuid); + void active_mods_changed(); + void order_changed(); +}; +} diff --git a/src/ui/views/views.h b/src/ui/views/views.h new file mode 100644 index 0000000..3ab0d6c --- /dev/null +++ b/src/ui/views/views.h @@ -0,0 +1,5 @@ +#pragma once + +// Aggregate of the built-in whole-window views. Each view header is added here as its +// port lands; registration happens in src/app/plugins.cpp. +#include "ui/views/base.h" diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp new file mode 100644 index 0000000..1f320e1 --- /dev/null +++ b/src/ui/window/actions.cpp @@ -0,0 +1,66 @@ +#include "ui/window/actions.h" + +#include +#include +#include + +namespace px::ui { + +QString ActionSpec::tooltip() const { + return shortcut.isEmpty() ? label + : QStringLiteral("%1 (%2)").arg(label, QKeySequence(shortcut).toString(QKeySequence::NativeText)); +} + +const std::array& actionSpecs() { + static const std::array specs{{ + {ActionId::Save, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, + {ActionId::Restore, "restore", "&Restore Mod List…", "Restore mod list", {}}, + {ActionId::Settings, "settings", "&Settings…", "Settings", "Ctrl+,"}, + {ActionId::Quit, "quit", "&Quit", "Quit", "Ctrl+Q"}, + {ActionId::Refresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, + {ActionId::FullRescan, "full_rescan", "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R"}, + {ActionId::AutoSort, "auto_sort", "&Auto-Sort Mods", "Auto-sort mods", {}}, + {ActionId::UpdateMods, "update_mods", "&Update Mods", "Update downloaded mods", {}}, + {ActionId::FocusSearch, "focus_search", "Focus &Search", "Focus search", "Ctrl+F"}, + {ActionId::NextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, + {ActionId::PrevView, "prev_view", "&Previous View", "Previous view", "Ctrl+Shift+Tab"}, + {ActionId::Fullscreen, "fullscreen", "Toggle &Fullscreen", "Toggle fullscreen", "F11"}, + {ActionId::ReportIssue, "report_issue", "&Report Issue", "Report issue", {}}, + {ActionId::UploadLogs, "upload_logs", "Log && System &Info…", "Log & system info", {}}, + {ActionId::OpenLogs, "open_logs", "Open &Logs Folder", "Open logs folder", {}}, + {ActionId::Shortcuts, "shortcuts", "&Keyboard Shortcuts", "Keyboard shortcuts", {}}, + {ActionId::CheckUpdates, "check_updates", "Check for &Updates…", "Check for updates", {}}, + {ActionId::About, "about", "&About PxModRim", "About PxModRim", "F1"}, + }}; + return specs; +} + +const ActionSpec& actionSpec(ActionId id) { + return actionSpecs()[static_cast(id)]; +} + +ActionMap createActions(QWidget& window) { + ActionMap actions; + for (const auto& spec : actionSpecs()) { + auto* action = new QAction(spec.menuText, &window); + if (!spec.shortcut.isEmpty()) action->setShortcut(QKeySequence(spec.shortcut)); + window.addAction(action); + actions.emplace(spec.id, action); + } + return actions; +} + +QList> shortcutRows() { + QList> rows; + for (const auto& spec : actionSpecs()) { + if (!spec.shortcut.isEmpty()) { + rows.append({QKeySequence(spec.shortcut).toString(QKeySequence::NativeText), spec.label}); + } + } + rows.append({QStringLiteral("Ctrl+1–9"), QStringLiteral("Switch to view")}); + rows.append({QStringLiteral("Alt"), QStringLiteral("Show or hide menu bar")}); + rows.append({QStringLiteral("Esc"), QStringLiteral("Hide menu bar")}); + return rows; +} + +} // namespace px::ui diff --git a/src/ui/window/actions.h b/src/ui/window/actions.h new file mode 100644 index 0000000..b97d606 --- /dev/null +++ b/src/ui/window/actions.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +class QAction; +class QWidget; + +namespace px::ui { + +enum class ActionId { + Save, Restore, Settings, Quit, Refresh, FullRescan, AutoSort, UpdateMods, + FocusSearch, NextView, PrevView, Fullscreen, ReportIssue, UploadLogs, + OpenLogs, Shortcuts, CheckUpdates, About, +}; + +struct ActionSpec { + ActionId id; + QString key; + QString menuText; + QString label; + QString shortcut; + QString tooltip() const; +}; + +using ActionMap = std::map; +const std::array& actionSpecs(); +const ActionSpec& actionSpec(ActionId id); +ActionMap createActions(QWidget& window); +QList> shortcutRows(); + +} // namespace px::ui diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp new file mode 100644 index 0000000..0b186ac --- /dev/null +++ b/src/ui/window/main_window.cpp @@ -0,0 +1,574 @@ +#include "ui/window/main_window.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/downloads/manager.h" +#include "core/mod_service.h" +#include "core/services/diagnostics_service.h" +#include "core/services/game_launcher.h" +#include "core/version.h" +#include "ui/components/dialogs.h" +#include "ui/components/header_controller.h" +#include "ui/components/header_panel.h" +#include "ui/components/mod_updates.h" +#include "ui/components/svg_provider.h" +#include "ui/components/toast.h" +#include "ui/components/view_rail_panel.h" +#include "ui/config.h" +#include "ui/mod_selection.h" +#include "ui/navigation.h" +#include "ui/panels/about_panel.h" +#include "ui/panels/keyboard_shortcuts_dialog.h" +#include "ui/panels/mod_info_panel.h" +#include "ui/panels/mod_list_panel.h" +#include "ui/panels/restore_snapshot_dialog.h" +#include "ui/panels/settings_panel.h" +#include "ui/panels/update_dialog.h" +#include "ui/panels/upload_report_dialog.h" +#include "ui/theme/constants.h" +#include "ui/theme/qml_theme.h" +#include "ui/views/mods_view.h" +#include "ui/window/menu_bar.h" + +namespace px::ui { + +UnsavedChangesDialog::UnsavedChangesDialog(QWidget* parent) : QMessageBox(parent) { + setIcon(Question); + setWindowTitle(QStringLiteral("Unsaved Changes")); + setText(QStringLiteral("The active mod list has unsaved changes.")); + setInformativeText(QStringLiteral("Save before closing, discard the changes, or cancel closing.")); + setStandardButtons(Save | Discard | Cancel); + setDefaultButton(Save); +} + +LaunchConfirmDialog::LaunchConfirmDialog(QWidget* parent, const QString& title, + const QString& text, const QString& info, const QString& accept) : QMessageBox(parent) { + setIcon(Warning); + setWindowTitle(title); + setText(text); + setInformativeText(info); + setStandardButtons(Ok | Cancel); + button(Ok)->setText(accept); + setDefaultButton(Cancel); + setCheckBox(new QCheckBox(QStringLiteral("Don't ask again"), this)); +} + +bool LaunchConfirmDialog::dontAskAgain() const { return checkBox()->isChecked(); } + +MainWindow::MainWindow(AppContext& app, QWidget* parent) + : QMainWindow(parent), app_(app), ctx_(app.coreContext()), + engine_(createQmlEngine(this)), saved_active_(ctx_.loadedActiveUuids()), + updates_(core::appVersion(), ctx_.network()) { + setWindowTitle(QStringLiteral("PxModRim[*]")); + setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + resize(1400, 850); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint + | Qt::WindowMaximizeButtonHint | Qt::WindowCloseButtonHint); + setFocusPolicy(Qt::ClickFocus); + engine_->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(engine_)); + + const QVariantMap tooltips{ + {"refresh", actionSpec(ActionId::Refresh).tooltip()}, + {"sort", actionSpec(ActionId::AutoSort).tooltip()}, + {"save", actionSpec(ActionId::Save).tooltip()}, + {"update_mods", actionSpec(ActionId::UpdateMods).tooltip()}, + }; + header_controller_ = new HeaderController(windowFlags().testFlag(Qt::FramelessWindowHint), + static_cast(app_.uiPrefs().launch_strategy), core::appVersion(), tooltips, + ctx_.downloads().available(), this); + header_ = new HeaderPanel(*header_controller_, engine_, this); + setupActions(); + menu_ = new MenuBar(actions_, this); + menu_->hide(); + connect(header_controller_, &HeaderController::refreshRequested, this, [this] { reloadMods(); }); + connect(header_controller_, &HeaderController::sortRequested, this, [this] { autoSort(); }); + connect(header_controller_, &HeaderController::saveRequested, this, [this] { saveActiveMods(); }); + connect(header_controller_, &HeaderController::launchRequested, this, [this] { launchGame(); }); + connect(header_controller_, &HeaderController::updateModsRequested, this, [this] { updateMods(); }); + connect(header_controller_, &HeaderController::downloadsRequested, this, &MainWindow::openDownloads); + connect(header_controller_, &HeaderController::minimizeRequested, this, &QWidget::showMinimized); + connect(header_controller_, &HeaderController::maximizeRequested, this, [this] { + isMaximized() ? showNormal() : showMaximized(); + }); + connect(header_controller_, &HeaderController::closeRequested, this, &QWidget::close); + connect(header_controller_, &HeaderController::dragStartRequested, this, [this] { + if (windowHandle()) windowHandle()->startSystemMove(); + }); + connect(header_controller_, &HeaderController::strategyChanged, this, [this](int index) { + const auto strategy = static_cast(index); + if (app_.uiPrefs().launch_strategy != strategy) { + app_.uiPrefs().launch_strategy = strategy; + savePrefs(); + } + }); + + auto* outer = new QWidget(this); + outer->setObjectName(QStringLiteral("outerContainer")); + auto* layout = new QVBoxLayout(outer); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(header_); + layout->addWidget(menu_); + QVariantList tabs; + for (const auto& view : app_.railViews()) { + tabs.append(QVariantMap{{"viewId", QString(view.meta.viewId)}, + {"icon", QString(view.meta.iconName)}, {"label", QString(view.meta.label)}}); + } + QVariantList help; + for (const auto id : {ActionId::ReportIssue, ActionId::UploadLogs, ActionId::OpenLogs, + ActionId::Shortcuts, ActionId::CheckUpdates, ActionId::About}) { + const auto& spec = actionSpec(id); + help.append(QVariantMap{{"id", spec.key}, {"label", spec.label}}); + } + splitter_ = new QSplitter(Qt::Horizontal, outer); + splitter_->setContentsMargins(0, 0, 0, 0); + splitter_->setHandleWidth(1); + rail_ = new ViewRailPanel(tabs, help, engine_, splitter_); + rail_->setObjectName(QStringLiteral("viewRail")); + rail_->setMinimumWidth(RAIL_MIN_WIDTH); + rail_->setMaximumWidth(RAIL_MAX_WIDTH); + stack_ = new QStackedWidget(splitter_); + stack_->setObjectName(QStringLiteral("viewStack")); + splitter_->addWidget(rail_); + splitter_->addWidget(stack_); + splitter_->setStretchFactor(0, 0); + splitter_->setStretchFactor(1, 1); + splitter_->setCollapsible(0, false); + splitter_->setCollapsible(1, false); + const int railWidth = app_.uiPrefs().rail_collapsed ? RAIL_MIN_WIDTH : RAIL_MAX_WIDTH; + splitter_->setSizes({railWidth, width() - railWidth}); + connect(rail_, &ViewRailPanel::currentChanged, this, &MainWindow::selectView); + connect(rail_, &ViewRailPanel::hovered, this, &MainWindow::preloadView); + connect(rail_, &ViewRailPanel::settingsRequested, this, [this] { app_.navigate(buildRoute(QString(kSettingsViewId))); }); + connect(rail_, &ViewRailPanel::helpActionRequested, this, [this](const QString& key) { + for (const auto& spec : actionSpecs()) if (spec.key == key) actions_.at(spec.id)->trigger(); + }); + connect(splitter_, &QSplitter::splitterMoved, this, [this] { snapRail(); }); + for (const auto& spec : app_.railViews()) { + auto* view = spec.create(ctx_, engine_, stack_, app_); + views_.push_back(view); + stack_->addWidget(view); + if (spec.meta.viewId == QLatin1StringView("mods")) mods_view_ = qobject_cast(view); + } + if (mods_view_) { + selection_ = std::make_unique(ctx_, *mods_view_->mod_info); + connect(mods_view_, &ModsViewPanel::entry_selected, this, [this](const core::view::SidebarEntry& entry) { + mods_view_->apply_sidebar_entry(entry); + }); + connect(mods_view_, &ModsViewPanel::mod_selected, this, [this](const QString& uuid) { selectMod(uuid); }); + connect(mods_view_->mod_info, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { requestMod(uuid); }); + } + layout->addWidget(splitter_, 1); + setCentralWidget(outer); + toasts_ = new ToastManager(outer); + toasts_->resizeToParent(); + app_.setToasts(toasts_); + app_.setNavigator([this](const QString& url) { openRoute(url); }); + active_connection_ = ctx_.activeStateChanged().connect([this](const auto&) { refreshUnsavedState(); }); + mods_connection_ = ctx_.modService().mods_changed.connect([this](const auto&) { onModsReloaded(); }); + auto& diagnostics = ctx_.diagnosticsService(); + diagnostics_connection_ = diagnostics.diagnostics_summary_changed.connect([this](const auto&) { + if (!mods_view_) return; + mods_view_->apply_current_sidebar_filter(); + if (!selected_uuid_.isEmpty()) mods_view_->mod_info->setIssues(ctx_.diagnosticsService().issues_for(selected_uuid_)); + }); + status_connection_ = diagnostics.status_message_changed.connect([this](const QString& message) { toasts_->info(message); }); + task_connection_ = diagnostics.background_task_changed.connect([this](const QString& text) { header_controller_->setTaskText(text); }); + auto& downloads = ctx_.downloads(); + if (downloads.available()) { + download_busy_connection_ = downloads.busy_changed.connect([this](bool busy) { + header_controller_->setDownloadsBusy(busy); + actions_.at(ActionId::UpdateMods)->setEnabled(!busy); + if (busy) header_controller_->setDownloadsProgress(QStringLiteral("Updating mods…"), 0, 0); + }); + download_progress_connection_ = downloads.download_progress.connect([this](const core::DownloadProgress& progress) { + header_controller_->setDownloadsProgress( + QStringLiteral("Updating mods: %1 / %2 · %3 MB").arg(progress.completed).arg(progress.total) + .arg(progress.bytes_done / 1048576.0, 0, 'f', 0), progress.completed, progress.total); + }); + download_finished_connection_ = downloads.download_finished.connect([this](const auto& result) { onDownloadsFinished(result); }); + download_status_connection_ = downloads.status_message_changed.connect([this](const QString& message) { toasts_->info(message); }); + } + qApp->installEventFilter(this); + if (!views_.empty()) showView(0); + refreshUnsavedState(); +} + +MainWindow::~MainWindow() { + qApp->removeEventFilter(this); + app_.setNavigator({}); + app_.setToasts(nullptr); + ctx_.activeStateChanged().disconnect(active_connection_); + ctx_.modService().mods_changed.disconnect(mods_connection_); + auto& diagnostics = ctx_.diagnosticsService(); + diagnostics.diagnostics_summary_changed.disconnect(diagnostics_connection_); + diagnostics.status_message_changed.disconnect(status_connection_); + diagnostics.background_task_changed.disconnect(task_connection_); + auto& downloads = ctx_.downloads(); + downloads.busy_changed.disconnect(download_busy_connection_); + downloads.download_progress.disconnect(download_progress_connection_); + downloads.download_finished.disconnect(download_finished_connection_); + downloads.status_message_changed.disconnect(download_status_connection_); + const auto dialogs = findChildren(QString(), Qt::FindDirectChildrenOnly); + for (auto* dialog : dialogs) delete dialog; + // QuickWidgets must release their scenes before the shared engine is destroyed. + delete takeCentralWidget(); + delete engine_; +} + +void MainWindow::setupActions() { + actions_ = createActions(*this); + const auto bind = [this](ActionId id, auto handler) { connect(actions_.at(id), &QAction::triggered, this, handler); }; + bind(ActionId::Save, [this] { saveActiveMods(); }); + bind(ActionId::Restore, [this] { restoreSnapshot(); }); + bind(ActionId::Settings, [this] { app_.navigate(buildRoute(QString(kSettingsViewId))); }); + bind(ActionId::Quit, [this] { close(); }); + bind(ActionId::Refresh, [this] { reloadMods(); }); + bind(ActionId::FullRescan, [this] { reloadMods(true); }); + bind(ActionId::AutoSort, [this] { autoSort(); }); + bind(ActionId::UpdateMods, [this] { updateMods(); }); + bind(ActionId::FocusSearch, [this] { focusSearch(); }); + bind(ActionId::NextView, [this] { cycleView(1); }); + bind(ActionId::PrevView, [this] { cycleView(-1); }); + bind(ActionId::Fullscreen, [this] { isFullScreen() ? showNormal() : showFullScreen(); }); + bind(ActionId::ReportIssue, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); + bind(ActionId::OpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(core::config_dir()).filePath(QStringLiteral("logs")))); }); + bind(ActionId::UploadLogs, [this] { uploadReport(); }); + bind(ActionId::Shortcuts, [this] { showShortcuts(); }); + bind(ActionId::CheckUpdates, [this] { checkUpdates(true); }); + bind(ActionId::About, [this] { showAbout(); }); + actions_.at(ActionId::UpdateMods)->setEnabled(ctx_.downloads().available()); + for (int index = 0; index < 9; ++index) { + auto* action = new QAction(this); + action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(index + 1))); + connect(action, &QAction::triggered, this, [this, index] { selectView(index); }); + addAction(action); + } +} + +void MainWindow::setAppQuitCallback(std::function callback) { quit_ = std::move(callback); } + +void MainWindow::openRoute(const QString& url) { + const auto route = parseRoute(url); + if (!route) return; + if (route->view_id == kSettingsViewId) { openSettings(); return; } + const auto& specs = app_.railViews(); + for (std::size_t i = 0; i < views_.size(); ++i) { + if (route->view_id == specs[i].meta.viewId) { + showView(static_cast(i)); + views_[i]->openRoute(route->path); + return; + } + } +} + +void MainWindow::selectView(int index) { + if (index >= 0 && index < stack_->count()) app_.navigate(buildRoute(QString(app_.railViews()[index].meta.viewId))); +} + +void MainWindow::showView(int index) { + rail_->setCurrent(index); + stack_->setCurrentIndex(index); + header_controller_->setDownloadsProgressShown(!app_.railViews()[index].meta.listsDownloads); + preloadView(index - 1); + preloadView(index + 1); +} + +void MainWindow::preloadView(int index) { + if (index >= 0 && index < static_cast(views_.size())) views_[index]->preload(); +} + +void MainWindow::cycleView(int direction) { + const int count = stack_->count(); + if (count) selectView(((stack_->currentIndex() + direction) % count + count) % count); +} + +void MainWindow::openDownloads() { + for (const auto& view : app_.railViews()) { + if (view.meta.listsDownloads) { app_.navigate(buildRoute(QString(view.meta.viewId))); return; } + } +} + +void MainWindow::focusSearch() { + auto* search = stack_->currentWidget() ? stack_->currentWidget()->findChild(QStringLiteral("searchInput")) : nullptr; + if (search) search->setFocus(); + else if (mods_view_) mods_view_->mod_list->search_input->setFocus(); +} + +void MainWindow::savePrefs() { + if (ctx_.hasConfigService()) { + const auto result = saveUiPrefs(app_.uiPrefs(), ctx_.configService()); + if (!result) toasts_->error(result.error().message); + } +} + +void MainWindow::snapRail() { + const int target = rail_->width() >= RAIL_COLLAPSE_WIDTH ? RAIL_MAX_WIDTH : RAIL_MIN_WIDTH; + splitter_->setSizes({target, splitter_->width() - target}); + const bool collapsed = target == RAIL_MIN_WIDTH; + if (app_.uiPrefs().rail_collapsed != collapsed) { app_.uiPrefs().rail_collapsed = collapsed; savePrefs(); } +} + +void MainWindow::refreshUnsavedState() { + unsaved_ = ctx_.activeUuids() != saved_active_; + header_controller_->setUnsavedChanges(unsaved_); + setWindowModified(unsaved_); +} + +void MainWindow::onModsReloaded() { saved_active_ = ctx_.loadedActiveUuids(); refreshUnsavedState(); } + +bool MainWindow::eventFilter(QObject* object, QEvent* event) { + if (event->type() == QEvent::KeyPress) { + auto* widget = qobject_cast(object); + if (widget && widget->window() == this) { + const int key = static_cast(event)->key(); + if (key == Qt::Key_Alt) { + menu_->setVisible(!menu_->isVisible()); + if (menu_->isVisible()) menu_->setFocus(); + return true; + } + if (key == Qt::Key_Escape && menu_->isVisible()) { menu_->hide(); return true; } + } + } + return QMainWindow::eventFilter(object, event); +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (unsaved_ && !close_confirmed_) { + event->ignore(); + if (!close_prompt_open_) { close_prompt_open_ = true; confirmClose(); } + return; + } + closing_ = true; + for (auto* view : views_) view->teardown(); + event->accept(); + if (quit_) quit_(); +} + +QCoro::Task MainWindow::confirmClose() { + const auto [result, dialog] = co_await awaitDialog(this); + close_prompt_open_ = false; + if (result == QMessageBox::Save) { + if (!co_await saveActiveMods()) co_return; + } else if (result != QMessageBox::Discard) co_return; + close_confirmed_ = true; + close(); +} + +void MainWindow::resizeEvent(QResizeEvent* event) { + QMainWindow::resizeEvent(event); + if (toasts_) toasts_->resizeToParent(); +} + +void MainWindow::changeEvent(QEvent* event) { + QMainWindow::changeEvent(event); + if (event->type() == QEvent::WindowStateChange && header_controller_) header_controller_->setMaximized(isMaximized()); +} + +QCoro::Task MainWindow::reloadMods(bool full) { + toasts_->info(full ? QStringLiteral("Rescanning all mods…") : QStringLiteral("Refreshing mods…")); + const auto count = co_await app_.refreshMods(full); + if (!count) { toasts_->error(count.error().message); co_return; } + if (!*count) { toasts_->warning(QStringLiteral("No mods found")); co_return; } + toasts_->success((full ? QStringLiteral("Rescanned %1 mods") : QStringLiteral("Reloaded %1 mods")).arg(*count)); +} + +QCoro::Task MainWindow::autoSort() { + const auto [count, elapsed] = co_await ctx_.autoSort(); + toasts_->success(QStringLiteral("Sorted %1 mods in %2ms").arg(count).arg(elapsed, 0, 'f', 0)); +} + +QCoro::Task MainWindow::updateMods() { + auto& downloads = ctx_.downloads(); + if (!downloads.available()) co_return; + if (downloads.is_downloading()) { downloads.cancel(); co_return; } + const auto ids = downloads.updatable_ids(ctx_.allMods().values()); + if (ids.isEmpty()) { toasts_->info(QStringLiteral("No downloaded mods to update")); co_return; } + if (!co_await confirm_update(ids.size(), this)) co_return; + toasts_->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); + const auto result = co_await downloads.download_mods(ids); + if (!result) toasts_->error(result.error().message); +} + +void MainWindow::onDownloadsFinished(const core::DownloadResult& result) { + const auto ok = result.succeeded.size(), failed = result.failed.size(), changed = result.changed.size(); + if (!ok && !failed) { toasts_->info(QStringLiteral("Download cancelled")); return; } + const QString summary = QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); + if (failed) { + const auto names = ctx_.downloads().names_by_id(ctx_.allMods().values()); + QStringList shown; + for (const auto& id : result.failed.mid(0, 3)) shown.append(names.value(id, id)); + toasts_->warning(QStringLiteral("%1, %2 failed: %3%4").arg(summary).arg(failed).arg(shown.join(QStringLiteral(", ")), failed > 3 ? QStringLiteral("…") : QString())); + } else toasts_->success(summary); + if (changed) app_.refreshMods(); +} + +QCoro::Task MainWindow::openSettings() { + if (settings_open_) co_return; + settings_open_ = true; + const auto [result, dialog] = co_await awaitDialog(app_, engine_, this); + settings_open_ = false; + if (result != QDialog::Accepted) co_return; + auto config = dialog->getConfig(); + const auto saved = ctx_.configService().save(QStringLiteral("config.json"), config); + if (!saved) { toasts_->error(saved.error().message); co_return; } + ctx_.updateConfig(config); + ctx_.resetProviders(config.paths); + const auto reloaded = co_await ctx_.modService().reload(); + if (!reloaded) { toasts_->error(reloaded.error().message); co_return; } + toasts_->success(QStringLiteral("Settings saved")); +} + +QCoro::Task MainWindow::showAbout() { co_await awaitDialog(this, ctx_, *toasts_); } +QCoro::Task MainWindow::showShortcuts() { co_await awaitDialog(shortcutRows() + QML_SHORTCUTS, this); } +QCoro::Task MainWindow::uploadReport() { co_await handle_upload_report(this, ctx_, *toasts_); } + +QCoro::Task MainWindow::restoreSnapshot() { + const auto snapshots = ctx_.modService().get_snapshots(); + if (snapshots.isEmpty()) { toasts_->warning(QStringLiteral("No saved mod lists are available")); co_return; } + const auto [result, dialog] = co_await awaitDialog(snapshots, this); + if (result != QDialog::Accepted) co_return; + const auto chosen = dialog->selectedSnapshot(); + if (!chosen) co_return; + const auto [confirmed, confirmation] = co_await awaitDialog(QFileInfo(*chosen).fileName(), this); + if (confirmed != QMessageBox::Yes) co_return; + if (co_await ctx_.modService().restore_snapshot(*chosen)) toasts_->success(QStringLiteral("Restored mod list from %1").arg(QFileInfo(*chosen).fileName())); + else toasts_->error(QStringLiteral("The saved mod list is invalid or cannot be read")); +} + +void MainWindow::startStartupUpdateCheck() { checkUpdates(false); } + +QCoro::Task MainWindow::checkUpdates(bool manual) { + if (checking_updates_) co_return; + checking_updates_ = true; + const QPointer guard(this); + const auto release = co_await updates_.check(); + if (!guard || closing_) co_return; + if (!release) { + checking_updates_ = false; + if (manual) toasts_->error(QStringLiteral("Could not check for updates")); + co_return; + } + if (!release->has_value()) { + checking_updates_ = false; + if (manual) toasts_->success(QStringLiteral("PxModRim is up to date")); + co_return; + } + const auto info = **release; + if (!manual && info.tag.toStdString() == app_.uiPrefs().skipped_update_tag) { checking_updates_ = false; co_return; } + const auto [result, dialog] = co_await awaitDialog(info, updates_.current_version(), this); + if (!guard || closing_) co_return; + checking_updates_ = false; + if (result == QDialog::Accepted) QDesktopServices::openUrl(QUrl(info.url)); + else if (result == SKIP_RESULT) { app_.uiPrefs().skipped_update_tag = info.tag.toStdString(); savePrefs(); } +} + +QCoro::Task> MainWindow::writeActiveLayout() { + const auto active = ctx_.activeUuids(); + if (!co_await ctx_.modService().save_active_layout(active)) co_return std::nullopt; + saved_active_ = active; + refreshUnsavedState(); + co_return active; +} + +QCoro::Task MainWindow::saveActiveMods() { + const auto saved = co_await writeActiveLayout(); + if (!saved) { toasts_->warning(QStringLiteral("Config folder not set")); co_return false; } + toasts_->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); + co_return true; +} + +QCoro::Task MainWindow::confirmLaunch(bool core::AppConfig::* setting, const QString& title, + const QString& text, const QString& info, const QString& accept) { + const auto [result, dialog] = co_await awaitDialog(this, title, text, info, accept); + if (result != QMessageBox::Ok) co_return false; + if (dialog->dontAskAgain()) { + auto config = ctx_.config(); + config.*setting = false; + const auto saved = ctx_.configService().save(QStringLiteral("config.json"), config); + if (!saved) { toasts_->error(saved.error().message); co_return false; } + ctx_.updateConfig(std::move(config)); + } + co_return true; +} + +QCoro::Task MainWindow::launchGame() { + if (header_controller_->launchState() != QStringLiteral("idle")) co_return; + header_controller_->setLaunchState(QStringLiteral("launching")); + const QPointer guard(this); + auto& launcher = ctx_.gameLauncher(); + const auto config = ctx_.config(); + const bool running = config.launch_confirm_running && co_await QtConcurrent::run([&launcher] { return launcher.is_running(); }); + if (!guard || closing_) co_return; + const int errors = ctx_.diagnosticsService().active_error_count(); + const auto abort = [this] { header_controller_->setLaunchState(QStringLiteral("idle")); }; + if (running && !co_await confirmLaunch(&core::AppConfig::launch_confirm_running, + QStringLiteral("Game Already Running"), QStringLiteral("RimWorld is already running."), + QStringLiteral("A second copy can overwrite the first one's saves and settings."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } + if (config.launch_confirm_errors && errors > 0 && !co_await confirmLaunch(&core::AppConfig::launch_confirm_errors, + QStringLiteral("Mod List Has Errors"), QStringLiteral("%1 active mod(s) have errors.").arg(errors), + QStringLiteral("Missing dependencies or wrong load order can crash the game."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } + if (config.launch_confirm_unsaved && unsaved_ && !co_await confirmLaunch(&core::AppConfig::launch_confirm_unsaved, + QStringLiteral("Unsaved Changes"), QStringLiteral("The active mod list has unsaved changes."), + QStringLiteral("The list is saved to ModsConfig.xml before the game starts."), QStringLiteral("Save and Launch"))) { abort(); co_return; } + if (!co_await writeActiveLayout()) toasts_->warning(QStringLiteral("Config folder not set — mod list won't be saved")); + const auto result = co_await launcher.launch(app_.uiPrefs().launch_strategy); + if (!guard || closing_) co_return; + if (!result) { toasts_->warning(result.error().message); abort(); co_return; } + toasts_->success(*result); + const auto after = ctx_.config().after_launch; + if (!co_await launcher.wait_for_start()) { + if (guard && !closing_) { toasts_->warning(QStringLiteral("Game did not start")); abort(); } + co_return; + } + if (!guard || closing_) co_return; + header_controller_->setLaunchState(QStringLiteral("running")); + if (after == core::AfterLaunch::Close && !unsaved_) { close_confirmed_ = true; close(); co_return; } + if (after == core::AfterLaunch::Minimize) showMinimized(); + const auto code = co_await launcher.wait_for_exit(); + if (!guard || closing_) co_return; + if (code && *code) toasts_->warning(QStringLiteral("Game exited with code %1").arg(*code)); + else toasts_->info(QStringLiteral("Game closed")); + if (after == core::AfterLaunch::Minimize) { showNormal(); activateWindow(); } + abort(); +} + +QCoro::Task MainWindow::selectMod(QString uuid) { + selected_uuid_ = uuid; + if (!selection_) co_return; + if (uuid.isEmpty()) selection_->clear(); + else co_await selection_->show(std::move(uuid)); +} + +QCoro::Task MainWindow::requestMod(QString uuid) { + if (!mods_view_) co_return; + if (mods_view_->mod_list->select_uuid(uuid)) co_return; + mods_view_->mod_list->search_input->clear(); + mods_view_->sidebar->select_all_entry(); + if (!mods_view_->mod_list->select_uuid(uuid)) co_await selectMod(std::move(uuid)); +} + +} // namespace px::ui diff --git a/src/ui/window/main_window.h b/src/ui/window/main_window.h new file mode 100644 index 0000000..4b257b3 --- /dev/null +++ b/src/ui/window/main_window.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "core/downloads/types.h" +#include "core/services/update_service.h" +#include "ui/context.h" +#include "ui/window/actions.h" + +class QQmlEngine; +class QSplitter; +class QStackedWidget; + +namespace px::ui { + +class HeaderController; +class HeaderPanel; +class MenuBar; +class ModSelectionPresenter; +class ModsViewPanel; +class ToastManager; +class ViewRailPanel; + +class UnsavedChangesDialog : public QMessageBox { +public: + explicit UnsavedChangesDialog(QWidget* parent); +}; + +class LaunchConfirmDialog : public QMessageBox { +public: + LaunchConfirmDialog(QWidget* parent, const QString& title, const QString& text, + const QString& info, const QString& accept); + bool dontAskAgain() const; +}; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(AppContext& app, QWidget* parent = nullptr); + ~MainWindow() override; + + QQmlEngine* qmlEngine() const noexcept { return engine_; } + void setAppQuitCallback(std::function callback); + void startStartupUpdateCheck(); + void openRoute(const QString& url); + void selectView(int index); + void cycleView(int direction); + void refreshUnsavedState(); + bool hasUnsavedChanges() const noexcept { return unsaved_; } + QCoro::Task saveActiveMods(); + +protected: + bool eventFilter(QObject* object, QEvent* event) override; + void closeEvent(QCloseEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void changeEvent(QEvent* event) override; + +private: + void setupActions(); + void showView(int index); + void preloadView(int index); + void openDownloads(); + void focusSearch(); + void snapRail(); + void savePrefs(); + void onModsReloaded(); + void onDownloadsFinished(const core::DownloadResult& result); + QCoro::Task confirmClose(); + QCoro::Task reloadMods(bool full = false); + QCoro::Task autoSort(); + QCoro::Task updateMods(); + QCoro::Task openSettings(); + QCoro::Task showAbout(); + QCoro::Task showShortcuts(); + QCoro::Task uploadReport(); + QCoro::Task restoreSnapshot(); + QCoro::Task checkUpdates(bool manual); + QCoro::Task> writeActiveLayout(); + QCoro::Task confirmLaunch(bool core::AppConfig::* setting, const QString& title, + const QString& text, const QString& info, const QString& accept); + QCoro::Task launchGame(); + QCoro::Task selectMod(QString uuid); + QCoro::Task requestMod(QString uuid); + + AppContext& app_; + core::CoreContext& ctx_; + QQmlEngine* engine_; + HeaderController* header_controller_ = nullptr; + HeaderPanel* header_ = nullptr; + MenuBar* menu_ = nullptr; + ViewRailPanel* rail_ = nullptr; + QStackedWidget* stack_ = nullptr; + QSplitter* splitter_ = nullptr; + ToastManager* toasts_ = nullptr; + ModsViewPanel* mods_view_ = nullptr; + std::vector views_; + std::unique_ptr selection_; + QString selected_uuid_; + QStringList saved_active_; + ActionMap actions_; + std::function quit_; + core::services::UpdateService updates_; + bool unsaved_ = false; + bool close_prompt_open_ = false; + bool close_confirmed_ = false; + bool closing_ = false; + bool settings_open_ = false; + bool checking_updates_ = false; + std::uint64_t active_connection_ = 0; + std::uint64_t mods_connection_ = 0; + std::uint64_t diagnostics_connection_ = 0; + std::uint64_t status_connection_ = 0; + std::uint64_t task_connection_ = 0; + std::uint64_t download_busy_connection_ = 0; + std::uint64_t download_progress_connection_ = 0; + std::uint64_t download_finished_connection_ = 0; + std::uint64_t download_status_connection_ = 0; +}; + +} // namespace px::ui diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp new file mode 100644 index 0000000..4cd0da9 --- /dev/null +++ b/src/ui/window/menu_bar.cpp @@ -0,0 +1,28 @@ +#include "ui/window/menu_bar.h" + +#include + +namespace px::ui { + +const std::array& menuLayout() { + static const std::array layout{{ + {"&File", {ActionId::Save, ActionId::Restore, ActionId::Settings, std::nullopt, ActionId::Quit}}, + {"&Mods", {ActionId::Refresh, ActionId::FullRescan, ActionId::AutoSort, std::nullopt, ActionId::UpdateMods}}, + {"&View", {ActionId::FocusSearch, ActionId::NextView, ActionId::PrevView, std::nullopt, ActionId::Fullscreen}}, + {"&Help", {ActionId::ReportIssue, ActionId::UploadLogs, ActionId::OpenLogs, ActionId::Shortcuts, + ActionId::CheckUpdates, std::nullopt, ActionId::About}}, + }}; + return layout; +} + +MenuBar::MenuBar(const ActionMap& actions, QWidget* parent) : QMenuBar(parent) { + for (const auto& spec : menuLayout()) { + auto* menu = addMenu(spec.title); + for (auto id : spec.actions) { + if (id) menu->addAction(actions.at(*id)); + else menu->addSeparator(); + } + } +} + +} // namespace px::ui diff --git a/src/ui/window/menu_bar.h b/src/ui/window/menu_bar.h new file mode 100644 index 0000000..3b025c3 --- /dev/null +++ b/src/ui/window/menu_bar.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "ui/window/actions.h" + +namespace px::ui { + +struct MenuSpec { + QString title; + std::vector> actions; +}; + +const std::array& menuLayout(); + +class MenuBar : public QMenuBar { + Q_OBJECT +public: + explicit MenuBar(const ActionMap& actions, QWidget* parent = nullptr); +}; + +} // namespace px::ui diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1c69a2d..70e9105 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,12 @@ include(Catch) -add_executable(pxcore_tests core/test_version.cpp) +file(GLOB_RECURSE PX_CORE_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/core/*.cpp) +add_executable(pxcore_tests ${PX_CORE_TESTS}) target_link_libraries(pxcore_tests PRIVATE pxcore Catch2::Catch2WithMain) catch_discover_tests(pxcore_tests) +target_compile_definitions(pxcore_tests PRIVATE PX_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") + +file(GLOB_RECURSE PX_UI_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/ui/*.cpp) +add_executable(pxui_tests ${PX_UI_TESTS}) +target_link_libraries(pxui_tests PRIVATE pxui pxuiplugin Qt6::Test Catch2::Catch2) +catch_discover_tests(pxui_tests) diff --git a/tests/ui/main.cpp b/tests/ui/main.cpp new file mode 100644 index 0000000..27863a2 --- /dev/null +++ b/tests/ui/main.cpp @@ -0,0 +1,13 @@ +#include + +#include +#include + +Q_IMPORT_QML_PLUGIN(PxModRimPlugin) + +// One QApplication for the whole UI test binary: Qt allows a single instance per process. +int main(int argc, char* argv[]) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + return Catch::Session().run(argc, argv); +} diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp new file mode 100644 index 0000000..46e052e --- /dev/null +++ b/tests/ui/test_dialog_chrome.cpp @@ -0,0 +1,46 @@ +#include + +#include +#include +#include +#include + +#include "ui/components/dialog_chrome.h" + +namespace { + +// The widget tests need a QApplication for the lifetime of the test binary. +QApplication& application() { + static int argc = 1; + static char name[] = "pxui_tests"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; +} + +void clickClose(QDialog& dialog) { + px::ui::installDialogChrome(&dialog); + auto* button = dialog.findChild(QStringLiteral("dialogCloseBtn")); + REQUIRE(button != nullptr); + button->click(); +} + +} // namespace + +TEST_CASE("message box close yields cancel") { + application(); + for (const auto buttons : {QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel}) { + QMessageBox box; + box.setStandardButtons(buttons); + clickClose(box); + CHECK(box.result() == static_cast(QMessageBox::Cancel)); + } +} + +TEST_CASE("plain dialog close rejects") { + application(); + QDialog dialog; + clickClose(dialog); + CHECK(dialog.result() == QDialog::Rejected); +} diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp new file mode 100644 index 0000000..c04bcc9 --- /dev/null +++ b/tests/ui/test_downloads_model.cpp @@ -0,0 +1,49 @@ +#include + +#include + +#include "ui/plugins/downloads/model.h" + +namespace { +QHash states(const px::ui::DownloadsModel& model) { + QHash result; + for (int row = 0; row < model.rowCount(); ++row) { + const auto index = model.index(row); + result.insert(model.data(index, px::ui::DownloadsModel::IdRole).toString(), + model.data(index, px::ui::DownloadsModel::StateRole).toString()); + } + return result; +} +} + +TEST_CASE("download item events resolve states and cancel leftover rows") { + px::ui::DownloadsModel model; + model.begin({"1", "2", "3", "4", "5"}, {{"1", "One"}}); + model.apply({"1", "downloading", 0, 0}); + model.apply({"2", "downloading", 5, 10}); + model.apply({"3", "downloading", 10, 10}); + model.apply({"4", "error", 0, 0, "boom"}); + model.flush(); + const QHash expected{{"1", "unchanged"}, {"2", "downloading"}, {"3", "updated"}, {"4", "failed"}, {"5", "queued"}}; + CHECK(states(model) == expected); + model.finish({{"1", "3"}, {"4"}, {"3"}}); + CHECK(states(model).value("2") == "cancelled"); + CHECK(states(model).value("5") == "cancelled"); + CHECK(model.updated() == 1); + CHECK(model.unchanged() == 1); + CHECK(model.failed() == 3); + CHECK(model.active() == 0); + CHECK(model.failed_ids() == QStringList{"2", "4", "5"}); +} + +TEST_CASE("download filters limit visible rows and keep fallback titles") { + px::ui::DownloadsModel model; + model.begin({"1", "2"}, {{"1", "One"}}); + model.apply({"2", "error", 0, 0, "x"}); + model.flush(); + model.setFilter(QStringLiteral("failed")); + REQUIRE(model.rowCount() == 1); + CHECK(model.data(model.index(0), px::ui::DownloadsModel::TitleRole).toString() == "2"); + model.setFilter(QStringLiteral("all")); + CHECK(model.rowCount() == 2); +} diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp new file mode 100644 index 0000000..6afce3a --- /dev/null +++ b/tests/ui/test_main_window.cpp @@ -0,0 +1,189 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "core/context.h" +#include "core/downloads/manager.h" +#include "core/mod_service.h" +#include "core/models/metadata/structures.h" +#include "ui/context.h" +#include "ui/window/main_window.h" + +namespace { +void ensureApplication() { + static int argc = 1; + static char name[] = "pxmodrim-window-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) app = std::make_unique(argc, argv); +} + +class FirstView : public px::ui::RailView { +public: + using RailView::RailView; + static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("first"), QLatin1StringView("mods"), QLatin1StringView("First")}; +}; +class SecondView : public px::ui::RailView { +public: + using RailView::RailView; + static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("second"), QLatin1StringView("mods"), QLatin1StringView("Second")}; + void openRoute(const QStringList& path) override { opened = path; } + QStringList opened; +}; +class ThirdView : public px::ui::RailView { +public: + using RailView::RailView; + static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("third"), QLatin1StringView("mods"), QLatin1StringView("Third")}; +}; + +struct Fixture { + QTemporaryDir directory; + px::core::ConfigService configService{directory.path()}; + std::unique_ptr core; + std::unique_ptr app; + explicit Fixture(bool canSave = true) { + ensureApplication(); + px::core::AppConfig config; + if (canSave) { + config.paths.config_folder = QDir(directory.path()).filePath(QStringLiteral("game-config")).toStdString(); + QDir().mkpath(QString::fromStdString(config.paths.config_folder)); + } + core = px::core::CoreContext::create(config, &configService); + core->registerPlugin(std::make_unique()); + auto a = std::make_shared(); + a->package_id = px::core::CaseInsensitiveStr(u"test.a"); + auto b = std::make_shared(); + b->package_id = px::core::CaseInsensitiveStr(u"test.b"); + core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, {QStringLiteral("a"), QStringLiteral("b")}); + app = std::make_unique(*core); + app->addRailView(); + app->addRailView(); + app->addRailView(); + } +}; + +QStackedWidget* stackOf(px::ui::MainWindow& window) { + return window.findChild(QStringLiteral("viewStack")); +} + +QMessageBox* closePrompt(px::ui::MainWindow& window) { + for (auto* dialog : window.findChildren()) + if (dialog->windowTitle() == QStringLiteral("Unsaved Changes") && dialog->isVisible()) return dialog; + return nullptr; +} +} + +TEST_CASE("View shortcuts wrap and ignore invalid indexes", "[ui][window]") { + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.cycleView(-1); + CHECK(stack->currentIndex() == 2); + window.cycleView(1); + CHECK(stack->currentIndex() == 0); + window.selectView(3); + window.selectView(-1); + CHECK(stack->currentIndex() == 0); +} + +TEST_CASE("Routes select their view before handing it the path", "[ui][window]") { + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.openRoute(QStringLiteral("modrim://second/mod/42")); + CHECK(stack->currentIndex() == 1); + CHECK(static_cast(stack->widget(1))->opened == QStringList{"mod", "42"}); + window.openRoute(QStringLiteral("modrim://missing/x")); + window.openRoute(QStringLiteral("second")); + CHECK(stack->currentIndex() == 1); +} + +TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + window.selectView(1); + window.openRoute(QStringLiteral("modrim://settings")); + CHECK(stackOf(window)->currentIndex() == 1); + bool found = false; + for (auto* dialog : window.findChildren()) { + if (dialog->isVisible()) { found = true; dialog->reject(); } + } + CHECK(found); +} + +TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui][window]") { + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + CHECK(window.hasUnsavedChanges()); + CHECK(window.isWindowModified()); + fixture.core->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("a")}); + fixture.core->modService().mods_changed.notify(std::monostate{}); + CHECK(window.hasUnsavedChanges()); +} + +TEST_CASE("Successful saves clear the unsaved state", "[ui][window]") { + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(window.hasUnsavedChanges()); + CHECK(QCoro::waitFor(window.saveActiveMods())); + CHECK_FALSE(window.hasUnsavedChanges()); + CHECK_FALSE(window.isWindowModified()); +} + +TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", "[ui][window]") { + const auto choice = GENERATE(QMessageBox::Save, QMessageBox::Discard, QMessageBox::Cancel); + Fixture fixture; + px::ui::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + int closed = 0; + QEventLoop loop; + window.setAppQuitCallback([&] { ++closed; loop.quit(); }); + QCloseEvent first, second; + QCoreApplication::sendEvent(&window, &first); + QCoreApplication::sendEvent(&window, &second); + CHECK_FALSE(first.isAccepted()); + CHECK_FALSE(second.isAccepted()); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + int visiblePrompts = 0; + for (auto* item : window.findChildren()) if (item->isVisible()) ++visiblePrompts; + CHECK(visiblePrompts == 1); + QTimer::singleShot(10, prompt, [prompt, choice] { prompt->done(choice); }); + QTimer::singleShot(choice == QMessageBox::Cancel ? 40 : 3000, &loop, &QEventLoop::quit); + loop.exec(); + CHECK(closed == (choice == QMessageBox::Cancel ? 0 : 1)); +} + +TEST_CASE("A failed close-time save leaves the window open", "[ui][window]") { + Fixture fixture(false); + px::ui::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + bool closed = false; + window.setAppQuitCallback([&] { closed = true; }); + QCloseEvent event; + QCoreApplication::sendEvent(&window, &event); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + prompt->done(QMessageBox::Save); + CHECK_FALSE(closed); + CHECK(window.hasUnsavedChanges()); +} diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp new file mode 100644 index 0000000..337f910 --- /dev/null +++ b/tests/ui/test_menu_bar.cpp @@ -0,0 +1,69 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "ui/window/actions.h" +#include "ui/window/menu_bar.h" + +namespace { +void ensureApplication() { + static int argc = 1; + static char name[] = "pxmodrim-menu-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) app = std::make_unique(argc, argv); +} +} + +TEST_CASE("Every window action belongs to exactly one menu", "[ui][window]") { + std::multiset placed; + for (const auto& menu : px::ui::menuLayout()) + for (const auto id : menu.actions) if (id) placed.insert(*id); + REQUIRE(placed.size() == px::ui::actionSpecs().size()); + for (const auto& spec : px::ui::actionSpecs()) CHECK(placed.count(spec.id) == 1); +} + +TEST_CASE("Window shortcuts are unique and view switching is collapsed", "[ui][window]") { + ensureApplication(); + std::set shortcuts; + for (const auto& spec : px::ui::actionSpecs()) + if (!spec.shortcut.isEmpty()) CHECK(shortcuts.insert(spec.shortcut).second); + std::set> unique; + int viewRows = 0; + bool save = false, refresh = false; + for (const auto& row : px::ui::shortcutRows()) { + CHECK(unique.emplace(row.first, row.second).second); + if (row.second == QStringLiteral("Switch to view")) ++viewRows; + save |= row.second == QStringLiteral("Save mod list"); + refresh |= row.second == QStringLiteral("Refresh mods"); + } + CHECK(viewRows == 1); + CHECK(save); + CHECK(refresh); +} + +TEST_CASE("Hidden menu actions retain their window shortcuts", "[ui][window]") { + ensureApplication(); + QMainWindow window; + const auto actions = px::ui::createActions(window); + px::ui::MenuBar menu(actions, &window); + menu.hide(); + bool triggered = false; + QObject::connect(actions.at(px::ui::ActionId::Save), &QAction::triggered, &window, [&] { triggered = true; }); + QMenu* file = nullptr; + for (auto* item : menu.findChildren()) if (item->title() == QStringLiteral("&File")) file = item; + REQUIRE(file != nullptr); + auto* save = actions.at(px::ui::ActionId::Save); + CHECK(file->actions().contains(save)); + CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); + CHECK(window.actions().contains(save)); + save->trigger(); + CHECK(triggered); +} diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp new file mode 100644 index 0000000..1ab6759 --- /dev/null +++ b/tests/ui/test_mod_info_data.cpp @@ -0,0 +1,271 @@ +#include + +#include +#include + +#include +#include +#include +#include + +#include "test_support.h" +#include "core/downloads/manager.h" +#include "ui/panels/mod_info_data.h" + +namespace px::ui { +namespace { + +std::shared_ptr make_mod(const QString& name, const QString& pid) { + auto mod = std::make_shared(); + mod->name = name; + mod->package_id = core::CaseInsensitiveStr(pid); + mod->description.clear(); + return mod; +} + +void add_dependency(core::AboutXmlMod& mod, const QString& pid, + const QStringList& alternatives = {}) { + core::DependencyMod dependency; + dependency.name = pid; + dependency.package_id = core::CaseInsensitiveStr(pid); + dependency.alternative_package_ids = core::CaseInsensitiveSet(alternatives); + mod.about_rules.dependencies.append(std::move(dependency)); +} + +struct ModInfoContext { + QTemporaryDir game; + std::unique_ptr ctx; + + ModInfoContext(const QList& mods, QStringList active) { + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim-mod-info-tests"; + static char* argv[] = {name, nullptr}; + static QApplication application(argc, argv); + } + REQUIRE(game.isValid()); + QFile version(game.filePath("Version.txt")); + REQUIRE(version.open(QIODevice::WriteOnly)); + REQUIRE(version.write("1.5.4297") == 8); + version.close(); + core::AppConfig config; + config.paths.game = game.path().toStdString(); + ctx = px::ui_test::makeCore(config); + // CoreContext registers the real, empty DownloadManager during construction. + REQUIRE_FALSE(ctx->downloads().available()); + QHash indexed; + for (const auto& mod : mods) indexed.insert(mod->uuid(), mod); + ctx->load(std::move(indexed), std::move(active)); + } +}; + +} // namespace + +TEST_CASE("Mod info autolinks bare URLs without changing markup", "[ui][mod-info]") { + CHECK(autolink("see https://a.com/x?y=1 now") == + "see https://a.com/x?y=1 now"); + CHECK(autolink("go to https://a.com.") == + "go to https://a.com."); + const QString anchor = "https://b.com"; + CHECK(autolink(anchor) == anchor); + CHECK(autolink("https://a.com/?a=1&b=2") == + "https://a.com/?a=1&b=2"); +} + +TEST_CASE("Mod info converts BBCode images and linked images", "[ui][mod-info]") { + CHECK(bbcode_images("[img]https://i.imgur.com/a.png[/img]\nhi") == + "\nhi"); + CHECK_FALSE(autolink(bbcode_images("[img]https://i.imgur.com/a.png[/img]")).contains("")); +} + +TEST_CASE("Mod info converts BBCode links and formatting", "[ui][mod-info]") { + CHECK(bbcode_markup("[url=https://a.com/w]the wiki[/url]") == + "the wiki"); + const QString unsafe = "[url=javascript:alert(1)]x[/url]"; + CHECK(bbcode_markup(unsafe) == unsafe); + CHECK(bbcode_markup("[b]a[/b] [i]b[/i]") == "a b"); +} + +TEST_CASE("Mod info converts Steam structural tags without stray breaks", "[ui][mod-info]") { + CHECK(bbcode_markup("[list]
    [*]one
    [*]two
    [/list]
    after") == + "
    • one
    • two
    after"); + CHECK(bbcode_markup("[h2]Title[/h2]
    body") == "

    Title

    body"); + CHECK(bbcode_markup("[quote=Bob]hi[/quote]") == + "
    Bob wrote:
    hi
    "); + CHECK(bbcode_markup("[table noborder=1][tr][td]x[/td][/tr][/table]") == + "
  • x
    "); +} + +TEST_CASE("Mod info restores entities once and excludes URL delimiters", "[ui][mod-info]") { + CHECK(restore_entities("&#8226; item") == "• item"); + CHECK(restore_entities("a & b") == "a & b"); + CHECK(description_to_html("see \"https://a.com/x\" ok") == + "see "https://a.com/x" ok"); + CHECK(description_to_html("") == + "<https://a.com/x>"); +} + +TEST_CASE("Mod info rich descriptions preserve formatting entities and images", "[ui][mod-info]") { + CHECK(description_to_html( + "[h2]Title[/h2]\nBold •\n" + "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki") == + "

    Title

    Bold •
    " + "
    " + "https://a.com/wiki"); +} + +TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", "[ui][mod-info]") { + CHECK(description_to_html( + "Literal & text\n[img]https://a.com/image.png[/img]\n" + "[url=https://a.com]wiki[/url]", "plaintext") == + "<b>Literal & text</b>
    " + "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]"); +} + +TEST_CASE("Mod info only embeds HTTPS images", "[ui][mod-info][security]") { + CHECK_FALSE(description_to_html("[img]file:///etc/passwd[/img]").contains("")); + CHECK_FALSE(description_to_html("[url=javascript:alert(1)]x[/url]").contains("x y"); + CHECK_FALSE(html.contains("") == + "<img src="file:///etc/passwd">"); +} + +TEST_CASE("Mod info rejects unsafe schemes in every rich link form", "[ui][mod-info][security]") { + const QStringList urls{"javascript:alert(1)", "data:image/png;base64,AAAA", + "file:///etc/passwd", "qrc:/private.png", "steam://run/294100"}; + for (const auto& url : urls) { + CAPTURE(url); + const auto html = description_to_html( + "[url=" + url + "]link[/url] [url]" + url + "[/url] [img]" + url + + "[/img] Unity link"); + CHECK_FALSE(html.contains("uuid()}); + const auto needs = build_mod_info(*context.ctx, *main, {}).value("needs").toList(); + const QVariantList expected{ + QVariantMap{{"name", "a.active"}, {"state", "active"}, {"uuid", active->uuid()}}, + QVariantMap{{"name", "A.Inactive"}, {"state", "inactive"}, {"uuid", inactive->uuid()}}, + QVariantMap{{"name", "a.missing"}, {"state", "inactive"}, {"uuid", alternative->uuid()}}, + QVariantMap{{"name", "a.gone"}, {"state", "missing"}, {"uuid", ""}}}; + CHECK(needs == expected); +} + +TEST_CASE("Mod info conflicts include only active mods sorted by name", "[ui][mod-info]") { + auto beta = make_mod("beta", "x.b"); + auto alpha = make_mod("Alpha", "x.a"); + auto off = make_mod("Off", "x.off"); + auto main = make_mod("Main", "x.main"); + main->about_rules.incompatible_with = core::CaseInsensitiveSet( + QStringList{"X.B", "x.a", "x.off", "x.no"}); + ModInfoContext context({main, alpha, beta, off}, {alpha->uuid(), beta->uuid()}); + CHECK(build_mod_info(*context.ctx, *main, {}).value("conflicts").toStringList() == + QStringList{"Alpha", "beta"}); +} + +TEST_CASE("Mod info needed by matches alternatives and sorts dependents", "[ui][mod-info]") { + auto target = make_mod("Target", "t.target"); + auto zed = make_mod("zed", "t.z"); + auto apple = make_mod("Apple", "t.a"); + auto unrelated = make_mod("Unrelated", "t.u"); + add_dependency(*zed, "t.target"); + add_dependency(*apple, "t.other", {"T.Target"}); + add_dependency(*unrelated, "t.other"); + ModInfoContext context({target, zed, apple, unrelated}, {}); + const QVariantList expected{ + QVariantMap{{"name", "Apple"}, {"uuid", apple->uuid()}}, + QVariantMap{{"name", "zed"}, {"uuid", zed->uuid()}}}; + CHECK(build_mod_info(*context.ctx, *target, {}).value("neededBy").toList() == expected); +} + +TEST_CASE("Mod info builds active mod status issues dependencies and description", "[ui][mod-info]") { + auto dep = make_mod("Dep", "d.dep"); + auto main = make_mod("Main", "m.main"); + add_dependency(*main, "d.dep"); + main->description = "hello x https://a.com/x"; + main->authors = {"Ann"}; + auto other = make_mod("Other", "o.other"); + ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); + const core::view::ModIssueView issue{"c", "Cat", std::nullopt, false}; + const auto info = build_mod_info(*context.ctx, *main, {issue}); + CHECK(info.value("name").toString() == "Main"); + CHECK(info.value("author").toString() == "Ann"); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "warning"); + CHECK(status.value("title").toString() == "1 warning"); + CHECK(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 2); + CHECK(status.value("total").toInt() == 2); + const QVariantList expected_issues{ + QVariantMap{{"title", "Cat"}, {"detail", ""}, {"isError", false}}}; + CHECK(status.value("issues").toList() == expected_issues); + const QVariantList expected_needs{ + QVariantMap{{"name", "d.dep"}, {"state", "inactive"}, {"uuid", dep->uuid()}}}; + CHECK(info.value("needs").toList() == expected_needs); + CHECK(info.value("description").toString().contains("")); + const QVariantMap expected_version{{"known", false}, {"ok", false}, {"target", "1.5"}}; + CHECK(info.value("version").toMap() == expected_version); +} + +TEST_CASE("Mod info builds inactive mod without description or dependents", "[ui][mod-info]") { + auto mod = make_mod("Solo", "s.solo"); + ModInfoContext context({mod}, {}); + const auto info = build_mod_info(*context.ctx, *mod, {}); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "ok"); + CHECK_FALSE(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 0); + CHECK(info.value("description").toString().isEmpty()); + CHECK(info.value("neededBy").toList().isEmpty()); +} + +TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", "[ui][mod-info]") { + constexpr double now = 2'000'000'000; + CHECK(format_age(0, now).isEmpty()); + CHECK(format_age(-1, now).isEmpty()); + CHECK(format_age(now + 1, now) == "just now"); + CHECK(format_age(now, now) == "just now"); + CHECK(format_age(now - 3599, now) == "just now"); + struct AgeCase { double elapsed; const char* expected; }; + const AgeCase cases[]{ + {3600, "1 hour ago"}, {7200, "2 hours ago"}, {86399, "23 hours ago"}, + {86400, "1 day ago"}, {2 * 86400, "2 days ago"}, {7 * 86400 - 1, "6 days ago"}, + {7 * 86400, "1 week ago"}, {14 * 86400, "2 weeks ago"}, + {30 * 86400 - 1, "4 weeks ago"}, {30 * 86400, "1 month ago"}, + {60 * 86400, "2 months ago"}, {365 * 86400 - 1, "12 months ago"}, + {365 * 86400, "1 year ago"}, {730 * 86400, "2 years ago"}}; + for (const auto& age : cases) { + CAPTURE(age.elapsed); + CHECK(format_age(now - age.elapsed, now) == age.expected); + } +} + +} // namespace px::ui diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp new file mode 100644 index 0000000..6fdfab7 --- /dev/null +++ b/tests/ui/test_mod_list_model.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include "ui/models/mod_list_model.h" +#include "ui/models/impact.h" +#include "ui/theme/palette.h" +using namespace px::ui; +namespace { +struct ListFixture { + QHash mods; + ModListModel model{{{QStringLiteral("stub"),QStringLiteral("#abcdef")}}}; + ListFixture() { + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->provider_id = QStringLiteral("stub"); + mods.insert(QStringLiteral("uuid-%1").arg(i),mod); + } + model.load_mods(mods,{"uuid-0","uuid-2"}); + } + QStringList order() const { + QStringList result; + for (int row = 0; row < model.rowCount(); ++row) result.append(model.get_item(row)->uuid); + return result; + } + QList indices() const { + QList result; + for (int row = 0; row < model.rowCount(); ++row) result.append(model.data(model.index(row),ModListModel::LoadIndexRole).toInt()); + return result; + } +}; +} +TEST_CASE("mod list load skips missing active UUIDs and sorts inactive mods") { + ListFixture f; + CHECK(f.order() == QStringList{"uuid-0","uuid-2","uuid-1","uuid-3","uuid-4"}); + f.model.load_mods(f.mods,{"ghost","uuid-2"}); + CHECK(f.model.active_uuids() == QStringList{"uuid-2"}); + CHECK(f.model.rowCount() == 5); + CHECK(f.model.rowCount(f.model.index(0)) == 0); + CHECK_FALSE(f.model.data({}).isValid()); + CHECK_FALSE(f.model.setData({},Qt::Checked,ModListModel::CheckStateRole)); +} +TEST_CASE("mod list check state emits role updates without resetting") { + ListFixture f; + QSignalSpy reset(&f.model,&QAbstractItemModel::modelReset); + QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); + QSignalSpy active(&f.model,&ModListModel::active_mods_changed); + REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::CheckStateRole)); + CHECK(f.model.get_item(2)->checked); + CHECK(f.indices() == QList{1,2,3,-1,-1}); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains(ModListModel::LoadIndexRole)); + CHECK(active.size() == 1); + changed.clear(); + REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::CheckStateRole)); + CHECK(changed.isEmpty()); + f.model.set_check_states({0},false); + CHECK(f.indices() == QList{-1,1,2,-1,-1}); +} +TEST_CASE("mod list commits subset order preserving persistent UUID identities") { + ListFixture f; + QPersistentModelIndex persistent(f.model.index(0)); + QSignalSpy about(&f.model,&QAbstractItemModel::layoutAboutToBeChanged); + QSignalSpy changed(&f.model,&QAbstractItemModel::layoutChanged); + f.model.commitOrder({"uuid-2","uuid-0"}); + CHECK(f.order() == QStringList{"uuid-2","uuid-0","uuid-1","uuid-3","uuid-4"}); + CHECK(persistent.row() == 1); + CHECK(persistent.data(ModListModel::UuidRole).toString() == "uuid-0"); + CHECK(about.size() == 1); + CHECK(changed.size() == 1); + f.model.commitOrder(f.order()); + CHECK(changed.size() == 1); + f.model.commitOrder({"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); + CHECK(f.order() == QStringList{"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); + CHECK(f.model.active_uuids() == QStringList{"uuid-0","uuid-2"}); +} +TEST_CASE("mod list move updates load indices and rejects invalid moves") { + ListFixture f; + QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); + CHECK(f.indices() == QList{1,2,-1,-1,-1}); + REQUIRE(f.model.move_row(0,1)); + CHECK(f.order().first() == "uuid-2"); + CHECK(f.indices() == QList{1,2,-1,-1,-1}); + REQUIRE_FALSE(changed.isEmpty()); + CHECK(changed.first()[2].value>().contains(ModListModel::LoadIndexRole)); + CHECK_FALSE(f.model.move_row(0,0)); + CHECK_FALSE(f.model.move_row(-1,1)); + CHECK_FALSE(f.model.move_row(0,5)); +} +TEST_CASE("mod list provider labels and QML roles match the reference") { + CHECK(provider_label("steam") == "Workshop"); + CHECK(provider_label("downloaded") == "Workshop"); + CHECK(provider_label("local") == "Local"); + CHECK(provider_label("core","ludeon.rimworld") == "Core"); + CHECK(provider_label("core","ludeon.rimworld.royalty") == "DLC"); + CHECK(provider_label("unknown","ludeon.rimworld.ideology") == "DLC"); + ListFixture f; + const auto roles = f.model.roleNames(); + CHECK(roles.value(ModListModel::CheckStateRole) == "checkState"); + CHECK(roles.value(ModListModel::SectionNameRole) == "sectionName"); + CHECK(roles.value(ModListModel::StartupImpactTextRole) == "startupImpactText"); + CHECK(f.model.data(f.model.index(0),ModListModel::ProviderColorRole).toString() == "#abcdef"); + CHECK(f.model.data(f.model.index(2),ModListModel::SectionNameRole).toString() == "Inactive (3)"); +} +TEST_CASE("mod list startup impacts include presentation roles and empty input is silent") { + ModListModel empty; + QSignalSpy no_changes(&empty,&QAbstractItemModel::dataChanged); + empty.set_startup_impact({{"mod.0",1.5}}); + CHECK(no_changes.isEmpty()); + ListFixture f; + f.model.set_startup_impact({{"mod.0",1.5}}); + CHECK(f.model.data(f.model.index(0),ModListModel::StartupImpactRole).toDouble() == 1.5); + CHECK(f.model.data(f.model.index(0),ModListModel::StartupImpactTextRole).toString() == "1.50 s"); + CHECK(f.model.data(f.model.index(1),ModListModel::StartupImpactRole).toDouble() == 0); + CHECK(format_duration(0.0001) == "0 ms"); + CHECK(format_duration(0.5) == "500 ms"); + CHECK(format_duration(60) == "1m 00s"); + CHECK(impact_color(0.5) == paletteValue(u"SUCCESS")); + CHECK(impact_color(1) == paletteValue(u"WARNING")); + CHECK(impact_color(5) == paletteValue(u"DANGER")); +} diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp new file mode 100644 index 0000000..7fc2ac5 --- /dev/null +++ b/tests/ui/test_mod_list_panel.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "test_support.h" +#include "core/context.h" +#include "core/services/diagnostics_service.h" +#include "ui/components/mod_activation.h" +#include "ui/components/svg_provider.h" +#include "ui/panels/mod_list_panel.h" +#include "ui/theme/qml_theme.h" +#include "../core/services/services_support.h" +using px::ui::ModListPanel; +namespace { +struct PanelFixture { + std::unique_ptr ctx; + QWidget parent; + QQmlEngine* engine; + std::unique_ptr panel; + PanelFixture(bool active = true, bool dependencies = false) { + ctx = px::ui_test::makeCore(); + QHash mods; + for (int i = 0; i < 3; ++i) { + const auto letter = QChar('a'+i); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(QChar('A'+i)); + mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); + mod->provider_id = QStringLiteral("stub"); + if (dependencies && i > 0) { + px::core::DependencyMod dependency; + dependency.package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); + mod->about_rules.dependencies.append(dependency); + } + mods.insert(QStringLiteral("uuid-%1").arg(letter),mod); + } + ctx->load(mods,active ? QStringList{"uuid-a","uuid-b","uuid-c"} : QStringList{}); + ctx->diagnosticsService().rebuild(); + engine = px::ui::createQmlEngine(&parent); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"),new px::ui::Theme(engine)); + panel = std::make_unique(*ctx,engine,&parent); + panel->load_mods(mods,ctx->activeUuids()); + } + QObject* root() const { return panel->qml_widget()->rootObject(); } + QObject* view() const { return root() ? root()->findChild(QStringLiteral("listView")) : nullptr; } + void select(int row, const QString& uuid) const { + REQUIRE(root()); + REQUIRE(QMetaObject::invokeMethod(root(),"selectRow",Qt::DirectConnection, + Q_ARG(QVariant,QVariant(row)),Q_ARG(QVariant,QVariant(uuid)),Q_ARG(QVariant,QVariant(0)))); + } + void show() const { + panel->resize(420,320); panel->show(); QTest::qWait(20); + REQUIRE(panel->qml_widget()->status() == QQuickWidget::Ready); + REQUIRE(root()); REQUIRE(view()); + } +}; +QVariant qml_value(QObject* object,const char* name) { + const auto value = object->property(name); + return value.metaType() == QMetaType::fromType() ? value.value().toVariant() : value; +} +void toggle_with_answer(PanelFixture& f, const QList& rows, QMessageBox::StandardButton answer, QString* prompt = nullptr) { + QTimer click(&f.parent); click.setInterval(1); + QObject::connect(&click,&QTimer::timeout,&f.parent,[&] { + for (auto* box : f.panel->findChildren()) if (box->isVisible()) { + if (prompt) *prompt = box->text(); + click.stop(); box->button(answer)->click(); return; + } + }); + click.start(); services_test::run_async(f.panel->toggle_rows(rows)); +} +} +TEST_CASE("disabling dependencies supports selected only all dependents and cancellation") { + SECTION("selected only") { + PanelFixture f(true,true); QString prompt; + toggle_with_answer(f,{0},QMessageBox::No,&prompt); + CHECK(f.panel->active_uuids() == QStringList{"uuid-b","uuid-c"}); + CHECK(prompt.contains("Mod B (mod.b)")); CHECK(prompt.contains("Mod C (mod.c)")); + } + SECTION("selected plus transitive dependents") { + PanelFixture f(true,true); + toggle_with_answer(f,{0,1},QMessageBox::Yes); + CHECK(f.panel->active_uuids().isEmpty()); + } + SECTION("cancel") { + PanelFixture f(true,true); + toggle_with_answer(f,{0},QMessageBox::Cancel); + CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); + } +} +TEST_CASE("disabled mods leave the active block and reenabling restores dependency position") { + PanelFixture f(true,true); + toggle_with_answer(f,{1},QMessageBox::No); + CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-c"}); + const auto* inactive = f.panel->model()->get_item(2); + REQUIRE(inactive); CHECK(inactive->uuid == "uuid-b"); CHECK_FALSE(inactive->checked); CHECK(inactive->load_index == -1); + services_test::run_async(f.panel->toggle_rows({f.panel->rowForUuid("uuid-b")})); + CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); + CHECK(f.ctx->activeUuids() == f.panel->active_uuids()); +} +TEST_CASE("hover and tiny drag handle jitter never display the drag proxy") { + PanelFixture f; f.show(); + auto* drag_proxy = f.root()->findChild(QStringLiteral("dragProxy")); REQUIRE(drag_proxy); + auto* widget = f.panel->qml_widget(); + QTest::mouseMove(widget,QPoint(4,26)); QTest::mouseMove(widget,QPoint(14,26)); + CHECK_FALSE(drag_proxy->property("visible").toBool()); + QTest::mousePress(widget,Qt::LeftButton,Qt::NoModifier,QPoint(20,26)); + QTest::mouseMove(widget,QPoint(21,26)); + CHECK_FALSE(drag_proxy->property("visible").toBool()); + QTest::mouseRelease(widget,Qt::LeftButton,Qt::NoModifier,QPoint(21,26)); +} +TEST_CASE("highlights preserve user selection across filter changes") { + PanelFixture f; f.select(0,"uuid-a"); + f.panel->highlight_rows({"uuid-c"}); + f.panel->set_search_filter("Mod"); f.panel->set_search_filter(""); + CHECK(f.panel->isHighlighted("uuid-c")); CHECK_FALSE(f.panel->isHighlighted("uuid-a")); + REQUIRE(f.view()); + CHECK(qml_value(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-a")}); + CHECK(f.view()->property("currentUuid").toString() == "uuid-a"); +} +TEST_CASE("highlight also changes the selected delegate tint") { + PanelFixture f; f.show(); f.select(0,"uuid-a"); QTest::qWait(1); + auto colors = [&] { + QSet result; + auto* content = f.view()->property("contentItem").value(); + REQUIRE(content); + for (auto* item : content->childItems()) if (item->property("color").isValid()) result.insert(item->property("color").value().name(QColor::HexArgb)); + return result; + }; + const auto before = colors(); + f.panel->highlight_rows({"uuid-a"}); QTest::qWait(1); + CHECK(colors() != before); +} +TEST_CASE("QML selection clears when filters hide current UUID and empty state appears") { + PanelFixture f(false); REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == -1); + f.select(2,"uuid-c"); f.panel->set_search_filter("Mod A"); + CHECK(f.view()->property("count").toInt() == 1); + CHECK(f.view()->property("currentIndex").toInt() == -1); + CHECK(f.view()->property("anchorIndex").toInt() == -1); + CHECK(qml_value(f.view(),"selectedIndices").toList().isEmpty()); + f.panel->set_search_filter("not present"); + CHECK(f.view()->property("count").toInt() == 0); + auto* empty = f.root()->findChild(QStringLiteral("emptyStateText")); REQUIRE(empty); + CHECK(empty->property("visible").toBool()); +} +TEST_CASE("QML selection follows UUID during a row move and reveal respects filters") { + PanelFixture f; + f.select(2,"uuid-c"); f.panel->moveRow(2,0); + CHECK(f.panel->uuidAt(0) == "uuid-c"); REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == 0); + CHECK(qml_value(f.view(),"selectedIndices").toList() == QVariantList{0}); + CHECK(qml_value(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-c")}); + CHECK(f.panel->select_uuid("uuid-b")); + f.panel->set_search_filter("Mod A"); CHECK_FALSE(f.panel->select_uuid("uuid-b")); +} +TEST_CASE("search focus disables list keyboard actions and Space remains input text") { + PanelFixture f(false); f.show(); + QTest::mouseClick(f.panel->qml_widget(),Qt::LeftButton,Qt::NoModifier,QPoint(100,26)); + QTest::qWait(1); CHECK(f.root()->property("keyboardActive").toBool()); + f.select(0,"uuid-a"); f.panel->search_input->setFocus(); QTest::qWait(1); + CHECK_FALSE(f.root()->property("keyboardActive").toBool()); + QTest::keyClick(f.panel->search_input,Qt::Key_Return); + CHECK(f.panel->active_uuids().isEmpty()); + QTest::keyClick(f.panel->search_input,Qt::Key_Space); + CHECK(f.panel->search_input->text() == " "); + CHECK(f.panel->active_uuids().isEmpty()); +} diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp new file mode 100644 index 0000000..881e3dc --- /dev/null +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -0,0 +1,129 @@ +#include +#include +#include +#include "ui/models/mod_list_proxy_model.h" +using namespace px::ui; +namespace { +struct ProxyFixture { + ModListModel source; + ModListProxyModel proxy{&source}; + ProxyFixture() { + QHash mods; + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mods.insert(QStringLiteral("uuid-%1").arg(i),mod); + } + source.load_mods(mods,{"uuid-0","uuid-2"}); + } + QList source_rows() const { + QList rows; + for (int row = 0; row < proxy.rowCount(); ++row) rows.append(proxy.mapToSource(proxy.index(row)).row()); + return rows; + } + void check_round_trip() const { + for (int row = 0; row < proxy.rowCount(); ++row) { + const auto source_index = proxy.mapToSource(proxy.index(row)); + REQUIRE(source_index.isValid()); + CHECK(proxy.mapFromSource(source_index).row() == row); + } + } +}; +} +TEST_CASE("mod proxy sidebar filtering maps subset empty and cleared states") { + ProxyFixture f; + CHECK(f.proxy.rowCount() == 5); + f.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-2","uuid-4"}); + CHECK(f.source_rows() == QList{0,1,4}); + f.check_round_trip(); + f.proxy.set_sidebar_filter(QSet{}); + CHECK(f.proxy.rowCount() == 0); + f.proxy.set_sidebar_filter(std::nullopt); + CHECK(f.proxy.rowCount() == 5); +} +TEST_CASE("mod proxy search normalizes case whitespace package IDs and Unicode") { + ProxyFixture f; + f.proxy.set_search_filter("Mod 1"); CHECK(f.source_rows() == QList{2}); + f.proxy.set_search_filter("mod.3"); CHECK(f.source_rows() == QList{3}); + f.proxy.set_search_filter("MOD 2"); CHECK(f.source_rows() == QList{1}); + f.proxy.set_search_filter("zzzz"); CHECK(f.proxy.rowCount() == 0); + f.proxy.set_search_filter(" \t \n "); CHECK(f.proxy.rowCount() == 5); + auto unicode = std::make_shared(); + unicode->name = QStringLiteral("Große Mod"); + unicode->package_id = px::core::CaseInsensitiveStr(QStringLiteral("grosse.mod")); + auto padded = std::make_shared(); + padded->name = QStringLiteral(" Padded Mod "); + padded->package_id = px::core::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); + f.source.load_mods({{"u",unicode},{"p",padded}},{"u","p"}); + f.proxy.set_search_filter(QStringLiteral(" GROßE ")); CHECK(f.source_rows() == QList{0}); + f.proxy.set_search_filter(" padded.pkg "); CHECK(f.source_rows() == QList{1}); +} +TEST_CASE("mod proxy combines search and sidebar filters and exposes transitions") { + ProxyFixture f; + QSignalSpy filtered(&f.proxy,&ModListProxyModel::is_filtered_changed); + f.proxy.set_sidebar_filter(QSet{"uuid-1","uuid-2","uuid-3"}); + f.proxy.set_search_filter("Mod 2"); CHECK(f.source_rows() == QList{1}); + CHECK(f.proxy.is_filtered()); CHECK(filtered.size() == 1); + f.proxy.set_search_filter("Mod 0"); CHECK(f.proxy.rowCount() == 0); + f.proxy.set_search_filter(""); CHECK(f.proxy.rowCount() == 3); + CHECK(filtered.size() == 1); + f.proxy.set_sidebar_filter(std::nullopt); CHECK_FALSE(f.proxy.is_filtered()); CHECK(filtered.size() == 2); + f.proxy.set_search_filter("mod"); CHECK(filtered.size() == 3); + f.proxy.set_search_filter(""); CHECK(filtered.size() == 4); +} +TEST_CASE("mod proxy preserves persistent UUIDs that survive filtering") { + ProxyFixture f; + QPersistentModelIndex kept(f.proxy.index(2)); + QPersistentModelIndex hidden(f.proxy.index(1)); + f.proxy.set_sidebar_filter(QSet{"uuid-1","uuid-3"}); + CHECK(kept.isValid()); CHECK(kept.row() == 0); + CHECK(kept.data(ModListModel::UuidRole).toString() == "uuid-1"); + CHECK_FALSE(hidden.isValid()); + CHECK_FALSE(f.proxy.mapFromSource(f.source.index(0)).isValid()); + f.proxy.set_sidebar_filter(std::nullopt); + f.proxy.set_search_filter("Mod 4"); CHECK(f.source_rows() == QList{4}); + f.proxy.set_search_filter(""); f.check_round_trip(); +} +TEST_CASE("mod proxy moves retain source mappings with and without filters") { + ProxyFixture f; + REQUIRE(f.proxy.move_row(0,2)); f.check_round_trip(); + ProxyFixture filtered; + filtered.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-1","uuid-4"}); + REQUIRE(filtered.proxy.move_row(0,1)); filtered.check_round_trip(); + CHECK_FALSE(filtered.proxy.mapFromSource(filtered.source.index(0)).isValid()); +} +TEST_CASE("mod proxy clamped move stays inside the unfiltered active block") { + ProxyFixture f; + REQUIRE(f.proxy.active_count() == 2); + REQUIRE(f.proxy.move_row(0,4,true)); + CHECK(f.proxy.data(f.proxy.index(0),ModListModel::UuidRole).toString() == "uuid-2"); + CHECK(f.proxy.data(f.proxy.index(1),ModListModel::UuidRole).toString() == "uuid-0"); + CHECK(f.proxy.data(f.proxy.index(2),ModListModel::UuidRole).toString() == "uuid-1"); + CHECK_FALSE(f.proxy.move_row(2,0,true)); + f.proxy.set_search_filter("mod"); CHECK_FALSE(f.proxy.move_row(0,1,true)); + f.check_round_trip(); +} +TEST_CASE("mod proxy emits data changes only after move completion") { + ProxyFixture f; + f.source.load_mods({{"u0",std::make_shared()}, + {"u1",std::make_shared()}, + {"u2",std::make_shared()}},{"u0","u1","u2"}); + QStringList events; + QObject::connect(&f.proxy,&QAbstractItemModel::rowsAboutToBeMoved,&f.proxy,[&] { events.append("begin"); }); + QObject::connect(&f.proxy,&QAbstractItemModel::rowsMoved,&f.proxy,[&] { events.append("end"); }); + QObject::connect(&f.proxy,&QAbstractItemModel::dataChanged,&f.proxy,[&](const QModelIndex& top,const QModelIndex& bottom) { + events.append(QStringLiteral("data:%1:%2").arg(top.row()).arg(bottom.row())); + }); + REQUIRE(f.proxy.move_row(0,2)); + CHECK(events == QStringList{"begin","end","data:0:2"}); + for (int row = 0; row < 3; ++row) CHECK(f.proxy.data(f.proxy.index(row),ModListModel::LoadIndexRole).toInt() == row+1); +} +TEST_CASE("mod proxy section counts reflect visible rows rather than the whole collection") { + ProxyFixture f; + f.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-1"}); + CHECK(f.proxy.active_count() == 1); + CHECK(f.proxy.data(f.proxy.index(1),ModListModel::SectionNameRole).toString() == "Inactive (1)"); + f.proxy.set_sidebar_filter(QSet{"uuid-1"}); + CHECK(f.proxy.data(f.proxy.index(0),ModListModel::SectionNameRole).toString().isEmpty()); +} diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp new file mode 100644 index 0000000..594d764 --- /dev/null +++ b/tests/ui/test_mod_tree_model.cpp @@ -0,0 +1,279 @@ +#include + +#include +#include + +#include "ui/plugins/organizer/filter_model.h" +#include "ui/plugins/organizer/tree_model.h" + +using namespace px::core; +using namespace px::core::organizer; +using px::ui::ModTreeModel; +using px::ui::OrganizerFilterModel; + +namespace { +struct OrganizerFixture { + OrganizerState state; + ModsByUuid mods; + QSet active{QStringLiteral("u1")}; + + OrganizerFixture() { + state.folders.insert(ROOT_ID, {ROOT_ID, std::nullopt, QStringLiteral("Root")}); + auto add = [&](const QString& uuid, const QString& name, const QString& pid, const QString& provider) { + auto mod = std::make_shared(); + mod->name = name; + mod->package_id = CaseInsensitiveStr(pid); + mod->provider_id = provider; + mods.insert(uuid, mod); + }; + add("u1", "One", "a.one", "local"); + add("u2", "Two", "a.two", "steam"); + add("u3", "Loose", "loose.mod", "local"); + } + + void nested() { + state.folders.insert(2, {2, ROOT_ID, QStringLiteral("Top")}); + state.folders.insert(3, {3, 2, QStringLiteral("Sub")}); + state.placements.insert("a.one", 2); + state.placements.insert("a.two", 3); + } + + FolderNode tree(const std::optional& query = std::nullopt) const { + return build_tree(state, mods, active, query); + } +}; + +ModTreeModel::SummaryFor no_diagnostics = [](const QString&) -> std::optional { + return std::nullopt; +}; +} + +TEST_CASE("organizer tree has nested folder rows and trailing ungrouped") { + OrganizerFixture f; + f.nested(); + ModTreeModel model(no_diagnostics); + model.set_tree(f.tree(), f.mods, {}, false); + REQUIRE(model.rowCount() == 2); + const auto top = model.index(0, 0); + const auto ungrouped = model.index(1, 0); + CHECK(model.data(top).toString() == "Top"); + CHECK(model.data(ungrouped).toString() == "Ungrouped"); + CHECK(model.data(model.index(0, 0, top)).toString() == "Sub"); + CHECK(model.data(model.index(1, 0, top)).toString() == "One"); + CHECK(model.data(model.index(0, 0, ungrouped)).toString() == "Loose"); + CHECK(model.parent(model.index(0, 0, top)) == top); + CHECK(model.parent(model.index(0, 0, ungrouped)) == ungrouped); + CHECK_FALSE(model.parent(top).isValid()); + CHECK(model.data(ungrouped, ModTreeModel::TotalCountRole).toInt() == 1); + CHECK(model.data(top, ModTreeModel::TotalCountRole).toInt() == 2); + CHECK(model.node_at(top)->folder_id() == 2); + CHECK(model.node_at(model.index_for_uuid("u2"))->folder_id() == 3); + CHECK(model.node_at(ungrouped)->folder_id() == ROOT_ID); + CHECK(model.node_at({}) == nullptr); + CHECK_FALSE(model.index(-1, 0).isValid()); + CHECK_FALSE(model.index(0, 1).isValid()); + CHECK_FALSE(model.index_for_key("missing").isValid()); + CHECK_FALSE(model.index_for_uuid("missing").isValid()); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.columnCount() == 1); +} + +TEST_CASE("organizer check states retain total counts while filtering") { + OrganizerFixture f; + f.state.folders.insert(2, {2, ROOT_ID, QStringLiteral("Group")}); + f.state.placements.insert("a.one", 2); + f.state.placements.insert("a.two", 2); + ModTreeModel model(no_diagnostics); + model.set_tree(f.tree(), f.mods, {}, false); + auto group = model.index(0, 0); + CHECK(model.data(group, ModTreeModel::CheckStateRole).toInt() == Qt::PartiallyChecked); + CHECK(model.data(model.index(0, 0, group), ModTreeModel::CheckStateRole).toInt() == Qt::Checked); + CHECK(model.data(model.index(1, 0, group), ModTreeModel::CheckStateRole).toInt() == Qt::Unchecked); + CHECK(model.data(model.index(1, 0), ModTreeModel::CheckStateRole).toInt() == Qt::Unchecked); + TreeQuery query; + query.text = QStringLiteral("One"); + model.set_tree(f.tree(query), f.mods, {}, true); + group = model.index(0, 0); + CHECK(model.rowCount() == 1); + CHECK(model.filtering()); + CHECK(model.data(group, ModTreeModel::FilteringRole).toBool()); + CHECK(model.data(group, ModTreeModel::VisibleCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::TotalCountRole).toInt() == 2); + CHECK(model.data(group, ModTreeModel::EnabledCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::CheckStateRole).toInt() == Qt::PartiallyChecked); + CHECK(model.index_for_key("f:2").isValid()); +} + +TEST_CASE("organizer issue markers only show on enabled mods") { + OrganizerFixture f; + view::ModDiagnosticsView summary{true, true, QStringLiteral("Error"), QStringLiteral("Incompatible")}; + int calls = 0; + ModTreeModel model([&](const QString&) -> std::optional { ++calls; return summary; }); + model.set_tree(f.tree(), f.mods, {}, false); + const auto enabled = model.index_for_uuid("u1"); + const auto inactive = model.index_for_uuid("u2"); + CHECK(model.parent(enabled) == model.index(0, 0)); + CHECK(model.data(enabled, ModTreeModel::HasErrorRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::HasWarningRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::ErrorTooltipRole).toString() == "Error"); + CHECK(model.data(enabled, ModTreeModel::WarningTooltipRole).toString() == "Incompatible"); + calls = 0; + CHECK_FALSE(model.data(inactive, ModTreeModel::HasErrorRole).toBool()); + CHECK_FALSE(model.data(inactive, ModTreeModel::HasWarningRole).toBool()); + CHECK(model.data(inactive, ModTreeModel::ErrorTooltipRole).toString().isEmpty()); + CHECK(model.data(inactive, ModTreeModel::WarningTooltipRole).toString().isEmpty()); + CHECK(calls == 0); + f.active.insert("u2"); + model.set_tree(f.tree(), f.mods, {}, false); + CHECK(model.data(model.index_for_uuid("u2"), ModTreeModel::HasWarningRole).toBool()); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.refresh_diagnostics(); + REQUIRE(changed.count() == 2); + CHECK(changed.first()[2].value>() == QList{ModTreeModel::HasErrorRole, + ModTreeModel::ErrorTooltipRole, ModTreeModel::HasWarningRole, ModTreeModel::WarningTooltipRole}); +} + +TEST_CASE("organizer same-shape updates preserve persistent indexes and selection") { + OrganizerFixture f; + f.nested(); + ModTreeModel model(no_diagnostics); + model.set_tree(f.tree(), f.mods, {}, false); + QPersistentModelIndex one(model.index_for_uuid("u1")); + model.select({one}); + model.select({model.index_for_uuid("u2")}, "extend"); + REQUIRE(model.selected_nodes().size() == 2); + model.select({model.index_for_uuid("u2")}, "toggle"); + CHECK_FALSE(model.is_selected(model.index_for_uuid("u2"))); + CHECK(model.is_selected(one)); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + f.active.insert("u2"); + model.set_tree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(one.isValid()); + CHECK(model.data(one).toString() == "One"); + CHECK(model.data(one, ModTreeModel::SelectedRole).toBool()); + model.clear_selection(); + CHECK(model.selected_nodes().isEmpty()); + model.select({model.index_for_uuid("u3")}); + TreeQuery query; + query.text = QStringLiteral("One"); + model.set_tree(f.tree(query), f.mods, {}, true); + CHECK(reset.count() == 1); + CHECK_FALSE(one.isValid()); + CHECK(model.selected_nodes().isEmpty()); +} + +TEST_CASE("organizer expansion respects collapsed ancestors except when filtering") { + OrganizerFixture f; + f.nested(); + f.state.folders[2].collapsed = true; + ModTreeModel model(no_diagnostics); + model.set_tree(f.tree(), f.mods, {}, false); + auto expanded = model.expanded_indexes(); + REQUIRE(expanded.size() == 1); + CHECK(model.data(expanded.first(), ModTreeModel::KeyRole).toString() == "ungrouped"); + model.set_tree(f.tree(), f.mods, {}, true); + expanded = model.expanded_indexes(); + REQUIRE(expanded.size() == 3); + CHECK(model.data(expanded[0], ModTreeModel::KeyRole).toString() == "f:2"); + CHECK(model.data(expanded[1], ModTreeModel::KeyRole).toString() == "f:3"); + CHECK(model.expanded_indexes(model.index_for_key("f:2")).size() == 1); +} + +TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { + OrganizerFixture f; + f.nested(); + f.state.tags.insert(7, {7, QStringLiteral("Later"), QStringLiteral("blue")}); + f.state.tags.insert(2, {2, QStringLiteral("First"), QStringLiteral("red")}); + f.state.mod_tags.insert("a.one", {7, 2, 99}); + auto about = std::dynamic_pointer_cast(f.mods["u1"]); + about->mod_version = QStringLiteral("1.2.3"); + auto tree = f.tree(); + tree.children[0].has_rules = true; + tree.has_rules = true; + ModTreeModel model(no_diagnostics); + model.set_tree(tree, f.mods, {{QStringLiteral("local"), QStringLiteral("green")}}, false, f.state.tags); + const auto one = model.index_for_uuid("u1"); + CHECK(model.data(one, ModTreeModel::UuidRole).toString() == "u1"); + CHECK(model.data(one, ModTreeModel::PackageIdRole).toString() == "a.one"); + CHECK(model.data(one, ModTreeModel::CanPlaceRole).toBool()); + CHECK(model.data(one, ModTreeModel::ProviderLabelRole).toString() == "Local"); + CHECK(model.data(one, ModTreeModel::ProviderIconRole).toString() == "folder"); + CHECK(model.data(one, ModTreeModel::ProviderColorRole).toString() == "green"); + CHECK(model.data(one, ModTreeModel::ModVersionRole).toString() == "1.2.3"); + CHECK(model.data(model.index_for_uuid("u2"), ModTreeModel::ProviderIconRole).toString() == "steam"); + const auto tags = model.data(one, ModTreeModel::TagsRole).toList(); + REQUIRE(tags.size() == 2); + CHECK(tags[0].toMap()["name"].toString() == "First"); + CHECK(tags[1].toMap()["color"].toString() == "blue"); + CHECK(model.data(model.index_for_key("f:2"), ModTreeModel::HasRuleRole).toBool()); + CHECK_FALSE(model.data(model.index_for_key("ungrouped"), ModTreeModel::HasRuleRole).toBool()); + CHECK(model.roleNames()[ModTreeModel::ProviderLabelRole] == "providerLabel"); + CHECK(model.roleNames()[ModTreeModel::TagsRole] == "tags"); + + auto invalid = std::make_shared(); + invalid->provider_id = QStringLiteral("git"); + f.mods.insert("invalid", invalid); + model.set_tree(f.tree(), f.mods, {}, false); + const auto idx = model.index_for_uuid("invalid"); + REQUIRE(idx.isValid()); + CHECK_FALSE(model.data(idx, ModTreeModel::CanPlaceRole).toBool()); + CHECK(model.data(idx, ModTreeModel::PackageIdRole).toString().isEmpty()); + CHECK(model.data(idx, ModTreeModel::ProviderIconRole).toString() == "git"); + CHECK(model.data(idx, ModTreeModel::ModVersionRole).toString().isEmpty()); +} + +TEST_CASE("organizer filter sections icons and fallbacks match sidebar entries") { + OrganizerFilterModel model; + CHECK(model.rowCount() == 0); + CHECK(model.for_key("missing").key == "all"); + CHECK_FALSE(model.key_at(0)); + QList entries{ + {QStringLiteral("all"), QStringLiteral("All"), 10}, + {QStringLiteral("active"), QStringLiteral("Active"), 6, StatusFilter::Active}, + {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, StatusFilter::Inactive}, + {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, StatusFilter::All, QStringLiteral("steam")}, + {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, StatusFilter::All, std::nullopt, 1}, + {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::All, std::nullopt, 2}}; + model.update(entries); + REQUIRE(model.rowCount() == 6); + const QStringList sections{"Status", "Status", "Status", "Providers", "Tags", "Tags"}; + const QStringList icons{"grid", "check-circle", "ban", "steam", "tag", "tag"}; + for (int row = 0; row < 6; ++row) { + CHECK(model.data(model.index(row), OrganizerFilterModel::SectionRole).toString() == sections[row]); + CHECK(model.data(model.index(row), OrganizerFilterModel::IconRole).toString() == icons[row]); + } + CHECK(model.data(model.index(4), OrganizerFilterModel::KeyRole).toString() == "tag:1"); + CHECK(model.data(model.index(4)).toString() == "Medieval"); + CHECK(model.data(model.index(4), OrganizerFilterModel::LabelRole).toString() == "Medieval"); + CHECK(model.data(model.index(4), OrganizerFilterModel::CountRole).toInt() == 5); + CHECK(model.data(model.index(5), OrganizerFilterModel::CountRole).toInt() == 0); + CHECK(model.for_key("tag:1").label == "Medieval"); + CHECK(model.for_key("tag:999").key == "all"); + CHECK(model.key_at(4) == QStringLiteral("tag:1")); + CHECK_FALSE(model.key_at(-1)); + CHECK_FALSE(model.key_at(6)); + CHECK(model.rowCount(model.index(0)) == 0); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.roleNames()[Qt::DisplayRole] == "filterLabel"); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + entries[4].label = QStringLiteral("Medieval Overhaul"); + model.update(entries); + CHECK(reset.isEmpty()); + CHECK(changed.count() == 1); + CHECK(model.data(model.index(4)).toString() == "Medieval Overhaul"); + entries.removeLast(); + model.update(entries); + CHECK(reset.count() == 1); +} + +TEST_CASE("organizer filter infers sections from keys and unknown providers use folders") { + OrganizerFilterModel model; + model.update({{QStringLiteral("provider:custom"), QStringLiteral("Custom"), 1}, + {QStringLiteral("tag:9"), QStringLiteral("Tag"), 1}}); + CHECK(model.data(model.index(0), OrganizerFilterModel::SectionRole).toString() == "Providers"); + CHECK(model.data(model.index(0), OrganizerFilterModel::IconRole).toString() == "folder"); + CHECK(model.data(model.index(1), OrganizerFilterModel::SectionRole).toString() == "Tags"); + CHECK(model.data(model.index(1), OrganizerFilterModel::IconRole).toString() == "tag"); +} diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp new file mode 100644 index 0000000..d750237 --- /dev/null +++ b/tests/ui/test_mod_updates.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include +#include +#include "ui/components/mod_updates.h" +#include "../core/services/services_support.h" + +TEST_CASE("small updates skip confirmation") { + QWidget parent; + CHECK(services_test::run_async(px::ui::confirm_update(px::ui::CONFIRM_THRESHOLD-1,&parent))); + CHECK(parent.findChildren().isEmpty()); +} +TEST_CASE("large updates follow the actual confirmation dialog answer") { + QWidget parent; + for (const auto answer : {QMessageBox::Yes,QMessageBox::Cancel}) { + QTimer click(&parent); + click.setInterval(1); + QObject::connect(&click,&QTimer::timeout,&parent,[&] { + for (auto* box : parent.findChildren()) if (box->isVisible()) { + click.stop(); box->button(answer)->click(); return; + } + }); + click.start(); + CHECK(services_test::run_async(px::ui::confirm_update(px::ui::CONFIRM_THRESHOLD,&parent)) == (answer == QMessageBox::Yes)); + } +} diff --git a/tests/ui/test_navigation.cpp b/tests/ui/test_navigation.cpp new file mode 100644 index 0000000..cc016d4 --- /dev/null +++ b/tests/ui/test_navigation.cpp @@ -0,0 +1,30 @@ +#include + +#include +#include + +#include "ui/navigation.h" + +using px::ui::Route; +using px::ui::buildRoute; +using px::ui::parseRoute; + +TEST_CASE("route round-trips ids with reserved characters") { + const QString url = buildRoute(QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}); + CHECK(url == QStringLiteral("modrim://workshop/collection/picked%3Aa%2Fb")); + CHECK(parseRoute(url) == Route{QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}}); +} + +TEST_CASE("view-only route has an empty path") { + CHECK(parseRoute(QStringLiteral("modrim://downloads")) == Route{QStringLiteral("downloads"), {}}); + CHECK(parseRoute(QStringLiteral("modrim://downloads/")) == Route{QStringLiteral("downloads"), {}}); +} + +TEST_CASE("non-routes are rejected") { + for (const char* url : {"downloads", "https://downloads", "modrim://", ""}) { + CAPTURE(url); + CHECK_FALSE(parseRoute(QString::fromUtf8(url)).has_value()); + } +} diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp new file mode 100644 index 0000000..6086f96 --- /dev/null +++ b/tests/ui/test_organizer_dialogs.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include +#include +#include +#include + +#include "ui/plugins/organizer/dialogs.h" + +using px::ui::DeleteFolderDialog; + +namespace { + +// Widgets need a QApplication. The test binary keeps one for its whole run, offscreen +// unless the environment picks a platform, so no display is needed. +QApplication& widget_app() { + static int argc = 1; + static char name[] = "pxmodrim_ui_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + static QApplication app(argc, argv); + return app; +} + +} // namespace + +TEST_CASE("delete folder dialog confirms with yes button") { + widget_app(); + QWidget parent; + DeleteFolderDialog dialog(QStringLiteral("Fixes"), &parent); + auto* yes = qobject_cast(dialog.button(QMessageBox::Yes)); + REQUIRE(yes != nullptr); + CHECK(yes->text() == QStringLiteral("Delete")); + yes->click(); + CHECK(dialog.result() == static_cast(QMessageBox::Yes)); +} diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp new file mode 100644 index 0000000..95e0078 --- /dev/null +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -0,0 +1,355 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.h" +#include "core/context.h" +#include "core/organizer/defaults.h" +#include "ui/components/svg_provider.h" +#include "ui/context.h" +#include "ui/plugins/organizer/tree_model.h" +#include "ui/plugins/organizer/view.h" +#include "ui/theme/qml_theme.h" +#include "../core/organizer/test_util.h" + +namespace { +using namespace px::core; +using namespace px::core::organizer; +using namespace px::ui; +using organizer_test::expect; +using organizer_test::wait; + +// UI tests may share a QApplication supplied by their test executable. +class Application { +public: + Application() { + if (!QCoreApplication::instance()) app_ = std::make_unique(argc_, argv_); + REQUIRE(qobject_cast(QCoreApplication::instance())); + } +private: + int argc_ = 1; + char name_[16] = "organizer-test"; + char* argv_[2] = {name_, nullptr}; + std::unique_ptr app_; +}; + +template +void until(Predicate predicate) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < 5000) QTest::qWait(10); + REQUIRE(predicate()); +} + +QQuickItem* item(QQuickItem* root, const QString& name) { + if (root->objectName() == name) return root; + for (auto* child : root->childItems()) { + if (auto* found = item(child, name)) return found; + } + return nullptr; +} + +struct Harness { + Application application; + QTemporaryDir dir; + std::unique_ptr ctx = px::ui_test::makeCore(AppConfig{}); + OrganizerDb db{dir.filePath(QStringLiteral("organizer.db"))}; + OrganizerService* service = nullptr; + std::unique_ptr engine{createQmlEngine(QCoreApplication::instance())}; + AppContext app{*ctx}; + QWidget parent; + std::unique_ptr view; + QQuickItem* editor = nullptr; + + Harness() { + REQUIRE(dir.isValid()); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Example"); + mod->provider_id = QStringLiteral("local"); + mod->package_id = CaseInsensitiveStr(QStringView(u"example.mod")); + ctx->load({{QStringLiteral("example"), mod}}, {}); + auto plugin = std::make_unique(*ctx, db); + service = plugin.get(); + ctx->registerPlugin(std::move(plugin)); + wait(service->init()); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(engine.get())); + view = std::make_unique(*ctx, engine.get(), &parent, app); + parent.resize(920, 700); + view->resize(parent.size()); + parent.show(); + view->show(); + view->rebuild(); + } + + ~Harness() { + view.reset(); + wait(service->shutdown()); + } + + QJSValue call(const QString& expression) { + engine->globalObject().setProperty(QStringLiteral("editor"), engine->newQObject(editor)); + auto result = engine->evaluate(QStringLiteral("editor.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + return result; + } + + void open(int count) { + view->openRules(); + auto* quick = view->findChild(QStringLiteral("organizerRuleEditorQuickWidget")); + REQUIRE(quick); + until([&] { return quick->status() != QQuickWidget::Loading; }); + INFO((quick->errors().isEmpty() ? std::string{} : quick->errors().first().toString().toStdString())); + REQUIRE(quick->status() == QQuickWidget::Ready); + editor = quick->rootObject(); + REQUIRE(editor); + until([&] { return editor->property("opened").toBool() && draft().size() == count; }); + } + + QVariantList draft() { return call(QStringLiteral("draftRules()")).toVariant().toList(); } + + void pattern(int index, const QString& value) { + REQUIRE(QMetaObject::invokeMethod(editor, "setRulePattern", Q_ARG(QVariant, index), Q_ARG(QVariant, value))); + } + + QQuickItem* row(int index) { + QQuickItem* result = nullptr; + until([&] { + auto visit = [&](auto&& self, QQuickItem* node) -> void { + if (node->objectName() == QStringLiteral("organizerRuleRow") && node->property("index").toInt() == index) result = node; + for (auto* child : node->childItems()) self(self, child); + }; + visit(visit, editor); + return result != nullptr; + }); + return result; + } + + void choose(int index, const QString& name, int choice) { + auto* control = item(row(index), name); + REQUIRE(control); + REQUIRE(control->setProperty("currentIndex", choice)); + REQUIRE(QMetaObject::invokeMethod(control, "activated", Q_ARG(int, choice))); + } + + void save() { + call(QStringLiteral("submitRules()")); + until([&] { return !editor->property("opened").toBool(); }); + } +}; + +void checkRule(const Rule& rule, RuleOp op, const QString& pattern, qint64 folder) { + CHECK(rule.field == RuleField::PackageId); + CHECK(rule.op == op); + CHECK(rule.pattern == pattern); + CHECK(rule.folder_id == folder); +} +} // namespace + +TEST_CASE("organizer rule editor preserves order and retains an invalid draft", "[ui][organizer]") { + Harness h; + const auto first = expect(wait(h.service->create_folder(QStringLiteral("First")))); + const auto second = expect(wait(h.service->create_folder(QStringLiteral("Second")))); + h.view->rebuild(); + h.open(0); + h.call(QStringLiteral("addRule()")); + h.pattern(0, QStringLiteral("example")); + h.save(); + auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::Prefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folder_mod_uuids(first.id)) == QStringList{QStringLiteral("example")}); + + h.open(1); + h.call(QStringLiteral("addRule()")); + h.pattern(1, QStringLiteral("example.mod")); + h.choose(1, QStringLiteral("organizerRuleOperator"), 2); + const auto folders = h.view->ruleFolders(); + const auto target = std::find_if(folders.begin(), folders.end(), [&](const QVariant& value) { + return value.toMap().value(QStringLiteral("id")).toLongLong() == second.id; + }); + REQUIRE(target != folders.end()); + h.choose(1, QStringLiteral("organizerRuleFolder"), static_cast(target - folders.begin())); + h.call(QStringLiteral("moveUp(1)")); + REQUIRE(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral("example.mod")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 2); + checkRule(state.rules[0], RuleOp::Equals, QStringLiteral("example.mod"), second.id); + checkRule(state.rules[1], RuleOp::Prefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folder_mod_uuids(second.id)) == QStringList{QStringLiteral("example")}); + + const auto saved = state.rules; + h.open(2); + h.pattern(0, QStringLiteral(" ")); + h.call(QStringLiteral("submitRules()")); + until([&] { return h.editor->property("errorMessage").toString().contains(QStringLiteral("pattern cannot be empty")); }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral(" ")); + CHECK(h.draft()[1].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral("example")); + CHECK(expect(wait(h.db.load())).rules == saved); + h.call(QStringLiteral("removeRule(0)")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::Prefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folder_mod_uuids(first.id)) == QStringList{QStringLiteral("example")}); +} + +TEST_CASE("organizer standard rules refresh only a clean editor draft", "[ui][organizer]") { + Harness h; + expect(wait(h.service->create_folder(QStringLiteral("Mine")))); + h.view->rebuild(); + h.open(0); + auto* standard = item(h.editor, QStringLiteral("organizerAddStandardRules")); + REQUIRE(standard); + REQUIRE(standard->property("enabled").toBool()); + REQUIRE(QMetaObject::invokeMethod(standard, "clicked")); + until([&] { return h.draft().size() == standard_rules().size(); }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.editor->property("errorMessage").toString().isEmpty()); + CHECK_FALSE(h.editor->property("draftDirty").toBool()); + const auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == standard_rules().size()); + for (qsizetype i = 0; i < state.rules.size(); ++i) { + const auto& actual = state.rules[i]; + const auto& expected = standard_rules()[i]; + CHECK(state.folders.value(actual.folder_id).name == expected.folder); + CHECK(actual.field == expected.field); + CHECK(actual.op == expected.op); + CHECK(actual.pattern == expected.pattern); + CHECK(h.draft()[i].toMap().value(QStringLiteral("pattern")).toString() == expected.pattern); + } + REQUIRE(standard->property("enabled").toBool()); + h.pattern(0, QStringLiteral("edited")); + until([&] { return !standard->property("enabled").toBool(); }); + CHECK(h.editor->property("draftDirty").toBool()); + CHECK(expect(wait(h.db.load())).rules == state.rules); +} + +TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->provider_id = QStringLiteral("local"); + second->package_id = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + const auto select = [&](bool both) { + const auto first = model->index_for_uuid(QStringLiteral("example")); + REQUIRE(first.isValid()); + h.view->selectSingle(first); + if (both) { + const auto other = model->index_for_uuid(QStringLiteral("second")); + REQUIRE(other.isValid()); + h.view->selectRow(other, static_cast(Qt::ControlModifier), {}); + } + }; + // Invoke through QML so QmlTask-returning actions follow the real call path. + h.engine->globalObject().setProperty(QStringLiteral("panel"), h.engine->newQObject(h.view.get())); + const auto action = [&](const QString& expression) { + const auto result = h.engine->evaluate(QStringLiteral("panel.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + }; + select(false); + action(QStringLiteral("createTag('Favorites', 'not-a-color')")); + until([&] { return h.view->editorError().contains(QStringLiteral("valid color")); }); + CHECK(h.service->state().tags.isEmpty()); + action(QStringLiteral("createTag('Favorites', '#3399aa')")); + until([&] { return h.service->state().tags.size() == 1; }); + const auto id = h.service->state().tags.constBegin().key(); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(h.service->state().tags.value(id).color == QStringLiteral("#3399aa")); + CHECK(h.view->editorError().isEmpty()); + const auto assign = [&](bool add) { + action(QStringLiteral("assignTag(%1, %2)").arg(id).arg(add ? QStringLiteral("true") : QStringLiteral("false"))); + }; + const auto checkState = [&] { + const auto rows = h.view->tagRows(); + REQUIRE(rows.size() == 1); + return rows[0].toMap().value(QStringLiteral("checkState")).toInt(); + }; + CHECK(checkState() == 0); + assign(true); + until([&] { return h.service->state().mod_tags.value(QStringLiteral("example.mod")).contains(id); }); + CHECK(checkState() == 1); + select(true); + CHECK(checkState() == 2); + assign(true); + until([&] { return h.service->state().mod_tags.value(QStringLiteral("second.mod")).contains(id); }); + CHECK(checkState() == 1); + auto persisted = expect(wait(h.db.load())); + CHECK(persisted.mod_tags.value(QStringLiteral("example.mod")) == QSet{id}); + CHECK(persisted.mod_tags.value(QStringLiteral("second.mod")) == QSet{id}); + assign(false); + until([&] { + return h.service->state().mod_tags.value(QStringLiteral("example.mod")).isEmpty() + && h.service->state().mod_tags.value(QStringLiteral("second.mod")).isEmpty(); + }); + CHECK(checkState() == 0); + action(QStringLiteral("updateTag(%1, 'Best', 'bad')").arg(id)); + until([&] { return h.view->editorError().contains(QStringLiteral("valid color")); }); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(expect(wait(h.db.load())).tags.value(id).color == QStringLiteral("#3399aa")); + action(QStringLiteral("updateTag(%1, 'Best', '#123456')").arg(id)); + until([&] { return h.service->state().tags.value(id).name == QStringLiteral("Best"); }); + CHECK(h.view->editorError().isEmpty()); + persisted = expect(wait(h.db.load())); + CHECK(persisted.tags.value(id).name == QStringLiteral("Best")); + CHECK(persisted.tags.value(id).color == QStringLiteral("#123456")); + CHECK(persisted.mod_tags.value(QStringLiteral("example.mod")).isEmpty()); + CHECK(persisted.mod_tags.value(QStringLiteral("second.mod")).isEmpty()); + action(QStringLiteral("deleteTag(%1)").arg(id)); + until([&] { return h.service->state().tags.isEmpty(); }); + CHECK(expect(wait(h.db.load())).tags.isEmpty()); +} + +TEST_CASE("organizer folder checkbox toggles descendants with one active-state change", "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->provider_id = QStringLiteral("local"); + second->package_id = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + const auto parent = expect(wait(h.service->create_folder(QStringLiteral("Parent")))); + const auto child = expect(wait(h.service->create_folder(QStringLiteral("Child"), parent.id))); + expect(wait(h.service->place({QStringLiteral("example.mod")}, child.id))); + expect(wait(h.service->place({QStringLiteral("second.mod")}, parent.id))); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + int changes = 0; + const auto connection = h.ctx->activeStateChanged().connect([&](const QStringList&) { ++changes; }); + const auto toggle = [&] { + const auto index = model->index_for_key(QStringLiteral("f:%1").arg(parent.id)); + REQUIRE(index.isValid()); + h.view->toggleCheck(index); + }; + toggle(); + until([&] { return h.ctx->activeUuids().size() == 2; }); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("example"))); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("second"))); + CHECK(changes == 1); + h.view->rebuild(); + toggle(); + until([&] { return h.ctx->activeUuids().isEmpty(); }); + CHECK(changes == 2); + h.ctx->activeStateChanged().disconnect(connection); +} diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp new file mode 100644 index 0000000..fec7482 --- /dev/null +++ b/tests/ui/test_procedural_preview.cpp @@ -0,0 +1,68 @@ +#include + +#include +#include +#include +#include + +#include "ui/components/procedural_preview.h" + +namespace { + +// generate_preview paints with QPainter and QFont, so it needs a GUI application. Reuse one +// if the test binary already has it; otherwise create one, offscreen unless the environment picks a platform. +QApplication& application() { + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "pxmodrim_ui_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + static QApplication app(argc, argv); + return app; +} + +} // namespace + +TEST_CASE("initials abbreviates titles") { + struct Case { + const char* title; + const char* expected; + }; + const Case cases[] = { + {"[AV] Framework - Russian Translation", "FR"}, + {"SF Grim Reality", "SG"}, + {"Vanilla Expanded Framework", "VE"}, + {"RimworldTweaks", "RT"}, + {"Combat", "CO"}, + {"[AV]", "AV"}, + {"[AV] Framework", "AF"}, + {"The Lord of Rings", "LR"}, + {"Русский перевод", "РП"}, + {"---", "--"}, + }; + for (const Case& c : cases) { + INFO("title: " << c.title); + CHECK(px::ui::initials(QString::fromUtf8(c.title)) == QString::fromUtf8(c.expected)); + } +} + +TEST_CASE("generate preview returns a non-null image of the requested size") { + application(); + const QImage image = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + CHECK(image.width() == 120); + CHECK(image.height() == 60); +} + +TEST_CASE("generate preview returns the cached image for the same request") { + application(); + const QImage image = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + // QImage shares its data implicitly, so a cache hit is identified by the shared data's cacheKey. + const QImage again = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + CHECK(again.cacheKey() == image.cacheKey()); +} diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp new file mode 100644 index 0000000..6595393 --- /dev/null +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -0,0 +1,267 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mods_config.h" +#include "core/models/metadata/structures.h" +#include "ui/panels/restore_snapshot_dialog.h" + +namespace { + +using px::core::CaseInsensitiveStr; +using px::core::ModsConfig; +using px::ui::ConfirmRestoreDialog; +using px::ui::RestoreSnapshotDialog; + +// Widgets need a QApplication for the whole run; the first test that asks for it creates it. +QApplication& ensure_app() { + if (auto* existing = qobject_cast(QCoreApplication::instance())) return *existing; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim_tests"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; +} + +void write_text(const QString& path, const QByteArray& bytes) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + REQUIRE(file.write(bytes) == bytes.size()); +} + +void write_snapshot(const QString& path, const QStringList& package_ids) { + ModsConfig config; + config.version = QStringLiteral("1.5"); + for (const QString& id : package_ids) { + config.activeMods.append(CaseInsensitiveStr(id)); + } + REQUIRE(px::core::write_mods_config(path, config, QFileInfo(path).absolutePath()).has_value()); +} + +QListWidget* list_of(RestoreSnapshotDialog& dialog) { + return dialog.findChild(); +} + +QPushButton* button_labelled(RestoreSnapshotDialog& dialog, const QString& text) { + for (QPushButton* button : dialog.findChildren()) { + if (button->text() == text) { + return button; + } + } + return nullptr; +} + +// The details label is the only label carrying markup; the instruction line has none. +QString details_text(RestoreSnapshotDialog& dialog) { + for (QLabel* label : dialog.findChildren()) { + if (label->text().contains(QStringLiteral(""))) { + return label->text(); + } + } + return {}; +} + +} // namespace + +TEST_CASE("Restore dialog selects readable snapshots and disables Restore for unreadable ones", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + write_snapshot(valid_snap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + write_text(corrupt_snap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{valid_snap, corrupt_snap}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + // The first readable snapshot is selected initially. + REQUIRE(dialog.selectedSnapshot() == valid_snap); + QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); + QPushButton* cancel_btn = button_labelled(dialog, QStringLiteral("Cancel")); + REQUIRE(restore_btn != nullptr); + REQUIRE(cancel_btn != nullptr); + REQUIRE(restore_btn->isEnabled()); + REQUIRE(restore_btn->parentWidget() == &dialog); + + list->setCurrentRow(1); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE_FALSE(restore_btn->isEnabled()); + + list->setCurrentRow(0); + REQUIRE(dialog.selectedSnapshot() == valid_snap); + REQUIRE(restore_btn->isEnabled()); + + cancel_btn->click(); + REQUIRE(dialog.result() == QDialog::Rejected); + + RestoreSnapshotDialog accept_dialog(QStringList{valid_snap}); + QListWidget* accept_list = list_of(accept_dialog); + REQUIRE(accept_list != nullptr); + emit accept_list->itemDoubleClicked(accept_list->item(0)); + REQUIRE(accept_dialog.result() == QDialog::Accepted); + REQUIRE(accept_dialog.selectedSnapshot() == valid_snap); +} + +TEST_CASE("Restore dialog Restore button accepts the selected snapshot", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + write_snapshot(valid_snap, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{valid_snap}); + QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); + REQUIRE(restore_btn != nullptr); + restore_btn->click(); + REQUIRE(dialog.result() == QDialog::Accepted); + REQUIRE(dialog.selectedSnapshot() == valid_snap); +} + +TEST_CASE("Restore dialog double-click on an unreadable snapshot does not accept", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + write_text(corrupt_snap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{corrupt_snap}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + emit list->itemDoubleClicked(list->item(0)); + REQUIRE(dialog.result() == QDialog::Rejected); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); +} + +TEST_CASE("Restore dialog lists snapshot timestamps and mod counts in given order", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString two_mods = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + write_snapshot(two_mods, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString one_mod = dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); + write_snapshot(one_mod, {QStringLiteral("mod.foo")}); + const QString no_mods = dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); + write_snapshot(no_mods, {}); + const QString corrupt = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + write_text(corrupt, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{two_mods, one_mod, no_mods, corrupt}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 4); + + const QString dash = QString::fromUtf8("\u2014"); + REQUIRE(list->item(0)->text() == QStringLiteral("2026-09-22 12:00:00 UTC ") + dash + + QStringLiteral(" 2 active mods, RimWorld 1.5")); + REQUIRE(list->item(1)->text() == QStringLiteral("2026-09-22 14:00:00 UTC ") + dash + + QStringLiteral(" 1 active mod, RimWorld 1.5")); + REQUIRE(list->item(2)->text() == QStringLiteral("2026-09-22 15:00:00 UTC ") + dash + + QStringLiteral(" 0 active mods, RimWorld 1.5")); + REQUIRE(list->item(3)->text() == QStringLiteral("2026-09-22 13:00:00 UTC ") + dash + + QStringLiteral(" Cannot be read")); + REQUIRE(list->item(0)->toolTip() == QStringLiteral("ModsConfig_20260922_120000.xml")); +} + +TEST_CASE("Restore dialog falls back to file modification time for unnamed or invalid snapshot names", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString unnamed = dir.filePath(QStringLiteral("custom.xml")); + write_snapshot(unnamed, {QStringLiteral("mod.foo")}); + const QString bad_date = dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); + write_snapshot(bad_date, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{unnamed, bad_date}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + const QRegularExpression pattern(QStringLiteral( + R"(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC \x{2014} 1 active mod, RimWorld 1\.5$)")); + REQUIRE(pattern.match(list->item(0)->text()).hasMatch()); + REQUIRE(pattern.match(list->item(1)->text()).hasMatch()); +} + +TEST_CASE("Restore dialog details describe the selected snapshot", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + write_snapshot(valid_snap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + write_text(corrupt_snap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{valid_snap, corrupt_snap}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + + REQUIRE(details_text(dialog) == + QStringLiteral("File: ModsConfig_20260922_120000.xml
    Active mods: 2 | " + "Game version: 1.5")); + + list->setCurrentRow(1); + REQUIRE(details_text(dialog) == + QStringLiteral("This saved mod list cannot be read. ModsConfig_20260922_130000.xml " + "will not be restored.")); + + list->setCurrentItem(nullptr); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE(details_text(dialog).isEmpty()); +} + +TEST_CASE("Restore dialog with no snapshots has nothing to select", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + + RestoreSnapshotDialog dialog(QStringList{}); + QListWidget* list = list_of(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 0); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); + REQUIRE(restore_btn != nullptr); + REQUIRE_FALSE(restore_btn->isEnabled()); +} + +TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensure_app(); + + ConfirmRestoreDialog confirm(QStringLiteral("ModsConfig_20260922_120000.xml")); + REQUIRE(confirm.windowTitle() == QStringLiteral("Restore Mod List")); + REQUIRE(confirm.text().contains(QStringLiteral("ModsConfig_20260922_120000.xml"))); + REQUIRE(confirm.informativeText().contains(QStringLiteral("backup"))); + REQUIRE(confirm.icon() == QMessageBox::Question); + REQUIRE(confirm.defaultButton() == confirm.button(QMessageBox::Cancel)); + + auto* restore_btn = qobject_cast(confirm.button(QMessageBox::Yes)); + REQUIRE(restore_btn != nullptr); + REQUIRE(restore_btn->text() == QStringLiteral("Restore")); + REQUIRE(restore_btn->objectName() == QStringLiteral("primaryAction")); + auto* cancel_btn = qobject_cast(confirm.button(QMessageBox::Cancel)); + REQUIRE(cancel_btn != nullptr); + REQUIRE(cancel_btn->text() == QStringLiteral("Cancel")); +} diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp new file mode 100644 index 0000000..1c3e0b8 --- /dev/null +++ b/tests/ui/test_settings_panel.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "test_support.h" +#include "core/context.h" +#include "ui/components/svg_provider.h" +#include "ui/panels/settings_panel.h" +namespace { +void ensureApplication() { + if (QCoreApplication::instance()) return; + static int argc = 1; + static char name[] = "settings_panel_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); + static QApplication app(argc, argv); +} +void settle(int milliseconds) { + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(milliseconds); + loop.exec(); +} +} +TEST_CASE("Cancel settings unloads QML without binding errors") { + ensureApplication(); + QWidget host; + auto* engine = px::ui::createQmlEngine(&host); + QList warnings; + QObject::connect(engine, &QQmlEngine::warnings, &host, [&](const QList& values) { warnings.append(values); }); + auto context = px::ui_test::makeCore(); + px::ui::SettingsPanel panel(*context, engine, &host); + panel.resize(760, 640); + panel.show(); + settle(300); + auto* quick = panel.findChild(); + REQUIRE(quick != nullptr); + auto* root = quick->rootObject(); + REQUIRE(root != nullptr); + QQuickItem* cancel = nullptr; + for (auto* item : root->findChildren()) + if (QString::fromLatin1(item->metaObject()->className()).startsWith("PxButton") && item->property("text").toString() == "Cancel") { + cancel = item; + break; + } + REQUIRE(cancel != nullptr); + REQUIRE(QMetaObject::invokeMethod(cancel, "clicked", Qt::DirectConnection)); + settle(20); + CHECK(quick->rootObject() == nullptr); + CHECK_FALSE(panel.isVisible()); + CHECK(warnings.isEmpty()); +} diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp new file mode 100644 index 0000000..45cee28 --- /dev/null +++ b/tests/ui/test_sidebar_model.cpp @@ -0,0 +1,33 @@ +#include +#include +#include "ui/models/sidebar_model.h" +#include "ui/theme/palette.h" +TEST_CASE("sidebar entry updates refresh provider icons without resetting") { + using px::core::view::SidebarEntry; + px::ui::SidebarModel model; + model.set_entries({{SidebarEntry::Kind::Provider,"Steam",{"a","b"},"steam"}}); + QSignalSpy changed(&model,&QAbstractItemModel::dataChanged); + QSignalSpy reset(&model,&QAbstractItemModel::modelReset); + model.update_entries({{SidebarEntry::Kind::Provider,"Local",{"a","b"},"local"}}); + CHECK(model.data(model.index(0),px::ui::SidebarModel::IconRole).toString() == "folder"); + CHECK(model.data(model.index(0),px::ui::SidebarModel::IconColorRole).toString() == px::ui::paletteValue(u"TEXT_DIM")); + CHECK(model.data(model.index(0),px::ui::SidebarModel::CountRole).toInt() == 2); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains(px::ui::SidebarModel::IconRole)); + CHECK(model.get_entry(0)->provider_id == "local"); +} +TEST_CASE("sidebar models status sections and resets when its entry shape changes") { + using px::core::view::SidebarEntry; + using px::ui::SidebarModel; + SidebarModel model; + model.set_entries({{SidebarEntry::Kind::All,"All",{"a"},{}}, + {SidebarEntry::Kind::WithErrors,"Errors",{"a"},{}}}); + CHECK(model.data(model.index(0),SidebarModel::SectionRole).toString() == "Status"); + CHECK(model.data(model.index(1),SidebarModel::BadgeFgRole).toString() == px::ui::paletteValue(u"DANGER")); + CHECK_FALSE(model.get_entry(-1).has_value()); + QSignalSpy reset(&model,&QAbstractItemModel::modelReset); + model.update_entries({}); + CHECK(reset.size() == 1); + CHECK(model.rowCount() == 0); +} diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp new file mode 100644 index 0000000..813835a --- /dev/null +++ b/tests/ui/test_startup_impact_data.cpp @@ -0,0 +1,98 @@ +#include +#include +#include "ui/panels/startup_impact_data.h" +using namespace px::ui; +using namespace px::core::startup_impact; +namespace { +StartupImpactReport report() { + StartupImpactReport result; + result.mods = { + {"Small", QStringLiteral("a.small"), 1, {{"LoadingProgress.StartupImpact.ApplyPatches", 1}}}, + {"Big", QStringLiteral("b.big"), 4, {{"LoadingProgress.StartupImpact.ModContentPackReloadContentInt.Textures", 3}, + {"LoadingProgress.StartupImpact.ApplyPatches", 1}}, {}, 0.5}, + {QString(BASE_GAME_PID), QString(BASE_GAME_PID), 10, + {{"LoadingProgress.StartupImpact.GlobalTextureAtlasManagerBakeStaticAtlases", 10}}}}; + return result; +} +QVariantMap build(QStringList active = {}, std::optional selected = {}, double estimate = 0) { + return build_startup_impact(report(), active, selected, estimate); +} +} +TEST_CASE("Startup impact excludes base and ranks mods") { + const auto rows = build().value("mods").toList(); + REQUIRE(rows.size() == 2); + CHECK(rows[0].toMap().value("name").toString() == "Big"); + CHECK(rows[1].toMap().value("name").toString() == "Small"); + CHECK(rows[0].toMap().value("rank").toInt() == 1); + CHECK(rows[1].toMap().value("rank").toInt() == 2); + const auto sum = [](const QVariantMap& row) { + double total = 0; + for (const auto& segment : row.value("segments").toList()) total += segment.toMap().value("fraction").toDouble(); + return total; + }; + CHECK(sum(rows[0].toMap()) == Catch::Approx(1)); + CHECK(sum(rows[1].toMap()) == Catch::Approx(0.25)); + CHECK(rows[0].toMap().value("dominant").toString() == "Textures & audio"); + CHECK(rows[0].toMap().value("off_thread").toString() == "500 ms"); +} +TEST_CASE("Startup impact normalizes selected and active package IDs") { + const auto data = build({"B.Big_steam"}, QStringLiteral("A.SMALL")); + const auto rows = data.value("mods").toList(); + CHECK(rows[0].toMap().value("active").toBool()); + CHECK_FALSE(rows[0].toMap().value("selected").toBool()); + CHECK_FALSE(rows[1].toMap().value("active").toBool()); + CHECK(rows[1].toMap().value("selected").toBool()); + CHECK(data.value("active_count").toInt() == 1); + CHECK(data.value("selected_key").toString() == "a.small"); +} +TEST_CASE("Startup impact summary and estimates") { + const auto data = build({}, {}, 12); + CHECK(data.value("estimated").toString().isEmpty()); + CHECK(build({"a.small"}, {}, 12).value("estimated").toString() == "12.00 s"); + CHECK(data.value("last_launch").toString() == "15.00 s"); + const auto summary = data.value("summary").toList(); + REQUIRE(summary.size() == 3); + double total = 0; + for (qsizetype i = 0; i < summary.size(); ++i) { + CHECK(summary[i].toMap().value("parallel").toBool() == (i == 2)); + total += summary[i].toMap().value("fraction").toDouble(); + } + CHECK(total == Catch::Approx(1)); + CHECK(summary[0].toMap().value("fraction").toDouble() == Catch::Approx(10.0 / 15.5)); +} +TEST_CASE("Startup impact phases toggle base contribution") { + const auto data = build({}, QStringLiteral("b.big")); + const auto phases = data.value("phases_with_base").toList(); + REQUIRE(phases.size() == 2); + const auto content = phases[0].toMap(); + CHECK(content.value("label").toString() == "Textures & audio"); + CHECK(phases[1].toMap().value("label").toString() == "Patches"); + CHECK(content.value("value").toString() == "13.00 s"); + CHECK(content.value("share").toString() == "87%"); + const auto contributors = content.value("mods").toList(); + REQUIRE(contributors.size() == 2); + CHECK(contributors[0].toMap().value("name").toString() == "Base game"); + CHECK(contributors[0].toMap().value("base").toBool()); + CHECK(contributors[1].toMap().value("selected").toBool()); + const auto modsOnly = data.value("phases_mods_only").toList(); + REQUIRE(modsOnly.size() == 2); + CHECK(modsOnly[0].toMap().value("value").toString() == "3.00 s"); + CHECK(modsOnly[1].toMap().value("value").toString() == "2.00 s"); + for (const auto& phase : modsOnly) + for (const auto& mod : phase.toMap().value("mods").toList()) CHECK_FALSE(mod.toMap().value("base").toBool()); +} +TEST_CASE("Startup impact duration formatting") { + CHECK(format_duration(0.0004) == "0 ms"); + CHECK(format_duration(0.25) == "250 ms"); + CHECK(format_duration(59.5) == "59.50 s"); + CHECK(format_duration(60) == "1m 00s"); + CHECK(format_duration(268.7) == "4m 29s"); +} +TEST_CASE("Startup impact tooltip combines metrics sharing labels") { + auto data = report(); + data.mods[0].metrics = {{"ApplyPatches|Foo -> a", 1}, {"ApplyPatches|Foo -> b", 2}}; + const auto rows = build_startup_impact(data, {}, {}, 0).value("mods").toList(); + const auto tooltip = rows[1].toMap().value("phases").toList()[0].toMap().value("tooltip").toString(); + CHECK(tooltip.count('\n') == 0); + CHECK(tooltip.endsWith("3.00 s")); +} diff --git a/tests/ui/test_support.h b/tests/ui/test_support.h new file mode 100644 index 0000000..51f905c --- /dev/null +++ b/tests/ui/test_support.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include + +#include "core/config.h" +#include "core/context.h" + +namespace px::ui_test { + +// CoreContext services read their cache/config files through a ConfigService, so tests +// give every context one rooted in a directory that lives for the whole test run. +inline std::unique_ptr makeCore(px::core::AppConfig config = {}) { + static QTemporaryDir configDir; + static px::core::ConfigService configService(configDir.path()); + return px::core::CoreContext::create(std::move(config), &configService); +} + +} // namespace px::ui_test diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp new file mode 100644 index 0000000..faa63f5 --- /dev/null +++ b/tests/ui/test_toast_and_progress.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/loading.h" +#include "ui/components/progress_dialog.h" +#include "ui/components/toast.h" + +namespace { + +using px::ui::ProgressDialog; +using px::ui::Toast; +using px::ui::ToastLevel; +using px::ui::ToastManager; + +QApplication& application() { + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "test_toast_and_progress"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; +} + +// Runs the event loop for `ms` so animations and timers can fire. +void waitMs(int ms) { + QEventLoop loop; + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); +} + +} // namespace + +TEST_CASE("close button removes a persistent toast") { + application(); + QWidget parent; + ToastManager manager(&parent); + manager.showToast(QStringLiteral("stays"), ToastLevel::Info, 0); + + auto* toast = manager.findChild(); + REQUIRE(toast != nullptr); + auto* close = toast->findChild(QStringLiteral("toastClose")); + REQUIRE(close != nullptr); + + close->click(); + waitMs(300); + + REQUIRE(manager.layout()->count() == 0); +} + +TEST_CASE("timed toast dismisses itself after its duration") { + application(); + QWidget parent; + ToastManager manager(&parent); + manager.info(QStringLiteral("gone soon"), 50); + REQUIRE(manager.layout()->count() == 1); + + waitMs(600); + + REQUIRE(manager.layout()->count() == 0); +} + +TEST_CASE("progress dialog stays visible when a new task starts before it hides") { + application(); + QWidget parent; + px::core::LoadingState loading(&parent); + ProgressDialog dialog(loading, &parent); + + { auto first = loading.task(QStringLiteral("first")); } + { + auto second = loading.task(QStringLiteral("second")); + waitMs(500); + REQUIRE(dialog.isVisible()); + REQUIRE(dialog.windowTitle() == QStringLiteral("second")); + } + + waitMs(800); + REQUIRE_FALSE(dialog.isVisible()); +} diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp new file mode 100644 index 0000000..73fae93 --- /dev/null +++ b/tests/ui/test_unity_rich_text.cpp @@ -0,0 +1,128 @@ +#include + +#include + +#include +#include + +#include "ui/components/unity_rich_text.h" + +namespace { + +struct Case { + const char* input; + const char* expected; +}; + +// Checks every case and reports each failing input, rather than stopping at the first. +void checkCases(std::initializer_list cases) { + for (const Case& c : cases) { + INFO("input: " << c.input); + CHECK(px::ui::unity_rich_text_to_html(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); + } +} + +} // namespace + +TEST_CASE("unity rich text passes plain text through") { + checkCases({{"Hello world", "Hello world"}}); +} + +TEST_CASE("unity rich text maps bold tags") { + checkCases({ + {"bold", "bold"}, + {"bold", "bold"}, + }); +} + +TEST_CASE("unity rich text maps italic tags") { + checkCases({ + {"italic", "italic"}, + {"italic", "italic"}, + }); +} + +TEST_CASE("unity rich text maps underline to a span") { + checkCases({{"underline", "underline"}}); +} + +TEST_CASE("unity rich text maps strikethrough") { + checkCases({{"strike", "strike"}}); +} + +TEST_CASE("unity rich text maps color names and hex values to font color") { + checkCases({ + {"red", "red"}, + {"red", "red"}, + }); +} + +TEST_CASE("unity rich text maps size units to font-size points") { + checkCases({ + {"big", "big"}, + {"big", "big"}, + {"big", "big"}, + {"relative", "relative"}, + {"small", "small"}, + }); +} + +TEST_CASE("unity rich text maps align to a text-align div") { + checkCases({{"center", "
    center
    "}}); +} + +TEST_CASE("unity rich text maps indent to a margin-left div") { + checkCases({{"indented", "
    indented
    "}}); +} + +TEST_CASE("unity rich text maps mark to a background-colored mark") { + checkCases({{"marked", "marked"}}); +} + +TEST_CASE("unity rich text keeps sub and sup") { + checkCases({ + {"sub", "sub"}, + {"sup", "sup"}, + }); +} + +TEST_CASE("unity rich text maps http and https links to anchors") { + checkCases({{"link", "
    link"}}); +} + +TEST_CASE("unity rich text drops links with unsafe urls and keeps their text") { + for (const char* url : { + "javascript:alert(1)", + "steam://rungameid/294100", + "https:example.com", + }) { + INFO("url: " << url); + const QString input = QStringLiteral("link").arg(QString::fromUtf8(url)); + CHECK(px::ui::unity_rich_text_to_html(input) == QStringLiteral("link")); + } +} + +TEST_CASE("unity rich text uses the default size for a non-finite size") { + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::ui::unity_rich_text_to_html(QStringLiteral("large").arg(huge)); + CHECK(html == QStringLiteral("large")); +} + +TEST_CASE("unity rich text uses the default indent for a non-finite indent") { + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::ui::unity_rich_text_to_html(QStringLiteral("wide").arg(huge)); + CHECK(html == QStringLiteral("
    wide
    ")); +} + +TEST_CASE("unity rich text nests formatting tags") { + checkCases({ + {"bold italic", "bold italic"}, + {"green", "green"}, + }); +} + +TEST_CASE("unity rich text strips unknown tags") { + checkCases({{"bar", "bar"}}); +} diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp new file mode 100644 index 0000000..2c008fb --- /dev/null +++ b/tests/ui/test_views.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.h" +#include "core/context.h" +#include "ui/components/svg_provider.h" +#include "ui/context.h" +#include "ui/theme/qml_theme.h" + +#ifdef PX_HAS_STEAM +#include "ui/plugins/steam_workshop/view.h" + +namespace { +// The embedded browser exists only in WebEngine builds; without it there is no "workshopWeb" object. +QObject* web_of(const px::ui::SteamWorkshopViewPanel& view) { + for (auto* quick : view.findChildren()) { + if (auto* root = quick->rootObject()) { + if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) return web; + } + } + return nullptr; +} +} // namespace + +TEST_CASE("steam workshop view preloads without error") { + QWidget host; + auto* engine = px::ui::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::ui::AppContext app(*core); + px::ui::SteamWorkshopViewPanel view(*core, engine, &host, app); + view.preload(); +} + +TEST_CASE("steam workshop view has no web view before initialization") { + QWidget host; + auto* engine = px::ui::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::ui::AppContext app(*core); + px::ui::SteamWorkshopViewPanel view(*core, engine, &host, app); + CHECK(web_of(view) == nullptr); +} +#endif diff --git a/tests/ui/test_workshop_images.cpp b/tests/ui/test_workshop_images.cpp new file mode 100644 index 0000000..e4ccc67 --- /dev/null +++ b/tests/ui/test_workshop_images.cpp @@ -0,0 +1,165 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString kWorkshopDir = QStringLiteral("qrc:/qt/qml/PxModRim"); + +constexpr qint64 kWaitMs = 5000; + + +// Loads thumbs.js the way `import "thumbs.js" as Thumbs` does, minus the `.pragma library` +// header, which only QML's loader understands. +class Thumbs { +public: + Thumbs() { + const QString path = QStringLiteral(":/qt/qml/PxModRim/thumbs.js"); + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QString script = QString::fromUtf8(file.readAll()); + const QString pragma = QStringLiteral(".pragma library"); + if (script.startsWith(pragma)) + script.remove(0, pragma.size()); + const QJSValue result = engine_.evaluate(script, path); + REQUIRE(!result.isError()); + sized_ = engine_.globalObject().property(QStringLiteral("sized")); + REQUIRE(sized_.isCallable()); + } + + QString sized(const QString& url, int width, int height) { + return sized_.call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}).toString(); + } + +private: + QJSEngine engine_; + QJSValue sized_; +}; + +// Spins the event loop until `predicate` holds or kWaitMs elapses. +bool waitUntil(const std::function& predicate) { + QElapsedTimer clock; + clock.start(); + while (!predicate()) { + if (clock.hasExpired(kWaitMs)) + return false; + QEventLoop loop; + QTimer::singleShot(10, &loop, [&loop] { loop.quit(); }); + loop.exec(); + } + return true; +} + +// Runs the teardown when a REQUIRE/CHECK failure unwinds the test body. +struct Cleanup { + std::function run; + ~Cleanup() { run(); } +}; + +} // namespace + +TEST_CASE("thumbnail leaves other URLs unchanged") { + Thumbs thumbs; + const QStringList urls{ + QStringLiteral("https://example.com/image.png"), + QStringLiteral("https://notsteamstatic.com/image.png"), + QStringLiteral("https://steamusercontent.com.example.com/image.png"), + QStringLiteral("https://user@steamstatic.com/image.png"), + QStringLiteral(""), + }; + for (const QString& url : urls) { + INFO(url.toStdString()); + CHECK(thumbs.sized(url, 256, 128) == url); + } +} + +TEST_CASE("thumbnail replaces resize query before fragment") { + Thumbs thumbs; + for (const char* host : {"images.steamusercontent.com", "steamstatic.com", "cdn.steamstatic.com"}) { + const QString hostName = QString::fromLatin1(host); + INFO(host); + const QString url = QStringLiteral("https://%1/image.png?token=abc&imw=720&imh=360&ima=fit&impolicy=Letterbox#preview") + .arg(hostName); + const QString expected = QStringLiteral("https://%1/image.png?token=abc&imw=256&imh=128&ima=fit&impolicy=Letterbox#preview") + .arg(hostName); + const QString resized = thumbs.sized(url, 256, 128); + CHECK(resized == expected); + CHECK(thumbs.sized(resized, 256, 128) == expected); + } +} + +TEST_CASE("hidden thumbnail unloads and can load again") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + QImage image(64, 32, QImage::Format_RGB32); + image.fill(0xFF00FF00); + const QString imagePath = dir.filePath(QStringLiteral("preview.png")); + REQUIRE(image.save(imagePath)); + + const QString sourcePath = dir.filePath(QStringLiteral("Image.qml")); + { + QFile source(sourcePath); + REQUIRE(source.open(QIODevice::WriteOnly | QIODevice::Text)); + const QString text = QStringLiteral( + "import QtQuick\n" + "import \"%1\" as Workshop\n" + "Workshop.CatalogImage { previewUrl: \"%2\" }\n") + .arg(kWorkshopDir, + QUrl::fromLocalFile(imagePath).toString()); + REQUIRE(source.write(text.toUtf8()) >= 0); + } + + QStringList warnings; + auto owner = std::make_unique(); + auto* engine = new QQmlEngine(owner.get()); + QObject::connect(engine, &QQmlEngine::warnings, owner.get(), [&warnings](const QList& errors) { + for (const QQmlError& error : errors) + warnings.append(error.toString()); + }); + + auto* widget = new QQuickWidget(engine, owner.get()); + widget->setResizeMode(QQuickWidget::SizeRootObjectToView); + widget->resize(200, 110); + widget->setSource(QUrl::fromLocalFile(sourcePath)); + owner->show(); + widget->show(); + const Cleanup cleanup{[&] { + delete widget; + delete engine; + }}; + + QQuickItem* root = widget->rootObject(); + REQUIRE(root != nullptr); + + // QQuickImageBase::Status: Null = 0, Ready = 1. + constexpr int kNull = 0; + constexpr int kReady = 1; + REQUIRE(waitUntil([&] { return root->property("status").toInt() == kReady; })); + root->setVisible(false); + REQUIRE(waitUntil([&] { return root->property("status").toInt() == kNull; })); + root->setVisible(true); + widget->resize(300, 150); + REQUIRE(waitUntil([&] { return root->property("status").toInt() == kReady; })); + CHECK(widget->grabFramebuffer().pixelColor(150, 75).name() == QStringLiteral("#00ff00")); + INFO(warnings.join(QLatin1Char('\n')).toStdString()); + CHECK(warnings.isEmpty()); +} diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp new file mode 100644 index 0000000..9f14ec2 --- /dev/null +++ b/tests/ui/test_workshop_installed.cpp @@ -0,0 +1,91 @@ +#include + +#include + +#include + +#include "ui/plugins/workshop/installed.h" + +using px::core::workshop::CatalogMod; +using px::ui::workshop::InstalledList; +using px::ui::workshop::PAGE; + +namespace { +CatalogMod installed_mod(std::string id, std::string title, std::string author = "Someone", + std::vector tags = {}) { + CatalogMod mod; + mod.id = std::move(id); + mod.title = std::move(title); + mod.author.name = std::move(author); + mod.tags = std::move(tags); + return mod; +} + +QStringList ids(const std::vector& mods) { + QStringList result; + for (const auto& mod : mods) result.append(QString::fromStdString(mod.id)); + return result; +} +} // namespace + +TEST_CASE("workshop installed search matches title author id and tags case insensitively") { + InstalledList installed; + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.total() == 0); + REQUIRE(installed.visible().empty()); + REQUIRE(installed.next_page().empty()); + installed.search(""); + installed.replace({installed_mod("11", "Harmony", "Brrainz"), installed_mod("12", "HugsLib", "Someone", {"Library"}), + installed_mod("999", "Other")}); + REQUIRE(installed.loaded()); + REQUIRE(installed.mods().size() == 3); + const std::vector> cases{{"harm", {"11"}}, {"BRRAINZ", {"11"}}, + {"library", {"12"}}, {"999", {"999"}}, {"", {"11", "12", "999"}}, {"nothing", {}}}; + for (const auto& [query, expected] : cases) { + CAPTURE(query.toStdString()); + installed.search(query); + REQUIRE(ids(installed.visible()) == expected); + REQUIRE(installed.total() == expected.size()); + } +} + +TEST_CASE("workshop installed paging reveals one page and search restarts it") { + InstalledList installed; + std::vector mods; + for (int i = 0; i < PAGE * 2 + 5; ++i) mods.push_back(installed_mod(std::to_string(i), "Mod")); + installed.replace(mods); + REQUIRE(installed.visible().size() == PAGE); + REQUIRE(installed.has_more()); + const auto second = installed.next_page(); + REQUIRE(second.size() == PAGE); + REQUIRE(second.front().id == std::to_string(PAGE)); + REQUIRE(installed.next_page().size() == 5); + REQUIRE_FALSE(installed.has_more()); + REQUIRE(installed.next_page().empty()); + installed.search(""); + REQUIRE(installed.visible().size() == PAGE); + REQUIRE(installed.total() == PAGE * 2 + 5); + installed.next_page(); + installed.reset_paging(); + REQUIRE(installed.visible().size() == PAGE); + REQUIRE(installed.has_more()); +} + +TEST_CASE("workshop replacing installed mods preserves query and current paging") { + InstalledList installed; + installed.search("mod"); + std::vector mods; + for (int i = 0; i < PAGE * 3; ++i) mods.push_back(installed_mod(std::to_string(i), "Mod")); + installed.replace(mods); + installed.next_page(); + installed.replace(mods); + REQUIRE(installed.visible().size() == PAGE * 2); + installed.replace({installed_mod("match", "Mod"), installed_mod("other", "Other")}); + REQUIRE(ids(installed.visible()) == QStringList{"match"}); + REQUIRE(installed.total() == 1); + REQUIRE_FALSE(installed.has_more()); + REQUIRE(installed.next_page().empty()); + installed.replace({}); + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.visible().empty()); +} diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp new file mode 100644 index 0000000..a96753e --- /dev/null +++ b/tests/ui/test_workshop_models.cpp @@ -0,0 +1,228 @@ +#include + +#include +#include + +#include + +#include "ui/panels/mod_info_data.h" +#include "ui/plugins/workshop/models.h" + +using namespace px::core::workshop; +using namespace px::ui::workshop; + +namespace { +CatalogMod mod(std::string id = "1") { + CatalogMod value; + value.id = std::move(id); + value.title = "Mod " + value.id; + value.author = Author{"1", "Author", std::nullopt}; + value.description = "[b]Description[/b]"; + value.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + value.id; + value.tags = {"Utility"}; + value.supported_versions = {"1.5"}; + value.updated_at = 100; + value.file_size = "1048576"; + return value; +} + +InstallState missing(const CatalogMod&) { return InstallState::missing; } +InstallState outdated(const CatalogMod&) { return InstallState::outdated; } + +int role(const CatalogListModel& model, const QByteArray& name) { + const auto names = model.roleNames(); + for (auto it = names.cbegin(); it != names.cend(); ++it) if (it.value() == name) return it.key(); + FAIL("Missing role " << name.constData()); + return -1; +} +} // namespace + +TEST_CASE("workshop model rejects unsafe URLs and preserves web URLs") { + for (const auto* url : {"javascript:alert(1)", "file:///tmp/test", "data:text/plain,hi", "//example.com", + "https:///path", "https://example.com/\nsecret", "https://[bad", "https://example.com/a b"}) { + CAPTURE(url); + REQUIRE(safe_url(QString::fromUtf8(url)).isEmpty()); + } + for (const auto* url : {"https://example.com/path", "http://example.com", "HTTPS://example.com"}) { + REQUIRE(safe_url(QString::fromUtf8(url)) == QString::fromUtf8(url)); + } +} + +TEST_CASE("workshop catalog roles paging append deduplication and reset") { + QObject owner; + CatalogListModel model(&owner); + const QList rows{item_row(mod("1"), outdated, "1.6"), item_row(mod("2"), outdated, "1.6"), + item_row(mod("3"), outdated, "1.6")}; + int notifications = 0; + QObject::connect(&model, &CatalogListModel::changed, [&] { ++notifications; }); + model.set_page({rows[0], rows[1]}, 10, QStringLiteral("page2")); + REQUIRE(model.hasMore()); + REQUIRE(model.cursor() == std::optional(QStringLiteral("page2"))); + REQUIRE(model.count() == 2); + REQUIRE(model.total() == 10); + QSet names; + for (const auto& name : model.roleNames()) names.insert(QString::fromUtf8(name)); + REQUIRE(names == QSet(rows[0].keyBegin(), rows[0].keyEnd())); + REQUIRE(role(model, "itemId") == Qt::UserRole + 1); + REQUIRE(role(model, "compatLabel") == Qt::UserRole + 25); + const int id_role = role(model, "itemId"); + REQUIRE(model.data(model.index(0), id_role).toString() == "1"); + model.set_page({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); + REQUIRE(model.count() == 3); + REQUIRE_FALSE(model.hasMore()); + REQUIRE(model.data(model.index(2), id_role).toString() == "3"); + REQUIRE_FALSE(model.data({}, id_role).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::DisplayRole).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::UserRole + 26).isValid()); + REQUIRE(model.rowCount(model.index(0)) == 0); + REQUIRE(model.rowCount() == 3); + model.set_page({}, 0, std::nullopt); + REQUIRE(model.count() == 0); + REQUIRE(model.total() == 0); + REQUIRE(notifications == 3); +} + +TEST_CASE("workshop refreshing a page changes only changed rows without resetting") { + CatalogListModel model; + auto first = mod("1"), second = mod("2"), third = mod("3"); + auto show = [&] { model.set_page({item_row(first, missing, "1.5"), item_row(second, missing, "1.5"), + item_row(third, missing, "1.5")}, 3, std::nullopt); }; + show(); + int resets = 0; + QList changed; + QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); + QObject::connect(&model, &QAbstractItemModel::dataChanged, + [&](const QModelIndex& first_index, const QModelIndex&) { changed.append(first_index.row()); }); + first.subscriptions = 500; + second.title = "Renamed"; + show(); + REQUIRE(resets == 0); + REQUIRE(changed == QList{1}); + REQUIRE(model.data(model.index(1), role(model, "title")).toString() == "Renamed"); + auto reversed = model.rows(); + std::reverse(reversed.begin(), reversed.end()); + model.set_page(reversed, 3, std::nullopt); + REQUIRE(resets == 1); + model.refresh_rows([](const QVariantMap& row) { auto copy = row; copy["queued"] = true; return copy; }); + REQUIRE(model.rows().front().value("queued").toBool()); + REQUIRE(changed.back() == 0); + model.set_page({}, 0, std::nullopt); + changed.clear(); + model.refresh_rows([](const QVariantMap& row) { return row; }); + REQUIRE(changed.isEmpty()); +} + +TEST_CASE("workshop mod rows preserve state sizes versions descriptions and compatibility") { + auto value = mod(); + auto row = item_row(value, outdated, "1.6"); + REQUIRE(row.value("actionLabel").toString() == "Update"); + REQUIRE(row.value("stateLabel").toString() == "Update available"); + REQUIRE(row.value("incompatible").toBool()); + REQUIRE(row.value("fileSize").toString() == "1.0 MB"); + REQUIRE(row.value("description").toString().isEmpty()); + REQUIRE(item_row(value, missing, "1.5", true).value("description").toString() + == px::ui::description_to_html(QString::fromStdString(value.description), "bbcode")); + value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; + value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; + row = item_row(value, missing, "1.5"); + REQUIRE(row.value("versions").toString() == "1.2, 1.2.1, 1.5, 1.10"); + REQUIRE(row.value("tags").toString() == QStringLiteral("Mod · Utility")); + struct Compatibility { std::vector versions; QString game; bool incompatible; QString label; }; + const std::vector compatibility_cases{ + {{"1.5"}, "1.5", false, "Works with 1.5"}, + {{"1.4"}, "1.5", true, "Not for 1.5"}, + {{}, "1.5", false, "Version not stated"}, {{"1.5"}, "", false, ""}}; + for (const auto& scenario : compatibility_cases) { + value.supported_versions = scenario.versions; + value.incompatible = scenario.incompatible; + REQUIRE(item_row(value, missing, scenario.game).value("compatLabel").toString() == scenario.label); + } + value.author = {}; + value.file_size.reset(); + row = item_row(value, missing, ""); + REQUIRE(row.value("author").toString() == "Unknown author"); + REQUIRE(row.value("fileSize").toString() == "Unknown"); +} + +TEST_CASE("workshop vote approval uses counts and formats thousands") { + struct Case { std::optional votes; QString approval, up, down; }; + const std::vector cases{{std::nullopt, "Unrated", "0", "0"}, {Votes{0, 0, 100}, "Unrated", "0", "0"}, + {Votes{0, 1, {}}, "0%", "0", "1"}, {Votes{1, 0, {}}, "100%", "1", "0"}, + {Votes{1, 2, {}}, "33%", "1", "2"}, {Votes{2, 1, {}}, "67%", "2", "1"}, + {Votes{1000, 3000, {}}, "25%", "1,000", "3,000"}}; + for (const auto& scenario : cases) { + auto value = mod(); + value.votes = scenario.votes; + const auto row = item_row(value, [](const CatalogMod&) { return InstallState::installed; }, "1.5"); + REQUIRE(row.value("votes").toString() == scenario.approval); + REQUIRE(row.value("votesUp").toString() == scenario.up); + REQUIRE(row.value("votesDown").toString() == scenario.down); + REQUIRE(row.value("actionLabel").toString() == "Installed"); + } +} + +TEST_CASE("workshop summaries strip bbcode media and truncate at a word boundary") { + REQUIRE(summarize("[h1]Title[/h1] [img]https://x.test/a.png[/img] Adds [url=https://x.test]a thing[/url]") + == "Title Adds a thing"); + REQUIRE(summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept"); + const QString summary = summarize(QStringLiteral("word ").repeated(100)); + REQUIRE(summary.endsWith(QStringLiteral("word…"))); + REQUIRE(summary.size() <= 141); +} + +TEST_CASE("workshop collection rows select previews filter collage and display server size") { + CatalogCollection value; + value.id = "picked:x"; + value.source = CollectionSource::picked; + value.title = "C"; + value.author.name = "Curator"; + value.description = "[b]Description[/b]"; + value.previews = {{PreviewType::video, "https://youtube.test/v"}, {PreviewType::image, "https://images.test/own.png"}}; + value.member_ids = {"10"}; + value.member_count = 1; + value.member_previews = {"https://images.test/m.png", "javascript:alert(1)"}; + const auto never_install = [](const CatalogMod&) { FAIL("Collections must not request install state"); return InstallState::missing; }; + auto row = item_row(value, never_install, "1.6", true); + REQUIRE(row.value("previewUrl").toString() == "https://images.test/own.png"); + REQUIRE(row.value("collage").toStringList() == QStringList{"https://images.test/m.png"}); + REQUIRE(row.value("votes").toString() == "Unrated"); + REQUIRE(row.value("votesUp").toString() == "0"); + REQUIRE(row.value("votesDown").toString() == "0"); + REQUIRE_FALSE(row.value("active").toBool()); + REQUIRE(row.value("fileSize").toString() == "Unknown"); + REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); + REQUIRE(row.value("actionLabel").toString() == "Download all"); + REQUIRE(row.value("memberCount").toInt() == 1); + REQUIRE(row.value("description").toString() == px::ui::description_to_html("[b]Description[/b]", "text")); + value.total_size = "3145728"; + value.preview_url = "https://images.test/explicit.png"; + value.source = CollectionSource::steam; + row = item_row(value, never_install, "1.6"); + REQUIRE(row.value("fileSize").toString() == "3.0 MB"); + REQUIRE(row.value("previewUrl").toString() == "https://images.test/explicit.png"); + REQUIRE(row.value("sourceLabel").toString() == "Steam collection"); +} + +TEST_CASE("workshop collection version declarations override conflicting member summary") { + CatalogCollection value; + value.no_common_version = true; + auto row = item_row(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "No common version"); + REQUIRE(row.value("incompatible").toBool()); + value.no_common_version = false; + row = item_row(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "Version not stated"); + REQUIRE_FALSE(row.value("incompatible").toBool()); + value.no_common_version = true; + const std::vector, std::vector>> cases{ + {{}, {"Mod", "1.6"}}, {{"1.5"}, {"Mod", "1.6"}}, {{"1.6"}, {"Mod"}}}; + for (const auto& [versions, tags] : cases) { + value.supported_versions = versions; + value.tags = tags; + row = item_row(value, missing, "1.6"); + REQUIRE_FALSE(row.value("incompatible").toBool()); + REQUIRE(row.value("compatLabel").toString() == "Works with 1.6"); + REQUIRE(row.value("versions").toString().contains("1.6")); + REQUIRE(row.value("tags").toString() == "Mod"); + } +} diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp new file mode 100644 index 0000000..8279519 --- /dev/null +++ b/tests/ui/test_workshop_view.cpp @@ -0,0 +1,417 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "test_support.h" +#include "../core/workshop/support.h" +#include "core/context.h" +#include "core/downloads/downloader.h" +#include "core/mod_service.h" +#include "ui/components/svg_provider.h" +#include "ui/context.h" +#include "ui/navigation.h" +#include "ui/plugins/workshop/plugin.h" +#include "ui/plugins/workshop/view.h" + +namespace { +using namespace px::core; +using namespace px::core::workshop; +using namespace px::ui; +using namespace px::ui::workshop; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::run; +using workshop_test::wire; + +struct Batch { + std::vector items; + std::vector unavailable_ids; +}; + +class DownloaderFixture : public Downloader { +public: + explicit DownloaderFixture(DownloadManager& manager) + : Downloader(QStringLiteral("test_steam"), QStringLiteral("Steam"), manager) {} + bool is_downloading() const override { return busy; } + QSet active_ids() const override { return active; } + bool accepts(const QString&) const override { return true; } + std::optional updatable_id(const ListedMod& mod) const override { return mod.published_file_id(); } + std::optional last_synced(const ListedMod&) const override { return 50.; } + QCoro::Task> download_mods(QStringList ids) override { + calls.append(ids); + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + void cancel() override {} + bool busy = false; + QSet active; + QList calls; +}; + +struct PanelFixture { + QWidget owner; + QTemporaryDir dir; + std::unique_ptr core = px::ui_test::makeCore(AppConfig{}); + AppContext app{*core}; + DownloaderFixture downloader{core->downloads()}; + WorkshopCatalog catalog{nullptr, core->downloads(), [this] { return core->allMods().values(); }}; + CatalogMod mod; + CatalogMod dependency; + CatalogCollection collection; + bool fail = false; + bool complete = false; + std::function on_request; + workshop_test::FakeCatalog server{[this](const HttpRequest& request) { return answer(request); }}; + QQmlEngine* engine = nullptr; + std::unique_ptr view; + + PanelFixture() { + REQUIRE(QApplication::instance()); + REQUIRE(dir.isValid()); + core->downloads().register_downloader(downloader); + mod.id = "1"; mod.title = "Untrusted title"; + mod.author.name = "Author"; mod.description = "[b]Safe description[/b]"; + mod.tags = {"Utility"}; mod.supported_versions = {"1.5"}; + mod.updated_at = 100; mod.file_size = "1024"; mod.dependencies = {"2"}; + mod.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=1"; + dependency = mod; dependency.id = "2"; dependency.title = "Dependency"; dependency.dependencies.clear(); + collection.id = "picked:test"; collection.source = CollectionSource::picked; + collection.title = "Test collection"; collection.author.name = "Curator"; + collection.description = "Plain text"; + collection.supported_versions = {"1.6"}; collection.member_ids = {"1", "missing"}; collection.member_count = 2; + catalog.set_base_url(server.url()); + engine = createQmlEngine(&owner); + view = std::make_unique(*core, engine, &owner, app, catalog); + view->resize(1100, 800); + run(view->load()); + } + ~PanelFixture() { + view.reset(); + run(catalog.shutdown()); + } + HttpResponse answer(const HttpRequest& request) { + if (on_request) on_request(request); + if (fail) return {503, R"({"error":{"message":"Catalog unavailable"}})"}; + const auto path = request.path(); + if (path == QStringLiteral("/catalog/discover")) return {200, wire(Discover{{{mod}, 1, {}}, {{collection}, 1, {}}})}; + if (path == QStringLiteral("/catalog/mods")) { + const bool second = request.query().hasQueryItem(QStringLiteral("cursor")); + return {200, wire(CatalogPage{{second ? dependency : mod}, 2, second ? std::nullopt : std::optional("second")})}; + } + if (path == QStringLiteral("/catalog/collections")) return {200, wire(CatalogPage{{collection}, 1, {}})}; + if (path == QStringLiteral("/catalog/mods/1")) return {200, wire(mod)}; + if (path == QStringLiteral("/catalog/mods/2")) return {200, wire(dependency)}; + if (path == QStringLiteral("/catalog/collections/picked/test")) { + auto item = collection; + if (complete) { item.member_ids = {"1"}; item.member_count = 1; } + return {200, wire(CollectionDetail{item, {mod}, complete ? std::vector{} : std::vector{"missing"}, complete})}; + } + if (path == QStringLiteral("/catalog/mods/batch")) return {200, wire(Batch{{mod}, {}})}; + if (path == QStringLiteral("/catalog/resolve")) { + const bool collection_request = request.body.find("picked:test") != std::string::npos; + const bool incomplete = collection_request && !complete; + return {200, wire(ResolvedDownload{{"1"}, {{"1", mod}, {"2", dependency}}, {"2", "1"}, + incomplete ? std::vector{"missing"} : std::vector{}, + incomplete ? std::vector{"picked:test"} : std::vector{}, !incomplete})}; + } + return {404, R"({"error":{"message":"Not found"}})"}; + } + QString install(const QString& id, const QString& provider = QStringLiteral("steam")) { + auto installed = std::make_shared(); + REQUIRE(installed->set_mod_path(dir.filePath(provider + QLatin1Char('/') + id)).has_value()); + installed->provider_id = provider; + installed->name = id; + auto mods = core->allMods(); + const auto uuid = installed->uuid(); + mods.insert(uuid, installed); + core->load(std::move(mods), core->activeUuids()); + catalog.invalidate_installed(); + return uuid; + } + qsizetype requests(const QString& path) const { + return std::ranges::count_if(server.requests, [&path](const auto& request) { return request.path() == path; }); + } +}; +} + +TEST_CASE("workshop view loads discovery and paginates filtered catalog results") { + PanelFixture f; + CHECK(f.view->findChild()->status() == QQuickWidget::Ready); + CHECK(f.view->mods_model->count() == 1); + CHECK(f.view->collections_model->count() == 1); + run(f.view->select_tab(QStringLiteral("Mods"))); + run(f.view->filter_items({}, QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + run(f.view->load(true)); + CHECK(f.view->mods_model->count() == 2); + CHECK_FALSE(f.view->mods_model->hasMore()); + CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("cursor")) == QStringLiteral("second")); + CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("version")) == QStringLiteral("1.6")); + f.fail = true; + run(f.view->filter_items(QStringLiteral("uncached"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->error() == QStringLiteral("Catalog unavailable")); + f.fail = false; + run(f.view->retry_load()); + CHECK(f.view->error().isEmpty()); + CHECK(f.view->mods_model->count() == 1); +} + +TEST_CASE("workshop detail escapes descriptions and lists dependencies") { + PanelFixture f; + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->hasDetail()); + CHECK(f.view->members_model->count() == 1); + CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("Safe description"))); + run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("<b>Plain text</b>"))); + CHECK(f.view->detail().value(QStringLiteral("warning")).toString().contains(QStringLiteral("missing"))); +} + +TEST_CASE("incomplete workshop downloads require explicit partial approval and never activate") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + run(f.view->download_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + CHECK(f.view->pendingPlan()); + CHECK(f.view->canDownloadAvailable()); + CHECK(f.downloader.calls.isEmpty()); + f.view->downloadAvailable(); + CHECK_FALSE(f.view->pendingPlan()); + REQUIRE(f.downloader.calls.size() == 1); + CHECK(f.downloader.calls.front() == QStringList{QStringLiteral("2"), QStringLiteral("1")}); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + CHECK(f.view->notice().contains(QStringLiteral("Mods are not activated"))); +} + +TEST_CASE("workshop partial plan cancellation clears notice without queuing") { + PanelFixture f; + run(f.view->download_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + f.view->dismissPlan(); + CHECK_FALSE(f.view->pendingPlan()); + CHECK(f.view->notice().isEmpty()); + CHECK(f.downloader.calls.isEmpty()); +} + +TEST_CASE("workshop collection detail falls back to member preview collage") { + PanelFixture f; + f.mod.preview_url = "https://example.com/preview.png"; + run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("collage")).toStringList() == QStringList{QStringLiteral("https://example.com/preview.png")}); +} + +TEST_CASE("workshop collection listing detects partial and fully installed members") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->select_tab(QStringLiteral("Collections"))); + auto row = f.view->collections_model->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Complete download")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); + f.install(QStringLiteral("missing")); + row = f.view->collections_model->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Installed")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); + f.downloader.active = {QStringLiteral("1")}; + f.core->downloads().busy_changed.notify(true); + row = f.view->collections_model->rows().front(); + CHECK(row.value(QStringLiteral("queued")).toBool()); + CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Downloading")); +} + +TEST_CASE("disabled workshop catalog does not issue requests") { + PanelFixture f; + f.catalog.set_base_url({}); + const auto before = f.server.requests.size(); + run(f.view->load()); + CHECK_FALSE(f.view->configured()); + CHECK_FALSE(f.view->busy()); + CHECK(f.server.requests.size() == before); +} + +TEST_CASE("workshop settings apply catalog URL only on save") { + PanelFixture f; + CatalogSettingsSection section(f.catalog, &f.owner); + REQUIRE(section.set(QStringLiteral("url"), QStringLiteral(" https://example.com/catalog/// "))); + CHECK(f.catalog.settings().url == f.server.url().toStdString()); + f.view->teardown(); + REQUIRE(section.apply().has_value()); + CHECK(f.catalog.settings().url == "https://example.com/catalog"); +} + +TEST_CASE("installed workshop search is local and cached across revisits") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->select_tab(QStringLiteral("Installed"))); + REQUIRE(f.view->mods_model->count() == 1); + const auto fetched = f.requests(QStringLiteral("/catalog/mods/batch")); + run(f.view->filter_items(QStringLiteral("absent"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->mods_model->count() == 0); + run(f.view->filter_items(QStringLiteral("Author"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->mods_model->count() == 1); + run(f.view->select_tab(QStringLiteral("Mods"))); + run(f.view->select_tab(QStringLiteral("Installed"))); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == fetched); +} + +TEST_CASE("workshop activation toggles one copy and removes every active duplicate") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + run(f.view->toggle_activation(QStringLiteral("1"))); + REQUIRE(f.core->activeUuids().size() == 1); + CHECK((f.core->activeUuids().contains(first) || f.core->activeUuids().contains(second))); + f.core->setActive({first, second}); + run(f.view->toggle_activation(QStringLiteral("1"))); + CHECK(f.core->activeUuids().isEmpty()); + run(f.view->toggle_activation(QStringLiteral("absent"))); + CHECK(f.core->activeUuids().isEmpty()); +} + +TEST_CASE("external workshop activation refreshes listing detail and dependencies") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = f.install(QStringLiteral("2")); + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + f.core->setActive({first, second}); + CHECK(f.view->detail().value(QStringLiteral("active")).toBool()); + CHECK(f.view->mods_model->rows().front().value(QStringLiteral("active")).toBool()); + CHECK(f.view->members_model->rows().front().value(QStringLiteral("active")).toBool()); +} + +TEST_CASE("workshop tags accumulate excluding supported version tags") { + PanelFixture f; + f.mod.tags = {"1.5", "Utility", "Animals"}; + run(f.view->select_tab(QStringLiteral("Mods"))); + f.dependency.tags = {"Library"}; + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->tagOptions() == QStringList{QStringLiteral("All tags"), QStringLiteral("Animals"), QStringLiteral("Library"), QStringLiteral("Utility")}); +} + +TEST_CASE("workshop detail back walks history before restoring listing filters") { + PanelFixture f; + run(f.view->select_tab(QStringLiteral("Mods"))); + run(f.view->filter_items(QStringLiteral("query"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->backLabel() == QStringLiteral("Back to Test collection")); + run(f.view->go_back()); + CHECK(f.view->detail().value(QStringLiteral("itemId")) == QStringLiteral("picked:test")); + run(f.view->go_back()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK(f.view->searchQuery() == QStringLiteral("query")); +} + +TEST_CASE("workshop routes tabs items and settings through app navigator") { + PanelFixture f; + QString route; + f.app.setNavigator([&route](const QString& value) { route = value; }); + f.view->openItem(QStringLiteral("picked:test"), QStringLiteral("collection")); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->path == QStringList{QStringLiteral("collection"), QStringLiteral("picked:test")}); + run(f.view->follow_route(parseRoute(route)->path)); + CHECK(f.view->hasDetail()); + run(f.view->follow_route({QStringLiteral("mods")})); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK_FALSE(f.view->hasDetail()); + f.view->openSettings(); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->view_id == QStringLiteral("settings")); +} + +TEST_CASE("workshop starred collections stay in favourites until unstarred") { + PanelFixture f; + f.view->toggleFavourite(QStringLiteral("picked:test")); + run(f.view->select_tab(QStringLiteral("Favourites"))); + REQUIRE(f.view->collections_model->count() == 1); + CHECK(f.view->collections_model->rows().front().value(QStringLiteral("favourite")).toBool()); + run(f.view->filter_items(QStringLiteral("not present"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->collections_model->count() == 0); + run(f.view->filter_items({}, {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + f.view->toggleFavourite(QStringLiteral("picked:test")); + CHECK(f.view->collections_model->count() == 0); +} + +TEST_CASE("fully installed workshop pack activates all or only its members and dependencies") { + PanelFixture f; + f.complete = true; + const auto first = f.install(QStringLiteral("1")); + const auto duplicate = f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + const auto dependency = f.install(QStringLiteral("2")); + const auto other = f.install(QStringLiteral("3")); + f.core->setActive({duplicate, other}); + run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("state")) == QStringLiteral("installed")); + run(f.view->toggle_collection()); + CHECK(f.core->activeUuids().contains(duplicate)); + CHECK(f.core->activeUuids().contains(dependency)); + CHECK(f.core->activeUuids().contains(other)); + CHECK_FALSE(f.core->activeUuids().contains(first)); + run(f.view->toggle_collection(true)); + CHECK_FALSE(f.core->activeUuids().contains(other)); + f.core->setActive({first, duplicate, dependency}); + run(f.view->toggle_collection()); + CHECK(f.core->activeUuids().isEmpty()); +} + +TEST_CASE("partly installed and incomplete workshop collections cannot activate") { + PanelFixture f; + f.install(QStringLiteral("1")); + for (bool complete : {false, true}) { + f.complete = complete; + f.catalog.set_base_url(f.server.url() + (complete ? QStringLiteral("/") : QString{})); + run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->toggle_collection(true)); + CHECK(f.core->activeUuids().isEmpty()); + } +} + +TEST_CASE("workshop installed events preserve open details until back reloads the list") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->select_tab(QStringLiteral("Installed"))); + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + const auto before = f.requests(QStringLiteral("/catalog/mods/batch")); + f.install(QStringLiteral("2")); + CHECK(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before); + run(f.view->go_back()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before + 1); +} + +TEST_CASE("workshop teardown disconnects events and blocks late fetch and plan completion") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + f.on_request = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/mods")) f.view->teardown(); + }; + run(f.view->select_tab(QStringLiteral("Mods"))); + f.view->teardown(); + run(f.view->toggle_activation(QStringLiteral("1"))); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + f.catalog.queue_changed.notify({}); + f.catalog.installed_changed.notify({}); + f.core->downloads().busy_changed.notify(false); + CHECK(f.view->mods_model->count() == 1); +} + +TEST_CASE("workshop discards download plan completing after view destruction") { + PanelFixture f; + f.on_request = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/resolve")) f.view.reset(); + }; + run(f.view->download_item(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK_FALSE(f.view); + CHECK(f.downloader.calls.isEmpty()); +} + +TEST_CASE("workshop detail metadata retains all tags and numerically sorted versions") { + PanelFixture f; + f.mod.tags = {"1.6", "Utility", "Animals"}; + f.mod.supported_versions = {"1.10", "1.6", "1.5"}; + run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->detail().value(QStringLiteral("versions")) == QStringLiteral("1.5, 1.6, 1.10")); + CHECK(f.view->detail().value(QStringLiteral("tags")) == QStringLiteral("Utility · Animals")); +} From 4b8b82729dfcff522e4f7dcfac7c79b32e85ef38 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 01:57:53 +0300 Subject: [PATCH 04/50] fix(core,ui): drop organizer context seam, share one QApplication across UI tests --- src/core/organizer/service.cpp | 24 +++--------------------- src/core/organizer/service.h | 5 ----- tests/ui/test_dialog_chrome.cpp | 10 ++-------- tests/ui/test_organizer_dialogs.cpp | 14 ++------------ tests/ui/test_settings_panel.cpp | 3 +++ tests/ui/test_workshop_view.cpp | 3 +++ 6 files changed, 13 insertions(+), 46 deletions(-) diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index 6f26ebf..7fb088b 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -8,6 +8,7 @@ #include #include +#include "core/context.h" #include "core/organizer/defaults.h" namespace px::core::organizer { @@ -16,18 +17,6 @@ namespace { const QHash kEmptyMods; const QStringList kEmptyActive; -struct ContextSeam { - std::function&(const CoreContext&)> all_mods = - [](const CoreContext&) -> const QHash& { return kEmptyMods; }; - std::function active_uuids = - [](const CoreContext&) -> const QStringList& { return kEmptyActive; }; -}; - -ContextSeam& global_context_seam() { - static ContextSeam seam; - return seam; -} - Result clean_name(const QString& name, const QString& what) { const QString cleaned = name.trimmed(); if (cleaned.isEmpty()) { @@ -38,13 +27,6 @@ Result clean_name(const QString& name, const QString& what) { } // namespace -void register_core_context_seam( - std::function&(const CoreContext&)> all_mods, - std::function active_uuids) { - global_context_seam().all_mods = std::move(all_mods); - global_context_seam().active_uuids = std::move(active_uuids); -} - OrganizerService::OrganizerService(CoreContext& ctx, OrganizerDb& db) : Plugin(QStringLiteral("organizer")), ctx_(&ctx), db_(db) {} @@ -59,7 +41,7 @@ const QHash& OrganizerService::all_mods() const { return all_mods_fn_(); } if (ctx_) { - return global_context_seam().all_mods(*ctx_); + return ctx_->allMods(); } return kEmptyMods; } @@ -69,7 +51,7 @@ const QStringList& OrganizerService::active_uuids() const { return active_uuids_fn_(); } if (ctx_) { - return global_context_seam().active_uuids(*ctx_); + return ctx_->activeUuids(); } return kEmptyActive; } diff --git a/src/core/organizer/service.h b/src/core/organizer/service.h index 706b8e9..542ff79 100644 --- a/src/core/organizer/service.h +++ b/src/core/organizer/service.h @@ -115,9 +115,4 @@ class OrganizerService : public Plugin { std::optional state_; }; -// Wires the bridge between CoreContext and OrganizerService when CoreContext is available. -void register_core_context_seam( - std::function&(const CoreContext&)> all_mods, - std::function active_uuids); - } // namespace px::core::organizer diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp index 46e052e..2f9bb89 100644 --- a/tests/ui/test_dialog_chrome.cpp +++ b/tests/ui/test_dialog_chrome.cpp @@ -9,14 +9,8 @@ namespace { -// The widget tests need a QApplication for the lifetime of the test binary. -QApplication& application() { - static int argc = 1; - static char name[] = "pxui_tests"; - static char* argv[] = {name, nullptr}; - static QApplication app(argc, argv); - return app; -} +// The shared QApplication is created by tests/ui/main.cpp. +QApplication& application() { return *static_cast(QApplication::instance()); } void clickClose(QDialog& dialog) { px::ui::installDialogChrome(&dialog); diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp index 6086f96..71afad7 100644 --- a/tests/ui/test_organizer_dialogs.cpp +++ b/tests/ui/test_organizer_dialogs.cpp @@ -12,18 +12,8 @@ using px::ui::DeleteFolderDialog; namespace { -// Widgets need a QApplication. The test binary keeps one for its whole run, offscreen -// unless the environment picks a platform, so no display is needed. -QApplication& widget_app() { - static int argc = 1; - static char name[] = "pxmodrim_ui_tests"; - static char* argv[] = {name, nullptr}; - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { - qputenv("QT_QPA_PLATFORM", "offscreen"); - } - static QApplication app(argc, argv); - return app; -} +// The shared QApplication is created by tests/ui/main.cpp. +QApplication& widget_app() { return *static_cast(QApplication::instance()); } } // namespace diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp index 1c3e0b8..c94c30e 100644 --- a/tests/ui/test_settings_panel.cpp +++ b/tests/ui/test_settings_panel.cpp @@ -1,12 +1,14 @@ #include #include #include +#include #include #include #include #include #include #include "test_support.h" +#include "ui/theme/qml_theme.h" #include "core/context.h" #include "ui/components/svg_provider.h" #include "ui/panels/settings_panel.h" @@ -32,6 +34,7 @@ TEST_CASE("Cancel settings unloads QML without binding errors") { ensureApplication(); QWidget host; auto* engine = px::ui::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); QList warnings; QObject::connect(engine, &QQmlEngine::warnings, &host, [&](const QList& values) { warnings.append(values); }); auto context = px::ui_test::makeCore(); diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index 8279519..5a943fc 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include "test_support.h" +#include "ui/theme/qml_theme.h" #include "../core/workshop/support.h" #include "core/context.h" #include "core/downloads/downloader.h" @@ -85,6 +87,7 @@ struct PanelFixture { collection.supported_versions = {"1.6"}; collection.member_ids = {"1", "missing"}; collection.member_count = 2; catalog.set_base_url(server.url()); engine = createQmlEngine(&owner); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); view = std::make_unique(*core, engine, &owner, app, catalog); view->resize(1100, 800); run(view->load()); From 2da08710ecc32c7ff083588c80750a1a8005d30a Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 02:16:07 +0300 Subject: [PATCH 05/50] chore: remove legacy Python tree; move tooling, CI, packaging and docs to CMake/C++ --- .codacy.yml | 4 +- .github/workflows/build.yml | 318 +++- .github/workflows/ci.yml | 85 +- AGENTS.md | 177 +- CHANGELOG.md | 1 + CLAUDE.md | 43 +- CMakeLists.txt | 5 +- README.md | 4 +- ROADMAP.md | 2 + cmake/Steam.cmake | 4 +- justfile | 86 +- legacy/pxmodrim/__init__.py | 0 legacy/pxmodrim/__main__.py | 15 - legacy/pxmodrim/_app.py | 340 ---- legacy/pxmodrim/core/__init__.py | 5 - legacy/pxmodrim/core/checker/__init__.py | 1 - legacy/pxmodrim/core/checker/checker.py | 323 ---- legacy/pxmodrim/core/checker/databases.py | 242 --- legacy/pxmodrim/core/checker/graph.py | 399 ----- legacy/pxmodrim/core/checker/issues.py | 315 ---- legacy/pxmodrim/core/checker/models.py | 70 - legacy/pxmodrim/core/checker/sort.py | 156 -- legacy/pxmodrim/core/config.py | 455 ------ legacy/pxmodrim/core/constants.py | 62 - legacy/pxmodrim/core/context.py | 287 ---- legacy/pxmodrim/core/downloads/__init__.py | 20 - legacy/pxmodrim/core/downloads/downloader.py | 90 - legacy/pxmodrim/core/downloads/manager.py | 151 -- legacy/pxmodrim/core/downloads/steam.py | 499 ------ legacy/pxmodrim/core/downloads/types.py | 30 - legacy/pxmodrim/core/events.py | 22 - legacy/pxmodrim/core/loading.py | 87 - legacy/pxmodrim/core/migrator.py | 81 - legacy/pxmodrim/core/mod_service.py | 265 --- legacy/pxmodrim/core/models/__init__.py | 0 .../pxmodrim/core/models/metadata/__init__.py | 0 .../pxmodrim/core/models/metadata/parsing.py | 748 --------- .../core/models/metadata/structures.py | 412 ----- legacy/pxmodrim/core/models/view/__init__.py | 1 - .../pxmodrim/core/models/view/diagnostics.py | 21 - legacy/pxmodrim/core/models/view/sidebar.py | 73 - legacy/pxmodrim/core/mods_config.py | 215 --- legacy/pxmodrim/core/msgspec_hooks.py | 19 - legacy/pxmodrim/core/organizer/__init__.py | 56 - legacy/pxmodrim/core/organizer/db.py | 563 ------- legacy/pxmodrim/core/organizer/defaults.py | 76 - legacy/pxmodrim/core/organizer/models.py | 52 - legacy/pxmodrim/core/organizer/resolve.py | 357 ---- legacy/pxmodrim/core/organizer/service.py | 369 ----- legacy/pxmodrim/core/plugin.py | 75 - legacy/pxmodrim/core/plugin_config.py | 75 - legacy/pxmodrim/core/profiler.py | 37 - legacy/pxmodrim/core/providers/__init__.py | 41 - legacy/pxmodrim/core/providers/base.py | 118 -- legacy/pxmodrim/core/providers/core.py | 58 - legacy/pxmodrim/core/providers/local.py | 127 -- legacy/pxmodrim/core/services/__init__.py | 0 .../core/services/activation_service.py | 341 ---- .../core/services/diagnostics_service.py | 385 ----- .../pxmodrim/core/services/game_launcher.py | 205 --- .../pxmodrim/core/services/metadata_cache.py | 389 ----- .../pxmodrim/core/services/mod_discovery.py | 57 - legacy/pxmodrim/core/services/sort_service.py | 83 - .../startup_impact_service/__init__.py | 29 - .../services/startup_impact_service/db.py | 445 ----- .../services/startup_impact_service/labels.py | 74 - .../services/startup_impact_service/models.py | 68 - .../services/startup_impact_service/parser.py | 95 -- .../services/startup_impact_service/phases.py | 70 - .../startup_impact_service/service.py | 123 -- .../pxmodrim/core/services/update_service.py | 115 -- legacy/pxmodrim/core/sort/__init__.py | 39 - legacy/pxmodrim/core/sort/community.py | 72 - .../pxmodrim/core/sort/community_service.py | 99 -- legacy/pxmodrim/core/sort/config.py | 62 - legacy/pxmodrim/core/sort/exceptions.py | 9 - legacy/pxmodrim/core/sort/models.py | 17 - legacy/pxmodrim/core/sort/progress.py | 16 - legacy/pxmodrim/core/sort/tiers.py | 141 -- legacy/pxmodrim/core/structures.py | 10 - legacy/pxmodrim/core/support/__init__.py | 19 - legacy/pxmodrim/core/support/report.py | 311 ---- legacy/pxmodrim/core/utils.py | 19 - legacy/pxmodrim/core/workshop/__init__.py | 37 - legacy/pxmodrim/core/workshop/catalog.py | 357 ---- legacy/pxmodrim/core/workshop/client.py | 183 --- legacy/pxmodrim/core/workshop/favourites.py | 60 - legacy/pxmodrim/core/workshop/types.py | 129 -- legacy/pxmodrim/core/xml.py | 110 -- legacy/pxmodrim/ui/__init__.py | 0 legacy/pxmodrim/ui/assets/__init__.py | 1 - legacy/pxmodrim/ui/assets/logo.png | Bin 36953 -> 0 bytes legacy/pxmodrim/ui/assets/logo.svg | 15 - legacy/pxmodrim/ui/assets/logo_nobg_1.svg | 14 - legacy/pxmodrim/ui/assets/logo_nobg_2.svg | 15 - legacy/pxmodrim/ui/assets/logo_nobg_3.svg | 14 - legacy/pxmodrim/ui/assets/logo_nobg_4.svg | 15 - .../pxmodrim/ui/components/FilterSidebar.qml | 127 -- legacy/pxmodrim/ui/components/Header.qml | 368 ----- legacy/pxmodrim/ui/components/IconRail.qml | 246 --- legacy/pxmodrim/ui/components/__init__.py | 31 - .../pxmodrim/ui/components/banner_widget.py | 151 -- legacy/pxmodrim/ui/components/button.py | 11 - .../components/controls/DiagnosticBadge.qml | 49 - .../ui/components/controls/EmptyState.qml | 54 - .../ui/components/controls/ModDescription.qml | 29 - .../ui/components/controls/PxBadge.qml | 57 - .../ui/components/controls/PxButton.qml | 132 -- .../ui/components/controls/PxCheckBox.qml | 31 - .../components/controls/PxCheckIndicator.qml | 48 - .../ui/components/controls/PxComboBox.qml | 92 -- .../ui/components/controls/PxDialog.qml | 77 - .../ui/components/controls/PxDialogFooter.qml | 37 - .../ui/components/controls/PxMenu.qml | 37 - .../ui/components/controls/PxMenuItem.qml | 62 - .../ui/components/controls/PxProgressBar.qml | 49 - .../ui/components/controls/PxScrollBar.qml | 21 - .../ui/components/controls/PxSpinBox.qml | 71 - .../ui/components/controls/PxTextField.qml | 40 - .../ui/components/controls/PxToolTip.qml | 28 - .../pxmodrim/ui/components/dialog_chrome.py | 157 -- legacy/pxmodrim/ui/components/dialogs.py | 36 - .../pxmodrim/ui/components/filter_sidebar.py | 43 - .../ui/components/header_controller.py | 218 --- legacy/pxmodrim/ui/components/header_panel.py | 33 - legacy/pxmodrim/ui/components/icon_button.py | 40 - legacy/pxmodrim/ui/components/icons.py | 440 ----- .../pxmodrim/ui/components/mod_activation.py | 83 - legacy/pxmodrim/ui/components/mod_updates.py | 110 -- legacy/pxmodrim/ui/components/popup_parent.py | 18 - .../ui/components/procedural_preview.py | 106 -- .../pxmodrim/ui/components/progress_dialog.py | 139 -- legacy/pxmodrim/ui/components/svg_provider.py | 64 - legacy/pxmodrim/ui/components/toast.py | 151 -- .../pxmodrim/ui/components/unity_rich_text.py | 277 ---- .../pxmodrim/ui/components/view_rail_panel.py | 62 - legacy/pxmodrim/ui/config.py | 44 - legacy/pxmodrim/ui/context.py | 150 -- legacy/pxmodrim/ui/mod_selection.py | 50 - legacy/pxmodrim/ui/models/__init__.py | 0 legacy/pxmodrim/ui/models/impact.py | 26 - legacy/pxmodrim/ui/models/mod_list_model.py | 435 ----- .../ui/models/mod_list_proxy_model.py | 352 ---- legacy/pxmodrim/ui/models/sidebar_model.py | 249 --- legacy/pxmodrim/ui/navigation.py | 34 - legacy/pxmodrim/ui/panels/ModInfo.qml | 539 ------ legacy/pxmodrim/ui/panels/ModList.qml | 764 --------- legacy/pxmodrim/ui/panels/Settings.qml | 419 ----- legacy/pxmodrim/ui/panels/TimeAnalytics.qml | 832 ---------- legacy/pxmodrim/ui/panels/__init__.py | 0 legacy/pxmodrim/ui/panels/about_panel.py | 318 ---- .../ui/panels/keyboard_shortcuts_dialog.py | 52 - legacy/pxmodrim/ui/panels/mod_info_data.py | 297 ---- legacy/pxmodrim/ui/panels/mod_info_panel.py | 328 ---- legacy/pxmodrim/ui/panels/mod_list_panel.py | 351 ---- .../ui/panels/restore_snapshot_dialog.py | 169 -- legacy/pxmodrim/ui/panels/settings_panel.py | 267 --- legacy/pxmodrim/ui/panels/sidebar_panel.py | 58 - .../pxmodrim/ui/panels/startup_impact_data.py | 225 --- .../ui/panels/time_analytics_panel.py | 94 -- legacy/pxmodrim/ui/panels/update_dialog.py | 79 - .../ui/panels/upload_report_dialog.py | 201 --- legacy/pxmodrim/ui/plugins/__init__.py | 3 - .../ui/plugins/downloads/Downloads.qml | 278 ---- .../pxmodrim/ui/plugins/downloads/__init__.py | 5 - legacy/pxmodrim/ui/plugins/downloads/model.py | 310 ---- .../pxmodrim/ui/plugins/downloads/plugin.py | 17 - legacy/pxmodrim/ui/plugins/downloads/view.py | 82 - .../ui/plugins/organizer/ErrorBanner.qml | 35 - .../pxmodrim/ui/plugins/organizer/ModTree.qml | 410 ----- .../ui/plugins/organizer/RuleEditor.qml | 481 ------ .../ui/plugins/organizer/TagEditor.qml | 208 --- .../pxmodrim/ui/plugins/organizer/__init__.py | 7 - .../pxmodrim/ui/plugins/organizer/dialogs.py | 139 -- .../ui/plugins/organizer/filter_model.py | 102 -- .../pxmodrim/ui/plugins/organizer/plugin.py | 17 - .../ui/plugins/organizer/tree_model.py | 453 ------ legacy/pxmodrim/ui/plugins/organizer/view.py | 902 ----------- .../steam_downloader/SteamSettings.qml | 114 -- .../ui/plugins/steam_downloader/__init__.py | 5 - .../ui/plugins/steam_downloader/plugin.py | 33 - .../steam_workshop/DownloadSidebar.qml | 150 -- .../plugins/steam_workshop/SteamWorkshop.qml | 214 --- .../ui/plugins/steam_workshop/__init__.py | 15 - .../ui/plugins/steam_workshop/bridge_obj.py | 110 -- .../steam_workshop/download_queue_model.py | 93 -- .../steam_workshop/download_sidebar.py | 83 - .../ui/plugins/steam_workshop/inject.css | 393 ----- .../ui/plugins/steam_workshop/plugin.py | 282 ---- .../ui/plugins/steam_workshop/ts/api.ts | 69 - .../ui/plugins/steam_workshop/ts/badges.ts | 74 - .../ui/plugins/steam_workshop/ts/bridge.ts | 138 -- .../plugins/steam_workshop/ts/collection.ts | 275 ---- .../ui/plugins/steam_workshop/ts/controls.ts | 165 -- .../ui/plugins/steam_workshop/ts/deps.ts | 216 --- .../ui/plugins/steam_workshop/ts/detail.ts | 237 --- .../ui/plugins/steam_workshop/ts/globals.d.ts | 24 - .../ui/plugins/steam_workshop/ts/lifecycle.ts | 230 --- .../ui/plugins/steam_workshop/ts/main.ts | 37 - .../plugins/steam_workshop/ts/qwebchannel.js | 457 ------ .../ui/plugins/steam_workshop/ts/rendering.ts | 165 -- .../plugins/steam_workshop/ts/tsconfig.json | 13 - .../ui/plugins/steam_workshop/ts/types.ts | 32 - .../ui/plugins/steam_workshop/ts/utils.ts | 59 - .../ui/plugins/steam_workshop/view.py | 217 --- .../ui/plugins/workshop/CatalogCard.qml | 135 -- .../ui/plugins/workshop/CatalogImage.qml | 18 - .../ui/plugins/workshop/CatalogSettings.qml | 36 - .../ui/plugins/workshop/CatalogThumb.qml | 45 - .../ui/plugins/workshop/CollectionBanner.qml | 126 -- .../pxmodrim/ui/plugins/workshop/ModRow.qml | 89 - .../ui/plugins/workshop/TagPicker.qml | 94 -- .../pxmodrim/ui/plugins/workshop/Workshop.qml | 582 ------- .../pxmodrim/ui/plugins/workshop/__init__.py | 6 - .../pxmodrim/ui/plugins/workshop/details.py | 71 - .../pxmodrim/ui/plugins/workshop/installed.py | 62 - legacy/pxmodrim/ui/plugins/workshop/models.py | 274 ---- legacy/pxmodrim/ui/plugins/workshop/plugin.py | 33 - legacy/pxmodrim/ui/plugins/workshop/thumbs.js | 24 - legacy/pxmodrim/ui/plugins/workshop/view.py | 867 ---------- legacy/pxmodrim/ui/settings_section.py | 57 - legacy/pxmodrim/ui/theme/__init__.py | 0 legacy/pxmodrim/ui/theme/constants.py | 22 - legacy/pxmodrim/ui/theme/palette.py | 61 - legacy/pxmodrim/ui/theme/qml_theme.py | 214 --- legacy/pxmodrim/ui/theme/style.qss | 663 -------- legacy/pxmodrim/ui/ui_prefs.py | 12 - legacy/pxmodrim/ui/views/__init__.py | 11 - legacy/pxmodrim/ui/views/base.py | 49 - legacy/pxmodrim/ui/views/mods_view.py | 82 - legacy/pxmodrim/ui/window/__init__.py | 0 legacy/pxmodrim/ui/window/actions.py | 104 -- legacy/pxmodrim/ui/window/main_window.py | 943 ----------- legacy/pxmodrim/ui/window/menu_bar.py | 67 - legacy/tests/__init__.py | 0 legacy/tests/benchmarks/bench_discovery.py | 95 -- legacy/tests/benchmarks/bench_sort.py | 134 -- legacy/tests/conftest.py | 30 - legacy/tests/test_checker/__init__.py | 1 - legacy/tests/test_checker/test_checker.py | 204 --- legacy/tests/test_checker/test_graph.py | 252 --- legacy/tests/test_checker/test_issues.py | 312 ---- legacy/tests/test_compat/test_config.py | 240 --- legacy/tests/test_compat/test_migrator.py | 192 --- legacy/tests/test_downloads/test_manager.py | 130 -- legacy/tests/test_downloads/test_steam.py | 506 ------ legacy/tests/test_logging.py | 56 - legacy/tests/test_metadata/__init__.py | 0 .../data/local_mods/valid_mod/About/About.xml | 14 - .../Data/ludeon.rimworld/About/About.xml | 14 - .../data/valid_mod/About/About.xml | 14 - .../test_metadata/test_metadata_cache.py | 391 ----- .../tests/test_metadata/test_mods_config.py | 480 ------ legacy/tests/test_metadata/test_parsing.py | 178 -- legacy/tests/test_metadata/test_sidebar.py | 15 - .../test_metadata/test_startup_impact.py | 247 --- .../test_metadata/test_startup_impact_db.py | 60 - .../test_startup_impact_phases.py | 54 - legacy/tests/test_metadata/test_structures.py | 85 - legacy/tests/test_organizer/__init__.py | 0 legacy/tests/test_organizer/test_db.py | 349 ---- legacy/tests/test_organizer/test_resolve.py | 364 ----- legacy/tests/test_organizer/test_service.py | 470 ------ legacy/tests/test_packaging.py | 288 ---- legacy/tests/test_plugin.py | 155 -- legacy/tests/test_providers.py | 51 - .../test_services/test_activation_service.py | 389 ----- .../test_diagnostics_databases.py | 113 -- .../tests/test_services/test_game_launcher.py | 238 --- .../tests/test_services/test_sort_service.py | 219 --- .../test_services/test_update_service.py | 120 -- legacy/tests/test_sort/test_tier_order.py | 254 --- legacy/tests/test_support/test_report.py | 306 ---- legacy/tests/test_ui/test_dialog_chrome.py | 42 - legacy/tests/test_ui/test_downloads_model.py | 46 - legacy/tests/test_ui/test_main_window.py | 314 ---- legacy/tests/test_ui/test_menu_bar.py | 65 - legacy/tests/test_ui/test_mod_info_data.py | 311 ---- legacy/tests/test_ui/test_mod_list_model.py | 229 --- legacy/tests/test_ui/test_mod_list_panel.py | 456 ------ .../test_ui/test_mod_list_proxy_model.py | 306 ---- legacy/tests/test_ui/test_mod_tree_model.py | 508 ------ legacy/tests/test_ui/test_mod_updates.py | 51 - legacy/tests/test_ui/test_navigation.py | 21 - .../tests/test_ui/test_organizer_dialogs.py | 25 - .../test_ui/test_organizer_rule_editor.py | 327 ---- .../tests/test_ui/test_procedural_preview.py | 35 - .../test_ui/test_restore_snapshot_dialog.py | 324 ---- legacy/tests/test_ui/test_settings_panel.py | 52 - legacy/tests/test_ui/test_sidebar_model.py | 32 - .../tests/test_ui/test_startup_impact_data.py | 132 -- .../tests/test_ui/test_toast_and_progress.py | 53 - legacy/tests/test_ui/test_unity_rich_text.py | 127 -- legacy/tests/test_ui/test_views.py | 95 -- legacy/tests/test_ui/test_workshop_images.py | 101 -- .../tests/test_ui/test_workshop_installed.py | 68 - legacy/tests/test_ui/test_workshop_models.py | 323 ---- legacy/tests/test_ui/test_workshop_view.py | 1443 ----------------- legacy/tests/test_workshop/conftest.py | 64 - legacy/tests/test_workshop/test_catalog.py | 405 ----- legacy/tests/test_workshop/test_client.py | 387 ----- legacy/tests/test_workshop/test_favourites.py | 71 - legacy/tests/test_workshop/test_types.py | 92 -- packaging/build.py | 678 -------- packaging/entrypoint.py | 4 - .../flatpak/com.github.PyXiion.PxModRim.yaml | 10 +- packaging/linux/pxmodrim.spec | 6 +- packaging/windows/installer.nsi | 2 +- pyproject.toml | 101 -- scripts/check-deps.py | 279 ---- src/app/CMakeLists.txt | 41 + src/ui/components/dialogs.h | 32 +- src/ui/plugins/workshop/view.cpp | 26 +- src/ui/plugins/workshop/view.h | 3 + tests/core/services/test_update_service.cpp | 2 +- tests/ui/test_mod_list_panel.cpp | 1 + tests/ui/test_workshop_view.cpp | 3 +- uv.lock | 543 ------- 318 files changed, 574 insertions(+), 49316 deletions(-) delete mode 100644 legacy/pxmodrim/__init__.py delete mode 100644 legacy/pxmodrim/__main__.py delete mode 100644 legacy/pxmodrim/_app.py delete mode 100644 legacy/pxmodrim/core/__init__.py delete mode 100644 legacy/pxmodrim/core/checker/__init__.py delete mode 100644 legacy/pxmodrim/core/checker/checker.py delete mode 100644 legacy/pxmodrim/core/checker/databases.py delete mode 100644 legacy/pxmodrim/core/checker/graph.py delete mode 100644 legacy/pxmodrim/core/checker/issues.py delete mode 100644 legacy/pxmodrim/core/checker/models.py delete mode 100644 legacy/pxmodrim/core/checker/sort.py delete mode 100644 legacy/pxmodrim/core/config.py delete mode 100644 legacy/pxmodrim/core/constants.py delete mode 100644 legacy/pxmodrim/core/context.py delete mode 100644 legacy/pxmodrim/core/downloads/__init__.py delete mode 100644 legacy/pxmodrim/core/downloads/downloader.py delete mode 100644 legacy/pxmodrim/core/downloads/manager.py delete mode 100644 legacy/pxmodrim/core/downloads/steam.py delete mode 100644 legacy/pxmodrim/core/downloads/types.py delete mode 100644 legacy/pxmodrim/core/events.py delete mode 100644 legacy/pxmodrim/core/loading.py delete mode 100644 legacy/pxmodrim/core/migrator.py delete mode 100644 legacy/pxmodrim/core/mod_service.py delete mode 100644 legacy/pxmodrim/core/models/__init__.py delete mode 100644 legacy/pxmodrim/core/models/metadata/__init__.py delete mode 100644 legacy/pxmodrim/core/models/metadata/parsing.py delete mode 100644 legacy/pxmodrim/core/models/metadata/structures.py delete mode 100644 legacy/pxmodrim/core/models/view/__init__.py delete mode 100644 legacy/pxmodrim/core/models/view/diagnostics.py delete mode 100644 legacy/pxmodrim/core/models/view/sidebar.py delete mode 100644 legacy/pxmodrim/core/mods_config.py delete mode 100644 legacy/pxmodrim/core/msgspec_hooks.py delete mode 100644 legacy/pxmodrim/core/organizer/__init__.py delete mode 100644 legacy/pxmodrim/core/organizer/db.py delete mode 100644 legacy/pxmodrim/core/organizer/defaults.py delete mode 100644 legacy/pxmodrim/core/organizer/models.py delete mode 100644 legacy/pxmodrim/core/organizer/resolve.py delete mode 100644 legacy/pxmodrim/core/organizer/service.py delete mode 100644 legacy/pxmodrim/core/plugin.py delete mode 100644 legacy/pxmodrim/core/plugin_config.py delete mode 100644 legacy/pxmodrim/core/profiler.py delete mode 100644 legacy/pxmodrim/core/providers/__init__.py delete mode 100644 legacy/pxmodrim/core/providers/base.py delete mode 100644 legacy/pxmodrim/core/providers/core.py delete mode 100644 legacy/pxmodrim/core/providers/local.py delete mode 100644 legacy/pxmodrim/core/services/__init__.py delete mode 100644 legacy/pxmodrim/core/services/activation_service.py delete mode 100644 legacy/pxmodrim/core/services/diagnostics_service.py delete mode 100644 legacy/pxmodrim/core/services/game_launcher.py delete mode 100644 legacy/pxmodrim/core/services/metadata_cache.py delete mode 100644 legacy/pxmodrim/core/services/mod_discovery.py delete mode 100644 legacy/pxmodrim/core/services/sort_service.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/__init__.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/db.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/labels.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/models.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/parser.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/phases.py delete mode 100644 legacy/pxmodrim/core/services/startup_impact_service/service.py delete mode 100644 legacy/pxmodrim/core/services/update_service.py delete mode 100644 legacy/pxmodrim/core/sort/__init__.py delete mode 100644 legacy/pxmodrim/core/sort/community.py delete mode 100644 legacy/pxmodrim/core/sort/community_service.py delete mode 100644 legacy/pxmodrim/core/sort/config.py delete mode 100644 legacy/pxmodrim/core/sort/exceptions.py delete mode 100644 legacy/pxmodrim/core/sort/models.py delete mode 100644 legacy/pxmodrim/core/sort/progress.py delete mode 100644 legacy/pxmodrim/core/sort/tiers.py delete mode 100644 legacy/pxmodrim/core/structures.py delete mode 100644 legacy/pxmodrim/core/support/__init__.py delete mode 100644 legacy/pxmodrim/core/support/report.py delete mode 100644 legacy/pxmodrim/core/utils.py delete mode 100644 legacy/pxmodrim/core/workshop/__init__.py delete mode 100644 legacy/pxmodrim/core/workshop/catalog.py delete mode 100644 legacy/pxmodrim/core/workshop/client.py delete mode 100644 legacy/pxmodrim/core/workshop/favourites.py delete mode 100644 legacy/pxmodrim/core/workshop/types.py delete mode 100644 legacy/pxmodrim/core/xml.py delete mode 100644 legacy/pxmodrim/ui/__init__.py delete mode 100644 legacy/pxmodrim/ui/assets/__init__.py delete mode 100644 legacy/pxmodrim/ui/assets/logo.png delete mode 100644 legacy/pxmodrim/ui/assets/logo.svg delete mode 100644 legacy/pxmodrim/ui/assets/logo_nobg_1.svg delete mode 100644 legacy/pxmodrim/ui/assets/logo_nobg_2.svg delete mode 100644 legacy/pxmodrim/ui/assets/logo_nobg_3.svg delete mode 100644 legacy/pxmodrim/ui/assets/logo_nobg_4.svg delete mode 100644 legacy/pxmodrim/ui/components/FilterSidebar.qml delete mode 100644 legacy/pxmodrim/ui/components/Header.qml delete mode 100644 legacy/pxmodrim/ui/components/IconRail.qml delete mode 100644 legacy/pxmodrim/ui/components/__init__.py delete mode 100644 legacy/pxmodrim/ui/components/banner_widget.py delete mode 100644 legacy/pxmodrim/ui/components/button.py delete mode 100644 legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/EmptyState.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/ModDescription.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxBadge.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxButton.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxCheckBox.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxComboBox.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxDialog.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxMenu.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxMenuItem.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxProgressBar.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxScrollBar.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxSpinBox.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxTextField.qml delete mode 100644 legacy/pxmodrim/ui/components/controls/PxToolTip.qml delete mode 100644 legacy/pxmodrim/ui/components/dialog_chrome.py delete mode 100644 legacy/pxmodrim/ui/components/dialogs.py delete mode 100644 legacy/pxmodrim/ui/components/filter_sidebar.py delete mode 100644 legacy/pxmodrim/ui/components/header_controller.py delete mode 100644 legacy/pxmodrim/ui/components/header_panel.py delete mode 100644 legacy/pxmodrim/ui/components/icon_button.py delete mode 100644 legacy/pxmodrim/ui/components/icons.py delete mode 100644 legacy/pxmodrim/ui/components/mod_activation.py delete mode 100644 legacy/pxmodrim/ui/components/mod_updates.py delete mode 100644 legacy/pxmodrim/ui/components/popup_parent.py delete mode 100644 legacy/pxmodrim/ui/components/procedural_preview.py delete mode 100644 legacy/pxmodrim/ui/components/progress_dialog.py delete mode 100644 legacy/pxmodrim/ui/components/svg_provider.py delete mode 100644 legacy/pxmodrim/ui/components/toast.py delete mode 100644 legacy/pxmodrim/ui/components/unity_rich_text.py delete mode 100644 legacy/pxmodrim/ui/components/view_rail_panel.py delete mode 100644 legacy/pxmodrim/ui/config.py delete mode 100644 legacy/pxmodrim/ui/context.py delete mode 100644 legacy/pxmodrim/ui/mod_selection.py delete mode 100644 legacy/pxmodrim/ui/models/__init__.py delete mode 100644 legacy/pxmodrim/ui/models/impact.py delete mode 100644 legacy/pxmodrim/ui/models/mod_list_model.py delete mode 100644 legacy/pxmodrim/ui/models/mod_list_proxy_model.py delete mode 100644 legacy/pxmodrim/ui/models/sidebar_model.py delete mode 100644 legacy/pxmodrim/ui/navigation.py delete mode 100644 legacy/pxmodrim/ui/panels/ModInfo.qml delete mode 100644 legacy/pxmodrim/ui/panels/ModList.qml delete mode 100644 legacy/pxmodrim/ui/panels/Settings.qml delete mode 100644 legacy/pxmodrim/ui/panels/TimeAnalytics.qml delete mode 100644 legacy/pxmodrim/ui/panels/__init__.py delete mode 100644 legacy/pxmodrim/ui/panels/about_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py delete mode 100644 legacy/pxmodrim/ui/panels/mod_info_data.py delete mode 100644 legacy/pxmodrim/ui/panels/mod_info_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/mod_list_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py delete mode 100644 legacy/pxmodrim/ui/panels/settings_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/sidebar_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/startup_impact_data.py delete mode 100644 legacy/pxmodrim/ui/panels/time_analytics_panel.py delete mode 100644 legacy/pxmodrim/ui/panels/update_dialog.py delete mode 100644 legacy/pxmodrim/ui/panels/upload_report_dialog.py delete mode 100644 legacy/pxmodrim/ui/plugins/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/downloads/Downloads.qml delete mode 100644 legacy/pxmodrim/ui/plugins/downloads/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/downloads/model.py delete mode 100644 legacy/pxmodrim/ui/plugins/downloads/plugin.py delete mode 100644 legacy/pxmodrim/ui/plugins/downloads/view.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/ModTree.qml delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/dialogs.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/filter_model.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/plugin.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/tree_model.py delete mode 100644 legacy/pxmodrim/ui/plugins/organizer/view.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml delete mode 100644 legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/inject.css delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts delete mode 100644 legacy/pxmodrim/ui/plugins/steam_workshop/view.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/ModRow.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/Workshop.qml delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/__init__.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/details.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/installed.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/models.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/plugin.py delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/thumbs.js delete mode 100644 legacy/pxmodrim/ui/plugins/workshop/view.py delete mode 100644 legacy/pxmodrim/ui/settings_section.py delete mode 100644 legacy/pxmodrim/ui/theme/__init__.py delete mode 100644 legacy/pxmodrim/ui/theme/constants.py delete mode 100644 legacy/pxmodrim/ui/theme/palette.py delete mode 100644 legacy/pxmodrim/ui/theme/qml_theme.py delete mode 100644 legacy/pxmodrim/ui/theme/style.qss delete mode 100644 legacy/pxmodrim/ui/ui_prefs.py delete mode 100644 legacy/pxmodrim/ui/views/__init__.py delete mode 100644 legacy/pxmodrim/ui/views/base.py delete mode 100644 legacy/pxmodrim/ui/views/mods_view.py delete mode 100644 legacy/pxmodrim/ui/window/__init__.py delete mode 100644 legacy/pxmodrim/ui/window/actions.py delete mode 100644 legacy/pxmodrim/ui/window/main_window.py delete mode 100644 legacy/pxmodrim/ui/window/menu_bar.py delete mode 100644 legacy/tests/__init__.py delete mode 100644 legacy/tests/benchmarks/bench_discovery.py delete mode 100644 legacy/tests/benchmarks/bench_sort.py delete mode 100644 legacy/tests/conftest.py delete mode 100644 legacy/tests/test_checker/__init__.py delete mode 100644 legacy/tests/test_checker/test_checker.py delete mode 100644 legacy/tests/test_checker/test_graph.py delete mode 100644 legacy/tests/test_checker/test_issues.py delete mode 100644 legacy/tests/test_compat/test_config.py delete mode 100644 legacy/tests/test_compat/test_migrator.py delete mode 100644 legacy/tests/test_downloads/test_manager.py delete mode 100644 legacy/tests/test_downloads/test_steam.py delete mode 100644 legacy/tests/test_logging.py delete mode 100644 legacy/tests/test_metadata/__init__.py delete mode 100644 legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml delete mode 100644 legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml delete mode 100644 legacy/tests/test_metadata/data/valid_mod/About/About.xml delete mode 100644 legacy/tests/test_metadata/test_metadata_cache.py delete mode 100644 legacy/tests/test_metadata/test_mods_config.py delete mode 100644 legacy/tests/test_metadata/test_parsing.py delete mode 100644 legacy/tests/test_metadata/test_sidebar.py delete mode 100644 legacy/tests/test_metadata/test_startup_impact.py delete mode 100644 legacy/tests/test_metadata/test_startup_impact_db.py delete mode 100644 legacy/tests/test_metadata/test_startup_impact_phases.py delete mode 100644 legacy/tests/test_metadata/test_structures.py delete mode 100644 legacy/tests/test_organizer/__init__.py delete mode 100644 legacy/tests/test_organizer/test_db.py delete mode 100644 legacy/tests/test_organizer/test_resolve.py delete mode 100644 legacy/tests/test_organizer/test_service.py delete mode 100644 legacy/tests/test_packaging.py delete mode 100644 legacy/tests/test_plugin.py delete mode 100644 legacy/tests/test_providers.py delete mode 100644 legacy/tests/test_services/test_activation_service.py delete mode 100644 legacy/tests/test_services/test_diagnostics_databases.py delete mode 100644 legacy/tests/test_services/test_game_launcher.py delete mode 100644 legacy/tests/test_services/test_sort_service.py delete mode 100644 legacy/tests/test_services/test_update_service.py delete mode 100644 legacy/tests/test_sort/test_tier_order.py delete mode 100644 legacy/tests/test_support/test_report.py delete mode 100644 legacy/tests/test_ui/test_dialog_chrome.py delete mode 100644 legacy/tests/test_ui/test_downloads_model.py delete mode 100644 legacy/tests/test_ui/test_main_window.py delete mode 100644 legacy/tests/test_ui/test_menu_bar.py delete mode 100644 legacy/tests/test_ui/test_mod_info_data.py delete mode 100644 legacy/tests/test_ui/test_mod_list_model.py delete mode 100644 legacy/tests/test_ui/test_mod_list_panel.py delete mode 100644 legacy/tests/test_ui/test_mod_list_proxy_model.py delete mode 100644 legacy/tests/test_ui/test_mod_tree_model.py delete mode 100644 legacy/tests/test_ui/test_mod_updates.py delete mode 100644 legacy/tests/test_ui/test_navigation.py delete mode 100644 legacy/tests/test_ui/test_organizer_dialogs.py delete mode 100644 legacy/tests/test_ui/test_organizer_rule_editor.py delete mode 100644 legacy/tests/test_ui/test_procedural_preview.py delete mode 100644 legacy/tests/test_ui/test_restore_snapshot_dialog.py delete mode 100644 legacy/tests/test_ui/test_settings_panel.py delete mode 100644 legacy/tests/test_ui/test_sidebar_model.py delete mode 100644 legacy/tests/test_ui/test_startup_impact_data.py delete mode 100644 legacy/tests/test_ui/test_toast_and_progress.py delete mode 100644 legacy/tests/test_ui/test_unity_rich_text.py delete mode 100644 legacy/tests/test_ui/test_views.py delete mode 100644 legacy/tests/test_ui/test_workshop_images.py delete mode 100644 legacy/tests/test_ui/test_workshop_installed.py delete mode 100644 legacy/tests/test_ui/test_workshop_models.py delete mode 100644 legacy/tests/test_ui/test_workshop_view.py delete mode 100644 legacy/tests/test_workshop/conftest.py delete mode 100644 legacy/tests/test_workshop/test_catalog.py delete mode 100644 legacy/tests/test_workshop/test_client.py delete mode 100644 legacy/tests/test_workshop/test_favourites.py delete mode 100644 legacy/tests/test_workshop/test_types.py delete mode 100644 packaging/build.py delete mode 100644 packaging/entrypoint.py delete mode 100644 pyproject.toml delete mode 100644 scripts/check-deps.py delete mode 100644 uv.lock diff --git a/.codacy.yml b/.codacy.yml index 82d75f1..71d29c3 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,7 +1,5 @@ --- exclude_paths: - - "uv.lock" - - "**/uv.lock" - "pnpm-lock.yaml" - "rimsort-original/**" - "tests/**" @@ -10,4 +8,4 @@ exclude_paths: - "**/*.png" - "**/*.ico" - "**/*.icns" - - ".codacy/**" \ No newline at end of file + - ".codacy/**" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3928397..4b5f50e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,10 @@ on: permissions: contents: write +env: + QT_VERSION: '6.11.0' + QCORO_REF: 'v0.13.0' + jobs: build-linux: runs-on: ubuntu-22.04 @@ -17,19 +21,16 @@ jobs: matrix: workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] env: - UV_NO_SYNC: '1' + VARIANT: ${{ matrix.workshop }} steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@main - with: - python-version-file: pyproject.toml - - - name: Install uv - uses: astral-sh/setup-uv@v7 + - name: Install Qt + uses: jurplel/install-qt-action@v4 with: - enable-cache: true + version: ${{ env.QT_VERSION }} + cache: true + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} - name: Setup Node.js uses: actions/setup-node@v7 @@ -37,53 +38,127 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2.87.16 + uses: taiki-e/install-action@v2 with: - tool: just@1.40.0 + tool: just - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y dpkg libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 patchelf ccache rpm - wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool - chmod +x /usr/local/bin/appimagetool + sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libspdlog-dev + + - name: Build QCoro against Qt + shell: bash + run: | + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: release-linux-${{ matrix.workshop }} + + - name: Build injected JavaScript + run: just build-js - - name: Install Dependencies - run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} + - name: Configure + shell: bash + run: | + cmake --preset release \ + -DPX_WORKSHOP_VARIANT="$VARIANT" \ + -DPX_BUILD_TESTS=OFF \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" - - name: Build with Nuitka - run: just build-release-bundled --workshop ${{ matrix.workshop }} + - name: Build + run: cmake --build --preset release + + - name: Install into AppDir + run: cmake --install build/release --prefix "$PWD/dist/AppDir/usr" - name: Verify glibc floor run: | set -euo pipefail - max=$(find dist/entrypoint.dist -type f \( -name '*.so*' -o -perm -u+x \) -exec objdump -T {} + 2>/dev/null \ + max=$(find dist/AppDir/usr -type f \( -name '*.so*' -o -perm -u+x \) -exec objdump -T {} + 2>/dev/null \ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 || true) echo "Highest GLIBC symbol: ${max:-none}" test -n "$max" test "$(printf '%s\nGLIBC_2.35\n' "$max" | sort -V | tail -1)" = "GLIBC_2.35" - - name: Package tar.gz - run: tar -czf PxModRim-Linux-${{ matrix.workshop }}.tar.gz -C dist entrypoint.dist/ - - - name: Rename Linux packages + - name: Deploy Qt and build AppImage + shell: bash run: | set -euo pipefail - mv dist/PxModRim-x86_64.AppImage dist/PxModRim-Linux-x86_64-${{ matrix.workshop }}.AppImage - for f in dist/*.deb; do - [ -f "$f" ] || continue - mv "$f" "${f%.deb}-${{ matrix.workshop }}.deb" - done - for f in dist/*.rpm; do - [ -f "$f" ] || continue - mv "$f" "${f%.rpm}-${{ matrix.workshop }}.rpm" + tools="$RUNNER_TEMP/tools" + mkdir -p "$tools" + curl -fsSL -o "$tools/linuxdeploy" https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + curl -fsSL -o "$tools/linuxdeploy-plugin-qt" https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage + chmod +x "$tools/linuxdeploy" "$tools/linuxdeploy-plugin-qt" + cp src/ui/assets/logo.svg dist/pxmodrim.svg + export PATH="$tools:$PATH" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 + export QMAKE="$QT_ROOT_DIR/bin/qmake" QML_SOURCES_PATHS="$PWD/src/ui" + export OUTPUT="$PWD/dist/PxModRim-Linux-x86_64-$VARIANT.AppImage" + linuxdeploy --appdir dist/AppDir \ + --executable dist/AppDir/usr/bin/pxmodrim \ + --desktop-file dist/AppDir/usr/share/applications/pxmodrim.desktop \ + --icon-file dist/pxmodrim.svg \ + --plugin qt \ + --output appimage + tar -czf "dist/PxModRim-Linux-$VARIANT.tar.gz" -C dist/AppDir usr + + - name: Build deb and rpm + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + + pkg=dist/deb + rm -rf "$pkg" + mkdir -p "$pkg/DEBIAN" "$pkg/opt/pxmodrim" "$pkg/usr/bin" \ + "$pkg/usr/share/applications" "$pkg/usr/share/icons/hicolor/scalable/apps" + cp -a dist/AppDir/usr/. "$pkg/opt/pxmodrim/" + ln -s /opt/pxmodrim/bin/pxmodrim "$pkg/usr/bin/pxmodrim" + install -m 644 packaging/linux/pxmodrim.desktop "$pkg/usr/share/applications/pxmodrim.desktop" + install -m 644 src/ui/assets/logo.svg "$pkg/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg" + installed_size=$(du -sk "$pkg" | cut -f1) + cat > "$pkg/DEBIAN/control" <- ${{ secrets.MACOS_CERT != '' && secrets.MACOS_CERT_PASSWORD != '' && @@ -216,15 +347,12 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@main + - name: Install Qt + uses: jurplel/install-qt-action@v4 with: - python-version-file: pyproject.toml - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true + version: ${{ env.QT_VERSION }} + cache: true + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} - name: Setup Node.js uses: actions/setup-node@v7 @@ -232,15 +360,58 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2.87.15 + uses: taiki-e/install-action@v2 + with: + tool: just + + - name: Install dependencies + run: brew install ninja ccache pugixml libzip spdlog dylibbundler + + - name: Build QCoro against Qt + shell: bash + run: | + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@v1.2 with: - tool: just@1.40.0 + key: release-macos-${{ matrix.workshop }} - - name: Install Dependencies - run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} + - name: Build injected JavaScript + run: just build-js - - name: Build with Nuitka - run: just build-release-bundled --workshop ${{ matrix.workshop }} + - name: Configure + shell: bash + run: | + cmake --preset release \ + -DPX_WORKSHOP_VARIANT="$VARIANT" \ + -DPX_BUILD_TESTS=OFF \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" + + - name: Build + run: cmake --build --preset release + + - name: Install and deploy Qt + shell: bash + run: | + set -euo pipefail + cmake --install build/release --prefix "$PWD/dist/stage" + mv dist/stage/pxmodrim.app dist/PxModRim.app + rm -rf dist/stage + macdeployqt dist/PxModRim.app -qmldir=src/ui -always-overwrite + mkdir -p dist/PxModRim.app/Contents/Frameworks + dylibbundler -od -b -x dist/PxModRim.app/Contents/MacOS/pxmodrim \ + -d dist/PxModRim.app/Contents/Frameworks/ -p @executable_path/../Frameworks/ - name: Sign and notarize macOS app if: env.MACOS_SIGNING_CONFIGURED == 'true' @@ -293,11 +464,21 @@ jobs: security delete-keychain "$keychain_path" rm -f "$certificate" "$notary_zip" + - name: Ad-hoc sign and archive macOS app + if: env.MACOS_SIGNING_CONFIGURED != 'true' + run: | + set -euo pipefail + codesign --force --deep --sign - dist/PxModRim.app + ditto -c -k --keepParent dist/PxModRim.app dist/PxModRim-macOS.zip + hdiutil create -volname PxModRim \ + -srcfolder dist/PxModRim.app \ + -ov -format UDZO dist/PxModRim-macOS.dmg + - name: Rename macOS packages run: | set -euo pipefail - mv dist/PxModRim-macOS.zip dist/PxModRim-macOS-${{ matrix.workshop }}.zip - mv dist/PxModRim-macOS.dmg dist/PxModRim-macOS-${{ matrix.workshop }}.dmg + mv dist/PxModRim-macOS.zip "dist/PxModRim-macOS-$VARIANT.zip" + mv dist/PxModRim-macOS.dmg "dist/PxModRim-macOS-$VARIANT.dmg" - name: Upload artifact (dmg) uses: actions/upload-artifact@v7 @@ -310,6 +491,7 @@ jobs: with: name: PxModRim-macOS-ZIP-${{ matrix.workshop }} path: dist/PxModRim-macOS-${{ matrix.workshop }}.zip + release: needs: [build-linux, build-flatpak, build-windows, build-macos] runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83409cf..da46daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,26 +8,35 @@ on: permissions: {} +env: + QT_VERSION: '6.11.0' + QCORO_REF: 'v0.13.0' + jobs: - ci: + build-and-test: + name: ${{ matrix.os }} / ${{ matrix.workshop }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + QT_QPA_PLATFORM: offscreen steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@main + - name: Install Qt + uses: jurplel/install-qt-action@v4 with: - python-version-file: pyproject.toml + version: ${{ env.QT_VERSION }} + cache: true + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true + - name: Setup MSVC environment + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 - name: Setup Node.js uses: actions/setup-node@v7 @@ -35,29 +44,67 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2.87.16 + uses: taiki-e/install-action@v2 with: - tool: just@1.40.0 + tool: just - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 + sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libspdlog-dev - - name: Install Dependencies - run: uv sync --locked --dev --extra steam + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install ninja ccache pugixml libzip spdlog - - name: Check (type checking + dependency layers) - run: just check + - name: Install system dependencies (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install ninja ccache --yes --no-progress + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install pugixml libzip spdlog --triplet x64-windows-static-md - - name: Run tests + - name: Build QCoro against Qt shell: bash run: | - if [ "$RUNNER_OS" = "Linux" ]; then - export QT_QPA_PLATFORM=offscreen + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ci-${{ matrix.os }}-${{ matrix.workshop }} + + - name: Build injected JavaScript + run: just build-js + + - name: Configure + shell: bash + run: | + extra=() + if [ "$RUNNER_OS" = "Windows" ]; then + extra+=(-DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static-md) fi - just test + cmake --preset default \ + -DPX_WORKSHOP_VARIANT=${{ matrix.workshop }} \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" \ + "${extra[@]}" + + - name: Build + run: cmake --build --preset default + + - name: Test + run: ctest --preset default workshop-backend: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 862b8d2..909e035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,142 @@ # PxModRim — Agent guide ## Workflow -1. `just fix` — ruff lint fix + format (fastest cycle) -2. `just check` — ruff → `build-js` → pyright → check-deps -3. `just test` — run full test suite -4. `just ci` — `check` then `test` (matches CI pipeline) +1. `just format` — clang-format the C++ tree (fastest cycle) +2. `just check` — `format-check` → `build` → `build-js` +3. `just test` — build, then run the full ctest suite offscreen +4. `just ci` — `check` then `test` 5. Always make commits on the go — atomic, conventional commits as milestones are reached; never leave uncommitted work. +CI runs configure, build and test on every OS (see CI matrix); it does not run `format-check`, so run `just check` before pushing. + ## Environment -**uv**, **Python 3.12.\*** only, **PySide6 >=6.11 + qasync**. -Task runner is **just** (`just` to list). CI uses `uv sync --locked --dev`. -`pxsteamdl` (Workshop downloader) is an optional dependency (`steam` extra, also in the `dev` group), built from source: needs CMake >= 3.24 and a C++20 compiler (GCC, Clang, or MSVC); external dependencies are vendored via CPM. Without it the app runs as a plain mod sorter. +- Task runner: **just** (`just` lists the recipes). Node.js 22 for `build-js` (`npx esbuild`). +- **C++23** compiler: GCC or Clang (CI also builds with MSVC on Windows). +- **CMake >= 3.24** with **Ninja** (the presets use it). **ccache** is picked up automatically when it is on `PATH`. +- **Qt 6** (`find_package(Qt6 6.8)` is the minimum; CI and releases use **6.11.0**). Components: Core, Concurrent, Network, Sql, Gui, Quick, QuickControls2, QuickWidgets, Svg, Widgets, Test, Qml. `WebEngineQuick` is needed only by the SteamWorkshop variant. +- **QCoro 0.13** (`QCoro6`: Core, Network, Qml, Quick), built against the same Qt install. Enable coroutines with `qcoro_enable_coroutines()`, as the root CMakeLists already does. +- **pugixml**, **libzip**, **spdlog** via `find_package`. +- **CPM** (`cmake/CPM.cmake`) fetches glaze 5.5.4, Catch2 3.8.1 (tests) and PxSteamDL 0.3.0 (`cmake/Steam.cmake`, skipped by NoWorkshop). +- Without PxSteamDL the app runs as a plain mod sorter. ## Entrypoint -- `just run` (sets `LOGURU_LEVEL=DEBUG`) -- `uv run python -m pxmodrim` -- Chain: `__main__.py` -> `_app.py` (`App().run()`) -> DI composition root +- `just run` (sets `PX_LOG_LEVEL=debug`), or `build/default/src/app/pxmodrim`. +- Chain: `src/app/main.cpp` `main()` → `runApp()`, the composition root: + config services → `CoreContext::create` → `px::app::registerCorePlugins` → `px::ui::AppContext` → `px::app::registerUiPlugins` → `setupAll()` → `MainWindow` → `refreshMods()` → `initAll()`. +- `src/app/plugins.cpp` is the only list of concrete plugins. ## Module layout ``` -src/pxmodrim/ -├── _app.py # composition root: DI assembly, Fusion + QPalette -├── core/ # all domain logic; never imports ui/ -└── ui/ # Qt widgets + QML; imports core/ freely +src/ +├── core/ pxcore (static lib, no GUI): domain logic, services, downloads, workshop, organizer, plugin API +├── ui/ pxui (static lib) + QML module PxModRim: Qt Quick views, widgets, plugins/ +└── app/ pxmodrim executable: main.cpp (composition root), plugins.cpp (plugin list) +tests/ +├── core/ Catch2 → pxcore_tests (mirrors src/core/) +└── ui/ Catch2 + Qt Test → pxui_tests (shared QApplication in main.cpp) +packaging/ desktop file, icons, RPM spec, Flatpak manifest, NSIS script +scripts/ build-inject-js.py +legacy/ read-only Python reference of the previous implementation ``` +**Core must never know about UI.** `pxcore` links only non-GUI Qt modules (Core, Concurrent, Network, Sql) and must not include `ui/` headers. Check both with `grep -rn '#include "ui/' src/core tests/core` and `cmake --preset default --graphviz=build/graph.dot` (the target graph must show no `pxcore` → `Qt6::Gui`/`Quick`/`Widgets` edge). Plugins must not know about layers; layers must not know about plugins. + ## Download plugins -Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`DownloadManager`, always present, plugin name `downloads`). -- Sources subclass `core.downloads.Downloader` (`core/downloads/steam.py` = `steam_downloader`, the only one today) and register with the manager in `setup()`. Third-party imports stay inside the source's own module; `_app.py` imports it lazily and skips it if disabled or missing. -- Catalog: `workshop_catalog` consumes the read-only catalog API with constructor-injected configuration/client factory; it resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `AppConfig.workshop_catalog_url` defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted config fields load from older files without a schema migration. -- UI plugins: `downloads_ui` (Downloads tab, source-agnostic, shown when any downloader exists), `steamworkshop` (Steam WebView, needs `steam_downloader`), `workshop_ui` (native QML Workshop browser, needs `workshop_catalog` and `downloads`). +Downloads are plugins; the UI only talks to `core/downloads/manager.h` (`DownloadManager`, always present, plugin name `downloads`, `kDownloadsManagerName`). +- Sources subclass `px::core::Downloader` (`core/downloads/downloader.h`). `SteamDownloader` (`core/downloads/steam.h`, plugin name `steam_downloader`) is the only one today. The constructor hands the source to the manager, and `setup()` registers it. Third-party includes stay inside the source's own `.cpp`, behind `PX_HAS_STEAM`. `registerCorePlugins` (`src/app/plugins.cpp`) adds it only when it is not disabled. +- Catalog: `WorkshopCatalog` (`core/workshop/catalog.h`, plugin `workshop_catalog`, depends on `downloads`) consumes the read-only catalog API through `core/workshop/client.h` with an injected configuration and client. It resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `CatalogSettings::url` (legacy `config.json` key `workshop_catalog_url`) defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted settings fields load from older files without a schema migration. +- UI plugins: `DownloadsUiPlugin` (`ui/plugins/downloads/`, Downloads tab, source-agnostic, shown when any downloader exists), `SteamWorkshopUiPlugin` (`ui/plugins/steam_workshop/`, Steam WebView, needs `steam_downloader`), `workshop::WorkshopUiPlugin` (`ui/plugins/workshop/`, native QML Workshop browser, needs `workshop_catalog` and `downloads`). - Disable at runtime: `PX_DISABLED_PLUGINS=steam_downloader,steamworkshop,downloads_ui,workshop_catalog,workshop_ui` (comma-separated). Disabling the catalog also omits its UI. - Installed catalog state uses discovered mods' `published_file_id` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. -- Catalog requests batch installed metadata by 100 IDs and resolution by 50 mod roots / 10 collection roots; large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. -- New source: subclass `Downloader`, register in `_app.py`, add its name to the `downloaders` list; UI needs no change. +- Catalog requests batch installed metadata by 100 IDs (`kIdsPerBatch`) and resolution by 50 mod roots / 10 collection roots (`kModIdsPerResolve`, `kCollectionIdsPerResolve`); large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. +- New source: subclass `Downloader`, register it in `registerCorePlugins` (`src/app/plugins.cpp`), and add its name to the `downloaders` list; the UI needs no change. ## Plugin API -Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `_app.py` (composition root) does. -- Settings: `PluginConfig(ctx.config_service, name, Struct, legacy={old_config_json_key: field})` in `setup()` → `plugins/.json`; subscribe to `.changed`. -- Settings UI: a UI plugin calls `app_ctx.add_settings_section(factory)` (one Settings tab each); subclass `ui.settings_section.PluginConfigSection` with `title` + `source` (QML whose root has `required property QtObject section`, reading `section.initial` and calling `section.set(key, value)`). Edits apply on Save only. -- Views: `RailView` subclasses on `app_ctx.add_rail_view`; `open_route(path)` handles `modrim:///…`; `lists_downloads = True` marks the download queue. -- `steam_downloader_ui` holds the Steam download settings section. -- Build variants: `packaging/build.py --workshop {NoWorkshop,SteamWorkshop,NativeWorkshop}` bakes `workshop-variant.txt` into the package, which `_app.py` turns into disabled plugins, and `_variant_exclusions` leaves the matching modules out of the Nuitka build. `NoWorkshop` omits PxSteamDL, the Downloads tab and both browsers (CI syncs it with `--no-install-package pxsteamdl`); `SteamWorkshop` omits the native browser; `NativeWorkshop` omits the web browser and WebEngine. CI builds all three on every OS. Build jobs set `UV_NO_SYNC=1` so `uv run` preserves the explicitly synced variant environment; both Workshop variants sync with `--extra steam`. +Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `src/app/` does. +- Base classes: `px::core::Plugin` (`BasicPlugin`) and `px::ui::UiPlugin` (`BasicPlugin`), from `core/plugin.h` and `ui/context.h`. Override `setup(Ctx&)` (synchronous, dependency order), `QCoro::Task init(Ctx&)` (awaited right after setup) and `shutdown()` (reverse order). Dependencies are passed to the constructor; the registry rejects unknown ones. `registerPlugin` with an existing name replaces the plugin in place. +- Settings: `core::PluginConfig(&ctx.configService(), "name", {{"old_config_key", "field"}})` in `setup()` → `plugins/.json`. Read `value()`; write with `update(T)`; subscribe to `changed`. The legacy map seeds the file the first time it is created. +- Settings UI: a UI plugin calls `app.addSettingsSection(factory)` (one Settings tab each). Subclass `ui::PluginConfigSection` for a typed settings struct, or `PluginConfigSectionBase` (`ui/settings_section.h`) with `title()` and `source()`. The QML root has `required property QtObject section`, reads `section.initial` and calls `section.set(key, value)`. Edits apply on Save only. +- Views: `RailView` subclasses declare `static constexpr RailViewMeta kRailMeta{viewId, iconName, label}` (optionally `listsDownloads = true` for the download queue) and are registered with `app.addRailView()` in `registerUiPlugins`, before the plugins that read the rail. `openRoute(const QStringList&)` handles `modrim:///…`. +- `SteamDownloaderUiPlugin` (`ui/plugins/steam_downloader/`) holds the Steam download settings section. + +## Build variants +- `PX_WORKSHOP_VARIANT` (CMake cache, `NoWorkshop | SteamWorkshop | NativeWorkshop`, default `SteamWorkshop`): `cmake --preset default -DPX_WORKSHOP_VARIANT=NativeWorkshop`. +- Every build writes `workshop-variant.txt` beside the executable (build tree via a POST_BUILD copy; install via `install(FILES)`). `px::app::parseDisabledPlugins` (`src/app/plugins.cpp`) turns it into disabled plugins, on top of `PX_DISABLED_PLUGINS`. A missing file bakes nothing. +- `NoWorkshop` skips PxSteamDL (no `PX_HAS_STEAM`, no Steam downloader), omits the Downloads tab and both browsers, and never looks for WebEngine. +- `SteamWorkshop` adds the WebEngine browser and omits the native browser. +- `NativeWorkshop` omits the web browser and WebEngine. +- Code uses the compile-time gates, not variant names: `PX_HAS_STEAM` (public define of `pxcore` when PxSteamDL is found) and `PX_HAS_WEBENGINE` (public define of `pxui` when `Qt6::WebEngineQuick` is found, which only happens for SteamWorkshop). +- CI builds and tests all three variants on every OS; the release workflow packages all three. ## Key conventions -- `from __future__ import annotations` in every `.py` file +- C++23, `.clang-format` (Google base, 4-space indent, 100 columns). The root sets `-Wall -Wextra -Wpedantic`. - No comments unless explaining _why_. _What_ comments allowed for long blocks. -- **Core must never know about UI.** Plugins must not know about layers. -- Async signal handlers need `@asyncSlot()` from `qasync` -- Never `QApplication.processEvents()`, `dialog.exec()`, `QThread`, `time.sleep()`, `QTimer.singleShot(0, ...)` -- use `await asyncio.to_thread()`, `await await_dialog()`, `await asyncio.sleep(0)` -- Never global singletons -- constructor DI everywhere -- Long blocking work -> `await asyncio.to_thread(target)` -- Git renames: `git mv`, never `shutil.move` -- Always ask user about Qt/QML widget type choice. -- Never orphan Qt objects -- always pass parent. -- Always make commits on the go -- never leave working tree uncommitted at milestones or task completion. +- Never global singletons — constructor DI everywhere. +- Never orphan Qt objects — always pass a parent. +- Errors: functions return `core::Result` (`std::expected`); use `std::unexpected(core::Error{...})`. +- Logging: spdlog. Log file `logs/pxmodrim.log` under the config directory; `PX_LOG_LEVEL` sets the level. +- Settings are JSON through glaze (`ConfigService`, `PluginConfig`): `config.json` and `plugins/.json` in the config directory. +- Git renames: `git mv`. +- Ask the user before picking a Qt/QML widget type. +- Always make commits on the go — never leave the working tree uncommitted at milestones or task completion. + +## Coroutines +- Async functions return `QCoro::Task`. Wait with `co_await`: + - network replies: `co_await qCoro(reply).waitForFinished()` + - signals: `co_await qCoro(obj, &Class::signal)` + - modal dialogs: `co_await awaitDialog(args...)` (`ui/components/dialogs.h`), never `exec()` + - delays: `co_await QCoro::sleepFor(duration)` + - QML-invokable coroutines return `QCoro::QmlTask` +- Long blocking work runs on a pool (`px::core::providers::runOnPool` or `QtConcurrent::run`), and the coroutine `co_await`s the future. +- Never `QApplication::processEvents()`, `QDialog::exec()`, `QThread`, `sleep`/`sleep_for`, or `QTimer::singleShot(0, …)`. Wait for the event that matters instead of spinning or deferring by zero. +- `QCoro::waitFor` belongs only at the top of `main()`. +- GCC 16 ICE (`gimple-low.cc`, `lower_stmt`) was hit by a coroutine with `co_await` in both arms of a `?:`; write it as `if`/`else` assigning a local. ## TypeScript / JS build -Steam Workshop WebView injection uses TypeScript sources in `src/ui/plugins/steam_workshop/ts/`, bundled to `src/ui/plugins/steam_workshop/inject.js` via esbuild: +Steam Workshop WebView injection uses TypeScript sources in `src/ui/plugins/steam_workshop/ts/`, bundled to `src/ui/plugins/steam_workshop/inject.js` by `scripts/build-inject-js.py`, which runs: ``` -just build-js # npx esbuild ts/main.ts --bundle --format=iife --target=es2020 +npx --yes esbuild ts/main.ts --bundle --format=iife --outfile=inject.js --target=es2020 ``` -`build-js` runs as a prerequisite of `run`, `test`, `check`, and `build`. -Cached by mtime comparison -- only rebuilds when `ts/` sources change. -Output `inject.js` is gitignored. When present at configure/build time, it is embedded as a Qt resource; without it the web browser remains usable but has no injected download controls. - -## Layer dependency checker -`just check-deps` (`scripts/check-deps.py`) enforces strict import rules via pydeps. -Defines groups with explicit ALLOWED dependency matrices, including `core.workshop`. -If a new import violates boundaries, update `scripts/check-deps.py`'s `ALLOWED` dict. - -## CI matrix -- `ci.yml`: ubuntu-latest, windows-latest, macos-latest. `uv sync --locked --dev` -> `just check` -> `just test` -- Linux CI pre-installs: `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0`. Qt tests need `QT_QPA_PLATFORM=offscreen` -- `build.yml`: tag `v*` triggers Nuitka standalone + AppImage (Linux) / zip (Windows) - -## Packaging (Nuitka) -``` -just build # Nuitka standalone (debug) -just build-release # Nuitka + LTO + AppImage/zip -``` -Nuitka uses `--include-package-data=pxmodrim` -- generated `inject.js` is included automatically as long as it exists at build time (which `build-js` ensures). +- `just build-js` runs before `build`, `build-release`, `check` and therefore `test`. It is mtime-cached and rebuilds only when `ts/` changes. +- `inject.js` is gitignored. When it exists, `src/ui/CMakeLists.txt` embeds it as a resource (`plugins/*/inject.js` is globbed with `CONFIGURE_DEPENDS`). Without it the web browser remains usable but has no injected download controls. ## Testing -- Config: `--import-mode=importlib`, `--no-qt-log`, `pythonpath = 'src'`, `testpaths = ['tests']` -- Test tree mirrors `src/pxmodrim/` -- Single test: `uv run pytest tests/test_metadata/test_structures.py -v` -- Mock providers by subclassing `BaseModProvider` +- Catch2 3.8.1 (CPM). `tests/core` → `pxcore_tests`, `tests/ui` → `pxui_tests`. `catch_discover_tests` registers every case with ctest. +- `tests/ui/main.cpp` owns the single `QApplication` for the UI binary (offscreen). Qt allows one per process. +- Run everything: `just test` (`QT_QPA_PLATFORM=offscreen ctest --preset default`). One case: `build/default/tests/pxui_tests "case name"`, or `ctest --preset default -R `. +- The test tree mirrors `src/`. Fake sources subclass `Downloader` (see `FakeDownloader` in `tests/core/downloads/test_manager.cpp`); providers are mocked by subclassing `providers::BaseModProvider`. +- Tests that need a variant feature are wrapped in `#ifdef PX_HAS_STEAM` / `PX_HAS_WEBENGINE`, as `tests/core/downloads/test_steam.cpp` is. ## QML / SVG quirks -- Icons via `image://icons/?color=` -- `SvgIconProvider` on shared `QQmlEngine` -- **Color URL encoding**: QML must `encodeURIComponent(color)`. Provider does `urllib.parse.unquote`. -- `QQuickImageProvider.Pixmap` works at runtime; pyright flags false positive. -- QML files sit next to their Python panel +- QML files sit directly in `src/ui/` (the module root, URI `PxModRim`). Qt does not reliably register types from subfolders, and plain type names resolve only within one flat module. +- Resource paths: `:/qt/qml/PxModRim/theme/style.qss`, `:/qt/qml/PxModRim/assets/*`, `:/qt/qml/PxModRim/plugins//inject.*`. +- Icons come from `image://icons/?color=`, served by `SvgIconProvider` (`ui/components/svg_provider.h`) on the shared `QQmlEngine`. +- **Color URL encoding**: QML must `encodeURIComponent(color)`. The provider decodes with `QUrl::fromPercentEncoding`. +- `main.cpp` keeps `Q_IMPORT_QML_PLUGIN(PxModRimPlugin)` and sets `QT_QUICK_CONTROLS_STYLE=Basic` by default. ## Companion mods C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): -- `companion-mods/PxLoadingProgress` -- Harmony-patching loading progress mod -- `companion-mods/rimworld-utils/` -- shared C# build infrastructure (from ilyvion/rimworld-utils) +- `companion-mods/PxLoadingProgress` — Harmony-patching loading progress mod +- `companion-mods/rimworld-utils/` — shared C# build infrastructure (from ilyvion/rimworld-utils) ## Other source trees -- `cf-workers/` -- Cloudflare Workers (Steam Workshop dep resolver, separate deploy) -- `rimsort-original/` -- vendored original RimSort reference (excluded from linting) +- `cf-workers/` — Cloudflare Workers (Steam Workshop dependency resolver, separate deploy); checked by the `workshop-backend` CI job. -## Stale / needs attention -- `core/loading.py` -- `LoadingState` QObject, planned move to `ui/progress.py`. Three files still import from `core/loading`. -- `core/models/view/` -- view models living in `core/`; potential future move to `ui/`. - -## For AI PRs +## CI matrix +- `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.11.0 (`jurplel/install-qt-action`, WebEngine modules only for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. +- Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md`. +- Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. + +## Packaging +Release builds run on `v*` tags (`build.yml`). Each variant runs `cmake --build --preset release` and `cmake --install`, then: +- **Linux**: install into `dist/AppDir/usr`; linuxdeploy + linuxdeploy-plugin-qt write the AppImage and deploy Qt into the same tree; `PxModRim-Linux-.tar.gz` is `usr/`; `.deb` (`/opt/pxmodrim`, `/usr/bin/pxmodrim` symlink) and `.rpm` (`packaging/linux/pxmodrim.spec`) are built from the same tree. The Flatpak manifest (`packaging/flatpak/`) consumes the tarball. +- **Windows**: install into `dist/PxModRim/bin`, rename to `PxModRim.exe`, `windeployqt --release --no-translations --qmldir src/ui`, then the NSIS installer (`packaging/windows/installer.nsi`) and the release zip. +- **macOS**: install as `pxmodrim.app`, rename to `PxModRim.app`, `macdeployqt -qmldir=src/ui`, `dylibbundler` for Homebrew dylibs. Developer ID signing and notarization run only when the secrets are set; otherwise the app is ad-hoc signed. +- The version comes from the tag for artifact names; keep `project(VERSION)` in the root CMakeLists in step with it. + +## AI PRs PR description starts with: "Greetings, PyXiion! The silicon-based contributor is here." PR title starts with: "🦀" Omission = auto-rejected. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f63d0..8d2f6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- Rewrote PxModRim in C++23 on Qt 6 (Qt Quick UI, QCoro coroutines) with CMake, replacing the Python/PySide6 implementation. User-visible behaviour is unchanged. Building from source now needs CMake 3.24+, Qt 6.8+ (CI uses 6.11), QCoro, pugixml, libzip and spdlog, see `AGENTS.md`; release packages are built from CMake install rules and Qt deployment tools. - Project license changed from LGPL-3.0 to GPL-3.0-or-later. Parts of earlier releases were derived from RimSort (GPL-3.0); `NOTICE` previously described RimSort as MIT-licensed, which was incorrect and is fixed. - Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. diff --git a/CLAUDE.md b/CLAUDE.md index bbb0dff..56d88cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,40 +6,35 @@ Full agent guide lives in [`AGENTS.md`](./AGENTS.md) — read it too, it has mor ## Commands -- `just fix` — ruff lint fix + format (fastest edit cycle) -- `just check` — ruff fix -> `build-js` -> pyright -> `check-deps` (matches CI's check step) -- `just test` — `build-js` + `dev-setup` -> `uv run pytest --doctest-modules --no-qt-log -s` -- `just ci` — `check` then `test` (full CI pipeline) -- `just run` — `build-js` + `dev-setup` -> `LOGURU_LEVEL=DEBUG uv run python -m pxmodrim` -- Single test: `uv run pytest tests/test_metadata/test_structures.py -v` -- `just bench ` — run `tests/benchmarks/bench_*.py` (name filters by substring, or omit for all) -- `just build-js` — bundles Steam Workshop TS injection (`ui/plugins/steam_workshop/ts/`) to `inject.js` via esbuild; gitignored, must build before running; mtime-cached +- `just format` — clang-format over `src/` and `tests/` (fastest edit cycle) +- `just check` — `format-check` → `build` → `build-js` +- `just test` — build, then `QT_QPA_PLATFORM=offscreen ctest --preset default` +- `just ci` — `check` then `test` +- `just configure` / `just build` / `just run` — configure the `default` preset, build it, run `build/default/src/app/pxmodrim` with `PX_LOG_LEVEL=debug` +- `just build-release` — `release` preset +- Single test case: `build/default/tests/pxui_tests "case name"` +- `just build-js` — bundles the Steam Workshop TS injection (`src/ui/plugins/steam_workshop/ts/`) to `inject.js` via esbuild; gitignored; mtime-cached ## Architecture -- Entry chain: `__main__.py` -> `_app.py` (`App().run()`, DI composition root, Fusion style + QPalette) -- `core/` — all domain logic, never imports `ui/` -- `ui/` — Qt widgets + QML, imports `core/` freely -- Layer boundaries are enforced by `scripts/check-deps.py` (`just check-deps`), which defines 23 groups with explicit allowed-dependency matrices via pydeps. A new cross-layer import that violates the rules must update that file's `ALLOWED` dict, not bypass the checker. -- Plugin system: plugins (e.g. `ui/plugins/steam_workshop/`) must not know about UI layers; layers must not know about plugins. -- QML files sit next to their Python panel (e.g. `ui/components/Header.qml` next to `header_panel.py`). -- Icons served via `image://icons/?color=` through `SvgIconProvider` on the shared `QQmlEngine`. QML must `encodeURIComponent(color)`; the provider does `urllib.parse.unquote`. -- Test tree mirrors `src/pxmodrim/`. Mock providers by subclassing `BaseModProvider`. -- `companion-mods/` — separate C# RimWorld mods (own `AGENTS.md`); `cf-workers/` — Cloudflare Workers deploy; `rimsort-original/` — vendored reference, excluded from lint/type-check. +- Entry chain: `src/app/main.cpp` (`runApp()`, DI composition root) → `src/app/plugins.cpp` (the only list of concrete plugins) +- `src/core/` (`pxcore`) — all domain logic, no GUI dependency, never includes `ui/` +- `src/ui/` (`pxui` + QML module `PxModRim`) — Qt Quick views, widgets, plugins; uses `core/` freely +- Plugins live under `src/ui/plugins/` and `src/core/downloads|workshop/`; they must not depend on the shell, and the shell must not name them +- QML files sit directly in `src/ui/` (flat module root); icons come from `image://icons/?color=`, with QML calling `encodeURIComponent(color)` +- `companion-mods/` — separate C# RimWorld mods (own `AGENTS.md`); `cf-workers/` — Cloudflare Workers deploy; `legacy/` — read-only Python reference ## Conventions -- `from __future__ import annotations` in every `.py` file. - No comments unless explaining *why*; brief *what* comments allowed only for long blocks. -- Async signal handlers need `@asyncSlot()` from `qasync`. -- Never `QApplication.processEvents()`, `dialog.exec()`, `QThread`, `time.sleep()`, `QTimer.singleShot(0, ...)` — use `await asyncio.to_thread()`, `await await_dialog()`, `await asyncio.sleep(0)`. +- Coroutines: `QCoro::Task` with `co_await`; never `QApplication::processEvents()`, `QDialog::exec()`, `QThread`, `sleep`, or `QTimer::singleShot(0, ...)` — use `co_await awaitDialog(...)`, `co_await QCoro::sleepFor(...)`, or run blocking work on a pool and `co_await` it. +- Avoid structured bindings and `co_await` inside ternaries in coroutines (GCC ICE). - Never global singletons — constructor DI everywhere. -- Long blocking work -> `await asyncio.to_thread(target)`. -- Git renames: `git mv`, never `shutil.move`. - Never orphan Qt objects — always pass a parent. +- Git renames: `git mv`. - Ask the user before picking a Qt/QML widget type. +- Always make commits on the go. ## Known stale areas -- `core/loading.py`'s `LoadingState` QObject is planned to move to `ui/progress.py`; three files still import from `core/loading`. -- `core/models/view/` holds view models that may move to `ui/`. +- The Python-era phases in `ROADMAP.md` describe the old implementation; their commands are historical. diff --git a/CMakeLists.txt b/CMakeLists.txt index 03c0f50..68bd5cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,7 +45,10 @@ if(PX_BUILD_TESTS) list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") endif() -include(cmake/Steam.cmake OPTIONAL) +# NoWorkshop ships without Steam downloads, so PxSteamDL is not fetched or linked. +if(NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") + include(cmake/Steam.cmake OPTIONAL) +endif() add_compile_options(-Wall -Wextra -Wpedantic) diff --git a/README.md b/README.md index 05244b0..078a93a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

    - PxModRim + PxModRim

    A friendly, modern mod manager for RimWorld. NOw with fast Steam downloading

    - Python 3.12 + C++23 License: GPL-3.0 Codacy grade Platforms diff --git a/ROADMAP.md b/ROADMAP.md index 31b81c3..2128744 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,8 @@ acceptance-criteria-driven plan behind it. It reflects the state of the reposito `PyXiion/PxModRim` GitHub tracker as observed on 2026-09-21. Re-verify issue/PR/milestone state before treating any of the numbers below as current. +> Historical note: the phases below were written for the Python/PySide6 implementation (`uv`, `just test` via pytest, Nuitka). The project now builds with CMake and `just`; see `AGENTS.md`. Commands quoted in the phase text are kept as they were written. + ## Current state snapshot (2026-09-21) - Project version: `0.1.0` (`pyproject.toml`). diff --git a/cmake/Steam.cmake b/cmake/Steam.cmake index 787ac02..886dca8 100644 --- a/cmake/Steam.cmake +++ b/cmake/Steam.cmake @@ -1,6 +1,6 @@ # Steam Workshop downloads via PxSteamDL. The root CMakeLists includes this file -# OPTIONALLY: without it the build has no Steam support and downloads/steam.cpp -# is compiled out (PX_HAS_STEAM stays undefined). +# unless PX_WORKSHOP_VARIANT is NoWorkshop. Without it the build has no Steam support +# and downloads/steam.cpp is compiled out (PX_HAS_STEAM stays undefined). # PxSteamDL builds all of its dependencies from source. Its cacert dependency # uses a date as VERSION, which CPM rejects as a find_package() name when local diff --git a/justfile b/justfile index 9f68cbf..203337f 100644 --- a/justfile +++ b/justfile @@ -1,82 +1,44 @@ -ruff_config := "--config pyproject.toml" -pytest_opts := "--doctest-modules --no-qt-log" +set windows-shell := ["bash", "-cu"] + +python := if os() == "windows" { "python" } else { "python3" } @default: just --list -build-js: - uv run python scripts/build-inject-js.py - -run: build-js dev-setup - LOGURU_LEVEL=DEBUG uv run python -m pxmodrim - -test: build-js dev-setup - QT_QPA_PLATFORM=offscreen uv run pytest {{pytest_opts}} -s +configure *args: + cmake --preset default {{args}} -test-verbose: build-js dev-setup - QT_QPA_PLATFORM=offscreen uv run pytest {{pytest_opts}} -v --tb=short -s - -pyright: - uv run python -m pyright -p pyproject.toml src/ tests/ +build *args: build-js + [ -f build/default/CMakeCache.txt ] || cmake --preset default + cmake --build --preset default {{args}} -ruff-fix: - uv run ruff check {{ruff_config}} src/ tests/ scripts/ packaging/ --fix +run *args: build + PX_LOG_LEVEL=debug build/default/src/app/pxmodrim {{args}} -ruff-format-fix: - uv run ruff format {{ruff_config}} src/ tests/ scripts/ packaging/ +test: build + QT_QPA_PLATFORM=offscreen ctest --preset default -ruff-check: - uv run ruff check {{ruff_config}} src/ tests/ scripts/ packaging/ +build-release: build-js + [ -f build/release/CMakeCache.txt ] || cmake --preset release + cmake --build --preset release -ruff-format-check: - uv run ruff format --check {{ruff_config}} src/ tests/ scripts/ packaging/ +build-js: + {{python}} scripts/build-inject-js.py -fix: ruff-fix ruff-format-fix +format: + find src tests \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format -i -check: ruff-check ruff-format-check build-js pyright check-deps +format-check: + find src tests \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format --dry-run --Werror -check-deps: - uv run python scripts/check-deps.py +check: format-check build build-js ci: check test -dev-setup: - uv venv --allow-existing - uv sync --dev - linux-copy-desktop: mkdir -p ~/.local/share/applications - sed "s|Icon=pxmodrim|Icon={{justfile_directory()}}/src/pxmodrim/ui/assets/logo.svg|" packaging/linux/pxmodrim.desktop > ~/.local/share/applications/pxmodrim.desktop + sed "s|Icon=pxmodrim|Icon={{justfile_directory()}}/src/ui/assets/logo.svg|" packaging/linux/pxmodrim.desktop > ~/.local/share/applications/pxmodrim.desktop kbuildsycoca6 --noincremental -bench name="": - #!/usr/bin/env python3 - import glob, subprocess, sys - from pathlib import Path - root = Path("{{justfile_directory()}}") / "tests" / "benchmarks" - scripts = sorted(glob.glob(str(root / "bench_*.py"))) - name = "{{name}}" - if name and name != "all": - scripts = [s for s in scripts if name in s] - if not scripts: - print("no benchmark found"); sys.exit(1) - for s in scripts: - print(f" === {Path(s).stem} ===") - subprocess.run(["uv", "run", "python", s]) - -build *args: build-js - uv run python packaging/build.py {{args}} - -build-release *args: build-js - uv run python packaging/build.py --release {{args}} - -build-release-bundled *args: build-js - uv run python packaging/build.py --release --bundle-qt {{args}} - -build-clean: - rm -rf build/ dist/ *.build/ *.dist/ *.onefile-build/ - clean: - rm -rf build/ dist/ *.egg-info - rm -rf .pytest_cache .mypy_cache .ruff_cache - find . -path ./rimsort-original -prune -o -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + rm -rf build dist diff --git a/legacy/pxmodrim/__init__.py b/legacy/pxmodrim/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/__main__.py b/legacy/pxmodrim/__main__.py deleted file mode 100644 index 54f585f..0000000 --- a/legacy/pxmodrim/__main__.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -import sys - -from pxmodrim._app import App - - -def main() -> None: - """Entry point: instantiate App and run the Qt event loop.""" - app = App() - sys.exit(app.run()) - - -if __name__ == "__main__": - main() diff --git a/legacy/pxmodrim/_app.py b/legacy/pxmodrim/_app.py deleted file mode 100644 index a7be82a..0000000 --- a/legacy/pxmodrim/_app.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import sys -import traceback -from importlib.resources import files as resource_files -from types import TracebackType - -from loguru import logger -from PySide6.QtGui import QColor, QIcon, QPalette -from PySide6.QtWidgets import QApplication, QDialog, QMessageBox -from qasync import QEventLoop - -# Qt6 defaults to PassThrough, which causes QtWebEngine to render at -# integer buffer-scale (e.g. 1×) while the compositor upscales fractionally -# (e.g. 1.25×, 1.5×) – results in pixelated content. -# Rounding down (< 0.75) avoids the blurred upscale at the cost of smaller UI; -# this matches the workaround used by Anki and qutebrowser for QTBUG-113574. -os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor") -os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") - -_we_verbose = os.environ.get("PX_WEBENGINE_VERBOSE") -if _we_verbose is not None: - os.environ.setdefault( - "QTWEBENGINE_CHROMIUM_FLAGS", - f"--enable-logging --v={_we_verbose}", - ) - os.environ.setdefault( - "QT_LOGGING_RULES", - "qt.webenginecontext.*=true", - ) - -from pxmodrim.core.config import ( # noqa: E402 - AppConfig, - AppConfigService, - ConfigService, - config_dir, - detect_game_paths, -) -from pxmodrim.core.context import CoreContext # noqa: E402 -from pxmodrim.ui.components.dialogs import await_dialog # noqa: E402 -from pxmodrim.ui.components.popup_parent import QuickPopupParenting # noqa: E402 -from pxmodrim.ui.config import UIPrefsService # noqa: E402 -from pxmodrim.ui.context import AppContext # noqa: E402 -from pxmodrim.ui.panels.settings_panel import SettingsPanel # noqa: E402 -from pxmodrim.ui.theme.palette import PALETTE, get_stylesheet # noqa: E402 -from pxmodrim.ui.ui_prefs import UIPrefs # noqa: E402 -from pxmodrim.ui.views.mods_view import ModsViewPanel # noqa: E402 -from pxmodrim.ui.window.main_window import MainWindow # noqa: E402 - - -def _exception_hook( - exc_type: type[BaseException], - exc_value: BaseException, - exc_tb: TracebackType | None, -) -> None: - """Global exception handler for unhandled exceptions.""" - if exc_type is KeyboardInterrupt: - return - - error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) - logger.error("Unhandled exception:\n{}", error_msg) - app = QApplication.instance() - if app is not None: - QMessageBox.critical( - None, - "PxModRim - Crash", - f"An unexpected error occurred:\n\n" - f"{exc_type.__name__}: {exc_value}\n\n" - "Details have been logged.\n" - "You can upload diagnostics and logs via Help > Upload Log & System Info.", - ) - - -def _async_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: - """Handle unhandled exceptions in async tasks.""" - exc = context.get("exception") - if exc: - logger.error("Unhandled async exception: {}", exc, exc_info=exc) - else: - logger.error("Unhandled async error: {}", context.get("message")) - - -sys.excepthook = _exception_hook - - -def _configure_file_logging() -> int: - logs_dir = config_dir() / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - return logger.add( - logs_dir / "pxmodrim.log", - level=os.environ.get("LOGURU_LEVEL", "INFO"), - rotation="10 MB", - retention=5, - serialize=True, - format="{time} | {level} | pid={process.id} | {module}:{line} | {message}", - ) - - -def _parse_disabled_plugins() -> set[str]: - disabled_raw = os.environ.get("PX_DISABLED_PLUGINS", "") - disabled = {n.strip() for n in disabled_raw.split(",") if n.strip()} - manifest = resource_files("pxmodrim") / "workshop-variant.txt" - if manifest.is_file(): - exclusions = { - "NoWorkshop": { - "steam_downloader", - "steamworkshop", - "downloads_ui", - "workshop_catalog", - "workshop_ui", - }, - "NativeWorkshop": {"steamworkshop"}, - "SteamWorkshop": {"workshop_catalog", "workshop_ui"}, - } - disabled.update(exclusions[manifest.read_text(encoding="utf-8").strip()]) - return disabled - - -_WHEEL_SCROLL_LINES = 8 - - -class App: - """Top-level application class wiring together Qt, services, and the main window.""" - - __slots__ = ( - "_app_ctx", - "_ctx", - "_disabled_plugins", - "main_window", - "qt_app", - ) - - def __init__(self) -> None: - _configure_file_logging() - self._disabled_plugins = _parse_disabled_plugins() - if ( - "steamworkshop" not in self._disabled_plugins - and "steam_downloader" not in self._disabled_plugins - ): - from PySide6.QtWebEngineQuick import QtWebEngineQuick - - QtWebEngineQuick.initialize() - - self._ctx: CoreContext | None = None - self.qt_app = QApplication(sys.argv) - self.qt_app.installEventFilter(QuickPopupParenting(self.qt_app)) - # async_run owns the exit: plugin shutdown awaits I/O after the window closes. - self.qt_app.setQuitOnLastWindowClosed(False) - icon = QIcon(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - self.qt_app.setWindowIcon(icon) - self.qt_app.setApplicationName("PxModRim") - self.qt_app.setOrganizationName("PxModRim") - self.qt_app.setApplicationDisplayName("PxModRim") - self.qt_app.setDesktopFileName("pxmodrim") - - # Honoured by widgets and Qt Quick alike; the default of 3 feels slow. - self.qt_app.styleHints().setWheelScrollLines(_WHEEL_SCROLL_LINES) - self._apply_theme() - - def _apply_theme(self) -> None: - """Set Fusion style, dark palette, and stylesheet.""" - self.qt_app.setStyle("Fusion") - - palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(PALETTE["ELEVATE_0"])) - palette.setColor(QPalette.ColorRole.WindowText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Base, QColor(PALETTE["ELEVATE_1"])) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.Text, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Button, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.ButtonText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.BrightText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Highlight, QColor(PALETTE["PRIMARY"])) - palette.setColor( - QPalette.ColorRole.HighlightedText, QColor(PALETTE["ELEVATE_0"]) - ) - palette.setColor(QPalette.ColorRole.Link, QColor(PALETTE["PRIMARY"])) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor(PALETTE["TEXT_MAIN"])) - self.qt_app.setPalette(palette) - - try: - self.qt_app.setStyleSheet(get_stylesheet()) - except (FileNotFoundError, KeyError) as exc: - logger.warning("Failed to load theme: {}", exc) - - def _setup( - self, - cfg: AppConfig, - config_svc: ConfigService, - ui_prefs: UIPrefs | None = None, - ) -> CoreContext: - """Initialize CoreContext, services, and the main window via constructor DI.""" - - ctx = CoreContext.create(cfg, config_svc) - self._ctx = ctx - self._app_ctx = AppContext(ctx, ui_prefs) - self._app_ctx.add_rail_view(ModsViewPanel) - - from pxmodrim.core.organizer import OrganizerDb, OrganizerService - from pxmodrim.core.organizer.db import db_path - - self._ctx.register_plugin( - OrganizerService(ctx, OrganizerDb(db_path(config_svc.config_dir))) - ) - from pxmodrim.ui.plugins.organizer import OrganizerUiPlugin - - self._app_ctx.register_plugin(OrganizerUiPlugin()) - - disabled = self._disabled_plugins - - downloaders: list[str] = [] - steam_enabled = "steam_downloader" not in disabled - if steam_enabled: - try: - from pxmodrim.core.downloads.steam import SteamDownloader - except ImportError: - logger.info("pxsteamdl is not installed; Steam downloads disabled") - steam_enabled = False - else: - self._ctx.register_plugin(SteamDownloader()) - downloaders.append("steam") - from pxmodrim.ui.plugins.steam_downloader import ( - SteamDownloaderUiPlugin, - ) - - self._app_ctx.register_plugin(SteamDownloaderUiPlugin()) - - if "steam" in downloaders and "steamworkshop" not in disabled: - from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopUiPlugin - - self._app_ctx.register_plugin(SteamWorkshopUiPlugin()) - - # The Downloads tab is source-agnostic: shown whenever any downloader exists. - if downloaders and "downloads_ui" not in disabled: - from pxmodrim.ui.plugins.downloads import DownloadsUiPlugin - - self._app_ctx.register_plugin(DownloadsUiPlugin()) - - if downloaders and "workshop_catalog" not in disabled: - from pxmodrim.core.workshop import WorkshopCatalog - - self._ctx.register_plugin(WorkshopCatalog()) - if "workshop_ui" not in disabled: - from pxmodrim.ui.plugins.workshop import WorkshopUiPlugin - - self._app_ctx.register_plugin(WorkshopUiPlugin()) - - self._app_ctx.setup_all() - self.main_window = MainWindow(self._app_ctx) - return ctx - - async def async_run(self) -> int: - """Run the app: migrate config, create services, open window, run event loop.""" - - logger.info("Starting PxModRim") - - # Phase 1: config migration - config_svc = ConfigService(config_dir()) - app_cfg_svc = AppConfigService(config_svc) - ui_prefs_svc = UIPrefsService(config_svc) - - try: - await app_cfg_svc.setup() - await ui_prefs_svc.setup() - except Exception: # noqa: BLE001 - configuration migration boundary - logger.exception("Failed to initialize configuration") - QMessageBox.critical( - None, - "PxModRim - Configuration Error", - "Failed to initialize configuration.\nCheck the logs for details.\n", - ) - return 1 - - cfg = app_cfg_svc.cfg() - ui_prefs = ui_prefs_svc.prefs() - logger.info( - "Configuration loaded: game={}, local={}, workshop={}", - cfg.paths.game or "", - cfg.paths.local or "", - cfg.paths.workshop or "", - ) - - if not cfg.paths.game: - logger.info("No game path in config, attempting auto-detect") - detected = detect_game_paths() - if detected.game: - cfg.paths = detected - app_cfg_svc.save_cfg() - - # Phase 2: service initialization - ctx = self._setup(cfg, config_svc, ui_prefs) - logger.info("Services initialized for game version {}", ctx.game_version) - - app_close_event = asyncio.Event() - self.qt_app.aboutToQuit.connect(app_close_event.set) - self.main_window.set_app_quit_callback(app_close_event.set) - self.main_window.show() - - if not ctx.config.paths.game: - logger.info("No game path found, showing settings dialog") - result, dialog = await await_dialog( - SettingsPanel, - ctx, - self.main_window.qml_engine, - self.main_window, - self._app_ctx.settings_sections, - ) - if app_close_event.is_set(): - await self._app_ctx.shutdown_all() - return 0 - new_cfg = dialog.get_config() - if result != QDialog.DialogCode.Accepted or not new_cfg.paths.game: - logger.warning("No game path configured, exiting") - return 1 - ctx.config_service.save("config.json", new_cfg) - ctx.update_config(new_cfg) - ctx.reset_providers(new_cfg.paths) - - mod_count = await self._app_ctx.refresh_mods() - if not app_close_event.is_set(): - logger.info("Initial mod load complete: {} mods", mod_count) - await self._app_ctx.init_all() - self.main_window.start_startup_update_check() - await app_close_event.wait() - await self._app_ctx.shutdown_all() - return 0 - - def run(self) -> int: - """Create a qasync event loop and run the application synchronously.""" - loop = QEventLoop() - loop.set_exception_handler(_async_exception_handler) - try: - return asyncio.run(self.async_run(), loop_factory=lambda: loop) - finally: - if self._ctx is not None: - self._ctx.close_sync() diff --git a/legacy/pxmodrim/core/__init__.py b/legacy/pxmodrim/core/__init__.py deleted file mode 100644 index abb57f7..0000000 --- a/legacy/pxmodrim/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.structures import CollectionStats - -__all__ = ["CollectionStats", "CoreContext", "ModService"] diff --git a/legacy/pxmodrim/core/checker/__init__.py b/legacy/pxmodrim/core/checker/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/legacy/pxmodrim/core/checker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/legacy/pxmodrim/core/checker/checker.py b/legacy/pxmodrim/core/checker/checker.py deleted file mode 100644 index 07df9ef..0000000 --- a/legacy/pxmodrim/core/checker/checker.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any - -from loguru import logger - -from pxmodrim.core.checker.graph import ConstraintGraph -from pxmodrim.core.checker.models import ( - CheckContext, - ModDiagnostics, - ModIssue, - PackageId, -) -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - -if TYPE_CHECKING: - from ttimer import Timer - - from pxmodrim.core.checker.issues import ModIssueChecker - from pxmodrim.core.sort.community import CommunityRule - from pxmodrim.core.sort.config import SortSettings - - -class ModChecker: - """Orchestrates diagnostic checks across active mods.""" - - __slots__ = ( - "_active_mods", - "_all_mods", - "_cached_cycles", - "_checker_results", - "_checkers", - "_community_rules", - "_diagnostics", - "_graph", - "_no_version_warning", - "_on_diagnostics_changed", - "_ordered_pids", - "_settings", - "_target_version", - "_use_this_instead", - "_uuid_to_pid", - ) - - def __init__( - self, - checkers: list[ModIssueChecker], - settings: SortSettings, - target_version: str = "1.5", - on_diagnostics_changed: Callable[[dict[str, ModDiagnostics]], Any] - | None = None, - ) -> None: - self._checkers = checkers - self._settings = settings - self._target_version = target_version - self._on_diagnostics_changed = on_diagnostics_changed - - self._graph = ConstraintGraph() - self._all_mods: dict[str, ListedMod] = {} - self._diagnostics: dict[PackageId, ModDiagnostics] = {} - self._active_mods: dict[PackageId, AboutXmlMod] = {} - self._uuid_to_pid: dict[str, PackageId] = {} - self._ordered_pids: list[PackageId] = [] - - self._no_version_warning: set[PackageId] = set() - self._use_this_instead: Mapping[str, Any] = {} - self._community_rules: dict[PackageId, CommunityRule] | None = None - self._cached_cycles: list[list[PackageId]] = [] - # Per-mod issues from each checker (checker order), reused by reorder() - # for order-independent checkers; None when stale. - self._checker_results: dict[PackageId, list[list[ModIssue]]] | None = None - - # ── Database config ─────────────────────────────────────── - - def set_no_version_warning(self, pids: set[PackageId]) -> None: - self._no_version_warning = pids - self._checker_results = None - - def set_use_this_instead(self, db: Mapping[str, Any]) -> None: - self._use_this_instead = db - self._checker_results = None - - def set_community_rules(self, rules: dict[PackageId, CommunityRule] | None) -> None: - self._community_rules = rules - - # ── Rebuild ─────────────────────────────────────────────── - - def rebuild( - self, - mods: dict[str, ListedMod], - ordered_uuids: list[str], - timer: Timer | None = None, - ) -> None: - """Re-scan all mods and regenerate diagnostics from scratch.""" - from ttimer import Timer - - t = timer or Timer() - - with t("collect_active"): - self._collect_active(mods, ordered_uuids) - - with t("graph.build"): - self._graph.build( - self._active_mods, - self._ordered_pids, - self._settings, - self._community_rules, - ) - - with t("find_cycles"): - self._cached_cycles = self._graph.find_cycles() - ctx = self._build_context(self._cached_cycles) - - with t("check_mod"): - self._check_all(ctx) - - self._emit() - - # ── Incremental updates ─────────────────────────────────── - - def toggle_mod( - self, - mod: ListedMod, - active: bool, - ordered_uuids: list[str], - ) -> None: - """Rebuild constraints and diagnostics after toggling a mod.""" - if not isinstance(mod, AboutXmlMod): - self._emit() - return - - self.rebuild(self._all_mods, ordered_uuids) - - def move_mod(self, uuid: str, old_index: int, new_index: int) -> None: - """Move a mod to new position and recheck diagnostics for neighbors.""" - if not self._ordered_pids: - return - - pid = self._uuid_to_pid.get(uuid) - if pid is None: - return - - self._ordered_pids.insert(new_index, self._ordered_pids.pop(old_index)) - self._graph.update_order(self._ordered_pids) - - ctx = self._build_context(self._cached_cycles) - # Only neighbours are rechecked, so cached results no longer match the order. - self._checker_results = None - - affected = {pid} - affected.update(self._graph.neighbors(pid)) - for current_pid in affected: - if current_pid in self._active_mods: - current_mod = self._active_mods[current_pid] - diag = self._check_mod(current_mod, ctx) - self._diagnostics[current_pid] = diag - - self._emit() - - def reorder(self, ordered_uuids: list[str]) -> None: - """Reapply the full active-mod order and regenerate all diagnostics.""" - previous = self._active_mods - previous_index = self._graph.pid_to_index - self._collect_active(self._all_mods, ordered_uuids) - self._graph.update_order(self._ordered_pids) - - cycles = self._graph.find_cycles() - ctx = self._build_context(cycles) - - results = self._checker_results - if results is None or not _same_mods(previous, self._active_mods): - self._check_all(ctx) - else: - moved = { - pid - for pid, index in ctx.pid_to_index.items() - if previous_index.get(pid) != index - } - changed: set[PackageId] = set() - for i, checker in enumerate(self._checkers): - if not checker.order_dependent: - continue - affected = checker.affected_by_moves(ctx, moved) - for pid, mod in self._active_mods.items(): - if affected is not None and pid not in affected: - continue - issues = self._run_checker(checker, mod, ctx) - if issues != results[pid][i]: - results[pid][i] = issues - changed.add(pid) - for pid in changed: - self._diagnostics[pid] = _compose(results[pid]) - - self._emit() - - # ── Query ───────────────────────────────────────────────── - - @property - def active_mods(self) -> dict[PackageId, AboutXmlMod]: - return dict(self._active_mods) - - @property - def graph(self) -> ConstraintGraph: - return self._graph - - @property - def ordered_pids(self) -> list[PackageId]: - return list(self._ordered_pids) - - @property - def community_rules(self) -> dict[PackageId, CommunityRule] | None: - return self._community_rules - - def diagnostics_for(self, uuid: str) -> ModDiagnostics | None: - """Return diagnostics for a given UUID, or None if not active.""" - pid = self._uuid_to_pid.get(uuid) - if pid is None: - return None - return self._diagnostics.get(pid) - - def active_mod_diagnostics(self) -> dict[str, ModDiagnostics]: - """Return diagnostics for all active mods, keyed by UUID.""" - result: dict[str, ModDiagnostics] = {} - for uuid, pid in self._uuid_to_pid.items(): - diag = self._diagnostics.get(pid) - if diag: - result[uuid] = diag - return result - - # ── Private ─────────────────────────────────────────────── - - def _collect_active( - self, mods: dict[str, ListedMod], ordered_uuids: list[str] - ) -> None: - """Populate internal active-mod state from full mod list and UUID ordering.""" - self._all_mods = dict(mods) - self._active_mods = {} - self._uuid_to_pid = {} - self._ordered_pids = [] - - for uuid in ordered_uuids: - mod = mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid = mod.package_id - if type(pid) is not PackageId: - pid = PackageId(pid) - self._active_mods[pid] = mod - self._uuid_to_pid[uuid] = pid - self._ordered_pids.append(pid) - - def _build_context( - self, - cycles: list[list[PackageId]], - ) -> CheckContext: - return CheckContext( - active_mods=self._active_mods, - ordered_pids=self._ordered_pids, - pid_to_index=self._graph.pid_to_index, - graph=self._graph, - settings=self._settings, - target_version=self._target_version, - no_version_warning=self._no_version_warning, - use_this_instead=self._use_this_instead, - cycles=cycles, - ) - - def _check_all(self, ctx: CheckContext) -> None: - """Run every checker on every active mod, caching per-checker results.""" - results: dict[PackageId, list[list[ModIssue]]] = {} - diagnostics: dict[PackageId, ModDiagnostics] = {} - checkers = self._checkers - run = self._run_checker - for pid, mod in self._active_mods.items(): - per_checker = [run(c, mod, ctx) for c in checkers] - results[pid] = per_checker - diagnostics[pid] = _compose(per_checker) - self._checker_results = results - self._diagnostics = diagnostics - - def _check_mod(self, mod: AboutXmlMod, ctx: CheckContext) -> ModDiagnostics: - """Run all registered checkers against a single mod and collect diagnostics.""" - return _compose([self._run_checker(c, mod, ctx) for c in self._checkers]) - - @staticmethod - def _run_checker( - checker: ModIssueChecker, mod: AboutXmlMod, ctx: CheckContext - ) -> list[ModIssue]: - if not checker.should_check(mod, ctx): - return [] - try: - issues = checker.check(mod, ctx) - except Exception: # noqa: BLE001 - one checker must not abort diagnostics - logger.exception( - f"Checker {type(checker).__name__} failed on {mod.package_id}" - ) - return [] - return issues if type(issues) is list else list(issues) - - def _emit(self) -> None: - if self._on_diagnostics_changed is not None: - uuid_diag: dict[str, ModDiagnostics] = {} - for uuid, pid in self._uuid_to_pid.items(): - diag = self._diagnostics.get(pid) - if diag and (diag.has_errors or diag.has_warnings): - uuid_diag[uuid] = diag - self._on_diagnostics_changed(uuid_diag) - - -def _compose(per_checker: list[list[ModIssue]]) -> ModDiagnostics: - issues = [issue for checker_issues in per_checker for issue in checker_issues] - if not issues: - return ModDiagnostics([], []) - return ModDiagnostics( - [issue for issue in issues if issue.severity == "error"], - [issue for issue in issues if issue.severity != "error"], - ) - - -def _same_mods( - a: dict[PackageId, AboutXmlMod], b: dict[PackageId, AboutXmlMod] -) -> bool: - return a.keys() == b.keys() and all(b[pid] is mod for pid, mod in a.items()) diff --git a/legacy/pxmodrim/core/checker/databases.py b/legacy/pxmodrim/core/checker/databases.py deleted file mode 100644 index eb6c030..0000000 --- a/legacy/pxmodrim/core/checker/databases.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import time -import zipfile -from collections.abc import Mapping -from pathlib import Path -from typing import TYPE_CHECKING - -import httpx -from loguru import logger - -from pxmodrim.core.checker.models import PackageId, ReplacementInfo -from pxmodrim.core.xml import xml_path_to_json - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - -NO_VERSION_WARNING_URL = ( - "https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip" -) -USE_THIS_INSTEAD_URL = ( - "https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip" -) - - -def _age_s(path: Path) -> float | None: - try: - return max(0.0, time.time() - path.stat().st_mtime) - except OSError: - return None - - -class NoVersionWarningService: - """Service to download and cache the NoVersionWarning package-ID list.""" - - __slots__ = ("_cache_dir", "_loaded_stamp", "_pids", "_xml_path") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._xml_path = self._cache_dir / "ModIdsToFix.xml" - self._pids: set[PackageId] = set() - self._loaded_stamp: tuple[int, int] | None = None - - async def ensure(self, force: bool = False) -> set[PackageId]: - """Return cached PIDs, or download and cache them if missing or forced.""" - if not force: - stamp = await asyncio.to_thread(_file_stamp, self._xml_path) - if stamp is not None: - if stamp != self._loaded_stamp: - self._pids = await asyncio.to_thread(self._load) - self._loaded_stamp = stamp - return self._pids - return await self._download() - - def cache_age_s(self) -> float | None: - return _age_s(self._xml_path) - - def load_if_exists(self) -> set[PackageId]: - """Load cached PIDs if the local file exists, otherwise return empty set.""" - if self._xml_path.exists(): - self._pids = self._load() - return self._pids - - def _load(self) -> set[PackageId]: - """Parse the cached XML file into a set of PackageIds.""" - try: - data = xml_path_to_json(str(self._xml_path)) - mod_ids = data.get("ModIdsToFix", {}).get("li", []) - if isinstance(mod_ids, str): - mod_ids = [mod_ids] - result: set[PackageId] = {PackageId(mid) for mid in mod_ids} - logger.info(f"Loaded {len(result)} No Version Warning entries") - return result - except (KeyError, TypeError, ValueError) as e: - logger.error(f"Failed to load No Version Warning DB: {e}") - return set() - - async def _download(self) -> set[PackageId]: - """Download, extract, and cache the NoVersionWarning database from GitHub.""" - self._cache_dir.mkdir(parents=True, exist_ok=True) - zip_path = self._cache_dir / "no_version_warning.zip" - extract_dir = self._cache_dir / "no_version_warning_extracted" - - try: - async with ( - httpx.AsyncClient() as client, - client.stream( - "GET", NO_VERSION_WARNING_URL, follow_redirects=True, timeout=30.0 - ) as resp, - ): - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - finally: - await asyncio.to_thread(fh.close) - - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - - for root, _dirs, files in extract_dir.walk(): - for filename in files: - if filename == "ModIdsToFix.xml": - src_path = root / filename - src_path.replace(self._xml_path) - self._pids = self._load() - # Cleanup - _rmtree(zip_path, extract_dir) - return self._pids - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - logger.warning(f"Failed to download No Version Warning DB: {e}") - - _rmtree(zip_path, extract_dir) - return set() - - -class UseThisInsteadService: - """Service to download and cache the UseThisInstead replacement database.""" - - __slots__ = ("_cache_dir", "_entries", "_json_path", "_loaded_stamp") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._json_path = self._cache_dir / "replacements.json.gz" - self._entries: dict[str, ReplacementInfo] = {} - self._loaded_stamp: tuple[int, int] | None = None - - async def ensure(self, force: bool = False) -> Mapping[str, ReplacementInfo]: - """Return cached replacement entries, download if missing or forced.""" - if not force: - stamp = await asyncio.to_thread(_file_stamp, self._json_path) - if stamp is not None: - if stamp != self._loaded_stamp: - self._entries = await asyncio.to_thread(self._load) - self._loaded_stamp = stamp - return self._entries - return await self._download() - - def cache_age_s(self) -> float | None: - return _age_s(self._json_path) - - def load_if_exists(self) -> Mapping[str, ReplacementInfo]: - """Load cached replacement entries if local file exists, else empty dict.""" - if self._json_path.exists(): - self._entries = self._load() - return self._entries - - def _load(self) -> dict[str, ReplacementInfo]: - """Parse cached JSON into a dict of old-Workshop-ID to ReplacementInfo.""" - try: - path = self._json_path - if str(path).endswith(".gz"): - import gzip - - with gzip.open(path, "rt", encoding="utf-8-sig") as f: - raw = json.load(f) - else: - with open(path, encoding="utf-8-sig") as f: - raw = json.load(f) - - rules = raw.get("rules", []) if isinstance(raw, dict) else [] - result: dict[str, ReplacementInfo] = {} - for r in rules: - if isinstance(r, dict) and "oldWorkshopId" in r: - result[str(r["oldWorkshopId"])] = ReplacementInfo( - name=r.get("newName", ""), - author=r.get("newAuthor", ""), - packageid=r.get("newPackageId", ""), - pfid=r.get("newWorkshopId", ""), - supportedversions=r.get("newVersions", []), - source="database", - ) - logger.info(f"Loaded {len(result)} Use This Instead entries") - return result - except Exception as e: # noqa: BLE001 - malformed cache falls back empty - logger.error(f"Failed to load Use This Instead DB: {e}") - return {} - - async def _download(self) -> dict[str, ReplacementInfo]: - """Download, extract, and cache the UseThisInstead database from GitHub.""" - self._cache_dir.mkdir(parents=True, exist_ok=True) - zip_path = self._cache_dir / "use_this_instead.zip" - extract_dir = self._cache_dir / "use_this_instead_extracted" - - try: - async with ( - httpx.AsyncClient() as client, - client.stream( - "GET", USE_THIS_INSTEAD_URL, follow_redirects=True, timeout=30.0 - ) as resp, - ): - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - finally: - await asyncio.to_thread(fh.close) - - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - - for root, _dirs, files in extract_dir.walk(): - for filename in files: - if filename == "replacements.json.gz": - src_path = root / filename - src_path.replace(self._json_path) - self._entries = self._load() - _rmtree(zip_path, extract_dir) - return self._entries - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - logger.warning(f"Failed to download Use This Instead DB: {e}") - - _rmtree(zip_path, extract_dir) - return {} - - -def _file_stamp(path: Path) -> tuple[int, int] | None: - """(mtime_ns, size) of *path*, or None when it does not exist.""" - try: - st = path.stat() - except OSError: - return None - return st.st_mtime_ns, st.st_size - - -def _rmtree(zip_path: Path, extract_dir: Path) -> None: - try: - if zip_path.exists(): - zip_path.unlink() - except OSError: - pass - try: - if extract_dir.exists(): - import shutil - - shutil.rmtree(extract_dir) - except OSError: - pass diff --git a/legacy/pxmodrim/core/checker/graph.py b/legacy/pxmodrim/core/checker/graph.py deleted file mode 100644 index a8ba8c2..0000000 --- a/legacy/pxmodrim/core/checker/graph.py +++ /dev/null @@ -1,399 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable, Iterator -from collections.abc import Set as AbstractSet -from enum import IntEnum, auto -from typing import TYPE_CHECKING, NamedTuple - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, -) - -if TYPE_CHECKING: - from pxmodrim.core.sort.community import CommunityRule - from pxmodrim.core.sort.config import SortSettings - -PackageId = CaseInsensitiveStr - - -class EdgeType(IntEnum): - DEPENDENCY = auto() - LOAD_AFTER = auto() - LOAD_BEFORE = auto() - INCOMPATIBILITY = auto() - ALTERNATIVE = auto() - - -class EdgeOrigin(IntEnum): - ABOUT_XML = auto() - COMMUNITY_RULES = auto() - - -class ConstraintEdge(NamedTuple): - # A NamedTuple keeps hashing/equality in C; graph builds create thousands. - source: PackageId - target: PackageId - type: EdgeType - origin: EdgeOrigin - - def __repr__(self) -> str: - return ( - f"ConstraintEdge({self.source} -> {self.target}, " - f"type={self.type.name}, origin={self.origin.name})" - ) - - -# Bypasses NamedTuple's Python-level __new__ on the build hot path. -_new_edge = tuple.__new__ -_EdgesByType = dict[EdgeType, set[ConstraintEdge]] -_NO_EDGES: frozenset[ConstraintEdge] = frozenset() -_NO_TYPED: dict[EdgeType, set[ConstraintEdge]] = {} -_CYCLE_TYPES = (EdgeType.DEPENDENCY, EdgeType.LOAD_AFTER) - - -class ConstraintGraph: - """Directed graph modelling mod dependencies, load order, incompatibilities.""" - - __slots__ = ( - "_cycle_components", - "_incoming", - "_nodes", - "_ordered_pids", - "_outgoing", - "_pid_to_index", - ) - - def __init__(self) -> None: - # Edges are partitioned by type so per-type queries need no filtering. - self._outgoing: dict[PackageId, _EdgesByType] = {} - self._incoming: dict[PackageId, _EdgesByType] = {} - self._nodes: dict[PackageId, None] = {} - self._pid_to_index: dict[PackageId, int] = {} - self._ordered_pids: list[PackageId] = [] - # Cycle membership depends only on edges, so it survives reorders. - self._cycle_components: list[list[PackageId]] | None = None - - # ── Query ───────────────────────────────────────────────── - - @property - def nodes(self) -> AbstractSet[PackageId]: - return self._nodes.keys() - - def has_node(self, pid: PackageId) -> bool: - return pid in self._nodes - - def outgoing(self, pid: PackageId) -> frozenset[ConstraintEdge]: - by_type = self._outgoing.get(pid) - return frozenset().union(*by_type.values()) if by_type else _NO_EDGES - - def incoming(self, pid: PackageId) -> frozenset[ConstraintEdge]: - by_type = self._incoming.get(pid) - return frozenset().union(*by_type.values()) if by_type else _NO_EDGES - - def neighbors(self, pid: PackageId) -> frozenset[PackageId]: - nbrs: set[PackageId] = set() - for edges in self._outgoing.get(pid, _NO_TYPED).values(): - nbrs.update(edge.target for edge in edges) - for edges in self._incoming.get(pid, _NO_TYPED).values(): - nbrs.update(edge.source for edge in edges) - return frozenset(nbrs) - - def edges_of_type( - self, pid: PackageId, edge_type: EdgeType - ) -> AbstractSet[ConstraintEdge]: - """Read-only view of outgoing edges of one type; callers must not mutate.""" - return self._outgoing.get(pid, _NO_TYPED).get(edge_type, _NO_EDGES) - - def incoming_of_type( - self, pid: PackageId, edge_type: EdgeType - ) -> AbstractSet[ConstraintEdge]: - """Read-only view of incoming edges of one type; callers must not mutate.""" - return self._incoming.get(pid, _NO_TYPED).get(edge_type, _NO_EDGES) - - def index_of(self, pid: PackageId) -> int: - return self._pid_to_index.get(pid, -1) - - @property - def pid_to_index(self) -> dict[PackageId, int]: - return dict(self._pid_to_index) - - def ordered_pids(self) -> list[PackageId]: - return list(self._ordered_pids) - - # ── Build ────────────────────────────────────────────────── - - def build( - self, - active_mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId], - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - ) -> None: - """Build constraint graph from active mods, order, settings, community rules.""" - self._outgoing = {} - self._incoming = {} - self._cycle_components = None - self._ordered_pids = list(ordered_pids) - self._pid_to_index = {pid: i for i, pid in enumerate(ordered_pids)} - - # Pre-populate nodes so empty mods are tracked - self._nodes = dict.fromkeys(ordered_pids) - - alt_map = ( - _build_alt_map(active_mods) if settings.use_alternative_package_ids else {} - ) - - for pid, mod in active_mods.items(): - self._add_rules(pid, mod.about_rules, EdgeOrigin.ABOUT_XML, alt_map) - - if settings.use_community_rules and community_rules: - for pid in active_mods: - if pid in community_rules: - cr = community_rules[pid] - self._add_load_rules( - pid, - cr.load_before, - cr.load_after, - cr.incompatible_with, - EdgeOrigin.COMMUNITY_RULES, - alt_map, - ) - - # ── Incremental ops ──────────────────────────────────────── - - def add_mod( - self, - pid: PackageId, - mod: AboutXmlMod, - index: int, - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - ) -> None: - """Insert a single mod into the graph at a given index and add its edges.""" - self._cycle_components = None - self._ensure_node(pid) - self._ordered_pids.insert(index, pid) - self._rebuild_index() - - alt_map = ( - _build_alt_map({pid: mod}) if settings.use_alternative_package_ids else {} - ) - self._add_rules(pid, mod.about_rules, EdgeOrigin.ABOUT_XML, alt_map) - - if settings.use_community_rules and community_rules and pid in community_rules: - cr = community_rules[pid] - self._add_load_rules( - pid, - cr.load_before, - cr.load_after, - cr.incompatible_with, - EdgeOrigin.COMMUNITY_RULES, - _build_alt_map({pid: mod}), - ) - - def remove_mod(self, pid: PackageId) -> None: - """Remove a mod and all its incident edges from the graph.""" - self._cycle_components = None - for edges in self._outgoing.pop(pid, _NO_TYPED).values(): - for edge in edges: - self._incoming[edge.target][edge.type].discard(edge) - for edges in self._incoming.pop(pid, _NO_TYPED).values(): - for edge in edges: - self._outgoing[edge.source][edge.type].discard(edge) - self._nodes.pop(pid, None) - self._pid_to_index.pop(pid, None) - self._ordered_pids = [p for p in self._ordered_pids if p != pid] - self._rebuild_index() - - def update_order(self, ordered_pids: list[PackageId]) -> None: - """Replace the mod ordering without rebuilding constraint edges.""" - self._ordered_pids = list(ordered_pids) - self._pid_to_index = {pid: i for i, pid in enumerate(ordered_pids)} - - # ── Cycle detection ──────────────────────────────────────── - - def find_cycles(self) -> list[list[PackageId]]: - """Return strongly connected components that contain dependency cycles.""" - if self._cycle_components is None: - self._cycle_components = self._find_cycle_components() - index = self._pid_to_index - missing = len(index) - return [ - sorted(component, key=lambda member: index.get(member, missing)) - for component in self._cycle_components - ] - - def _find_cycle_components(self) -> list[list[PackageId]]: - # Nodes without dependency/load-after edges are trivial SCCs that can - # never be part of a cycle, so they are left out of the search. - adjacency: dict[PackageId, list[PackageId]] = {} - for pid, by_type in self._outgoing.items(): - targets: set[PackageId] = set() - for edge_type in _CYCLE_TYPES: - edges = by_type.get(edge_type) - if edges: - targets.update(edge.target for edge in edges) - if targets: - adjacency[pid] = list(targets) - for neighbors in adjacency.values(): - neighbors[:] = [t for t in neighbors if t in adjacency] - - indices: dict[PackageId, int] = {} - lowlinks: dict[PackageId, int] = {} - active_stack: list[PackageId] = [] - on_stack: set[PackageId] = set() - components: list[list[PackageId]] = [] - next_index = 0 - - for root, root_neighbors in adjacency.items(): - if root in indices: - continue - - indices[root] = next_index - lowlinks[root] = next_index - next_index += 1 - active_stack.append(root) - on_stack.add(root) - dfs_stack: list[tuple[PackageId, Iterator[PackageId]]] = [ - (root, iter(root_neighbors)) - ] - - while dfs_stack: - pid, neighbors = dfs_stack[-1] - neighbor = next(neighbors, None) - if neighbor is None: - dfs_stack.pop() - if lowlinks[pid] == indices[pid]: - component: list[PackageId] = [] - while True: - member = active_stack.pop() - on_stack.remove(member) - component.append(member) - if member == pid: - break - if len(component) > 1 or pid in adjacency[pid]: - components.append(component) - if dfs_stack: - parent = dfs_stack[-1][0] - lowlinks[parent] = min(lowlinks[parent], lowlinks[pid]) - continue - - if neighbor not in indices: - indices[neighbor] = next_index - lowlinks[neighbor] = next_index - next_index += 1 - active_stack.append(neighbor) - on_stack.add(neighbor) - dfs_stack.append((neighbor, iter(adjacency[neighbor]))) - elif neighbor in on_stack and indices[neighbor] < lowlinks[pid]: - lowlinks[pid] = indices[neighbor] - - return components - - # ── Private helpers ──────────────────────────────────────── - - def _ensure_node(self, pid: PackageId) -> None: - if pid not in self._nodes: - self._nodes[pid] = None - - def _add_edges( - self, - source: PackageId, - targets: Iterable[PackageId], - type: EdgeType, - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId] | None, - ) -> None: - # Hot path of build(): one call per rule set rather than per edge. - nodes = self._nodes - incoming = self._incoming - out_edges: set[ConstraintEdge] | None = None - for target in targets: - if alt_map: - target = alt_map.get(target, target) - if out_edges is None: - if source not in nodes: - nodes[source] = None - by_type = self._outgoing.get(source) - if by_type is None: - by_type = self._outgoing[source] = {} - out_edges = by_type.get(type) - if out_edges is None: - out_edges = by_type[type] = set() - if target not in nodes: - nodes[target] = None - edge = _new_edge(ConstraintEdge, (source, target, type, origin)) - out_edges.add(edge) - in_by_type = incoming.get(target) - if in_by_type is None: - incoming[target] = {type: {edge}} - elif (in_edges := in_by_type.get(type)) is None: - in_by_type[type] = {edge} - else: - in_edges.add(edge) - - def _add_rules( - self, - pid: PackageId, - rules: BaseRules, - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId], - ) -> None: - for dep_id, dep_mod in rules.dependencies.items(): - self._add_edges(pid, (dep_id,), EdgeType.DEPENDENCY, origin, alt_map) - if dep_mod.alternative_package_ids: - self._add_edges( - pid, - dep_mod.alternative_package_ids, - EdgeType.ALTERNATIVE, - origin, - None, - ) - - self._add_load_rules( - pid, - rules.load_before, - rules.load_after, - rules.incompatible_with, - origin, - alt_map, - ) - - def _add_load_rules( - self, - pid: PackageId, - load_before: AbstractSet[PackageId], - load_after: AbstractSet[PackageId], - incompatible_with: AbstractSet[PackageId], - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId], - ) -> None: - if load_after: - self._add_edges(pid, load_after, EdgeType.LOAD_AFTER, origin, alt_map) - if load_before: - self._add_edges(pid, load_before, EdgeType.LOAD_BEFORE, origin, alt_map) - if incompatible_with: - self._add_edges( - pid, incompatible_with, EdgeType.INCOMPATIBILITY, origin, alt_map - ) - - def _rebuild_index(self) -> None: - self._pid_to_index = {pid: i for i, pid in enumerate(self._ordered_pids)} - - -def _build_alt_map( - active_mods: dict[PackageId, AboutXmlMod], -) -> dict[PackageId, PackageId]: - """Map canonical package IDs to active alternatives that satisfy them.""" - alt_map: dict[PackageId, PackageId] = {} - for mod in active_mods.values(): - for dep_id, dep in mod.about_rules.dependencies.items(): - if dep_id in active_mods: - continue - for alternative_pid in dep.alternative_package_ids: - if alternative_pid in active_mods: - alt_map[dep_id] = alternative_pid - break - return alt_map diff --git a/legacy/pxmodrim/core/checker/issues.py b/legacy/pxmodrim/core/checker/issues.py deleted file mode 100644 index f36f275..0000000 --- a/legacy/pxmodrim/core/checker/issues.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Set as AbstractSet - -from pxmodrim.core.checker.graph import EdgeType -from pxmodrim.core.checker.models import CheckContext, ModIssue, PackageId -from pxmodrim.core.models.metadata.structures import AboutXmlMod - - -class ModIssueChecker(ABC): - """Abstract base for individual mod-issue checkers.""" - - category_display_name: str = "" - # Whether results depend on load order; order-independent results are - # reused when only the order changes. - order_dependent: bool = True - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return True - - def affected_by_moves( - self, ctx: CheckContext, moved: AbstractSet[PackageId] - ) -> AbstractSet[PackageId] | None: - """Mods whose results may change when `moved` changed index; None means all.""" - return None - - @abstractmethod - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Run checks against a single mod and return any issues found.""" - ... - - -class DependencyIssueChecker(ModIssueChecker): - """Check for missing or unsatisfied mod dependencies.""" - - category_display_name = "Missing Dependency" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return bool(mod.about_rules.dependencies) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report errors for unresolved deps, considering alternatives if enabled.""" - if not ctx.settings.check_missing_dependencies: - return [] - - issues: list[ModIssue] = [] - consider_alternatives = ctx.settings.use_alternative_package_ids - - for dep_id, dep_mod in mod.about_rules.dependencies.items(): - satisfied = dep_id in ctx.active_mods - if not satisfied and consider_alternatives: - satisfied = any( - alt in ctx.active_mods for alt in dep_mod.alternative_package_ids - ) - - if not satisfied: - alt_note = "" - if consider_alternatives: - alt_candidates = [str(a) for a in dep_mod.alternative_package_ids] - if alt_candidates: - alt_note = f" (alternatives: {', '.join(alt_candidates)})" - - issues.append( - ModIssue( - category="missing_dependency", - category_display_name=self.category_display_name, - severity="error", - short_message="Missing dependencies", - detail_message=( - f"Dependency not satisfied: {dep_id}{alt_note}" - ), - related_package_ids=(PackageId(dep_id),), - ) - ) - - return issues - - -class IncompatibilityIssueChecker(ModIssueChecker): - """Check for mod-to-mod incompatibility declarations (self-declared and reverse).""" - - category_display_name = "Incompatibility" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - pid = _package_id(mod) - outgoing = ctx.graph.edges_of_type(pid, EdgeType.INCOMPATIBILITY) - incoming = ctx.graph.incoming_of_type(pid, EdgeType.INCOMPATIBILITY) - return ( - bool(mod.about_rules.incompatible_with) or bool(outgoing) or bool(incoming) - ) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report errors for active incompatible mods without duplicate sources.""" - issues: list[ModIssue] = [] - pid = _package_id(mod) - - # Self-declared incompatibilities - for incomp in mod.about_rules.incompatible_with: - if incomp in ctx.active_mods: - other = ctx.active_mods.get(PackageId(incomp)) - name = other.name if other else str(incomp) - issues.append( - ModIssue( - category="incompatibility", - category_display_name=self.category_display_name, - severity="error", - short_message="Incompatibilities detected", - detail_message=f"Mod declares incompatibility with: " - f"{name} ({incomp})", - related_package_ids=(PackageId(incomp),), - ) - ) - - reported_sources = set(mod.about_rules.incompatible_with) - for edge in ctx.graph.incoming_of_type(pid, EdgeType.INCOMPATIBILITY): - if ( - edge.source not in ctx.active_mods - or edge.source == pid - or edge.source in reported_sources - ): - continue - reported_sources.add(edge.source) - other = ctx.active_mods[edge.source] - issues.append( - ModIssue( - category="incompatibility", - category_display_name=self.category_display_name, - severity="error", - short_message="Incompatibilities detected", - detail_message=f"Declared incompatible by: " - f"{other.name} ({edge.source})", - related_package_ids=(PackageId(edge.source),), - ) - ) - - return issues - - -class LoadOrderIssueChecker(ModIssueChecker): - """Check that load-before/after constraints are satisfied by current order.""" - - category_display_name = "Load Order" - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - pid = _package_id(mod) - return bool( - ctx.graph.edges_of_type(pid, EdgeType.LOAD_BEFORE) - or ctx.graph.edges_of_type(pid, EdgeType.LOAD_AFTER) - ) - - def affected_by_moves( - self, ctx: CheckContext, moved: AbstractSet[PackageId] - ) -> AbstractSet[PackageId] | None: - # Results only read the indices of the mod itself and its edge targets. - affected = set(moved) - for pid in moved: - for edge_type in (EdgeType.LOAD_BEFORE, EdgeType.LOAD_AFTER): - affected.update( - edge.source for edge in ctx.graph.incoming_of_type(pid, edge_type) - ) - return affected - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report warnings when a load-before or load-after constraint is violated.""" - issues: list[ModIssue] = [] - pid = _package_id(mod) - idx = ctx.pid_to_index.get(pid, -1) - if idx < 0: - return issues - - for edge in ctx.graph.edges_of_type(pid, EdgeType.LOAD_BEFORE): - other_idx = ctx.pid_to_index.get(edge.target, -1) - if other_idx >= 0 and idx >= other_idx: - other = ctx.active_mods.get(edge.target) - name = other.name if other else str(edge.target) - issues.append( - ModIssue( - category="load_order", - category_display_name=self.category_display_name, - severity="warning", - short_message="Load order violated", - detail_message=( - f"Should load before: {name} ({edge.target}), " - f"but appears after (index {idx} vs {other_idx})" - ), - related_package_ids=(PackageId(edge.target),), - ) - ) - - for edge in ctx.graph.edges_of_type(pid, EdgeType.LOAD_AFTER): - other_idx = ctx.pid_to_index.get(edge.target, -1) - if other_idx >= 0 and idx <= other_idx: - other = ctx.active_mods.get(edge.target) - name = other.name if other else str(edge.target) - issues.append( - ModIssue( - category="load_order", - category_display_name=self.category_display_name, - severity="warning", - short_message="Load order violated", - detail_message=( - f"Should load after: {name} ({edge.target}), " - f"but appears before (index {idx} vs {other_idx})" - ), - related_package_ids=(PackageId(edge.target),), - ) - ) - - return issues - - -class CycleIssueChecker(ModIssueChecker): - """Check whether a mod participates in a dependency cycle.""" - - category_display_name = "Cycle" - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report warnings for each cycle the mod belongs to.""" - pid = _package_id(mod) - issues: list[ModIssue] = [] - - for cycle in ctx.cycles: - if pid in cycle: - issues.append( - ModIssue( - category="cycle", - category_display_name=self.category_display_name, - severity="warning", - short_message="Dependency cycle detected", - detail_message=( - "Participates in a dependency cycle involving: " - + ", ".join(str(c) for c in cycle) - ), - related_package_ids=tuple(c for c in cycle if c != pid), - ) - ) - - return issues - - -class GameVersionIssueChecker(ModIssueChecker): - """Check that the mod's supported game versions match the target version.""" - - category_display_name = "Version Mismatch" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return bool(mod.supported_versions) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report a warning if the mod's supported versions lack the target version.""" - if not mod.supported_versions: - return [] - - pid = str(mod.package_id).lower() - if pid in ctx.no_version_warning: - return [] - - target = ctx.target_version.split(".")[:2] - target_major_minor = ".".join(target) - - if target_major_minor not in mod.supported_versions: - return [ - ModIssue( - category="version_mismatch", - category_display_name=self.category_display_name, - severity="warning", - short_message="Version mismatch", - detail_message=( - f"Mod supports {', '.join(sorted(mod.supported_versions))}, " - f"but game is on {target_major_minor}" - ), - ) - ] - - return [] - - -class ReplacementIssueChecker(ModIssueChecker): - """Check whether a mod has a known replacement available.""" - - category_display_name = "Replacement Available" - order_dependent = False - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report a warning if the mod's published file ID has a replacement.""" - pid = str(mod.published_file_id) if mod.published_file_id else None - if pid is None: - return [] - - replacement = ctx.use_this_instead.get(pid) if ctx.use_this_instead else None - if replacement is None: - return [] - - return [ - ModIssue( - category="replacement_available", - category_display_name=self.category_display_name, - severity="warning", - short_message="Replacement available", - detail_message=( - f"Recommended replacement: {replacement.name} " - f"({replacement.packageid}) by {replacement.author}" - ), - related_package_ids=(PackageId(replacement.packageid),), - ) - ] - - -def _package_id(mod: AboutXmlMod) -> PackageId: - pid = mod.package_id - return pid if type(pid) is PackageId else PackageId(pid) diff --git a/legacy/pxmodrim/core/checker/models.py b/legacy/pxmodrim/core/checker/models.py deleted file mode 100644 index 98249a0..0000000 --- a/legacy/pxmodrim/core/checker/models.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -from pxmodrim.core.models.metadata.structures import ( - CaseInsensitiveStr, - ReplacementInfo, -) -from pxmodrim.core.sort.config import SortSettings - -if TYPE_CHECKING: - from pxmodrim.core.checker.graph import ConstraintGraph - from pxmodrim.core.models.metadata.structures import AboutXmlMod - -PackageId = CaseInsensitiveStr - - -@dataclass(frozen=True, slots=True) -class ModIssue: - category: str - severity: Literal["error", "warning"] - short_message: str - category_display_name: str = "" - detail_message: str = "" - related_package_ids: tuple[PackageId, ...] = () - - -@dataclass(slots=True) -class ModDiagnostics: - errors: list[ModIssue] = field(default_factory=list) - warnings: list[ModIssue] = field(default_factory=list) - - @property - def has_errors(self) -> bool: - return bool(self.errors) - - @property - def has_warnings(self) -> bool: - return bool(self.warnings) - - @property - def error_tooltip(self) -> str: - if not self.errors: - return "" - if len(self.errors) == 1: - return self.errors[0].short_message - return f"{len(self.errors)} errors" - - @property - def warning_tooltip(self) -> str: - if not self.warnings: - return "" - if len(self.warnings) == 1: - return self.warnings[0].short_message - return f"{len(self.warnings)} warnings" - - -@dataclass(slots=True) -class CheckContext: - active_mods: dict[PackageId, AboutXmlMod] - ordered_pids: list[PackageId] - pid_to_index: dict[PackageId, int] - graph: ConstraintGraph - settings: SortSettings - target_version: str = "1.5" - no_version_warning: set[PackageId] = field(default_factory=set) - use_this_instead: Mapping[str, ReplacementInfo] = field(default_factory=dict) - cycles: list[list[PackageId]] = field(default_factory=list) diff --git a/legacy/pxmodrim/core/checker/sort.py b/legacy/pxmodrim/core/checker/sort.py deleted file mode 100644 index 6e04ad0..0000000 --- a/legacy/pxmodrim/core/checker/sort.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import heapq -from typing import TYPE_CHECKING - -from pxmodrim.core.checker.graph import ConstraintGraph, EdgeType -from pxmodrim.core.checker.models import PackageId -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.sort.config import SortSettings, Tier, TierConfig -from pxmodrim.core.sort.models import CommunityRule -from pxmodrim.core.sort.tiers import assign_tiers - -if TYPE_CHECKING: - from ttimer import Timer - - -def topological_sort( - active_mods: dict[PackageId, AboutXmlMod], - graph: ConstraintGraph, - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - timer: Timer | None = None, -) -> list[PackageId]: - """Sort active mods into tiers by dependencies, load-order, and config priority.""" - from ttimer import Timer - - t = timer or Timer() - all_pids = set(active_mods.keys()) - if not all_pids: - return [] - - with t("build_deps"): - deps, rev_deps = _build_deps_and_rev_deps(graph, all_pids) - with t("build_load_first"): - load_first = _build_load_first(community_rules, all_pids, settings) - with t("build_load_last"): - load_last = _build_load_last(community_rules, all_pids, settings) - - with t("assign_tiers"): - tiers = assign_tiers( - all_pids, deps, rev_deps, load_first, load_last, settings.tier_config - ) - - order: list[PackageId] = [] - ordered: set[PackageId] = set() - for tier in (Tier.TIER_0, Tier.TIER_1, Tier.TIER_2, Tier.TIER_3): - tier_pids = { - pid for pid in all_pids if pid not in ordered and tiers.get(pid) == tier - } - if not tier_pids: - continue - - with t(f"kahn_{tier.name.lower()}"): - sub_deps = {pid: deps.get(pid, set()) & tier_pids for pid in tier_pids} - indegree = {pid: len(d) for pid, d in sub_deps.items()} - - rev: dict[PackageId, set[PackageId]] = {} - for pid, ds in sub_deps.items(): - for d in ds: - rev.setdefault(d, set()).add(pid) - - config_priority = _build_config_priority(settings.tier_config) - - def _key(pid: PackageId, config_priority=config_priority) -> tuple: - return (config_priority.get(pid, len(config_priority)), pid.lower()) - - queue = [(_key(pid), pid) for pid, deg in indegree.items() if deg == 0] - heapq.heapify(queue) - - while queue: - _, pid = heapq.heappop(queue) - order.append(pid) - ordered.add(pid) - for dependent in rev.get(pid, set()): - indegree[dependent] -= 1 - if indegree[dependent] == 0: - heapq.heappush(queue, (_key(dependent), dependent)) - - remaining = tier_pids - ordered - if remaining: - order.extend(sorted(remaining, key=_key)) - ordered.update(remaining) - - return order - - -def _build_deps_and_rev_deps( - graph: ConstraintGraph, - all_pids: set[PackageId], -) -> tuple[dict[PackageId, set[PackageId]], dict[PackageId, set[PackageId]]]: - """Extract dependency and reverse-dependency maps from constraint graph edges.""" - deps: dict[PackageId, set[PackageId]] = {pid: set() for pid in all_pids} - rev_deps: dict[PackageId, set[PackageId]] = {pid: set() for pid in all_pids} - - for pid in graph.nodes: - if pid not in all_pids: - continue - - deps_pid = deps[pid] - rev_pid = rev_deps[pid] - for edge in graph.outgoing(pid): - target = edge.target - if target not in all_pids: - continue - if edge.type == EdgeType.LOAD_BEFORE: - deps[target].add(pid) - rev_pid.add(target) - elif edge.type in (EdgeType.LOAD_AFTER, EdgeType.DEPENDENCY): - deps_pid.add(target) - rev_deps[target].add(pid) - - return deps, rev_deps - - -def _build_load_first( - community_rules: dict[PackageId, CommunityRule] | None, - all_pids: set[PackageId], - settings: SortSettings, -) -> set[PackageId]: - """Collect PIDs that should be loaded first from community rules.""" - if not settings.use_community_rules or not community_rules: - return set() - return { - pid - for pid in all_pids - if (rule := community_rules.get(pid)) is not None and rule.load_first - } - - -def _build_load_last( - community_rules: dict[PackageId, CommunityRule] | None, - all_pids: set[PackageId], - settings: SortSettings, -) -> set[PackageId]: - """Collect PIDs that should be loaded last from community rules.""" - if not settings.use_community_rules or not community_rules: - return set() - return { - pid - for pid in all_pids - if (rule := community_rules.get(pid)) is not None and rule.load_last - } - - -def _build_config_priority(tier_config: TierConfig) -> dict[PackageId, int]: - """Build a stable ordering priority map from tier-config package lists.""" - priority: dict[PackageId, int] = {} - for i, pid in enumerate(tier_config.tier_0): - priority[pid] = i - for i, pid in enumerate(tier_config.tier_1): - if pid not in priority: - priority[pid] = len(tier_config.tier_0) + i - for i, pid in enumerate(tier_config.tier_3): - if pid not in priority: - priority[pid] = len(tier_config.tier_0) + len(tier_config.tier_1) + i - return priority diff --git a/legacy/pxmodrim/core/config.py b/legacy/pxmodrim/core/config.py deleted file mode 100644 index 1ca6574..0000000 --- a/legacy/pxmodrim/core/config.py +++ /dev/null @@ -1,455 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import sys -from collections.abc import Callable -from pathlib import Path -from time import time -from typing import Any, TypeVar - -import msgspec -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, AfterLaunch -from pxmodrim.core.msgspec_hooks import dec_hook, enc_hook -from pxmodrim.core.sort.config import SortSettings, TierConfig - -StructT = TypeVar("StructT", bound=msgspec.Struct) -_JSON_SCHEMA_MARKER = "schema_version" -CURRENT_CONFIG_SCHEMA_VERSION = 1 -_VERSIONED_CONFIG_FILES = frozenset({"config.json", "ui_prefs.json"}) -_JsonMigration = Callable[[dict[str, Any]], None] - - -def _v1_json_migration(data: dict[str, Any]) -> None: - return None - - -_JSON_MIGRATIONS: dict[int, _JsonMigration] = {1: _v1_json_migration} - - -def read_game_version(game_path: str | Path) -> str | None: - """Read game version string from ``Version.txt`` in the game directory. - - Returns the stripped content (e.g. ``"1.6.4871 rev598"``) or ``None`` if - the file is missing or cannot be read. - """ - path = Path(game_path) / "Version.txt" - if not path.exists(): - return None - try: - return path.read_text(encoding="utf-8").strip() - except OSError: - logger.warning("Failed to read game version from {}", path) - return None - - -class PathConfig(msgspec.Struct): - """Paths to the game, mods, workshop, and config directories.""" - - game: str = "" - local: str = "" - workshop: str = "" - config_folder: str = "" - community_rules_file: str = "" - no_version_warning_file: str = "" - use_this_instead_file: str = "" - - -class AppConfig(msgspec.Struct): - """Top-level core config: RimWorld paths and sort settings.""" - - paths: PathConfig = msgspec.field(default_factory=PathConfig) - sort: SortSettings = msgspec.field( - default_factory=lambda: SortSettings(tier_config=TierConfig.default()) - ) - max_snapshots: int = 10 - compact_mod_list: bool = False - log_upload_endpoint: str = "https://paste.rs/" - launch_args: str = "" - launch_wrapper: str = "" - after_launch: AfterLaunch = AfterLaunch.STAY - launch_confirm_errors: bool = True - launch_confirm_unsaved: bool = True - launch_confirm_running: bool = True - - -def _migrate_json(data: dict[str, Any], current: int) -> None: - if current > CURRENT_CONFIG_SCHEMA_VERSION: - raise ValueError( - f"Unsupported JSON schema version {current}; " - f"latest is {CURRENT_CONFIG_SCHEMA_VERSION}" - ) - for target in range(current + 1, CURRENT_CONFIG_SCHEMA_VERSION + 1): - migration = _JSON_MIGRATIONS.get(target) - if migration is None: - raise KeyError(f"Missing JSON migration step for target version {target}") - migration(data) - - -class ConfigService: - """Generic file-based config repository backed by a config directory. - - Provides typed load/save for msgspec structs. Core and UI layer - wrappers live in their respective modules and use this service - instead of calling ``config_dir()`` directly. - """ - - __slots__ = ("_config_dir",) - - def __init__(self, config_dir: Path) -> None: - self._config_dir = config_dir - - @property - def config_dir(self) -> Path: - return self._config_dir - - def load(self, filename: str, struct_type: type[StructT]) -> StructT: - path = self._config_dir / filename - if not path.exists(): - return struct_type() - try: - if filename not in _VERSIONED_CONFIG_FILES: - return msgspec.json.decode( - path.read_bytes(), type=struct_type, dec_hook=dec_hook - ) - - raw = msgspec.json.decode(path.read_bytes(), type=dict[str, Any]) - marker = raw.pop(_JSON_SCHEMA_MARKER, None) - if marker is None: - version = 0 - elif type(marker) is int and marker >= 0: - version = marker - else: - raise msgspec.DecodeError("Invalid JSON schema version") - - if version < CURRENT_CONFIG_SCHEMA_VERSION: - _migrate_json(raw, version) - backup = path.with_suffix(f"{path.suffix}.bak.{int(time())}") - shutil.copy2(path, backup) - raw[_JSON_SCHEMA_MARKER] = CURRENT_CONFIG_SCHEMA_VERSION - self._write(path, raw) - elif version > CURRENT_CONFIG_SCHEMA_VERSION: - _migrate_json(raw, version) - - return msgspec.json.decode( - msgspec.json.encode(raw), type=struct_type, dec_hook=dec_hook - ) - except (OSError, msgspec.DecodeError) as e: - logger.warning(f"Failed to load {filename}: {e}") - return struct_type() - - def save(self, filename: str, data: msgspec.Struct) -> None: - path = self._config_dir / filename - path.parent.mkdir(parents=True, exist_ok=True) - encoded = msgspec.json.encode(data, enc_hook=enc_hook) - if filename in _VERSIONED_CONFIG_FILES: - raw = msgspec.json.decode(encoded, type=dict[str, Any]) - raw[_JSON_SCHEMA_MARKER] = CURRENT_CONFIG_SCHEMA_VERSION - encoded = msgspec.json.encode(raw) - self._write(path, encoded) - - def load_existing( - self, filename: str, struct_type: type[StructT] - ) -> StructT | None: - """Like ``load``, but ``None`` when *filename* does not exist yet.""" - if not (self._config_dir / filename).exists(): - return None - return self.load(filename, struct_type) - - def load_raw(self, filename: str) -> dict[str, Any]: - """The undecoded top-level JSON object in *filename*, or ``{}``.""" - try: - return msgspec.json.decode( - (self._config_dir / filename).read_bytes(), type=dict[str, Any] - ) - except (OSError, msgspec.DecodeError): - return {} - - @staticmethod - def _write(path: Path, data: bytes | dict[str, Any]) -> None: - encoded = msgspec.json.encode(data) if isinstance(data, dict) else data - formatted = msgspec.json.format(encoded, indent=2) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_bytes(formatted) - os.replace(tmp, path) - - -def config_dir() -> Path: - base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - return Path(base) / "pxmodrim" - - -def config_file_path() -> Path: - return config_dir() / "config.json" - - -def community_rules_file() -> Path: - return config_dir() / "communityRules.json" - - -def load_config(path: Path | None = None) -> AppConfig: - """Load config from a JSON file. Returns defaults if missing or corrupt.""" - path = path or config_file_path() - return ConfigService(path.parent).load(path.name, AppConfig) - - -def save_config(cfg: AppConfig, path: Path | None = None) -> None: - """Serialize and write the core config (paths + sort) to a JSON file.""" - path = path or config_file_path() - ConfigService(path.parent).save(path.name, cfg) - logger.info(f"Config saved to {path}") - - -def _detect_config_folder(steam_root: Path | None) -> str: - """Locate the RimWorld Config folder for the current platform.""" - if sys.platform == "linux": - if steam_root: - proton = ( - steam_root - / "steamapps" - / "compatdata" - / RIMWORLD_STEAM_APP_ID - / "pfx" - / "drive_c" - / "users" - / "steamuser" - / "AppData" - / "LocalLow" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if proton.is_dir(): - logger.debug(f"Detected Proton config folder: {proton}") - return str(proton) - native = ( - Path.home() - / ".config" - / "unity3d" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if native.is_dir(): - logger.debug(f"Detected native config folder: {native}") - return str(native) - elif sys.platform == "darwin": - config = Path.home() / "Library" / "Application Support" / "Rimworld" / "Config" - if config.is_dir(): - logger.debug(f"Detected macOS config folder: {config}") - return str(config) - elif sys.platform == "win32": - config = ( - Path.home() - / "AppData" - / "LocalLow" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if config.is_dir(): - logger.debug(f"Detected Windows config folder: {config}") - return str(config) - return "" - - -def detect_game_paths() -> PathConfig: - """Auto-detect game, local mods, and workshop paths via Steam library folders.""" - result = PathConfig() - steam_id = RIMWORLD_STEAM_APP_ID - found_root: Path | None = None - - if sys.platform == "linux": - candidates = [ - Path.home() / ".steam" / "debian-installation", - Path.home() / ".steam" / "steam", - Path.home() / ".local" / "share" / "Steam", - Path.home() - / ".var" - / "app" - / "com.valvesoftware.Steam" - / ".local" - / "share" - / "Steam", - Path.home() / "snap" / "steam" / "common" / ".local" / "share" / "Steam", - ] - for root in candidates: - if not root.is_dir(): - continue - game = _find_rimworld_in_steam_root(root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str(_workshop_path_from_game(game, root, steam_id)) - found_root = root - break - if not result.game: - fallback_root = Path.home() / ".steam" / "steam" - fallback = fallback_root / "steamapps" / "common" / "RimWorld" - if fallback.is_dir(): - result.game = str(fallback) - result.local = str(fallback / "Mods") - result.workshop = str( - fallback_root / "steamapps" / "workshop" / "content" / steam_id - ) - found_root = fallback_root if fallback_root.is_dir() else None - - elif sys.platform == "darwin": - steam_root = Path.home() / "Library" / "Application Support" / "Steam" - found_root = steam_root if steam_root.is_dir() else None - game = _find_rimworld_in_steam_root(steam_root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str(_workshop_path_from_game(game, steam_root, steam_id)) - else: - fallback = steam_root / "steamapps" / "common" / "RimWorld" - app_bundle = _find_mac_app(fallback) - if app_bundle: - result.game = str(app_bundle) - result.local = str(fallback / "Mods") - result.workshop = str( - steam_root / "steamapps" / "workshop" / "content" / steam_id - ) - - elif sys.platform == "win32": - result = _detect_windows_paths(steam_id) - - result.config_folder = _detect_config_folder(found_root) - result.community_rules_file = str(community_rules_file()) - - return result - - -def _find_rimworld_in_steam_root(root: Path, steam_id: str) -> Path | None: - """Search a Steam library root for an installed RimWorld directory.""" - game = root / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - return game - vdf_path = root / "config" / "libraryfolders.vdf" - if vdf_path.exists(): - return _vdf_find_rimworld(vdf_path, steam_id) - vdf_path = root / "steamapps" / "libraryfolders.vdf" - if vdf_path.exists(): - return _vdf_find_rimworld(vdf_path, steam_id) - return None - - -def _vdf_find_rimworld(vdf_path: Path, steam_id: str) -> Path | None: - """Parse a libraryfolders.vdf to find RimWorld in alternate Steam library paths.""" - try: - import re - - text = vdf_path.read_text(encoding="utf-8", errors="replace") - - # Simple regex-based VDF parser for libraryfolders.vdf - # Looking for: "libraryfolders" { "0" { "path" "..." "apps" - # { "294100" { ... } } } } - path_pattern = re.compile(r'"path"\s+"(.+?)"') - app_pattern = re.compile(r'"' + re.escape(steam_id) + r'"\s*{') - - current_path = None - - for line in text.splitlines(): - stripped = line.strip() - - # Match library folder path - path_match = path_pattern.search(stripped) - if path_match: - current_path = Path(path_match.group(1)) - continue - - # Match app id entry - if app_pattern.search(stripped) and current_path: - game = current_path / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - return game - - except OSError: - logger.warning("Failed to parse libraryfolders.vdf") - return None - - -def _workshop_path_from_game(game: Path, steam_root: Path, steam_id: str) -> Path: - try: - idx = game.parts.index("common") - base = Path(*game.parts[:idx]) - workshop = base / "workshop" / "content" / steam_id - if workshop.is_dir(): - return workshop - except ValueError: - pass - return steam_root / "steamapps" / "workshop" / "content" / steam_id - - -def _find_mac_app(rimworld_dir: Path) -> Path | None: - if rimworld_dir.is_dir(): - apps = list(rimworld_dir.glob("*.app")) - if apps: - return apps[0] - return None - - -def _detect_windows_paths(steam_id: str) -> PathConfig: - """Detect RimWorld paths on Windows via the Windows registry.""" - result = PathConfig() - import winreg - - for reg_key in [ - r"SOFTWARE\Wow6432Node\Valve\Steam", - r"SOFTWARE\Valve\Steam", - ]: - try: - with winreg.OpenKey( # type: ignore[attr-defined] - winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined] - reg_key, - ) as key: - steam_path = winreg.QueryValueEx( # type: ignore[attr-defined] - key, "InstallPath" - )[0] - steam_root = Path(steam_path) - game = _find_rimworld_in_steam_root(steam_root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str( - _workshop_path_from_game(game, steam_root, steam_id) - ) - return result - except OSError: - logger.debug("Unable to inspect Windows Steam registry key {}", reg_key) - fallback = Path("C:/Program Files (x86)/Steam") - game = fallback / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str( - fallback / "steamapps" / "workshop" / "content" / steam_id - ) - return result - - -class AppConfigService: - """Owns AppConfig lifecycle: load and save. - - Follows the Service protocol (setup). - """ - - __slots__ = ("_cfg", "_svc") - - def __init__(self, svc: ConfigService) -> None: - self._svc = svc - self._cfg: AppConfig | None = None - - async def setup(self) -> None: - self._cfg = self._svc.load("config.json", AppConfig) - - def cfg(self) -> AppConfig: - assert self._cfg is not None - return self._cfg - - def save_cfg(self) -> None: - if self._cfg is not None: - self._svc.save("config.json", self._cfg) diff --git a/legacy/pxmodrim/core/constants.py b/legacy/pxmodrim/core/constants.py deleted file mode 100644 index ca8483e..0000000 --- a/legacy/pxmodrim/core/constants.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum - -RIMWORLD_STEAM_APP_ID = "294100" - - -class LaunchStrategy(IntEnum): - DIRECT = 0 - STEAM = 1 - - -class AfterLaunch(IntEnum): - STAY = 0 - MINIMIZE = 1 - CLOSE = 2 - - -DEFAULT_MISSING_PACKAGEID = "missing.packageid" - -RIMWORLD_DLC_METADATA: dict[str, dict[str, str]] = { - "294100": { - "packageid": "ludeon.rimworld", - "name": "RimWorld", - "steam_url": "https://store.steampowered.com/app/294100/RimWorld", - "description": "Base game", - }, - "1149640": { - "packageid": "ludeon.rimworld.royalty", - "name": "RimWorld - Royalty", - "steam_url": "https://store.steampowered.com/app/1149640/RimWorld__Royalty", - "description": "DLC #1", - }, - "1392840": { - "packageid": "ludeon.rimworld.ideology", - "name": "RimWorld - Ideology", - "steam_url": "https://store.steampowered.com/app/1392840/RimWorld__Ideology", - "description": "DLC #2", - }, - "1826140": { - "packageid": "ludeon.rimworld.biotech", - "name": "RimWorld - Biotech", - "steam_url": "https://store.steampowered.com/app/1826140/RimWorld__Biotech", - "description": "DLC #3", - }, - "2380740": { - "packageid": "ludeon.rimworld.anomaly", - "name": "RimWorld - Anomaly", - "steam_url": "https://store.steampowered.com/app/2380740/RimWorld__Anomaly/", - "description": "DLC #4", - }, - "3022790": { - "packageid": "ludeon.rimworld.odyssey", - "name": "RimWorld - Odyssey", - "steam_url": "https://store.steampowered.com/app/3022790/RimWorld__Odyssey/", - "description": "DLC #5", - }, -} - -RIMWORLD_PACKAGE_IDS: list[str] = [ - v["packageid"] for v in RIMWORLD_DLC_METADATA.values() -] diff --git a/legacy/pxmodrim/core/context.py b/legacy/pxmodrim/core/context.py deleted file mode 100644 index ef88d58..0000000 --- a/legacy/pxmodrim/core/context.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -import os -import time -from concurrent.futures import ThreadPoolExecutor -from typing import TYPE_CHECKING - -from loguru import logger - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.downloads.manager import DownloadManager -from pxmodrim.core.events import Event -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.plugin import PluginRegistry -from pxmodrim.core.structures import CollectionStats - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService, PathConfig - from pxmodrim.core.mod_service import ModService - from pxmodrim.core.plugin import Plugin - from pxmodrim.core.providers.base import BaseModProvider - from pxmodrim.core.services.activation_service import ActivationService - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - from pxmodrim.core.services.game_launcher import GameLauncher - from pxmodrim.core.services.sort_service import SortService - -# Minimum valid version string shape: "X.Y" — reject anything shorter -_VERSION_MIN_PARTS = 2 - - -def _require_not_none[T](value: T | None, name: str) -> T: - if value is None: - raise RuntimeError(f"{name} accessed before initialisation") - return value - - -class CoreContext: - """Centralised application state for mods, config, and game version.""" - - __slots__ = ( - "_activation", - "_active_state_changed", - "_active_uuids", - "_cfg", - "_config_changed", - "_config_service", - "_diagnostics_service", - "_game_launcher", - "_game_version", - "_loaded_active_uuids", - "_mod_service", - "_mods", - "_plugins", - "_pool", - "_providers", - "_sort_service", - ) - - def __init__( - self, cfg: AppConfig, config_service: ConfigService | None = None - ) -> None: - self._cfg = cfg - self._config_service = config_service - self._mods: dict[str, ListedMod] = {} - self._active_uuids: list[str] = [] - self._loaded_active_uuids: tuple[str, ...] = () - self._game_version: str = "Unknown" - self._refresh_game_version() - self._mod_service: ModService | None = None - self._diagnostics_service: DiagnosticsService | None = None - self._sort_service: SortService | None = None - self._game_launcher: GameLauncher | None = None - self._activation: ActivationService | None = None - self._providers: list[BaseModProvider] | None = None - self._pool: ThreadPoolExecutor | None = None - self._plugins = PluginRegistry() - # Always present; stays empty until a downloader plugin registers with it. - self._plugins.register(DownloadManager()) - self._active_state_changed = Event[tuple[str, ...]]() - self._config_changed = Event[AppConfig]() - - def load(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - """Replace all mods and active UUIDs, taking ownership of the data.""" - self._mods = dict(mods) - self._active_uuids = list(active_uuids) - self._loaded_active_uuids = tuple(active_uuids) - - def set_active(self, uuids: list[str]) -> None: - old_set = frozenset(self._active_uuids) - new_set = frozenset(uuids) - if old_set == new_set and self._active_uuids == uuids: - return - set_changed = old_set != new_set - self._active_uuids = list(uuids) - if set_changed: - self.diagnostics_service.rebuild(self._active_uuids) - else: - self.diagnostics_service.reorder(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - - async def auto_sort(self) -> tuple[int, float]: - deps = self.sort_service.resolve_missing_dependencies(set(self._active_uuids)) - if deps: - self._active_uuids.extend(deps) - self.diagnostics_service.rebuild(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - logger.info("auto-sort: enabling {} missing dependencies", len(deps)) - t0 = time.monotonic() - self._active_uuids = await self.sort_service.sort_active_mods() - elapsed_ms = (time.monotonic() - t0) * 1000 - self.diagnostics_service.reorder(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - n = len(self._active_uuids) - logger.info("auto-sort: {} mods sorted in {:.0f}ms", n, elapsed_ms) - return n, elapsed_ms - - def update_config(self, cfg: AppConfig) -> None: - """Replace the live config and refresh derived values (game version).""" - self._cfg = cfg - self._refresh_game_version() - self._config_changed.emit(cfg) - - # ── Game version ────────────────────────────────────────────────────────── - - def _refresh_game_version(self) -> None: - """Read the game version from disk and cache it in `_game_version`.""" - from pxmodrim.core.config import read_game_version - - game = self._cfg.paths.game - if not game: - self._game_version = "Unknown" - return - version = read_game_version(game) - self._game_version = version if version else "Unknown" - - @property - def game_version(self) -> str: - return self._game_version - - @property - def target_version(self) -> str: - parts = self._game_version.split(".") - if len(parts) >= _VERSION_MIN_PARTS: - return f"{parts[0]}.{parts[1]}" - return "Unknown" - - # ── Mods ────────────────────────────────────────────────────────────────── - - @property - def all_mods(self) -> dict[str, ListedMod]: - return dict(self._mods) - - @property - def active_uuids(self) -> list[str]: - return list(self._active_uuids) - - @property - def loaded_active_uuids(self) -> list[str]: - """Active UUIDs as last loaded from disk, unaffected by later edits.""" - return list(self._loaded_active_uuids) - - @property - def active_state_changed(self) -> Event[tuple[str, ...]]: - return self._active_state_changed - - @property - def config(self) -> AppConfig: - return self._cfg - - @property - def config_changed(self) -> Event[AppConfig]: - return self._config_changed - - @property - def has_config_service(self) -> bool: - return self._config_service is not None - - @property - def config_service(self) -> ConfigService: - assert self._config_service is not None - return self._config_service - - def compute_stats(self, active_ids: list[str] | None = None) -> CollectionStats: - """Return total/active/inactive/error counts for the current mod set.""" - ids = active_ids if active_ids is not None else self._active_uuids - stats = CollectionStats() - stats.total = len(self._mods) - stats.active = len(ids) - stats.inactive = stats.total - stats.active - for m in self._mods.values(): - if not m.valid: - stats.errors += 1 - return stats - - # ── Factory ──────────────────────────────────────────────────────────────── - - @classmethod - def create( - cls, cfg: AppConfig, config_service: ConfigService | None = None - ) -> CoreContext: - """Create a fully-initialised context with all core services.""" - from pxmodrim.core.mod_service import ModService - from pxmodrim.core.profiler import profile - from pxmodrim.core.providers import create_providers - from pxmodrim.core.services.activation_service import ActivationService - from pxmodrim.core.services.diagnostics_service import ( - DiagnosticsService, - ) - from pxmodrim.core.services.game_launcher import GameLauncher - from pxmodrim.core.services.sort_service import SortService - - ctx = cls(cfg, config_service) - ctx._pool = ThreadPoolExecutor(max_workers=os.cpu_count()) - with profile("services.create") as t: - with t("create_providers"): - ctx._providers = create_providers(cfg.paths, ctx._pool) - with t("diagnostics_service"): - ctx._diagnostics_service = DiagnosticsService(ctx) - with t("mod_service"): - ctx._mod_service = ModService(ctx, ctx._providers) - with t("sort_service"): - ctx._sort_service = SortService(ctx, ctx._diagnostics_service) - with t("game_launcher"): - ctx._game_launcher = GameLauncher(ctx) - with t("activation"): - ctx._activation = ActivationService(ctx) - return ctx - - # ── Service accessors ────────────────────────────────────────────────────── - - @property - def mod_service(self) -> ModService: - return _require_not_none(self._mod_service, "mod_service") - - @property - def diagnostics_service(self) -> DiagnosticsService: - return _require_not_none(self._diagnostics_service, "diagnostics_service") - - @property - def sort_service(self) -> SortService: - return _require_not_none(self._sort_service, "sort_service") - - @property - def activation(self) -> ActivationService: - return _require_not_none(self._activation, "activation") - - @property - def game_launcher(self) -> GameLauncher: - return _require_not_none(self._game_launcher, "game_launcher") - - async def initialize(self) -> None: - svc = _require_not_none(self._mod_service, "mod_service") - await svc.initialize() - - async def close(self) -> None: - if self._pool is not None: - self._pool.shutdown(wait=True) - svc = self._mod_service - if svc is not None: - await svc.startup_impact.close_connection() - await svc.close() - - def close_sync(self) -> None: - """Synchronous teardown for use after the event loop has stopped.""" - if self._pool is not None: - self._pool.shutdown(wait=True) - svc = self._mod_service - if svc is not None: - svc.startup_impact.close_connection_sync() - svc.close_sync() - - def reset_providers(self, paths: PathConfig) -> None: - """Replace providers when the config changes (e.g. after settings dialog).""" - from pxmodrim.core.providers import create_providers - - svc = _require_not_none(self._mod_service, "mod_service") - self._providers = create_providers(paths, self._pool) - svc.reset_providers(self._providers) - - # ── Plugin system ────────────────────────────────────────────────────────── - - def register_plugin(self, plugin: Plugin) -> None: - self._plugins.register(plugin, self) - - @property - def plugins(self) -> PluginRegistry: - return self._plugins diff --git a/legacy/pxmodrim/core/downloads/__init__.py b/legacy/pxmodrim/core/downloads/__init__.py deleted file mode 100644 index 6f66a34..0000000 --- a/legacy/pxmodrim/core/downloads/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.downloads.manager import DownloadManager, download_manager -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) - -__all__ = [ - "DownloadItemStatus", - "DownloadItemTitle", - "DownloadManager", - "DownloadProgress", - "DownloadResult", - "Downloader", - "download_manager", -] diff --git a/legacy/pxmodrim/core/downloads/downloader.py b/legacy/pxmodrim/core/downloads/downloader.py deleted file mode 100644 index e8d7fe4..0000000 --- a/legacy/pxmodrim/core/downloads/downloader.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Iterable -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - -MANAGER_NAME = "downloads" - - -class Downloader(Plugin, ABC): - """A source that downloads and updates mods into the local mods folder. - - Registers itself with the ``downloads`` manager in ``setup()``; the UI only - talks to the manager, so a downloader can be added or left out freely. - """ - - dependencies: ClassVar[list[str]] = [MANAGER_NAME] - # Human-readable source name shown in the UI, e.g. "Steam Workshop". - label: ClassVar[str] = "" - - status_message_changed: Event[str] - download_progress: Event[DownloadProgress] - download_item_status_changed: Event[DownloadItemStatus] - download_item_titled: Event[DownloadItemTitle] - # "login" -> "query" (item details) -> "run" (all items resolved) - download_phase_changed: Event[str] - download_finished: Event[DownloadResult] - busy_changed: Event[bool] - batch_started: Event[list[str]] - - def __init__(self) -> None: - self.status_message_changed = Event() - self.download_progress = Event() - self.download_item_status_changed = Event() - self.download_item_titled = Event() - self.download_phase_changed = Event() - self.download_finished = Event() - self.busy_changed = Event() - self.batch_started = Event() - - def setup(self, ctx: CoreContext) -> None: - # Deferred: the manager module imports this one. - from pxmodrim.core.downloads.manager import download_manager - - download_manager(ctx).register(self) - - @property - @abstractmethod - def is_downloading(self) -> bool: ... - - @property - @abstractmethod - def active_ids(self) -> frozenset[str]: - """Ids of the batch currently downloading.""" - - @abstractmethod - def accepts(self, mod_id: str) -> bool: - """Whether *mod_id* belongs to this source.""" - - @abstractmethod - def updatable_id(self, mod: ListedMod) -> str | None: - """Id of *mod* in this source if PxModRim downloaded it from here.""" - - @abstractmethod - def last_synced(self, mod: ListedMod) -> float | None: - """Unix time of the last successful sync of *mod*, if known.""" - - @abstractmethod - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download or update *mod_ids*; per-item failures don't raise.""" - - @abstractmethod - def cancel(self) -> None: ... - - def updatable_ids(self, mods: Iterable[ListedMod]) -> list[str]: - ids = (self.updatable_id(m) for m in mods) - return list(dict.fromkeys(pid for pid in ids if pid is not None)) diff --git a/legacy/pxmodrim/core/downloads/manager.py b/legacy/pxmodrim/core/downloads/manager.py deleted file mode 100644 index 615ff3a..0000000 --- a/legacy/pxmodrim/core/downloads/manager.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from typing import TYPE_CHECKING - -from pxmodrim.core.downloads.downloader import MANAGER_NAME, Downloader -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - - -class DownloadManager(Plugin): - """The one download entry point for the UI; routes to registered downloaders. - - With no downloader registered nothing is updatable and ``available`` is False, - which is how the UI hides every download control. - """ - - name = MANAGER_NAME - - status_message_changed: Event[str] - download_progress: Event[DownloadProgress] - download_item_status_changed: Event[DownloadItemStatus] - download_item_titled: Event[DownloadItemTitle] - download_phase_changed: Event[str] - download_finished: Event[DownloadResult] - busy_changed: Event[bool] - batch_started: Event[list[str]] - - def __init__(self) -> None: - self.status_message_changed = Event() - self.download_progress = Event() - self.download_item_status_changed = Event() - self.download_item_titled = Event() - self.download_phase_changed = Event() - self.download_finished = Event() - self.busy_changed = Event() - self.batch_started = Event() - self._downloaders: list[Downloader] = [] - self._cancelled = False - - def register(self, downloader: Downloader) -> None: - self._downloaders.append(downloader) - downloader.status_message_changed.connect(self.status_message_changed.emit) - downloader.download_progress.connect(self.download_progress.emit) - downloader.download_item_status_changed.connect( - self.download_item_status_changed.emit - ) - downloader.download_item_titled.connect(self.download_item_titled.emit) - downloader.download_phase_changed.connect(self.download_phase_changed.emit) - downloader.download_finished.connect(self.download_finished.emit) - downloader.batch_started.connect(self.batch_started.emit) - downloader.busy_changed.connect(self._on_busy) - - def _on_busy(self, busy: bool) -> None: - if busy: - self._cancelled = False - self.busy_changed.emit(self.is_downloading) - - @property - def downloaders(self) -> tuple[Downloader, ...]: - return tuple(self._downloaders) - - @property - def available(self) -> bool: - return bool(self._downloaders) - - @property - def is_downloading(self) -> bool: - return any(d.is_downloading for d in self._downloaders) - - @property - def active_ids(self) -> frozenset[str]: - return frozenset().union(*(d.active_ids for d in self._downloaders)) - - def source_of(self, mod: ListedMod) -> Downloader | None: - """The downloader that can update *mod*, if any.""" - return next((d for d in self._downloaders if d.updatable_id(mod)), None) - - def updatable_id(self, mod: ListedMod) -> str | None: - source = self.source_of(mod) - return None if source is None else source.updatable_id(mod) - - def updatable_ids(self, mods: Iterable[ListedMod]) -> list[str]: - mods = list(mods) - ids: dict[str, None] = {} - for downloader in self._downloaders: - ids.update(dict.fromkeys(downloader.updatable_ids(mods))) - return list(ids) - - def names_by_id(self, mods: Iterable[ListedMod]) -> dict[str, str]: - """Display names of installed *mods*, keyed by their source id.""" - return { - mod_id: mod.name - for mod in mods - if (mod_id := self.updatable_id(mod)) is not None - } - - def last_synced(self, mod: ListedMod) -> float | None: - source = self.source_of(mod) - return None if source is None else source.last_synced(mod) - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download *mod_ids*, one downloader after another (single-flight overall).""" - groups: dict[Downloader, list[str]] = {} - for mod_id in dict.fromkeys(mod_ids): - owner = next((d for d in self._downloaders if d.accepts(mod_id)), None) - if owner is None: - raise ValueError(f"No download source handles '{mod_id}'.") - groups.setdefault(owner, []).append(mod_id) - if not groups: - raise ValueError("No mods selected for download.") - if self.is_downloading: - raise RuntimeError("A download is already running.") - - self._cancelled = False - total = DownloadResult(succeeded=[], failed=[]) - for downloader, group in groups.items(): - if self._cancelled: - break - result = await downloader.download_mods(group) - total.succeeded += result.succeeded - total.failed += result.failed - total.changed += result.changed - return total - - @property - def cancelled(self) -> bool: - return self._cancelled - - def cancel(self) -> None: - self._cancelled = True - for downloader in self._downloaders: - if downloader.is_downloading: - downloader.cancel() - - -def download_manager(ctx: CoreContext) -> DownloadManager: - manager = ctx.plugins.get(MANAGER_NAME) - if isinstance(manager, DownloadManager): - return manager - raise RuntimeError(f"'{MANAGER_NAME}' plugin is not registered") diff --git a/legacy/pxmodrim/core/downloads/steam.py b/legacy/pxmodrim/core/downloads/steam.py deleted file mode 100644 index 7e4f113..0000000 --- a/legacy/pxmodrim/core/downloads/steam.py +++ /dev/null @@ -1,499 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import functools -import re -import time -from collections.abc import Awaitable, Callable, Sequence -from os import PathLike -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -import msgspec -import pxsteamdl -from loguru import logger - -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.plugin_config import PluginConfig - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - -# PxSteamDL runs one shared pool of parallel_items * threads_per_item workers. -MAX_PARALLEL_ITEMS = 8 -MAX_THREADS_PER_ITEM = 16 - -_PROGRESS_LOG_INTERVAL_S = 15.0 -_PROGRESS_EMIT_INTERVAL_S = 0.1 -_SYNC_FILE = "workshop_sync.json" -AUTO_STARTUP_DELAY_S = 45.0 -AUTO_CHECK_INTERVAL_S = 600.0 -_MAX_PUBLISHED_FILE_ID = 2**64 - 1 -_PUBLISHED_FILE_ID_RE = re.compile(r"[0-9]+") - - -class WorkshopClient(Protocol): - def download( - self, - ids: Sequence[int], - root: str | PathLike[str], - *, - parallel_items: int = ..., - threads_per_item: int = ..., - on_progress: Callable[[pxsteamdl.Progress], None] | None = None, - on_resolved: Callable[[pxsteamdl.ItemInfo], None] | None = None, - cancel: pxsteamdl.CancelToken | None = None, - ) -> list[pxsteamdl.Result]: ... - - -ClientOptions = tuple[str | None, int, int] -ClientFactory = Callable[[ClientOptions], Awaitable[WorkshopClient]] - - -async def _login(options: ClientOptions) -> WorkshopClient: - proxy, connect_timeout, stall_timeout = options - return await asyncio.to_thread( - pxsteamdl.Client, - proxy=proxy, - connect_timeout=connect_timeout, - stall_timeout=stall_timeout, - ) - - -def _validate_published_file_ids(publishedfileids: list[str]) -> None: - if any( - not isinstance(pfid, str) - or _PUBLISHED_FILE_ID_RE.fullmatch(pfid) is None - or int(pfid) > _MAX_PUBLISHED_FILE_ID - for pfid in publishedfileids - ): - raise ValueError( - "Published file IDs must be ASCII decimal numbers within uint64." - ) - - -class WorkshopSyncState(msgspec.Struct): - """When each Workshop item was last synced with Steam (unix seconds).""" - - synced: dict[str, float] = msgspec.field(default_factory=dict) - - -class _Batch: - __slots__ = ( - "bytes", - "failed", - "last_emitted", - "last_logged", - "resolved", - "seen", - "started", - "succeeded", - "total", - ) - - def __init__(self, total: int) -> None: - self.total = total - self.started = time.monotonic() - self.last_logged = self.started - self.last_emitted = 0.0 - self.seen = 0 - self.resolved = 0 - self.bytes: dict[str, tuple[int, int]] = {} - self.succeeded: list[str] = [] - self.failed: list[str] = [] - - def changed(self) -> list[str]: - return [pid for pid in self.succeeded if self.bytes.get(pid, (0, 0))[1] > 0] - - def progress(self) -> DownloadProgress: - # pxsteamdl returns results only after the whole batch, so an item that - # reports all its bytes (or 0/0 for an up-to-date copy) is counted as done - # before its Result arrives. - finished = set(self.succeeded) | set(self.failed) - finished.update( - pid for pid, (done, total) in self.bytes.items() if done >= total - ) - return DownloadProgress( - total=self.total, - completed=len(finished), - bytes_done=sum(done for done, _ in self.bytes.values()), - bytes_total=sum(total for _, total in self.bytes.values()), - ) - - -class SteamSettings(msgspec.Struct, frozen=True): - auto_update_hours: int = 0 - parallel_items: int = 8 - threads_per_item: int = 1 - proxy: str = "" - connect_timeout: int = 10 - stall_timeout: int = 30 - - -_LEGACY_SETTINGS = { - f"workshop_{field}": field for field in SteamSettings.__struct_fields__ -} - - -class SteamDownloader(Downloader): - """Downloads Steam Workshop items into the local mods folder via PxSteamDL.""" - - name = "steam_downloader" - label = "Steam Workshop" - - def __init__(self, client_factory: ClientFactory | None = None) -> None: - super().__init__() - self._ctx: CoreContext | None = None - self._client_factory: ClientFactory = client_factory or _login - self._client: WorkshopClient | None = None - self._client_options: ClientOptions | None = None - self._token: pxsteamdl.CancelToken | None = None - self._active_ids: frozenset[str] = frozenset() - self._sync = WorkshopSyncState() - self._auto_task: asyncio.Task[None] | None = None - self._running: asyncio.Future[list[pxsteamdl.Result]] | None = None - self.settings = PluginConfig(None, self.name, SteamSettings) - - def setup(self, ctx: CoreContext) -> None: - super().setup(ctx) - self._ctx = ctx - if ctx.has_config_service: - self.settings = PluginConfig( - ctx.config_service, self.name, SteamSettings, legacy=_LEGACY_SETTINGS - ) - - async def init(self, ctx: CoreContext) -> None: - self._sync = await asyncio.to_thread( - ctx.config_service.load, _SYNC_FILE, WorkshopSyncState - ) - self._auto_task = asyncio.create_task(self._auto_update_loop()) - - async def shutdown(self) -> None: - if self._auto_task is not None: - self._auto_task.cancel() - await asyncio.wait([self._auto_task]) - self._auto_task = None - self.cancel() - running = self._running - if running is not None: - await asyncio.wait([running]) - - @property - def active_ids(self) -> frozenset[str]: - """Published file ids of the batch currently downloading.""" - return self._active_ids - - @property - def is_downloading(self) -> bool: - return self._token is not None - - def accepts(self, mod_id: str) -> bool: - return _PUBLISHED_FILE_ID_RE.fullmatch(mod_id) is not None - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download items into ``paths.local//``; per-item failures don't raise. - - ``cancel()`` stops the batch: finished items stay, the rest are dropped. - """ - if not mod_ids: - raise ValueError("No mods selected for download.") - _validate_published_file_ids(mod_ids) - if self._ctx is None: - raise RuntimeError("SteamDownloader used before setup()") - local = self._ctx.config.paths.local - if not local: - raise ValueError("Local mods path is not configured.") - if self.is_downloading: - raise RuntimeError("A download is already running.") - - ids = list(dict.fromkeys(mod_ids)) - root = Path(local) - batch = _Batch(len(ids)) - token = pxsteamdl.CancelToken() - self._token = token - self._active_ids = frozenset(ids) - self.busy_changed.emit(True) - self.batch_started.emit(list(ids)) - logger.info( - "[workshop] downloading {} items into {} (first ids: {})", - len(ids), - root, - ids[:5], - ) - try: - await self._run(ids, root, batch, token) - finally: - self._token = None - self._active_ids = frozenset() - self.busy_changed.emit(False) - result = DownloadResult( - succeeded=batch.succeeded, - failed=batch.failed, - changed=batch.changed(), - ) - logger.info( - "[workshop] finished in {:.1f}s: {} ok, {} failed{}", - time.monotonic() - batch.started, - len(result.succeeded), - len(result.failed), - f" (failed ids: {result.failed[:20]})" if result.failed else "", - ) - self._record_synced(result.succeeded) - self.download_finished.emit(result) - return result - - def updatable_id(self, mod: ListedMod) -> str | None: - """Workshop id of *mod* if it is a PxModRim download in ``paths.local``.""" - if self._ctx is None or not self._ctx.config.paths.local: - return None - path = mod.mod_path - if path is None or path.parent != Path(self._ctx.config.paths.local): - return None - pfid = mod.published_file_id - return pfid if pfid == path.name else None - - def last_synced(self, mod: ListedMod) -> float | None: - pid = self.updatable_id(mod) - return self._sync.synced.get(pid) if pid else None - - def stale_ids(self, max_age_s: float, now: float | None = None) -> list[str]: - """Updatable ids never synced or synced over *max_age_s* ago, oldest first.""" - cutoff = (time.time() if now is None else now) - max_age_s - synced = self._sync.synced - if self._ctx is None: - return [] - stale = [ - pid - for pid in self.updatable_ids(self._ctx.all_mods.values()) - if synced.get(pid, 0.0) < cutoff - ] - return sorted(stale, key=lambda pid: synced.get(pid, 0.0)) - - def _record_synced(self, ids: list[str]) -> None: - if not ids or self._ctx is None: - return - now = time.time() - self._sync.synced.update(dict.fromkeys(ids, now)) - try: - self._ctx.config_service.save(_SYNC_FILE, self._sync) - except OSError as exc: - logger.warning("[workshop] cannot save sync times: {}", exc) - - async def _auto_update_loop(self) -> None: - await asyncio.sleep(AUTO_STARTUP_DELAY_S) - while True: - try: - await self._auto_update_once() - except Exception: # noqa: BLE001 - logger.exception("[workshop] auto-update iteration failed") - await asyncio.sleep(AUTO_CHECK_INTERVAL_S) - - async def _auto_update_once(self) -> None: - if self._ctx is None or self.is_downloading: - return - hours = self.settings.value.auto_update_hours - if hours <= 0: - return - ids = self.stale_ids(hours * 3600) - if not ids: - return - logger.info("[workshop] auto-update: {} mods older than {}h", len(ids), hours) - try: - await self.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[workshop] auto-update not started: {}", exc) - - def cancel(self) -> None: - token = self._token - if token is not None: - logger.debug("[workshop] cancel requested") - token.cancel() - - async def _run( - self, - ids: list[str], - root: Path, - batch: _Batch, - token: pxsteamdl.CancelToken, - ) -> None: - try: - await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True) - login_started = time.monotonic() - if self._client is None: - self.download_phase_changed.emit("login") - client = await self._ensure_client() - logger.debug( - "[workshop] steam session ready in {:.1f}s", - time.monotonic() - login_started, - ) - except (RuntimeError, OSError) as exc: - message = ( - f"Steam login failed: {exc}" - if isinstance(exc, RuntimeError) - else f"Cannot create {root}: {exc}" - ) - logger.error("[workshop] {}", message) - self.status_message_changed.emit(message) - for pid in ids: - self._finish_item(batch, pid, error=message) - return - - cfg = self.settings.value - parallel_items = min(MAX_PARALLEL_ITEMS, max(1, cfg.parallel_items)) - threads_per_item = min(MAX_THREADS_PER_ITEM, max(1, cfg.threads_per_item)) - logger.debug( - "[workshop] client.download start: {} items, {} parallel x {} threads", - len(ids), - parallel_items, - threads_per_item, - ) - loop = asyncio.get_running_loop() - - def on_resolved(info: pxsteamdl.ItemInfo) -> None: - # Called on the download() thread. - loop.call_soon_threadsafe( - self._on_resolved, batch, str(info.item_id), info.title - ) - - def on_progress(p: pxsteamdl.Progress) -> None: - loop.call_soon_threadsafe( - self._on_progress, - batch, - str(p.item_id), - p.unpacked_bytes, - p.unpacked_total, - ) - - self.download_phase_changed.emit("query") - running = loop.run_in_executor( - None, - functools.partial( - client.download, - [int(pid) for pid in ids], - root, - parallel_items=parallel_items, - threads_per_item=threads_per_item, - on_progress=on_progress, - on_resolved=on_resolved, - cancel=token, - ), - ) - self._running = running - try: - results = await asyncio.shield(running) - except asyncio.CancelledError: - token.cancel() - while not running.done(): - with contextlib.suppress(asyncio.CancelledError): - await asyncio.wait([running]) - raise - except (RuntimeError, OSError) as exc: - self._client = None - for pid in ids: - self._finish_item(batch, pid, error=str(exc) or type(exc).__name__) - return - finally: - self._running = None - - # A network failure leaves the Steam session suspect; log in again next time. - if any(r.error_kind == pxsteamdl.ErrorKind.NETWORK for r in results): - self._client = None - - # Flush progress callbacks queued before the executor returned. - await asyncio.sleep(0) - by_id = {str(r.item_id): r for r in results} - for pid in ids: - result = by_id.get(pid) - if result is None: - self._finish_item(batch, pid, error="no result") - elif not result.cancelled: - self._finish_item(batch, pid, error=result.error) - - def _wanted_options(self) -> ClientOptions: - cfg = self.settings.value - return ( - cfg.proxy.strip() or None, - max(1, cfg.connect_timeout), - max(1, cfg.stall_timeout), - ) - - async def _ensure_client(self) -> WorkshopClient: - options = self._wanted_options() - if self._client is not None and options != self._client_options: - self._client = None - if self._client is None: - self.status_message_changed.emit("Logging in to Steam\u2026") - self._client = await self._client_factory(options) - self._client_options = options - logger.debug("[workshop] pxsteamdl {} logged in", pxsteamdl.__version__) - return self._client - - def _on_resolved(self, batch: _Batch, pid: str, title: str) -> None: - batch.resolved += 1 - if title: - self.download_item_titled.emit(DownloadItemTitle(pid, title)) - if batch.resolved >= batch.total: - self.download_phase_changed.emit("run") - - def _on_progress(self, batch: _Batch, pid: str, done: int, total: int) -> None: - if pid in batch.succeeded or pid in batch.failed: - return - if pid not in batch.bytes: - batch.seen += 1 - logger.debug( - "[workshop] {} first progress: {}/{} bytes (item {} of {})", - pid, - done, - total, - batch.seen, - batch.total, - ) - batch.bytes[pid] = (done, total) - now = time.monotonic() - # pxsteamdl reports per network chunk (thousands/s); UI needs far less. - if done >= total or now - batch.last_emitted >= _PROGRESS_EMIT_INTERVAL_S: - batch.last_emitted = now - self._emit_item_progress(batch, pid, done, total) - if now - batch.last_logged >= _PROGRESS_LOG_INTERVAL_S: - batch.last_logged = now - progress = batch.progress() - logger.info( - "[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", - progress.completed, - progress.total, - progress.bytes_done / 2**20, - now - batch.started, - ) - - def _emit_item_progress( - self, batch: _Batch, pid: str, done: int, total: int - ) -> None: - self.download_item_status_changed.emit( - DownloadItemStatus( - mod_id=pid, status="downloading", bytes_done=done, bytes_total=total - ) - ) - self.download_progress.emit(batch.progress()) - - def _finish_item(self, batch: _Batch, pid: str, *, error: str) -> None: - _, total = batch.bytes.get(pid, (0, 0)) - if error: - logger.warning("[workshop] {} failed: {}", pid, error) - batch.failed.append(pid) - status = DownloadItemStatus(mod_id=pid, status="error", error=error) - else: - batch.succeeded.append(pid) - batch.bytes[pid] = (total, total) - status = DownloadItemStatus( - mod_id=pid, status="success", bytes_done=total, bytes_total=total - ) - self.download_item_status_changed.emit(status) - self.download_progress.emit(batch.progress()) diff --git a/legacy/pxmodrim/core/downloads/types.py b/legacy/pxmodrim/core/downloads/types.py deleted file mode 100644 index 84ecc24..0000000 --- a/legacy/pxmodrim/core/downloads/types.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import msgspec - - -class DownloadProgress(msgspec.Struct): - total: int - completed: int - bytes_done: int - bytes_total: int - - -class DownloadItemStatus(msgspec.Struct): - mod_id: str - status: str # "downloading" | "success" | "error" - bytes_done: int = 0 - bytes_total: int = 0 - error: str = "" - - -class DownloadItemTitle(msgspec.Struct): - mod_id: str - title: str - - -class DownloadResult(msgspec.Struct): - succeeded: list[str] - failed: list[str] - # Succeeded items that actually transferred data (others were already current). - changed: list[str] = msgspec.field(default_factory=list) diff --git a/legacy/pxmodrim/core/events.py b/legacy/pxmodrim/core/events.py deleted file mode 100644 index 661e8ad..0000000 --- a/legacy/pxmodrim/core/events.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -class Event[T]: - __slots__ = ("_handlers",) - - def __init__(self) -> None: - self._handlers: list[Callable[[T], Any]] = [] - - def connect(self, handler: Callable[[T], Any]) -> None: - if handler not in self._handlers: - self._handlers.append(handler) - - def disconnect(self, handler: Callable[[T], Any]) -> None: - self._handlers.remove(handler) - - def emit(self, data: T) -> None: - for handler in self._handlers: - handler(data) diff --git a/legacy/pxmodrim/core/loading.py b/legacy/pxmodrim/core/loading.py deleted file mode 100644 index b9f02e6..0000000 --- a/legacy/pxmodrim/core/loading.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass - -from PySide6.QtCore import QObject, Signal - - -@dataclass(slots=True) -class StackFrame: - status: str - current_step: int = 0 - total_steps: int = 100 - - -class LoadingState(QObject): - """Stack-based loading state for a single async operation.""" - - changed = Signal(str, int, int, int) # status, pct, current, total - finished = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - """Initialise with an empty stack and no active task.""" - super().__init__(parent) - self._stack: list[StackFrame] = [] - - @property - def statuses(self) -> tuple[str, ...]: - """Status text of every active frame, outermost first.""" - return tuple(frame.status for frame in self._stack) - - def push(self, status: str, total_steps: int = 100) -> None: - """Start a new subtask, pushing a frame onto the loading stack.""" - self._stack.append(StackFrame(status=status, total_steps=total_steps)) - self._emit() - - def pop(self) -> None: - """Finish the current subtask and restore the parent frame.""" - if self._stack: - self._stack.pop() - if not self._stack: - self.finished.emit() - else: - self._emit() - - def step(self, amount: int = 1) -> None: - """Advance the current task's progress by *amount* steps.""" - if self._stack: - frame = self._stack[-1] - frame.current_step = min(frame.total_steps, frame.current_step + amount) - self._emit() - - def set_total(self, total: int) -> None: - """Update the total step count for the current task.""" - if self._stack: - self._stack[-1].total_steps = max(1, total) - self._emit() - - def set_progress(self, value: int) -> None: - """Set the current task's progress to an absolute *value*.""" - if self._stack: - frame = self._stack[-1] - frame.current_step = max(0, min(frame.total_steps, value)) - self._emit() - - def _emit(self) -> None: - if not self._stack: - return - frame = self._stack[-1] - pct = ( - int((frame.current_step / frame.total_steps) * 100) - if frame.total_steps - else 0 - ) - self.changed.emit(frame.status, pct, frame.current_step, frame.total_steps) - - @contextmanager - def task( - self, status: str, total_steps: int = 100 - ) -> Generator[LoadingState, None, None]: - """Context manager: push a frame on enter, pop on exit (even on exception).""" - self.push(status, total_steps) - try: - yield self - finally: - self.pop() diff --git a/legacy/pxmodrim/core/migrator.py b/legacy/pxmodrim/core/migrator.py deleted file mode 100644 index fcc19c0..0000000 --- a/legacy/pxmodrim/core/migrator.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import shutil -from collections.abc import Awaitable, Callable -from pathlib import Path -from time import time -from typing import Any - -from loguru import logger - -MigrationFn = Callable[[], Awaitable[None]] -OnStepSuccess = Callable[[int], Awaitable[None]] - - -async def ensure_schema( - conn: Any, - *, - schema_sql: str, - schema_version: int, - steps: dict[int, MigrationFn], - backup_path: Path | None = None, - label: str = "schema", -) -> None: - """Stamp, init, or migrate a ``PRAGMA user_version`` schema to ``schema_version``. - - ``steps`` maps every target version to a migration coroutine. A file copy - backup of ``backup_path`` is made before a non-fresh migration. - """ - row = await conn.execute("PRAGMA user_version") - fetched = await row.fetchone() - current = fetched[0] if fetched is not None else 0 - - if current >= schema_version: - return - - async def stamp(v: int) -> None: - await conn.execute(f"PRAGMA user_version = {v}") - - if current == 0: - logger.info("Initializing {} (version {})", label, schema_version) - await conn.executescript(schema_sql) - await stamp(schema_version) - return - - logger.info("Migrating {} {} -> {}", label, current, schema_version) - if backup_path is not None: - await conn.execute("PRAGMA wal_checkpoint(FULL)") - backup = backup_path.with_suffix(f".db.bak.{int(time())}") - shutil.copy2(backup_path, backup) - - targets = range(current + 1, schema_version + 1) - for target in targets: - if target not in steps: - raise KeyError(f"Missing migration step for target version {target}") - - migrator = Migrator({target: steps[target] for target in targets}) - await migrator.migrate(current, on_step=stamp) - - -class Migrator: - __slots__ = ("_latest", "_steps") - - def __init__(self, steps: dict[int, MigrationFn]) -> None: - if not steps: - raise ValueError("steps must not be empty") - self._latest = max(steps.keys()) - self._steps = steps - - async def migrate(self, current: int, on_step: OnStepSuccess | None = None) -> bool: - if current >= self._latest: - return False - - for target in range(current + 1, self._latest + 1): - fn = self._steps.get(target) - if fn is None: - raise KeyError(f"Missing migration step for target version {target}") - await fn() - if on_step is not None: - await on_step(target) - - return True diff --git a/legacy/pxmodrim/core/mod_service.py b/legacy/pxmodrim/core/mod_service.py deleted file mode 100644 index a65ee36..0000000 --- a/legacy/pxmodrim/core/mod_service.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.events import Event -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, - ModsConfig, -) -from pxmodrim.core.mods_config import ( - create_snapshot, - get_snapshots, - parse_mods_config, - restore_snapshot, - write_mods_config, -) -from pxmodrim.core.profiler import adopt -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.metadata_cache import ( - MetadataCache, - metadata_cache_path, - normalize_paths, -) -from pxmodrim.core.services.mod_discovery import resolve_active_uuids -from pxmodrim.core.services.startup_impact_service import StartupImpactService -from pxmodrim.core.structures import CollectionStats - - -class ModService: - """Orchestrates mod discovery and lifecycle across a set of providers.""" - - __slots__ = ( - "_ctx", - "_metadata_cache", - "_providers", - "_save_lock", - "_startup_impact_service", - "mods_changed", - ) - - def __init__( - self, - ctx: CoreContext, - providers: list[BaseModProvider], - metadata_cache: MetadataCache | None = None, - ) -> None: - self._ctx = ctx - self._save_lock = asyncio.Lock() - self._providers: dict[str, BaseModProvider] = { - p.provider_id: p for p in providers - } - self._startup_impact_service = StartupImpactService(ctx) - if metadata_cache is not None: - self._metadata_cache = metadata_cache - elif ctx.has_config_service: - self._metadata_cache = MetadataCache( - metadata_cache_path(ctx.config_service.config_dir) - ) - else: - self._metadata_cache = None - self.mods_changed: Event[None] = Event() - - @property - def startup_impact(self) -> StartupImpactService: - return self._startup_impact_service - - def reset_providers(self, providers: list[BaseModProvider]) -> None: - """Replace the current provider set with a new list.""" - self._providers = {p.provider_id: p for p in providers} - - def get_provider(self, provider_id: str) -> BaseModProvider | None: - """Retrieve a provider by its ``provider_id``, or ``None``.""" - return self._providers.get(provider_id) - - @property - def provider_colors(self) -> dict[str, str]: - return {pid: p.color for pid, p in self._providers.items()} - - async def discover( - self, timer: Timer | None = None, force_reparse: bool = False - ) -> None: - """Run all providers' discovery in parallel and load results into context.""" - own = timer is None - if own: - timer = Timer() - with timer("discover"): - async with asyncio.TaskGroup() as tg: - provs = [ - tg.create_task(self._discover_one(p, force_reparse)) - for p in self._providers.values() - ] - all_mods: dict[str, ListedMod] = {} - for coro in provs: - mods, child = coro.result() - adopt(timer, child) - all_mods.update(mods) - with timer("cache_prune"): - if self._metadata_cache is not None: - mod_paths = [ - m.mod_path for m in all_mods.values() if m.mod_path is not None - ] - live_paths = await asyncio.to_thread( - lambda: set(normalize_paths(mod_paths)) - ) - await self._metadata_cache.prune_disappeared(live_paths) - with timer("resolve_active"): - active = resolve_active_uuids( - all_mods, self._ctx.config.paths.config_folder - ) - self._ctx.load(all_mods, active) - if own: - logger.debug("Profile report:\n{}", timer.render()) - - async def _discover_one( - self, p: BaseModProvider, force_reparse: bool = False - ) -> tuple[dict[str, ListedMod], Timer]: - t = Timer() - with t(f"provider.{p.provider_id}"): - mods = await p.discover( - self._ctx.target_version, - timer=t, - metadata_cache=self._metadata_cache, - force_reparse=force_reparse, - ) - return mods, t - - async def _timed(self, label: str, fn) -> Timer: - """Run *fn(timer)* wrapped in *label*, return the populated Timer.""" - t = Timer() - with t(label): - await fn(t) - return t - - async def _run_refresh_pipeline( - self, label: str, force_reparse: bool = False - ) -> None: - """Discover mods, initialize diagnostics and load startup impact.""" - timer = Timer() - with timer(f"mod_service.{label}"): - async with asyncio.TaskGroup() as tg: - d = tg.create_task( - self._timed( - "discover", - lambda t: self.discover(timer=t, force_reparse=force_reparse), - ) - ) - diag = tg.create_task( - self._timed( - "diagnostics", - lambda t: self._ctx.diagnostics_service.initialize(timer=t), - ) - ) - si = tg.create_task( - self._timed( - "startup_impact", - lambda _: self._startup_impact_service.load(), - ) - ) - adopt(timer, d.result()) - adopt(timer, diag.result()) - adopt(timer, si.result()) - self._ctx.diagnostics_service.rebuild(self._ctx.active_uuids, timer=timer) - logger.debug("Profile report:\n{}", timer.render()) - self.mods_changed.emit(None) - - def _snapshot_current_config(self) -> Path | None: - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - return None - config_path = Path(config_folder) / "ModsConfig.xml" - if not config_path.is_file(): - return None - return create_snapshot( - config_path, - snapshots_dir, - max_snapshots=self._ctx.config.max_snapshots, - ) - - async def initialize(self) -> None: - """One‑time startup initialisation: discover, init diagnostics, load impact.""" - self._snapshot_current_config() - await self._run_refresh_pipeline("initialize") - - async def reload(self, full: bool = False) -> None: - """Reparse all About.xml files when full is requested.""" - await self._run_refresh_pipeline("reload", force_reparse=full) - - def compute_stats(self, active_ids: list[str] | None = None) -> CollectionStats: - return self._ctx.compute_stats(active_ids) - - def _snapshots_dir(self) -> Path | None: - if not self._ctx.has_config_service: - return None - return self._ctx.config_service.config_dir / "snapshots" - - def get_snapshots(self) -> list[Path]: - """Return available mod-list snapshots, sorted newest first.""" - snapshots_dir = self._snapshots_dir() - return get_snapshots(snapshots_dir) if snapshots_dir is not None else [] - - async def restore_snapshot(self, snapshot_path: Path) -> bool: - """Restore a snapshot to ModsConfig.xml and reload live state.""" - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - logger.warning("Cannot restore snapshot: configuration is incomplete") - return False - - restored = restore_snapshot( - snapshot_path, - Path(config_folder) / "ModsConfig.xml", - snapshots_dir, - max_snapshots=self._ctx.config.max_snapshots, - ) - if restored: - await self.reload() - return restored - - async def save_active_layout(self, active_ids: list[str]) -> bool: - """Persist the given active UUIDs as a ModsConfig.xml file.""" - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - return False - - mods = self._ctx.all_mods - package_ids: list[CaseInsensitiveStr] = [] - for uuid in active_ids: - mod = mods.get(uuid) - if isinstance(mod, AboutXmlMod): - package_ids.append(mod.package_id) - config_path = Path(config_folder) / "ModsConfig.xml" - target_version = self._ctx.target_version - max_snapshots = self._ctx.config.max_snapshots - - def _write() -> None: - existing = parse_mods_config(config_path) - data = ModsConfig( - version=target_version, - activeMods=package_ids, - knownExpansions=existing.knownExpansions if existing else [], - ) - write_mods_config( - config_path, data, snapshots_dir, max_snapshots=max_snapshots - ) - - async with self._save_lock: - await asyncio.to_thread(_write) - return True - - async def close(self) -> None: - if self._metadata_cache is not None: - await self._metadata_cache.close() - - def close_sync(self) -> None: - if self._metadata_cache is not None: - self._metadata_cache.close_sync() diff --git a/legacy/pxmodrim/core/models/__init__.py b/legacy/pxmodrim/core/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/core/models/metadata/__init__.py b/legacy/pxmodrim/core/models/metadata/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/core/models/metadata/parsing.py b/legacy/pxmodrim/core/models/metadata/parsing.py deleted file mode 100644 index 23b9844..0000000 --- a/legacy/pxmodrim/core/models/metadata/parsing.py +++ /dev/null @@ -1,748 +0,0 @@ -from __future__ import annotations - -import traceback -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -import lxml.etree as ET -from loguru import logger - -from pxmodrim.core.constants import ( - DEFAULT_MISSING_PACKAGEID, - RIMWORLD_DLC_METADATA, - RIMWORLD_STEAM_APP_ID, -) -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.utils import find_about_xml -from pxmodrim.core.xml import _text - - -class MalformedDataException(Exception): - """Raised when parsed About.xml data is structurally invalid.""" - - def __init__(self, message: str): - super().__init__(message) - self.message = f"Malformed data: {message}" - - -def value_extractor( - input: dict[str, str] | dict[str, list[str]] | Sequence[str] | str | None, - strip_str: bool = True, -) -> str | list[Any] | dict[str, str] | dict[str, list[str]] | None: - """Normalize XML-parsed values, unwrap single-entry dicts and strip strings.""" - if input is None: - return None - if isinstance(input, str): - return input.strip() if strip_str else input - if isinstance(input, Sequence): - return [value_extractor(item) for item in input] - if isinstance(input, dict): - if len(input) == 1: - return value_extractor(next(iter(input.values()))) - if input.keys() == {"@IgnoreIfNoMatchingField", "#text"}: - return input["#text"] - return input - return input - - -def match_version( - input: dict[str, str] | dict[str, list[str]], - target_version: str, - stop_at_first: bool = True, -) -> tuple[bool, None | list[str] | str]: - """Match a versioned dict entry by major.minor, return value(s) on success.""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return False, None - version_key = f"v{major}.{minor}" - - if stop_at_first: - result = input.get(version_key) or input.get(f"{major}.{minor}") - if result is not None: - return True, result - - results = [] - for key, value in input.items(): - if key == version_key: - if stop_at_first: - return True, value - if isinstance(value, list): - results.extend(value) - else: - results.append(value) - - if not results: - return False, None - - return True, results - - -def _set_mod_invalid(mod: ListedMod, message: str) -> ListedMod: - mod.valid = False - logger.warning(message) - return mod - - -def _official_mod_name(package_id: str, name: str) -> str: - if name and name not in {"Unknown Mod Name", package_id}: - return name - if package_id == "ludeon.rimworld": - return "Core" - dlc_appid = _get_dlc_packageid_map().get(package_id) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - if dlc_meta: - return dlc_meta["name"].removeprefix("RimWorld - ") - return package_id - - -def _parse_basic(mod_data: dict[str, Any], mod: AboutXmlMod) -> AboutXmlMod: - """Populate an AboutXmlMod with basic fields (packageId, name, authors).""" - package_id = value_extractor(mod_data.get("packageId", False)) - if isinstance(package_id, str) and package_id.strip(): - mod.package_id = CaseInsensitiveStr(package_id) - else: - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - logger.warning( - f"packageId missing or invalid: {package_id}. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'." - ) - - steam_app_id = value_extractor(mod_data.get("steamAppId", False)) - if isinstance(steam_app_id, str) and steam_app_id.isdigit(): - mod.steam_app_id = int(steam_app_id) - elif str(mod.package_id) in _get_dlc_packageid_map(): - mod.steam_app_id = int(_get_dlc_packageid_map()[str(mod.package_id)]) - - dlc_appid = _get_dlc_packageid_map().get(str(mod.package_id)) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - - name = value_extractor(mod_data.get("name", False)) - mod.name = _official_mod_name( - str(mod.package_id), name if isinstance(name, str) else "" - ) - - description = value_extractor(mod_data.get("description", False)) - if isinstance(description, str): - mod.description = description - elif dlc_meta: - mod.description = dlc_meta["description"] - - author = value_extractor(mod_data.get("author", False)) - authors = value_extractor(mod_data.get("authors", False)) - - if isinstance(author, str): - mod.authors.append(author) - - normalized_authors: list[str] = [] - if isinstance(authors, dict) and authors.get("li"): - li = authors.get("li") - if isinstance(li, list): - normalized_authors = [str(a) for a in li if a] - elif isinstance(li, str): - normalized_authors = [li] - elif isinstance(authors, list): - normalized_authors = [str(a) for a in authors if a] - elif isinstance(authors, str): - normalized_authors = [authors] - else: - normalized_authors = [] - - mod.authors.extend(normalized_authors) - - supported_versions = value_extractor(mod_data.get("supportedVersions", False)) - if isinstance(supported_versions, list): - mod.supported_versions = set(supported_versions) - elif isinstance(supported_versions, str): - mod.supported_versions = {supported_versions} - - return mod - - -def _parse_optional( - mod_data: dict[str, Any], - mod: AboutXmlMod, - target_version: str, - prefer_versioned: bool = True, -) -> AboutXmlMod: - """Populate optional AboutXmlMod fields (modVersion, icon, url, rules).""" - mod_version = value_extractor(mod_data.get("modVersion", False)) - if mod_version and isinstance(mod_version, str): - mod.mod_version = mod_version - - mod_icon_path = value_extractor(mod_data.get("modIconPath", False)) - if mod_icon_path and isinstance(mod_icon_path, str): - mod.mod_icon_path = Path(mod_icon_path) - - url = value_extractor(mod_data.get("url", False)) - if url and isinstance(url, str): - mod.url = url - - mod.about_rules = create_base_rules(mod_data, target_version, prefer_versioned) - - dlc_map = _get_dlc_packageid_map() - str_pid = str(mod.package_id) - if str_pid in dlc_map and dlc_map[str_pid] != RIMWORLD_STEAM_APP_ID: - rimworld_pid = CaseInsensitiveStr("ludeon.rimworld") - if rimworld_pid not in mod.about_rules.dependencies: - mod.about_rules.dependencies[rimworld_pid] = DependencyMod( - package_id=rimworld_pid, - name="RimWorld", - workshop_url="https://store.steampowered.com/app/294100/RimWorld", - ) - - descriptions_by_version: bool | dict[str, str] = mod_data.get( - "descriptionsByVersion", False - ) - if isinstance(descriptions_by_version, dict): - _, description = match_version(descriptions_by_version, target_version) - if description and isinstance(description, str): - mod.description = description - - return mod - - -def _match_byversion_raw( - byversion_data: dict[str, Any], - target_version: str, -) -> tuple[bool, Any]: - """Match versioned raw data by major.minor key, returning the first match.""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return False, None - - version_key = f"v{major}.{minor}" - - if version_key in byversion_data: - return True, byversion_data[version_key] - if f"{major}.{minor}" in byversion_data: - return True, byversion_data[f"{major}.{minor}"] - - return False, None - - -def create_mod_dependency(input_dict: dict[str, str]) -> DependencyMod: - """Build a DependencyMod from a parsed dependency dictionary.""" - mod = DependencyMod() - package_id = input_dict.get("packageId", False) - if isinstance(package_id, str): - mod.package_id = CaseInsensitiveStr(package_id) - - name = input_dict.get("displayName", False) - if isinstance(name, str): - mod.name = name - - workshop_url = input_dict.get("workshopUrl", False) - if isinstance(workshop_url, str): - mod.workshop_url = workshop_url - - alts = input_dict.get("alternativePackageIds", False) - if isinstance(alts, list): - for a in alts: - if isinstance(a, str) and a.strip(): - mod.alternative_package_ids.add(CaseInsensitiveStr(a)) - - return mod - - -def create_base_rules( - mod_data: dict[str, Any], - target_version: str, - prefer_versioned: bool = True, -) -> BaseRules: - """Build BaseRules from parsed mod_data, resolving versioned deps and load-order.""" - rules = BaseRules() - - mod_dependencies = value_extractor(mod_data.get("modDependencies", [])) - if mod_dependencies is None: - mod_dependencies = [] - mod_dependencies = ( - mod_dependencies if isinstance(mod_dependencies, list) else [mod_dependencies] - ) - - if prefer_versioned: - byversion_deps = mod_data.get("modDependenciesByVersion", {}) - if isinstance(byversion_deps, dict) and byversion_deps: - matched, versioned_deps_raw = _match_byversion_raw( - byversion_deps, target_version - ) - if matched: - versioned_deps = ( - value_extractor(versioned_deps_raw) if versioned_deps_raw else [] - ) - mod_dependencies = ( - versioned_deps - if isinstance(versioned_deps, list) - else [versioned_deps] - if versioned_deps - else [] - ) - - for dependency in mod_dependencies: - if isinstance(dependency, dict): - if not dependency or dependency.get("@isNull") == "True": - continue - deps: dict[str, Any] = {} - for key, value in dependency.items(): - if isinstance(value, str): - deps[key] = value - elif key == "alternativePackageIds" and isinstance(value, dict): - alt_li = value.get("li") - alt_list: list[str] = [] - if isinstance(alt_li, list): - for v in alt_li: - if isinstance(v, str): - alt_list.append(v) - elif ( - isinstance(v, dict) - and "#text" in v - and isinstance(v["#text"], str) - ): - alt_list.append(v["#text"]) - elif isinstance(alt_li, str): - alt_list.append(alt_li) - if alt_list: - deps["alternativePackageIds"] = alt_list - elif isinstance(value, dict) and ( - not value or value.get("@isNull") == "True" - ): - continue - else: - logger.warning(f"Skipping invalid dependency value: {value}.") - - dep = create_mod_dependency(deps) - - if dep.package_id in rules.dependencies: - logger.warning( - f"Duplicate dependency found: {dep.package_id}. Skipping." - ) - else: - rules.dependencies[dep.package_id] = dep - elif dependency: - logger.warning(f"Skipping invalid dependency: {dependency}.") - - def load_operations( - mod_data: dict[str, Any], - key: str, - force_key: str, - target_version: str, - prefer_versioned: bool, - ) -> CaseInsensitiveSet: - load = value_extractor(mod_data.get(key, [])) - if load is None: - load = [] - load = load if isinstance(load, list) else [load] - - if prefer_versioned: - byversion = mod_data.get(f"{key}ByVersion", {}) - if isinstance(byversion, dict) and byversion: - matched, versioned_raw = _match_byversion_raw(byversion, target_version) - if matched: - versioned = value_extractor(versioned_raw) if versioned_raw else [] - load = ( - versioned - if isinstance(versioned, list) - else [versioned] - if versioned - else [] - ) - - force_load = value_extractor(mod_data.get(force_key, [])) - if force_load is None: - force_load = [] - force_load = force_load if isinstance(force_load, list) else [force_load] - load.extend(force_load) - - load = [item for item in load if isinstance(item, (str, CaseInsensitiveStr))] - - return CaseInsensitiveSet(load) - - rules.load_before = load_operations( - mod_data, "loadBefore", "forceLoadBefore", target_version, prefer_versioned - ) - - rules.load_after = load_operations( - mod_data, "loadAfter", "forceLoadAfter", target_version, prefer_versioned - ) - - incompatible_with = value_extractor(mod_data.get("incompatibleWith", [])) - if incompatible_with is None: - incompatible_with = [] - incompatible_with = ( - incompatible_with - if isinstance(incompatible_with, list) - else [incompatible_with] - ) - - if prefer_versioned: - byversion_incompat = mod_data.get("incompatibleWithByVersion", {}) - if isinstance(byversion_incompat, dict) and byversion_incompat: - matched, incompat_raw = _match_byversion_raw( - byversion_incompat, target_version - ) - if matched: - versioned_incompat = ( - value_extractor(incompat_raw) if incompat_raw else [] - ) - incompatible_with = ( - versioned_incompat - if isinstance(versioned_incompat, list) - else [versioned_incompat] - if versioned_incompat - else [] - ) - - incompatible_with = [ - item - for item in incompatible_with - if isinstance(item, (str, CaseInsensitiveStr)) - ] - rules.incompatible_with = CaseInsensitiveSet(incompatible_with) - - return rules - - -def create_about_mod( - mod_data: dict[str, Any], - target_version: str, - prefer_versioned: bool = True, -) -> tuple[bool, AboutXmlMod]: - """Create an AboutXmlMod from parsed XML data. Returns (valid, mod).""" - mod = _parse_basic(mod_data, AboutXmlMod()) - if str(mod.package_id) == DEFAULT_MISSING_PACKAGEID: - _set_mod_invalid(mod, "packageId missing or invalid.") - - if not isinstance(mod, AboutXmlMod): - ruled_mod = AboutXmlMod() - ruled_mod.__dict__ = mod.__dict__ - mod = ruled_mod - - mod = _parse_optional(mod_data, mod, target_version, prefer_versioned) - - return mod.valid, mod - - -def _match_versioned_child( - parent: ET._Element, target_version: str -) -> ET._Element | None: - """Find the versioned child element matching target_version (*.*).""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return None - for key in (f"v{major}.{minor}", f"{major}.{minor}"): - found = parent.find(key) - if found is not None: - return found - return None - - -def _dep_from_li(li: ET._Element) -> DependencyMod: - """Parse a

  • element from into a DependencyMod.""" - dep = DependencyMod() - pid = _text(li, "packageId") - if pid: - dep.package_id = CaseInsensitiveStr(pid) - name = _text(li, "displayName") - if name: - dep.name = name - url = _text(li, "workshopUrl") - if url: - dep.workshop_url = url - alt_el = li.find("alternativePackageIds") - if alt_el is not None: - for alt_li in alt_el: - if alt_li.tag == "li" and alt_li.text and alt_li.text.strip(): - dep.alternative_package_ids.add(CaseInsensitiveStr(alt_li.text.strip())) - return dep - - -def _element_value(el: ET._Element | None) -> str | list[str] | None: - """Extract text or
  • children as a string or list of strings.""" - if el is None: - return None - children = [ch for ch in el if ch.tag == "li"] - if children: - return [c.text.strip() for c in children if c.text and c.text.strip()] - if el.text: - text = el.text.strip() - return text or None - return None - - -def _create_about_mod_from_element( - root: ET._Element, - target_version: str, - prefer_versioned: bool = True, -) -> AboutXmlMod: - """Build AboutXmlMod in a single pass over the element children.""" - mod = AboutXmlMod() - rules = BaseRules() - has_package_id = False - - deps_el: ET._Element | None = None - deps_bv: bool = False - load_before_li: list[str] = [] - load_after_li: list[str] = [] - incompat_li: list[str] = [] - force_before_li: list[str] = [] - force_after_li: list[str] = [] - load_before_bv_el: ET._Element | None = None - load_after_bv_el: ET._Element | None = None - incompat_bv_el: ET._Element | None = None - - for child in root: - tag = child.tag - if tag == "packageId": - has_package_id = True - t = _element_value(child) - if isinstance(t, str) and t: - mod.package_id = CaseInsensitiveStr(t) - else: - _set_mod_invalid( - mod, - f"packageId missing or invalid: {t}. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'.", - ) - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - - elif tag == "steamAppId": - t = _element_value(child) - if isinstance(t, str) and t.isdigit(): - mod.steam_app_id = int(t) - - elif tag == "name": - t = _element_value(child) - if isinstance(t, str): - mod.name = t - - elif tag == "description": - t = _element_value(child) - if isinstance(t, str): - mod.description = t - - elif tag == "author": - t = _element_value(child) - if isinstance(t, str): - mod.authors.append(t) - - elif tag == "authors": - t = _element_value(child) - if isinstance(t, list): - mod.authors.extend(t) - - elif tag == "supportedVersions": - t = _element_value(child) - if isinstance(t, list): - mod.supported_versions = set(t) - - elif tag == "modVersion": - t = _element_value(child) - if isinstance(t, str): - mod.mod_version = t - - elif tag == "modIconPath": - t = _element_value(child) - if isinstance(t, str): - mod.mod_icon_path = Path(t) - - elif tag == "url": - t = _element_value(child) - if isinstance(t, str): - mod.url = t - - elif tag == "modDependencies": - if not deps_bv: - deps_el = child - elif tag == "modDependenciesByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - deps_el = matched - deps_bv = True - - elif tag == "loadBefore": - t = _element_value(child) - if isinstance(t, list): - load_before_li = t - elif tag == "loadBeforeByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - load_before_bv_el = matched - - elif tag == "loadAfter": - t = _element_value(child) - if isinstance(t, list): - load_after_li = t - elif tag == "loadAfterByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - load_after_bv_el = matched - - elif tag == "forceLoadBefore": - t = _element_value(child) - if isinstance(t, list): - force_before_li = t - elif tag == "forceLoadAfter": - t = _element_value(child) - if isinstance(t, list): - force_after_li = t - - elif tag == "incompatibleWith": - t = _element_value(child) - if isinstance(t, list): - incompat_li = t - elif tag == "incompatibleWithByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - incompat_bv_el = matched - - elif tag == "descriptionsByVersion": - matched = _match_versioned_child(child, target_version) - if matched is not None and matched.text and matched.text.strip(): - mod.description = matched.text.strip() - - if not has_package_id: - _set_mod_invalid( - mod, - "packageId missing or invalid. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'.", - ) - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - - # Apply versioned overrides - if load_before_bv_el is not None: - t = _element_value(load_before_bv_el) - if isinstance(t, list): - load_before_li = t - if load_after_bv_el is not None: - t = _element_value(load_after_bv_el) - if isinstance(t, list): - load_after_li = t - if incompat_bv_el is not None: - t = _element_value(incompat_bv_el) - if isinstance(t, list): - incompat_li = t - - # Combine force + regular - load_before_li.extend(force_before_li) - load_after_li.extend(force_after_li) - - # Build rules - rules.load_before = CaseInsensitiveSet(load_before_li) - rules.load_after = CaseInsensitiveSet(load_after_li) - rules.incompatible_with = CaseInsensitiveSet(incompat_li) - - # Process dependencies - if deps_el is not None: - for li in deps_el: - if li.tag != "li": - continue - if li.attrib.get("isNull") == "True": - continue - if not li.attrib and len(li) == 0: - continue - dep = _dep_from_li(li) - if dep.package_id in rules.dependencies: - logger.warning( - f"Duplicate dependency found: {dep.package_id}. Skipping." - ) - else: - rules.dependencies[dep.package_id] = dep - - mod.about_rules = rules - - str_pid = str(mod.package_id) - mod.name = _official_mod_name(str_pid, mod.name) - dlc_appid = _get_dlc_packageid_map().get(str_pid) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - if dlc_meta and not mod.description: - mod.description = dlc_meta["description"] - # DLC -> RimWorld dependency - dlc_map = _get_dlc_packageid_map() - if str_pid in dlc_map and dlc_map[str_pid] != RIMWORLD_STEAM_APP_ID: - rimworld_pid = CaseInsensitiveStr("ludeon.rimworld") - if rimworld_pid not in rules.dependencies: - rules.dependencies[rimworld_pid] = DependencyMod( - package_id=rimworld_pid, - name="RimWorld", - workshop_url="https://store.steampowered.com/app/294100/RimWorld", - ) - - return mod - - -def _create_about_mod_from_xml( - base_path: Path, - mod_xml_path: Path, - target_version: str, - prefer_versioned: bool = True, -) -> tuple[bool, AboutXmlMod]: - """Parse an About.xml file and return a validated AboutXmlMod with its path set.""" - try: - tree = ET.parse(str(mod_xml_path)) - except (OSError, TypeError, ET.XMLSyntaxError): - logger.error(f"Unable to parse {mod_xml_path}: {traceback.format_exc()}") - return False, AboutXmlMod(valid=False, _mod_path=base_path) - - root = tree.getroot() - if root is None: - logger.error(f"Could not parse {mod_xml_path}.") - return False, AboutXmlMod(valid=False) - - mod = _create_about_mod_from_element(root, target_version, prefer_versioned) - mod.mod_path = base_path - try: - mod.mtime = mod_xml_path.stat().st_mtime - except OSError: - mod.mtime = 0.0 - return mod.valid, mod - - -def create_listed_mod_from_path( - path: Path, - target_version: str, - prefer_versioned: bool = True, - about_xml_path: Path | None = None, -) -> tuple[bool, ListedMod]: - """Create a ListedMod from a directory path, parsing About.xml if present.""" - if path.is_dir(): - if about_xml_path is None: - about_xml_path = find_about_xml(path) - - if about_xml_path is not None: - success, about_mod = _create_about_mod_from_xml( - path, about_xml_path, target_version, prefer_versioned - ) - return success, about_mod - - rsc_files = list(path.glob("*.rsc")) - if len(rsc_files) > 1: - logger.warning(f"Multiple .rsc files found in {path}. Aborting.") - return False, ListedMod(valid=False, _mod_path=path) - - if len(rsc_files) == 1: - # Scenario .rsc files - not implemented in detail yet - logger.warning(f"Scenario .rsc files not yet supported: {path}") - return False, ListedMod(valid=False, _mod_path=path) - - logger.warning(f"No About.xml found in directory: {path}") - return False, ListedMod(valid=False, _mod_path=path) - - raise ValueError(f"Path must be a directory: {path}") - - -def _get_dlc_packageid_map() -> dict[str, str]: - return {dlc["packageid"]: appid for appid, dlc in RIMWORLD_DLC_METADATA.items()} diff --git a/legacy/pxmodrim/core/models/metadata/structures.py b/legacy/pxmodrim/core/models/metadata/structures.py deleted file mode 100644 index b37b78b..0000000 --- a/legacy/pxmodrim/core/models/metadata/structures.py +++ /dev/null @@ -1,412 +0,0 @@ -from __future__ import annotations - -import functools -import os -from collections.abc import Iterable, Iterator, MutableSet -from collections.abc import Set as AbstractSet -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal, Self -from uuid import uuid4 - -import msgspec - -from pxmodrim.core.constants import RIMWORLD_DLC_METADATA - - -@dataclass(slots=True) -class ReplacementInfo: - """Replacement info for a mod from the database or local data.""" - - name: str - author: str - packageid: str - pfid: str - supportedversions: list[str] - source: str = "database" - - -class CaseInsensitiveStr(str): - """Case-insensitive string that stores the lowered version.""" - - def __new__(cls, pid: str) -> Self: - return super().__new__(cls, pid.lower()) - - -class CaseInsensitiveSet(MutableSet[CaseInsensitiveStr]): - """Mutable set with case-insensitive string membership.""" - - def __init__( - self, - s: Iterable[CaseInsensitiveStr | str] | CaseInsensitiveStr | str | None = (), - ): - if isinstance(s, str): - data = {CaseInsensitiveStr(s)} - elif isinstance(s, Iterable): - data = {CaseInsensitiveStr(i) for i in s} - elif not s: - data = set() - else: - raise TypeError(f"Unsupported type, got {type(s)}") - self._data: set[CaseInsensitiveStr] = data - - def __contains__(self, value: Any) -> bool: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - elif not isinstance(value, CaseInsensitiveStr): - return False - return value in self._data - - def __iter__(self) -> Iterator[CaseInsensitiveStr]: - return iter(self._data) - - def __len__(self) -> int: - return len(self._data) - - def __or__(self, other: AbstractSet[Any]) -> CaseInsensitiveSet: - return CaseInsensitiveSet(self._data | {CaseInsensitiveStr(i) for i in other}) - - def __ror__(self, other: AbstractSet[Any]) -> CaseInsensitiveSet: - return self.__or__(other) - - def __eq__(self, other: object) -> bool: - if isinstance(other, CaseInsensitiveSet): - return self._data == other._data - if isinstance(other, AbstractSet): - return self._data == {CaseInsensitiveStr(i) for i in other} - return False - - def discard(self, value: CaseInsensitiveStr | str) -> None: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - return self._data.discard(value) - - def add(self, value: CaseInsensitiveStr | str) -> None: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - return self._data.add(value) - - def __and__(self, other: AbstractSet[Any]) -> AbstractSet[CaseInsensitiveStr]: - return super().__and__(other) - - -class ModsConfig: - """Parsed ModsConfig.xml data with active mods and known expansions.""" - - __slots__ = ("_activeMods", "_knownExpansions", "version") - - def __init__( - self, - version: str, - activeMods: list[CaseInsensitiveStr], - knownExpansions: list[CaseInsensitiveStr], - ): - self.version = version - self.activeMods = activeMods - self.knownExpansions = knownExpansions - - def clear_active_mods(self) -> None: - self._activeMods.clear() - - def clear_all(self) -> None: - self._activeMods.clear() - self._knownExpansions.clear() - - @property - def activeMods(self) -> list[CaseInsensitiveStr]: - return self._activeMods[:] - - @activeMods.setter - def activeMods(self, value: list[CaseInsensitiveStr] | list[str]) -> None: - if isinstance(value, list): - self._activeMods = [CaseInsensitiveStr(i) for i in value] - else: - raise TypeError(f"Expected list, got {type(value)}") - - @property - def knownExpansions(self) -> list[CaseInsensitiveStr]: - return self._knownExpansions[:] - - @knownExpansions.setter - def knownExpansions(self, value: list[CaseInsensitiveStr] | list[str]) -> None: - if isinstance(value, list): - self._knownExpansions = [CaseInsensitiveStr(i) for i in value] - else: - raise TypeError(f"Expected list, got {type(value)}") - - def check_active_duplicates(self) -> bool: - """True if the activeMods list contains duplicate entries.""" - return len(self.activeMods) != len(set(self.activeMods)) - - def check_expansions_duplicates(self) -> bool: - """True if the knownExpansions list contains duplicate entries.""" - return len(self.knownExpansions) != len(set(self.knownExpansions)) - - def to_dict(self) -> dict[str, Any]: - """Serialize ModsConfig to a plain dictionary matching XML li structure.""" - return { - "version": self.version, - "activeMods": {"li": [str(i) for i in self.activeMods]}, - "knownExpansions": {"li": [str(i) for i in self.knownExpansions]}, - } - - -@dataclass -class BaseMod: - """Base dataclass for all mod types with a name and UUID.""" - - name: str = "Unknown Mod Name" - _uuid: str = field(default_factory=lambda: str(uuid4())) - - @property - def uuid(self) -> str: - return self._uuid - - def __hash__(self) -> int: - return hash(self.uuid) - - -@dataclass -class PackageIdMod: - """Mixin dataclass adding a case-insensitive package ID.""" - - package_id: CaseInsensitiveStr = field( - default_factory=lambda: CaseInsensitiveStr("invalid.mod") - ) - - -@dataclass(slots=True) -class DependencyMod(BaseMod, PackageIdMod): - """A mod dependency with workshop URL and alternative package IDs.""" - - workshop_url: str = "" - alternative_package_ids: set[CaseInsensitiveStr] = field(default_factory=set) - - -@dataclass(slots=True) -class ListedMod(BaseMod): - """A mod discovered from any provider with validation and path info.""" - - valid: bool = True - supported_versions: set[str] = field(default_factory=set) - description: str = ( - "This mod is considered invalid by PxModRim (and the RimWorld game)." - + "\n\nThis mod does NOT contain an ./About/About.xml and is likely " - "leftover from previous usage." - ) - - _mod_path: Path | None = None - provider_id: str = "" - obsolete: bool = False - db_builder_no_name: bool = False - - @property - def mod_path(self) -> Path | None: - return self._mod_path - - @mod_path.setter - def mod_path(self, path: Path) -> None: - if self._mod_path: - raise ValueError("Mod path already set. Cannot override.") - self._mod_path = path - # hasattr() would compute the cached_property (disk I/O) just to discard it. - self.__dict__.pop("published_file_id", None) - - @property - def mod_folder(self) -> str | None: - """The folder name (stem) of the mod path, or None.""" - if self.mod_path: - return self.mod_path.stem - return None - - @property - def internal_time_touched(self) -> int: - """Modification timestamp of the mod folder, or -1 if unavailable.""" - if self.mod_path and os.path.exists(self.mod_path): - return int(os.path.getmtime(self.mod_path)) - return -1 - - @property - def uuid(self) -> str: - if self._mod_path: - return str(self._mod_path) - return self._uuid - - @functools.cached_property - def published_file_id(self) -> str | None: - """Published file ID from About/PublishedFileId.txt or numeric folder name.""" - if self.mod_path is None: - return None - pfid_path = self.mod_path / "About/PublishedFileId.txt" - if pfid_path.exists(): - try: - content = pfid_path.read_text(encoding="utf-8-sig").strip() - except OSError: - return None - if content.isdigit() and content: - return content - if self.mod_folder is not None and self.mod_folder.isdigit(): - return self.mod_folder - return None - - @functools.cached_property - def c_sharp_mod(self) -> bool: - """Whether the mod contains a non-empty Assemblies directory.""" - if self.mod_path is None: - return False - assemblies = self.mod_path / "Assemblies" - return assemblies.is_dir() and any(True for _ in assemblies.iterdir()) - - @functools.cached_property - def xml_patch_mod(self) -> bool: - """Whether the mod contains XML patch files in its Patches directory.""" - if self.mod_path is None: - return False - patches = self.mod_path / "Patches" - if not patches.is_dir(): - return False - return any(p.suffix == ".xml" for p in patches.iterdir()) - - @property - def preview_img_path(self) -> Path | None: - """Path to About/Preview.png if it exists, otherwise None.""" - if self.mod_path is None: - return None - candidate = self.mod_path / "About/Preview.png" - if candidate.exists(): - return candidate - return None - - -@dataclass(slots=True) -class ScenarioMod(ListedMod): - """A scenario mod with an optional summary.""" - - summary: str = "" - - -@dataclass(slots=True) -class BaseRules: - """Load order rules: load_after, load_before, incompatible_with, dependencies.""" - - load_after: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - load_before: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - incompatible_with: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - dependencies: dict[CaseInsensitiveStr, DependencyMod] = field(default_factory=dict) - - -@dataclass(slots=True) -class AboutXmlMod(ListedMod, PackageIdMod): - """A mod with an About.xml, including authors, version, rules, and DLC metadata.""" - - authors: list[str] = field(default_factory=list) - mod_version: str = "" - mod_icon_path: Path | None = None - steam_app_id: int = -1 - url: str = "" - mtime: float = 0.0 - about_rules: BaseRules = field(default_factory=BaseRules) - - def get_dlc_name(self) -> str | None: - """Return the DLC name if this mod matches a known RimWorld DLC.""" - for meta in RIMWORLD_DLC_METADATA.values(): - if meta["packageid"] == str(self.package_id): - return meta["name"] - return None - - -@dataclass(slots=True) -class CompiledDependencyData: - """Pre-computed dependency graphs and tier classification for sorting.""" - - deps_graph: dict[str, set[str]] = field(default_factory=dict) - rev_deps_graph: dict[str, set[str]] = field(default_factory=dict) - tier_zero_mods: set[str] = field(default_factory=set) - tier_one_mods: set[str] = field(default_factory=set) - tier_three_mods: set[str] = field(default_factory=set) - incompatibilities: dict[str, set[str]] = field(default_factory=dict) - declared_incompatibilities: dict[str, set[str]] = field(default_factory=dict) - - -class SubExternalRule(msgspec.Struct, omit_defaults=True): - """A single external rule entry with name and comment fields.""" - - name: list[str] | str = msgspec.field(default_factory=str) - comment: list[str] | str = msgspec.field(default_factory=str) - - -class SubExternalBoolRule(msgspec.Struct, omit_defaults=True): - """An external boolean rule with an optional comment.""" - - value: bool = False - comment: list[str] | str = msgspec.field(default_factory=str) - - -class ExternalRule(msgspec.Struct, omit_defaults=True): - """Community-sourced load order rule for a single package ID.""" - - loadAfter: dict[str, SubExternalRule] = {} - loadBefore: dict[str, SubExternalRule] = {} - loadTop: SubExternalBoolRule = msgspec.field(default_factory=SubExternalBoolRule) - loadBottom: SubExternalBoolRule = msgspec.field(default_factory=SubExternalBoolRule) - - -class ExternalRulesSchema(msgspec.Struct, omit_defaults=True): - """Top-level schema for community rules, keyed by package ID.""" - - timestamp: int = 0 - rules: dict[str, ExternalRule] = msgspec.field(default_factory=dict) - - -class SteamDbEntryDependency(msgspec.Struct, omit_defaults=True): - """A dependency reference from the Steam database.""" - - name: str = msgspec.field(default_factory=str) - url: str = msgspec.field(default_factory=str) - - -class SteamDbEntryBlacklist(msgspec.Struct, omit_defaults=True): - """Blacklist status with reason comment from the Steam database.""" - - value: bool = False - comment: str = msgspec.field(default_factory=str) - - -class SteamDbEntry(msgspec.Struct, omit_defaults=True): - """A single entry in the Steam Workshop database.""" - - unpublished: bool = False - url: str = msgspec.field(default_factory=str) - packageId: str = msgspec.field(default_factory=str) - gameVersions: list[str | None] | str | None = msgspec.field(default_factory=list) - steamName: str = msgspec.field(default_factory=str) - name: str = msgspec.field(default_factory=str) - authors: list[str] | str | None = msgspec.field(default_factory=str) - dependencies: dict[str, list[str] | SteamDbEntryDependency] = msgspec.field( - default_factory=dict - ) - blacklist: SteamDbEntryBlacklist = msgspec.field( - default_factory=SteamDbEntryBlacklist - ) - tags: list[dict[str, str]] = msgspec.field(default_factory=list) - - -class SteamDbSchema(msgspec.Struct): - """Top-level Steam Workshop database schema with version and entries.""" - - version: int = 0 - database: dict[str, SteamDbEntry] = msgspec.field(default_factory=dict) - - -ModMetadata = dict[str, Any] - - -@dataclass(slots=True) -class WorkshopUpdateResult: - """Result of a workshop mod update operation.""" - - status: Literal["success", "no_workshop_mods", "partial", "failed"] - mods_checked: int - mods_updated: int - failed_pfids: list[str] - errors: list[str] diff --git a/legacy/pxmodrim/core/models/view/__init__.py b/legacy/pxmodrim/core/models/view/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/legacy/pxmodrim/core/models/view/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/legacy/pxmodrim/core/models/view/diagnostics.py b/legacy/pxmodrim/core/models/view/diagnostics.py deleted file mode 100644 index 50f1954..0000000 --- a/legacy/pxmodrim/core/models/view/diagnostics.py +++ /dev/null @@ -1,21 +0,0 @@ -"""UI-agnostic view models for diagnostics data.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class ModDiagnosticsView: - has_errors: bool = False - has_warnings: bool = False - error_tooltip: str = "" - warning_tooltip: str = "" - - -@dataclass(frozen=True, slots=True) -class ModIssueView: - category: str - category_display_name: str - detail: str | None - is_error: bool diff --git a/legacy/pxmodrim/core/models/view/sidebar.py b/legacy/pxmodrim/core/models/view/sidebar.py deleted file mode 100644 index 731e2dc..0000000 --- a/legacy/pxmodrim/core/models/view/sidebar.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sidebar entry models — shared between services and UI.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -PROVIDER_LABELS: dict[str, str] = { - "local": "Local", - "steam": "Steam Workshop", - "downloaded": "Steam Workshop", - "core": "System / Core", -} - - -class SidebarEntry(ABC): - """A filter entry in the sidebar. Knows which mods it represents.""" - - visible_uuids: set[str] - count: int - - def __init__(self) -> None: - self.visible_uuids = set() - self.count = 0 - - @property - @abstractmethod - def label(self) -> str: ... - - def refresh_count(self) -> None: - self.count = len(self.visible_uuids) - - -class AllModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "All" - - -class ActiveModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "Active" - - -class InactiveModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "Inactive" - - -class ErrorModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "With errors" - - -class WarningModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "With warnings" - - -class ProviderModsEntry(SidebarEntry): - def __init__(self, provider_id: str, label: str, uuids: set[str]) -> None: - super().__init__() - self._pid = provider_id - self._label = label - self.visible_uuids = uuids - self.refresh_count() - - @property - def label(self) -> str: - return self._label diff --git a/legacy/pxmodrim/core/mods_config.py b/legacy/pxmodrim/core/mods_config.py deleted file mode 100644 index 935d324..0000000 --- a/legacy/pxmodrim/core/mods_config.py +++ /dev/null @@ -1,215 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from datetime import UTC, datetime -from pathlib import Path - -import lxml.etree as ET -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_DLC_METADATA -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.xml import dict_to_etree - - -def _get_dlc_package_ids() -> list[CaseInsensitiveStr]: - return [ - CaseInsensitiveStr(dlc["packageid"]) for dlc in RIMWORLD_DLC_METADATA.values() - ] - - -def _parse_mods_config(data: bytes, source: Path) -> ModsConfig | None: - try: - root = ET.fromstring(data) - except ET.XMLSyntaxError as e: - logger.error(f"Failed to parse ModsConfig.xml at {source}: {e}") - return None - - version_node = root.find("version") - active_mods_node = root.find("activeMods") - if root.tag != "ModsConfigData" or version_node is None or active_mods_node is None: - logger.error(f"Invalid ModsConfig.xml structure at {source}") - return None - - version = (version_node.text or "1.5").strip() - active_mods = [ - CaseInsensitiveStr(text) - for element in active_mods_node.findall("li") - if (text := (element.text or "").strip()) - ] - known_expansions = [ - CaseInsensitiveStr(text) - for element in root.findall("./knownExpansions/li") - if (text := (element.text or "").strip()) - ] - if not known_expansions: - known_expansions = _get_dlc_package_ids() - - return ModsConfig( - version=version, - activeMods=active_mods, - knownExpansions=known_expansions, - ) - - -def parse_mods_config(path: Path) -> ModsConfig | None: - """Parse a ModsConfig.xml file into a structured ModsConfig object.""" - try: - data = path.read_bytes() - except OSError as e: - logger.warning(f"Failed to read ModsConfig.xml at {path}: {e}") - return None - return _parse_mods_config(data, path) - - -def _snapshot_files(snapshots_dir: Path) -> list[Path]: - snapshots = [ - path for path in snapshots_dir.glob("ModsConfig_*.xml") if path.is_file() - ] - snapshots.sort(key=lambda path: (path.stat().st_mtime_ns, path.name), reverse=True) - return snapshots - - -def _atomic_write(path: Path, data: bytes) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temp_name = tempfile.mkstemp( - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - ) - tmp_path = Path(temp_name) - try: - with os.fdopen(descriptor, "wb") as tmp: - tmp.write(data) - tmp.flush() - os.fsync(tmp.fileno()) - os.replace(tmp_path, path) - finally: - tmp_path.unlink(missing_ok=True) - - -def _prune_snapshots( - snapshots_dir: Path, - max_snapshots: int, - preserved_path: Path | None = None, -) -> None: - snapshots = _snapshot_files(snapshots_dir) - excess = len(snapshots) - max(1, max_snapshots) - removable = [path for path in reversed(snapshots) if path != preserved_path] - for path in removable[: max(0, excess)]: - try: - path.unlink() - except OSError as e: - logger.warning(f"Failed to remove old snapshot {path}: {e}") - - -def create_snapshot( - config_path: Path, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> Path | None: - """Preserve a uniquely named snapshot of an existing ModsConfig.xml file.""" - if not config_path.is_file(): - return None - - try: - config_data = config_path.read_bytes() - except OSError as e: - logger.warning(f"Failed to read {config_path} for snapshot: {e}") - return None - - snapshots = get_snapshots(snapshots_dir) - if snapshots: - try: - if snapshots[0].read_bytes() == config_data: - _prune_snapshots(snapshots_dir, max_snapshots) - return snapshots[0] - except OSError: - pass - - timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f") - snapshot_path = snapshots_dir / f"ModsConfig_{timestamp}.xml" - counter = 1 - while snapshot_path.exists(): - snapshot_path = snapshots_dir / f"ModsConfig_{timestamp}_{counter:03d}.xml" - counter += 1 - - _atomic_write(snapshot_path, config_data) - _prune_snapshots(snapshots_dir, max_snapshots) - logger.info(f"Snapshot created at {snapshot_path}") - return snapshot_path - - -def get_snapshots(snapshots_dir: Path) -> list[Path]: - """Return available snapshot files, sorted newest first.""" - if not snapshots_dir.exists(): - return [] - return _snapshot_files(snapshots_dir) - - -def restore_snapshot( - snapshot_path: Path, - target_path: Path, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> bool: - """Atomically restore a valid snapshot while preserving the current file.""" - try: - snapshot_data = snapshot_path.read_bytes() - except OSError as e: - logger.error(f"Failed to read snapshot {snapshot_path}: {e}") - return False - - if _parse_mods_config(snapshot_data, snapshot_path) is None: - logger.error(f"Snapshot is invalid or corrupted: {snapshot_path}") - return False - - if target_path.exists(): - limit = max(1, max_snapshots) - create_snapshot(target_path, snapshots_dir, max_snapshots=limit + 1) - _prune_snapshots(snapshots_dir, limit, preserved_path=snapshot_path) - _atomic_write(target_path, snapshot_data) - logger.info(f"Restored snapshot {snapshot_path} to {target_path}") - return True - - -def write_mods_config( - path: Path, - data: ModsConfig, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> None: - root: ET._Element | None = None - if path.exists(): - try: - existing_root = ET.parse(str(path)).getroot() - except (OSError, ET.XMLSyntaxError): - existing_root = None - if existing_root is not None and existing_root.tag == "ModsConfigData": - root = existing_root - - if root is None: - root = dict_to_etree({"ModsConfigData": data.to_dict()}) - else: - fields = data.to_dict() - for name in ("version", "activeMods", "knownExpansions"): - node = root.find(name) - if node is None: - node = ET.SubElement(root, name) - value = fields[name] - if name == "version": - node.text = str(value) - continue - for child in node.findall("li"): - node.remove(child) - for item in value["li"]: - ET.SubElement(node, "li").text = item - - formatted = ET.tostring( - root, encoding="utf-8", xml_declaration=True, pretty_print=True - ) - if path.exists(): - create_snapshot(path, snapshots_dir, max_snapshots=max_snapshots) - _atomic_write(path, formatted) - logger.info(f"ModsConfig.xml written to {path}") diff --git a/legacy/pxmodrim/core/msgspec_hooks.py b/legacy/pxmodrim/core/msgspec_hooks.py deleted file mode 100644 index 92db58d..0000000 --- a/legacy/pxmodrim/core/msgspec_hooks.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - - -def enc_hook(obj: Any) -> Any: - if isinstance(obj, CaseInsensitiveStr): - return str(obj) - raise NotImplementedError( - f"Object of type {type(obj).__name__} is not JSON serializable" - ) - - -def dec_hook(type_: type, obj: Any) -> Any: - if type_ is CaseInsensitiveStr and isinstance(obj, str): - return CaseInsensitiveStr(obj) - raise NotImplementedError(f"Cannot decode {type_}") diff --git a/legacy/pxmodrim/core/organizer/__init__.py b/legacy/pxmodrim/core/organizer/__init__.py deleted file mode 100644 index e209a62..0000000 --- a/legacy/pxmodrim/core/organizer/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import STANDARD_RULES, StandardRule -from pxmodrim.core.organizer.models import ( - MAX_DEPTH, - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - Rule, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - CheckState, - FolderNode, - ModLeaf, - Placement, - StatusFilter, - TreeFilter, - TreeQuery, - build_tree, - folder_for, - tree_filters, -) -from pxmodrim.core.organizer.service import OrganizerService - -__all__ = [ - "MAX_DEPTH", - "ROOT_ID", - "STANDARD_RULES", - "CheckState", - "Folder", - "FolderNode", - "ModLeaf", - "OrganizerDb", - "OrganizerError", - "OrganizerService", - "OrganizerState", - "Placement", - "Rule", - "RuleField", - "RuleOp", - "RuleSpec", - "StandardRule", - "StatusFilter", - "Tag", - "TreeFilter", - "TreeQuery", - "build_tree", - "folder_for", - "tree_filters", -] diff --git a/legacy/pxmodrim/core/organizer/db.py b/legacy/pxmodrim/core/organizer/db.py deleted file mode 100644 index 03734cc..0000000 --- a/legacy/pxmodrim/core/organizer/db.py +++ /dev/null @@ -1,563 +0,0 @@ -from __future__ import annotations - -import asyncio -import sqlite3 -from collections.abc import Iterable, Sequence -from pathlib import Path - -import aiosqlite - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.organizer.defaults import StandardRule -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - Rule, - RuleSpec, - Tag, -) - -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - name TEXT NOT NULL, - collapsed INTEGER NOT NULL DEFAULT 0, - CHECK ((id = 1) = (parent_id IS NULL)) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_parent_name - ON folders (parent_id, name COLLATE NOCASE); - -CREATE TABLE IF NOT EXISTS placements ( - package_id TEXT PRIMARY KEY COLLATE NOCASE, - folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - color TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS mod_tags ( - package_id TEXT NOT NULL COLLATE NOCASE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (package_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - position INTEGER NOT NULL, - field TEXT NOT NULL CHECK (field IN ('package_id', 'name', 'author')), - op TEXT NOT NULL CHECK (op IN ('prefix', 'contains', 'equals')), - pattern TEXT NOT NULL, - folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE -); - -INSERT OR IGNORE INTO folders (id, parent_id, name, collapsed) -VALUES (1, NULL, 'Root', 0); -""" - -_SCHEMA_VERSION = 1 - - -def db_path(config_dir: Path) -> Path: - return config_dir / "organizer.db" - - -class OrganizerDb: - """Owns an aiosqlite connection guarded by an asyncio lock.""" - - __slots__ = ("_connection", "_lock", "_path") - - def __init__(self, path: Path) -> None: - self._path = path - self._connection: aiosqlite.Connection | None = None - self._lock = asyncio.Lock() - - async def _ensure_schema(self, conn: aiosqlite.Connection) -> None: - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={}, - backup_path=self._path, - label="organizer DB", - ) - - async def _get_conn(self) -> aiosqlite.Connection: - if self._connection is None: - if not self._path.parent.exists(): - self._path.parent.mkdir(parents=True, exist_ok=True) - conn = await aiosqlite.connect(str(self._path), check_same_thread=False) - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await self._ensure_schema(conn) - self._connection = conn - return self._connection - - async def close(self) -> None: - async with self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - - async def load(self) -> OrganizerState: - async with self._lock: - conn = await self._get_conn() - - folders: dict[int, Folder] = {} - async with conn.execute( - "SELECT id, parent_id, name, collapsed FROM folders" - ) as cur: - async for row in cur: - folders[row[0]] = Folder( - id=row[0], - parent_id=row[1], - name=row[2], - collapsed=bool(row[3]), - ) - - placements: dict[str, int] = {} - async with conn.execute( - "SELECT package_id, folder_id FROM placements" - ) as cur: - async for row in cur: - placements[row[0].lower()] = row[1] - - tags: dict[int, Tag] = {} - async with conn.execute("SELECT id, name, color FROM tags") as cur: - async for row in cur: - tags[row[0]] = Tag( - id=row[0], - name=row[1], - color=row[2], - ) - - mod_tags_builder: dict[str, set[int]] = {} - async with conn.execute("SELECT package_id, tag_id FROM mod_tags") as cur: - async for row in cur: - pid = row[0].lower() - mod_tags_builder.setdefault(pid, set()).add(row[1]) - mod_tags = {k: frozenset(v) for k, v in mod_tags_builder.items()} - - rules_list: list[Rule] = [] - async with conn.execute( - """ - SELECT id, position, field, op, pattern, folder_id - FROM rules - ORDER BY position ASC, id ASC - """ - ) as cur: - async for row in cur: - rules_list.append( - Rule( - id=row[0], - position=row[1], - field=row[2], - op=row[3], - pattern=row[4], - folder_id=row[5], - ) - ) - - return OrganizerState( - folders=folders, - placements=placements, - tags=tags, - mod_tags=mod_tags, - rules=tuple(rules_list), - ) - - async def create_folder(self, parent_id: int, name: str) -> Folder: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - (parent_id, name), - ) - folder_id = cur.lastrowid - assert folder_id is not None - await conn.commit() - return Folder( - id=folder_id, - parent_id=parent_id, - name=name, - collapsed=False, - ) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def create_folder_with( - self, parent_id: int, name: str, package_ids: Iterable[str] - ) -> Folder: - pids = list(dict.fromkeys(p.strip().lower() for p in package_ids if p.strip())) - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - (parent_id, name), - ) - folder_id = cur.lastrowid - assert folder_id is not None - if pids: - await conn.executemany( - """ - INSERT INTO placements (package_id, folder_id) - VALUES (?, ?) - ON CONFLICT (package_id) - DO UPDATE SET folder_id = excluded.folder_id - """, - [(pid, folder_id) for pid in pids], - ) - await conn.commit() - return Folder( - id=folder_id, - parent_id=parent_id, - name=name, - collapsed=False, - ) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def rename_folder(self, folder_id: int, name: str) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be renamed.") - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET name = ? WHERE id = ?", (name, folder_id) - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_parent(self, folder_id: int, parent_id: int) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be moved.") - if folder_id == parent_id: - raise OrganizerError("A folder cannot be its own parent.") - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET parent_id = ? WHERE id = ?", - (parent_id, folder_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_collapsed(self, folder_id: int, collapsed: bool) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET collapsed = ? WHERE id = ?", - (1 if collapsed else 0, folder_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_all_collapsed(self, collapsed: bool) -> None: - async with self._lock: - conn = await self._get_conn() - try: - await conn.execute( - "UPDATE folders SET collapsed = ?", (1 if collapsed else 0,) - ) - await conn.commit() - except Exception: - await conn.rollback() - raise - - async def delete_folder(self, folder_id: int) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be deleted.") - async with self._lock: - conn = await self._get_conn() - try: - async with conn.execute( - "SELECT 1 FROM folders WHERE id = ?", (folder_id,) - ) as cur: - if await cur.fetchone() is None: - raise OrganizerError( - f"Folder with id {folder_id} does not exist." - ) - - # Collect subtree with recursive CTE and move placements to root - await conn.execute( - """ - WITH RECURSIVE subtree(id) AS ( - SELECT ? - UNION ALL - SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id - ) - UPDATE placements SET folder_id = ? - WHERE folder_id IN (SELECT id FROM subtree) - """, - (folder_id, ROOT_ID), - ) - await conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,)) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def place(self, package_ids: Iterable[str], folder_id: int) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - if not pids: - return - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - """ - INSERT INTO placements (package_id, folder_id) - VALUES (?, ?) - ON CONFLICT (package_id) - DO UPDATE SET folder_id = excluded.folder_id - """, - [(pid, folder_id) for pid in pids], - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def unplace(self, package_ids: Iterable[str]) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - if not pids: - return - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - "DELETE FROM placements WHERE package_id = ?", - [(pid,) for pid in pids], - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def create_tag(self, name: str, color: str) -> Tag: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO tags (name, color) VALUES (?, ?)", (name, color) - ) - tag_id = cur.lastrowid - assert tag_id is not None - await conn.commit() - return Tag(id=tag_id, name=name, color=color) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def update_tag(self, tag_id: int, name: str, color: str) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE tags SET name = ?, color = ? WHERE id = ?", - (name, color, tag_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Tag with id {tag_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def delete_tag(self, tag_id: int) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) - if cur.rowcount == 0: - raise OrganizerError(f"Tag with id {tag_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_mod_tags( - self, - package_ids: Iterable[str], - add: Iterable[int] = (), - remove: Iterable[int] = (), - ) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - add_ids = list(add) - remove_ids = list(remove) - if not pids or (not add_ids and not remove_ids): - return - - async with self._lock: - conn = await self._get_conn() - try: - if remove_ids: - remove_params = [(pid, tid) for pid in pids for tid in remove_ids] - await conn.executemany( - "DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?", - remove_params, - ) - if add_ids: - add_params = [(pid, tid) for pid in pids for tid in add_ids] - await conn.executemany( - "INSERT OR IGNORE INTO mod_tags (package_id, tag_id) " - "VALUES (?, ?)", - add_params, - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def replace_rules(self, specs: Sequence[RuleSpec]) -> tuple[Rule, ...]: - async with self._lock: - conn = await self._get_conn() - try: - await conn.execute("DELETE FROM rules") - rules: list[Rule] = [] - for pos, spec in enumerate(specs): - cur = await conn.execute( - """ - INSERT INTO rules (position, field, op, pattern, folder_id) - VALUES (?, ?, ?, ?, ?) - """, - (pos, spec.field, spec.op, spec.pattern, spec.folder_id), - ) - rule_id = cur.lastrowid - assert rule_id is not None - rules.append( - Rule( - id=rule_id, - position=pos, - field=spec.field, - op=spec.op, - pattern=spec.pattern, - folder_id=spec.folder_id, - ) - ) - await conn.commit() - return tuple(rules) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def append_named_rules( - self, - parent_id: int, - new_folders: Sequence[str], - rules: Sequence[StandardRule], - ) -> None: - """Create *new_folders* under *parent_id*, then append *rules* after the - existing ones, resolving each rule's folder by name among its children. - """ - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - [(parent_id, name) for name in new_folders], - ) - folder_ids: dict[str, int] = {} - async with conn.execute( - "SELECT id, name FROM folders WHERE parent_id = ?", (parent_id,) - ) as cur: - async for row in cur: - folder_ids[row[1].casefold()] = row[0] - async with conn.execute( - "SELECT COALESCE(MAX(position), -1) + 1 FROM rules" - ) as cur: - row = await cur.fetchone() - assert row is not None - start = int(row[0]) - params: list[tuple[int, str, str, str, int]] = [] - for offset, rule in enumerate(rules): - folder_id = folder_ids.get(rule.folder.casefold()) - if folder_id is None: - raise OrganizerError(f'Folder "{rule.folder}" does not exist.') - params.append( - (start + offset, rule.field, rule.op, rule.pattern, folder_id) - ) - await conn.executemany( - """ - INSERT INTO rules (position, field, op, pattern, folder_id) - VALUES (?, ?, ?, ?, ?) - """, - params, - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise diff --git a/legacy/pxmodrim/core/organizer/defaults.py b/legacy/pxmodrim/core/organizer/defaults.py deleted file mode 100644 index 4fc888f..0000000 --- a/legacy/pxmodrim/core/organizer/defaults.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.organizer.models import RuleField, RuleOp - - -class StandardRule(msgspec.Struct, frozen=True): - """A built-in auto-folder rule targeting a top-level folder by name.""" - - folder: str - field: RuleField - op: RuleOp - pattern: str - - -def _exact(folder: str, *package_ids: str) -> tuple[StandardRule, ...]: - return tuple(StandardRule(folder, "package_id", "equals", p) for p in package_ids) - - -def _prefixes(folder: str, *prefixes: str) -> tuple[StandardRule, ...]: - return tuple(StandardRule(folder, "package_id", "prefix", p) for p in prefixes) - - -# Package ids are taken from each mod's own About.xml. Multi-category authors -# (brrainz, unlimitedhugs, bs, mlie) are matched by exact id only, and name -# heuristics such as "framework" or "performance" are avoided: they catch -# translations and unrelated content mods. Order matters (first match wins): -# libraries and performance precede the family prefixes that would swallow -# VE Framework and Dubs Performance Analyzer. -STANDARD_RULES: tuple[StandardRule, ...] = ( - *_prefixes("Official", "ludeon.rimworld"), - *_exact( - "Frameworks & Libraries", - "brrainz.harmony", - "unlimitedhugs.hugslib", - "erdelf.humanoidalienraces", - "oskarpotocki.vanillafactionsexpanded.core", - "imranfish.xmlextensions", - "zetrith.prepatcher", - "jecrell.jecstools", - "bs.fishery", - "smashphil.vehicleframework", - ), - *_exact( - "Performance", - "bs.performance", - "krkr.rocketman", - "taranchuk.performanceoptimizer", - ), - *_prefixes("Performance", "dubwise.dubsperformanceanalyzer"), - *_exact( - "Quality of Life", - "brrainz.achtung", - "brrainz.cameraplus", - "unlimitedhugs.allowtool", - "jaxe.rimhud", - "mehni.pickupandhaul", - "avilmask.commonsense", - "dhultgren.smarterconstruction", - "roolo.searchanddestroy", - ), - *_prefixes( - "Vanilla Expanded", - "vanillaexpanded.", - "oskarpotocki.", - "vanillaracesexpanded.", - "vanillaquestsexpanded.", - "vanillastorytellersexpanded.", - ), - StandardRule("Vanilla Expanded", "author", "contains", "oskar potocki"), - *_prefixes("Alpha Mods", "sarg."), - *_prefixes("Combat Extended", "ceteam."), - StandardRule("Combat Extended", "name", "contains", "combat extended"), - *_prefixes("Dubs Mods", "dubwise."), -) diff --git a/legacy/pxmodrim/core/organizer/models.py b/legacy/pxmodrim/core/organizer/models.py deleted file mode 100644 index db3703d..0000000 --- a/legacy/pxmodrim/core/organizer/models.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -import msgspec - -ROOT_ID = 1 -MAX_DEPTH = 3 - -RuleField = Literal["package_id", "name", "author"] -RuleOp = Literal["prefix", "contains", "equals"] - - -class OrganizerError(ValueError): - """Raised when an organizer operation violates domain constraints.""" - - -class Folder(msgspec.Struct, frozen=True): - id: int - parent_id: int | None - name: str - collapsed: bool = False - - -class Tag(msgspec.Struct, frozen=True): - id: int - name: str - color: str - - -class RuleSpec(msgspec.Struct, frozen=True): - field: RuleField - op: RuleOp - pattern: str - folder_id: int - - -class Rule(msgspec.Struct, frozen=True): - id: int - position: int - field: RuleField - op: RuleOp - pattern: str - folder_id: int - - -class OrganizerState(msgspec.Struct, frozen=True): - folders: dict[int, Folder] - placements: dict[str, int] - tags: dict[int, Tag] - mod_tags: dict[str, frozenset[int]] - rules: tuple[Rule, ...] diff --git a/legacy/pxmodrim/core/organizer/resolve.py b/legacy/pxmodrim/core/organizer/resolve.py deleted file mode 100644 index 7aa101e..0000000 --- a/legacy/pxmodrim/core/organizer/resolve.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Collection, Iterator, Mapping, Sequence -from typing import Literal - -import msgspec - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.models.view.sidebar import PROVIDER_LABELS -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerState, - Rule, - RuleSpec, -) - -Placement = Literal["manual", "rule", "none"] -CheckState = Literal["on", "off", "partial"] -StatusFilter = Literal["all", "active", "inactive"] - - -class TreeQuery(msgspec.Struct, frozen=True): - text: str = "" - tag_ids: frozenset[int] = frozenset() - status: StatusFilter = "all" - provider_ids: frozenset[str] = frozenset() - - @property - def is_empty(self) -> bool: - return ( - not self.text.strip() - and not self.tag_ids - and self.status == "all" - and not self.provider_ids - ) - - -class TreeFilter(msgspec.Struct, frozen=True): - """A sidebar preset (status, provider, or tag) and the mods it selects.""" - - key: str - label: str - count: int - status: StatusFilter = "all" - provider_id: str | None = None - tag_id: int | None = None - - def query(self, text: str = "") -> TreeQuery: - providers = ( - frozenset() if self.provider_id is None else frozenset((self.provider_id,)) - ) - tag_ids = frozenset() if self.tag_id is None else frozenset((self.tag_id,)) - return TreeQuery( - text=text, - tag_ids=tag_ids, - status=self.status, - provider_ids=providers, - ) - - -class ModLeaf(msgspec.Struct, frozen=True): - uuid: str - package_id: str | None - name: str - author: str - enabled: bool - tag_ids: frozenset[int] - placement: Placement - - -class FolderNode(msgspec.Struct, frozen=True): - folder: Folder - depth: int - children: tuple[FolderNode, ...] - mods: tuple[ModLeaf, ...] - total: int - enabled: int - check: CheckState - visible_total: int - own_total: int - own_enabled: int - has_rules: bool - - @property - def own_check(self) -> CheckState: - """Check state of the folder's direct mods only (the root's Ungrouped).""" - return _check_state(self.own_total, self.own_enabled) - - def walk(self) -> Iterator[FolderNode]: - yield self - for child in self.children: - yield from child.walk() - - def all_mods(self) -> Iterator[ModLeaf]: - for node in self.walk(): - yield from node.mods - - -def mod_package_id(mod: ListedMod) -> str | None: - if isinstance(mod, AboutXmlMod): - return str(mod.package_id).lower() - return None - - -def mod_author(mod: ListedMod) -> str: - if isinstance(mod, AboutXmlMod): - return ", ".join(mod.authors) - return "" - - -def _rule_matcher(rule: Rule | RuleSpec) -> Callable[[str], bool]: - pattern = rule.pattern.casefold() - if rule.op == "prefix": - return lambda value: value.startswith(pattern) - if rule.op == "contains": - return lambda value: pattern in value - return lambda value: value == pattern - - -class _Resolver: - __slots__ = ("_placements", "_rules") - - def __init__(self, state: OrganizerState) -> None: - self._placements = state.placements - self._rules = [ - (rule.field, _rule_matcher(rule), rule.folder_id) - for rule in state.rules - if rule.folder_id in state.folders and rule.folder_id != ROOT_ID - ] - - def resolve( - self, package_id: str | None, name: str, author: str - ) -> tuple[int, Placement]: - if package_id is None: - return ROOT_ID, "none" - pid = package_id.lower() - manual = self._placements.get(pid) - if manual is not None: - return manual, "manual" - values = { - "package_id": pid.casefold(), - "name": name.casefold(), - "author": author.casefold(), - } - for field, matches, folder_id in self._rules: - if matches(values[field]): - return folder_id, "rule" - return ROOT_ID, "none" - - -def folder_for( - state: OrganizerState, package_id: str | None, name: str, author: str -) -> tuple[int, Placement]: - return _Resolver(state).resolve(package_id, name, author) - - -def preview_rule_matches( - state: OrganizerState, mods: Mapping[str, ListedMod], specs: Sequence[RuleSpec] -) -> tuple[list[int], int]: - """First-match counts for a draft and currently ungrouped mods it would assign.""" - current = _Resolver(state) - candidates = [ - (spec.field, _rule_matcher(spec)) - if spec.pattern.strip() - and spec.folder_id in state.folders - and spec.folder_id != ROOT_ID - else None - for spec in specs - ] - counts = [0] * len(specs) - assignable = 0 - for mod in mods.values(): - pid = mod_package_id(mod) - if pid is None: - continue - author = mod_author(mod) - values = { - "package_id": pid.casefold(), - "name": mod.name.casefold(), - "author": author.casefold(), - } - for index, candidate in enumerate(candidates): - if candidate is None: - continue - field, matches = candidate - if matches(values[field]): - counts[index] += 1 - if ( - pid.lower() not in state.placements - and current.resolve(pid, mod.name, author)[0] == ROOT_ID - ): - assignable += 1 - break - return counts, assignable - - -def _matches_query(leaf: ModLeaf, provider_id: str, query: TreeQuery) -> bool: - if query.status == "active" and not leaf.enabled: - return False - if query.status == "inactive" and leaf.enabled: - return False - if query.provider_ids and provider_id not in query.provider_ids: - return False - if query.tag_ids and not query.tag_ids <= leaf.tag_ids: - return False - text = query.text.strip().casefold() - if not text: - return True - return ( - text in leaf.name.strip().casefold() - or (leaf.package_id is not None and text in leaf.package_id) - or text in leaf.author.casefold() - ) - - -def _check_state(total: int, enabled: int) -> CheckState: - if total and enabled == total: - return "on" - if enabled == 0: - return "off" - return "partial" - - -def _mod_sort_key(leaf: ModLeaf) -> tuple[str, str]: - return leaf.name.casefold(), leaf.uuid - - -def _folder_sort_key(folder: Folder) -> tuple[str, int]: - return folder.name.casefold(), folder.id - - -def tree_filters( - mods: Mapping[str, ListedMod], - active: Collection[str], - state: OrganizerState | None = None, -) -> list[TreeFilter]: - active_set = active if isinstance(active, (set, frozenset)) else set(active) - enabled = sum(1 for uuid in mods if uuid in active_set) - per_provider: dict[str, int] = {} - for mod in mods.values(): - per_provider[mod.provider_id] = per_provider.get(mod.provider_id, 0) + 1 - filters = [ - TreeFilter("all", "All", len(mods)), - TreeFilter("active", "Active", enabled, status="active"), - TreeFilter("inactive", "Inactive", len(mods) - enabled, status="inactive"), - ] - labelled = sorted( - (PROVIDER_LABELS.get(pid, pid.replace("_", " ").title()), pid) - for pid in per_provider - ) - filters.extend( - TreeFilter(f"provider:{pid}", label, per_provider[pid], provider_id=pid) - for label, pid in labelled - ) - if state is not None and state.tags: - tag_counts: dict[int, int] = dict.fromkeys(state.tags, 0) - for mod in mods.values(): - pid = mod_package_id(mod) - if pid and pid in state.mod_tags: - for tid in state.mod_tags[pid]: - if tid in tag_counts: - tag_counts[tid] += 1 - sorted_tags = sorted( - state.tags.values(), key=lambda t: (t.name.casefold(), t.id) - ) - filters.extend( - TreeFilter( - key=f"tag:{tag.id}", - label=tag.name, - count=tag_counts[tag.id], - tag_id=tag.id, - ) - for tag in sorted_tags - ) - return filters - - -def build_tree( - state: OrganizerState, - mods: Mapping[str, ListedMod], - active: Collection[str], - query: TreeQuery | None = None, -) -> FolderNode: - filtering = query is not None and not query.is_empty - active_set = active if isinstance(active, (set, frozenset)) else set(active) - resolver = _Resolver(state) - - all_by_folder: dict[int, list[ModLeaf]] = {} - visible_by_folder: dict[int, list[ModLeaf]] = {} - for uuid, mod in mods.items(): - pid = mod_package_id(mod) - author = mod_author(mod) - folder_id, placement = resolver.resolve(pid, mod.name, author) - if folder_id not in state.folders: - folder_id, placement = ROOT_ID, "none" - leaf = ModLeaf( - uuid=uuid, - package_id=pid, - name=mod.name, - author=author, - enabled=uuid in active_set, - tag_ids=state.mod_tags.get(pid, frozenset()) if pid else frozenset(), - placement=placement, - ) - all_by_folder.setdefault(folder_id, []).append(leaf) - if ( - query is None - or not filtering - or _matches_query(leaf, mod.provider_id, query) - ): - visible_by_folder.setdefault(folder_id, []).append(leaf) - - children_of: dict[int, list[Folder]] = {} - for folder in state.folders.values(): - if folder.parent_id is not None: - children_of.setdefault(folder.parent_id, []).append(folder) - ruled = {rule.folder_id for rule in state.rules} - - visited: set[int] = set() - - def build(folder: Folder, depth: int) -> FolderNode: - visited.add(folder.id) - children: list[FolderNode] = [] - total = 0 - enabled = 0 - visible_total = 0 - for child in sorted(children_of.get(folder.id, ()), key=_folder_sort_key): - if child.id in visited: - continue - node = build(child, depth + 1) - total += node.total - enabled += node.enabled - visible_total += node.visible_total - if not filtering or node.visible_total: - children.append(node) - own = all_by_folder.get(folder.id, ()) - visible = sorted(visible_by_folder.get(folder.id, ()), key=_mod_sort_key) - own_enabled = sum(1 for leaf in own if leaf.enabled) - total += len(own) - enabled += own_enabled - visible_total += len(visible) - return FolderNode( - folder=folder, - depth=depth, - children=tuple(children), - mods=tuple(visible), - total=total, - enabled=enabled, - check=_check_state(total, enabled), - visible_total=visible_total, - own_total=len(own), - own_enabled=own_enabled, - has_rules=folder.id in ruled, - ) - - return build(state.folders[ROOT_ID], 0) diff --git a/legacy/pxmodrim/core/organizer/service.py b/legacy/pxmodrim/core/organizer/service.py deleted file mode 100644 index cdffd93..0000000 --- a/legacy/pxmodrim/core/organizer/service.py +++ /dev/null @@ -1,369 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, get_args - -import msgspec - -from pxmodrim.core.events import Event -from pxmodrim.core.organizer.defaults import STANDARD_RULES, StandardRule -from pxmodrim.core.organizer.models import ( - MAX_DEPTH, - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - FolderNode, - TreeFilter, - TreeQuery, - build_tree, - tree_filters, -) -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.organizer.db import OrganizerDb - -_COLOR_RE = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})") -_RULE_FIELDS = frozenset(get_args(RuleField)) -_RULE_OPS = frozenset(get_args(RuleOp)) - - -def _clean_name(name: str, what: str) -> str: - cleaned = name.strip() - if not cleaned: - raise OrganizerError(f"{what} name cannot be empty.") - return cleaned - - -def _clean_package_ids(package_ids: Iterable[str]) -> list[str]: - return list(dict.fromkeys(p.strip().lower() for p in package_ids if p.strip())) - - -class OrganizerService(Plugin): - """Virtual folder tree, tags and auto-folder rules; never touches load order.""" - - name = "organizer" - - changed: Event[None] - - __slots__ = ("_ctx", "_db", "_state", "changed") - - def __init__(self, ctx: CoreContext, db: OrganizerDb) -> None: - self._ctx = ctx - self._db = db - self._state: OrganizerState | None = None - self.changed = Event() - - async def init(self, ctx: CoreContext) -> None: - self._state = await self._db.load() - self.changed.emit(None) - - async def shutdown(self) -> None: - await self._db.close() - - @property - def ready(self) -> bool: - """Whether ``init()`` has loaded state; views built earlier must wait.""" - return self._state is not None - - @property - def state(self) -> OrganizerState: - if self._state is None: - raise RuntimeError("OrganizerService accessed before init()") - return self._state - - async def _commit(self) -> None: - self._state = await self._db.load() - self.changed.emit(None) - - # ── Validation helpers ──────────────────────────────────────────────────── - - def _folder(self, folder_id: int) -> Folder: - folder = self.state.folders.get(folder_id) - if folder is None: - raise OrganizerError("That folder no longer exists.") - return folder - - def _non_root(self, folder_id: int) -> Folder: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be changed.") - return self._folder(folder_id) - - def _tag(self, tag_id: int) -> Tag: - tag = self.state.tags.get(tag_id) - if tag is None: - raise OrganizerError("That tag no longer exists.") - return tag - - def _depth(self, folder_id: int) -> int: - depth = 0 - current = self._folder(folder_id) - while current.parent_id is not None: - depth += 1 - current = self._folder(current.parent_id) - return depth - - def _children(self, folder_id: int) -> list[Folder]: - return [f for f in self.state.folders.values() if f.parent_id == folder_id] - - def _subtree_ids(self, folder_id: int) -> set[int]: - ids = {folder_id} - pending = [folder_id] - while pending: - for child in self._children(pending.pop()): - if child.id not in ids: - ids.add(child.id) - pending.append(child.id) - return ids - - def _height(self, folder_id: int) -> int: - children = self._children(folder_id) - return 1 + max((self._height(c.id) for c in children), default=0) - - def _check_depth(self, parent_id: int, height: int) -> None: - if self._depth(parent_id) + height > MAX_DEPTH: - raise OrganizerError( - f"Folders can be nested at most {MAX_DEPTH} levels deep." - ) - - def _check_unique_folder_name( - self, parent_id: int, name: str, exclude: int | None = None - ) -> None: - key = name.casefold() - for sibling in self._children(parent_id): - if sibling.id != exclude and sibling.name.casefold() == key: - raise OrganizerError(f'A folder named "{name}" already exists here.') - - def _check_tag(self, name: str, color: str, exclude: int | None = None) -> str: - cleaned = _clean_name(name, "Tag") - key = cleaned.casefold() - for tag in self.state.tags.values(): - if tag.id != exclude and tag.name.casefold() == key: - raise OrganizerError(f'A tag named "{cleaned}" already exists.') - if _COLOR_RE.fullmatch(color) is None: - raise OrganizerError(f'"{color}" is not a valid color (use #RRGGBB).') - return cleaned - - # ── Folders ─────────────────────────────────────────────────────────────── - - async def create_folder(self, name: str, parent_id: int = ROOT_ID) -> Folder: - folder = await self._create_folder(name, parent_id) - await self._commit() - return folder - - async def _create_folder(self, name: str, parent_id: int) -> Folder: - cleaned = _clean_name(name, "Folder") - self._folder(parent_id) - self._check_depth(parent_id, 1) - self._check_unique_folder_name(parent_id, cleaned) - return await self._db.create_folder(parent_id, cleaned) - - async def create_folder_from( - self, name: str, package_ids: Iterable[str], parent_id: int = ROOT_ID - ) -> Folder: - cleaned = _clean_name(name, "Folder") - self._folder(parent_id) - self._check_depth(parent_id, 1) - self._check_unique_folder_name(parent_id, cleaned) - pids = _clean_package_ids(package_ids) - folder = await self._db.create_folder_with(parent_id, cleaned, pids) - await self._commit() - return folder - - async def rename_folder(self, folder_id: int, name: str) -> None: - folder = self._non_root(folder_id) - cleaned = _clean_name(name, "Folder") - if cleaned == folder.name: - return - parent_id = folder.parent_id if folder.parent_id is not None else ROOT_ID - self._check_unique_folder_name(parent_id, cleaned, exclude=folder_id) - await self._db.rename_folder(folder_id, cleaned) - await self._commit() - - async def move_folder(self, folder_id: int, parent_id: int) -> None: - folder = self._non_root(folder_id) - self._folder(parent_id) - if parent_id == folder.parent_id: - return - if parent_id in self._subtree_ids(folder_id): - raise OrganizerError("A folder cannot be moved into itself.") - self._check_depth(parent_id, self._height(folder_id)) - self._check_unique_folder_name(parent_id, folder.name, exclude=folder_id) - await self._db.set_parent(folder_id, parent_id) - await self._commit() - - async def set_collapsed(self, folder_id: int, collapsed: bool) -> None: - if self._folder(folder_id).collapsed == collapsed: - return - await self._db.set_collapsed(folder_id, collapsed) - await self._commit() - - async def set_all_collapsed(self, collapsed: bool) -> None: - if all(folder.collapsed == collapsed for folder in self.state.folders.values()): - return - await self._db.set_all_collapsed(collapsed) - await self._commit() - - async def delete_folder(self, folder_id: int) -> None: - self._non_root(folder_id) - await self._db.delete_folder(folder_id) - await self._commit() - - # ── Placement ───────────────────────────────────────────────────────────── - - async def place(self, package_ids: Iterable[str], folder_id: int) -> None: - self._folder(folder_id) - pids = _clean_package_ids(package_ids) - if not pids: - return - await self._db.place(pids, folder_id) - await self._commit() - - async def ungroup(self, package_ids: Iterable[str]) -> None: - await self.place(package_ids, ROOT_ID) - - async def reset_to_rules(self, package_ids: Iterable[str]) -> None: - pids = _clean_package_ids(package_ids) - if not pids: - return - await self._db.unplace(pids) - await self._commit() - - # ── Tags ────────────────────────────────────────────────────────────────── - - async def create_tag(self, name: str, color: str) -> Tag: - cleaned = self._check_tag(name, color) - tag = await self._db.create_tag(cleaned, color) - await self._commit() - return tag - - async def update_tag(self, tag_id: int, name: str, color: str) -> None: - self._tag(tag_id) - cleaned = self._check_tag(name, color, exclude=tag_id) - await self._db.update_tag(tag_id, cleaned, color) - await self._commit() - - async def delete_tag(self, tag_id: int) -> None: - self._tag(tag_id) - await self._db.delete_tag(tag_id) - await self._commit() - - async def set_mod_tags( - self, - package_ids: Iterable[str], - add: Iterable[int] = (), - remove: Iterable[int] = (), - ) -> None: - pids = _clean_package_ids(package_ids) - add_ids = frozenset(add) - remove_ids = frozenset(remove) - if add_ids & remove_ids: - raise OrganizerError("A tag cannot be added and removed at once.") - for tag_id in add_ids | remove_ids: - self._tag(tag_id) - if not pids or not (add_ids or remove_ids): - return - await self._db.set_mod_tags(pids, add_ids, remove_ids) - await self._commit() - - # ── Rules ───────────────────────────────────────────────────────────────── - - async def set_rules(self, specs: Sequence[RuleSpec]) -> None: - cleaned: list[RuleSpec] = [] - for spec in specs: - if spec.field not in _RULE_FIELDS: - raise OrganizerError(f'Unknown rule field "{spec.field}".') - if spec.op not in _RULE_OPS: - raise OrganizerError(f'Unknown rule operator "{spec.op}".') - pattern = spec.pattern.strip() - if not pattern: - raise OrganizerError("Rule pattern cannot be empty.") - if spec.folder_id == ROOT_ID: - raise OrganizerError("Rules must target a folder, not the root.") - self._folder(spec.folder_id) - cleaned.append(msgspec.structs.replace(spec, pattern=pattern)) - await self._db.replace_rules(cleaned) - await self._commit() - - async def add_standard_rules(self) -> int: - """Append missing built-in rules, creating their top-level folders. - - Returns the number of rules added. - """ - present = { - (rule.field, rule.op, rule.pattern.casefold()) for rule in self.state.rules - } - existing = {folder.name.casefold() for folder in self._children(ROOT_ID)} - rules: list[StandardRule] = [] - new_folders: dict[str, str] = {} - for rule in STANDARD_RULES: - key = (rule.field, rule.op, rule.pattern.casefold()) - if key in present: - continue - present.add(key) - rules.append(rule) - folder_key = rule.folder.casefold() - if folder_key not in existing: - new_folders.setdefault(folder_key, rule.folder) - if not rules: - return 0 - await self._db.append_named_rules(ROOT_ID, list(new_folders.values()), rules) - await self._commit() - return len(rules) - - # ── Read helpers ────────────────────────────────────────────────────────── - - def tree(self, query: TreeQuery | None = None) -> FolderNode: - return build_tree(self.state, self._ctx.all_mods, self._ctx.active_uuids, query) - - def folder_mod_uuids(self, folder_id: int, recursive: bool = True) -> list[str]: - """Mods under *folder_id*; ``recursive=False`` keeps only its direct mods.""" - self._folder(folder_id) - for node in self.tree().walk(): - if node.folder.id == folder_id: - leaves = node.all_mods() if recursive else node.mods - return [leaf.uuid for leaf in leaves] - return [] - - def folder_toggle( - self, folder_id: int, recursive: bool = True - ) -> tuple[list[str], list[str]]: - """(enable, disable) for a folder checkbox click. - - A fully enabled folder disables everything; partial or off enables all. - """ - self._folder(folder_id) - for node in self.tree().walk(): - if node.folder.id == folder_id: - leaves = node.all_mods() if recursive else node.mods - uuids = [leaf.uuid for leaf in leaves] - check = node.check if recursive else node.own_check - return ([], uuids) if check == "on" else (uuids, []) - return [], [] - - def folder_move_targets(self, folder_id: int) -> frozenset[int]: - """Legal destinations, excluding the folder's current parent.""" - folder = self._non_root(folder_id) - subtree = self._subtree_ids(folder_id) - height = self._height(folder_id) - key = folder.name.casefold() - return frozenset( - candidate.id - for candidate in self.state.folders.values() - if candidate.id not in subtree - and candidate.id != folder.parent_id - and self._depth(candidate.id) + height <= MAX_DEPTH - and all(c.name.casefold() != key for c in self._children(candidate.id)) - ) - - def tree_filters(self) -> list[TreeFilter]: - return tree_filters(self._ctx.all_mods, self._ctx.active_uuids, self.state) diff --git a/legacy/pxmodrim/core/plugin.py b/legacy/pxmodrim/core/plugin.py deleted file mode 100644 index 50c8f16..0000000 --- a/legacy/pxmodrim/core/plugin.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from typing import Any, ClassVar - -import toposort -from loguru import logger - - -class Plugin: - """ - Base class for all plugins. - - ``setup(ctx)`` is called synchronously during ``setup_all()`` in dependency - order. Use it to register views, connect signals, etc. ``init(ctx)`` is - async and runs immediately after ``setup()`` — use it for I/O or async init. - """ - - name: ClassVar[str] = "" - dependencies: ClassVar[list[str]] = [] - - def setup(self, ctx: Any) -> None: ... - - async def init(self, ctx: Any) -> None: ... - - async def shutdown(self) -> None: ... - - -class PluginRegistry: - def __init__(self) -> None: - """Initialize the plugin registry with an empty plugin map.""" - self._plugins: dict[str, Plugin] = {} - - def register(self, plugin: Plugin, ctx: Any = None) -> None: - logger.debug("plugin registered: {}", plugin.name) - self._plugins[plugin.name] = plugin - - def get(self, name: str) -> Plugin | None: - return self._plugins.get(name) - - @property - def names(self) -> set[str]: - return set(self._plugins) - - def setup_all(self, ctx: Any, extra_deps: set[str] | None = None) -> None: - available = extra_deps or set() - for name, plugin in self._plugins.items(): - for dep in plugin.dependencies: - if dep not in self._plugins and dep not in available: - raise RuntimeError( - f"Plugin '{name}' depends on '{dep}' which is not registered" - ) - logger.info("setting up {} plugins...", len(self._plugins)) - for p in self._toposort(): - p.setup(ctx) - logger.info("all plugins set up") - - async def init_all(self, ctx: Any) -> None: - logger.info("initializing {} plugins...", len(self._plugins)) - for p in self._toposort(): - await p.init(ctx) - logger.info("all plugins initialized") - - def _toposort(self) -> list[Plugin]: - data = { - name: {dep for dep in plugin.dependencies if dep in self._plugins} - for name, plugin in self._plugins.items() - } - levels = list(toposort.toposort(data)) - return [self._plugins[name] for level in levels for name in sorted(level)] - - async def shutdown_all(self) -> None: - logger.info("shutting down {} plugins...", len(self._plugins)) - for p in reversed(self._toposort()): - await p.shutdown() - logger.info("all plugins shut down") diff --git a/legacy/pxmodrim/core/plugin_config.py b/legacy/pxmodrim/core/plugin_config.py deleted file mode 100644 index b86f98b..0000000 --- a/legacy/pxmodrim/core/plugin_config.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import TYPE_CHECKING - -import msgspec -from loguru import logger - -from pxmodrim.core.events import Event - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - - -class PluginConfig[T: msgspec.Struct]: - """A plugin's own settings, kept in ``plugins/.json``. - - *legacy* maps keys of the old shared ``config.json`` to fields of *settings_type*; - they seed the file the first time it is created. Without a *service* the - settings live in memory only. - """ - - __slots__ = ("_filename", "_service", "_value", "changed") - - def __init__( - self, - service: ConfigService | None, - name: str, - settings_type: type[T], - legacy: Mapping[str, str] | None = None, - ) -> None: - self.changed: Event[T] = Event() - self._service = service - self._filename = f"plugins/{name}.json" - stored = ( - None - if service is None - else service.load_existing(self._filename, settings_type) - ) - if stored is None: - stored = self._seed(settings_type, legacy or {}) - self._value: T = stored - - def _seed(self, settings_type: type[T], legacy: Mapping[str, str]) -> T: - if self._service is None or not legacy: - return settings_type() - raw = self._service.load_raw("config.json") - fields = {new: raw[old] for old, new in legacy.items() if old in raw} - try: - value = msgspec.convert(fields, settings_type) - except msgspec.ValidationError as exc: - logger.warning("Ignoring old settings for {}: {}", self._filename, exc) - return settings_type() - if fields: - self._store(value) - return value - - @property - def value(self) -> T: - return self._value - - def update(self, value: T) -> None: - if value == self._value: - return - self._value = value - self._store(value) - self.changed.emit(value) - - def _store(self, value: T) -> None: - if self._service is None: - return - try: - self._service.save(self._filename, value) - except OSError as exc: - logger.warning("Cannot save {}: {}", self._filename, exc) diff --git a/legacy/pxmodrim/core/profiler.py b/legacy/pxmodrim/core/profiler.py deleted file mode 100644 index a680768..0000000 --- a/legacy/pxmodrim/core/profiler.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager - -from loguru import logger -from ttimer import Timer - - -@contextmanager -def profile(label: str) -> Iterator[Timer]: - """Profile a code block and log the hierarchical timing report on exit. - - Usage: - with profile("discover") as t: - with t("provider.core"): - ... - with t("resolve_active"): - ... - """ - timer = Timer() - with timer(label): - yield timer - logger.debug("Profile report:\n{}", timer.render()) - - -def adopt(parent: Timer, child: Timer) -> None: - """Reparent all of *child*'s nodes under *parent*'s current stack position. - - Each root tree in *child* becomes a sibling of the nodes already under - ``parent._current_node``. After adoption *child* is discarded (its nodes - live in *parent* now). - """ - for stack, node in child._nodes.items(): - parent._nodes[stack] = node - for root in child.trees: - root.parent = parent._current_node diff --git a/legacy/pxmodrim/core/providers/__init__.py b/legacy/pxmodrim/core/providers/__init__.py deleted file mode 100644 index df0c4ae..0000000 --- a/legacy/pxmodrim/core/providers/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import TYPE_CHECKING - -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.providers.core import CoreModProvider -from pxmodrim.core.providers.local import ( - DownloadedModProvider, - LocalModProvider, - SteamWorkshopModProvider, -) - -if TYPE_CHECKING: - from pxmodrim.core.config import PathConfig - - -def create_providers( - paths: PathConfig, pool: ThreadPoolExecutor | None = None -) -> list[BaseModProvider]: - """Build the default provider list from the given path configuration.""" - providers: list[BaseModProvider] = [] - if paths.game: - providers.append(CoreModProvider(Path(paths.game))) - if paths.local: - providers.append(LocalModProvider(Path(paths.local), pool=pool)) - providers.append(DownloadedModProvider(Path(paths.local), pool=pool)) - if paths.workshop: - providers.append(SteamWorkshopModProvider(Path(paths.workshop), pool=pool)) - return providers - - -__all__ = [ - "BaseModProvider", - "CoreModProvider", - "DownloadedModProvider", - "LocalModProvider", - "SteamWorkshopModProvider", - "create_providers", -] diff --git a/legacy/pxmodrim/core/providers/base.py b/legacy/pxmodrim/core/providers/base.py deleted file mode 100644 index 7148074..0000000 --- a/legacy/pxmodrim/core/providers/base.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from abc import ABC, abstractmethod -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import TYPE_CHECKING - -from ttimer import Timer - -from pxmodrim.core.models.metadata.parsing import create_listed_mod_from_path -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -class BaseModProvider(ABC): - """Abstract mod provider. Subclasses implement :meth:`discover`.""" - - provider_id: str - color: str = "#808080" - - def __init__( - self, - path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - """Store the root filesystem path this provider scans.""" - self._path = path - self._pool = pool - self._metadata_cache = metadata_cache - - @abstractmethod - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - """Discover mods from this provider's path.""" - - async def _load_mods( - self, - dirs: dict[Path, Path], - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - cache = metadata_cache if metadata_cache is not None else self._metadata_cache - - def _stat_all() -> list[tuple[Path, Path, float, int]]: - out: list[tuple[Path, Path, float, int]] = [] - for d, a in dirs.items(): - try: - st = os.stat(a) - except OSError: - continue - out.append((d, a, st.st_mtime, st.st_size)) - return out - - candidates = await asyncio.to_thread(_stat_all) - - result: dict[str, ListedMod] = {} - if force_reparse: - missing = candidates - elif cache is not None: - with tm("cache_lookup"): - cached_mods, missing = await cache.get_mods(candidates, target_version) - for mod in cached_mods.values(): - mod.provider_id = self.provider_id - result[mod.uuid] = mod - else: - missing = candidates - - if missing: - with tm("parse_xml"): - newly_parsed = await self._parse_candidates(missing, target_version) - cache_items = [ - (m, a, mt, sz) - for m, a, mt, sz in newly_parsed - if isinstance(m, AboutXmlMod) - ] - if cache is not None and cache_items: - await cache.put_mods(cache_items, target_version) - for mod, _, _, _ in newly_parsed: - mod.provider_id = self.provider_id - result[mod.uuid] = mod - - # Warm the disk-backed cached_property off the UI thread. - await asyncio.to_thread(lambda: [m.published_file_id for m in result.values()]) - return result - - async def _parse_candidates( - self, - missing: list[tuple[Path, Path, float, int]], - target_version: str, - ) -> list[tuple[ListedMod, Path, float, int]]: - def _parse_one( - item: tuple[Path, Path, float, int], - ) -> tuple[ListedMod, Path, float, int]: - d, a, mtime, size = item - _, mod = create_listed_mod_from_path(d, target_version, about_xml_path=a) - return mod, a, mtime, size - - def _parse_all() -> list[tuple[ListedMod, Path, float, int]]: - pool = self._pool - if pool is not None and len(missing) > 1: - futures = [pool.submit(_parse_one, item) for item in missing] - return [f.result() for f in as_completed(futures)] - return [_parse_one(item) for item in missing] - - return await asyncio.to_thread(_parse_all) diff --git a/legacy/pxmodrim/core/providers/core.py b/legacy/pxmodrim/core/providers/core.py deleted file mode 100644 index 9fa533d..0000000 --- a/legacy/pxmodrim/core/providers/core.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.mod_discovery import scan_mod_directory - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -class CoreModProvider(BaseModProvider): - provider_id = "core" - color = "#e67e22" - - def __init__( - self, - game_path: Path, - metadata_cache: MetadataCache | None = None, - ) -> None: - """Point the provider at the game root (``Data/`` is resolved inside).""" - self._game = game_path - super().__init__(game_path, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - """Scan ``Data/`` directory for core mods (runs off the main thread).""" - tm = timer or Timer() - data_dir = self._game / "Data" - logger.debug("CoreModProvider scanning: {}", data_dir) - if not data_dir.exists(): - return {} - - with tm("scan_dir"): - dirs = await asyncio.to_thread(scan_mod_directory, data_dir) - - discovered = await self._load_mods( - dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - for mod in discovered.values(): - logger.debug("CoreModProvider found: {} (uuid: {})", mod.name, mod.uuid) - logger.info("CoreModProvider discovered {} mods", len(discovered)) - return discovered diff --git a/legacy/pxmodrim/core/providers/local.py b/legacy/pxmodrim/core/providers/local.py deleted file mode 100644 index 35ef7ed..0000000 --- a/legacy/pxmodrim/core/providers/local.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.mod_discovery import scan_mod_directory - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -_in_flight_scans: dict[Path, asyncio.Task[dict[Path, tuple[Path, bool]]]] = {} - - -def _scan_with_pfid(path: Path) -> dict[Path, tuple[Path, bool]]: - return { - d: (a, os.path.exists(os.path.join(d, "About", "PublishedFileId.txt"))) - for d, a in scan_mod_directory(path).items() - } - - -async def _shared_scan(path: Path) -> dict[Path, tuple[Path, bool]]: - """Scan *path* once for concurrent callers. - - Local and downloaded providers share a root and run in parallel; joining an - in-flight scan halves the disk work while never reusing a finished result. - """ - task = _in_flight_scans.get(path) - if task is None or task.get_loop() is not asyncio.get_running_loop(): - task = asyncio.ensure_future(asyncio.to_thread(_scan_with_pfid, path)) - _in_flight_scans[path] = task - task.add_done_callback( - lambda t: ( - _in_flight_scans.pop(path, None) - if _in_flight_scans.get(path) is t - else None - ) - ) - return await asyncio.shield(task) - - -class LocalModProvider(BaseModProvider): - provider_id = "local" - color = "#2ecc71" - - def __init__( - self, - local_path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - super().__init__(local_path, pool=pool, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - if not self._path.exists(): - logger.debug("LocalModProvider path does not exist: {}", self._path) - return {} - logger.debug("LocalModProvider scanning: {}", self._path) - with tm("scan_dir"): - scanned = await _shared_scan(self._path) - filtered_dirs = {d: a for d, (a, has_pfid) in scanned.items() if not has_pfid} - discovered = await self._load_mods( - filtered_dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - logger.info("LocalModProvider discovered {} mods", len(discovered)) - return discovered - - -class DownloadedModProvider(BaseModProvider): - provider_id = "downloaded" - color = "#3498db" - - def __init__( - self, - local_path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - super().__init__(local_path, pool=pool, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - if not self._path.exists(): - logger.debug("DownloadedModProvider path does not exist: {}", self._path) - return {} - logger.debug("DownloadedModProvider scanning: {}", self._path) - with tm("scan_dir"): - scanned = await _shared_scan(self._path) - filtered_dirs = {d: a for d, (a, has_pfid) in scanned.items() if has_pfid} - discovered = await self._load_mods( - filtered_dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - logger.info("DownloadedModProvider discovered {} mods", len(discovered)) - return discovered - - -class SteamWorkshopModProvider(DownloadedModProvider): - provider_id = "steam" diff --git a/legacy/pxmodrim/core/services/__init__.py b/legacy/pxmodrim/core/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/core/services/activation_service.py b/legacy/pxmodrim/core/services/activation_service.py deleted file mode 100644 index 66324b3..0000000 --- a/legacy/pxmodrim/core/services/activation_service.py +++ /dev/null @@ -1,341 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator -from heapq import heapify, heappop, heappush - -from pxmodrim.core.checker.graph import ( - ConstraintEdge, - ConstraintGraph, - EdgeType, - PackageId, -) -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - - -class ActivationService: - """Enable/disable mods without reordering the rest of the active list.""" - - __slots__ = ("_ctx",) - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - - def dependents_of( - self, uuids: Iterable[str], *, enable: Iterable[str] = () - ) -> list[str]: - """Active dependents of packages lost after the enable/disable changes.""" - excluded = set(uuids) - all_mods = self._ctx.all_mods - remaining = { - PackageId(mod.package_id) - for uuid in (*self._ctx.active_uuids, *enable) - if uuid not in excluded - and isinstance(mod := all_mods.get(uuid), AboutXmlMod) - } - roots: set[PackageId] = set() - for uuid in excluded: - mod = all_mods.get(uuid) - if ( - isinstance(mod, AboutXmlMod) - and PackageId(mod.package_id) not in remaining - ): - roots.add(PackageId(mod.package_id)) - if not roots: - return [] - - graph = self._ctx.diagnostics_service.constraint_graph - dependents: set[PackageId] = set() - pending = list(roots) - while pending: - target = pending.pop() - for edge in graph.incoming_of_type(target, EdgeType.DEPENDENCY): - if edge.source not in roots and edge.source not in dependents: - dependents.add(edge.source) - pending.append(edge.source) - - result: list[str] = [] - for uuid in self._ctx.active_uuids: - if uuid in excluded: - continue - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod) and PackageId(mod.package_id) in dependents: - result.append(uuid) - return result - - def apply(self, enable: Iterable[str] = (), disable: Iterable[str] = ()) -> bool: - """Apply batch enable/disable changes; return whether active state changed.""" - active = self._ctx.active_uuids - disable_set = set(disable) - new_active = [uuid for uuid in active if uuid not in disable_set] - candidates = self._enable_candidates(enable, disable_set, set(active)) - - placement = _Placement( - new_active, candidates, self._ctx.all_mods, self._build_edges - ) - result = placement.assemble() - if result == active: - return False - - self._ctx.set_active(result) - return True - - def _enable_candidates( - self, enable: Iterable[str], disable_set: set[str], active_set: set[str] - ) -> list[str]: - all_mods = self._ctx.all_mods - candidates: list[str] = [] - seen: set[str] = set() - for uuid in enable: - if uuid in seen or uuid in disable_set: - continue - seen.add(uuid) - if uuid in all_mods and uuid not in active_set: - candidates.append(uuid) - return candidates - - def _build_edges( - self, - mods: dict[PackageId, AboutXmlMod], - order: list[PackageId], - candidates: Iterable[PackageId], - ) -> list[tuple[PackageId, PackageId]]: - """(before, after) pairs between *mods* touching any of *candidates*. - - Edges through ids outside *mods* (uninstalled optional mods) are - dropped so they cannot chain unrelated mods together. - """ - graph = ConstraintGraph() - graph.build( - mods, - order, - self._ctx.config.sort, - self._ctx.diagnostics_service.community_rules, - ) - # Placement ignores active-to-active edges, so only edges incident to a - # candidate matter; the graph still resolves the full alternative map. - incident: set[ConstraintEdge] = set() - for pid in candidates: - incident.update(graph.outgoing(pid)) - incident.update(graph.incoming(pid)) - edges: list[tuple[PackageId, PackageId]] = [] - for edge in incident: - if edge.type == EdgeType.LOAD_BEFORE: - before, after = edge.source, edge.target - elif edge.type in (EdgeType.DEPENDENCY, EdgeType.LOAD_AFTER): - before, after = edge.target, edge.source - else: - continue - if before != after and before in mods and after in mods: - edges.append((before, after)) - return edges - - -class _Placement: - """Slots enable candidates into gaps of a fixed active order. - - Gap ``i`` means "just before ``new_active[i]``"; gap ``len(new_active)`` - is the end. Bounds come only from direct edges to active mods, propagated - through batch candidates, because the active order itself may already - violate constraints and must not be re-derived transitively. - """ - - def __init__( - self, - new_active: list[str], - candidates: list[str], - all_mods: dict[str, ListedMod], - build_edges: Callable[ - [dict[PackageId, AboutXmlMod], list[PackageId], Iterable[PackageId]], - list[tuple[PackageId, PackageId]], - ], - ) -> None: - self._new_active = new_active - self._candidates = candidates - self._end = len(new_active) - - mods: dict[PackageId, AboutXmlMod] = {} - order: list[PackageId] = [] - self._first_pos: dict[PackageId, int] = {} - self._last_pos: dict[PackageId, int] = {} - for index, uuid in enumerate(new_active): - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid = PackageId(mod.package_id) - self._first_pos.setdefault(pid, index) - self._last_pos[pid] = index - if pid not in mods: - mods[pid] = mod - order.append(pid) - - self._uuids_by_pid: dict[PackageId, list[str]] = {} - for uuid in candidates: - mod = all_mods[uuid] - if not isinstance(mod, AboutXmlMod): - continue - pid = PackageId(mod.package_id) - self._uuids_by_pid.setdefault(pid, []).append(uuid) - if pid not in mods: - mods[pid] = mod - order.append(pid) - - # Batch-internal adjacency plus direct bounds against active mods. - self._after: dict[str, set[str]] = {uuid: set() for uuid in candidates} - self._before: dict[str, set[str]] = {uuid: set() for uuid in candidates} - self._floor: dict[str, int] = {} - self._ceiling: dict[str, int] = {} - if self._uuids_by_pid: - for before, after in build_edges(mods, order, self._uuids_by_pid): - self._add_edge(before, after) - - def _add_edge(self, before: PackageId, after: PackageId) -> None: - before_uuids = self._uuids_by_pid.get(before, ()) - after_uuids = self._uuids_by_pid.get(after, ()) - for b in before_uuids: - for a in after_uuids: - if a != b: - self._after[b].add(a) - self._before[a].add(b) - first = self._first_pos.get(after) - if first is not None: - self._ceiling[b] = min(self._ceiling.get(b, first), first) - last = self._last_pos.get(before) - if last is not None: - for a in after_uuids: - self._floor[a] = max(self._floor.get(a, last + 1), last + 1) - - def assemble(self) -> list[str]: - ordered, cyclic = self._order_batch() - ceilings = self._propagated_ceilings(ordered, cyclic) - buckets: list[list[str]] = [[] for _ in range(self._end + 1)] - gaps: dict[str, int] = {} - for uuid in ordered: - gap = self._gap(uuid, cyclic, ceilings, gaps) - gaps[uuid] = gap - buckets[gap].append(uuid) - - result: list[str] = [] - for index, uuid in enumerate(self._new_active): - result.extend(buckets[index]) - result.append(uuid) - result.extend(buckets[-1]) - return result - - def _gap( - self, - uuid: str, - cyclic: set[str], - ceilings: dict[str, int], - gaps: dict[str, int], - ) -> int: - if uuid in cyclic: - return self._end - floors = [gaps[b] for b in self._before[uuid]] - if uuid in self._floor: - floors.append(self._floor[uuid]) - ceiling = ceilings.get(uuid) - if floors: - earliest = max(floors) - if ceiling is not None and earliest > ceiling: - return self._end - return earliest - return self._end if ceiling is None else ceiling - - def _propagated_ceilings( - self, ordered: list[str], cyclic: set[str] - ) -> dict[str, int]: - """Earliest active position each candidate must precede, via batch chains.""" - ceilings: dict[str, int] = {} - for uuid in reversed(ordered): - if uuid in cyclic: - continue - bounds = [ceilings[a] for a in self._after[uuid] if a in ceilings] - if uuid in self._ceiling: - bounds.append(self._ceiling[uuid]) - if bounds: - ceilings[uuid] = min(bounds) - return ceilings - - def _order_batch(self) -> tuple[list[str], set[str]]: - """Topologically order candidates by input order; cycle members as a unit.""" - components = _strongly_connected(self._candidates, self._after) - component_of = { - uuid: index for index, members in enumerate(components) for uuid in members - } - cyclic = { - uuid for members in components if len(members) > 1 for uuid in members - } - - input_index = {uuid: index for index, uuid in enumerate(self._candidates)} - successors: list[set[int]] = [set() for _ in components] - indegree = [0] * len(components) - for uuid, afters in self._after.items(): - source = component_of[uuid] - for after in afters: - target = component_of[after] - if target != source and target not in successors[source]: - successors[source].add(target) - indegree[target] += 1 - - for members in components: - members.sort(key=input_index.__getitem__) - ready = [ - (input_index[members[0]], index) - for index, members in enumerate(components) - if indegree[index] == 0 - ] - heapify(ready) - ordered: list[str] = [] - while ready: - _, index = heappop(ready) - ordered.extend(components[index]) - for target in successors[index]: - indegree[target] -= 1 - if indegree[target] == 0: - heappush(ready, (input_index[components[target][0]], target)) - return ordered, cyclic - - -def _strongly_connected( - nodes: list[str], adjacent: dict[str, set[str]] -) -> list[list[str]]: - """Iterative Tarjan SCC.""" - index_of: dict[str, int] = {} - lowlink: dict[str, int] = {} - stack: list[str] = [] - on_stack: set[str] = set() - components: list[list[str]] = [] - for root in nodes: - if root in index_of: - continue - work: list[tuple[str, Iterator[str]]] = [] - index_of[root] = lowlink[root] = len(index_of) - stack.append(root) - on_stack.add(root) - work.append((root, iter(adjacent[root]))) - while work: - node, children = work[-1] - for child in children: - if child not in index_of: - index_of[child] = lowlink[child] = len(index_of) - stack.append(child) - on_stack.add(child) - work.append((child, iter(adjacent[child]))) - break - if child in on_stack: - lowlink[node] = min(lowlink[node], index_of[child]) - else: - work.pop() - if work: - parent = work[-1][0] - lowlink[parent] = min(lowlink[parent], lowlink[node]) - if lowlink[node] == index_of[node]: - component: list[str] = [] - while True: - member = stack.pop() - on_stack.discard(member) - component.append(member) - if member == node: - break - components.append(component) - return components diff --git a/legacy/pxmodrim/core/services/diagnostics_service.py b/legacy/pxmodrim/core/services/diagnostics_service.py deleted file mode 100644 index eecb813..0000000 --- a/legacy/pxmodrim/core/services/diagnostics_service.py +++ /dev/null @@ -1,385 +0,0 @@ -from __future__ import annotations - -import asyncio -import operator -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -from ttimer import Timer - -from pxmodrim.core.checker.checker import ModChecker -from pxmodrim.core.checker.databases import ( - NoVersionWarningService, - UseThisInsteadService, -) -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - GameVersionIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ReplacementIssueChecker, -) -from pxmodrim.core.checker.models import ModDiagnostics -from pxmodrim.core.events import Event -from pxmodrim.core.models.view.diagnostics import ( - ModDiagnosticsView, - ModIssueView, -) -from pxmodrim.core.models.view.sidebar import ( - PROVIDER_LABELS, - ActiveModsEntry, - AllModsEntry, - ErrorModsEntry, - InactiveModsEntry, - ProviderModsEntry, - SidebarEntry, - WarningModsEntry, -) -from pxmodrim.core.sort.models import CommunityRule, PackageId - -if TYPE_CHECKING: - from pxmodrim.core.checker.graph import ConstraintGraph - from pxmodrim.core.config import ConfigService - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - - -DATABASE_MAX_AGE_S = 24 * 3600 - - -def _needs_refresh(loaded: object, service: _AgedCache) -> bool: - age = service.cache_age_s() - return not loaded or age is None or age > DATABASE_MAX_AGE_S - - -class _AgedCache(Protocol): - def cache_age_s(self) -> float | None: ... - - -class DiagnosticsService: - diagnostics_summary_changed: Event[dict[str, ModDiagnosticsView]] - status_message_changed: Event[str] - background_task_changed: Event[str] - sidebar_entries_changed: Event[list[SidebarEntry]] - - __slots__ = ( - "_checker", - "_community_rules", - "_ctx", - "_db_task", - "_last_active_uuids", - "_last_summary", - "_last_summary_sources", - "_no_version_warning_service", - "_sidebar_base", - "_use_this_instead_service", - "background_task_changed", - "diagnostics_summary_changed", - "sidebar_entries_changed", - "status_message_changed", - ) - - def __init__( - self, ctx: CoreContext, config_service: ConfigService | None = None - ) -> None: - """Initialise diagnostics with checkers, databases, and community rules.""" - self.diagnostics_summary_changed = Event() - self.status_message_changed = Event() - self.background_task_changed = Event() - self._db_task: asyncio.Task[None] | None = None - self.sidebar_entries_changed = Event() - self._ctx = ctx - cs = config_service or ctx.config_service - self._no_version_warning_service = NoVersionWarningService(cs) - self._use_this_instead_service = UseThisInsteadService(cs) - self._community_rules: dict[PackageId, CommunityRule] | None = None - self._last_active_uuids: list[str] = [] - self._last_summary: dict[str, ModDiagnosticsView] = {} - # Diagnostics each summary view was built from; reorders keep most - # diagnostics objects, so their views can be reused. - self._last_summary_sources: dict[str, ModDiagnostics] = {} - self._sidebar_base: _SidebarBase | None = None - self._checker = ModChecker( - checkers=[ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - GameVersionIssueChecker(), - ReplacementIssueChecker(), - ], - settings=ctx.config.sort, - target_version=ctx.target_version, - on_diagnostics_changed=self._on_checker_diagnostics_changed, - ) - - # ── Lifecycle ────────────────────────────────────────────── - - async def initialize(self, timer: Timer | None = None) -> None: - """Load databases and community rules, then apply them to the checker.""" - if timer is None: - await self._ensure_databases() - self._community_rules = await self._load_community_rules() - if self._community_rules: - self._checker.set_community_rules(self._community_rules) - return - with timer("diagnostics.init"): - with timer("ensure_databases"): - await self._ensure_databases() - with timer("load_community_rules"): - self._community_rules = await self._load_community_rules() - if self._community_rules: - self._checker.set_community_rules(self._community_rules) - - async def _ensure_databases(self) -> None: - """Apply cached databases now; refresh missing or stale ones in background.""" - nvw = await asyncio.to_thread(self._no_version_warning_service.load_if_exists) - if nvw: - self._checker.set_no_version_warning(nvw) - uti = await asyncio.to_thread(self._use_this_instead_service.load_if_exists) - if uti: - self._checker.set_use_this_instead(uti) - refresh_nvw = _needs_refresh(nvw, self._no_version_warning_service) - refresh_uti = _needs_refresh(uti, self._use_this_instead_service) - if (refresh_nvw or refresh_uti) and ( - self._db_task is None or self._db_task.done() - ): - self._db_task = asyncio.create_task( - self._download_databases(refresh_nvw, refresh_uti) - ) - - async def _download_databases(self, nvw: bool, uti: bool) -> None: - self.background_task_changed.emit("Downloading rule databases\u2026") - changed = False - try: - if nvw: - pids = await self._no_version_warning_service.ensure(force=True) - if pids: - self._checker.set_no_version_warning(pids) - changed = True - if uti: - entries = await self._use_this_instead_service.ensure(force=True) - if entries: - self._checker.set_use_this_instead(entries) - changed = True - if changed: - self.rebuild() - finally: - self.background_task_changed.emit("") - - async def _load_community_rules(self) -> dict[PackageId, CommunityRule] | None: - """Load community sorting rules from disk, if enabled.""" - if not self._ctx.config.sort.use_community_rules: - return None - from pxmodrim.core.sort.community import load_community_rules - - path = self._ctx.config.paths.community_rules_file - if not path: - path = str(self._ctx.config_service.config_dir / "communityRules.json") - if not path or not Path(path).exists(): - return None - return load_community_rules(Path(path)) - - # ── Mutations ────────────────────────────────────────────── - - def rebuild( - self, active_uuids: list[str] | None = None, timer: Timer | None = None - ) -> None: - """Rebuild the checker for all mods with the given active UUIDs.""" - if active_uuids is None: - active_uuids = self._ctx.active_uuids - self._last_active_uuids = active_uuids - if timer is None: - self._checker.rebuild(self._ctx.all_mods, active_uuids) - return - with timer("diagnostics.rebuild"): - self._checker.rebuild(self._ctx.all_mods, active_uuids, timer=timer) - - def reorder(self, active_uuids: list[str]) -> None: - """Notify the checker of a new active mod order.""" - self._last_active_uuids = active_uuids - self._checker.reorder(active_uuids) - - # ── Queries ──────────────────────────────────────────────── - - @property - def active_mods_by_pid(self) -> dict[PackageId, AboutXmlMod]: - return self._checker.active_mods - - @property - def constraint_graph(self) -> ConstraintGraph: - return self._checker.graph - - @property - def active_ordered_pids(self) -> list[PackageId]: - return self._checker.ordered_pids - - @property - def community_rules(self) -> dict[PackageId, CommunityRule] | None: - return self._checker.community_rules - - def issues_for(self, uuid: str) -> list[ModIssueView]: - """Return view models for all diagnostics associated with a given mod UUID.""" - diag = self._checker.diagnostics_for(uuid) - return self._to_issue_views(diag) if diag else [] - - # ── Conversions ──────────────────────────────────────────── - - @staticmethod - def _to_issue_views(diag: ModDiagnostics) -> list[ModIssueView]: - """Convert a ModDiagnostics object into a list of ModIssueView models.""" - views: list[ModIssueView] = [] - for issue in diag.errors: - views.append( - ModIssueView( - category=issue.category, - category_display_name=issue.category_display_name or issue.category, - detail=issue.detail_message or None, - is_error=True, - ) - ) - for issue in diag.warnings: - views.append( - ModIssueView( - category=issue.category, - category_display_name=issue.category_display_name or issue.category, - detail=issue.detail_message or None, - is_error=False, - ) - ) - return views - - @staticmethod - def _to_view(diag: ModDiagnostics) -> ModDiagnosticsView: - """Convert diagnostics to a serialisable view for the UI.""" - return ModDiagnosticsView( - has_errors=diag.has_errors, - has_warnings=diag.has_warnings, - error_tooltip=diag.error_tooltip, - warning_tooltip=diag.warning_tooltip, - ) - - # ── Checker callback ─────────────────────────────────────── - - def _on_checker_diagnostics_changed( - self, diagnostics: dict[str, ModDiagnostics] - ) -> None: - """Callback from checker; emit updated summary, status, and sidebar signals.""" - previous_views = self._last_summary - previous_sources = self._last_summary_sources - summary: dict[str, ModDiagnosticsView] = {} - for uuid, diag in diagnostics.items(): - if previous_sources.get(uuid) is diag: - summary[uuid] = previous_views[uuid] - else: - summary[uuid] = self._to_view(diag) - self._last_summary = summary - self._last_summary_sources = diagnostics - # Sidebar first: the summary handler re-applies the current sidebar - # filter and must see the entries for this state, not the previous one. - self.sidebar_entries_changed.emit(self._build_sidebar_entries()) - self.diagnostics_summary_changed.emit(self._last_summary) - self.status_message_changed.emit(self._format_status()) - - def summary_for(self, uuid: str) -> ModDiagnosticsView | None: - return self._last_summary.get(uuid) - - # ── Status ────────────────────────────────────────────────── - - def active_error_count(self) -> int: - diagnostics = self._checker.active_mod_diagnostics() - return sum(1 for d in diagnostics.values() if d.has_errors) - - def _format_status(self) -> str: - """Build a human-readable status string showing active/error/warning counts.""" - active = self._last_active_uuids - diagnostics = self._checker.active_mod_diagnostics() - - err_count = sum(1 for d in diagnostics.values() if d.has_errors) - warn_count = sum(1 for d in diagnostics.values() if d.has_warnings) - - parts: list[str] = [f"{len(active)} active"] - if err_count: - parts.append(f"{err_count} with errors") - if warn_count: - parts.append(f"{warn_count} with warnings") - return " | ".join(parts) - - # ── Sidebar ───────────────────────────────────────────────── - - def _build_sidebar_entries(self) -> list[SidebarEntry]: - """Build sidebar entries categorised by provider, active, error, warning.""" - mods = self._ctx.all_mods - active = self._last_active_uuids - diagnostics = self._checker.active_mod_diagnostics() - - base = self._sidebar_base - if base is None or not base.matches(mods): - base = self._sidebar_base = _SidebarBase(mods) - - errors = set(base.invalid) - warnings: set[str] = set() - for u, diag in diagnostics.items(): - if u not in mods: - continue - if diag.has_errors: - errors.add(u) - if diag.has_warnings: - warnings.add(u) - - all_entry = AllModsEntry() - active_entry = ActiveModsEntry() - inactive_entry = InactiveModsEntry() - error_entry = ErrorModsEntry() - warning_entry = WarningModsEntry() - all_entry.visible_uuids = set(base.all_uuids) - active_entry.visible_uuids = set(active) - inactive_entry.visible_uuids = ( - all_entry.visible_uuids - active_entry.visible_uuids - ) - error_entry.visible_uuids = errors - warning_entry.visible_uuids = warnings - entries: list[SidebarEntry] = [ - all_entry, - active_entry, - inactive_entry, - error_entry, - warning_entry, - ] - for entry in entries: - entry.refresh_count() - for pid, uuids in base.by_provider: - entries.append( - ProviderModsEntry(pid, PROVIDER_LABELS.get(pid, pid), set(uuids)) - ) - - return entries - - -class _SidebarBase: - """Diagnostics-independent sidebar partitions, reused until the mods change.""" - - __slots__ = ("all_uuids", "by_provider", "invalid", "mods") - - def __init__(self, mods: dict[str, ListedMod]) -> None: - self.mods = mods - by_provider: dict[str, set[str]] = {} - invalid: set[str] = set() - for u, m in mods.items(): - by_provider.setdefault(m.provider_id, set()).add(u) - if not m.valid: - invalid.add(u) - self.by_provider = sorted(by_provider.items(), key=lambda item: item[0]) - self.invalid = frozenset(invalid) - self.all_uuids = frozenset(mods) - - def matches(self, mods: dict[str, ListedMod]) -> bool: - # Callers pass copies of one dict, so identical iteration order is the - # common case; any difference just forces a (correct) recompute. - cached = self.mods - return ( - len(cached) == len(mods) - and all(map(operator.is_, cached.values(), mods.values())) - and all(map(operator.eq, cached, mods)) - ) diff --git a/legacy/pxmodrim/core/services/game_launcher.py b/legacy/pxmodrim/core/services/game_launcher.py deleted file mode 100644 index 03a7eec..0000000 --- a/legacy/pxmodrim/core/services/game_launcher.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import re -import shlex -import subprocess -import sys -import webbrowser -from pathlib import Path -from urllib.parse import quote - -import psutil -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, LaunchStrategy -from pxmodrim.core.context import CoreContext - -_PROCESS_NAMES = frozenset( - { - "RimWorld", - "RimWorldLinux", - "RimWorldWin64.exe", - "RimWorldWin.exe", - "RimWorld.exe", - "RimWorld by Ludeon Studios", - } -) -_COMMAND_TOKEN = "%command%" -_ENV_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") -_POLL_START = 0.5 -_POLL_EXIT = 2.0 - - -def _split(text: str) -> list[str]: - return shlex.split(text, posix=sys.platform != "win32") - - -def build_direct_command( - exe: str, wrapper: str, args: str -) -> tuple[list[str], dict[str, str]]: - """Expand wrapper/args into argv plus extra env. - - Wrapper follows Steam's convention: leading KEY=VALUE tokens are env vars and - ``%command%`` marks where the game goes; without it the wrapper is a prefix. - """ - game = [exe, *_split(args)] - tokens = _split(wrapper) - env: dict[str, str] = {} - while tokens and _ENV_ASSIGNMENT.match(tokens[0]): - key, _, value = tokens.pop(0).partition("=") - env[key] = value - if _COMMAND_TOKEN in tokens: - i = tokens.index(_COMMAND_TOKEN) - return [*tokens[:i], *game, *tokens[i + 1 :]], env - return [*tokens, *game], env - - -class GameLauncher: - __slots__ = ("_baseline", "_ctx", "_proc") - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - self._proc: subprocess.Popen[bytes] | None = None - self._baseline: frozenset[int] = frozenset() - - async def launch(self, strategy: LaunchStrategy) -> tuple[bool, str]: - if not self._ctx.config.paths.game: - logger.warning("Launch aborted — game path not configured") - return False, "Game path not configured" - - logger.info("Launching game with strategy: {}", strategy.name) - self._baseline = await asyncio.to_thread(self._game_pids) - if strategy == LaunchStrategy.DIRECT: - return await self._launch_direct() - return await self._launch_steam() - - @staticmethod - def _game_pids() -> frozenset[int]: - return frozenset( - proc.pid - for proc in psutil.process_iter(["name", "status"]) - if proc.info["name"] in _PROCESS_NAMES - and proc.info["status"] != psutil.STATUS_ZOMBIE - ) - - @classmethod - def is_running(cls) -> bool: - return bool(cls._game_pids()) - - def _launched_alive(self) -> bool: - """A game process that appeared after launch; stale ones are ignored.""" - return bool(self._game_pids() - self._baseline) - - async def wait_for_start(self, timeout: float = 60.0) -> bool: - """Wait until a game process appears; False if it died or never showed.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - if await asyncio.to_thread(self._launched_alive): - return True - if self._proc is not None and self._proc.poll() is not None: - return False - await asyncio.sleep(_POLL_START) - return False - - async def wait_for_exit(self) -> int | None: - """Block until no game process remains; the exit code if we spawned it.""" - while await asyncio.to_thread(self._launched_alive): - if self._proc is not None: - self._proc.poll() - await asyncio.sleep(_POLL_EXIT) - if self._proc is None: - return None - code = self._proc.poll() - self._proc = None - return code - - async def _launch_direct(self) -> tuple[bool, str]: - game = Path(self._ctx.config.paths.game) - exe = self._find_executable(game) - if exe is None: - logger.warning("Direct launch failed — executable not found in {}", game) - return False, "Game executable not found" - - self._ensure_steam_appid(game) - - cfg = self._ctx.config - try: - if sys.platform == "darwin": - argv = ["open", str(exe), "--args", *_split(cfg.launch_args)] - env: dict[str, str] = {} - else: - argv, env = build_direct_command( - str(exe), cfg.launch_wrapper, cfg.launch_args - ) - except ValueError as e: - logger.warning("Invalid launch arguments: {}", e) - return False, f"Invalid launch arguments: {e}" - - logger.info("Spawning process: {} (cwd: {})", argv, game) - try: - self._proc = await asyncio.to_thread( - subprocess.Popen, - argv, - cwd=str(game), - env={**os.environ, **env}, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - return True, "Game launched" - except (OSError, ValueError) as e: - logger.warning("Direct launch failed: {}", e) - return False, f"Failed to launch: {e}" - - async def _launch_steam(self) -> tuple[bool, str]: - args = self._ctx.config.launch_args.strip() - if args: - url = f"steam://run/{RIMWORLD_STEAM_APP_ID}//{quote(args, safe='')}/" - else: - url = f"steam://rungameid/{RIMWORLD_STEAM_APP_ID}" - logger.info("Opening Steam URL: {}", url) - try: - await asyncio.to_thread(webbrowser.open, url) - return True, "Launching via Steam..." - except webbrowser.Error as e: - logger.warning("Steam launch failed: {}", e) - return False, f"Steam launch failed: {e}" - - @staticmethod - def _find_executable(game_path: Path) -> Path | None: - if sys.platform == "linux": - for name in ("RimWorld", "RimWorldLinux"): - exe = game_path / name - if exe.is_file() and os.access(exe, os.X_OK): - return exe - return None - if sys.platform == "darwin": - exe = game_path / "RimWorld.app" - if exe.is_dir(): - return exe - apps = list(game_path.glob("*.app")) - return apps[0] if apps else None - if sys.platform == "win32": - for name in ("RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"): - exe = game_path / name - if exe.is_file(): - return exe - return None - return None - - @staticmethod - def _ensure_steam_appid(game_path: Path) -> None: - app_id_path = ( - game_path.parent / "steam_appid.txt" - if sys.platform == "darwin" - else game_path / "steam_appid.txt" - ) - if not app_id_path.exists(): - try: - app_id_path.write_text(RIMWORLD_STEAM_APP_ID) - logger.debug("Created {}", app_id_path) - except OSError as e: - logger.warning("Failed to create steam_appid.txt: {}", e) diff --git a/legacy/pxmodrim/core/services/metadata_cache.py b/legacy/pxmodrim/core/services/metadata_cache.py deleted file mode 100644 index 3750cfb..0000000 --- a/legacy/pxmodrim/core/services/metadata_cache.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import json -import os -import sqlite3 -from pathlib import Path -from typing import Any - -import aiosqlite -from loguru import logger - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) - -_SCHEMA = """ - CREATE TABLE IF NOT EXISTS mod_metadata ( - mod_path TEXT PRIMARY KEY, - about_path TEXT NOT NULL, - mtime REAL NOT NULL, - file_size INTEGER NOT NULL DEFAULT 0, - target_version TEXT NOT NULL, - payload_json TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_mm_about - ON mod_metadata(about_path); -""" - -_SCHEMA_VERSION = 2 - - -def metadata_cache_path(config_dir: Path) -> Path: - return config_dir / "metadata-cache.db" - - -def normalize_path(path: Path | str) -> str: - # Same result as str(Path(path).resolve()) without the pathlib round-trips. - return os.path.realpath(path) - - -def normalize_paths(paths: list[Path]) -> list[str]: - """``normalize_path`` for many paths, resolving each shared parent only once.""" - parents: dict[str, str] = {} - out: list[str] = [] - for path in paths: - head, tail = os.path.split(os.fspath(path)) - if not head or tail in ("", ".", "..") or os.path.islink(path): - out.append(os.path.realpath(path)) - continue - real_head = parents.get(head) - if real_head is None: - real_head = parents[head] = os.path.realpath(head) - out.append(os.path.join(real_head, tail)) - return out - - -def serialize_mod(mod: AboutXmlMod) -> dict[str, Any]: - return { - "name": mod.name, - "package_id": str(mod.package_id), - "authors": list(mod.authors), - "mod_version": mod.mod_version, - "mod_icon_path": str(mod.mod_icon_path) if mod.mod_icon_path else None, - "steam_app_id": mod.steam_app_id, - "url": mod.url, - "description": mod.description, - "supported_versions": sorted(mod.supported_versions), - "valid": mod.valid, - "mtime": mod.mtime, - "about_rules": { - "load_before": sorted(str(x) for x in mod.about_rules.load_before), - "load_after": sorted(str(x) for x in mod.about_rules.load_after), - "incompatible_with": sorted( - str(x) for x in mod.about_rules.incompatible_with - ), - "dependencies": { - str(k): { - "name": d.name, - "package_id": str(d.package_id), - "workshop_url": d.workshop_url, - "alternative_package_ids": sorted( - str(a) for a in d.alternative_package_ids - ), - } - for k, d in mod.about_rules.dependencies.items() - }, - }, - } - - -def deserialize_mod(mod_path: Path, data: dict[str, Any]) -> AboutXmlMod: - mod = AboutXmlMod() - mod.mod_path = mod_path - mod.name = data.get("name", "Unknown Mod Name") - mod.package_id = CaseInsensitiveStr(data.get("package_id", "invalid.mod")) - mod.authors = list(data.get("authors", [])) - mod.mod_version = data.get("mod_version", "") - icon_str = data.get("mod_icon_path") - mod.mod_icon_path = Path(icon_str) if icon_str else None - mod.steam_app_id = data.get("steam_app_id", -1) - mod.url = data.get("url", "") - mod.description = data.get("description", "") - mod.supported_versions = set(data.get("supported_versions", [])) - mod.valid = data.get("valid", True) - mod.mtime = data.get("mtime", 0.0) - - rules_data = data.get("about_rules", {}) - rules = BaseRules() - rules.load_before = CaseInsensitiveSet(rules_data.get("load_before", [])) - rules.load_after = CaseInsensitiveSet(rules_data.get("load_after", [])) - rules.incompatible_with = CaseInsensitiveSet( - rules_data.get("incompatible_with", []) - ) - deps: dict[CaseInsensitiveStr, DependencyMod] = {} - for k, d in rules_data.get("dependencies", {}).items(): - dep_pid = CaseInsensitiveStr(d.get("package_id", k)) - deps[dep_pid] = DependencyMod( - name=d.get("name", ""), - package_id=dep_pid, - workshop_url=d.get("workshop_url", ""), - alternative_package_ids={ - CaseInsensitiveStr(a) for a in d.get("alternative_package_ids", []) - }, - ) - rules.dependencies = deps - mod.about_rules = rules - return mod - - -def _decode_rows( - rows: list[Any], - norm_map: dict[str, tuple[Path, Path, float, int]], - target_version: str, -) -> tuple[dict[Path, AboutXmlMod], list[tuple[Path, Path, float, int]]]: - cached_mods: dict[Path, AboutXmlMod] = {} - found_norms: set[str] = set() - for norm_p, _about_p, mtime, file_size, ver, payload in rows: - cand = norm_map.get(norm_p) - if cand is None: - continue - orig_mod_path, _orig_about_path, cur_mtime, cur_size = cand - if ( - abs(mtime - cur_mtime) < 1e-4 - and file_size == cur_size - and ver == target_version - ): - try: - mod = deserialize_mod(orig_mod_path, json.loads(payload)) - except (json.JSONDecodeError, KeyError) as e: - logger.warning("Corrupted metadata cache payload for {}: {}", norm_p, e) - continue - cached_mods[orig_mod_path] = mod - found_norms.add(norm_p) - missing = [c for n, c in norm_map.items() if n not in found_norms] - return cached_mods, missing - - -class MetadataCache: - """Persistent SQLite cache for parsed About.xml metadata. - - Owns a dedicated metadata-cache.db aiosqlite connection guarded by an asyncio lock. - """ - - __slots__ = ( - "_connection", - "_db_path", - "_disabled", - "_lock", - ) - - def __init__(self, path: Path) -> None: - self._db_path = path - self._connection: aiosqlite.Connection | None = None - self._disabled: bool = False - self._lock = asyncio.Lock() - - async def _get_connection(self) -> aiosqlite.Connection | None: - if self._disabled: - return None - - async with self._lock: - if self._connection is not None: - return self._connection - - self._db_path.parent.mkdir(parents=True, exist_ok=True) - db_str = str(self._db_path) - try: - conn = await self._open_and_init(db_str) - self._connection = conn - return conn - except (sqlite3.DatabaseError, aiosqlite.DatabaseError) as exc: - logger.warning( - "Corrupt metadata cache database at {}: {}. Attempting recovery.", - self._db_path, - exc, - ) - await self._recover_corrupt_db() - try: - conn = await self._open_and_init(db_str) - self._connection = conn - return conn - except (sqlite3.Error, aiosqlite.Error, OSError) as recover_exc: - logger.error( - "Failed to re-initialize metadata cache at {}: {}. " - "Disabling cache.", - self._db_path, - recover_exc, - ) - self._disabled = True - return None - except OSError as exc: - logger.warning( - "Metadata cache unavailable at {}: {}. Disabling cache.", - self._db_path, - exc, - ) - self._disabled = True - return None - - async def _open_and_init(self, db_str: str) -> aiosqlite.Connection: - conn = await aiosqlite.connect(db_str, check_same_thread=False) - try: - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await conn.execute("PRAGMA auto_vacuum=INCREMENTAL") - - async def invalidate_metadata_payloads() -> None: - await conn.execute("DELETE FROM mod_metadata") - - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={2: invalidate_metadata_payloads}, - backup_path=self._db_path, - label="metadata cache schema", - ) - return conn - except BaseException: - await conn.close() - raise - - async def _recover_corrupt_db(self) -> None: - def _remove() -> None: - for p in ( - self._db_path, - self._db_path.with_name(f"{self._db_path.name}-wal"), - self._db_path.with_name(f"{self._db_path.name}-shm"), - ): - with contextlib.suppress(OSError): - if p.exists(): - p.unlink() - - await asyncio.to_thread(_remove) - - async def close(self) -> None: - async with self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - - async def get_mods( - self, - candidates: list[tuple[Path, Path, float, int]], - target_version: str, - ) -> tuple[dict[Path, AboutXmlMod], list[tuple[Path, Path, float, int]]]: - """Look up candidate mods in cache. - - Returns (cached_mods_by_path, missing_or_stale_candidates). - """ - if not candidates: - return {}, [] - conn = await self._get_connection() - if conn is None: - return {}, list(candidates) - - norm_map = await asyncio.to_thread( - lambda: dict( - zip( - normalize_paths([c[0] for c in candidates]), candidates, strict=True - ) - ) - ) - - try: - async with self._lock: - rows = await self._fetch_candidates(conn, list(norm_map.keys())) - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error reading metadata cache: {}", exc) - return {}, list(candidates) - - return await asyncio.to_thread(_decode_rows, rows, norm_map, target_version) - - async def _fetch_candidates( - self, conn: aiosqlite.Connection, norm_paths: list[str] - ) -> list[Any]: - rows: list[Any] = [] - chunk_size = 500 - for i in range(0, len(norm_paths), chunk_size): - chunk = norm_paths[i : i + chunk_size] - placeholders = ",".join("?" for _ in chunk) - query = ( - "SELECT mod_path, about_path, mtime, file_size, target_version, " - f"payload_json FROM mod_metadata WHERE mod_path IN ({placeholders})" - ) - async with conn.execute(query, chunk) as cursor: - fetched = await cursor.fetchall() - rows.extend(fetched) - return rows - - async def put_mods( - self, - entries: list[tuple[AboutXmlMod, Path, float, int]], - target_version: str, - ) -> None: - """Store or update parsed metadata entries in the cache.""" - if not entries: - return - conn = await self._get_connection() - if conn is None: - return - - rows = [] - for mod, about_path, mtime, file_size in entries: - if mod.mod_path is None: - continue - norm_mod_path = normalize_path(mod.mod_path) - norm_about_path = normalize_path(about_path) - payload = json.dumps(serialize_mod(mod)) - rows.append( - ( - norm_mod_path, - norm_about_path, - mtime, - file_size, - target_version, - payload, - ) - ) - - try: - async with self._lock: - await conn.executemany( - "INSERT OR REPLACE INTO mod_metadata " - "(mod_path, about_path, mtime, file_size, target_version, " - "payload_json) VALUES (?, ?, ?, ?, ?, ?)", - rows, - ) - await conn.commit() - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error writing to metadata cache: {}", exc) - - async def prune_disappeared(self, live_mod_paths: set[str]) -> None: - """Remove entries for mods that no longer exist on disk.""" - conn = await self._get_connection() - if conn is None: - return - - try: - async with self._lock: - async with conn.execute("SELECT mod_path FROM mod_metadata") as cursor: - cached_rows = await cursor.fetchall() - cached_paths = {row[0] for row in cached_rows} - disappeared = cached_paths - live_mod_paths - if disappeared: - logger.debug( - "Pruning {} disappeared mods from metadata cache", - len(disappeared), - ) - await conn.executemany( - "DELETE FROM mod_metadata WHERE mod_path = ?", - [(p,) for p in disappeared], - ) - await conn.commit() - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error pruning metadata cache: {}", exc) diff --git a/legacy/pxmodrim/core/services/mod_discovery.py b/legacy/pxmodrim/core/services/mod_discovery.py deleted file mode 100644 index bdc189f..0000000 --- a/legacy/pxmodrim/core/services/mod_discovery.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.mods_config import parse_mods_config -from pxmodrim.core.utils import find_about_xml - - -def scan_mod_directory(mods_path: Path) -> dict[Path, Path]: - """Scan a directory for mod subdirectories, returning {mod_path: about_xml_path}.""" - if not mods_path.exists() or not mods_path.is_dir(): - logger.warning(f"Mod directory not found: {mods_path}") - return {} - - results: dict[Path, Path] = {} - with os.scandir(mods_path) as it: - for entry in it: - if not entry.is_dir(): - continue - p = Path(entry.path) - about = find_about_xml(p) - if about: - results[p] = about - return results - - -def resolve_active_uuids( - all_mods: dict[str, ListedMod], - config_folder: str, -) -> list[str]: - """Parse ModsConfig.xml and return ordered UUIDs matching active mods.""" - if not config_folder: - return [] - path = Path(config_folder) / "ModsConfig.xml" - data = parse_mods_config(path) - if not data: - return [] - - # Build map from normalized package_id -> UUID (first match for duplicates) - pid_to_uuid: dict[str, str] = {} - for uuid, mod in all_mods.items(): - if isinstance(mod, AboutXmlMod): - pid = mod.package_id.lower().removesuffix("_steam") - if pid not in pid_to_uuid: - pid_to_uuid[pid] = uuid - - # Iterate ModsConfig.xml activeMods IN ORDER, pick matching UUID - active: list[str] = [] - for pid in data.activeMods: - normalized = str(pid).removesuffix("_steam").lower() - if normalized in pid_to_uuid: - active.append(pid_to_uuid[normalized]) - return active diff --git a/legacy/pxmodrim/core/services/sort_service.py b/legacy/pxmodrim/core/services/sort_service.py deleted file mode 100644 index e6c005d..0000000 --- a/legacy/pxmodrim/core/services/sort_service.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -from pxmodrim.core.checker.sort import topological_sort -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.profiler import profile -from pxmodrim.core.sort.models import PackageId - -if TYPE_CHECKING: - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -class SortService: - __slots__ = ("_ctx", "_diagnostics_service") - - def __init__(self, ctx: CoreContext, diagnostics_service: DiagnosticsService): - """Initialise with core context and diagnostics for topological sort.""" - self._ctx = ctx - self._diagnostics_service = diagnostics_service - - async def sort_active_mods(self) -> list[str]: - """Run topological sort on active mods in a thread, returning ordered UUIDs.""" - with profile("sort") as t: - active_mods = self._diagnostics_service.active_mods_by_pid - graph = self._diagnostics_service.constraint_graph - settings = self._ctx.config.sort - community_rules = self._diagnostics_service.community_rules - - def _sort() -> list[PackageId]: - return topological_sort( - active_mods, graph, settings, community_rules, timer=t - ) - - sorted_pids = await asyncio.to_thread(_sort) - - all_mods = self._ctx.all_mods - pid_to_uuid: dict[PackageId, str] = {} - for uuid in self._ctx.active_uuids: - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid_to_uuid[PackageId(mod.package_id)] = uuid - - return [pid_to_uuid[pid] for pid in sorted_pids if pid in pid_to_uuid] - - def resolve_missing_dependencies(self, active_uuids: set[str]) -> list[str]: - """Find dependencies of active mods that exist as inactive mods. - - Returns UUIDs of inactive mods that should be enabled to satisfy - missing dependencies of the currently active set. - """ - pid_to_uuid: dict[str, str] = {} - for uuid, mod in self._ctx.all_mods.items(): - if isinstance(mod, AboutXmlMod): - pid_to_uuid[str(mod.package_id)] = uuid - - active_pids: set[str] = set() - for uuid in active_uuids: - mod = self._ctx.all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - active_pids.add(str(mod.package_id)) - - deps_to_enable: list[str] = [] - for uuid in active_uuids: - mod = self._ctx.all_mods.get(uuid) - if not isinstance(mod, AboutXmlMod): - continue - for dep_pid, dep_info in mod.about_rules.dependencies.items(): - dep_str = str(dep_pid) - if dep_str in active_pids: - continue - satisfied = any( - str(alt) in active_pids for alt in dep_info.alternative_package_ids - ) - if satisfied: - continue - dep_uuid = pid_to_uuid.get(dep_str) - if dep_uuid is not None and dep_uuid not in active_uuids: - deps_to_enable.append(dep_uuid) - - return list(dict.fromkeys(deps_to_enable)) diff --git a/legacy/pxmodrim/core/services/startup_impact_service/__init__.py b/legacy/pxmodrim/core/services/startup_impact_service/__init__.py deleted file mode 100644 index 8da10b3..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.models import ( - IMPACT_HIGH_THRESHOLD_S, - IMPACT_WARN_THRESHOLD_S, - StartupImpactMod, - StartupImpactReport, - format_impact, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.parser import ( - get_startup_impact_path, - parse_startup_impact, -) -from pxmodrim.core.services.startup_impact_service.service import ( - StartupImpactService, -) - -__all__ = [ - "IMPACT_HIGH_THRESHOLD_S", - "IMPACT_WARN_THRESHOLD_S", - "StartupImpactMod", - "StartupImpactReport", - "StartupImpactService", - "format_impact", - "get_startup_impact_path", - "metric_label", - "normalize_package_id", - "parse_startup_impact", -] diff --git a/legacy/pxmodrim/core/services/startup_impact_service/db.py b/legacy/pxmodrim/core/services/startup_impact_service/db.py deleted file mode 100644 index 602dc67..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/db.py +++ /dev/null @@ -1,445 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from pathlib import Path -from time import time - -import aiosqlite - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) - -HISTORY_DEPTH = 5 - -# Recency weights: the most recent session gets WEIGHT_SPREAD, the next -# WEIGHT_SPREAD-1, ... down to 1. Sessions older than that are clamped to 1. -WEIGHT_SPREAD = 10 - -_SCHEMA = """ - CREATE TABLE IF NOT EXISTS startup_impact_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at REAL NOT NULL, - loading_time_s REAL NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT '' - ); - - CREATE TABLE IF NOT EXISTS startup_impact_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - package_id TEXT NOT NULL, - mod_name TEXT NOT NULL DEFAULT '', - total_impact_s REAL NOT NULL DEFAULT 0, - metrics_json TEXT NOT NULL DEFAULT '{}', - off_thread_metrics_json TEXT NOT NULL DEFAULT '{}', - off_thread_total_s REAL NOT NULL DEFAULT 0, - FOREIGN KEY (session_id) - REFERENCES startup_impact_sessions(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_sie_session - ON startup_impact_entries(session_id); - CREATE INDEX IF NOT EXISTS idx_sie_pid - ON startup_impact_entries(package_id); -""" - -_SCHEMA_VERSION = 1 - -# CTE ranking the most recent sessions by recency and assigning a descending -# weight (WEIGHT_SPREAD for the newest, WEIGHT_SPREAD-1 for the next, ... down -# to 1). Sessions older than WEIGHT_SPREAD clamp to weight 1. -# -# Bind parameters, in order: WEIGHT_SPREAD, HISTORY_DEPTH. -# Exposes columns: session_id, weight. -_WEIGHTED_SESSION_CTE = """ - ranked AS ( - SELECT id AS session_id, - ROW_NUMBER() OVER (ORDER BY id DESC) AS rn - FROM startup_impact_sessions - ORDER BY id DESC - LIMIT ? - ), - w AS ( - SELECT session_id, - MAX(? - rn + 1, 1) AS weight - FROM ranked - ) -""" - -_CTE_PARAMS: tuple[int, int] = (HISTORY_DEPTH, WEIGHT_SPREAD) - - -def db_path(config_dir: Path) -> Path: - return config_dir / "cache.db" - - -class StartupImpactDb: - """Owns a single aiosqlite connection guarded by an asyncio lock.""" - - __slots__ = ("_connection", "_connection_path", "_lock", "_operation_lock") - - def __init__(self) -> None: - self._connection: aiosqlite.Connection | None = None - self._connection_path: str = "" - self._lock = asyncio.Lock() - self._operation_lock = asyncio.Lock() - - async def _connection_for(self, path: Path) -> aiosqlite.Connection: - db_str = str(path) - async with self._lock: - if self._connection is None or self._connection_path != db_str: - if self._connection is not None: - await self._connection.close() - conn = await aiosqlite.connect(db_str, check_same_thread=False) - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await conn.execute("PRAGMA auto_vacuum=INCREMENTAL") - await self._ensure_schema(conn, path) - self._connection = conn - self._connection_path = db_str - return self._connection - - async def _ensure_schema(self, conn: aiosqlite.Connection, path: Path) -> None: - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={ - # Add future migration steps here: - # 2: _v2_add_column, - }, - backup_path=path, - label="DB schema", - ) - - async def close(self) -> None: - async with self._operation_lock, self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - self._connection_path = "" - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - self._connection_path = "" - - async def store_report(self, path: Path, report: StartupImpactReport) -> None: - async with self._operation_lock: - await self._store_report(path, report) - - async def _store_report(self, path: Path, report: StartupImpactReport) -> None: - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "INSERT INTO startup_impact_sessions" - " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", - (time(), report.loading_time_s, report.timestamp), - ) - sid = cursor.lastrowid - rows = [ - ( - sid, - mod.package_id or mod.mod_name, - mod.mod_name, - mod.total_impact_s, - json.dumps(mod.metrics), - json.dumps(mod.off_thread_metrics), - mod.off_thread_total_impact_s, - ) - for mod in report.mods - ] - await cursor.executemany( - "INSERT INTO startup_impact_entries" - " (session_id, package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", - rows, - ) - await cursor.execute( - "DELETE FROM startup_impact_sessions WHERE id NOT IN" - " (SELECT id FROM startup_impact_sessions ORDER BY id DESC" - " LIMIT ?)", - (HISTORY_DEPTH,), - ) - await db.commit() - - # Compact the database: checkpoint WAL, reclaim freelist, and VACUUM - # if the file has grown bloated (freelist pages accumulate because SQLite - # never shrinks the physical file by default). - await db.execute("PRAGMA wal_checkpoint(TRUNCATE)") - await db.execute("PRAGMA incremental_vacuum") - db_size = path.stat().st_size - if db_size > 10 * 1024 * 1024: - await db.execute("VACUUM") - - async def get_latest_report(self, path: Path) -> StartupImpactReport | None: - if not path.exists(): - return None - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "SELECT id, loading_time_s, timestamp" - " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1", - ) - row = await cursor.fetchone() - if not row: - return None - sid, loading_time_s, timestamp = row - await cursor.execute( - "SELECT package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s" - " FROM startup_impact_entries WHERE session_id = ?", - (sid,), - ) - rows = await cursor.fetchall() - mods = [_row_to_mod(r) for r in rows] - return StartupImpactReport( - path="", - loading_time_s=loading_time_s, - mods=tuple(mods), - timestamp=timestamp, - ) - - async def get_average(self, path: Path, package_id: str) -> float: - if not path.exists(): - return 0.0 - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, package_id), - ) - row = await cursor.fetchone() - return float(row[0]) if row else 0.0 - - async def get_totals_for( - self, path: Path, package_ids: list[str] - ) -> dict[str, tuple[float, float]]: - """Return {package_id: (on_thread_est, off_thread_est)} for the most - recent sessions using recency-weighted averages, in a single query. - - Mods absent from the stored history default to (0.0, 0.0). - """ - result: dict[str, tuple[float, float]] = dict.fromkeys(package_ids, (0.0, 0.0)) - if not package_ids: - return result - if not path.exists(): - return result - placeholders = ",".join("?" for _ in package_ids) - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ), - COALESCE( - SUM(e.off_thread_total_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id IN (""" - + placeholders - + """) - GROUP BY e.package_id - """, - (*_CTE_PARAMS, *package_ids), - ) - for pid, on_thread, off_thread in await cursor.fetchall(): - result[str(pid)] = (float(on_thread), float(off_thread)) - return result - - async def get_all_averages(self, path: Path) -> dict[str, float]: - if not path.exists(): - return {} - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - GROUP BY e.package_id - """, - _CTE_PARAMS, - ) - return {str(row[0]): float(row[1]) for row in await cursor.fetchall()} - - async def get_latest_with_averages( - self, - path: Path, - active_pids: list[str], - selected_pid: str | None = None, - ) -> tuple[ - StartupImpactReport | None, - float, - dict[str, tuple[float, float]], - float, - ]: - """Fetch the latest report plus base-game and per-mod impact averages in - a single connection/transaction. - - Returns (latest_report, base_game_avg, pid_totals, selected_mod_avg) - where pid_totals maps each active pid to (on_thread_avg, off_thread_avg) - and selected_mod_avg is the on-thread average for the selected mod (0.0 - if none/absent). - """ - if not path.exists(): - return None, 0.0, dict.fromkeys(active_pids, (0.0, 0.0)), 0.0 - - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "SELECT id, loading_time_s, timestamp" - " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1", - ) - row = await cursor.fetchone() - report: StartupImpactReport | None = None - if row is not None: - sid, loading_time_s, timestamp = row - await cursor.execute( - "SELECT package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s" - " FROM startup_impact_entries WHERE session_id = ?", - (sid,), - ) - mods = [_row_to_mod(r) for r in await cursor.fetchall()] - report = StartupImpactReport( - path="", - loading_time_s=loading_time_s, - mods=tuple(mods), - timestamp=timestamp, - ) - - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, BASE_GAME_PID), - ) - base_row = await cursor.fetchone() - base_avg = float(base_row[0]) if base_row else 0.0 - - totals: dict[str, tuple[float, float]] = dict.fromkeys( - active_pids, (0.0, 0.0) - ) - if active_pids: - placeholders = ",".join("?" for _ in active_pids) - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ), - COALESCE( - SUM(e.off_thread_total_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id IN (""" - + placeholders - + """) - GROUP BY e.package_id - """, - (*_CTE_PARAMS, *active_pids), - ) - for pid, on_thread, off_thread in await cursor.fetchall(): - totals[str(pid)] = (float(on_thread), float(off_thread)) - - selected_avg = 0.0 - if selected_pid is not None: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, selected_pid), - ) - sel_row = await cursor.fetchone() - selected_avg = float(sel_row[0]) if sel_row else 0.0 - - return report, base_avg, totals, selected_avg - - async def clear(self, path: Path) -> None: - async with self._operation_lock: - if not path.exists(): - return - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute("DELETE FROM startup_impact_entries") - await cursor.execute("DELETE FROM startup_impact_sessions") - await db.commit() - await db.execute("VACUUM") - await db.execute("PRAGMA wal_checkpoint(TRUNCATE)") - - -def _row_to_mod(row: aiosqlite.Row) -> StartupImpactMod: - pid, name, impact, metrics_json, ot_json, ot_total = row - pid_val: str | None = pid - if pid == name and pid != BASE_GAME_PID: - pid_val = None - return StartupImpactMod( - mod_name=name, - package_id=pid_val, - total_impact_s=impact, - metrics=json.loads(metrics_json or "{}"), - off_thread_metrics=json.loads(ot_json or "{}"), - off_thread_total_impact_s=ot_total, - ) diff --git a/legacy/pxmodrim/core/services/startup_impact_service/labels.py b/legacy/pxmodrim/core/services/startup_impact_service/labels.py deleted file mode 100644 index cf49ef0..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/labels.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -METRIC_LABELS: dict[str, str] = { - "ModConstructor": "Running mod constructor", - "LoadDefs": "Loading defs", - "CombineXml": "Combining XML into a single XML document", - "TKeySystemParse": "Translation key system parsing", - "LoadedModManagerParseAndProcessXML": "Parsing and processing XML", - "ErrorCheckPatches": "Error checking patches", - "LoadPatches": "Loading patches", - "ApplyPatches": "Applying patches", - "RegisterXmlInheritance": "Registering XML inheritance", - "ResolveXmlInheritance": "Resolving XML inheritance", - "ClearCachedPatches": "Clearing cached patches", - "ClearCachedXmlInheritance": "Clearing cached XML inheritance", - "LanguageDatabaseInitAllMetadata": "Loading language metadata", - "DefDatabaseAddAllInMods": "Copying all Defs from mods to global databases", - "ResolveAllWantedCrossReferences": "Resolving cross-references", - "ResolveAllWantedCrossReferences.NonImplied": "Resolving cross-references between non-implied Defs", # noqa: E501 - "DefOfHelperRebindAllDefOfs": "Rebinding DefOfs", - "DefOfHelperRebindAllDefOfs.Early": "Rebinding DefOfs (early)", - "DefOfHelperRebindAllDefOfs.Final": "Rebinding DefOfs (final)", - "TKeySystemBuildMappings": "Translation key system mapping", - "BackStoryTranslationUtilityLoadAndInjectBackstoryData": "Loading and injecting legacy backstory translations", # noqa: E501 - "ModContentPackReloadContentInt": "Loading content", - "ModContentPackReloadContentInt.AudioClips": "Loading audio clips", - "ModContentPackReloadContentInt.Textures": "Loading textures", - "ModContentPackReloadContentInt.Strings": "Loading strings", - "ModContentPackReloadContentInt.AssetBundles": "Loading asset bundles", - "LoadedLanguageInjectIntoDataBeforeImpliedDefs": "Injecting language data (early pass)", # noqa: E501 - "ColoredTextResetStaticData": "Running global operations (early pass)", - "DefGeneratorGenerateImpliedDefsPreResolve": "Generating implied Defs (pre-resolve)", # noqa: E501 - "ResolveAllWantedCrossReferences.Implied": "Resolving cross-references between implied Defs", # noqa: E501 - "PlayDataLoaderResetStaticDataPre": "Global operations (pre-resolve)", - "ResolveReferences": "Resolving references", - "DefGeneratorGenerateImpliedDefsPostResolve": "Generating implied Defs (post-resolve)", # noqa: E501 - "PlayDataLoaderResetStaticDataPost": "Global operations (post-resolve)", - "ErrorCheckAllDefs": "Error checking all defs", - "KeyPrefsInit": "Loading keyboard preferences", - "ShortHashGiverGiveAllShortHashes": "Short hash giving", - "ExecuteToExecuteWhenFinished": "Running delayed initialization task", - "SolidBioDatabaseLoadAllBios": "Loading all bios", - "LoadedLanguageInjectIntoDataAfterImpliedDefs": "Injecting language data", - "StaticConstructorOnStartupUtilityCallAll": "Running static constructors", - "FloatMenuMakerMapInit": "Initializing float menu maker map", - "GlobalTextureAtlasManagerBakeStaticAtlases": "Texture atlas baking", - "AbstractFilesystemClearAllCache": "Clearing filesystem cache", - "LoadModXml": "Loading and parsing mod XML", -} - - -_PREFIX = "LoadingProgress.StartupImpact." - - -def strip_metric_prefix(key: str) -> str: - return key.removeprefix(_PREFIX) - - -def metric_label(key: str) -> str: - key = strip_metric_prefix(key) - key, _, target = key.partition("|") - label = _base_label(key) - if target: - return f"{label} ({target.split(' -> ', 1)[0]})" - return label - - -def _base_label(key: str) -> str: - if key in METRIC_LABELS: - return METRIC_LABELS[key] - tail = key.split(".")[-1] - if tail in METRIC_LABELS: - return METRIC_LABELS[tail] - return key diff --git a/legacy/pxmodrim/core/services/startup_impact_service/models.py b/legacy/pxmodrim/core/services/startup_impact_service/models.py deleted file mode 100644 index 1bf79c8..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/models.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field - -IMPACT_WARN_THRESHOLD_S = 1.0 -IMPACT_HIGH_THRESHOLD_S = 5.0 -BASE_GAME_PID = "__base_game__" - - -@dataclass(frozen=True, slots=True) -class StartupImpactMod: - mod_name: str - package_id: str | None - total_impact_s: float - metrics: dict[str, float] = field(default_factory=dict) - off_thread_metrics: dict[str, float] = field(default_factory=dict) - off_thread_total_impact_s: float = 0.0 - - -@dataclass(frozen=True, slots=True) -class StartupImpactReport: - path: str - loading_time_s: float - mods: tuple[StartupImpactMod, ...] - timestamp: str = "" - metrics: dict[str, float] = field(default_factory=dict) - total_impact: float = 0.0 - off_thread_metrics: dict[str, float] = field(default_factory=dict) - off_thread_total_impact: float = 0.0 - - @property - def total_time(self) -> float: - return sum(m.total_impact_s for m in self.mods) - - @property - def off_thread_total_time(self) -> float: - return sum(m.off_thread_total_impact_s for m in self.mods) - - @property - def mods_total_time(self) -> float: - return self.total_time + self.off_thread_total_time - - @property - def base_game_time(self) -> float: - return sum(self.metrics.values()) - - def find( - self, package_id: str | None, mod_name: str | None - ) -> StartupImpactMod | None: - if package_id: - normalized = normalize_package_id(package_id) - for mod in self.mods: - if mod.package_id == normalized: - return mod - if mod_name: - name_lower = mod_name.lower() - for mod in self.mods: - if mod.mod_name.lower() == name_lower: - return mod - return None - - -def normalize_package_id(package_id: str) -> str: - return package_id.lower().removesuffix("_steam") - - -def format_impact(seconds: float) -> str: - return f"{round(seconds * 1000)}ms" diff --git a/legacy/pxmodrim/core/services/startup_impact_service/parser.py b/legacy/pxmodrim/core/services/startup_impact_service/parser.py deleted file mode 100644 index 46e8ab8..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/parser.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.services.startup_impact_service.models import ( - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) - -_STARTUP_IMPACT_FILENAME = "StartupImpactData.json" - - -def get_startup_impact_path(config_folder: str | Path) -> Path: - return Path(config_folder).parent / _STARTUP_IMPACT_FILENAME - - -def parse_startup_impact(path: str | Path) -> StartupImpactReport | None: - p = Path(path) - if not p.exists(): - logger.debug("Startup impact report not found at {}", p) - return None - - try: - data = json.loads(p.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to parse startup impact report at {}: {}", p, e) - return None - - if not isinstance(data, dict): - logger.warning("Startup impact report is not a JSON object: {}", p) - return None - - mods: list[StartupImpactMod] = [] - for entry in data.get("mods") or (): - if not isinstance(entry, dict): - continue - mod_name = entry.get("modName") - if not isinstance(mod_name, str) or not mod_name: - continue - - raw_package_id = entry.get("modPackageId") - package_id = ( - normalize_package_id(raw_package_id) - if isinstance(raw_package_id, str) and raw_package_id - else None - ) - - mods.append( - StartupImpactMod( - mod_name=mod_name, - package_id=package_id, - total_impact_s=_as_seconds(entry.get("totalImpact")), - metrics=_as_string_float_dict(entry.get("metrics")), - off_thread_metrics=_as_string_float_dict(entry.get("offThreadMetrics")), - off_thread_total_impact_s=_as_seconds( - entry.get("offThreadTotalImpact") - ), - ) - ) - - return StartupImpactReport( - path=str(p), - loading_time_s=_as_seconds(data.get("loadingTime")), - mods=tuple(mods), - timestamp=data.get("timestamp") or "", - metrics=_as_string_float_dict(data.get("metrics")), - total_impact=_as_seconds(data.get("totalImpact")), - off_thread_metrics=_as_string_float_dict(data.get("offThreadMetrics")), - off_thread_total_impact=_as_seconds(data.get("offThreadTotalImpact")), - ) - - -def _as_seconds(value: object) -> float: - if isinstance(value, (int, float)): - return float(value) / 1000.0 - if isinstance(value, str): - try: - return float(value) / 1000.0 - except ValueError: - return 0.0 - return 0.0 - - -def _as_string_float_dict(value: object) -> dict[str, float]: - if not isinstance(value, dict): - return {} - return { - str(k): float(v) / 1000.0 - for k, v in value.items() - if isinstance(v, (int, float)) - } diff --git a/legacy/pxmodrim/core/services/startup_impact_service/phases.py b/legacy/pxmodrim/core/services/startup_impact_service/phases.py deleted file mode 100644 index 4a0061a..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/phases.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from enum import StrEnum - -from pxmodrim.core.services.startup_impact_service.labels import strip_metric_prefix - - -class Phase(StrEnum): - CONTENT = "content" - MOD_CONSTRUCTORS = "mod_constructors" - STATIC_CONSTRUCTORS = "static_constructors" - PATCHES = "patches" - DEFS = "defs" - DEFERRED = "deferred" - OTHER = "other" - - -PHASE_LABELS: dict[Phase, str] = { - Phase.CONTENT: "Textures & audio", - Phase.MOD_CONSTRUCTORS: "Mod constructors", - Phase.STATIC_CONSTRUCTORS: "Static constructors", - Phase.PATCHES: "Patches", - Phase.DEFS: "Defs & XML", - Phase.DEFERRED: "Deferred init", - Phase.OTHER: "Other", -} - -_EXACT: dict[str, Phase] = { - "GlobalTextureAtlasManagerBakeStaticAtlases": Phase.CONTENT, - "ModConstructor": Phase.MOD_CONSTRUCTORS, - "StaticConstructorOnStartupUtilityCallAll": Phase.STATIC_CONSTRUCTORS, - "ExecuteToExecuteWhenFinished": Phase.DEFERRED, - "LoadDefs": Phase.DEFS, - "LoadModXml": Phase.DEFS, - "CombineXml": Phase.DEFS, - "LoadedModManagerParseAndProcessXML": Phase.DEFS, - "RegisterXmlInheritance": Phase.DEFS, - "ResolveXmlInheritance": Phase.DEFS, - "ClearCachedXmlInheritance": Phase.DEFS, - "DefDatabaseAddAllInMods": Phase.DEFS, - "ResolveReferences": Phase.DEFS, - "ErrorCheckAllDefs": Phase.DEFS, - "ShortHashGiverGiveAllShortHashes": Phase.DEFS, -} - -_PREFIXES: tuple[tuple[str, Phase], ...] = ( - ("ModContentPackReloadContentInt", Phase.CONTENT), - ("ResolveAllWantedCrossReferences", Phase.DEFS), - ("DefGeneratorGenerateImpliedDefs", Phase.DEFS), - ("DefOfHelperRebindAllDefOfs", Phase.DEFS), -) - - -def metric_phase(key: str) -> Phase: - name = strip_metric_prefix(key).split("|", 1)[0] - if phase := _EXACT.get(name): - return phase - if name.endswith("Patches"): - return Phase.PATCHES - for prefix, phase in _PREFIXES: - if name.startswith(prefix): - return phase - return Phase.OTHER - - -def phase_totals(metrics: dict[str, float]) -> dict[Phase, float]: - totals = dict.fromkeys(Phase, 0.0) - for key, seconds in metrics.items(): - totals[metric_phase(key)] += seconds - return totals diff --git a/legacy/pxmodrim/core/services/startup_impact_service/service.py b/legacy/pxmodrim/core/services/startup_impact_service/service.py deleted file mode 100644 index 581270f..0000000 --- a/legacy/pxmodrim/core/services/startup_impact_service/service.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.services.startup_impact_service import db -from pxmodrim.core.services.startup_impact_service.db import StartupImpactDb -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.parser import ( - get_startup_impact_path, - parse_startup_impact, -) - - -class StartupImpactService: - """Loads startup impact data into DB and provides async queries for - historical averages and per-session metric breakdowns.""" - - __slots__ = ("_ctx", "_db") - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - self._db = StartupImpactDb() - - async def load(self) -> StartupImpactReport | None: - cfg = self._ctx.config - if not cfg.paths.config_folder: - return None - - dbp = db.db_path(self._ctx.config_service.config_dir) - json_path = get_startup_impact_path(Path(cfg.paths.config_folder)) - logger.debug("Loading startup impact from {}", json_path) - - report = parse_startup_impact(json_path) - if report: - base = max(0.0, report.base_game_time) - entries: list[StartupImpactMod] = list(report.mods) - if base > 0.001: - entries.append( - StartupImpactMod( - mod_name=BASE_GAME_PID, - package_id=BASE_GAME_PID, - total_impact_s=base, - metrics=report.metrics, - off_thread_metrics=report.off_thread_metrics, - off_thread_total_impact_s=sum( - report.off_thread_metrics.values() - ), - ) - ) - full = StartupImpactReport( - path=report.path, - loading_time_s=report.loading_time_s, - mods=tuple(entries), - timestamp=report.timestamp, - ) - await self._db.store_report(dbp, full) - - return report - - async def get_latest_report(self) -> StartupImpactReport | None: - return await self._db.get_latest_report( - db.db_path(self._ctx.config_service.config_dir) - ) - - async def estimated_impact_for(self, package_id: str) -> float: - return await self._db.get_average( - db.db_path(self._ctx.config_service.config_dir), - normalize_package_id(package_id), - ) - - async def estimated_total(self, active_pids: list[str]) -> float: - dbp = db.db_path(self._ctx.config_service.config_dir) - bg = await self._db.get_average(dbp, BASE_GAME_PID) - totals = await self._db.get_totals_for( - dbp, [normalize_package_id(p) for p in active_pids] - ) - mod_sum = sum(on + off for on, off in totals.values()) - return bg + mod_sum - - async def get_all_averages(self) -> dict[str, float]: - return await self._db.get_all_averages( - db.db_path(self._ctx.config_service.config_dir) - ) - - async def base_game_average(self) -> float: - return await self._db.get_average( - db.db_path(self._ctx.config_service.config_dir), BASE_GAME_PID - ) - - async def snapshot( - self, active_pids: list[str], selected_pid: str | None = None - ) -> tuple[ - StartupImpactReport | None, - float, - dict[str, tuple[float, float]], - float, - ]: - """Single round-trip fetch of the latest report plus base-game average, - per-mod on/off-thread averages, and the selected mod's average. Replaces - the four sequential queries previously issued on every mod toggle. - """ - return await self._db.get_latest_with_averages( - db.db_path(self._ctx.config_service.config_dir), - [normalize_package_id(p) for p in active_pids], - normalize_package_id(selected_pid) if selected_pid else None, - ) - - async def clear(self) -> None: - await self._db.clear(db.db_path(self._ctx.config_service.config_dir)) - - async def close_connection(self) -> None: - await self._db.close() - - def close_connection_sync(self) -> None: - self._db.close_sync() diff --git a/legacy/pxmodrim/core/services/update_service.py b/legacy/pxmodrim/core/services/update_service.py deleted file mode 100644 index d4a46e8..0000000 --- a/legacy/pxmodrim/core/services/update_service.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -import httpx -from loguru import logger - -LATEST_RELEASE_URL = "https://api.github.com/repos/PyXiion/PxModRim/releases/latest" - -_TIMEOUT_SECONDS = 10.0 -_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)([^+]*)") - - -class UpdateCheckError(Exception): - """The latest-release lookup failed (network, HTTP status or bad payload).""" - - -@dataclass(frozen=True, slots=True) -class ReleaseInfo: - tag: str - name: str - notes: str - url: str - - -def _split_version(text: str) -> tuple[tuple[int, ...], str] | None: - match = _VERSION_RE.match(text.strip()) - if match is None: - return None - numbers = tuple(int(part) for part in match.group(1).split(".")) - return numbers, match.group(2).strip("-._ ").lower() - - -def parse_version(text: str) -> tuple[int, ...] | None: - """Parse ``v1.2.3``-style text into a numeric tuple, ignoring any suffix.""" - parsed = _split_version(text) - return None if parsed is None else parsed[0] - - -def is_newer(tag: str, current: str) -> bool: - """Return True when *tag* is a strictly higher release than *current*. - - A pre-release suffix (``-rc1``, ``-beta.1``) sorts below the plain release - with the same numbers. Unparseable versions (e.g. an unknown dev build) are - never reported as outdated, so a broken tag cannot nag the user. - """ - latest = _split_version(tag) - running = _split_version(current) - if latest is None or running is None: - return False - width = max(len(latest[0]), len(running[0])) - - def key(parsed: tuple[tuple[int, ...], str]) -> tuple[tuple[int, ...], bool, str]: - numbers, suffix = parsed - padded = numbers + (0,) * (width - len(numbers)) - return padded, not suffix, suffix - - return key(latest) > key(running) - - -class UpdateService: - """Looks up the newest published GitHub release.""" - - __slots__ = ("_current", "_transport") - - def __init__( - self, - current_version: str, - transport: httpx.AsyncBaseTransport | None = None, - ) -> None: - self._current = current_version - self._transport = transport - - @property - def current_version(self) -> str: - return self._current - - async def check(self) -> ReleaseInfo | None: - """Return the latest release if newer than the running app, else None. - - Raises ``UpdateCheckError`` on network failure, a non-success HTTP - status or a malformed response. - """ - try: - async with httpx.AsyncClient( - timeout=_TIMEOUT_SECONDS, - headers={ - "Accept": "application/vnd.github+json", - "User-Agent": f"PxModRim/{self._current}", - }, - transport=self._transport, - ) as client: - response = await client.get(LATEST_RELEASE_URL) - response.raise_for_status() - data = response.json() - except (httpx.HTTPError, ValueError) as exc: - raise UpdateCheckError(str(exc) or type(exc).__name__) from exc - - if not isinstance(data, dict): - raise UpdateCheckError("Latest release response is not a JSON object") - tag = str(data.get("tag_name") or "") - url = str(data.get("html_url") or "") - if not tag or not url: - raise UpdateCheckError("Latest release response lacks tag_name/html_url") - if not is_newer(tag, self._current): - logger.debug("No update: latest {} vs running {}", tag, self._current) - return None - logger.info("Update available: {} (running {})", tag, self._current) - return ReleaseInfo( - tag=tag, - name=str(data.get("name") or tag), - notes=str(data.get("body") or ""), - url=url, - ) diff --git a/legacy/pxmodrim/core/sort/__init__.py b/legacy/pxmodrim/core/sort/__init__.py deleted file mode 100644 index 6e46587..0000000 --- a/legacy/pxmodrim/core/sort/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.sort.community import ( - community_rules_path, - load_community_rules, -) -from pxmodrim.core.sort.community_service import CommunityRulesService -from pxmodrim.core.sort.config import ( - SortSettings, - Tier, - TierConfig, -) -from pxmodrim.core.sort.exceptions import CommunityRulesMissingError -from pxmodrim.core.sort.models import ( - CommunityRule, - PackageId, -) -from pxmodrim.core.sort.tiers import ( - assign_tiers, - find_cycle, - get_deps_recursive, - get_reverse_deps_recursive, -) - -__all__ = [ - "CommunityRule", - "CommunityRulesMissingError", - "CommunityRulesService", - "PackageId", - "SortSettings", - "Tier", - "TierConfig", - "assign_tiers", - "community_rules_path", - "find_cycle", - "get_deps_recursive", - "get_reverse_deps_recursive", - "load_community_rules", -] diff --git a/legacy/pxmodrim/core/sort/community.py b/legacy/pxmodrim/core/sort/community.py deleted file mode 100644 index 6dbe6ef..0000000 --- a/legacy/pxmodrim/core/sort/community.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from typing import Any - -import msgspec - -from pxmodrim.core.msgspec_hooks import dec_hook -from pxmodrim.core.sort.models import CommunityRule, PackageId - -COMMUNITY_RULES_URL = ( - "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/main.zip" -) -COMMUNITY_RULES_FILENAME = "communityRules.json" - - -def config_dir() -> Path: - base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - return Path(base) / "pxmodrim" - - -def community_rules_path() -> Path: - return config_dir() / COMMUNITY_RULES_FILENAME - - -class ExternalRule(msgspec.Struct, omit_defaults=True): - """A single community rule specifying load-ordering relationships for a mod.""" - - loadAfter: dict[str, Any] = {} - loadBefore: dict[str, Any] = {} - loadTop: SubExternalBoolRule = msgspec.field( - default_factory=lambda: SubExternalBoolRule() - ) - loadBottom: SubExternalBoolRule = msgspec.field( - default_factory=lambda: SubExternalBoolRule() - ) - - -class SubExternalBoolRule(msgspec.Struct, omit_defaults=True): - value: bool = False - - -class ExternalRulesSchema(msgspec.Struct, omit_defaults=True): - """Schema for the community rules JSON file, wrapping a timestamp and rule map.""" - - timestamp: int = 0 - rules: dict[str, ExternalRule] = {} - - def to_community_rules(self) -> dict[PackageId, CommunityRule]: - result: dict[PackageId, CommunityRule] = {} - for pid_str, rule in self.rules.items(): - pid = PackageId(pid_str) - result[pid] = CommunityRule( - package_id=pid, - load_after={PackageId(x) for x in rule.loadAfter}, - load_before={PackageId(x) for x in rule.loadBefore}, - load_first=rule.loadTop.value, - load_last=rule.loadBottom.value, - incompatible_with=set(), - ) - return result - - -def load_community_rules(json_path: Path) -> dict[PackageId, CommunityRule]: - """Load and deserialize community rules from a JSON file on disk.""" - if not json_path.exists(): - return {} - data = msgspec.json.decode( - json_path.read_bytes(), type=ExternalRulesSchema, dec_hook=dec_hook - ) - return data.to_community_rules() diff --git a/legacy/pxmodrim/core/sort/community_service.py b/legacy/pxmodrim/core/sort/community_service.py deleted file mode 100644 index d30d944..0000000 --- a/legacy/pxmodrim/core/sort/community_service.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import zipfile -from pathlib import Path -from typing import TYPE_CHECKING - -import httpx -import msgspec - -from pxmodrim.core.loading import LoadingState -from pxmodrim.core.sort.community import ( - COMMUNITY_RULES_FILENAME, - COMMUNITY_RULES_URL, - ExternalRulesSchema, - dec_hook, -) -from pxmodrim.core.sort.models import CommunityRule, PackageId - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - - -class CommunityRulesService: - """Service for downloading and loading community rules.""" - - __slots__ = ("_cache_dir", "_json_path") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._json_path = self._cache_dir / COMMUNITY_RULES_FILENAME - - async def ensure_rules( - self, loading: LoadingState, force: bool = False - ) -> Path | None: - """Ensure community rules file exists, downloading if needed.""" - if self._json_path.exists() and not force: - return self._json_path - return await self._download_rules(loading) - - async def _download_rules(self, loading: LoadingState) -> Path | None: - self._cache_dir.mkdir(parents=True, exist_ok=True) - - zip_path = self._cache_dir / "community_rules.zip" - extract_dir = self._cache_dir / "community_rules_extracted" - - try: - async with httpx.AsyncClient() as client: - head = await client.head( - COMMUNITY_RULES_URL, follow_redirects=True, timeout=10.0 - ) - total_size = int(head.headers.get("content-length", 0)) or 100 - - async with client.stream( - "GET", COMMUNITY_RULES_URL, follow_redirects=True, timeout=30.0 - ) as resp: - resp.raise_for_status() - - with loading.task( - "Downloading community rules...", total_steps=total_size - ) as task: - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - task.step(len(chunk)) - finally: - await asyncio.to_thread(fh.close) - - with loading.task("Extracting...", total_steps=100) as task: - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - task.step(50) - for root, _dirs, files in os.walk(extract_dir): - for filename in files: - if filename == COMMUNITY_RULES_FILENAME: - src_path = Path(str(root)) / filename - src_path.replace(self._json_path) - task.step(50) - return self._json_path - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - import logging - - logging.getLogger(__name__).warning( - f"Failed to download community rules: {e}" - ) - - return None - - def load_rules(self) -> dict[PackageId, CommunityRule]: - """Load community rules from cached file.""" - if not self._json_path.exists(): - return {} - data = msgspec.json.decode( - self._json_path.read_bytes(), type=ExternalRulesSchema, dec_hook=dec_hook - ) - return data.to_community_rules() diff --git a/legacy/pxmodrim/core/sort/config.py b/legacy/pxmodrim/core/sort/config.py deleted file mode 100644 index 355e290..0000000 --- a/legacy/pxmodrim/core/sort/config.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import IntEnum - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - -PackageId = CaseInsensitiveStr - - -class Tier(IntEnum): - TIER_0 = 0 - TIER_1 = 1 - TIER_2 = 2 - TIER_3 = 3 - - -@dataclass(frozen=True, slots=True) -class TierConfig: - tier_0: tuple[PackageId, ...] - tier_1: tuple[PackageId, ...] - tier_3: tuple[PackageId, ...] - - @staticmethod - def default() -> TierConfig: - return TierConfig( - tier_0=( - PackageId("zetrith.prepatcher"), - PackageId("brrainz.harmony"), - PackageId("brrainz.visualexceptions"), - PackageId("ludeon.rimworld"), - PackageId("ludeon.rimworld.royalty"), - PackageId("ludeon.rimworld.ideology"), - PackageId("ludeon.rimworld.biotech"), - PackageId("ludeon.rimworld.anomaly"), - PackageId("ludeon.rimworld.odyssey"), - ), - tier_1=( - PackageId("adaptive.storage.framework"), - PackageId("aoba.framework"), - PackageId("aoba.exosuit.framework"), - PackageId("ebsg.framework"), - PackageId("imranfish.xmlextensions"), - PackageId("thesepeople.ritualattachableoutcomes"), - PackageId("ohno.asf.ab.local"), - PackageId("oskarpotocki.vanillafactionsexpanded.core"), - PackageId("owlchemist.cherrypicker"), - PackageId("redmattis.betterprerequisites"), - PackageId("smashphil.vehicleframework"), - PackageId("unlimitedhugs.hugslib"), - PackageId("vanillaexpanded.backgrounds"), - ), - tier_3=(), - ) - - -@dataclass(frozen=True, slots=True) -class SortSettings: - use_alternative_package_ids: bool = True - check_missing_dependencies: bool = True - use_community_rules: bool = True - tier_config: TierConfig = field(default_factory=TierConfig.default) diff --git a/legacy/pxmodrim/core/sort/exceptions.py b/legacy/pxmodrim/core/sort/exceptions.py deleted file mode 100644 index 45e0b5e..0000000 --- a/legacy/pxmodrim/core/sort/exceptions.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -class CommunityRulesMissingError(Exception): - def __init__(self, path: Path): - self.path = path - super().__init__(f"Community rules file not found: {path}") diff --git a/legacy/pxmodrim/core/sort/models.py b/legacy/pxmodrim/core/sort/models.py deleted file mode 100644 index fd7cdb0..0000000 --- a/legacy/pxmodrim/core/sort/models.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - -PackageId = CaseInsensitiveStr - - -@dataclass(frozen=True, slots=True) -class CommunityRule: - package_id: PackageId - load_after: set[PackageId] - load_before: set[PackageId] - load_first: bool - load_last: bool - incompatible_with: set[PackageId] diff --git a/legacy/pxmodrim/core/sort/progress.py b/legacy/pxmodrim/core/sort/progress.py deleted file mode 100644 index ee79406..0000000 --- a/legacy/pxmodrim/core/sort/progress.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from typing import Protocol, Self - - -class ProgressReporter(Protocol): - """Minimal progress-reporting interface consumed by sort-layer operations.""" - - @contextmanager - def task( - self, status: str, total_steps: int = 100 - ) -> Generator[Self, None, None]: ... - - def step(self, amount: int = 1) -> None: ... diff --git a/legacy/pxmodrim/core/sort/tiers.py b/legacy/pxmodrim/core/sort/tiers.py deleted file mode 100644 index 3b94d9b..0000000 --- a/legacy/pxmodrim/core/sort/tiers.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.sort.config import PackageId, Tier, TierConfig - - -def assign_tiers( - canonical_ids: set[PackageId], - deps: dict[PackageId, set[PackageId]], - rev_deps: dict[PackageId, set[PackageId]], - load_first: set[PackageId], - load_last: set[PackageId], - tier_config: TierConfig, -) -> dict[PackageId, Tier]: - """Assign each mod to a Tier (0-3) based on load order & dependency constraints.""" - tiers = dict.fromkeys(canonical_ids, Tier.TIER_2) - - tier3_set = set(load_last) - for pid in load_last: - tier3_set.update(get_reverse_deps_recursive(pid, rev_deps)) - for pid in tier_config.tier_3: - if pid in canonical_ids: - tier3_set.add(pid) - tier3_set.update(get_reverse_deps_recursive(pid, rev_deps)) - for pid in tier3_set & canonical_ids: - tiers[pid] = Tier.TIER_3 - - tier0_set = set() - for pid in tier_config.tier_0: - if pid in canonical_ids: - tier0_set.add(pid) - tier0_set.update(get_deps_recursive(pid, deps)) - for pid in tier0_set & canonical_ids: - tiers[pid] = Tier.TIER_0 - - tier1_set = set() - for pid in tier_config.tier_1: - if pid in canonical_ids: - tier1_set.add(pid) - tier1_set.update(get_deps_recursive(pid, deps)) - for pid in load_first: - if pid in canonical_ids: - tier1_set.add(pid) - tier1_set.update(get_deps_recursive(pid, deps)) - for pid in tier1_set & canonical_ids: - if tiers[pid] == Tier.TIER_2: - tiers[pid] = Tier.TIER_1 - - return tiers - - -def get_deps_recursive( - start: PackageId, - deps: dict[PackageId, set[PackageId]], - visited: set[PackageId] | None = None, -) -> set[PackageId]: - """Collect all transitive dependencies for a given package.""" - if visited is None: - visited = set() - if start in visited: - return set() - - visited.add(start) - result: set[PackageId] = set() - stack = [start] - while stack: - pid = stack.pop() - for dep in deps.get(pid, set()): - result.add(dep) - if dep not in visited: - visited.add(dep) - stack.append(dep) - return result - - -def get_reverse_deps_recursive( - start: PackageId, - rev_deps: dict[PackageId, set[PackageId]], - visited: set[PackageId] | None = None, -) -> set[PackageId]: - """Collect all transitive reverse-dependents for a given package.""" - if visited is None: - visited = set() - if start in visited: - return set() - - visited.add(start) - result: set[PackageId] = set() - stack = [start] - while stack: - pid = stack.pop() - for rdep in rev_deps.get(pid, set()): - result.add(rdep) - if rdep not in visited: - visited.add(rdep) - stack.append(rdep) - return result - - -def find_cycle( - nodes: set[PackageId], - deps: dict[PackageId, set[PackageId]], -) -> list[PackageId] | None: - """Detect a dependency cycle in the given nodes using iterative DFS.""" - visited: set[PackageId] = set() - rec_stack: set[PackageId] = set() - parent: dict[PackageId, PackageId] = {} - - for root in nodes: - if root in visited: - continue - - parent[root] = root - visited.add(root) - rec_stack.add(root) - stack = [(root, iter(deps.get(root, set())))] - while stack: - pid, dependents = stack[-1] - try: - dep = next(dependents) - except StopIteration: - stack.pop() - rec_stack.remove(pid) - continue - - if dep not in nodes: - continue - if dep not in visited: - parent[dep] = pid - visited.add(dep) - rec_stack.add(dep) - stack.append((dep, iter(deps.get(dep, set())))) - elif dep in rec_stack: - cycle = [dep] - current = pid - while current != dep: - cycle.append(current) - current = parent[current] - cycle.append(dep) - return list(reversed(cycle)) - - return None diff --git a/legacy/pxmodrim/core/structures.py b/legacy/pxmodrim/core/structures.py deleted file mode 100644 index e395e1c..0000000 --- a/legacy/pxmodrim/core/structures.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -import msgspec - - -class CollectionStats(msgspec.Struct): - total: int = 0 - active: int = 0 - inactive: int = 0 - errors: int = 0 diff --git a/legacy/pxmodrim/core/support/__init__.py b/legacy/pxmodrim/core/support/__init__.py deleted file mode 100644 index 6b48ea6..0000000 --- a/legacy/pxmodrim/core/support/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, - get_app_version, - read_log_tail, - redact, -) - -__all__ = [ - "PasteUploader", - "ReportUploadError", - "build_report", - "get_app_version", - "read_log_tail", - "redact", -] diff --git a/legacy/pxmodrim/core/support/report.py b/legacy/pxmodrim/core/support/report.py deleted file mode 100644 index 5ff5062..0000000 --- a/legacy/pxmodrim/core/support/report.py +++ /dev/null @@ -1,311 +0,0 @@ -from __future__ import annotations - -import asyncio -import getpass -import json -import platform -import re -import urllib.error -import urllib.request -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from pxmodrim.core.config import config_dir - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - -MAX_LOG_BYTES = 2 * 1024 * 1024 -DEFAULT_UPLOAD_ENDPOINT = "https://paste.rs/" - - -def get_app_version() -> str: - """Return the application version from package metadata, with fallback.""" - try: - return version("pxmodrim") - except PackageNotFoundError: - return "unknown" - - -# Username directories under these roots; matched case-insensitively. -# Backslashes may be doubled: repr() of a Windows path in a log line. -_USER_DIR_PREFIX = r"(?:/home|/Users|/run/media|/media|\\{1,2}Users)(?:\\{1,2}|/)" -# Path components start and end where no adjacent name characters exist. -_COMPONENT_START = r"(? str: - """Replace the home directory with ~ and user-directory usernames with .""" - home_str = str(Path.home() if home_dir is None else Path(home_dir)).rstrip("/\\") - - if username is None: - try: - username = getpass.getuser() - except (OSError, KeyError): - username = "" - - if home_str: - backslashed = home_str.replace("/", "\\") - variants = { - home_str, - home_str.replace("\\", "/"), - backslashed, - backslashed.replace("\\", "\\\\"), - } - alternatives = "|".join( - re.escape(v) for v in sorted(variants, key=len, reverse=True) - ) - text = re.sub( - f"{_COMPONENT_START}(?:{alternatives}){_COMPONENT_END}", "~", text - ) - - # Only path contexts: a bare word match would corrupt ids like ludeon.rimworld. - if username: - pattern = re.compile( - f"({_USER_DIR_PREFIX}){re.escape(username)}{_COMPONENT_END}", - re.IGNORECASE, - ) - text = pattern.sub(r"\1", text) - return text - - -def _format_size(size_bytes: int) -> str: - """Format byte count into human-readable B, KiB, or MiB.""" - if size_bytes >= 1024 * 1024: - return f"{size_bytes / (1024 * 1024):.1f} MiB" - if size_bytes >= 1024: - return f"{size_bytes / 1024:.1f} KiB" - return f"{size_bytes} B" - - -def _log_line_text(line: str) -> str: - """Unpack a loguru serialized JSON record to its text; pass other lines through.""" - trimmed = line.strip() - if not (trimmed.startswith("{") and trimmed.endswith("}")): - return line - try: - data = json.loads(trimmed) - except ValueError: - return line - if isinstance(data, dict) and "text" in data: - return str(data["text"]).rstrip("\r\n") - return line - - -def _parse_log_text(raw_text: str) -> str: - """Extract plain text lines from log, unpacking loguru JSON lines if present.""" - result = "\n".join(_log_line_text(line) for line in raw_text.splitlines()) - if raw_text.endswith("\n") and result: - result += "\n" - return result - - -def _after_first_newline(data: bytes) -> bytes: - """Drop the partial leading line of a tail slice.""" - nl = data.find(b"\n") - return data[nl + 1 :] if nl != -1 else data - - -def _read_raw_tail(path: Path, size: int, window: int) -> bytes: - with path.open("rb") as f: - if size <= window: - return f.read() - f.seek(size - window) - return _after_first_newline(f.read(window)) - - -def read_log_tail(log_path: Path | None = None, max_bytes: int = MAX_LOG_BYTES) -> str: - """Read up to max_bytes from the end of the log file, converted to plain text.""" - path = log_path or (config_dir() / "logs" / "pxmodrim.log") - if not path.is_file(): - return "" - - try: - size = path.stat().st_size - except OSError as exc: - return f"" - - if size == 0: - return "" - - # JSON-serialized records shrink when unpacked, so read a wider raw window. - read_window = max(max_bytes * 5, 10 * 1024 * 1024) - try: - raw_bytes = _read_raw_tail(path, size, read_window) - except OSError as exc: - return f"" - - plain_text = _parse_log_text(raw_bytes.decode("utf-8", errors="replace")) - encoded = plain_text.encode("utf-8") - if len(encoded) <= max_bytes: - return plain_text - - tail = _after_first_newline(encoded[-max_bytes:]) - marker = ( - f"[... truncated, showing last {_format_size(len(tail))} " - f"of {_format_size(len(encoded))} ...]\n" - ) - return marker + tail.decode("utf-8", errors="replace") - - -def build_report( - ctx: CoreContext | Any, - log_path: Path | None = None, - home_dir: str | Path | None = None, - username: str | None = None, - max_log_bytes: int = MAX_LOG_BYTES, -) -> str: - """Build a plain-text system diagnostic and log report with redacted user info.""" - lines: list[str] = [] - - # App & runtime diagnostics - app_ver = get_app_version() - py_ver = platform.python_version() - - try: - from PySide6.QtCore import qVersion - - qt_ver = qVersion() - except (ImportError, RuntimeError, AttributeError): - qt_ver = "Unknown" - - try: - pyside_ver = version("PySide6") - except PackageNotFoundError: - try: - import PySide6 - - pyside_ver = getattr(PySide6, "__version__", "Unknown") - except (ImportError, AttributeError): - pyside_ver = "Unknown" - - os_info = f"{platform.system()} {platform.release()}" - arch = platform.machine() - - lines.append("=== PxModRim System Report ===") - lines.append(f"App Version: {app_ver}") - lines.append(f"Python Version: {py_ver}") - lines.append(f"Qt Version: {qt_ver}") - lines.append(f"PySide6 Version: {pyside_ver}") - lines.append(f"OS: {os_info} ({arch})") - lines.append("") - - # Game version and configured paths from CoreContext - game_version = getattr(ctx, "game_version", "Unknown") - cfg = getattr(ctx, "config", None) - paths = getattr(cfg, "paths", None) if cfg is not None else None - - lines.append("=== RimWorld Configuration ===") - lines.append(f"Game Version: {game_version}") - lines.append(f"Game Path: {getattr(paths, 'game', '') or ''}") - lines.append(f"Config Folder: {getattr(paths, 'config_folder', '') or ''}") - lines.append(f"Local Mods Path: {getattr(paths, 'local', '') or ''}") - lines.append(f"Workshop Mods Path: {getattr(paths, 'workshop', '') or ''}") - lines.append("") - - # Mod collection statistics - all_mods = getattr(ctx, "all_mods", {}) - active_uuids = getattr(ctx, "active_uuids", []) - total_mods_count = len(all_mods) if all_mods else 0 - active_mods_count = len(active_uuids) if active_uuids else 0 - - lines.append("=== Mod Collection ===") - lines.append(f"Total Mods: {total_mods_count}") - lines.append(f"Active Mods: {active_mods_count}") - lines.append("") - - # Active mods in load order - lines.append("=== Active Mods (Load Order) ===") - if active_uuids: - for i, uuid in enumerate(active_uuids, 1): - mod = all_mods.get(uuid) if all_mods else None - if mod is not None: - mod_name = getattr(mod, "name", "Unknown Mod Name") - pid = getattr(mod, "package_id", "Unknown") - else: - mod_name = "Unknown Mod" - pid = uuid - lines.append(f"{i:3d}. {mod_name} ({pid})") - else: - lines.append(" ") - lines.append("") - - # Application log tail - lines.append("=== Application Log ===") - lines.append(read_log_tail(log_path, max_bytes=max_log_bytes)) - - full_text = "\n".join(lines) - return redact(full_text, home_dir=home_dir, username=username) - - -class ReportUploadError(Exception): - """Raised when log or diagnostic report upload fails.""" - - -class PasteUploader: - """Uploader for posting plain-text reports to a paste service.""" - - def __init__( - self, endpoint: str = DEFAULT_UPLOAD_ENDPOINT, timeout: float = 30.0 - ) -> None: - self.endpoint = endpoint - self.timeout = timeout - - def upload(self, text: str) -> str: - """Synchronously POST text to the endpoint and return the resulting URL. - - Raises ReportUploadError on non-2xx response, network failure, or non-URL body. - """ - if not text or not text.strip(): - raise ReportUploadError("Report text is empty") - - data = text.encode("utf-8") - app_ver = get_app_version() - headers = { - "User-Agent": f"PxModRim/{app_ver}", - "Content-Type": "text/plain; charset=utf-8", - } - req = urllib.request.Request( - self.endpoint, - data=data, - headers=headers, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=self.timeout) as response: - status = getattr(response, "status", 200) - if not (200 <= status < 300): - raise ReportUploadError(f"Upload failed: HTTP status {status}") - body = response.read().decode("utf-8").strip() - except urllib.error.HTTPError as exc: - raise ReportUploadError( - f"Upload failed: HTTP {exc.code} {exc.reason}" - ) from exc - except urllib.error.URLError as exc: - raise ReportUploadError( - f"Upload failed: network error ({exc.reason})" - ) from exc - except TimeoutError as exc: - raise ReportUploadError( - "Upload failed: request timed out after 30s" - ) from exc - except OSError as exc: - raise ReportUploadError(f"Upload failed: {exc}") from exc - - if not body.startswith(("http://", "https://")): - raise ReportUploadError( - f"Upload failed: server did not return a valid URL: {body[:100]!r}" - ) - - return body - - async def async_upload(self, text: str) -> str: - """Asynchronously upload text using asyncio.to_thread.""" - return await asyncio.to_thread(self.upload, text) diff --git a/legacy/pxmodrim/core/utils.py b/legacy/pxmodrim/core/utils.py deleted file mode 100644 index b18f713..0000000 --- a/legacy/pxmodrim/core/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def find_about_xml(mod_path: Path) -> Path | None: - """Find About.xml in a mod directory (case-insensitive).""" - if not mod_path.is_dir(): - return None - candidate = mod_path / "About" / "About.xml" - if candidate.is_file(): - return candidate - for entry in mod_path.iterdir(): - if entry.name.lower() == "about" and entry.is_dir(): - for child in entry.iterdir(): - if child.name.lower() == "about.xml" and child.is_file(): - return child - break - return None diff --git a/legacy/pxmodrim/core/workshop/__init__.py b/legacy/pxmodrim/core/workshop/__init__.py deleted file mode 100644 index 730c9b3..0000000 --- a/legacy/pxmodrim/core/workshop/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.workshop.catalog import CatalogSettings, WorkshopCatalog -from pxmodrim.core.workshop.client import CatalogClient, CatalogError -from pxmodrim.core.workshop.types import ( - Author, - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, - Preview, - ResolvedDownload, - Votes, -) - -__all__ = [ - "Author", - "CatalogClient", - "CatalogCollection", - "CatalogError", - "CatalogMod", - "CatalogPage", - "CatalogQuery", - "CatalogSettings", - "CollectionDetail", - "Discover", - "DownloadPlan", - "InstallState", - "Preview", - "ResolvedDownload", - "Votes", - "WorkshopCatalog", -] diff --git a/legacy/pxmodrim/core/workshop/catalog.py b/legacy/pxmodrim/core/workshop/catalog.py deleted file mode 100644 index d1ca96e..0000000 --- a/legacy/pxmodrim/core/workshop/catalog.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import time -from collections.abc import AsyncIterator, Awaitable, Callable -from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar - -import msgspec -from loguru import logger - -from pxmodrim.core.downloads.manager import DownloadManager, download_manager -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin -from pxmodrim.core.plugin_config import PluginConfig -from pxmodrim.core.workshop.client import CatalogClient, CatalogError -from pxmodrim.core.workshop.favourites import FavouriteCollections -from pxmodrim.core.workshop.types import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, -) - -if TYPE_CHECKING: - from pathlib import Path - - from pxmodrim.core.config import AppConfig - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads.types import DownloadResult - from pxmodrim.core.models.metadata.structures import ListedMod - -FRESH_SECONDS = 300.0 -_CACHE_LIMIT = 200 -_DISK_LIMIT = 300 - -_V = TypeVar("_V") - - -class _Stored(msgspec.Struct, Generic[_V]): # noqa: UP046 - at: float - value: _V - - -def _cache_file(directory: Path, key: str) -> Path: - return directory / f"{hashlib.sha256(key.encode()).hexdigest()[:32]}.json" - - -def _read_stored(path: Path, value_type: Any) -> tuple[float, Any] | None: - stored_type: Any = _Stored[value_type] - try: - stored = msgspec.json.decode(path.read_bytes(), type=stored_type) - except (OSError, msgspec.DecodeError): - return None - return stored.at, stored.value - - -def _write_stored(directory: Path, path: Path, entry: tuple[float, Any]) -> None: - directory.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(".tmp") - temporary.write_bytes(msgspec.json.encode(_Stored(entry[0], entry[1]))) - temporary.replace(path) - files = sorted(directory.glob("*.json"), key=lambda file: file.stat().st_mtime) - for stale in files[:-_DISK_LIMIT]: - stale.unlink(missing_ok=True) - - -DEFAULT_CATALOG_URL = "https://api.modrim.pyxiion.dev" - - -class CatalogSettings(msgspec.Struct, frozen=True): - url: str = DEFAULT_CATALOG_URL - - -class WorkshopCatalog(Plugin): - name = "workshop_catalog" - dependencies: ClassVar[list[str]] = ["downloads"] - - def __init__( - self, - client_factory: Callable[[str], CatalogClient] = CatalogClient, - ) -> None: - self.installed_changed: Event[None] = Event() - self.queue_changed: Event[None] = Event() - self._queue: list[list[str]] = [] - self._queued: set[str] = set() - self._runner: asyncio.Future[None] | None = None - self._idle = asyncio.Event() - self.catalog_url_changed: Event[str] = Event() - self.settings = PluginConfig(None, self.name, CatalogSettings) - self.settings.changed.connect(self._on_settings_changed) - self.favourites = FavouriteCollections(None) - self._client_factory = client_factory - self._base_url = self.settings.value.url.strip().rstrip("/") - self._client: CatalogClient | None = None - self._clients: list[CatalogClient] = [] - self._ctx: CoreContext | None = None - self._manager: DownloadManager | None = None - self._installed_index: dict[str, list[ListedMod]] | None = None - self._responses: dict[str, tuple[float, Any]] = {} - self._cache_dir: Path | None = None - - def setup(self, ctx: CoreContext) -> None: - self._ctx = ctx - if ctx.has_config_service: - self.settings = PluginConfig( - ctx.config_service, - self.name, - CatalogSettings, - legacy={"workshop_catalog_url": "url"}, - ) - self.settings.changed.connect(self._on_settings_changed) - self._change_url(self.settings.value.url) - self._cache_dir = ctx.config_service.config_dir / "workshop-cache" - self.favourites = FavouriteCollections(ctx.config_service) - self._manager = download_manager(ctx) - ctx.mod_service.mods_changed.connect(self._on_installed_changed) - self._manager.download_finished.connect(self._on_download_finished) - self._manager.busy_changed.connect(self._on_busy_changed) - ctx.config_changed.connect(self._on_config_changed) - - def _on_installed_changed(self, _: None) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_download_finished(self, _: DownloadResult) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_config_changed(self, _config: AppConfig) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_settings_changed(self, settings: CatalogSettings) -> None: - self._change_url(settings.url) - - def _change_url(self, url: str) -> None: - url = url.strip().rstrip("/") - if self._base_url == url: - return - self._base_url = url - self._client = None - self.catalog_url_changed.emit(url) - - def set_base_url(self, url: str) -> None: - self.settings.update(CatalogSettings(url.strip().rstrip("/"))) - - @property - def configured(self) -> bool: - return bool(self._base_url) - - def _catalog_client(self) -> CatalogClient: - if not self.configured: - raise CatalogError("Workshop catalog is disabled.") - if self._client is None: - self._client = self._client_factory(self._base_url) - # Keep replaced clients alive until shutdown so in-flight requests finish. - self._clients.append(self._client) - return self._client - - async def shutdown(self) -> None: - self.cancel_queue() - if self._runner is not None: - self._runner.cancel() - self._runner = None - if self._ctx is not None: - self._ctx.mod_service.mods_changed.disconnect(self._on_installed_changed) - self._ctx.config_changed.disconnect(self._on_config_changed) - if self._manager is not None: - self._manager.download_finished.disconnect(self._on_download_finished) - self._manager.busy_changed.disconnect(self._on_busy_changed) - for client in self._clients: - await client.shutdown() - self._clients.clear() - self._client = None - - async def discover(self) -> Discover: - return await self._catalog_client().discover() - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - return await self._catalog_client().mods(q) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return await self._catalog_client().collections(q) - - async def mod(self, id: str) -> CatalogMod | None: - return await self._catalog_client().mod(id) - - async def collection(self, id: str) -> CollectionDetail | None: - return await self._catalog_client().collection(id) - - def _installed(self) -> dict[str, list[ListedMod]]: - if self._ctx is None: - raise RuntimeError("Workshop catalog accessed before setup") - if self._installed_index is None: - installed: dict[str, list[ListedMod]] = {} - for mod in self._ctx.all_mods.values(): - id = mod.published_file_id - if id is not None: - installed.setdefault(id, []).append(mod) - self._installed_index = installed - return self._installed_index - - async def cached[T]( - self, - key: str, - produce: Callable[[], Awaitable[T]], - fresh_for: float = FRESH_SECONDS, - value_type: Any = None, - ) -> AsyncIterator[T]: - """Yield the remembered value at once, then the refreshed one if it differs. - - A value younger than *fresh_for* is served without contacting the catalog. - With *value_type*, values are also kept on disk, so they survive a restart. - """ - key = f"{self._base_url}|{key}" - entry = self._responses.get(key) - directory = self._cache_dir if value_type is not None else None - if entry is None and directory is not None: - entry = await asyncio.to_thread( - _read_stored, _cache_file(directory, key), value_type - ) - if entry is not None: - self._responses[key] = entry - while len(self._responses) > _CACHE_LIMIT: - del self._responses[next(iter(self._responses))] - yield entry[1] - if time.time() - entry[0] < fresh_for: - return - value = await produce() - changed = entry is None or value != entry[1] - if entry is not None and not changed: - value = entry[1] - remembered = (time.time(), value) - self._responses.pop(key, None) - self._responses[key] = remembered - while len(self._responses) > _CACHE_LIMIT: - del self._responses[next(iter(self._responses))] - if directory is not None: - try: - await asyncio.to_thread( - _write_stored, directory, _cache_file(directory, key), remembered - ) - except OSError as exc: - logger.warning("[workshop] cannot write cache: {}", exc) - if changed: - yield value - - def _install_state( - self, mod: CatalogMod, installed: dict[str, list[ListedMod]] - ) -> InstallState: - local = installed.get(mod.id) - if not local: - return "missing" - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - synced = [ - timestamp - for item in local - if (timestamp := self._manager.last_synced(item)) is not None - ] - if synced and mod.updated_at is not None and max(synced) < mod.updated_at: - return "outdated" - return "installed" - - def install_state(self, mod: CatalogMod) -> InstallState: - return self._install_state(mod, self._installed()) - - async def installed_with_updates(self) -> list[CatalogMod]: - client = self._catalog_client() - items, _ = await client.mods_batch(list(self._installed())) - return items - - async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - resolved = await self._catalog_client().resolve(mod_ids, collection_ids) - installed = self._installed() - to_download: list[str] = [] - already_current: list[str] = [] - for id in resolved.mod_ids: - mod = resolved.items.get(id) - if not isinstance(mod, CatalogMod): - raise CatalogError( - "The Workshop catalog returned an invalid download plan." - ) - if self._install_state(mod, installed) == "installed": - already_current.append(id) - else: - to_download.append(id) - return DownloadPlan( - to_download=to_download, - already_current=already_current, - unavailable_ids=resolved.unavailable_ids, - incomplete_collection_ids=resolved.incomplete_collection_ids, - titles={id: item.title for id, item in resolved.items.items()}, - is_complete=resolved.is_complete, - ) - - async def download(self, plan: DownloadPlan) -> DownloadResult: - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - return await self._manager.download_mods(plan.to_download) - - @property - def queued_ids(self) -> frozenset[str]: - return frozenset(self._queued) - - def enqueue(self, plan: DownloadPlan) -> None: - """Queue *plan* behind any running download without waiting for it.""" - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - active = self._manager.active_ids - ids = [ - id for id in plan.to_download if id not in self._queued and id not in active - ] - if not ids: - return - self._queue.append(ids) - self._queued.update(ids) - self.queue_changed.emit(None) - if self._runner is None or self._runner.done(): - self._runner = asyncio.ensure_future(self._drain()) - - def cancel_queue(self) -> None: - self._queue.clear() - self._queued.clear() - self.queue_changed.emit(None) - - async def _drain(self) -> None: - manager = self._manager - if manager is None: - return - while self._queue: - ids = self._queue.pop(0) - waited = manager.is_downloading - while manager.is_downloading: - self._idle.clear() - await self._idle.wait() - if waited and manager.cancelled: - self.cancel_queue() - return - try: - await manager.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[workshop] queued download failed: {}", exc) - self._queued.difference_update(ids) - self.queue_changed.emit(None) - if manager.cancelled: - self.cancel_queue() - return - - def _on_busy_changed(self, busy: bool) -> None: - if not busy: - self._idle.set() diff --git a/legacy/pxmodrim/core/workshop/client.py b/legacy/pxmodrim/core/workshop/client.py deleted file mode 100644 index a494aaf..0000000 --- a/legacy/pxmodrim/core/workshop/client.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import annotations - -from urllib.parse import quote - -import httpx -import msgspec - -from pxmodrim.core.workshop.types import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - ResolvedDownload, -) - - -class CatalogError(Exception): - def __init__(self, message: str, status: int | None = None) -> None: - super().__init__(message) - self.message = message - self.status = status - - -class _ErrorMessage(msgspec.Struct): - message: str - - -class _ErrorEnvelope(msgspec.Struct): - error: _ErrorMessage - - -class _Batch(msgspec.Struct): - items: list[CatalogMod] - unavailable_ids: list[str] - - -class CatalogClient: - def __init__( - self, base_url: str, transport: httpx.AsyncBaseTransport | None = None - ) -> None: - self._base_url = base_url.strip().rstrip("/") - self._http = httpx.AsyncClient(transport=transport, timeout=30.0) - - async def shutdown(self) -> None: - await self._http.aclose() - - async def _request[T]( - self, - method: str, - path: str, - response_type: type[T], - *, - params: dict[str, str | int] | None = None, - payload: dict[str, list[str]] | None = None, - ) -> T: - if not self._base_url: - raise CatalogError("Workshop catalog is disabled.") - try: - response = await self._http.request( - method, - f"{self._base_url}/catalog/{path}", - params=params, - json=payload, - timeout=30.0, - ) - except (httpx.HTTPError, httpx.InvalidURL) as exc: - raise CatalogError(f"Cannot reach the Workshop catalog: {exc}") from exc - if not response.is_success: - try: - error = msgspec.json.decode(response.content, type=_ErrorEnvelope) - message = error.error.message - except msgspec.DecodeError: - message = ( - f"Workshop catalog request failed " - f"(HTTP {response.status_code} {response.reason_phrase})." - ) - raise CatalogError(message, response.status_code) - try: - return msgspec.json.decode(response.content, type=response_type) - except msgspec.DecodeError as exc: - raise CatalogError( - "The Workshop catalog returned an invalid response.", - response.status_code, - ) from exc - - @staticmethod - def _params(query: CatalogQuery) -> dict[str, str | int]: - params: dict[str, str | int] = { - "q": query.query, - "sort": query.sort, - "limit": query.limit, - "source": query.source, - } - for key, value in ( - ("tag", query.tag), - ("version", query.version), - ("cursor", query.cursor), - ): - if value is not None: - params[key] = value - return params - - async def discover(self) -> Discover: - return await self._request("GET", "discover", Discover) - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - return await self._request( - "GET", "mods", CatalogPage[CatalogMod], params=self._params(q) - ) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return await self._request( - "GET", "collections", CatalogPage[CatalogCollection], params=self._params(q) - ) - - async def mod(self, id: str) -> CatalogMod | None: - try: - return await self._request("GET", f"mods/{quote(id, safe='')}", CatalogMod) - except CatalogError as exc: - if exc.status == 404: - return None - raise - - async def collection(self, id: str) -> CollectionDetail | None: - source, separator, key = id.partition(":") - if not separator or source not in {"steam", "picked"} or not key: - raise CatalogError("Invalid Workshop collection ID.") - try: - return await self._request( - "GET", f"collections/{source}/{quote(key, safe='')}", CollectionDetail - ) - except CatalogError as exc: - if exc.status == 404: - return None - raise - - async def mods_batch(self, ids: list[str]) -> tuple[list[CatalogMod], list[str]]: - items: list[CatalogMod] = [] - unavailable: list[str] = [] - for start in range(0, len(ids), 100): - batch = await self._request( - "POST", "mods/batch", _Batch, payload={"ids": ids[start : start + 100]} - ) - items.extend(batch.items) - unavailable.extend(batch.unavailable_ids) - return items, unavailable - - async def resolve( - self, ids: list[str], collection_ids: list[str] - ) -> ResolvedDownload: - roots: dict[str, None] = {} - items: dict[str, CatalogMod | CatalogCollection] = {} - mod_ids: dict[str, None] = {} - unavailable: dict[str, None] = {} - incomplete: dict[str, None] = {} - is_complete = True - batches = max((len(ids) + 49) // 50, (len(collection_ids) + 9) // 10, 1) - for batch in range(batches): - resolved = await self._request( - "POST", - "resolve", - ResolvedDownload, - payload={ - "ids": ids[batch * 50 : (batch + 1) * 50], - "collection_ids": collection_ids[batch * 10 : (batch + 1) * 10], - }, - ) - roots.update(dict.fromkeys(resolved.roots)) - items.update(resolved.items) - mod_ids.update(dict.fromkeys(resolved.mod_ids)) - unavailable.update(dict.fromkeys(resolved.unavailable_ids)) - incomplete.update(dict.fromkeys(resolved.incomplete_collection_ids)) - is_complete = is_complete and resolved.is_complete - return ResolvedDownload( - roots=list(roots), - items=items, - mod_ids=list(mod_ids), - unavailable_ids=list(unavailable), - incomplete_collection_ids=list(incomplete), - is_complete=is_complete, - ) diff --git a/legacy/pxmodrim/core/workshop/favourites.py b/legacy/pxmodrim/core/workshop/favourites.py deleted file mode 100644 index dff168c..0000000 --- a/legacy/pxmodrim/core/workshop/favourites.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import msgspec - -from pxmodrim.core.plugin_config import PluginConfig -from pxmodrim.core.workshop.types import CatalogCollection - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - from pxmodrim.core.events import Event - - -class _Stored(msgspec.Struct, frozen=True): - collections: list[CatalogCollection] = msgspec.field(default_factory=list) - - -class FavouriteCollections: - """Collections the user starred, newest first. - - Each entry is the last catalog snapshot of the collection, so the list shows - without a network round trip and survives the catalog dropping it. - """ - - __slots__ = ("_config", "_ids") - - def __init__(self, service: ConfigService | None) -> None: - self._config = PluginConfig(service, "workshop_favourites", _Stored) - self._ids = {item.id for item in self._config.value.collections} - - @property - def changed(self) -> Event[_Stored]: - return self._config.changed - - @property - def items(self) -> list[CatalogCollection]: - return list(self._config.value.collections) - - def __contains__(self, collection_id: str) -> bool: - return collection_id in self._ids - - def set(self, collection: CatalogCollection, favourite: bool) -> None: - if (collection.id in self._ids) == favourite: - return - rest = [c for c in self._config.value.collections if c.id != collection.id] - self._save([collection, *rest] if favourite else rest) - - def remember(self, collection: CatalogCollection) -> None: - """Refresh the stored snapshot of a starred collection.""" - if collection.id not in self._ids: - return - stored = self._config.value.collections - if collection in stored: - return - self._save([collection if c.id == collection.id else c for c in stored]) - - def _save(self, collections: list[CatalogCollection]) -> None: - self._ids = {item.id for item in collections} - self._config.update(_Stored(collections)) diff --git a/legacy/pxmodrim/core/workshop/types.py b/legacy/pxmodrim/core/workshop/types.py deleted file mode 100644 index 4522890..0000000 --- a/legacy/pxmodrim/core/workshop/types.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -from typing import Generic, Literal, TypeVar - -import msgspec - - -class Author(msgspec.Struct, frozen=True): - id: str | None - name: str | None - profile_url: str | None - - -class Preview(msgspec.Struct, frozen=True): - type: Literal["image", "video"] - url: str - - -class Votes(msgspec.Struct, frozen=True): - up: int - down: int - positive_percent: float | None - - -class CatalogMod(msgspec.Struct, frozen=True, tag="mod", tag_field="kind"): - id: str - source: Literal["steam"] - title: str - author: Author - description: str - description_format: Literal["bbcode"] - preview_url: str | None - previews: list[Preview] - workshop_url: str - tags: list[str] - supported_versions: list[str] - created_at: int | None - updated_at: int | None - file_size: str | None - subscriptions: int | None - votes: Votes | None - dependencies: list[str] - package_id: str | None - incompatible: bool - - @property - def kind(self) -> Literal["mod"]: - return "mod" - - -class CatalogCollection( - msgspec.Struct, frozen=True, tag="collection", tag_field="kind" -): - id: str - source: Literal["steam", "picked"] - steam_id: str | None - title: str - author: Author - description: str - description_format: Literal["bbcode", "text"] - preview_url: str | None - previews: list[Preview] - workshop_url: str | None - tags: list[str] - supported_versions: list[str] - created_at: int | None - updated_at: int | None - member_ids: list[str] - member_count: int - member_previews: list[str] = msgspec.field(default_factory=list) - total_size: str | None = None - no_common_version: bool = False - - @property - def kind(self) -> Literal["collection"]: - return "collection" - - -T = TypeVar("T") - - -class CatalogPage(msgspec.Struct, Generic[T], frozen=True): # noqa: UP046 - # msgspec resolves postponed annotations using module globals, not PEP 695 scope. - items: list[T] - total: int - next_cursor: str | None - - -class CollectionDetail(msgspec.Struct, frozen=True): - collection: CatalogCollection - members: list[CatalogMod | CatalogCollection] - unavailable_ids: list[str] - is_complete: bool - - -class Discover(msgspec.Struct, frozen=True): - mods: CatalogPage[CatalogMod] - collections: CatalogPage[CatalogCollection] - - -class ResolvedDownload(msgspec.Struct, frozen=True): - roots: list[str] - items: dict[str, CatalogMod | CatalogCollection] - mod_ids: list[str] - unavailable_ids: list[str] - incomplete_collection_ids: list[str] - is_complete: bool - - -class CatalogQuery(msgspec.Struct, frozen=True): - query: str = "" - tag: str | None = None - version: str | None = None - sort: str = "popular" - limit: int = 24 - cursor: str | None = None - source: str = "all" - - -InstallState = Literal["missing", "installed", "outdated"] - - -class DownloadPlan(msgspec.Struct, frozen=True): - to_download: list[str] - already_current: list[str] - unavailable_ids: list[str] - incomplete_collection_ids: list[str] - titles: dict[str, str] - is_complete: bool diff --git a/legacy/pxmodrim/core/xml.py b/legacy/pxmodrim/core/xml.py deleted file mode 100644 index dfffae8..0000000 --- a/legacy/pxmodrim/core/xml.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any - -import lxml.etree as ET -from loguru import logger - - -def _text(elem: ET._Element, tag: str) -> str | None: - """Return stripped text of the first child matching *tag*, or None.""" - found = elem.find(tag) - if found is None or found.text is None: - return None - text = found.text.strip() - return text or None - - -def etree_to_dict(t: Any) -> dict[str, Any]: - """Recursively convert an lxml Element tree into a plain nested dictionary.""" - d: dict[str, Any] = {str(t.tag): {}} - children = list(t) - if children: - dd: dict[str, Any] = {} - for dc in map(etree_to_dict, children): - for k, v in dc.items(): - if k in dd: - if not isinstance(dd[k], list): - dd[k] = [dd[k]] - dd[k].append(v) - else: - dd[k] = v - d[str(t.tag)] = dd - if t.attrib: - d[str(t.tag)].update(("@" + str(k), str(v)) for k, v in t.attrib.items()) - if t.text: - text = str(t.text or "").strip() - if children or t.attrib: - if text: - d[str(t.tag)]["#text"] = text - else: - d[str(t.tag)] = text - return d - - -def xml_path_to_json(path: str) -> dict[str, Any]: - """Parse an XML file at *path* and return its contents as a nested dictionary.""" - if not os.path.exists(path): - logger.error(f"XML file does not exist at: {path}") - return {} - try: - tree = ET.parse(path) - root = tree.getroot() - return etree_to_dict(root) - except (OSError, ET.ParseError) as e: - logger.error(f"Error parsing XML file {path}: {e}") - return {} - - -def dict_to_etree(data: dict[str, Any]) -> ET._Element: - """Convert a dictionary to an lxml.etree Element recursively.""" - if len(data) != 1: - raise ValueError("Dictionary must have exactly one root key") - root_key, root_val = next(iter(data.items())) - root = ET.Element(root_key) - _build_etree(root, root_val) - return root - - -def _build_etree(parent: ET._Element, value: Any) -> None: - """Recursively build XML elements from dict/list/str values.""" - if isinstance(value, dict): - for k, v in value.items(): - if k.startswith("@"): - parent.set(k[1:], str(v)) - elif k == "#text": - parent.text = str(v) - elif isinstance(v, list): - for item in v: - child = ET.SubElement(parent, k) - _build_etree(child, item) - else: - child = ET.SubElement(parent, k) - _build_etree(child, v) - elif isinstance(value, list): - for item in value: - # Anonymous list items - shouldn't happen with well-formed data - child = ET.SubElement(parent, "li") - _build_etree(child, item) - else: - parent.text = str(value) - - -def json_to_xml_write( - data: dict[str, Any], path: str, raise_errs: bool = False -) -> None: - """Write dictionary data to an XML file with pretty printing.""" - try: - root = dict_to_etree(data) - formatted = ET.tostring( - root, encoding="utf-8", xml_declaration=True, pretty_print=True - ) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write(formatted.decode("utf-8")) - logger.debug(f"XML written to {path}") - except Exception as e: - logger.error(f"Error writing XML file: {e}") - if raise_errs: - raise diff --git a/legacy/pxmodrim/ui/__init__.py b/legacy/pxmodrim/ui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/ui/assets/__init__.py b/legacy/pxmodrim/ui/assets/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/legacy/pxmodrim/ui/assets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/legacy/pxmodrim/ui/assets/logo.png b/legacy/pxmodrim/ui/assets/logo.png deleted file mode 100644 index 94645a8b7a2bbf5004ae1a400a334a4b01162d56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36953 zcmZ^KWmuHm_w`VMfPj+H9U`I9H3-rT3eu$#10vE4FrbvAG)RL8k|GRUN{0i|-60YK zLkuy@yf;3-|Ht>@19M$--{6(egXWuVW*|`1cbZ(%WEx80)f~;8c!ZS_08H{@XNAr@LRrIx@_CZD*da{JZ@Mv zk$9hpypDsyhN;Hhm{^H~>ihitBn@HZ$;_9D+6s+H>zl6<`*rq*vI8F8q+)n?_|wil z<^gn?!1e0)sX~&mi6V5zv;hQijyVi8#d)re+inr6t zVZOYI+4{3!+uwR;sNEH#V%=X3v@VhcroM7GK6sf0_FmWx{lou<89p;QH2SwD`13rlfp)7AF& zro?0SEKNbXv+XdE>I*&6Al5zymUt?q$hfD3i?PpO4+7Q{D*ziF7;_&2Hao_0mf-G|{LN*P_dm9n z{9z%Si$t_aHM6kZ}5hU>IF*zFis^czNGB22&K!cMNVeWrsT@EL75Wx-|5@I zEAp4tcz@gR^oC{V0PkAWeso$Ln%k?f;XEg+%T;>+vx$%cwwHpNDYw6^M8csM*zs(l zDlVQ)F3F3#?6-jts96o&u5p)D@o@gXCk&_f*I-UIW2qh)1KYR>mL zflJ+ks-b^#t*&BvZymNHLl^LO+CbCdZY|*p6cAm?`nM`GcG*aB0?g6gLbQ#BzC?@bAvG0}p`j&Gni=LkE4d zunLp z>gp=2F;z?S!~5ogT(o@VpnxwYxbqw^BU0k5?(Ky*pDwdp&P%EcI*>Ay!UA%@Mx`|rQzItF%)8NP34D#Na z!6^~tCajA9Xp3+~nZcaViv%LSRySQmK;E&;z9~w9%+n+LPyi%UJ43-fSgz*NT?n-7 zvQ8Iomr?rQUJ<5Mu`(M?3q*Rd@!=U8i|lC^-<(%x9WAJ{kT@p-oeR2{^U?!eFL+Or z?Nh}H4mvy9YcMq@4Ngc1l$5CehdF#>K?4!ukQtA8pBBpo#q3nH84QvIdzLNH2mh*Q zOQQlTCql>+S&#e`dM9K_*l-<`p3BRBW-FDC)9o#&)g$NXYf>bgOz}kJH?3%( z=3bQ7^J?vpRkXnz9ZO#BY??g-F?eDFNnbnuJ_!TD`5Jwn`eZ>j`mL9iE&(%Z>#9fy zY$^(J?QW~GrTjN{Em292{oKr;jaSxhe1}u22+;Y)7Un>t|2$rVE_$NQ)_f$73UqWv zJ3B9PqYSK-n=k;SQ2i=QKN(o`MfD{h#di{QGEJu2u0G+_HqmQf0Z*TJ_}xf(#KyqN zx(}J;$utIqp0C2x+`Vcl`L5TPr}D=-uE~@hGC@LG&$M_r^ z5P|mM?X{%>!M}h1{#7j*-(Lp?UbVB7E(_c!1!_gE<+ezTeQ#WB#riKnR=g6KBQ~tc_@sw67UbK>#@TNT`1Tz6mH~Gu z=(onB1_}?S*cD9x$B^T$@se$W$F$4BborwzsdYP%3>xqx~;X#ea-?+D;7J{6}vG z2VS0T2pY592>(Qz^3UlZ4>O|~AI#a|YWE`QGZ*NkLlDJ(uQ?d(z6k{%y>Rmz#aHyh zrEaQnU!$W=|F4F}nF?93CA894;-FK9QXR01YVTlorPag=R?TKr3mRF(y@jk^lk5|7 zDKt{#w2({~pP7;IucQHv;amR8$N$!1;!AbWN_BO0*ao;^nIxFv(klKRmlce;cF|Zt z^VuxEf%Hox>m{!1l~Qd)yNxc`jnrnTEim(*aXeQA>|X}y4@B$+T$fEIYdJTx<)+C*FmNfFJTn+v+Wb^W^|IUy1#Dj49 z)EH2*kciTMz;(Tdeuiu5p--g&&F+}gOa5obd-VRZ*d-AGP|N1CYu5{Q_^VRo)~BXG zyE8Qo(|2+lVd{YEp^g!y?#&2h1v!daTp(Sd;bCF!e~y?@Wo}KjS!LnB`MarI@L!bp zRRTF|T0sZ5L`bhk6rAvA#lNoFQnWe*wy!w;GxJ`8I};#1EHu?XKX*P}lcthqbII)H}wIP_3=ar@+;+1jbX8ocbC5R<aVwwb|#`C!ma5cX-W8s00AcP;(Bbc z{y$A_g@xd&=)-bHw|5334#v<>lS`|W4~Ot3yBVXPt2Rxcwqo0LTk%H}t8$GXQByTw zB2HO)O>A_(ydR^9^Y$=fQrl?WD0wyw$=`xP5Ub+lvD4yO%8%w3(@SNa8A{|2lY%Oe zty|Kq%?KmKA3dJD4aE96A0u@@=ksPmEwI>^oa3Y^8cn`O=o7o(qa#>gx^)6?;DOZ& zyjPNjKzD}c2scJScs`BozeklY5KEa9Mh7CIicsFirNVKxK-WJ7883^k>Jnlj5jO!u6&W{T&yYCLr;8Wni=qA#dl24oFQl$1(*vft7x z8|Fb*_EgT7ruqp;*K}gVuzTF$L}Fg|$EV zMP+m@oDxQ)yg<>)XN#9F9GCoBh|4;h-@CI$cRF{3c(a!Mkk%gn(dA-Ff?CaPv$M=i zsa_|}p9iseI5VN3g@&Kjg7LXP@Ks$*XP=_Bf?+Q$YOo6Tzf&HZ8Fb1s2SNs>Wu zhoX&G2tKthev#;n+R=WqkXc0VOCu11&*KsyRUS4CEq~a>Y6mg!6^nAt5^Px>R=(vS3V{=~jxA0afmO-SV|s!E;lf{|U+CWb?O zii9CM!1(hVKAzqvrdK-NYdMQoBC4aOi!_FyNX$&Slp>PW+4T0Z*Tr~lrMUjKzM^Yd zFgd6^?%yIp##M5#8g&QTo{rX#6!J?+Zk)QZ4f*5emyAmGqO;U@yZ42niesPbE-?Pi zHF4S1dM*7dG^}8&??o6gyW~y4r~#a{?3S5qEm19JPu?31?ztth%9SV~t~UAUUZsJtupY$sCad8Rgz zlwG-z)Ko*K;siZol-57q@Q=|oIagLjnXpzQt>+HyD3|EStRp|XTh{=SvXjauJP5_x zozL^@^thaEb#mPrU;#rKFCiXTId#eQiJW<*CF5;z`G0BL$)7n?Kj_-FQ`)jCx4w_h zZfVbM>Z&rd-17^T=yfd+W{VZTRdi&aiXAubp87(Ch;)Sr#;5<(;wpXP}yVI20AXb!@e zB#O9hQ8DOIamR-Lox1~fl5$hzkp($(fgX~7yZ0YI8J@(yz_u;+TJ!K-+}-=_MBnkb zUoTk0vaoOM#GQiy62!6*uk3*FXuubKbggy$VR=*Hk2MfCrG zlWHml7h%CwWfSMsDmi8EQ>f=OsnwOzB9Gdc=PqtF*Z;XlYM;y-O(KTu(EP17gh;}#{T!gxZUVBMR1+USoSqNk+|pd=!TH$TRoVb zYLh=@xr8$$e z5qj)WqWL|Q%@Bb#=2?zNq=iAS8R82by4+Xv2Y?d`Z>%0%v8pnh=cf<>9*t%?@w5F zI?N(l3s&WF0~<~R@xVuK5=!4owbpxS+(#sJO+IfJFBURmHJq>CI#K>riO){9`(zL^+}kqWChzn{P2Ve}yGN&RHepJq`Ae_9TXRSrC!^P` zG@;}|>t6nClDGPEwV$3DA@_qRRngD*|8uDKtTx-qm0>|TlcI|6XfO4}Eydl$eBAWZ zo$rN=P~BmfMI4-Hm6w9Bi*EnIIV0&&s`G9V)HE`X@x7Lvrk%OP=Rd8MiI~<}Qi})w zA`~&^na-T zmbpr;DB|$m#)0Fjs8lsQc%&qErkzCA7An zgmBT3&>ICE)QrkD?DYJVEVumn;Y4*OnY7)a(vb5ZE)`YXDy@+IZbC5z<*au zLK36frW+kP;usS?(y=e}sVu@H=Cvd_9XS(OzkG4D26V~ZK`uUq_O5KE+=6);_obAj z4MVXWmNqTG(urHV2U2ic7Q0XvEOC)&WKY)JZ(;2y+Q@h3DTal>L z5i$`RHVvxoELI7gqVkP63Rir*%%M*KQ7qme(XIg5IF&7$S;Mw0WvX)Jw#|oEMcX01 zDD-{AWpj79%1K<9!qp>oJ!~1zzj9IXf~Qp}p>E>YHm0$L|K+#17b?r&IoZ{N2)0;s z{mLZ)@j~>Hd6tg|bYEDaoc3-%CeYA@n+2N~h?S5(=JSr4_M88{-u_%_x`+#;&es9| zUrK#C7}EeHqfWU0f>dI7J1)3qi0SMKMJGcZNmYn4;#WkrcJjv?+<3`EclUcOsDx*4 zwx#t%V-+o&fnOr@w`P&Y12f5|5^SO|#>OU+a|@K07zW#BQws+fYy2ls;VZDwK%5MV zvfEHDMOd$0)(?S5afZy(wJEx_4oK_-X<_0ve#L!FK{0GU8HjFR)(VlyR_Ba`fN`ZdT(TFP6B|bW9ol z781nIs}lqOnRU|7`B-lDeLSR}t=2;$Gw)&*92it(nzqYc!sAXH{b32Lq~pE^GMtOAOj+x;6&l&xZ~ub{E48DexVmO7aqG|DbQ+Fv2xI3?`Op^&z zf>F0LtP_Ha>E)9|H0Fa2Ycww@^9~fOJ{?2!u42os(yH(rQQ&AR&g<7BRbo|uV;&>? zlNT1UTI{-uzxByM{MkWPso8<9YKqD`i)Nz{FFP?>bQfQFR3aTI{*}f1armE?#-W7c zsswooLW;_X#^Z}jQzCiyx8iYAK~?VR?S~q<-Bplw^EtI|+~8l^&TG4L*EWQ>e8ARgB|7H%v>k`40z&uV6QRaC~St{a0|SyHG&=`igy%LxJJK~S@P9lA4r!=u|i^p@wA!4mn$C|zX zerm zq<2Io;Zkt|bkU%vWe>=@IemwRcJGgkjSUSAJ$&@2v(dvSOD6DB5?`;hv;v|(Y3=E~ zl$b&{uq(MiGT+b!NddDhgWXSNgJk83wMH*@cldT{z*0xzl&eLN;XOlP+Oc7>X(

    ;OLpj2CcbS6~y2~Cf@BNXFf^GYZkFQ9~mge>* zs$VUcYhZ)qC$r|)@=j`3yLTUdDyrMsC||)F48J_J$g{{QuKlJ6HyjXEs;0ZHvI;xw zPj>oA6fb`v5U4$*TvvVD2@5M`vfl5{3i3|XSTS#rxIwa9zuLLdr$0yO?8#3=jJtXs zY>eU;h52ccc|0B9IY_jcd?Wz!52XRk2&QNTTrn-JlUbkL$;JIlOHa=l;-;VuJt1VH zC4F+P@KZGLiO8n`Gc7;c)Kv^Kna87tFTYV};Eiy720gbfhX<@>;(FA;7w!s2Gs6>8 ztYT@L16xT?az&_AhzbHITBBTFDv49Gj8{QL-`roWIhx1!~kJy_95CyFB4e~;hvsnn-0YpnHdF! z7JV{VIsbdNh8g^zM|SDtwZcV5?;8Ylk>2TfTTp=#Se0=jQut{_a(bpr@BFcJd5Sne z1@Hgn%VuEOLQpRDnzS9VTlHwD3?JMd`t?xKD>dxgbVg@{(-3q_A^?(^vyW%nbj9sZ zuz~}`cJxCX%Nh)AH|GgG#vEkZs(Spn{u}N{8hL)a)z$^wZ{gcElxhYORRQoX``?$PMbHK zThh7}bfK96Vwt$R*WE1iN<$!Ql(B+*)=`_PAog)4`}rcFqbtczDwa^f$Ea3yUt~t6 z%wXdqzGhB?`lu{Y&L{MKR}d==>->&6+%_UExA=JRcJBj;N;`lJnW=l9SuoqXUNptJ zPkLi=y`+)yi)PGpc6k;}>qvlFA74|eXb0hKTZY?DZa@AQu%ANBmSRZ$dQmgT=HrKI z`{d{&(}-GOpjtdh^V<*-AtZ8xp)Z8+ThJOxRmR`kQ=E%pV#~VRQyR>=yC*#REX$M8 zaSdh~bv>4j$L0dsR8--&ZIiiT-Vz$*h^BFoH+x8GFsSY_%Xf^gOFc?*nA~fpGzElE zr10@TTHD94FuN~fc!-{-f+^gMCS_08NZpIxRT!5TmsvP^!5uk}NeRi$DWg9}`Rt0s zZIiX);wo9#vPC?*Yu_1u^PAHpy?U>mB{ez8tF)61)IZO!Q|+56S(%?Uv3j+y_abUi zo#EP9ycZ3h0xFH~Nl6bC+52vlBu70>70M#}8Ogt-R{4=HZHMok=l<8Eb0u)|sb&8* zb%em+*Z0BK!@;f3G9({7@QbSoTKh5B6bFn~^rXmww=y}2+gjMbRhdsBA$vCY=T8?@ zt6`LA6u>9_CnrfFzqW;R(%lH zj_4E0qeY{v+>N2>+i@%yRtLRY3wr307!Lk<0zMup@1(+a z5PIY={AP6oE$y-DxG4m!r`Y37Lk^0CdjrZTlJ(fU{Tw1cMsHOPTkOQ_((uJI+hO|h zk7Q`;q79?=xC$M|&RM8TlmOcDAAwIDvbDFdTdM}3VIfsQ24B!i2UM~E{RMmmL zV%r?^8-Oe+P`jF~Y9Siq(vvATN~YVC@>{1W)??PCka8|m!m%?+xIuR0Yp&&oyz_9* zZL5n!dIy$pDm`bQ6jAG8g17sPT;+ecjZJcBk4U@5P5qmEdKfo9{DS2jRoZ&?+I)sl zjQrvII+pdq*!>GE=-Tbh9Bgfw-(*eYunake@n?0;9WHk7>at3Yr`8D8?M^Si$?&f^x3~3An8?Td;DWVl&Xarl z@$FZ*5x#-2o;Az|d&zNax^NVy)p=CfOThB7S8Mmy)Yu$?yqk*9GvA72eF(3`>oOdS z`dh9{WvA2Pp&i5WGU!$5OLQOl7S-nDl2dq^RvZBx%gXOw)~!mY!ss}}9~oIjTU*ai z!N2YS941EmmWNKuHbo>PEasMR813U0|E7p$7`o_jeh`RuV2PrFtjcGx%KohUE!ao*2S3!<&ZO7^m)DNbxLZ~J-Gk>JBwItuDx1D{-*#B@ zlhndvm&rnBDYz%ET$$KvdyHioehRP+4r|Q#VkN2d{f43^50Ztf=F#;N_QY%}L(>ME z4pTwcGYkgb2ll5EGmbkY!)$@iAL-^QjIBr3_>AdDQW1_aKKGPhGP#DfR;49_1s&YhWr4nH-1hKwF!vv}tM1FaI=4pU z;?9QoQ-E!diL9m10^Br${fSYQy!hlXj)KruJ$X?4ia(5ORF_Ih&s0;gA`$5MYgt!3WAK7b*;4 z{Uc{+NAE$I5bv6V=zJ4ex{z&tJv`Nhy@hSZ9r~C?xX-An zdQ%gAmsa_^{v!Gd@hy+*oNfxN2sX+shO zV9Bfjx*D{Lu^sMH2iW1aSa;(l9mtkG7Nq`l>1E8#nc6_&_nCE|1;2L_{u`}Z?N~5Q zL=ueV{V6&mlO;aa>_e%?tPrrm=B)CqzLP9eGv%zym$%;d{Z+cF`NKw^M`xO}nZLo! zaOYDJMFtgqm9b*F@9ad1X(MJe?t>IJ>Rpr}cc=RY&dvlDaX+AE8M*!*llkQ{fBxh| z<3{{Qs~W@Re+|F3R@B+dw*QO9YVm$>Ij8_@c?NFn*QP^zSy)U!Pw&yTpi926Y?p~X zo=z={G}n2kQfw6UL&tnNaM@Yn^&Ce(jKz1@)yKvaQqNI0ipbB_yEr=cfq^6!=8H?c z4b8^T;pTHeO9y`iWo6DyfQ2d=4&vDnH6Dh#%NGbqm5xhyL%1cirZ$KDlBS*gAA_fYWt2WcC2&-zfYQ^cnPreb!L| zIo-W4SozAPGYMSu!Nm(j(y^%(0~#M{-3dIScpXf0b>QWyF)-kGG56d>%zPtrDgU^; z>Lp9hOQ)cwRD8u+gQaCvZzS{{Q@xAZsvX_$d%bZi$m#uHrW+=8uH)I6cGyaU0%bmq z&m4R|7rK2U+MF9;+3mmaYAXZUn|+POnB%$zVE;eaC>k^U;Cn7hGulqZ7h+oFj&MI% z&M)tLo-}{YKRZp~il>wkJp0qpOfX#BV{8g8gmDg|f{}nHrF24;A67XYEG* z%a6L^*<1~tR7~MEr{2BZ=T}@}daXkL>GF6+*u$-zdMO_UOM`d|9>2beB?jUc+ZJ+b zw{2B`+$DM9YJoFtXDDZ75n67!&`7o@v$^O_?CD6r{YXZ8{|npe*^^_$-nyHg)mY_} zZqaaH%D{U{2f#pn*FLnQA7S1-E>MAnHr2O4-((i9OVN<~s2$$-f)+aMi(T$9*2<;^4 zC8A{rdQk%Fy5qJ|`Of%W;NR${ZC3z`m49gJP!qP!8=11kwcTBH zCSvNyo(!{Dr zR-B^?9TZ{@W{V{r6r6r@;7xBo>I9IjvBl$`GHJFQ;e0k->&mpe!$z8mahVY}F0374 zA>)DolTa7Yn0xQqZRK1Mj*b-XSbF>#1CaC!p=%X{dv`92?99n=Q4-smLfcopgU1Xx zsVwPX9ghf{#kVppFVU-7klYKW6fz!Yk>bv=G7p=oU(D&BW3II5KWy_2>+%{XG=B;m zL2MYFpdd+;(-|8uV>Ex713>UI-(Eu1C~iefK+gwdJaCd?#UTkIX+U&YSp5967V9!F zT5eV+$cgCJ*nR;M3n!mJG}=>J;Jg|K zGBYl>9ZBGPBRN>C!VSsS9sFrQ_gPp*oVOzk6&lmN*gjpxDB0M)zTwd9fW7woZjtz0 zgxZXPT+}EkzA+8cS5akWllQ0|q~!4hBV?*yePoIn<& ztd~BXc`4ce9X;)w3+2AMeMC9@!9wQsBTMwR@9H@QT+G}dic{p{!V5VioVBTa3^B@O z)Q885#r1G@%rxwTLRqGhKT}^F>tP9%m1VzE ztbJ!z7@bdT+o-}a{r)w~iOzD6IBBZ*LVPLEf>F6YkM8FSv|f28qU5$)ogPiTJAN*}cbS8W4og>D0 zZ5)SbcR0_@AsGx{L_ZhtPQP{S;_@;(>~IS=RjM~??gnW6jBHn|esSifSJ*fxpb3qQ z0*Oegf(}(+R=y`%`UJoB#Fw0pd|)wWA2#Xz%DnRVVBM?u?TU9}C~gvv=*Y~o37|qa z*zS@h6n`8$-VkdJulr^zssdNIAI+Axl3MWXm!V<{^RZ7L7FGh;i{(+S3}$I^i1jA#xcI;Qln>l3=9m*Fzg=8 z-m~_oE?5ZN$F&5RfotVnkv3)qr5`kvnR~|@^Uq%xM&+LU^ebNb`XdMh9yOhlU+e7U zUqk~P+6Eic)V3z(g|31+-K9YM11e_{eC50(zpD;@C3})vG#8iCO0y>5RK~a#V`Co9@;~F zBizFENrxjU?4zJs{o78*uwoaHBr*isc#g%0oag94MKyExb82&~T^ia-BqpiKk`{n z3&6bi@<7fBHKky>U6r)PptScPhJ%eC#X+N#M1H6?k~`+IJJzx(ZGIlMMbr4&fg(a7_K>ajxYI)UB`ngnkRO1whRrb*GhycCDi`HZ60xh5A1MbcpL z>6Sox^YCzkyWS^)LPPGT4{V|yr`nRjx|UNnZrnKgWsQ4=oswNbRA$T8P!v0r!-cZ( zO9TRUYj$2;&S-wMi2Dhlr@y2Hh>b-&${_HVsyjYVVvIpJ9kTpt7~@Snia|t|VrhtPa%@NNeo>?kK$PaBWKH%`e2~=aDg7593B0mG*AJ zdmXbS7*O|z5Qaj(a!txTsRcmBj|HZiJVIm5Yh>5EWe3;K+5dG6)icY|``uUoH=1s+N+U4#`bO25Lbw2s(9o}olwc>bL>quhT|QzUN= zd;*H*Wd!<_ztWmYQoCkiyD`UOP>Au95I$0J15hIs>Gr$!HvO^O_dnik_PQ~ze^oBa z9S~)-a?k5pjD66^E+iJiHNvfjo3&3?UdfP$GFH6nCWv6fiY^!u_b~?3YRX*h6}hoD zU$u2Nn)2$g@KsjEBP?6lNyw)xp+fbG7x%1AqSD5eZ0Dwp*9NVznsfUv^k1`fuPCo6PUV*KRdY>?{eB=EsL5}K4?WL5s zEsyZFk&p3mBCsCS%EiW08W-}G)xQtx!6eDI5KKy2?u*M=y?1|Mx&&IvcppID_ws&N zf`F`>G1i(`|Gbi$>jNPy=Do{$c#vnetPiU&D_c1l4ZkUbwZa zNYDEqi2*9+Cl{bWkyCgZ^VPp%0?woC*i+8irW_+Y`ydxZD|)wO$-O$ zy?@79`*)f)7Vin;Pf$Tel3o%_VU(tzTOfGL0-nHx7I!)9FK!*eJdo1FIfS}qq1;;>hYBs@Z%v%fC5BA&M3KcCwC?KQisCiqMboB#FKIrMGF zSMUja!HW$T2WtLc{PF9>Dt(=DYDs!#0%^U8kVy@Kyw!Q>(v9FT`j#=U;(cVIh^d+9uf zY<|(!&T$Tzm|oE1DcgtWVYU~s0gj`5K|0oifeBwkC?(~+s3nn-IyzW5Lp=>MW30jQ z>O&8sBrQFn@ey%BtS~??WTKDz5UnOu8?)efNm;MdYCZ}>0m}w-1J$q75oJn`v%Z9( zedHkRtEGfr`d*SKGL_9mKa)9tFEwH9yJFy%iIqV&-ckKjOrpep%i#oB5<8EGQc5mA zYu_%7l|zGc^swRvHapFCULkTcuoU;;+*>~G*fe|_X;*UPLX+B7c$G&SaCo2{$JPs- znuJC#dLskT))zWL##j6H$qGwYzRA~OXhidyq&;)d^5;#{dV;xf=Tp?}y?`hjy#eNR zI!GdWj#^(z18Q=fNY$U=AJJ6{eLBQuOwDkOb&p45PX#w>7m%Y$wV8v40w6~IqPvVU zYBX?kf4iBm2deM!=Brt-HJ|_7OwHEW9|AU@mkP*|~apZ>KQ- zsj>3Z!p~nr572S4bKdPmmzPYNmrP0R#-aBzki$i{f>0dW5gNMtgG9$0;#Yp1J?}QX zBwdyV@+XVGglTrnNDl*0w%e)x?0|Q6{HWB5W}NJu=ZbYbGdVYrqVo}NUfaQHkpyhBU1qO{AXje%0VqkH?TKcTK`UFcpdP~oOa#P2C zv8}eIQbITubbKU}Ki4-RfskEkk1k5S+hgKf^c@3nWzHs*yNBvLMV`jGj|Y z3ILbPk#FF2Fonvk%hzV5oO7U%Uvm)*(+!QT=p)42SG)XV9u>H>XOYp3S8>hh85#XD zF-OKY{D+L-BbG^qBG zQv5tz9t*AC@|$>cbgU+X;{xe>W@>dE>0wVPiHGgVz=?;<0Iw5Owmj zLMI9Qd<(eykk#pTfLN>Ue;Q1TY=-D5>d}L2g@-4zp|jrYqd%GL)hG-q`U2YfCc!ON zY;$i9hRssIxM%m4g17-h{+ypfb_~P!af7oTVS!pp-#KuKe7#$YMUkGaZPwyLXb4JL zg(Hpm&lr#qTVn5tx}z)auBVm}XfwsnTw)v6%9e8T4dG9Px~hTOjL)_rvpG*G90DKS z%1eBd%ouUi>_nR9Kn8Nf_m0_BuK!B3jTxd@>&@wyLCE0gc;>z*rgTR}Ab}p2)+gB3 z7e}0k+(o%r{Ba;xB|aet_(@fuSX_olnz*_Gng@!Akz7gg zb=w`At{u1ezLUbHmr#tz`XAG70!oBoT>jHA&E1mck@jlL@8SKEiaSXHpe@E6?3hZ( z2umk!q>L!o&}9xDA?h-mJ~q^N6Oko1X<1bGN8R1!d<=Xk(slSZ-q2O@TDqYFfmlrn z0UyujkWT&Z;hjHIqU&yqCg)|Iec0aN_=GN(> z?_YWHt;e@#(kWqqQDG00@5MQjZ%i9~5Tea2CyUJPmX1Fm)eFFm!Jx{o8*?lO8=Q9H z>R5K5`$HOE_-bUMj3QN$fbwm->_Y<^@9wBvjNEUXDQX3HH;b)IEP(61)-yW~HT6`_ z4)eMZk-J#EFQ8fqQJL+(X)%P3oQ0#OZQBYsQKzH1={a0@nS^^Mc3m{j`;%30bGxx^ z9Z6AX(Ven&k7oweQKdvo=H*6Ri))tR0{E%mD<4y$5f3hqL4K7`G`0n;oh8THZ(Ys= z^n-`y7O@DR%f|vV1TR@e){1R~O)%?QUxU!qU#vXNjbLjQur(u?jnYqc(?9fo*Z(Xk zmGFnQwS9AAPa{94T)#!&_bW9dk}VLyn*(KU)Ront2U-z?){+p+yKOTwR7I1ivoD^DhPIo7DtPu|AES$^;IknZbzpTF+DJ9pxg! z6-wM6J^nOVH{FG}Y&)#-PNDgVzm1-xkMp{n$CcLUN#^J5DqtU@_ktae*9PB5MChGJ zrIlfbvD~rktrvZ`ffYG$pa=M}z7Fh#NTu zE!U#Enh3{QR&eOgak<6ke8LAhD(T<4U4tNc*rP{#JKaKEMVNDI3~ju^#d{!HHKT#K zoIaf=gqNz{DR|5gkI?9P@t5xFNw?4r)8f>^qo9%kIxqQ)h{n1K9Y%@t0z#> zhVjpw`ivQo%FP_bQvcsw07&lRveV*`ubcq~kRxsFUYfxiy9uMxCeOp%qC3jK&lW7c zpNV*rs?nN2FMRsJkv;ji*MfxpuL$$pY(4_5b2EQR4XmAf)0Q&ZCKnp~(TgFi+q`#v zZZQ*+))YYtzHlmC0{hNPHIBRi$Ld|(z+W5Vz|G<*^c%s1H8anj^eeSvFkGR-!0R_$A89R8>h5`AF#l@Dk42{Y(~s(N11@ZtovOM@_}_ICfX zBsc+A#)->6z|Tv5bu@C#g1cX-6EWRQs1vtj=n_YYl8Er1aiD4 zZoHnSd`y?YCM8ShgWI{E4QA0+X5 zVG$(+Gjiz3$d;KKg_2Y=5>v9S`e4^!oN>*?=q}79lFxqR4C-VJN>F&y%QdW>msI{dPhve+;^LawceQ@L!mf2G_uSQhdm?6yq0|W#KV}O{1FoAd1wyeRpLdEu??hj8rtS%~_U<-n zeBsrrKW0@b@2R%$Jx{T|1gB?a4$35NM%iy-4+G_=j0(^4po0;YaNs8f*d6Ev6Y!C< zLz_B1mK1uGZbm%=h{PoC zxl{U4e7+siCMLVrA8LiFVnNLA^|Lj#fcnrUW0KQN-bN{g36J>V(eq=G&)ajP0@CYS zP9)RQ(}BA;srMFxL>k$eZnVM!i+x{l#${YxqRX8R*OGo62{M|Xp|unbX6-#)`<8D1 z4Q5w2yWGTA7G~KWR{2Ot-biwTv*GaTdpQq1#sA0Nd%rdHd{LvJcj+DJ0!mk^^bXQ3 zfC7mS0R^RZNU%_)3kXOP6p$jJh|~}|NJo$kp@*JOLrKD$&-cCekGRise>>02DeKJa znX~quHT!(6GbI3Tw}p-!XBdq>td#i3Zd&7f`w3gmWiH}@?Uk%p#O|#Ny-ZF(&hPrF zmE<~k;=*UgG@+L|wT23D`UZ;9B9J_KG(l`E-zq!jv%H|1L+sL*010WuYeKSfa@Fg| z!=ktXx}js%@57Mr<_3ZH*9CB8#!s(XcGa{Heh0&q@aA&tLBo&Y(wm74)HEV-06H)? zJv~>oTrvpoJI;74r|J@X#DW!Wzb@2pbn`{A2yS}xg+pUKFpdxk`3N?}6k5l1Xp|PNljGP;a<4^~tXIwM>!5!Z!q{sF6}_;y$- zsJVID1A7NIECY=@#fK2KP8(QX7Rw_GLVN!*kp%Coi=xB90}C^Rls;KUc1>K9y;P91 zEdnTUx$D|-j9yRKNZFvs7CAW!vTB@JfsHS5a=ki?A#(?qjqZ>9(n9gk*~+{eE~rI? zZ*?L1w9fxT3?0=P7Bvf8fqUCletrkxH~+)ZpC1u-ip1j?uF&S^G!)P&a8F+uROh_w zFFZvo`ZEK6gygNEeaont&))teJ^yuDc2~8)y1cA<1SY%#1$^eDkybu{P$2!Xh_5XY z4AQj;{z#gO1}&84ZYtk-g#W$P&J7TbF3X;EY&zSIs(1l6@{(uYSgx+FZr8%2w0dGR zaj8TgZO#{t0aoA1su!;`%JJ9HPb!ePpqHuMpz($&O`hEf;F~r8Da^(K{k*LTibI zaN<#N(cJfF?k9di^{B@q2}!`w4tiAW>`f2H&rI2FZ6pE30%}0=V{_Ams|W_F5kS_L z#k`Q~hAPn11z{i`b7-kPJc`@93L#vqR9!<;xUM$6RG3zi8E3Z-gCfY_mXOc5c+hNi zpOf)fqH@Z?)h5X zppQkw%_G<`KB(`;Bo{(!l%1}-L9ji=;=c zZAj}l1UAR^G6wbF&%3(-u@cv<(JT#>rLNWYHh4)!Dzrwy`MSViyukUbe8M-&{ZsEV zEnzeKIiqH6Qv`SHod+{wy{ymn7S3YiPwSD$zc zbok%K{EIn^(X7kV^z_9~B*9S=BM_0D3Dj(u?h1Lqb>)XHIxSuLHQm*y{AH<)F<*m4 z4@Z8;sZKe%PSm6wSZc&H~VIYOM0+X+f62+GuNFg6;5! ze!{2DfB73-psl0M4EFokW7|zL1JmPwbRQLXu54`-VhwDJB~B_hPHwxeOov}5sajFC zd-@aXo15fLJnqm&cDA``T~k2Ul%erR!XhKlpnp&6g?_I#N|w7IL)K4s&8U8Gg1()x zwrr&{t?SLWObFsqcc%B#Is$&aUO}`ss{h(&uci?dGg~ut{(*4mqJ_S4uCR(wi%P2_ zTGXlZ2wy#~@Qtf?ehun~t0CbA+kqWu*)cxk=Fuo^Kdlik^3}U0GL-rVrxWzL-5q*| z5QJ*y0=$|NHkl{{*46#ih0SyCC}xa{jnZjio7~PnBtvqO!`Ht#wE4QgVoqKM;dbH;K*IHk@WePl-T(%xY=c0FMTRY;J6w_Blj|%Wk=j{VBAK!U_2qyxiWU9)`3o zhXX?hUoQw@8$^uzaVs&`ndk+cOCr2A+y$Nb@gC};TfAVCwE!pbX6YVzFJgc3yH~h7 zET%?rKI(!h64cta>^y^XZtc6|`_OgyJ~B7X3nHEe3#Vub%n%(}V_a$*vltkQU?XJ9 zFA<`Z+#akTWA2_GnV*l<4K4ux*p03(5a>g*zwx55ZU(V{^^nRVtkcJW+?J^ zeK_H2Wtj^AX4Qk|`z4)b6c+*?sh%&i2E^C7f!3oqM~;T-K+D-~`5V3F`vUT2MAB{+ zj%+}m$|hNn6*{|=hiQy0Q z1lKPuhZ=(CJ$hCACJ*da(CeSI!mGMaqp;{FR|B-yt+8-i*8)K*rV{fa>f2Vukyj+n z)DqNl-KHu>sr~79bSQjej@Bac@(JbEX{eNpjPLdldzC}eKt_dalkdhIP8o;ws5-YM z$khmt@lac2N)FG8)_c}vj@6L(YbB8Cj@opBI%grNW;vNFN2@i1aZ_q6Xp>fvj?~NC z&!Z+umaA=^Ej44ZQj7Nb zqo97j{@mn5$om;ihL!1*Yi|`f%C|S0NxmKvu&kiVBR?DtaTOJF2Cb;5P(k${=k^re z`uzE`-Lq#U6XRb};4g43Z5iujw)>}Yr1Ko7Zs}g>>yQ=0i7W zKkAn2Y9*I?8!V2GzO#izNbqryJ)fa?yI7x52(%25;KWTNr zllrk<*`Lu;jRH@?aZN5Z_p84AwcWf_dzyzDr4^E;tu4=!tx5E{*46yFs%3T~lH_@1 zPu5D6!}#t_k=DmrFJq~D5yvpyySM<&z|N8kT(9lbVAIuxI)PB6DYwpru2!k^lW!1c zB}}@BYVUmLFJ%)Q89#fYaZWY)w&;~g%%4Z! zhPb36ZGaIg?JE^g;lGbAb$YIKRGEmjM2`itg(P9WT-&DOJG()bN_(uO0tY#s9IG*j zOKl2tm*E@xbo7spIf>y%cF~8g>5s1}&(0w_gKm5y(--BZbn5?FA|d4Kp3-&UxQiPF z5ia%a$d-KOXIR(5R7+WvdoP zwfa;`S56BIV}}l`XBvZ+cw>qO^OH3ix{pGE(n}MAQJx$huR;wRek;=vtw1*_V*k{w zunx4h0TQzYNT1B#x)Dtxw@%5qgO?o86f()W%!!be@@j}z(8FbrSVf;ly^*{~KJD?I5>4w;oEo}Sa<2#MC5Ci|z zH11ntKAh``k8Zo<0}sIBT9)}OjiCZQ?#NzE!q=O4eoCrN07fqfK(AKxRbl`ye@ccl zIhy&i*mUkHL@>`e)we`cu3{;fxnIt#IVWjtp5?_KW8GubE@MWf87ZEE`y4- z-!RWJZ7m6l^DhY4olml_=oHXIuJsI7)RZG__rrF*5#MYl4WJ(ats?2~bQe($wO=tep#c8fzNmIm$TWm;n z9%s#4f<3hk^CRE?9yC7uZjdbRqdp7o5O;DLH0{JWmV~U2=k0=q4E$e=98rHF~Io>7epLqWA`EL-eq; zmM(N=Y`ctvS$^S=m5bF|=iPLJQm|9qA6hY1B z#THxQ`wNcV4X=BSB4K8J_Vcp^#p84hXCmiHRP{M&8%4U4xVx_jq}7aa&1%mX~VdvRzkBBT&a7@ed)8+tpciW1RZPOQzL^^rbfOLj$QT zzh4d(t9&u>>`PMa z5yij`K|T7iLH3C5Dm~^8i9A4XHz39xkYH(E=YQ2ZR#hr7u$UU>QxdTXQKLh>xo4@R z{yKjG%?fcJ&+MJlSYt3@gGd%*Vq7(745$xVTJHin`3Si_Ak5qWd;Sg(^%5G^AB0-* zaT!0N8vW^eXc*%u=6{bEn0!K30Oao^Xi$ZSz0eewjck2Bu>(a@AB(rrUPD)jBzY}u zLa$|Mrdz?}%O|(lv8pnOfipAw7}oj5=o?787t<)-;-zJgm4|_z zQ>%tc3cz%oTbJ(pVy@N8)YK-A^w0;0{|a z@}+1a8s16;tw)vGvavO#sOhb!hYVrW3Mi71jM3OG&7TKI0ph1a5PcdQs==I023_ZIgR{CNY7>KOI;F%?K*Cl1+Jw^Zt<>PKIohn4N?g^wE#!8HK$tI&_w6} zwP8#jg*na@k8x0NzQE)z5=KPwwT>5Am!t9N5c~F?8`#|YRf>DIj@h< z^iQ?d(5BUVLHxv7`A9em-IeYZ;`U`(kX=DfuSN&J7*6*OCj;(<9nOtB89Cv|lI6#< z>v4Y8uq==cUK#j`QP#!RK)enHibHov5uM*c2u4|CV8BW9%_Aw^o$|NNQht3FzH0+f zg^rJ`(-Y0E`>|lR63Jq&y%xl)m&h=EXH%d+$u2iKwQ)7S@qVjUD@`GOYdfYaY(5@p z_r3J_ozY-(G!Jas5oH`GKFO-SDpk3T8#h%Q1?@eifaLMjN=4#Zq8Ojxn?#Z`@CMAZ zIESapC<=6~3Ji3NOBSZ;1rT>8IyzsDtrPoYoYMg;y8G(0mM zVIE!x!l3!*GPe%h{yD4ns#XRkh=<~eQ6L$3PG3ImVzUTM*9++d!*g1H2W3pa?Q782 z?d<|Oo|U2bn)4eMrg@@8#06u z8z_$G!}#0SMtcGPd>W_ZnIKKmR1cl6MGK6){*Rn3pst_F1PDOUXmniD<(*%Mj=|FM z1?A!JwjetnE?Mg>N8D}au%+eGOw)a_HtORO-h4u%q{;+*prV$-!ZpY^{IJ?TxHC_(m91QfNoM1n!AQK?~dYyN}mU6mbykRtyHCnYeYa z)s#~ud>VLi+fl^#MG|%ws@J#9UH-M{7Z+TPqs8;G6XN$2d@q(nmW25esK}_f4Y%m_ z?PYyN+WH!O|16Gu&{C(=6FjZ)VKk6fKUOUp%<`tNd@&u;8nl)QTNdaRYIWW`U+;r? zpH7A5B0!txT?66p_tjRGw{gSycngVvd_0k7Pm>}(bQ2xUT)#>g>AQ+8!wN-LFJ6a# z0+;5$SUFOyCt-7Wn@0=v+gX}XZc99bh0*E0JFKBAsNkGqKC?{=*Ak@i1zF~LgHP0Emgxc!zUzyeTxN@~x)@=7_G) zwsEX#+WN_v8mv?64xb9y*XgIFb+4kx5x$#DvE(A{O^zXq2D6s0Z=F@wFPnxq zu!VnMoY&MzkUQ=|-+Ge_4(IrC7)(?WK6jXn(USTNmT;C_THfGA8-w))e=Rd);5SIs zX(-B$`34U#FMhX!FU&WOKiGGNsb#O4?oX&I2Y#yhwH-H>mY)Cpg>%1X2OKq8cNuYg z7cP}NapArK(;#$+Y2Lw6<&n7o08)#<8AUR{bKlU|K>Z8`GdexJa{tKUonOl0qeqgX#dhPUJOs?4NS%uF)r&&r4 zikFroK)UKjzInkoFO6xyf;<+V1a?fSNbD_&f8;Q~c41aewz; z#oJ9>ccrqwpP1<$L@n6)*02F0Rum1#A%KqEcZINlFvmCVDK=qSQ=-q z?@E9KuF9xcEJXb0WD47^{JQMSOhW0}p8Z=c&}Cv@<1JlGp>i|h{W zMPAgKwe65k1-W;+8qVS1IMZCJ@|NihI#vtIXdt4NldZ#ft+H&**Je?-f z>sNf9A}8%7s#hIyZTgImdo_$s(6S;D4!PcFOdEMegIJpwzehWlp0nusC4=3Xd~Zrg z=THz-M0%o&qkB_5gt&`;Xk9XQh*Nd3<0no!M|Cr+7>i) z#(thloE2Bo+0e%^^ojje0Q@*BR??^LMP5EJ;7fcex0rKR;qC!K!IKFJeW z{9}}?7Wj#CPCU2@&s8B-EKbvu#dToWPws1?cMB0I7qOyOk6^Vtc+-!@MqSdWYf?vq zONSd7$MS^=T|DwCIZbL9zv!|gxr6&Q@J<IStgxAxHmXD*BK0OUD$`gdu8Klm#o-U&e| z8h12!x|ZJZP!UFIx-U55NIO?e>Lv3gEpBGOpBL4YU{Dk)(h7)ae$2j!doHjDk-ss|J zX3fFG^^f@jTb8?#3y}*@CY(a7CQB=Xwfur^g8JBKT(*0;nEp5h7J!JuC_~=;&#qIVbVkOy7-{{u5-hsd7 z%^vaNyPU5A8UJ4`K#>xuncZR~@_TH=&l`N2Qr%88y>FB0{HRqjm%pXH_2MF*G|=pQ zzr6EOm)j?WB&rML2ViqxCoXFERvAP%|WuIWn>%8?D`k!`u2;+nyjFg$>FF0b;(cV*IUZ67$BAPD zA{beg=xircfqZJcvnQgvO0?=5xp>j&(+{Wu@Smavfk>^#$7(ey;C}fPFaD1BZ%oc6 zE~mV;#h5T^gxS4EX83+=&50~we`$fuWt5wS4DfaS>Dp$!DWB@$j8#=b@ww?6a=V%u z8d}k*@q8X|=%uKp*1+YC@UCQhtZX@$9F%8Yd<3zZ>}uKSQY*xaQ+wY)eXTA`jR{&& zN2RI2#d&ByrN#EORNAo08dq{l2y{`g?37JH{{D*~3*@@@n*TS5Av%ozpIGM&{3<^X z30%&Z$B%!z#QAzcLd=*#LG}L_`0=i~jP#M1MB@a?3@4mj6DahIWu&eboS2x*A|O6cxUCrA!p*4rq6A8ELe`5l#FJR`NeH+l!I>q?3bmFWHz-Ezs+@d~fx!`5fW980&qF@8kEMMT@Yr+UI+{M4sVc#c}hJS$&4K?9m~ za7jBmclW8+&j(Zrvgo4VbbM2u?E5V>iPj!9%avOv2Z8yWs(dc#+8CKg@(EFDlb0aA zwr%+6yN9@M?0*&Lc@)W4KE$AMc&X?2;|2OIu9Qhz5kZAZH)j3ou2al`Tf0J9f_wAP z$L5d+1?_ak7jAZVnT+x5xg1WO6+-R&l9;Z5?xp6)u-E3nnnSqW$=2GpU>4TRE#6nL z@Y~4@#Gw{-XwzACp(MT;Ma&)7^@wKeRU00{uM_I5l9|6M3GQq9G?d26TC&qaOIQ$n z!GAaB6S&Lrfmg@(gcX{*ZB6fo+f|ET73kl+FN$4k%}u--kkGN~Q#04lL`Ql-8J(U4 zJPM;It`K~mDICl=+!P}m3)`^#@r~*hZ!Y{{|K%C7AR)7G->F)s+f*qbf>kzRIk%)5 zyRo&_BTCySiyG4l01dpf3^Ajg&AbYCk>aXZi5Rfj*fytM6DbZ7fJdah4DYRmutDPH zO%(Vm%eu}x=BYe1Y(yiE?4#5Cq%DS?CJ8)HQI|1Gz99K#+=CDe%8#EFmE~9MlKi>m zU%f82j=5*nzmfUqJfZld%~2*hZhU@}w+TNtkL9hnfGCV9(;X{I54Svbb;}AzVyln* z5OO%)Z+jkwBLF~S0G?Jjdb_C$3rW1sxXTOL)WLn27ErGc)hy{vwG*mOF%ri{Gfdj3*<=t$(X%^%35qMrT_fIoVf-o{E=T3Jtu?DX7>S}lF)%`3iG{Z|8$qhvfwQO1z)kmOc+mHvmr&!|iH z_UOu`0@$`~x31)gY5fe>%4Ttm!}>RWl<(y9WfYVV;MA~;!ca^6)x%2$v4u2p#d`46Ed*_>Mddq`jx(O8>NSYLwYGtrw%|vjhBb=4Eqqb#-{Q zQsqR*lRGm9S)#W`$HF#EyP7oyMB0ss^h+7$NcwJD59>-!o~#rMenQ^fU2K~8leR)P zX>#Ij(qwe_t3j2&`Y(A$bCTU!*UUy9hkfG=c)4)g%@>ZZ5VzYO_9nQFG)3{tI_{7E zIg-G!L?Kg+zI~Nocz5T)vfl7pNn;kqo;yo)!W^=cK0d+?vIsLXYDwd!0_fUTGgcLl zm#rdsTfP@@8&!F%b!U;(lX9WjS1*0zWn~KmYp|GzUrxxkhhuDE1@!z2a)6!f>?iNU ziCHJw3KUv{%Aci3;7Elz5{yz*=tPyK;-UeahN+x1OA*^0;PQ_+tNv-~ZKFbEN-0W@ zuKLbhAJ4Y>Al2_}=tPUI8ZfQ9wXIvX4U-mM%mlJr+&w zK^9^meT1r6sa7AW+U2fC2KJ<3$X1Zj#f#s(Z%X-bnMn*8L`uxxA&*HwhL>+b? zqCm)v0HdJaAD3T9q7ubd62W?hZ?O6!6&!IdAQYI_p=G7fy6u1uMZ<_VQ<_ zw$y`(f?Ht+dsXn5+6b-?Vg%t$NNNS@UQ!;EU8!kMdUo=vly>o%yq%Az(nmyqJgF7t zw|M*&$Mvvr;>u(tYO)fem@Llb`DNb28!}s6J-N2C|30cXK?#82c`#c4Go8_mBT%>+0a6Z!+7BDk&OS z$Kj9z-{g%`nWwievrAEe$wdXHacv@!6P`CdT1KS)@d~Fpnt7F5<21YIsw2QJuKRS% zmZytxVjjsNLeyb^Rz|K*k}>c+gWDNKoQeS9#Z$$q)+16Q3(bz1?)6Qo4B+#NUq=K8 zoXFD%>0or5)nucZo|!(v25VcLS*lhSt(- z9;@&|i%~-sBM$#n}5KpqsmidnXFY3_kjp-tbH`J32#kBu2qn+TR zy_krX+4eltQ(VtXIv3Ji0{?oyEb8&+brp{+qsj7YlGcaD=M*onYREf$(rZ15qR2oMYQfqTjnOUmg>3T7iCt<0>WV|b@caXe(wJu51T&B(+ z-JAc}Ct;0;-oDi!uVhX#dzNIz(YRYs4l*uD@#jH)(S8s#e)UiS%_COnl0J<`(p@K6 z;M$D8Oa3wtjg^PoGNysKbld8%?|n^wzc_7wu&jeZv!yuxogFRz@uEj#(cuX;YdVlwC6W(N_7(VF>wM0==t_9$87Z%+?!+i3tPr+2L<1nNLZR^Q0y ztXKrnJNF%K(~C+QyM0xp`NNi_^s9s^O-cRIN|{Oh`r%McJt?JkV!F0sb`^g-z5lK6 z$TUvDfSa!3X4lzd$TVTzhTSYZ`n5-l#T*cOUP^g-0U)nkosPC>F#0FCSRYG?_?+4A zsJPEgMHFjq1|QGMj~^v8JnVhe)9&Bw`&eGK&~H2FW^#2(f=^jMZsuRsg@(0QdEX zmlNJL!tFnkU~vb${vgRmq-?Gshe}3C1+K5d!{^s3m}4OsH)mzXnGpZ|v;TZmRnP(qvky|6Z+%wp^7Gcv?`Tn)VIShC zkOvySPO8xz)LoVCJu`N`5B3<)O4OWKl_{{Am2749>=(`aD?-fp-n8^Zq^sK^jD|bK zlm)d+ry5;98vZULUU}{DGu!w!NnWpis`!?;Hu2wmKU}LLkr^4F%K+Ew6MLq4Id^Q$ z7USIu4W!{&-{>b$Ywfh2bs&i%AW$1tHmAA60`z&KC^D5Q}_eLM_eZc`A5s^04vh?xSJmann z!+#Q;aiTzA{9-tH@6;+?=o@9b#_GrS_H?#(&G7a@;$X0&Bvz8E7+2?YyOx}YQf z(NBfauu4mxKO~BX<6SyNd{y&S9(v?cs2RD62A_W-M=w3tD=wG>YAVrc6f#?WyUJ&^ zM9)bd;5q~zvGJoLNd+{Z$=DG3L#?bWWuG99ZQLeWp0J3X{dZNLV+OqjeaB{xu2sqW z4^R;ikQJdOUON@F&;*UDS5wZ`3Qcdim~F(#sEGK)0XaWkNG;=Tt}rnErmZRBdr3>L zoJLdi{n)`|voUvM;+_( zk72(>GrwZR(To>L>sy1)8b!-S$(o$;T@Q^N3_0Uj1@v%2ztF1>o1JR%ycxXA{w2b$ z_WUZ zL@n?@iWS*1@JA%5{i-q6$c{#K^qwcOvG?*X!;RtLyINZDiqfx{F98nrchw{AZJeq( zkQ-ay;30mREjJs#nE9z@qYUpGjZ#yHMPlUFPf&**9m!4M?~MxL8Stmfv|yj57|kn| ztG&LSz}Cp6OAGRk{PD!>qc5tqh$2Ncyxs2E*CMU{%NRn+AvT z!-}n+4Px4`9n_e&$~Vw@NC;zP1ZK@&R{AynoUFmM%mt20Sk?}oaXoN?yj*btT@*U? z_GJ-ju(juwO9&ARF`tNA=V!EOAHP^caoerW&n zwCTp@PBarO&A$IlG1>^~-LO=NBhr!|HZx@L${i9CpKYFA{A0A)efXMVPGDw%y}96= zwHJY3B+9Fh8$oj#e__yj`$4T42Yp&_B2s=P)zkBe`l2;{3AH~N=Fowj=dT^66io!> z$7)lanMy_lI&+zRUA${%`CCO@d87q7wmK|M_uBzwV10JG>3HGkhbWi9PUqfYd)4X8 z`|DB3gI09e$Bmt7Y9Q>e>{m>Z6yq@}p41oJIoVQsktEJB7TW3;aGa+ii^%J8BP)sC{3R0^YqzW%h?5cIF$g@3Mwz=c7zN7Q>;wCM_V z+2LjNTI|n2({XI^mNJD_@}rZ1i}S>$i&R$`5CzCOFuDYA@L|*4fB3Ps9a5tTFQeE4%Nc%nlTw`i29b_MNHWHI@bR_%{UeR?H!gjvEo`K5mqwvwSh0hpHH*qbMe(^v% z_iL#OP;Wo79G2{BEnD_tnPP-Ve)LtI1T^Ae2UoT$>6`_7Q^+beyu%!^l18A>A`V4g<4#~>vu)e)cneZA7fsc5p&6@{Lj%3{ z9R%fI4>#bx|An;ClJXJBa43Mf$wX3hL|jEcm~7FOs`}Qq-J^=}bMm-G+U@#m}AO z!A@mgGf_{d+az>zc7(AiXuTh8Otk1bPC>-D8KZ}ChBG>^6r<^x*_YmQmBQlP%>?B> zfJLhF8J%D%o}3JTbBdPwS8T7ws@sb8DBS_-0CSOcGQ-Y~XCnq-+T>u`*18qb8QLjg z%7bq9bptv2S}u9xqnBLNGv5Uj-`D4NN3Q)PXJP)VrZIx-H-4?+{%4i>R992#yFxgv2+g79=J_+Cw&S9teY>{OAhMG&=@ROY^(+*iBHBsa) zDcGB0Ont#Cl41RvUbMH@$T*>oix-%da#y0^XMK&9j$^Khs;jEf{A#~{CyvdmK5AP& z%U$hRTr_=PyoKk}QNCSAyM6Y`Kn+t5Cr$jSduGqSgh~m&^?J^O+XihvY^o%?j)%c{ zvgPVL&zL0w8xpx|hfJ3C$V-lI@lerS*NY|tX3vUlDJkHT$&EJkoKb*Yx?SQ@{)4Nlz~X+N{4mGX)&jzVwGzaRIMg6(V3AK9>#!@ z9B3m>szMXzGL@ZQSvZ+KHP)RZ@fD{Mlcm2sR-w7R^^mrXB<+J8=U>XFOri2()Gcon zWxjKX8nV!5y<{`a1~SS0R+2E|z9rEBuYPftiUwi)3KR$XzuEks3vPCVw8cCKd zvR>*}?he>v`4Fw$ zWO+wVaPJUM^KvXskQ}V79vf_y8kzpSqLk~4x;}L|ioa6cD*BQHOsoIh{n6_IO^OZG zUU14CM{NcEgtBDChrG0>ZSSLQ$+3J^;z09mCq9>oos>?~yVNuOIp)bdS2kuLU1nmu z=HGkxnE^YXt;R6_Sx(YfoGq%&6h=$9%PJ}6{F_=cQj?_l5`;YA5VKXLEwu&n;8O_N((WZl#x z){#Ge@#F9Q3a0!O1W=!nWi0c^g~XC7B$9%q^a~l!-gRZAq%uUsRtfFBkA0pf$dX9s zF7h?>I{0UKRY#;eugKfous0a_g-CBlW_!p;V+%ToVdVMDYePMx`l04VYFij6E;l)k zdl0n|q8W@bmuF0>pX}8+=|431Ch1ri=w>xDh^^>4(RmHl;SV@RG#hd+!8W_DG%c4* z{a)8A3nY9p+{!O8Vq8L{hXf;WL;WSXjCamH-o5jK1jBS_uTDexP9IkAAou$cagj#b zj60Stzm+XuPH^bH1RNtwTL(Lfx+IP7_;5Q4!FDA~{p&V114ZquE?koTk13I=JI3eY z{)4exnw3H9>mP9LSf=h_Osmidq-aUycQfShlmVq6EHey-q-+j+kTuZC70<8*0ZSJ= zX+!I7yK-amFU}vO_5A}SIX|XKM&M2KUXC!R_|sRIqo*_`hZ=G* z%ApG)G?&dt4!rzI+0I|<;tzPg+#|bn7i>jTLllWuC9y1<>UD^G64Z21IT5E0p#W*3M0Vog}t zc@8;%lKu{8Kdr+ma4kg>UGR|HQSQc<@wdI#MzmY1Jk-rn*3^lUO{6f zf560*7j<}Ddlg@dHZu2e)2$2&deHT?$ubIPo&Ls(?0G@;N_>!BBmna;?)AkjM47c9 z>F^}UMvcbdv zJmg)4Cr74fhO2^iLOyMFwcnf-BdtFC9KzEObr;Sur*Mj}i5lkZd=@(VB%JvBC+)T* zas_?(jT+@8@o3alH=H>dZFO;t>3FP<`a68!bzQb`vv>}gZfU#}}yDc~CM%I8| zxq{)EUffAC?Edefe2wK{Lo)44ck;#FFiJ=fgHDQa>r{B9?q)#cLd*STt9-O0B(XgB zMqDRQvOcr*ej5vx0WJBj=gvDXC|6m7a;$p51Mt1a>+A-zw2A<60f2ccwO!(RM%{2zFhxznf)@32 z9)@u&9$SXa+H%4sHjbLg{!x}ERboDK5Bt`bsdsSoe4ZM&sD9DX`d#8a==TQf!Nl(%G4#l%{hKXbN45#w^cpje^D4?a z5{-4BD%~D*&2J_q-oA;9y?Y{XpHJ!Zglg^D9$8^d5z=nYn8Qo8c(TyYI^~KkmH2qX zWKH(Gd&cJFZ$+lBWtbdQ6xj`Qy9e39k*hPJu1!k~JfEv`;U5+c?m{6&g{xT~FAy6t z`3K3@6Cc}}ITQV`iBAJqlp=MuDqvf9$Z)qfXlA1D|6W#oPWqOF=^VF|^dC&(eLtwv5%rkCih%#U6*&`) z1%kwzrIO@-)i;!YJj`#qv2B1CVfTgqR}`27G(D2lQvk>STN@8n>cZ5-{%<+^Xz6I4 z+BAvr0EAL=W#vtn@*QG@%cNW{W-_}^C16!zxyyH77SyfUFs-MS#dUq?UN?99)Z)LZ zL%9B*9{Lz4S-j}(vl;h@SWpA!pX%tDPo&fyJx|^re)%6>>s!HuAY>^$1?U7PJ}90+ zQtf@P@I-xde>m&E>hW%`g*D}aN70=qm}6MY4$k8XEEYrj&4D_xMFxKO{-2(%J)Ft* zk3Tl1$YH5Si0LG6MkPO!6qCHvThfVo(UOv5<{TS2j79ZQ>di4NNk)_zW0O-cn^a~_ z6KTW`Lt`0xpUv<6J%2sd^?APE`}6sHzxRD#*KQ=Fyql5Y2_C^NQ<@p0R;@-!qDGqh8i^hg!saNJdGe z*`Er__>xj(c}qCS4WJexAwl4cNiHvXal}Ptrl!dAwAhOQ0P2SH*_oC?=`d|}LpCZ? zJ)XcT$~QWYle|}FHPe$5MgFmvAU+H_)XlAQ(x)#4wSbE{7!ZJX7bx#P?Q>fWz%S5g z6Na+nqXy;F=YhU}Ruo;SpZi51t8PW#Z@*}@24XdLEg(3Q;n6!_CT+UZirV+M>RY1O z-m7JPxe(yaoWyql_P^|gOBCeBF*Vbel`4S*6`QmER&qN>I_WyCON<*L$a0e zd>}zu4ts9MSz?nU&&oQY6~-_wquo^aw!C2R|L&#JR%Y8dVNr|eDgYi`ktZL}KbOB6 zXnl~=>LxCXwrn13Zy*whIpbXFnIZ7}Ezec5+IkWSk~TZLb81wFdUxSe;cK6q}!mk9m+t_U%6%q-D(19Tt^kA?xRL)o!LNosi4? zw$^6dWVHY<3sq~feOOIj$=8hZgz95;_u@v5l&!Y!B>_S5Ix&P#J~Wm%RSV1iFxypH z#$@WC65n%FN1|kU{gT9!qsqu-qSwXoI7482WR)-+k5rjhMdt;t1OrRtUt6zSgE8LT z;G9k4(-k%)haQ(r1fUv26O)LVNV3{Qyxl$kTxM>KE6m_jW*TW^DYrYOgVCaoL`{!_ zR!pKQNyZ=tWWahG7p}4--ThPd-drOYYD>%-v=O&YsR}=jl!@3w^pGsF%s558ILWQ? zcTPOTYA=uI7e;tkqF`Vl{$QOG=bY4-+bjuhuugpROtP1bq~0gVMg2%I0$ZNhRjqC= z2*_;Ol7b+l$e&B{+;0}_b$MNilg5CSrpf+O8c6g&#M<9x$jZ|#?yk;+zph*P7wy}i zv>~ahiFD;x^21r+KL_-5d(n{gt$!H<-{wSLBbeP#UB?b} zIQ;SRnzrj693Iam^E19l31H+}b7NlHPsBkTS#S_O3Om+JN%MGeS@ zhG#x01j1)(qv{PMYJZud+V3;~nEyE%i>P?ZxbMr#jxJ#9Y?h63hL? z(Lj0$mE^yXWyKJy&)K8VzD)+q?oCms`d7jseFebNu|2iDV>xCh`{ZUc8Vx~dMbdWU zv12qYN1V9}>@XH$#p>=@>wC%%j-b)ok5tE9&I{V{vD^Yu7o*8Qi=90A_{W+$di0*gSw6cyV^jf#{G z_i|JJ5gdG-Fd&{lI0n%X3v2^9P?F2_{aCFR4fanibXRp_{)DoWazOG|lyt%<9nH5| zg`G!slu#kSy$^3L4Z-{H4+<&?1cF%2%8;TWy5(>Hdyig{Vu+_3KTw_WFqPcN${BNJ zlAj(WrV1y|f3NQH7j|KQ(!ZwGV^#s+RDlAey-B1=yatboCQ)@2eKlU~b znC;1fn5>nmy{bP_v@2b&jbE9UkbV>OqY4lkw7q#%-G{-sv_cRF7u|YZhaOm>Lr_=S zBFjsmq4G0G8g^`meOy_Sb~0{54~g-rvj7~(P5WKs%hwGDgx^s%3J0rA$lzm`>6Q99%V*CRaKQjqxXR)l?v|@ScV8hD1+5leP5|5a$#?*skBK_J$-y18F0Q95RmKS%Zi!--+Ud$j5&jbY;1&0 zd#%(oW2|OH;-Im^2FxCaDfgXFD5`YWJ<;I@E!TdeiOL`loQn+^4*v;EOK0|)na;Qy zVe>m(ft9aG*bpbSc@uc>p2}F87dXwXL=;&CYH-u3Dj)g#zFw5LUVELxh~$@^&-`20 zLnX|T70TF+y{M0HxzWa8SL3e3Ju>gw=Lr|WCslM`J}d^ZpLTb5w_t*ONmP&Fp!;bO zqUZMVZP)|w`;QxxhH7=fj`kq+xGb3Qbld1n!-9-28XNIO71;4E%h4CXk*IWc%-rbF z&yy{P{wMey`5$I1(>r{`O5ZPxU4p{z8#1&`nu$$&P9;Mbu}m0~%a8E*_HB3Z?;O8M dlRdwR1y;Ds+HBXq6A1u#Ioh8 - - - - - - -Px - - - - - - - diff --git a/legacy/pxmodrim/ui/assets/logo_nobg_1.svg b/legacy/pxmodrim/ui/assets/logo_nobg_1.svg deleted file mode 100644 index c618163..0000000 --- a/legacy/pxmodrim/ui/assets/logo_nobg_1.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - -PxModRim - - - - - - diff --git a/legacy/pxmodrim/ui/assets/logo_nobg_2.svg b/legacy/pxmodrim/ui/assets/logo_nobg_2.svg deleted file mode 100644 index 5b099d5..0000000 --- a/legacy/pxmodrim/ui/assets/logo_nobg_2.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - -Px -ModRim - - - - - - diff --git a/legacy/pxmodrim/ui/assets/logo_nobg_3.svg b/legacy/pxmodrim/ui/assets/logo_nobg_3.svg deleted file mode 100644 index e7e3f79..0000000 --- a/legacy/pxmodrim/ui/assets/logo_nobg_3.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - -PxModRim - - - - - - - - diff --git a/legacy/pxmodrim/ui/assets/logo_nobg_4.svg b/legacy/pxmodrim/ui/assets/logo_nobg_4.svg deleted file mode 100644 index 3d7fcb4..0000000 --- a/legacy/pxmodrim/ui/assets/logo_nobg_4.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - -Px -ModRim - - - - - - diff --git a/legacy/pxmodrim/ui/components/FilterSidebar.qml b/legacy/pxmodrim/ui/components/FilterSidebar.qml deleted file mode 100644 index 47c90c6..0000000 --- a/legacy/pxmodrim/ui/components/FilterSidebar.qml +++ /dev/null @@ -1,127 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "controls" - -// Shared filter list (Mods and Organizer). The model exposes: filterLabel, count, -// iconName, sectionName and optionally iconColor, badgeBg, badgeFg. -Rectangle { - id: root - - property var model: null - property alias currentIndex: filters.currentIndex - signal activated(int index) - - color: Theme.elevate2 - - ListView { - id: filters - objectName: "listView" - anchors.fill: parent - anchors.margins: 12 - anchors.topMargin: 8 - clip: true - spacing: 2 - model: root.model - currentIndex: 0 - section.property: "sectionName" - section.delegate: Item { - width: filters.width - height: 26 - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 1 - color: Theme.border - } - Text { - x: 12 - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: -1 - text: section - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Bold - font.capitalization: Font.AllUppercase - font.letterSpacing: 0.5 - } - } - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - - delegate: Rectangle { - id: row - readonly property bool selected: filters.currentIndex === index - width: filters.width - height: 36 - radius: Theme.radiusMd - color: selected ? Theme.elevate4 - : (mouse.containsMouse ? Theme.elevate3 : "transparent") - Rectangle { - visible: row.selected - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - width: 3 - height: 18 - radius: Theme.radiusXs - color: Theme.primary - } - RowLayout { - anchors.fill: parent - anchors.leftMargin: row.selected ? 13 : 16 - anchors.rightMargin: 10 - spacing: 8 - Image { - source: "image://icons/" + (model.iconName || "folder") + "?color=" - + encodeURIComponent(row.selected ? Theme.primary - : (model.iconColor || Theme.textDim)) - sourceSize.width: 14 - sourceSize.height: 14 - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - fillMode: Image.PreserveAspectFit - horizontalAlignment: Image.AlignHCenter - verticalAlignment: Image.AlignVCenter - } - Text { - text: model.filterLabel || "" - Layout.fillWidth: true - color: row.selected ? Theme.primary : Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: row.selected ? Font.Medium : Font.Normal - elide: Text.ElideRight - maximumLineCount: 1 - } - Rectangle { - visible: (model.count || 0) > 0 - Layout.preferredWidth: countText.contentWidth + 12 - Layout.preferredHeight: 20 - radius: Theme.radiusPill - color: model.badgeBg || Theme.elevate4 - Text { - id: countText - anchors.centerIn: parent - text: String(model.count || 0) - color: model.badgeFg || Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Medium - } - } - } - MouseArea { - id: mouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - filters.currentIndex = index - root.activated(index) - } - } - } - } -} diff --git a/legacy/pxmodrim/ui/components/Header.qml b/legacy/pxmodrim/ui/components/Header.qml deleted file mode 100644 index 266e6f0..0000000 --- a/legacy/pxmodrim/ui/components/Header.qml +++ /dev/null @@ -1,368 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "controls" - -Rectangle { - id: root - height: 80 - color: Theme.elevate1 - property var controller: headerController - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - anchors.bottomMargin: 8 - spacing: 12 - - // ── Logo (easter egg: poke it) ── - Item { - id: logoBox - z: 10 - Layout.preferredWidth: 128 - Layout.fillHeight: true - - property int pokes: 0 - property string currentLogo: Theme.logoFileDataUri - - function swapLogo() { - var others = Theme.logoVariants.filter(function (u) { return u !== currentLogo }) - if (others.length > 0) - currentLogo = others[Math.floor(Math.random() * others.length)] - } - readonly property var quips: [ - "Oh, hello.", "Ow.", "Hey!", "Stop poking me.", - "Load order: sorted.", "Nothing to see here." - ] - - Timer { - id: pokeReset - interval: 2500 - onTriggered: logoBox.pokes = 0 - } - - Image { - id: logo - anchors.fill: parent - source: logoBox.currentLogo - fillMode: Image.PreserveAspectFit - scale: logoArea.pressed ? 0.92 : logoArea.containsMouse ? 1.08 : 1.0 - Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } - - transform: Rotation { - id: flip - origin.x: logo.width / 2 - origin.y: logo.height / 2 - axis { x: 0; y: 1; z: 0 } - angle: 0 - } - - SequentialAnimation { - id: spin - NumberAnimation { - target: flip - property: "angle" - from: 0 - to: 90 - duration: 300 - easing.type: Easing.InCubic - } - ScriptAction { script: logoBox.swapLogo() } - NumberAnimation { - target: flip - property: "angle" - from: 90 - to: 0 - duration: 300 - easing.type: Easing.OutCubic - } - } - } - - MouseArea { - id: logoArea - anchors.centerIn: parent - width: logo.paintedWidth - height: logo.paintedHeight - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - logoBox.pokes += 1 - pokeReset.restart() - if (logoBox.pokes >= 10) { - bubble.show("Fine. Have a crab. 🦀") - logoBox.pokes = 0 - } else { - bubble.show(logoBox.quips[Math.min(logoBox.pokes - 1, logoBox.quips.length - 1)]) - } - spin.restart() - } - } - - Rectangle { - id: bubble - property alias text: bubbleText.text - function show(message) { - bubbleText.text = message - opacity = 1 - bubbleFade.restart() - } - anchors.left: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 0 - width: bubbleText.implicitWidth + 20 - height: bubbleText.implicitHeight + 12 - radius: Theme.radiusXs - color: Theme.elevate3 - border.color: Theme.primary - border.width: 1 - opacity: 0 - z: 10 - visible: opacity > 0 - - Text { - id: bubbleText - anchors.centerIn: parent - color: Theme.textMain - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontFamily - } - - SequentialAnimation { - id: bubbleFade - PauseAnimation { duration: 1400 } - NumberAnimation { target: bubble; property: "opacity"; to: 0; duration: 300 } - } - } - } - - - // ── Text column ── - ColumnLayout { - Layout.alignment: Qt.AlignVCenter - spacing: 0 - - Text { - text: "RimWorld Mod Manager" - color: Theme.textMuted - font.pixelSize: Theme.fontSizeXs - font.family: Theme.fontFamily - } - - Text { - text: "v" + root.controller.appVersion - color: Theme.textDim - font.pixelSize: Theme.fontSizeXs - font.family: Theme.fontFamily - } - } - - // ── Workshop progress (centre; idle = empty) ── - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - id: downloadsProgress - visible: root.controller.downloadsBusy && root.controller.downloadsProgressShown - anchors.centerIn: parent - width: Math.min(parent.width, 360) - spacing: 6 - - Text { - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - text: root.controller.downloadsText - color: Theme.textMuted - elide: Text.ElideRight - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontFamily - } - - PxProgressBar { - Layout.fillWidth: true - from: 0 - to: Math.max(root.controller.downloadsTotal, 1) - value: root.controller.downloadsValue - indeterminate: root.controller.downloadsTotal <= 0 - } - } - - ColumnLayout { - visible: !downloadsProgress.visible && root.controller.taskText !== "" - anchors.centerIn: parent - width: Math.min(parent.width, 360) - spacing: 6 - - Text { - Layout.fillWidth: true - horizontalAlignment: Text.AlignHCenter - text: root.controller.taskText - color: Theme.textMuted - elide: Text.ElideRight - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontFamily - } - - PxProgressBar { - Layout.fillWidth: true - indeterminate: true - } - } - - MouseArea { - anchors.fill: downloadsProgress - visible: downloadsProgress.visible - cursorShape: Qt.PointingHandCursor - onClicked: root.controller.openDownloads() - ToolTip.text: "Open Downloads" - ToolTip.visible: containsMouse - hoverEnabled: true - } - } - - // ── Buttons column ── - ColumnLayout { - Layout.alignment: Qt.AlignVCenter - spacing: 8 - - // Window controls (top row) - Row { - Layout.alignment: Qt.AlignRight - spacing: 6 - visible: root.controller.is_frameless - - PxButton { - variant: "ghost" - iconName: "minimize" - ToolTip.text: "Minimize" - onClicked: root.controller.minimize() - } - - PxButton { - variant: "ghost" - iconName: root.controller.maximized ? "restore" : "maximize" - ToolTip.text: root.controller.maximized ? "Restore" : "Maximize" - onClicked: root.controller.maximize() - } - - PxButton { - variant: "danger" - iconName: "close" - ToolTip.text: "Close" - onClicked: root.controller.closeWindow() - } - } - - // Action buttons (bottom row) - Row { - Layout.alignment: Qt.AlignRight - spacing: 6 - - PxButton { - iconName: "save" - iconColor: root.controller.unsavedChanges ? Theme.warning : "transparent" - ToolTip.text: root.controller.tooltips.save || "Save" - onClicked: root.controller.save() - - Rectangle { - visible: root.controller.unsavedChanges - width: 8; height: 8 - radius: Theme.radiusXs - color: Theme.warning - border.width: 2 - border.color: Theme.elevate3 - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: 4 - anchors.rightMargin: 4 - } - } - - PxButton { - variant: "primary" - iconName: "sort" - ToolTip.text: root.controller.tooltips.sort || "Auto-sort" - onClicked: root.controller.autoSort() - } - - PxButton { - iconName: "refresh" - ToolTip.text: root.controller.tooltips.refresh || "Refresh" - onClicked: root.controller.refresh() - } - - PxButton { - visible: root.controller.downloadsAvailable - variant: root.controller.downloadsBusy ? "danger" : "secondary" - iconName: root.controller.downloadsBusy ? "close" : "download" - ToolTip.text: root.controller.downloadsBusy ? "Stop download" : (root.controller.tooltips.update_mods || "Update downloaded mods") - onClicked: root.controller.updateMods() - } - - // ── Launch split-button ── - Row { - spacing: 1 - - PxButton { - variant: "success" - iconName: "play" - text: root.controller.launchState === "launching" ? "Launching\u2026" - : root.controller.launchState === "running" ? "Running" : "Play" - enabled: root.controller.launchState === "idle" - ToolTip.text: root.controller.launchState === "running" ? "Game is running" : "Launch game" - onClicked: root.controller.launch() - } - - PxButton { - id: chevronBtn - objectName: "launchStrategyButton" - variant: "success" - iconName: "chevron-down" - ToolTip.text: "Launch strategy" - onClicked: strategyMenu.popup(chevronBtn, 0, chevronBtn.height + 4) - } - } - } - } - } - - // ── Dropdown menu (children of root, outside layouts) ── - PxMenu { - id: strategyMenu - objectName: "launchStrategyMenu" - - Repeater { - model: root.controller.strategies - - PxMenuItem { - required property var modelData - text: modelData.label - checkable: true - checked: root.controller.strategyIndex === modelData.index - onTriggered: root.controller.setStrategy(modelData.index) - } - } - } - - // ── Bottom border ── - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 1 - color: Theme.border - } - - // ── Drag region (behind buttons, z: -1) ── - MouseArea { - anchors.fill: parent - z: -1 - acceptedButtons: Qt.LeftButton - onPressed: root.controller.dragStarted() - onDoubleClicked: root.controller.maximize() - - cursorShape: root.controller.is_frameless ? Qt.OpenHandCursor : Qt.ArrowCursor - } -} diff --git a/legacy/pxmodrim/ui/components/IconRail.qml b/legacy/pxmodrim/ui/components/IconRail.qml deleted file mode 100644 index b2cdfcd..0000000 --- a/legacy/pxmodrim/ui/components/IconRail.qml +++ /dev/null @@ -1,246 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "controls" - -Rectangle { - id: root - color: Theme.elevate2 - - property int currentIndex: 0 - property bool collapsed: root.width < Theme.railCollapseWidth - property var tabModel: railModel - - signal tabSelected(int index) - signal tabHovered(int index) - signal settingsRequested() - signal helpActionRequested(string actionId) - - component RailButton: Item { - id: button - property string iconName - property string label - property bool collapsed - signal clicked() - - width: parent ? parent.width : 0 - height: 44 - - Rectangle { - anchors.fill: parent - anchors.margins: 4 - radius: Theme.radiusMd - color: area.containsMouse ? Theme.elevate3 : "transparent" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: button.collapsed ? 0 : 12 - anchors.rightMargin: button.collapsed ? 0 : 8 - spacing: 10 - - Item { - Layout.preferredWidth: 18 - Layout.preferredHeight: 18 - Layout.alignment: button.collapsed ? Qt.AlignCenter : Qt.AlignVCenter - Layout.fillWidth: button.collapsed - - Image { - anchors.centerIn: parent - source: "image://icons/" + button.iconName + "?color=" + encodeURIComponent(Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - } - } - - Text { - visible: !button.collapsed - Layout.fillWidth: true - text: button.label - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - } - - MouseArea { - id: area - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: button.clicked() - } - - Loader { - active: button.collapsed && area.containsMouse - sourceComponent: PxToolTip { - visible: true - text: button.label - } - } - } - - Accessible.role: Accessible.Button - Accessible.name: button.label - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - ListView { - id: listView - objectName: "railList" - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 4 - model: root.tabModel - currentIndex: root.currentIndex - - delegate: Item { - id: delegate - width: listView.width - height: 44 - property bool active: index === listView.currentIndex - - Rectangle { - id: bg - anchors.fill: parent - anchors.margins: 4 - radius: Theme.radiusMd - color: delegate.active - ? Theme.elevate4 - : (hoverArea.containsMouse ? Theme.elevate3 : "transparent") - - Rectangle { - visible: delegate.active - width: 3 - height: 18 - radius: Theme.radiusXs - color: Theme.primary - anchors.left: parent.left - anchors.leftMargin: 3 - anchors.verticalCenter: parent.verticalCenter - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: root.collapsed ? 0 : 12 - anchors.rightMargin: 8 - spacing: 10 - visible: !root.collapsed - - Item { - width: 18; height: 18 - Layout.alignment: Qt.AlignVCenter - - Image { - anchors.centerIn: parent - source: "image://icons/" + (modelData.icon || "") + "?color=" + - encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - opacity: 1 - } - } - - Text { - id: labelText - Layout.fillWidth: true - text: modelData.label || "" - color: delegate.active ? Theme.primary : Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: delegate.active ? Font.Medium : Font.Normal - elide: Text.ElideRight - horizontalAlignment: Text.AlignLeft - opacity: 1 - } - } - - Item { - anchors.fill: parent - visible: root.collapsed - - Image { - id: collapsedIcon - width: 18; height: 18 - anchors.centerIn: parent - source: "image://icons/" + (modelData.icon || "") + "?color=" + - encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - opacity: 1 - } - } - - MouseArea { - id: hoverArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: root.tabHovered(index) - onClicked: { - root.currentIndex = index - root.tabSelected(index) - } - } - - Loader { - active: root.collapsed && hoverArea.containsMouse - sourceComponent: PxToolTip { - visible: true - text: modelData.label || "" - } - } - - Behavior on color { - ColorAnimation { duration: 120; easing.type: Easing.OutCubic } - } - } - } - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.border - } - - Column { - Layout.fillWidth: true - Layout.topMargin: 4 - Layout.bottomMargin: 4 - - RailButton { - iconName: "settings" - label: "Settings" - collapsed: root.collapsed - onClicked: root.settingsRequested() - } - - RailButton { - id: helpButton - iconName: "help" - label: "Help" - collapsed: root.collapsed - onClicked: helpMenu.popup(helpButton, helpButton.width, helpButton.height - helpMenu.implicitHeight) - } - } - } - - PxMenu { - id: helpMenu - objectName: "helpMenu" - - Repeater { - model: railHelpEntries - - PxMenuItem { - required property var modelData - text: modelData.label - onTriggered: root.helpActionRequested(modelData.id) - } - } - } -} diff --git a/legacy/pxmodrim/ui/components/__init__.py b/legacy/pxmodrim/ui/components/__init__.py deleted file mode 100644 index e6da780..0000000 --- a/legacy/pxmodrim/ui/components/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from .banner_widget import AspectRatioBanner -from .button import AppButton -from .header_controller import HeaderController -from .header_panel import HeaderPanel -from .icon_button import IconButton -from .icons import icon, pixmap, svg_str -from .procedural_preview import generate_preview -from .progress_dialog import ProgressDialog -from .svg_provider import SvgIconProvider, create_qml_engine -from .toast import Toast, ToastManager -from .view_rail_panel import ViewRailPanel - -__all__ = [ - "AppButton", - "AspectRatioBanner", - "HeaderController", - "HeaderPanel", - "IconButton", - "ProgressDialog", - "SvgIconProvider", - "Toast", - "ToastManager", - "ViewRailPanel", - "create_qml_engine", - "generate_preview", - "icon", - "pixmap", - "svg_str", -] diff --git a/legacy/pxmodrim/ui/components/banner_widget.py b/legacy/pxmodrim/ui/components/banner_widget.py deleted file mode 100644 index 45fd463..0000000 --- a/legacy/pxmodrim/ui/components/banner_widget.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import ( - QColor, - QLinearGradient, - QPainter, - QPaintEvent, - QPixmap, - QResizeEvent, -) -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.constants import ( - BANNER_ASPECT_H, - BANNER_ASPECT_W, - BANNER_MAX_HEIGHT, - BANNER_OVERLAY_HEIGHT, -) -from pxmodrim.ui.theme.palette import PANEL_BG_Q, TEXT_MAIN_Q, TEXT_MUTED_Q - - -class AspectRatioBanner(QWidget): - """Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it.""" - - def __init__( - self, parent: QWidget | None = None, max_height: int = BANNER_MAX_HEIGHT - ) -> None: - super().__init__(parent) - self._pixmap: QPixmap | None = None - self._scaled_pixmap: QPixmap | None = None - self._title = "" - self._subtitle = "" - self._max_height = max_height - self._overlay_height = BANNER_OVERLAY_HEIGHT - self._show_overlay = True - self.setSizePolicy( - self.sizePolicy().Policy.Expanding, - self.sizePolicy().Policy.Fixed, - ) - - def setPixmap(self, pixmap: QPixmap) -> None: - self._pixmap = pixmap - self._recompute_scaled_pixmap() - self.updateGeometry() - self.update() - - def _recompute_scaled_pixmap(self) -> None: - if not self._pixmap or self._pixmap.isNull() or self.width() <= 0: - self._scaled_pixmap = None - return - - self._scaled_pixmap = self._pixmap.scaled( - self.width(), - self.heightForWidth(self.width()), - Qt.AspectRatioMode.KeepAspectRatioByExpanding, - Qt.TransformationMode.SmoothTransformation, - ) - - def setTitle(self, title: str) -> None: - self._title = title - self.update() - - def setSubtitle(self, subtitle: str) -> None: - self._subtitle = subtitle or "" - self.update() - - def setShowOverlay(self, show: bool) -> None: - self._show_overlay = show - self.update() - - def hasHeightForWidth(self) -> bool: - return True - - def heightForWidth(self, width: int) -> int: - return min(width * BANNER_ASPECT_H // BANNER_ASPECT_W, self._max_height) - - def sizeHint(self) -> QSize: - width = self.width() or 300 - return QSize(width, self.heightForWidth(width)) - - def paintEvent(self, event: QPaintEvent) -> None: - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - painter.setClipRect(self.rect()) - - # Background fill when no pixmap - if not self._scaled_pixmap or self._scaled_pixmap.isNull(): - painter.fillRect(self.rect(), PANEL_BG_Q) - else: - x = (self.width() - self._scaled_pixmap.width()) // 2 - y = (self.height() - self._scaled_pixmap.height()) // 2 - painter.drawPixmap(x, y, self._scaled_pixmap) - - # Gradient overlay at bottom (only when no actual preview image) - if self._title and self._show_overlay: - gradient = QLinearGradient( - 0, self.height() - self._overlay_height, 0, self.height() - ) - gradient.setColorAt(0.0, QColor(0, 0, 0, 0)) - gradient.setColorAt(1.0, QColor(0, 0, 0, 180)) - - painter.fillRect( - 0, - self.height() - self._overlay_height, - self.width(), - self._overlay_height, - gradient, - ) - - pad = 16 - text_area_top = self.height() - self._overlay_height + 12 - text_area_w = self.width() - pad * 2 - - font = painter.font() - font.setPointSize(18) - font.setBold(True) - painter.setFont(font) - painter.setPen(TEXT_MAIN_Q) - - title_h = self._overlay_height - 24 - if self._subtitle: - title_h = title_h * 3 // 5 - painter.drawText( - pad, - text_area_top, - text_area_w, - title_h, - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom, - self._title, - ) - - if self._subtitle: - font.setPointSize(12) - font.setBold(False) - painter.setFont(font) - painter.setPen(TEXT_MUTED_Q) - subtitle_top = text_area_top + title_h + 2 - subtitle_h = self._overlay_height - title_h - 26 - painter.drawText( - pad, - subtitle_top, - text_area_w, - subtitle_h, - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, - self._subtitle, - ) - - def resizeEvent(self, event: QResizeEvent) -> None: - self._recompute_scaled_pixmap() - super().resizeEvent(event) diff --git a/legacy/pxmodrim/ui/components/button.py b/legacy/pxmodrim/ui/components/button.py deleted file mode 100644 index ec29ca1..0000000 --- a/legacy/pxmodrim/ui/components/button.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QPushButton, QWidget - - -class AppButton(QPushButton): - def __init__(self, text: str = "", parent: QWidget | None = None) -> None: - super().__init__(text, parent) - self.setObjectName("appBtn") - self.setCursor(Qt.CursorShape.PointingHandCursor) diff --git a/legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml b/legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml deleted file mode 100644 index 9b4ea51..0000000 --- a/legacy/pxmodrim/ui/components/controls/DiagnosticBadge.qml +++ /dev/null @@ -1,49 +0,0 @@ -import QtQuick - -// "error" | "warning" glyph with an optional count and hover tooltip. -Item { - id: root - - property string level: "error" - property int count: 0 - property string tooltip: "" - - readonly property color tint: level === "error" ? Theme.danger : Theme.warning - - implicitWidth: countText.visible ? 16 + 3 + countText.implicitWidth : 16 - implicitHeight: 16 - - Accessible.role: Accessible.Graphic - Accessible.name: tooltip.length > 0 ? tooltip : level - - Image { - width: 16 - height: 16 - sourceSize.width: 16 - sourceSize.height: 16 - source: "image://icons/" + (root.level === "error" ? "error" : "warning") + "?color=" + encodeURIComponent(root.tint) - } - - Text { - id: countText - - visible: root.count > 0 - x: 19 - anchors.verticalCenter: parent.verticalCenter - text: root.count - color: root.tint - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - font.weight: Font.DemiBold - } - - HoverHandler { - id: hover - cursorShape: Qt.PointingHandCursor - } - - PxToolTip { - visible: hover.hovered && root.tooltip.length > 0 - text: root.tooltip - } -} diff --git a/legacy/pxmodrim/ui/components/controls/EmptyState.qml b/legacy/pxmodrim/ui/components/controls/EmptyState.qml deleted file mode 100644 index 4ce3c6f..0000000 --- a/legacy/pxmodrim/ui/components/controls/EmptyState.qml +++ /dev/null @@ -1,54 +0,0 @@ -import QtQuick - -Item { - id: root - - property string iconName: "" - property string title: "" - property string detail: "" - - implicitWidth: column.implicitWidth - implicitHeight: column.implicitHeight - - Accessible.role: Accessible.StaticText - Accessible.name: title - - Column { - id: column - - anchors.centerIn: parent - width: Math.min(root.width, 320) - spacing: 8 - - Image { - visible: root.iconName.length > 0 - anchors.horizontalCenter: parent.horizontalCenter - width: 32 - height: 32 - sourceSize.width: 32 - sourceSize.height: 32 - source: visible ? "image://icons/" + root.iconName + "?color=" + encodeURIComponent(Theme.textDim) : "" - } - - Text { - width: parent.width - text: root.title - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - - Text { - visible: root.detail.length > 0 - width: parent.width - text: root.detail - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/ModDescription.qml b/legacy/pxmodrim/ui/components/controls/ModDescription.qml deleted file mode 100644 index 8b3294f..0000000 --- a/legacy/pxmodrim/ui/components/controls/ModDescription.qml +++ /dev/null @@ -1,29 +0,0 @@ -import QtQuick - -TextEdit { - id: root - - property string description: "" - - text: description.length > 0 - ? "" - + description.replace(/__IMG_WIDTH__/g, Math.max(1, Math.floor(width))) - : "" - textFormat: TextEdit.RichText - readOnly: true - selectByMouse: true - selectedTextColor: Theme.onAccent - selectionColor: Theme.primary - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: TextEdit.WordWrap - - HoverHandler { - cursorShape: root.hoveredLink.length > 0 ? Qt.PointingHandCursor : Qt.IBeamCursor - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxBadge.qml b/legacy/pxmodrim/ui/components/controls/PxBadge.qml deleted file mode 100644 index 73f0603..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxBadge.qml +++ /dev/null @@ -1,57 +0,0 @@ -import QtQuick - -// Small rounded label used for mod metadata (version, provider, tags, startup impact). -// `outlined` adds a 1px border in `accent` over a tinted fill (provider style). -Rectangle { - id: root - - property string text: "" - property color textColor: Theme.primary - property color fillColor: Theme.primaryBg - property color accent: "transparent" - property bool outlined: false - property bool compact: false - property string tooltip: "" - - implicitHeight: compact ? 18 : 20 - implicitWidth: label.implicitWidth + 12 - radius: Theme.radiusSm - color: outlined ? Theme.elevate3 : fillColor - border.width: outlined ? 1 : 0 - border.color: accent - - Accessible.role: Accessible.StaticText - Accessible.name: text - - Rectangle { - visible: root.outlined - anchors.fill: parent - radius: parent.radius - color: root.accent - opacity: 0.18 - } - - Text { - id: label - anchors.centerIn: parent - text: root.text - color: root.textColor - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Bold - } - - HoverHandler { - id: hover - enabled: root.tooltip.length > 0 - } - - Loader { - active: hover.hovered && root.tooltip.length > 0 - sourceComponent: PxToolTip { - parent: root - visible: true - text: root.tooltip - } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxButton.qml b/legacy/pxmodrim/ui/components/controls/PxButton.qml deleted file mode 100644 index f404145..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxButton.qml +++ /dev/null @@ -1,132 +0,0 @@ -import QtQuick -import QtQuick.Controls - -Button { - id: control - - // "secondary" | "primary" | "success" | "warning" | "danger" (soft, fills on - // hover) | "dangerSolid" | "ghost" - property string variant: "secondary" - // Name from the image://icons provider; icon-only when text is empty. - property string iconName: "" - property real iconRotation: 0 - - readonly property bool isPrimary: variant === "primary" - readonly property bool isSuccess: variant === "success" - readonly property bool isDangerSolid: variant === "dangerSolid" - readonly property bool isDanger: variant === "danger" - readonly property bool isWarning: variant === "warning" - readonly property bool isGhost: variant === "ghost" - readonly property bool isSolid: isPrimary || isSuccess || isDangerSolid - readonly property bool iconOnly: text.length === 0 && iconName.length > 0 - - implicitHeight: 32 - implicitWidth: iconOnly ? 32 : Math.max(implicitBackgroundWidth + leftInset + rightInset, - implicitContentWidth + leftPadding + rightPadding) - leftPadding: iconOnly ? 8 : 12 - rightPadding: leftPadding - topPadding: 0 - bottomPadding: 0 - hoverEnabled: true - focusPolicy: Qt.StrongFocus - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: isSolid ? Font.DemiBold : Font.Medium - - HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } - - // Optional override for the icon/text colour (e.g. warning-tinted Save). - property color iconColor: "transparent" - - readonly property color foreground: iconColor.a > 0 ? iconColor - : isSolid ? Theme.onAccent - : (isDanger || isWarning) && hovered ? Theme.onAccent - : isDanger ? Theme.danger - : isWarning ? Theme.warning - : hovered ? Theme.textMain - : Theme.textMuted - - Accessible.name: text.length > 0 ? text : ToolTip.text - - Loader { - active: control.hovered && control.ToolTip.text.length > 0 - sourceComponent: PxToolTip { - parent: control - visible: true - text: control.ToolTip.text - } - } - - contentItem: Item { - implicitWidth: content.implicitWidth - implicitHeight: content.implicitHeight - opacity: control.enabled ? 1 : Theme.disabledOpacity - - Row { - id: content - anchors.centerIn: parent - spacing: 6 - - Image { - visible: control.iconName.length > 0 - anchors.verticalCenter: parent.verticalCenter - width: 16 - height: 16 - sourceSize.width: 16 - sourceSize.height: 16 - rotation: control.iconRotation - source: visible ? "image://icons/" + control.iconName + "?color=" + encodeURIComponent(control.foreground) : "" - } - - Text { - visible: control.text.length > 0 - anchors.verticalCenter: parent.verticalCenter - text: control.text - font: control.font - color: control.foreground - } - } - } - - background: Rectangle { - implicitWidth: 32 - implicitHeight: 32 - radius: Theme.radiusMd - opacity: control.enabled ? 1 : Theme.disabledOpacity - color: { - if (control.isPrimary) - return control.down || control.hovered ? Theme.primaryHover : Theme.primary - if (control.isSuccess) - return control.down || control.hovered ? Theme.successHover : Theme.success - if (control.isDangerSolid) - return control.down || control.hovered ? Theme.dangerHover : Theme.danger - if (control.isDanger) - return control.down || control.hovered ? Theme.danger : Theme.dangerBg - if (control.isWarning) - return control.down || control.hovered ? Theme.warning : Theme.warningBg - if (control.isGhost) - return control.down || control.hovered ? Theme.elevate3 : "transparent" - return control.down || control.hovered ? Theme.elevate4 : Theme.elevate3 - } - border.width: 1 - border.color: { - if (control.visualFocus) - return Theme.primary - if (control.isPrimary) - return control.hovered ? Theme.primaryHover : Theme.primary - if (control.isSuccess) - return control.hovered ? Theme.successHover : Theme.success - if (control.isDangerSolid) - return control.hovered ? Theme.dangerHover : Theme.danger - if (control.isDanger) - return Theme.danger - if (control.isWarning) - return Theme.warning - if (control.isGhost) - return control.hovered ? Theme.border : "transparent" - return control.hovered ? Theme.elevate4 : Theme.border - } - - Behavior on color { ColorAnimation { duration: 100 } } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxCheckBox.qml b/legacy/pxmodrim/ui/components/controls/PxCheckBox.qml deleted file mode 100644 index 3fb27ab..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxCheckBox.qml +++ /dev/null @@ -1,31 +0,0 @@ -import QtQuick -import QtQuick.Controls - -CheckBox { - id: control - - hoverEnabled: true - spacing: 8 - padding: 0 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } - - indicator: PxCheckIndicator { - x: control.leftPadding - y: (control.height - height) / 2 - checkState: control.checkState - hovered: control.hovered - focused: control.visualFocus - opacity: control.enabled ? 1 : Theme.disabledOpacity - } - - contentItem: Text { - leftPadding: control.text.length > 0 ? control.indicator.width + control.spacing : control.indicator.width - text: control.text - font: control.font - color: control.enabled ? Theme.textMain : Theme.textDim - verticalAlignment: Text.AlignVCenter - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml b/legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml deleted file mode 100644 index 4bb3a05..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxCheckIndicator.qml +++ /dev/null @@ -1,48 +0,0 @@ -import QtQuick - -// Visual-only box; the owning control supplies state and handles input. -Item { - id: root - - property int checkState: Qt.Unchecked - property bool hovered: false - property bool focused: false - - readonly property bool isChecked: checkState === Qt.Checked - readonly property bool isPartial: checkState === Qt.PartiallyChecked - - implicitWidth: 16 - implicitHeight: 16 - - Rectangle { - anchors.fill: parent - radius: Theme.radiusXs - color: root.isChecked ? Theme.primary - : root.isPartial ? Theme.primaryBg - : "transparent" - border.width: 1 - border.color: root.isChecked || root.isPartial || root.focused ? Theme.primary - : root.hovered ? Theme.textDim - : Theme.border - } - - Image { - anchors.centerIn: parent - visible: root.isChecked - width: 12 - height: 12 - sourceSize.width: 12 - sourceSize.height: 12 - source: visible ? "image://icons/check?color=" + encodeURIComponent(Theme.onAccent) : "" - } - - Text { - anchors.centerIn: parent - visible: root.isPartial - text: "\u2212" - color: Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.bold: true - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxComboBox.qml b/legacy/pxmodrim/ui/components/controls/PxComboBox.qml deleted file mode 100644 index c6071e1..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxComboBox.qml +++ /dev/null @@ -1,92 +0,0 @@ -import QtQuick -import QtQuick.Controls - -ComboBox { - id: control - - implicitHeight: 32 - leftPadding: 10 - rightPadding: 28 - hoverEnabled: true - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - contentItem: Text { - text: control.displayText - font: control.font - color: control.enabled ? Theme.textMain : Theme.textDim - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - indicator: Image { - x: control.width - width - 10 - y: (control.height - height) / 2 - width: 12 - height: 12 - source: "image://icons/chevron-down?color=" + encodeURIComponent(control.hovered ? Theme.textMain : Theme.textDim) - sourceSize.width: 12 - sourceSize.height: 12 - rotation: control.popup.visible ? 180 : 0 - } - - background: Rectangle { - implicitWidth: 120 - implicitHeight: 32 - radius: Theme.radiusMd - opacity: control.enabled ? 1 : Theme.disabledOpacity - color: Theme.elevate0 - border.width: 1 - border.color: control.activeFocus || control.popup.visible ? Theme.primary - : control.hovered ? Theme.elevate4 - : Theme.border - } - - delegate: ItemDelegate { - id: option - required property int index - required property var model - width: ListView.view ? ListView.view.width : control.width - height: 28 - hoverEnabled: true - leftPadding: 8 - rightPadding: 8 - topPadding: 0 - bottomPadding: 0 - highlighted: control.highlightedIndex === index - contentItem: Text { - text: option.model[control.textRole] !== undefined ? option.model[control.textRole] : option.model.modelData - font: control.font - color: control.currentIndex === option.index ? Theme.primary : Theme.textMain - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - background: Rectangle { - radius: Theme.radiusSm - color: option.highlighted ? Theme.elevate3 : "transparent" - } - } - - popup: Popup { - popupType: Popup.Window - y: control.height + 4 - width: control.width - implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) - padding: 4 - - contentItem: ListView { - clip: true - implicitHeight: contentHeight - model: control.popup.visible ? control.delegateModel : null - currentIndex: control.highlightedIndex - ScrollBar.vertical: PxScrollBar {} - } - - background: Rectangle { - radius: Theme.radiusMd - color: Theme.elevate2 - border.width: 1 - border.color: Theme.border - } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxDialog.qml b/legacy/pxmodrim/ui/components/controls/PxDialog.qml deleted file mode 100644 index 3e0e00e..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxDialog.qml +++ /dev/null @@ -1,77 +0,0 @@ -import QtQuick -import QtQuick.Controls - -// Themed modal shell. Footer buttons: `standardButtons` render as PxButton -// (accept role = primary); custom footers can assign their own DialogButtonBox. -Dialog { - id: dialog - - parent: Overlay.overlay - anchors.centerIn: parent - modal: true - padding: 16 - topPadding: 12 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - Overlay.modal: Rectangle { color: Theme.overlay } - - background: Rectangle { - color: Theme.elevate2 - border.color: Theme.border - radius: Theme.radiusLg - } - - header: Item { - implicitHeight: 48 - visible: dialog.title.length > 0 - - Text { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 16 - anchors.rightMargin: 16 - text: dialog.title - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.weight: Font.DemiBold - elide: Text.ElideRight - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 1 - color: Theme.border - } - } - - footer: PxDialogFooter { - visible: buttonBox.count > 0 - - DialogButtonBox { - id: buttonBox - - standardButtons: dialog.standardButtons - alignment: Qt.AlignRight - spacing: 8 - padding: 0 - background: null - - onAccepted: dialog.accept() - onRejected: dialog.reject() - onApplied: dialog.applied() - onDiscarded: dialog.discarded() - onHelpRequested: dialog.helpRequested() - onReset: dialog.reset() - - delegate: PxButton { - variant: DialogButtonBox.buttonRole === DialogButtonBox.AcceptRole - || DialogButtonBox.buttonRole === DialogButtonBox.YesRole ? "primary" : "ghost" - } - } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml b/legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml deleted file mode 100644 index 65c7e3c..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxDialogFooter.qml +++ /dev/null @@ -1,37 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -// Dialog footer strip; children are laid out in a right-aligned row. -Rectangle { - id: root - - default property alias content: row.data - - implicitHeight: 56 - radius: Theme.radiusLg - color: Theme.elevate1 - border.width: 1 - border.color: Theme.border - - // Squares the top corners so only the bottom follows the dialog's radius. - Rectangle { - width: parent.width - height: Theme.radiusLg - color: Theme.elevate1 - - Rectangle { width: 1; height: parent.height; color: Theme.border } - Rectangle { x: parent.width - 1; width: 1; height: parent.height; color: Theme.border } - Rectangle { width: parent.width; height: 1; color: Theme.border } - } - - RowLayout { - id: row - - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 8 - - Item { Layout.fillWidth: true } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxMenu.qml b/legacy/pxmodrim/ui/components/controls/PxMenu.qml deleted file mode 100644 index 9521513..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxMenu.qml +++ /dev/null @@ -1,37 +0,0 @@ -import QtQuick -import QtQuick.Controls - -Menu { - id: control - popupType: Popup.Window - - implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, - contentItem.implicitWidth + leftPadding + rightPadding) - implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, - contentItem.implicitHeight + topPadding + bottomPadding) - padding: 4 - margins: 0 - overlap: 1 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - delegate: PxMenuItem {} - - contentItem: ListView { - implicitHeight: contentHeight - model: control.contentModel - interactive: Window.window ? contentHeight + control.topPadding + control.bottomPadding > Window.window.height : false - clip: true - currentIndex: control.currentIndex - ScrollBar.vertical: PxScrollBar {} - } - - background: Rectangle { - implicitWidth: 160 - implicitHeight: 36 - radius: Theme.radiusMd - color: Theme.elevate2 - border.width: 1 - border.color: Theme.border - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxMenuItem.qml b/legacy/pxmodrim/ui/components/controls/PxMenuItem.qml deleted file mode 100644 index 974df03..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxMenuItem.qml +++ /dev/null @@ -1,62 +0,0 @@ -import QtQuick -import QtQuick.Controls - -MenuItem { - id: control - - implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, - implicitContentWidth + leftPadding + rightPadding) - implicitHeight: 28 - leftPadding: 8 - rightPadding: 8 - topPadding: 0 - bottomPadding: 0 - spacing: 8 - hoverEnabled: true - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - HoverHandler { cursorShape: control.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor } - - indicator: PxCheckIndicator { - x: control.leftPadding - y: (control.height - height) / 2 - visible: control.checkable - checkState: control.checked ? Qt.Checked : Qt.Unchecked - hovered: control.highlighted - opacity: control.enabled ? 1 : Theme.disabledOpacity - } - - arrow: Image { - x: control.width - width - control.rightPadding - y: (control.height - height) / 2 - visible: control.subMenu !== null - width: 12 - height: 12 - sourceSize.width: 12 - sourceSize.height: 12 - rotation: -90 - opacity: control.enabled ? 1 : Theme.disabledOpacity - source: visible ? "image://icons/chevron-down?color=" + encodeURIComponent(Theme.textDim) : "" - } - - contentItem: Text { - leftPadding: control.checkable ? control.indicator.width + control.spacing : 0 - rightPadding: control.subMenu !== null ? control.arrow.width + control.spacing : 0 - text: control.text - font: control.font - color: Theme.textMain - opacity: control.enabled ? 1 : Theme.disabledOpacity - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - background: Rectangle { - implicitWidth: 160 - implicitHeight: 28 - radius: Theme.radiusSm - color: control.highlighted && control.enabled - ? (control.down ? Theme.primaryBg : Theme.elevate3) - : "transparent" - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxProgressBar.qml b/legacy/pxmodrim/ui/components/controls/PxProgressBar.qml deleted file mode 100644 index 41c329a..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxProgressBar.qml +++ /dev/null @@ -1,49 +0,0 @@ -import QtQuick -import QtQuick.Controls - -ProgressBar { - id: control - - property int thickness: 4 - - implicitWidth: 200 - implicitHeight: thickness - - background: Rectangle { - implicitWidth: 200 - implicitHeight: control.thickness - radius: Theme.radiusXs - color: Theme.elevate3 - } - - contentItem: Item { - implicitWidth: 200 - implicitHeight: control.thickness - clip: true - - Rectangle { - visible: !control.indeterminate - width: control.visualPosition * parent.width - height: parent.height - radius: Theme.radiusXs - color: Theme.primary - } - - Rectangle { - id: sweep - visible: control.indeterminate - width: parent.width * 0.3 - height: parent.height - radius: Theme.radiusXs - color: Theme.primary - - XAnimator on x { - running: control.indeterminate && control.visible - loops: Animation.Infinite - from: -sweep.width - to: sweep.parent.width - duration: 1200 - } - } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxScrollBar.qml b/legacy/pxmodrim/ui/components/controls/PxScrollBar.qml deleted file mode 100644 index ca6c1c2..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxScrollBar.qml +++ /dev/null @@ -1,21 +0,0 @@ -import QtQuick -import QtQuick.Controls - -ScrollBar { - id: control - - implicitWidth: Theme.scrollbarWidth - implicitHeight: Theme.scrollbarWidth - padding: 0 - hoverEnabled: true - visible: policy !== ScrollBar.AlwaysOff && size < 1.0 - - contentItem: Rectangle { - implicitWidth: Theme.scrollbarWidth - implicitHeight: Theme.scrollbarWidth - radius: Theme.radiusPill - color: control.pressed || control.hovered ? Theme.textMuted : Theme.textDim - } - - background: Item {} -} diff --git a/legacy/pxmodrim/ui/components/controls/PxSpinBox.qml b/legacy/pxmodrim/ui/components/controls/PxSpinBox.qml deleted file mode 100644 index 1713bb6..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxSpinBox.qml +++ /dev/null @@ -1,71 +0,0 @@ -import QtQuick -import QtQuick.Controls - -SpinBox { - id: control - - property string toolTipText: "" - - implicitHeight: 32 - implicitWidth: 110 - editable: true - leftPadding: 28 - rightPadding: 28 - hoverEnabled: true - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - contentItem: TextInput { - text: control.textFromValue(control.value, control.locale) - font: control.font - color: control.enabled ? Theme.textMain : Theme.textDim - selectionColor: Theme.primary - horizontalAlignment: Qt.AlignHCenter - verticalAlignment: Qt.AlignVCenter - readOnly: !control.editable - validator: control.validator - inputMethodHints: Qt.ImhFormattedNumbersOnly - } - - up.indicator: Item { - x: control.width - width - width: 28 - height: control.height - opacity: control.value < control.to ? 1 : Theme.disabledOpacity - - Text { - anchors.centerIn: parent - text: "+" - font: control.font - color: control.up.pressed || control.up.hovered ? Theme.textMain : Theme.textDim - } - } - - down.indicator: Item { - width: 28 - height: control.height - opacity: control.value > control.from ? 1 : Theme.disabledOpacity - - Text { - anchors.centerIn: parent - text: "\u2212" - font: control.font - color: control.down.pressed || control.down.hovered ? Theme.textMain : Theme.textDim - } - } - - background: Rectangle { - radius: Theme.radiusMd - opacity: control.enabled ? 1 : Theme.disabledOpacity - color: Theme.elevate0 - border.width: 1 - border.color: control.activeFocus ? Theme.primary - : control.hovered ? Theme.elevate4 - : Theme.border - } - - PxToolTip { - visible: control.toolTipText !== "" && control.hovered - text: control.toolTipText - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxTextField.qml b/legacy/pxmodrim/ui/components/controls/PxTextField.qml deleted file mode 100644 index 7a46add..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxTextField.qml +++ /dev/null @@ -1,40 +0,0 @@ -import QtQuick -import QtQuick.Controls - -TextField { - id: control - - property bool invalid: false - property bool monospace: false - - implicitHeight: 32 - leftPadding: 10 - rightPadding: 10 - topPadding: 0 - bottomPadding: 0 - verticalAlignment: TextInput.AlignVCenter - hoverEnabled: true - selectByMouse: true - color: readOnly ? Theme.textMuted : Theme.textMain - placeholderTextColor: Theme.textDim - selectionColor: Theme.primary - selectedTextColor: Theme.elevate0 - font.family: monospace ? Theme.fontMono : Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - - background: Rectangle { - implicitWidth: 120 - implicitHeight: 32 - radius: Theme.radiusMd - opacity: control.enabled ? 1 : Theme.disabledOpacity - color: control.readOnly ? "transparent" : Theme.elevate0 - border.width: 1 - border.color: control.invalid ? Theme.danger - : control.activeFocus ? Theme.primary - : control.readOnly ? "transparent" - : control.hovered ? Theme.elevate4 - : Theme.border - - Behavior on border.color { ColorAnimation { duration: 100 } } - } -} diff --git a/legacy/pxmodrim/ui/components/controls/PxToolTip.qml b/legacy/pxmodrim/ui/components/controls/PxToolTip.qml deleted file mode 100644 index 77177e9..0000000 --- a/legacy/pxmodrim/ui/components/controls/PxToolTip.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import QtQuick.Controls - -ToolTip { - id: control - popupType: Popup.Window - - delay: Theme.tooltipDelay - padding: 6 - leftPadding: 8 - rightPadding: 8 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - - contentItem: Text { - text: control.text - font: control.font - color: Theme.textMain - wrapMode: Text.Wrap - } - - background: Rectangle { - radius: Theme.radiusSm - color: Theme.elevate3 - border.width: 1 - border.color: Theme.border - } -} diff --git a/legacy/pxmodrim/ui/components/dialog_chrome.py b/legacy/pxmodrim/ui/components/dialog_chrome.py deleted file mode 100644 index d7213bb..0000000 --- a/legacy/pxmodrim/ui/components/dialog_chrome.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEvent, QObject, Qt -from PySide6.QtGui import QEnterEvent, QMouseEvent -from PySide6.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QSizeGrip, - QWidget, -) - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -_TITLE_BAR_HEIGHT = 36 -_BORDER = 1 -_GRIP_SIZE = 16 -_CLOSE_SIZE = 26 - - -class _CloseButton(QPushButton): - """Soft-red close button that fills solid on hover, like the main window's.""" - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent) - self.setObjectName("dialogCloseBtn") - self.setFixedSize(_CLOSE_SIZE, _CLOSE_SIZE) - # A global QPushButton min-height would otherwise beat setFixedSize. - inner = _CLOSE_SIZE - 2 # the 1px QSS border sits outside the content box - self.setStyleSheet( - f"min-width: {inner}px; max-width: {inner}px;" - f" min-height: {inner}px; max-height: {inner}px;" - ) - self.setToolTip("Close") - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._normal_icon = icon("close", 12, PALETTE["DANGER"]) - self._hover_icon = icon("close", 12, PALETTE["TEXT_ON_ACCENT"]) - self.setIcon(self._normal_icon) - - def enterEvent(self, event: QEnterEvent) -> None: - self.setIcon(self._hover_icon) - super().enterEvent(event) - - def leaveEvent(self, event: QEvent) -> None: - self.setIcon(self._normal_icon) - super().leaveEvent(event) - - -def _close_like_escape(dialog: QDialog) -> None: - if isinstance(dialog, QMessageBox): - button = dialog.escapeButton() - if button is not None: - button.click() - return - if dialog.button(QMessageBox.StandardButton.Cancel) is not None: - dialog.done(int(QMessageBox.StandardButton.Cancel)) - return - dialog.reject() - - -class _TitleBar(QWidget): - def __init__(self, dialog: QDialog, closable: bool) -> None: - super().__init__(dialog) - self.setObjectName("dialogTitleBar") - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - layout = QHBoxLayout(self) - layout.setContentsMargins(14, 0, 6, 0) - layout.setSpacing(6) - - self._title = QLabel(dialog.windowTitle(), self) - self._title.setObjectName("dialogTitleText") - layout.addWidget(self._title, 1) - dialog.windowTitleChanged.connect(self._title.setText) - - if closable: - close_btn = _CloseButton(self) - close_btn.clicked.connect(lambda: _close_like_escape(dialog)) - layout.addWidget(close_btn) - - def mousePressEvent(self, event: QMouseEvent) -> None: - if event.button() == Qt.MouseButton.LeftButton: - handle = self.window().windowHandle() - if handle is not None: - handle.startSystemMove() - event.accept() - return - super().mousePressEvent(event) - - -class _Chrome(QObject): - """Keeps the overlay title bar and size grip pinned to the dialog frame.""" - - def __init__(self, dialog: QDialog, closable: bool, resizable: bool) -> None: - super().__init__(dialog) - self._dialog = dialog - self._bar = _TitleBar(dialog, closable) - self._grip = QSizeGrip(dialog) if resizable else None - dialog.installEventFilter(self) - self._place() - - def _place(self) -> None: - width = self._dialog.width() - self._bar.setGeometry(_BORDER, _BORDER, width - 2 * _BORDER, _TITLE_BAR_HEIGHT) - self._bar.raise_() - if self._grip is not None: - self._grip.setGeometry( - width - _GRIP_SIZE - _BORDER, - self._dialog.height() - _GRIP_SIZE - _BORDER, - _GRIP_SIZE, - _GRIP_SIZE, - ) - self._grip.raise_() - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if watched is self._dialog and event.type() in ( - QEvent.Type.Resize, - QEvent.Type.Show, - ): - self._place() - return False - - -def install_dialog_chrome( - dialog: QDialog, *, closable: bool = True, resizable: bool | None = None -) -> None: - """Give *dialog* the frameless title-bar look of the main window. - - Call after the dialog's layout is built and before it is shown. Native file - pickers keep the OS look. The title bar is an overlay, so the dialog's own - layout only needs extra top margin, which is added here. - """ - if isinstance(dialog, QFileDialog) or dialog.property("chromed"): - return - if resizable is None: - resizable = not (isinstance(dialog, QMessageBox) or dialog.isSizeGripEnabled()) - - keep = dialog.windowFlags() & Qt.WindowType.WindowStaysOnTopHint - dialog.setWindowFlags( - Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | keep - ) - dialog.setProperty("chromed", True) - - layout = dialog.layout() - if layout is not None: - m = layout.contentsMargins() - layout.setContentsMargins( - m.left() + _BORDER, - m.top() + _TITLE_BAR_HEIGHT + _BORDER, - m.right() + _BORDER, - m.bottom() + _BORDER, - ) - _Chrome(dialog, closable, resizable) diff --git a/legacy/pxmodrim/ui/components/dialogs.py b/legacy/pxmodrim/ui/components/dialogs.py deleted file mode 100644 index cd4954e..0000000 --- a/legacy/pxmodrim/ui/components/dialogs.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from PySide6.QtWidgets import QDialog - -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome - - -async def await_dialog[T: QDialog]( - cls: type[T], - *args: Any, - **kwargs: Any, -) -> tuple[int, T]: - """Show a modal QDialog async and await its result via an asyncio future. - - The dialog is scheduled for deletion on the next loop iteration, so callers - must read what they need from it synchronously after the await. - """ - dialog = cls(*args, **kwargs) - install_dialog_chrome(dialog) - dialog.setModal(True) - - future: asyncio.Future[int] = asyncio.get_running_loop().create_future() - - def handle_finished(result_code: int) -> None: - if not future.done(): - future.set_result(result_code) - - dialog.finished.connect(handle_finished) - dialog.show() - - result_code = await future - asyncio.get_running_loop().call_soon(dialog.deleteLater) - return result_code, dialog diff --git a/legacy/pxmodrim/ui/components/filter_sidebar.py b/legacy/pxmodrim/ui/components/filter_sidebar.py deleted file mode 100644 index 45dc80d..0000000 --- a/legacy/pxmodrim/ui/components/filter_sidebar.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any, cast - -from PySide6.QtCore import QAbstractItemModel, QObject, QUrl, Signal -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.palette import PALETTE - -_QML = Path(__file__).parent / "FilterSidebar.qml" - - -class FilterSidebar(QQuickWidget): - """Left-hand filter list shared by the Mods and Organizer views.""" - - activated = Signal(int) - - def __init__( - self, - qml_engine: QQmlEngine | None, - model: QAbstractItemModel, - parent: QWidget | None = None, - ) -> None: - super().__init__(qml_engine, parent) # pyright: ignore[reportCallIssue, reportArgumentType] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self.rootContext().setContextProperty("filterSidebarModel", model) - self.setSource(QUrl.fromLocalFile(str(_QML))) - root = self.rootObject() - if root is not None: - root.setProperty("model", model) - cast(Any, root).activated.connect(self.activated) - - def current_index(self) -> int: - root = self.rootObject() - if root is None: - return -1 - view = root.findChild(QObject, "listView") - return int(view.property("currentIndex")) if view is not None else -1 diff --git a/legacy/pxmodrim/ui/components/header_controller.py b/legacy/pxmodrim/ui/components/header_controller.py deleted file mode 100644 index e289c72..0000000 --- a/legacy/pxmodrim/ui/components/header_controller.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Property, QObject, Signal, Slot - -from pxmodrim.core.constants import LaunchStrategy - -_STRATEGY_LABELS = { - LaunchStrategy.DIRECT: "Direct executable", - LaunchStrategy.STEAM: "Steam", -} - - -class HeaderController(QObject): - refresh_requested = Signal() - sort_requested = Signal() - save_requested = Signal() - launch_requested = Signal() - strategy_changed = Signal(int) - minimize_requested = Signal() - maximize_requested = Signal() - close_requested = Signal() - drag_started = Signal() - unsaved_changes_changed = Signal() - update_mods_requested = Signal() - downloads_busy_changed = Signal() - downloads_progress_changed = Signal() - downloads_progress_shown_changed = Signal() - downloads_requested = Signal() - task_text_changed = Signal() - launch_state_changed = Signal() - - def __init__( - self, - is_frameless: bool = False, - parent: QObject | None = None, - initial_strategy: int = 0, - app_version: str = "", - tooltips: dict[str, str] | None = None, - downloads_available: bool = False, - ) -> None: - super().__init__(parent) - self._is_frameless = is_frameless - self._maximized = False - self._strategy_index: int = initial_strategy - self._unsaved_changes = False - self._launch_state = "idle" - self._app_version = app_version - self._tooltips = tooltips or {} - self._downloads_available = downloads_available - self._downloads_busy = False - self._task_text = "" - self._downloads_text = "" - self._downloads_value = 0 - self._downloads_total = 0 - self._downloads_progress_shown = True - - def is_frameless_getter(self) -> bool: - return self._is_frameless - - is_frameless = Property(bool, is_frameless_getter, constant=True) - - appVersion = Property(str, lambda self: self._app_version, constant=True) - tooltips = Property(dict, lambda self: self._tooltips, constant=True) - downloadsAvailable = Property( - bool, lambda self: self._downloads_available, constant=True - ) - - @Property("QVariantList", constant=True) # type: ignore[operator] - def strategies(self) -> list[dict[str, object]]: - return [{"index": int(s), "label": _STRATEGY_LABELS[s]} for s in LaunchStrategy] - - def set_maximized(self, value: bool) -> None: - if self._maximized != value: - self._maximized = value - self.maximized_changed.emit() - - maximized_changed = Signal() - maximized = Property( - bool, lambda self: self._maximized, set_maximized, notify=maximized_changed - ) - - @Slot() - def refresh(self) -> None: - self.refresh_requested.emit() - - @Slot() - def autoSort(self) -> None: - self.sort_requested.emit() - - @Slot() - def openDownloads(self) -> None: - self.downloads_requested.emit() - - @Slot() - def updateMods(self) -> None: - self.update_mods_requested.emit() - - @Slot() - def save(self) -> None: - self.save_requested.emit() - - @Slot() - def launch(self) -> None: - self.launch_requested.emit() - - @Slot() - def minimize(self) -> None: - self.minimize_requested.emit() - - @Slot() - def maximize(self) -> None: - self.maximize_requested.emit() - - @Slot() - def closeWindow(self) -> None: - self.close_requested.emit() - - @Slot(int) - def setStrategy(self, index: int) -> None: - if self._strategy_index != index: - self._strategy_index = index - self.strategy_changed.emit(index) - - @Slot() - def dragStarted(self) -> None: - self.drag_started.emit() - - # ── QML property ──────────────────────────────────────── - - def _get_strategy_index(self) -> int: - return self._strategy_index - - strategyIndex = Property(int, _get_strategy_index, notify=strategy_changed) - - def _get_unsaved_changes(self) -> bool: - return self._unsaved_changes - - def set_launch_state(self, value: str) -> None: - if self._launch_state != value: - self._launch_state = value - self.launch_state_changed.emit() - - launchState = Property( - str, - lambda self: self._launch_state, - set_launch_state, - notify=launch_state_changed, - ) - - @Slot(bool) - def set_unsaved_changes(self, value: bool) -> None: - if self._unsaved_changes != value: - self._unsaved_changes = value - self.unsaved_changes_changed.emit() - - unsavedChanges = Property( - bool, - _get_unsaved_changes, - set_unsaved_changes, - notify=unsaved_changes_changed, - ) - - @Slot(bool) - def set_task_text(self, text: str) -> None: - if self._task_text != text: - self._task_text = text - self.task_text_changed.emit() - - taskText = Property( - str, - lambda self: self._task_text, - notify=task_text_changed, # type: ignore[arg-type] - ) - - def set_downloads_busy(self, value: bool) -> None: - if self._downloads_busy != value: - self._downloads_busy = value - self.downloads_busy_changed.emit() - - def set_downloads_progress_shown(self, shown: bool) -> None: - if self._downloads_progress_shown != shown: - self._downloads_progress_shown = shown - self.downloads_progress_shown_changed.emit() - - downloadsProgressShown = Property( - bool, - lambda self: self._downloads_progress_shown, - notify=downloads_progress_shown_changed, # type: ignore[arg-type] - ) - - def set_downloads_progress(self, text: str, value: int, total: int) -> None: - self._downloads_text = text - self._downloads_value = value - self._downloads_total = total - self.downloads_progress_changed.emit() - - downloadsText = Property( - str, - lambda self: self._downloads_text, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - downloadsValue = Property( - int, - lambda self: self._downloads_value, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - downloadsTotal = Property( - int, - lambda self: self._downloads_total, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - - downloadsBusy = Property( - bool, - lambda self: self._downloads_busy, - set_downloads_busy, - notify=downloads_busy_changed, - ) diff --git a/legacy/pxmodrim/ui/components/header_panel.py b/legacy/pxmodrim/ui/components/header_panel.py deleted file mode 100644 index 8a4b627..0000000 --- a/legacy/pxmodrim/ui/components/header_panel.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.components.header_controller import HeaderController -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_HEADER_QML = _QML_DIR / "Header.qml" - - -class HeaderPanel(QQuickWidget): - def __init__( - self, - controller: HeaderController, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(qml_engine, parent) # type: ignore[arg-type] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self.rootContext().setContextProperty("headerController", controller) - self.setSource(QUrl.fromLocalFile(str(_HEADER_QML))) - root_obj = self.rootObject() - if root_obj is None: - raise RuntimeError("Header.qml failed to load: rootObject is None") diff --git a/legacy/pxmodrim/ui/components/icon_button.py b/legacy/pxmodrim/ui/components/icon_button.py deleted file mode 100644 index d551c03..0000000 --- a/legacy/pxmodrim/ui/components/icon_button.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QPushButton, QWidget - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - - -class IconButton(QPushButton): - def __init__( - self, - icon_name: str, - tooltip: str = "", - primary: bool = False, - size: int = 32, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._icon_name = icon_name - self._primary = primary - self._icon_px = max(16, size // 2) - - self.set_icon_color(PALETTE["TEXT_MUTED"]) - self.setFixedSize(size, size) - self.setObjectName("iconBtn") - # QSS min-height on QPushButton overrides setFixedSize; the 1px border sits - # outside the QSS content box, so subtract it. - inner = size - 2 - self.setStyleSheet( - f"QPushButton#iconBtn {{ min-width: {inner}px; max-width: {inner}px;" - f" min-height: {inner}px; max-height: {inner}px; }}" - ) - if primary: - self.setProperty("primary", True) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setToolTip(tooltip) - - def set_icon_color(self, color: str) -> None: - self.setIcon(icon(self._icon_name, self._icon_px, color)) diff --git a/legacy/pxmodrim/ui/components/icons.py b/legacy/pxmodrim/ui/components/icons.py deleted file mode 100644 index 39b69ca..0000000 --- a/legacy/pxmodrim/ui/components/icons.py +++ /dev/null @@ -1,440 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - IconName = Literal[ - "logo", - "download", - "refresh", - "sort", - "save", - "settings", - "search", - "check", - "warning", - "error", - "close", - "minimize", - "maximize", - "restore", - "chevron", - "chevron-left", - "chevron-down", - "folder", - "thumbs-up", - "tag", - "steam", - "local", - "git", - "grid", - "home", - "mods", - "check-circle", - "ban", - "info", - "help", - "empty", - "play", - "link", - "clock", - "trash", - "donut", - "bars", - "grip", - "star", - "star-filled", - ] - -from PySide6.QtCore import QRectF -from PySide6.QtGui import QColor, QGuiApplication, QIcon, QPainter, QPixmap -from PySide6.QtSvg import QSvgRenderer -from PySide6.QtXml import QDomDocument - -from pxmodrim.ui.theme.palette import PALETTE - -_STAR = ( - "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25" - "L7 14.14 2 9.27l6.91-1.01L12 2z" -) - -# Each icon is an SVG path data string. -# stroke svg uses 24x24 viewBox, stroke-width 2, stroke="currentColor" -_ICONS: dict[str, str] = { - "thumbs-up": ( - '' - '' - '' - "" - ), - "logo": ( - '' - '' - '' - "" - ), - "download": ( - '' - '' - '' - '' - "" - ), - "refresh": ( - '' - '' - '' - '' - "" - ), - "sort": ( - '' - '' - '' - '' - "" - ), - "save": ( - '' - '' - '' - '' - "" - ), - "settings": ( - '' - '' - '' - "" - ), - "search": ( - '' - '' - '' - "" - ), - "check": ( - '' - '' - "" - ), - "warning": ( - '' - '' - '' - '' - "" - ), - "error": ( - '' - '' - '' - '' - "" - ), - "close": ( - '' - '' - '' - "" - ), - "minimize": ( - '' - '' - "" - ), - "maximize": ( - '' - '' - "" - ), - "restore": ( - '' - '' - '' - "" - ), - "chevron": ( - '' - '' - "" - ), - "chevron-left": ( - '' - '' - "" - ), - "folder": ( - '' - '' - "" - ), - "tag": ( - '' - '' - '' - "" - ), - "steam": ( - '' - '' - "" - ), - "local": ( - '' - '' - '' - "" - ), - "git": ( - '' - '' - "" - ), - # ── Sidebar icons ── - "grid": ( - '' - '' - '' - '' - '' - "" - ), - "home": ( - '' - '' - '' - "" - ), - "mods": ( - '' - '' - '' - '' - '' - '' - '' - "" - ), - "check-circle": ( - '' - '' - '' - "" - ), - "ban": ( - '' - '' - '' - "" - ), - "help": ( - '' - '' - '' - '' - "" - ), - "info": ( - '' - '' - '' - '' - "" - ), - "play": ( - '' - '' - "" - ), - "empty": ( - '' - '' - '' - "" - ), - "link": ( - '' - '' - '' - "" - ), - "copy": ( - '' - '' - '' - "" - ), - "chevron-down": ( - '' - '' - "" - ), - "clock": ( - '' - '' - '' - "" - ), - "trash": ( - '' - '' - '' - '' - '' - "" - ), - "donut": ( - '' - '' - '' - "" - ), - "bars": ( - '' - '' - '' - '' - "" - ), - "grip": ( - '' - '' - '' - '' - "" - ), - "star": ( - '' - f'' - "" - ), - "star-filled": ( - '' - f'' - "" - ), -} - - -def _color_to_hex(color: str | QColor) -> str: - if isinstance(color, QColor): - return color.name() - return color - - -def svg_str(name: str, color: str = "currentColor") -> str: - raw = _ICONS[name] - if 'xmlns="http://www.w3.org/2000/svg"' not in raw: - raw = raw.replace(" QPixmap: - hex_color = _color_to_hex(color) - svg = svg_str(name, hex_color) - # Rasterise at >=2x so small icons stay crisp even on 1x screens. - screen = QGuiApplication.primaryScreen() - scale = max(2.0, screen.devicePixelRatio() if screen else 1.0) - pm = QPixmap(round(size * scale), round(size * scale)) - pm.setDevicePixelRatio(scale) - pm.fill(QColor(0, 0, 0, 0)) - painter = QPainter(pm) - try: - painter.setRenderHints( - QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform - ) - svg_bytes = svg.encode("utf-8") - doc = QDomDocument() - if doc.setContent(svg_bytes): - renderer = QSvgRenderer(doc.toByteArray()) - renderer.render(painter, QRectF(0, 0, size, size)) - finally: - painter.end() - return pm - - -def icon( - name: str, - size: int = 16, - color: str | QColor = PALETTE["TEXT_MUTED"], -) -> QIcon: - pm = pixmap(name, size, color) - return QIcon(pm) diff --git a/legacy/pxmodrim/ui/components/mod_activation.py b/legacy/pxmodrim/ui/components/mod_activation.py deleted file mode 100644 index faf8792..0000000 --- a/legacy/pxmodrim/ui/components/mod_activation.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from typing import TYPE_CHECKING - -from PySide6.QtWidgets import QMessageBox, QWidget - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -def _dependent_detail(mod: ListedMod) -> str: - if isinstance(mod, AboutXmlMod): - return f"{mod.name} ({mod.package_id})" - return mod.name - - -class DependentModsDialog(QMessageBox): - def __init__(self, dependents: list[str], parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle("Disable Dependent Mods") - self.setText( - "These active mods depend on the mod you are disabling:\n\n" - + "\n".join(f"• {dependent}" for dependent in dependents) - ) - self.setInformativeText( - "Choose whether to disable only the selected mod or these dependents too." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No - | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.No) - self.setButtonText( - QMessageBox.StandardButton.Yes, "Disable selected + dependents" - ) - self.setButtonText(QMessageBox.StandardButton.No, "Disable selected only") - yes_button = self.button(QMessageBox.StandardButton.Yes) - if yes_button is not None: - yes_button.setObjectName("primaryAction") - - -async def apply_activation( - ctx: CoreContext, - parent: QWidget, - enable: Iterable[str] = (), - disable: Iterable[str] = (), -) -> bool: - """Enable/disable mods in one core call, confirming active dependents first. - - Every UI surface that toggles mods goes through here so the dependents - prompt cannot diverge between views. Returns whether active state changed. - """ - enabling = list(enable) - disabling = list(disable) - activation = ctx.activation - dependents = activation.dependents_of(disabling, enable=enabling) - if dependents: - all_mods = ctx.all_mods - details = [_dependent_detail(all_mods[uuid]) for uuid in dependents] - result, _ = await await_dialog(DependentModsDialog, details, parent) - if result == QMessageBox.StandardButton.Cancel: - return False - if result == QMessageBox.StandardButton.Yes: - disabling.extend(dependents) - return activation.apply(enable=enabling, disable=disabling) - - -async def toggle_mods(ctx: CoreContext, parent: QWidget, uuids: Iterable[str]) -> bool: - """Flip each mod: active ones are disabled, inactive ones enabled.""" - active = set(ctx.active_uuids) - selected = list(uuids) - return await apply_activation( - ctx, - parent, - enable=[uuid for uuid in selected if uuid not in active], - disable=[uuid for uuid in selected if uuid in active], - ) diff --git a/legacy/pxmodrim/ui/components/mod_updates.py b/legacy/pxmodrim/ui/components/mod_updates.py deleted file mode 100644 index c0b23e2..0000000 --- a/legacy/pxmodrim/ui/components/mod_updates.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from loguru import logger -from PySide6.QtGui import QAction -from PySide6.QtWidgets import QDialog, QMenu, QMessageBox, QWidget - -from pxmodrim.core.downloads import download_manager -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -def updatable_ids(ctx: CoreContext, uuids: Iterable[str]) -> list[str]: - """Source ids among the mods *uuids* that PxModRim can update.""" - all_mods = ctx.all_mods - return download_manager(ctx).updatable_ids( - all_mods[u] for u in uuids if u in all_mods - ) - - -CONFIRM_THRESHOLD = 25 - - -@dataclass(frozen=True, slots=True) -class UpdateState: - label: str - enabled: bool - tooltip: str - - -def update_state(ctx: CoreContext, uuids: Iterable[str]) -> UpdateState: - selected = list(uuids) - ids = updatable_ids(ctx, selected) - label = "Update" - if len(selected) > 1: - label += f" ({len(ids)} of {len(selected)})" - downloads = download_manager(ctx) - if not downloads.available: - return UpdateState(label, False, "No download sources are enabled") - if downloads.is_downloading: - return UpdateState(label, False, "A download is in progress") - if not ids: - return UpdateState( - label, False, "None of these mods were downloaded by PxModRim" - ) - return UpdateState(label, True, "") - - -def add_update_action( - menu: QMenu, ctx: CoreContext, uuids: Iterable[str], handler: Callable[[], object] -) -> QAction | None: - """Add an Update entry to *menu*; skipped when no download source exists.""" - if not download_manager(ctx).available: - return None - state = update_state(ctx, uuids) - action = menu.addAction(state.label, handler) - action.setEnabled(state.enabled) - action.setToolTip(state.tooltip) - return action - - -class ConfirmUpdateDialog(QMessageBox): - def __init__(self, count: int, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - QDialog.setWindowTitle(self, "Update Mods") - self.setText(f"Update {count} mods?") - self.setInformativeText( - "Every mod downloaded by PxModRim is checked against its source and " - "changed files are downloaded. Mods managed by other tools, such as " - "Steam's own Workshop folder, are not included. This can take a " - "long time." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - yes = self.button(QMessageBox.StandardButton.Yes) - if yes: - yes.setText("Update") - yes.setObjectName("primaryAction") - - -async def confirm_update(count: int, parent: QWidget) -> bool: - """Ask before a large update; small ones go through unasked.""" - if count < CONFIRM_THRESHOLD: - return True - confirmed, _ = await await_dialog(ConfirmUpdateDialog, count, parent) - return confirmed == QMessageBox.StandardButton.Yes - - -async def update_mods(ctx: CoreContext, uuids: Iterable[str], parent: QWidget) -> None: - """Update the updatable mods among *uuids*; results are toasted by MainWindow.""" - downloads = download_manager(ctx) - ids = updatable_ids(ctx, uuids) - if not ids: - logger.debug("[downloads] update requested but nothing is updatable") - return - if not await confirm_update(len(ids), parent): - return - logger.info("[downloads] update requested for {} mods", len(ids)) - try: - await downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] update not started: {}", exc) diff --git a/legacy/pxmodrim/ui/components/popup_parent.py b/legacy/pxmodrim/ui/components/popup_parent.py deleted file mode 100644 index 065e2a8..0000000 --- a/legacy/pxmodrim/ui/components/popup_parent.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEvent, QObject -from PySide6.QtQuickWidgets import QQuickWidget - - -class QuickPopupParenting(QObject): - """Gives every QQuickWidget's offscreen window its top-level window as transient - parent when shown. Without it Wayland refuses ``Popup.Window`` popups (menus, - combo box lists, tooltips): "Failed to create popup … transientParent". - """ - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if event.type() == QEvent.Type.Show and isinstance(watched, QQuickWidget): - top = watched.window().windowHandle() - if top is not None: - watched.quickWindow().setTransientParent(top) - return False diff --git a/legacy/pxmodrim/ui/components/procedural_preview.py b/legacy/pxmodrim/ui/components/procedural_preview.py deleted file mode 100644 index 81be3b5..0000000 --- a/legacy/pxmodrim/ui/components/procedural_preview.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import hashlib -import re -from functools import lru_cache - -from PySide6.QtCore import QPointF, QRectF, Qt -from PySide6.QtGui import QColor, QImage, QPainter, QRadialGradient - -from pxmodrim.ui.theme.constants import BANNER_OVERLAY_HEIGHT - - -def _seedrand(seed: str) -> float: - h = int(hashlib.sha256(seed.encode()).hexdigest(), 16) - return (h % 10000) / 10000 - - -def _hsl(h: float, s: float, lum: float) -> QColor: - return QColor.fromHslF(h % 1.0, max(0.0, min(1.0, s)), max(0.0, min(1.0, lum))) - - -_TAG_RE = re.compile(r"[\[(][^\])]*[\])]") -_WORD_RE = re.compile(r"[^\W_]+") -_CAMEL_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+") -_STOP_WORDS = frozenset({"a", "an", "and", "for", "of", "the", "to", "in", "on"}) - - -def initials(title: str) -> str: - """Two-letter monogram: skips [TAGS] and punctuation, splits CamelCase.""" - stripped = _TAG_RE.sub(" ", title) - # A title that is only tags ("[AV]") falls back to its contents. - source = stripped if _WORD_RE.search(stripped) else title - words: list[str] = [] - for chunk in _WORD_RE.findall(source): - words.extend(_CAMEL_RE.findall(chunk) or [chunk]) - meaningful = [w for w in words if w.lower() not in _STOP_WORDS] or words - if not meaningful: - return title.strip()[:2].upper() or "?" - if len(meaningful) == 1: - # "[AV] Framework": a lone word borrows the tag's letter instead of - # its own second one, which reads as random. - tag = _WORD_RE.search(" ".join(_TAG_RE.findall(title))) - if tag and source is stripped: - return (tag.group()[0] + meaningful[0][0]).upper() - return meaningful[0][:2].upper() - return (meaningful[0][0] + meaningful[1][0]).upper() - - -def _blob( - painter: QPainter, cx: float, cy: float, radius: float, color: QColor -) -> None: - # Radial falloff stands in for a gaussian blur, which QPainter lacks. - grad = QRadialGradient(QPointF(cx, cy), radius) - inner = QColor(color) - outer = QColor(color) - outer.setAlpha(0) - grad.setColorAt(0.0, inner) - grad.setColorAt(1.0, outer) - painter.setBrush(grad) - painter.drawEllipse(QPointF(cx, cy), radius, radius) - - -@lru_cache(maxsize=16) -def generate_preview(title: str, width: int, height: int) -> QImage: - """Aurora: dark tinted base, two soft hue-shifted glows, ghosted initials. - - Returns a QImage: QPixmap painting is only safe on the GUI thread, and this - runs in a worker thread. - """ - image = QImage(width, height, QImage.Format.Format_ARGB32_Premultiplied) - hue = _seedrand(title) - image.fill(_hsl(hue, 0.40, 0.12)) - - painter = QPainter(image) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setPen(Qt.PenStyle.NoPen) - - span = max(width, height) - jitter_x = _seedrand(title + "jx") * 0.2 - jitter_y = _seedrand(title + "jy") * 0.2 - warm = _hsl(hue, 0.70, 0.50) - warm.setAlphaF(0.75) - cool = _hsl(hue + 0.14, 0.70, 0.50) - cool.setAlphaF(0.65) - _blob( - painter, width * (0.15 + jitter_x), height * (0.1 + jitter_y), span * 0.6, warm - ) - _blob( - painter, width * (0.85 - jitter_x), height * (0.9 - jitter_y), span * 0.55, cool - ) - - monogram = initials(title) - font = painter.font() - font.setBold(True) - font.setWeight(font.Weight.ExtraBold) - font.setPixelSize(max(32, int(height * 0.36))) - font.setLetterSpacing(font.SpacingType.PercentageSpacing, 96) - painter.setFont(font) - painter.setPen(QColor(255, 255, 255, 210)) - visible = max(height - BANNER_OVERLAY_HEIGHT, height // 2) - painter.drawText( - QRectF(0, 0, width, visible), Qt.AlignmentFlag.AlignCenter, monogram - ) - - painter.end() - return image diff --git a/legacy/pxmodrim/ui/components/progress_dialog.py b/legacy/pxmodrim/ui/components/progress_dialog.py deleted file mode 100644 index 2ee539e..0000000 --- a/legacy/pxmodrim/ui/components/progress_dialog.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from typing import Self - -from PySide6.QtCore import QPropertyAnimation, Qt, QTimer -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QProgressBar, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.loading import LoadingState -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -_HIDE_DELAY_MS = 300 -_FADE_IN_MS = 150 -_FADE_OUT_MS = 200 - - -class ProgressDialog(QDialog): - """Modal progress dialog showing stacked loading tasks as breadcrumbs.""" - - def __init__(self, loading: LoadingState, parent: QWidget) -> None: - super().__init__( - parent, - Qt.WindowType.WindowStaysOnTopHint - | Qt.WindowType.CustomizeWindowHint - | Qt.WindowType.WindowTitleHint, - ) - self.setWindowTitle("Loading") - self.setMinimumWidth(420) - self.setModal(True) - - self._loading = loading - self._shown = False - - self._crumbs = QWidget(self) - self._crumbs_layout = QHBoxLayout(self._crumbs) - self._crumbs_layout.setContentsMargins(0, 0, 0, 8) - self._crumbs_layout.setSpacing(4) - - self._progress = QProgressBar(self) - self._progress.setTextVisible(False) - self._progress.setRange(0, 100) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(0) - layout.addWidget(self._crumbs) - layout.addWidget(self._progress) - install_dialog_chrome(self, closable=False) - - self._hide_timer = QTimer(self) - self._hide_timer.setSingleShot(True) - self._hide_timer.setInterval(_HIDE_DELAY_MS) - self._hide_timer.timeout.connect(self._begin_hide) - - self._fade = QPropertyAnimation(self, b"windowOpacity", self) - self._fade.finished.connect(self._on_fade_finished) - - loading.changed.connect(self._on_changed) - loading.finished.connect(self._on_finished) - - @property - def loading(self) -> LoadingState: - return self._loading - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: - # Dialog auto-closes via LoadingState.finished signal - pass - - def _on_changed(self, status: str, pct: int, cur: int, total: int) -> None: - self._hide_timer.stop() - if not self._shown: - self._shown = True - if not self.isVisible(): - self.setWindowOpacity(0.0) - self.show() - self._fade_to(1.0, _FADE_IN_MS) - - statuses = self._loading.statuses - if statuses: - self.setWindowTitle(statuses[0]) - self._update_breadcrumbs(statuses) - self._progress.setValue(pct) - - def _on_finished(self) -> None: - self._hide_timer.start() - - def _update_breadcrumbs(self, statuses: tuple[str, ...]) -> None: - while (item := self._crumbs_layout.takeAt(0)) is not None: - widget = item.widget() - if widget is not None: - widget.hide() - widget.deleteLater() - - last = len(statuses) - 1 - for i, status in enumerate(statuses): - label = QLabel(status, self._crumbs) - if i == last: - font = label.font() - font.setBold(True) - label.setFont(font) - self._crumbs_layout.addWidget(label) - - if i < last: - sep = QLabel(self._crumbs) - sep.setPixmap(icon("chevron", 12, PALETTE["TEXT_DIM"]).pixmap(12, 12)) - self._crumbs_layout.addWidget(sep) - - self._crumbs_layout.addStretch() - - def _begin_hide(self) -> None: - self._shown = False - self._fade_to(0.0, _FADE_OUT_MS) - - def _fade_to(self, end: float, duration: int) -> None: - self._fade.stop() - self._fade.setDuration(duration) - self._fade.setStartValue(self.windowOpacity()) - self._fade.setEndValue(end) - self._fade.start() - - def _on_fade_finished(self) -> None: - if not self._shown: - self.hide() diff --git a/legacy/pxmodrim/ui/components/svg_provider.py b/legacy/pxmodrim/ui/components/svg_provider.py deleted file mode 100644 index e5723f9..0000000 --- a/legacy/pxmodrim/ui/components/svg_provider.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from urllib.parse import unquote - -from PySide6.QtCore import QByteArray, QObject, QSize, Qt -from PySide6.QtGui import QPainter, QPixmap -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickImageProvider -from PySide6.QtQuickControls2 import QQuickStyle -from PySide6.QtSvg import QSvgRenderer - -from pxmodrim.ui.components.icons import svg_str -from pxmodrim.ui.theme.palette import PALETTE - - -class SvgIconProvider(QQuickImageProvider): - """Serves named SVG icons to QML via image://icons/?color=.""" - - def __init__(self) -> None: - super().__init__(QQuickImageProvider.Pixmap) # pyright: ignore[reportAttributeAccessIssue] - - def requestPixmap(self, id: str, _size: QSize, requested_size: QSize) -> QPixmap: - parts = id.split("?", 1) - icon_name = parts[0] - color = PALETTE["TEXT_MUTED"] - if len(parts) > 1: - for param in parts[1].split("&"): - if param.startswith("color="): - raw = param[6:] - color = unquote(raw) - if not color.startswith("#") and color.startswith("23"): - color = "#" + color[2:] - - try: - svg = svg_str(icon_name, color) - except KeyError: - svg = '' - - renderer = QSvgRenderer(QByteArray(svg.encode())) - - sz = requested_size if requested_size.isValid() else QSize(16, 16) - pix = QPixmap(sz) - pix.fill(Qt.GlobalColor.transparent) - painter = QPainter(pix) - renderer.render(painter) - painter.end() - return pix - - -def create_qml_engine(parent: QObject | None = None) -> QQmlEngine: - """Build a QQmlEngine with the ``icons`` image provider registered. - - Every QML surface in the app -- main window chrome and every plugin view - -- shares one engine via constructor DI, and that engine must carry the - ``icons`` provider or ``image://icons/..`` URLs fail to resolve - (AGENTS.md's icon convention). Centralizing construction here means a - caller can no longer forget the registration the way a bare - ``QQmlEngine()`` call would. - """ - if not QQuickStyle.name(): - QQuickStyle.setStyle("Basic") - engine = QQmlEngine(parent) - engine.addImageProvider("icons", SvgIconProvider()) - return engine diff --git a/legacy/pxmodrim/ui/components/toast.py b/legacy/pxmodrim/ui/components/toast.py deleted file mode 100644 index 9222fdf..0000000 --- a/legacy/pxmodrim/ui/components/toast.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from PySide6.QtCore import QPropertyAnimation, Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QGraphicsOpacityEffect, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -ToastLevel = Literal["info", "success", "warning", "error"] - -_TOAST_LEVELS: dict[ToastLevel, tuple[str, str]] = { - "info": ("info", PALETTE["PRIMARY"]), - "success": ("check-circle", PALETTE["SUCCESS"]), - "warning": ("warning", PALETTE["WARNING"]), - "error": ("error", PALETTE["DANGER"]), -} - - -class Toast(QWidget): - dismissed = Signal() - - def __init__( - self, - message: str, - level: ToastLevel = "info", - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setObjectName("toast") - self.setFixedWidth(320) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) - - icon_name, accent = _TOAST_LEVELS[level] - - self._opacity_effect = QGraphicsOpacityEffect(self) - self._opacity_effect.setOpacity(0.0) - self.setGraphicsEffect(self._opacity_effect) - - layout = QHBoxLayout(self) - layout.setContentsMargins(12, 10, 8, 10) - layout.setSpacing(8) - - icon_label = QLabel() - icon_label.setPixmap(icon(icon_name, 16, accent).pixmap(16, 16)) - icon_label.setFixedSize(16, 16) - layout.addWidget(icon_label) - - self._message_label = QLabel(message) - self._message_label.setObjectName("toastMessage") - self._message_label.setWordWrap(True) - layout.addWidget(self._message_label, 1) - - close_btn = QPushButton() - close_btn.setObjectName("toastClose") - close_btn.setIcon(icon("close", 10, PALETTE["TEXT_DIM"])) - close_btn.setFixedSize(20, 20) - close_btn.setToolTip("Dismiss") - close_btn.clicked.connect(self.dismiss) - layout.addWidget(close_btn) - - self._opacity_anim = QPropertyAnimation(self._opacity_effect, b"opacity") - self._opacity_anim.setDuration(200) - self._opacity_anim.setStartValue(0.0) - self._opacity_anim.setEndValue(1.0) - - self._fade_out_anim = QPropertyAnimation(self._opacity_effect, b"opacity", self) - self._fade_out_anim.setDuration(150) - self._fade_out_anim.setEndValue(0.0) - self._fade_out_anim.finished.connect(self.dismissed) - self._dismissing = False - - def show_with_animation(self) -> None: - self.show() - self.raise_() - self._opacity_anim.setDirection(QPropertyAnimation.Direction.Forward) - self._opacity_anim.start() - - def dismiss(self) -> None: - if self._dismissing: - return - self._dismissing = True - self._opacity_anim.stop() - self._fade_out_anim.setStartValue(self._opacity_effect.opacity()) - self._fade_out_anim.start() - - -class ToastManager(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("toastManager") - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) - - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 16, 16) - self._layout.setSpacing(8) - self._layout.setAlignment( - Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight - ) - - self._active_toasts: list[Toast] = [] - - def show_toast( - self, - message: str, - level: ToastLevel = "info", - duration_ms: int = 3000, - ) -> None: - toast = Toast(message, level, self) - self._layout.addWidget(toast) - self._active_toasts.append(toast) - toast.dismissed.connect(lambda: self._remove(toast)) - - toast.show_with_animation() - - if duration_ms > 0: - QTimer.singleShot(duration_ms, toast, toast.dismiss) - - def _remove(self, toast: Toast) -> None: - toast.hide() - self._layout.removeWidget(toast) - toast.deleteLater() - if toast in self._active_toasts: - self._active_toasts.remove(toast) - - def info(self, message: str, duration: int = 3000) -> None: - self.show_toast(message, "info", duration) - - def success(self, message: str, duration: int = 3000) -> None: - self.show_toast(message, "success", duration) - - def warning(self, message: str, duration: int = 4000) -> None: - self.show_toast(message, "warning", duration) - - def error(self, message: str, duration: int = 5000) -> None: - self.show_toast(message, "error", duration) - - def resize_to_parent(self) -> None: - parent = self.parentWidget() - if parent: - self.setGeometry(parent.rect()) diff --git a/legacy/pxmodrim/ui/components/unity_rich_text.py b/legacy/pxmodrim/ui/components/unity_rich_text.py deleted file mode 100644 index 150fa93..0000000 --- a/legacy/pxmodrim/ui/components/unity_rich_text.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import html -import math -import re -from urllib.parse import urlsplit - -UNITY_TAG_PATTERN = re.compile(r"\s]+))?\s*>") -UNITY_COLOR_PATTERN = re.compile(r"^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$") -UNITY_SIZE_PATTERN = re.compile(r"^([+-]?\d+(?:\.\d+)?)(px|em|%)?$") - - -class UnityRichTextConverter: - """Convert Unity rich text tags to Qt-compatible HTML subset.""" - - __slots__ = ("_tag_stack",) - - def __init__(self) -> None: - self._tag_stack: list[str] = [] - - def convert(self, text: str) -> str: - self._tag_stack.clear() - # Split text into segments: tags and text content - # Process tags first, escape text content between tags - result_parts = [] - last_end = 0 - - for match in UNITY_TAG_PATTERN.finditer(text): - # Text before this tag - escape it - if match.start() > last_end: - text_segment = text[last_end : match.start()] - result_parts.append(html.escape(text_segment)) - - # Process the tag - full = match.group(0) - tag_name = match.group(1).lower() - attr = match.group(2) - - if full.startswith("") - result = result.replace("\\n", "
    ") - return self._close_all_tags(result) - - def _handle_open_tag(self, tag: str, attr: str | None) -> str: - html_tag = self._unity_to_html_open(tag, attr) - if html_tag: - self._tag_stack.append(tag) - return html_tag - return "" - - def _handle_close_tag(self, tag: str) -> str: - tag = tag.lower() - if tag in self._tag_stack: - while self._tag_stack and self._tag_stack[-1] != tag: - self._tag_stack.pop() - if self._tag_stack: - self._tag_stack.pop() - return self._unity_to_html_close(tag) - return "" - - def _close_all_tags(self, text: str) -> str: - for tag in reversed(self._tag_stack): - text += self._unity_to_html_close(tag) - self._tag_stack.clear() - return text - - def _unity_to_html_open(self, tag: str, attr: str | None) -> str: - match tag: - case "b" | "bold": - return "" - case "i" | "italic": - return "" - case "u" | "underline": - return '' - case "s" | "strike" | "strikethrough": - return "" - case "color": - return self._color_tag(attr) - case "size": - return self._size_tag(attr) - case "align": - return self._align_tag(attr) - case "indent": - return self._indent_tag(attr) - case "mark": - return self._mark_tag(attr) - case "br": - return "
    " - case "sub": - return "" - case "sup": - return "" - case "a" | "link": - return self._link_tag(attr) - case ( - "noparse" - | "alpha" - | "font" - | "font-weight" - | "mspace" - | "style" - | "gradient" - | "cspace" - | "voffset" - | "pos" - | "width" - | "line-height" - | "line-indent" - | "margin" - | "margin-left" - | "margin-right" - | "uppercase" - | "lowercase" - | "smallcaps" - | "rotate" - | "sprite" - ): - # TODO: Implement these tags - return "" - case _: - return "" - - def _unity_to_html_close(self, tag: str) -> str: - match tag: - case "b" | "bold": - return "
    " - case "i" | "italic": - return "" - case "u" | "underline": - return "" - case "s" | "strike" | "strikethrough": - return "" - case "color": - return "
    " - case "size": - return "" - case "align": - return "

  • " - case "indent": - return "
    " - case "mark": - return "" - case "sub": - return "" - case "sup": - return "" - case "a" | "link": - return "" - case _: - return "" - - def _color_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - if UNITY_COLOR_PATTERN.match(attr): - return f'' - return '' - - def _normalize_color(self, color: str) -> str: - if color.startswith("#"): - hex_part = color[1:] - if len(hex_part) == 3: - return "#" + "".join(c * 2 for c in hex_part) - if len(hex_part) == 4: - return "#" + "".join(c * 2 for c in hex_part) - if len(hex_part) == 6: - return "#" + hex_part - if len(hex_part) == 8: - return "#" + hex_part[:6] - named_colors = { - "red": "#ff0000", - "green": "#00ff00", - "blue": "#0000ff", - "white": "#ffffff", - "black": "#000000", - "yellow": "#ffff00", - "cyan": "#00ffff", - "magenta": "#ff00ff", - "gray": "#808080", - "grey": "#808080", - "orange": "#ffa500", - "purple": "#800080", - } - return named_colors.get(color.lower(), "#ffffff") - - def _size_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - m = UNITY_SIZE_PATTERN.match(attr) - if not m: - return '' - raw_value = m.group(1) - unit = m.group(2) or "px" - is_relative = raw_value.startswith(("+", "-")) - value = float(raw_value) - base_pt = 12.0 - if unit == "%": - pt = base_pt * value / 100 - elif unit == "em": - pt = base_pt * value - else: - pt = base_pt + value if is_relative else value - if not math.isfinite(pt): - pt = 13.0 - pt = min(max(pt, 1.0), 200.0) - pt_str = str(int(pt)) if pt == int(pt) else str(pt) - return f'' - - def _align_tag(self, attr: str | None) -> str: - if not attr: - return '
    ' - attr = attr.strip("\"'").lower() - align_map = { - "left": "left", - "center": "center", - "right": "right", - "justified": "justify", - "flush": "justify", - } - return f'
    ' - - def _indent_tag(self, attr: str | None) -> str: - if not attr: - return '
    ' - attr = attr.strip("\"'") - m = UNITY_SIZE_PATTERN.match(attr) - if not m: - return '
    ' - value = float(m.group(1)) - if not math.isfinite(value): - value = 20.0 - unit = m.group(2) or "px" - if unit == "%": - px = 400 * value / 100 - elif unit == "em": - px = 16 * value - else: - px = value - px = int(min(max(px, -10000.0), 10000.0)) - return f'
    ' - - def _mark_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - color = self._normalize_color(attr) - if len(attr) == 9 and attr.startswith("#"): - alpha = int(attr[7:], 16) / 255 - return f'' - return f'' - - def _link_tag(self, attr: str | None) -> str: - if not attr: - return "" - attr = attr.strip("\"'") - parsed = urlsplit(attr) - if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: - return "" - return f'' - - -def unity_rich_text_to_html(text: str) -> str: - """Convert Unity rich text to Qt-compatible HTML.""" - return UnityRichTextConverter().convert(text) diff --git a/legacy/pxmodrim/ui/components/view_rail_panel.py b/legacy/pxmodrim/ui/components/view_rail_panel.py deleted file mode 100644 index 44213cd..0000000 --- a/legacy/pxmodrim/ui/components/view_rail_panel.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_RAIL_QML = _QML_DIR / "IconRail.qml" - - -class ViewRailPanel(QQuickWidget): - currentChanged = Signal(int) - hovered = Signal(int) - settings_requested = Signal() - help_action_requested = Signal(str) - - def __init__( - self, - tabs: list[dict], - help_entries: list[dict[str, str]], - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - """Initialize the icon rail with tab entries.""" - super().__init__(qml_engine, parent) # type: ignore[arg-type] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self.setMinimumWidth(48) - self.setMaximumWidth(220) - - qml_ctx = self.rootContext() - qml_ctx.setContextProperty("railPanel", self) - qml_ctx.setContextProperty("railModel", tabs) - qml_ctx.setContextProperty("railHelpEntries", help_entries) - self.setSource(QUrl.fromLocalFile(str(_RAIL_QML))) - - root = self.rootObject() - if root is not None: - root.tabSelected.connect(self._on_tab_selected) # type: ignore[attr-defined] - root.tabHovered.connect(self._on_tab_hovered) # type: ignore[attr-defined] - root.settingsRequested.connect(self.settings_requested) # type: ignore[attr-defined] - root.helpActionRequested.connect(self.help_action_requested) # type: ignore[attr-defined] - - @Slot(int) - def _on_tab_selected(self, index: int) -> None: - self.currentChanged.emit(index) - - @Slot(int) - def _on_tab_hovered(self, index: int) -> None: - self.hovered.emit(index) - - def set_current(self, index: int) -> None: - root = self.rootObject() - if root is not None: - root.setProperty("currentIndex", index) diff --git a/legacy/pxmodrim/ui/config.py b/legacy/pxmodrim/ui/config.py deleted file mode 100644 index 2c23e05..0000000 --- a/legacy/pxmodrim/ui/config.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - -__all__ = ["UIPrefs", "UIPrefsService", "load_ui_prefs", "save_ui_prefs"] - - -def load_ui_prefs(config_service: ConfigService) -> UIPrefs: - """Load UI prefs from ``ui_prefs.json`` via config service.""" - return config_service.load("ui_prefs.json", UIPrefs) - - -def save_ui_prefs(prefs: UIPrefs, config_service: ConfigService) -> None: - """Save UI prefs to ``ui_prefs.json`` via config service.""" - config_service.save("ui_prefs.json", prefs) - - -class UIPrefsService: - """Owns UIPrefs lifecycle: load and save. - - Follows the Service protocol (setup). - """ - - __slots__ = ("_prefs", "_svc") - - def __init__(self, svc: ConfigService) -> None: - self._svc = svc - self._prefs: UIPrefs | None = None - - async def setup(self) -> None: - self._prefs = self._svc.load("ui_prefs.json", UIPrefs) - - def prefs(self) -> UIPrefs: - assert self._prefs is not None - return self._prefs - - def save_prefs(self) -> None: - if self._prefs is not None: - self._svc.save("ui_prefs.json", self._prefs) diff --git a/legacy/pxmodrim/ui/context.py b/legacy/pxmodrim/ui/context.py deleted file mode 100644 index 05cd402..0000000 --- a/legacy/pxmodrim/ui/context.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, ClassVar, Protocol - -from PySide6.QtWidgets import QWidget - -from pxmodrim.core.plugin import Plugin, PluginRegistry -from pxmodrim.ui.settings_section import SettingsSectionFactory -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - - from pxmodrim.core.context import CoreContext - - -class Notifier(Protocol): - def info(self, message: str, duration: int = ...) -> None: ... - def success(self, message: str, duration: int = ...) -> None: ... - def warning(self, message: str, duration: int = ...) -> None: ... - def error(self, message: str, duration: int = ...) -> None: ... - - -class RailView(QWidget): - """Base of widgets registered on the navigation rail.""" - - view_id: ClassVar[str] - icon_name: ClassVar[str] - label: ClassVar[str] - #: The download queue: the header's downloads button opens it, and the header - #: hides its own download progress while it is shown. - lists_downloads: ClassVar[bool] = False - - def open_route(self, path: tuple[str, ...]) -> None: - """Handle the part of a ``modrim:///…`` link after the view id.""" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(parent) - - -class AppContext: - __slots__ = ( - "_core", - "_navigate", - "_plugins", - "_rail_views", - "_settings_sections", - "_toasts", - "_ui_prefs", - ) - - def __init__(self, core: CoreContext, ui_prefs: UIPrefs | None = None) -> None: - self._core = core - self._plugins = PluginRegistry() - self._rail_views: list[type[RailView]] = [] - self._settings_sections: list[SettingsSectionFactory] = [] - self._toasts: Notifier | None = None - self._navigate: Callable[[str], None] | None = None - self._ui_prefs = ui_prefs or UIPrefs() - - # ── Plugin system (UI layer) ────────────────────── - - def register_plugin(self, plugin: Plugin) -> None: - self._plugins.register(plugin) - - @property - def plugins(self) -> PluginRegistry: - return self._plugins - - # ── Rail views ──────────────────────────────────── - - def add_rail_view( - self, view_cls: type[RailView], *, position: int | None = None - ) -> None: - if position is None: - self._rail_views.append(view_cls) - else: - self._rail_views.insert(position, view_cls) - - @property - def rail_views(self) -> tuple[type[RailView], ...]: - return tuple(self._rail_views) - - # ── Settings sections ───────────────────────────── - - def add_settings_section(self, factory: SettingsSectionFactory) -> None: - """Show a plugin's section, built per dialog with the dialog as parent.""" - self._settings_sections.append(factory) - - @property - def settings_sections(self) -> tuple[SettingsSectionFactory, ...]: - return tuple(self._settings_sections) - - # ── Toasts ──────────────────────────────────────── - - @property - def toasts(self) -> Notifier | None: - return self._toasts - - @toasts.setter - def toasts(self, notifier: Notifier | None) -> None: - self._toasts = notifier - - # ── Navigation ──────────────────────────────────── - - def navigate(self, url: str) -> None: - """Open a ``modrim://[/…]`` link (no-op if the view is unknown).""" - if self._navigate is not None: - self._navigate(url) - - def set_navigator(self, navigate: Callable[[str], None] | None) -> None: - self._navigate = navigate - - # ── UI prefs ────────────────────────────────────── - - @property - def ui_prefs(self) -> UIPrefs: - return self._ui_prefs - - # ── Core access ─────────────────────────────────── - - @property - def core(self) -> CoreContext: - return self._core - - # ── Lifecycle ───────────────────────────────────── - - def setup_all(self) -> None: - self._core.plugins.setup_all(self._core) - core_names = self._core.plugins.names - self._plugins.setup_all(self, extra_deps=core_names) - - async def init_all(self) -> None: - await self._core.plugins.init_all(self._core) - await self._plugins.init_all(self) - - async def refresh_mods(self, full: bool = False) -> int: - await self._core.mod_service.reload(full=full) - return len(self._core.all_mods) - - async def shutdown_all(self) -> None: - await self._plugins.shutdown_all() - await self._core.plugins.shutdown_all() diff --git a/legacy/pxmodrim/ui/mod_selection.py b/legacy/pxmodrim/ui/mod_selection.py deleted file mode 100644 index 7f03719..0000000 --- a/legacy/pxmodrim/ui/mod_selection.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel - - -class ModSelectionPresenter: - """Loads a selected mod's details into a ModInfoPanel for any view.""" - - def __init__(self, ctx: CoreContext, panel: ModInfoPanel) -> None: - self._ctx = ctx - self._panel = panel - self._generation = 0 - - async def show(self, uuid: str) -> None: - generation = self._generation + 1 - self._generation = generation - - if not uuid: - self._panel.clear() - return - - mod = self._ctx.all_mods.get(uuid) - if mod is None: - self._panel.clear() - return - - self._panel.show_mod(mod, self._ctx.diagnostics_service.issues_for(uuid)) - pid = getattr(mod, "package_id", None) - active_pids = self._resolve_active_pids() - - if generation != self._generation: - return - await self._panel.set_time_analytics( - str(pid) if pid is not None else None, - active_pids, - ) - - def clear(self) -> None: - self._generation += 1 - self._panel.clear() - - def _resolve_active_pids(self) -> list[str]: - active = set(self._ctx.active_uuids) - return [ - str(m.package_id).lower() - for m in self._ctx.all_mods.values() - if isinstance(m, AboutXmlMod) and m.uuid in active - ] diff --git a/legacy/pxmodrim/ui/models/__init__.py b/legacy/pxmodrim/ui/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/ui/models/impact.py b/legacy/pxmodrim/ui/models/impact.py deleted file mode 100644 index b030c2e..0000000 --- a/legacy/pxmodrim/ui/models/impact.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.services.startup_impact_service.models import ( - IMPACT_HIGH_THRESHOLD_S, - IMPACT_WARN_THRESHOLD_S, -) -from pxmodrim.ui.theme.palette import PALETTE - - -def format_duration(seconds: float) -> str: - if seconds < 0.001: - return "0 ms" - if seconds < 1: - return f"{round(seconds * 1000)} ms" - if seconds < 60: - return f"{seconds:.2f} s" - minutes, secs = divmod(round(seconds), 60) - return f"{minutes}m {secs:02d}s" - - -def impact_color(seconds: float) -> str: - if seconds < IMPACT_WARN_THRESHOLD_S: - return PALETTE["SUCCESS"] - if seconds < IMPACT_HIGH_THRESHOLD_S: - return PALETTE["WARNING"] - return PALETTE["DANGER"] diff --git a/legacy/pxmodrim/ui/models/mod_list_model.py b/legacy/pxmodrim/ui/models/mod_list_model.py deleted file mode 100644 index 0cb7571..0000000 --- a/legacy/pxmodrim/ui/models/mod_list_model.py +++ /dev/null @@ -1,435 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -def provider_label(provider_id: str, package_id: str = "") -> str: - pid = provider_id.lower().strip() - pkg = package_id.lower().strip() - if pid in {"steam", "downloaded"}: - return "Workshop" - if pid == "local": - return "Local" - if pid == "core": - if pkg and pkg != "ludeon.rimworld": - return "DLC" - return "Core" - if pkg.startswith("ludeon.rimworld.") and pkg != "ludeon.rimworld": - return "DLC" - if pkg == "ludeon.rimworld": - return "Core" - return pid.capitalize() if pid else "" - - -def section_name(checked: bool, active_count: int, inactive_count: int) -> str: - if checked or active_count == 0 or inactive_count == 0: - return "" - return f"Inactive ({inactive_count})" - - -@dataclass(slots=True) -class ModItem: - mod: ListedMod - uuid: str - checked: bool = False - provider_color: str = PALETTE["TEXT_DIM"] - startup_impact_s: float = 0.0 - load_index: int = -1 - provider_label: str = "" - - -class ModListModel(QAbstractListModel): - CheckStateRole = Qt.ItemDataRole.UserRole + 1 - PackageIdRole = Qt.ItemDataRole.UserRole + 2 - ModVersionRole = Qt.ItemDataRole.UserRole + 3 - ProviderColorRole = Qt.ItemDataRole.UserRole + 4 - UuidRole = Qt.ItemDataRole.UserRole + 5 - HasErrorRole = Qt.ItemDataRole.UserRole + 6 - HasWarningRole = Qt.ItemDataRole.UserRole + 7 - ErrorTooltipRole = Qt.ItemDataRole.UserRole + 8 - WarningTooltipRole = Qt.ItemDataRole.UserRole + 9 - StartupImpactRole = Qt.ItemDataRole.UserRole + 10 - LoadIndexRole = Qt.ItemDataRole.UserRole + 11 - IsActiveRole = Qt.ItemDataRole.UserRole + 12 - SectionNameRole = Qt.ItemDataRole.UserRole + 13 - ProviderLabelRole = Qt.ItemDataRole.UserRole + 14 - StartupImpactColorRole = Qt.ItemDataRole.UserRole + 15 - StartupImpactTextRole = Qt.ItemDataRole.UserRole + 16 - active_mods_changed = Signal() - active_count_changed = Signal() - - @Property(int, notify=active_count_changed) - def activeCount(self) -> int: - return self._active_count - - def __init__( - self, - provider_colors: dict[str, str], - diagnostics: DiagnosticsService | None = None, - parent: QObject | None = None, - ) -> None: - super().__init__(parent) - self._provider_colors = provider_colors - self._items: list[ModItem] = [] - self._active_count: int = 0 - self._diag = diagnostics - if diagnostics is not None: - diagnostics.diagnostics_summary_changed.connect( - self._on_diagnostics_summary_changed - ) - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() < 0 or index.row() >= len(self._items): - return None - - item = self._items[index.row()] - - if role == Qt.ItemDataRole.DisplayRole: - return item.mod.name - if role == self.CheckStateRole: - return Qt.CheckState.Checked if item.checked else Qt.CheckState.Unchecked - if role == self.PackageIdRole: - return str(item.mod.package_id) if isinstance(item.mod, AboutXmlMod) else "" - if role == self.ModVersionRole: - return item.mod.mod_version if isinstance(item.mod, AboutXmlMod) else "" - if role == self.ProviderColorRole: - return item.provider_color - if role == self.UuidRole: - return item.uuid - if role in { - self.HasErrorRole, - self.HasWarningRole, - self.ErrorTooltipRole, - self.WarningTooltipRole, - }: - diag = self._diag.summary_for(item.uuid) if self._diag else None - if role == self.HasErrorRole: - return 1 if diag is not None and diag.has_errors else 0 - if role == self.HasWarningRole: - return 1 if diag is not None and diag.has_warnings else 0 - if role == self.ErrorTooltipRole: - return diag.error_tooltip if diag is not None else "" - if role == self.WarningTooltipRole: - return diag.warning_tooltip if diag is not None else "" - if role == self.StartupImpactRole: - return item.startup_impact_s - if role == self.StartupImpactColorRole: - return impact_color(item.startup_impact_s) - if role == self.StartupImpactTextRole: - return format_duration(item.startup_impact_s) - if role == self.LoadIndexRole: - return item.load_index - if role == self.IsActiveRole: - return item.checked - if role == self.SectionNameRole: - return section_name( - item.checked, self._active_count, len(self._items) - self._active_count - ) - if role == self.ProviderLabelRole: - if item.provider_label: - return item.provider_label - pkg = str(getattr(item.mod, "package_id", "")) - return provider_label(item.mod.provider_id, pkg) - if ( - role == Qt.ItemDataRole.ToolTipRole - and hasattr(item.mod, "description") - and item.mod.description - ): - return item.mod.description - - return None - - def setData( - self, - index: QModelIndex | QPersistentModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if not index.isValid() or index.row() < 0 or index.row() >= len(self._items): - return False - - if role == self.CheckStateRole: - self.set_check_states([index.row()], value == Qt.CheckState.Checked) - return True - - return False - - def set_startup_impact(self, impacts: dict[str, float] | None = None) -> None: - if not self._items: - return - if impacts is None: - impacts = {} - for item in self._items: - mod = item.mod - pid = getattr(mod, "package_id", None) - item.startup_impact_s = ( - impacts.get(str(pid), 0.0) if pid is not None else 0.0 - ) - - top = self.index(0, 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit( - top, - bottom, - [ - self.StartupImpactRole, - self.StartupImpactColorRole, - self.StartupImpactTextRole, - ], - ) - - def _on_diagnostics_summary_changed(self, diags: dict[str, Any]) -> None: - if not self._items: - return - old = getattr(self, "_last_diags", {}) or {} - self._last_diags = diags - changed = [ - row - for row, item in enumerate(self._items) - if old.get(item.uuid) != diags.get(item.uuid) - ] - if not changed: - return - top = self.index(min(changed), 0) - bottom = self.index(max(changed), 0) - self.dataChanged.emit( - top, - bottom, - [ - self.HasErrorRole, - self.HasWarningRole, - self.ErrorTooltipRole, - self.WarningTooltipRole, - ], - ) - - def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags | Qt.ItemFlag.ItemIsDropEnabled - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsUserCheckable - ) - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"name"), - self.CheckStateRole: QByteArray(b"checkState"), - self.PackageIdRole: QByteArray(b"packageId"), - self.ModVersionRole: QByteArray(b"modVersion"), - self.ProviderColorRole: QByteArray(b"providerColor"), - self.UuidRole: QByteArray(b"uuid"), - self.HasErrorRole: QByteArray(b"hasError"), - self.HasWarningRole: QByteArray(b"hasWarning"), - self.ErrorTooltipRole: QByteArray(b"errorTooltip"), - self.WarningTooltipRole: QByteArray(b"warningTooltip"), - self.StartupImpactRole: QByteArray(b"startupImpact"), - self.LoadIndexRole: QByteArray(b"loadIndex"), - self.IsActiveRole: QByteArray(b"isActive"), - self.SectionNameRole: QByteArray(b"sectionName"), - self.ProviderLabelRole: QByteArray(b"providerLabel"), - self.StartupImpactColorRole: QByteArray(b"startupImpactColor"), - self.StartupImpactTextRole: QByteArray(b"startupImpactText"), - Qt.ItemDataRole.ToolTipRole: QByteArray(b"toolTip"), - } - - def update_provider_colors(self, provider_colors: dict[str, str]) -> None: - self._provider_colors = provider_colors - - def move_row(self, source_row: int, target_row: int) -> bool: - if source_row == target_row: - return False - if not (0 <= source_row < len(self._items)): - return False - if not (0 <= target_row < len(self._items)): - return False - - parent = QModelIndex() - destination_child = target_row + 1 if source_row < target_row else target_row - self.beginMoveRows(parent, source_row, source_row, parent, destination_child) - item = self._items.pop(source_row) - self._items.insert(target_row, item) - self.endMoveRows() - changed = self._update_load_indices() - if changed: - top = self.index(min(changed), 0) - bottom = self.index(max(changed), 0) - self.dataChanged.emit( - top, - bottom, - [self.LoadIndexRole, self.SectionNameRole], - ) - return True - - def commitOrder(self, new_ordered_uuids: list[str]) -> None: - item_by_uuid = {item.uuid: item for item in self._items} - existing_uuids = [uuid for uuid in new_ordered_uuids if uuid in item_by_uuid] - - ordered_set = set(existing_uuids) - ordered_items = [item_by_uuid[uuid] for uuid in existing_uuids] - remaining = [item for item in self._items if item.uuid not in ordered_set] - # Keep the load_mods invariant: active block first, inactive block by name. - new_items = ( - ordered_items - + [item for item in remaining if item.checked] - + sorted( - (item for item in remaining if not item.checked), - key=lambda item: item.mod.name.lower(), - ) - ) - - if new_items == self._items: - return - - old_uuid_to_row = {item.uuid: i for i, item in enumerate(self._items)} - new_row_for_old = { - old_uuid_to_row[item.uuid]: i for i, item in enumerate(new_items) - } - - self.layoutAboutToBeChanged.emit() - persistent_indexes = self.persistentIndexList() - self._items = new_items - self._update_load_indices() - new_persistent = [ - self.index(new_row_for_old.get(index.row(), -1), 0) - for index in persistent_indexes - ] - if persistent_indexes: - self.changePersistentIndexList(persistent_indexes, new_persistent) - self.layoutChanged.emit() - - def _make_item(self, uuid: str, mod: ListedMod, checked: bool) -> ModItem: - return ModItem( - mod=mod, - uuid=uuid, - checked=checked, - provider_color=self._provider_colors.get( - mod.provider_id, PALETTE["TEXT_DIM"] - ), - provider_label=provider_label( - mod.provider_id, str(getattr(mod, "package_id", "")) - ), - ) - - def load_mods(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - self.beginResetModel() - try: - active_set = set(active_uuids) - inactive = sorted( - ((uuid, mod) for uuid, mod in mods.items() if uuid not in active_set), - key=lambda kv: kv[1].name.lower(), - ) - self._items = [ - self._make_item(uuid, mods[uuid], True) - for uuid in active_uuids - if uuid in mods - ] + [self._make_item(uuid, mod, False) for uuid, mod in inactive] - self._update_load_indices() - finally: - self.endResetModel() - - def active_uuids(self) -> list[str]: - return [item.uuid for item in self._items if item.checked] - - def get_item(self, row: int) -> ModItem | None: - if 0 <= row < len(self._items): - return self._items[row] - return None - - def get_item_by_uuid(self, uuid: str) -> ModItem | None: - for item in self._items: - if item.uuid == uuid: - return item - return None - - def set_checkable(self, uuids: frozenset[str], checked: bool) -> None: - if not uuids: - return - rows = [ - i - for i, it in enumerate(self._items) - if it.uuid in uuids and it.checked != checked - ] - self.set_check_states(rows, checked) - - def set_check_states(self, rows: list[int], checked: bool) -> None: - if not rows: - return - changed_rows: list[int] = [] - for row in rows: - if 0 <= row < len(self._items) and self._items[row].checked != checked: - self._items[row].checked = checked - changed_rows.append(row) - if not changed_rows: - return - load_changed = self._update_load_indices() - all_affected = set(changed_rows) | set(load_changed) - top = self.index(min(all_affected), 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit( - top, - bottom, - [ - self.CheckStateRole, - self.LoadIndexRole, - self.IsActiveRole, - self.SectionNameRole, - ], - ) - self.active_mods_changed.emit() - - def _update_load_indices(self) -> list[int]: - changed: list[int] = [] - counter = 0 - for row, item in enumerate(self._items): - if item.checked: - counter += 1 - new_idx = counter - else: - new_idx = -1 - if item.load_index != new_idx: - item.load_index = new_idx - changed.append(row) - old_active = self._active_count - self._active_count = counter - if old_active != self._active_count: - self.active_count_changed.emit() - return changed - - @property - def active_count(self) -> int: - return self._active_count diff --git a/legacy/pxmodrim/ui/models/mod_list_proxy_model.py b/legacy/pxmodrim/ui/models/mod_list_proxy_model.py deleted file mode 100644 index 532c875..0000000 --- a/legacy/pxmodrim/ui/models/mod_list_proxy_model.py +++ /dev/null @@ -1,352 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.ui.models.mod_list_model import ModListModel, section_name - - -class ModListProxyModel(QAbstractListModel): - is_filtered_changed = Signal() - active_count_changed = Signal() - - @property - def is_filtered(self) -> bool: - return bool(self._search_text) or self._sidebar_uuids is not None - - @Property(bool, notify=is_filtered_changed) - def isFiltered(self) -> bool: - return self.is_filtered - - @Property(int, notify=active_count_changed) - def activeCount(self) -> int: - return self._visible_active_count - - def __init__(self, source_model: ModListModel, parent: Any = None) -> None: - super().__init__(parent) - self._source = source_model - self._search_text = "" - self._sidebar_uuids: set[str] | None = None - self._visible_uuids: list[str] = [] - self._source_row_for_uuid: dict[str, int] = {} - self._proxy_row_by_uuid: dict[str, int] = {} - self._in_proxy_move = False - self._visible_active_count: int = 0 - self._visible_inactive_count: int = 0 - - self._source.dataChanged.connect(self._on_source_data_changed) - self._source.layoutChanged.connect(self._on_source_layout_changed) - self._source.modelReset.connect(self._on_source_model_reset) - self._source.rowsMoved.connect(self._on_source_rows_moved) - self._source.rowsInserted.connect(self._on_source_rows_inserted) - self._source.rowsRemoved.connect(self._on_source_rows_removed) - - self._rebuild() - - def _rebuild(self) -> None: - self._rebuild_source_mapping() - self._visible_uuids = [ - uuid - for uuid, row in self._source_row_for_uuid.items() - if self._accept_item(self._source.get_item(row)) - ] - self._proxy_row_by_uuid = { - uuid: row for row, uuid in enumerate(self._visible_uuids) - } - self._update_visible_counts() - - def _rebuild_source_mapping(self) -> None: - self._source_row_for_uuid = { - item.uuid: row - for row in range(self._source.rowCount()) - if (item := self._source.get_item(row)) is not None - } - - def _accept_item(self, item: Any) -> bool: - if self._sidebar_uuids is not None and item.uuid not in self._sidebar_uuids: - return False - - if self._search_text: - name_match = self._search_text in item.mod.name.strip().casefold() - pid_match = ( - isinstance(item.mod, AboutXmlMod) - and self._search_text in str(item.mod.package_id).strip().casefold() - ) - if not (name_match or pid_match): - return False - - return True - - def proxy_row_for_uuid(self, uuid: str) -> int | None: - return self._proxy_row_by_uuid.get(uuid) - - def set_search_filter(self, search_text: str) -> None: - text = search_text.strip().casefold() - if self._search_text == text: - return - old_filtered = self.is_filtered - self._search_text = text - self._apply_filter_change() - if self.is_filtered != old_filtered: - self.is_filtered_changed.emit() - - def set_sidebar_filter(self, uuids: set[str] | None) -> None: - if self._sidebar_uuids == uuids: - return - old_filtered = self.is_filtered - self._sidebar_uuids = uuids - self._apply_filter_change() - if self.is_filtered != old_filtered: - self.is_filtered_changed.emit() - - def _apply_filter_change(self) -> None: - self._layout_rebuild() - - def _layout_rebuild(self) -> None: - persistent_indexes = self.persistentIndexList() - persistent_uuids = [ - self._visible_uuids[index.row()] - if index.isValid() and 0 <= index.row() < len(self._visible_uuids) - else None - for index in persistent_indexes - ] - self.layoutAboutToBeChanged.emit() - self._rebuild() - new_indexes = [ - self.index(self._proxy_row_by_uuid[uuid], 0) - if uuid is not None and uuid in self._proxy_row_by_uuid - else QModelIndex() - for uuid in persistent_uuids - ] - self.changePersistentIndexList(persistent_indexes, new_indexes) - self.layoutChanged.emit() - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._visible_uuids) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if ( - not index.isValid() - or index.row() < 0 - or index.row() >= len(self._visible_uuids) - ): - return None - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return None - if role == ModListModel.SectionNameRole: - item = self._source.get_item(source_row) - return section_name( - item is None or item.checked, - self._visible_active_count, - self._visible_inactive_count, - ) - return self._source.data(self._source.index(source_row, 0), role) - - def setData( - self, - index: QModelIndex | QPersistentModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if ( - not index.isValid() - or index.row() < 0 - or index.row() >= len(self._visible_uuids) - ): - return False - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return False - return self._source.setData(self._source.index(source_row, 0), value, role) - - def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags | Qt.ItemFlag.ItemIsDropEnabled - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return Qt.ItemFlag.NoItemFlags - return self._source.flags(self._source.index(source_row, 0)) - - def roleNames(self) -> dict[int, QByteArray]: - return self._source.roleNames() - - def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - if ( - not proxy_index.isValid() - or proxy_index.row() < 0 - or proxy_index.row() >= len(self._visible_uuids) - ): - return QModelIndex() - source_row = self._source_row_for_uuid.get( - self._visible_uuids[proxy_index.row()], -1 - ) - if source_row < 0: - return QModelIndex() - return self._source.index(source_row, 0) - - def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - if not source_index.isValid(): - return QModelIndex() - item = self._source.get_item(source_index.row()) - if item is None: - return QModelIndex() - proxy_row = self.proxy_row_for_uuid(item.uuid) - if proxy_row is None: - return QModelIndex() - return self.index(proxy_row, 0) - - def move_row( - self, proxy_source: int, proxy_target: int, *, clamp_to_active: bool = False - ) -> bool: - if not (0 <= proxy_source < len(self._visible_uuids)): - return False - source_uuid = self._visible_uuids[proxy_source] - source_source_row = self._source_row_for_uuid.get(source_uuid, -1) - if clamp_to_active: - item = self._source.get_item(source_source_row) - if self.is_filtered or item is None or not item.checked: - return False - proxy_target = max(0, min(proxy_target, self._visible_active_count - 1)) - if proxy_source == proxy_target: - return False - if not (0 <= proxy_target < len(self._visible_uuids)): - return False - - source_target_row = self._source_row_for_uuid.get( - self._visible_uuids[proxy_target], -1 - ) - if source_source_row < 0 or source_target_row < 0: - return False - - destination_child = ( - proxy_target + 1 if proxy_source < proxy_target else proxy_target - ) - - # Source signals carry post-move rows; forwarding them before endMoveRows - # would make views apply the data to delegates still at pre-move rows. - self._in_proxy_move = True - try: - self.beginMoveRows( - QModelIndex(), - proxy_source, - proxy_source, - QModelIndex(), - destination_child, - ) - self._visible_uuids.pop(proxy_source) - self._visible_uuids.insert(proxy_target, source_uuid) - self._proxy_row_by_uuid = { - uuid: row for row, uuid in enumerate(self._visible_uuids) - } - self._source.move_row(source_source_row, source_target_row) - self._rebuild_source_mapping() - self.endMoveRows() - finally: - self._in_proxy_move = False - self.dataChanged.emit( - self.index(min(proxy_source, proxy_target), 0), - self.index(max(proxy_source, proxy_target), 0), - [ModListModel.LoadIndexRole, ModListModel.SectionNameRole], - ) - return True - - def _on_source_data_changed( - self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] - ) -> None: - if self._in_proxy_move: - return - if ( - not roles - or ModListModel.CheckStateRole in roles - or ModListModel.IsActiveRole in roles - ): - self._update_visible_counts() - first = top_left.row() - last = bottom_right.row() - proxy_rows: list[int] = [] - for source_row in range(first, last + 1): - item = self._source.get_item(source_row) - if item is None: - continue - proxy_row = self.proxy_row_for_uuid(item.uuid) - if proxy_row is not None: - proxy_rows.append(proxy_row) - if not proxy_rows: - return - top = self.index(min(proxy_rows), 0) - bottom = self.index(max(proxy_rows), 0) - self.dataChanged.emit(top, bottom, roles) - - def _on_source_layout_changed(self) -> None: - if self._in_proxy_move: - return - self._layout_rebuild() - - def _on_source_model_reset(self) -> None: - self.beginResetModel() - self._rebuild() - self.endResetModel() - - def _on_source_rows_moved( - self, - parent: QModelIndex, - start: int, - end: int, - destination: QModelIndex, - row: int, - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _on_source_rows_inserted( - self, parent: QModelIndex, first: int, last: int - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _on_source_rows_removed( - self, parent: QModelIndex, first: int, last: int - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _update_visible_counts(self) -> None: - active = 0 - inactive = 0 - for uuid in self._visible_uuids: - source_row = self._source_row_for_uuid.get(uuid, -1) - item = self._source.get_item(source_row) - if item is not None: - if item.checked: - active += 1 - else: - inactive += 1 - changed = self._visible_active_count != active - self._visible_active_count = active - self._visible_inactive_count = inactive - if changed: - self.active_count_changed.emit() diff --git a/legacy/pxmodrim/ui/models/sidebar_model.py b/legacy/pxmodrim/ui/models/sidebar_model.py deleted file mode 100644 index 9b1fd63..0000000 --- a/legacy/pxmodrim/ui/models/sidebar_model.py +++ /dev/null @@ -1,249 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.ui.theme.palette import PALETTE - -# (badge_bg, badge_fg, icon_name, icon_color) -_ENTRY_TYPES: dict[str, tuple[str, str, str, str]] = { - "all": (PALETTE["ELEVATE_4"], PALETTE["TEXT_MUTED"], "grid", PALETTE["TEXT_DIM"]), - "active": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "check-circle", - PALETTE["TEXT_DIM"], - ), - "inactive": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "ban", - PALETTE["TEXT_DIM"], - ), - "errors": (PALETTE["DANGER_BG_QML"], PALETTE["DANGER"], "error", PALETTE["DANGER"]), - "warnings": ( - PALETTE["WARNING_BG_QML"], - PALETTE["WARNING"], - "warning", - PALETTE["WARNING"], - ), - "provider": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "folder", - PALETTE["TEXT_DIM"], - ), -} - - -# Provider icon overrides -_PROVIDER_ICONS: dict[str, str] = { - "steam": "steam", - "downloaded": "steam", - "local": "folder", - "core": "grid", - "git": "git", -} - - -class SidebarItem: - __slots__ = ( - "badge_bg", - "badge_fg", - "count", - "entry", - "icon_color", - "icon_name", - "label", - "section_name", - ) - - def __init__( - self, - label: str, - count: int, - badge_bg: str, - badge_fg: str, - icon_name: str, - icon_color: str, - section_name: str, - entry: object, - ) -> None: - self.label = label - self.count = count - self.badge_bg = badge_bg - self.badge_fg = badge_fg - self.icon_name = icon_name - self.icon_color = icon_color - self.section_name = section_name - self.entry = entry - - -def _detect_entry_type(entry: object) -> str: - cls = type(entry).__name__ - if "Active" in cls and "Inactive" not in cls: - return "active" - if "Inactive" in cls: - return "inactive" - if "Error" in cls: - return "errors" - if "Warning" in cls: - return "warnings" - if "All" in cls: - return "all" - return "provider" - - -def _section_for(entry: object) -> str: - return "Providers" if _detect_entry_type(entry) == "provider" else "Status" - - -def _icon_for_provider(entry: object) -> str: - """Determine provider icon from entry data.""" - label = getattr(entry, "label", "").lower() - for keyword, icon_name in _PROVIDER_ICONS.items(): - if keyword in label: - return icon_name - return "folder" - - -def _style_for(entry: object) -> tuple[str, str, str, str]: - entry_type = _detect_entry_type(entry) - bg, fg, icon_name, icon_color = _ENTRY_TYPES[entry_type] - if entry_type == "provider": - icon_name = _icon_for_provider(entry) - return bg, fg, icon_name, icon_color - - -class SidebarModel(QAbstractListModel): - LabelRole = Qt.ItemDataRole.UserRole + 1 - CountRole = Qt.ItemDataRole.UserRole + 2 - BadgeBgRole = Qt.ItemDataRole.UserRole + 3 - BadgeFgRole = Qt.ItemDataRole.UserRole + 4 - IconRole = Qt.ItemDataRole.UserRole + 5 - IconColorRole = Qt.ItemDataRole.UserRole + 6 - SectionRole = Qt.ItemDataRole.UserRole + 7 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[SidebarItem] = [] - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._items): - return None - - item = self._items[index.row()] - - if role == Qt.ItemDataRole.DisplayRole or role == self.LabelRole: - return item.label if item.label else "" - if role == self.CountRole: - return item.count if item.count is not None else 0 - if role == self.BadgeBgRole: - return item.badge_bg if item.badge_bg else "" - if role == self.BadgeFgRole: - return item.badge_fg if item.badge_fg else "" - if role == self.IconRole: - return item.icon_name if item.icon_name else "" - if role == self.IconColorRole: - return item.icon_color if item.icon_color else "" - if role == self.SectionRole: - return item.section_name if item.section_name else "" - - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self.LabelRole: QByteArray(b"filterLabel"), - self.CountRole: QByteArray(b"count"), - self.BadgeBgRole: QByteArray(b"badgeBg"), - self.BadgeFgRole: QByteArray(b"badgeFg"), - self.IconRole: QByteArray(b"iconName"), - self.IconColorRole: QByteArray(b"iconColor"), - self.SectionRole: QByteArray(b"sectionName"), - } - - def set_entries(self, entries: Sequence[object]) -> None: - self.beginResetModel() - self._items = [] - for e in entries: - bg, fg, icon_name, icon_color = _style_for(e) - section = _section_for(e) - self._items.append( - SidebarItem( - label=getattr(e, "label", ""), - count=getattr(e, "count", 0), - badge_bg=bg, - badge_fg=fg, - icon_name=icon_name, - icon_color=icon_color, - section_name=section, - entry=e, - ) - ) - self.endResetModel() - - def update_entries(self, entries: Sequence[object]) -> None: - if len(entries) != len(self._items): - self.set_entries(entries) - return - - changed_roles: set[int] = set() - for i, entry in enumerate(entries): - item = self._items[i] - bg, fg, icon_name, icon_color = _style_for(entry) - - new_label = getattr(entry, "label", "") or "" - new_count = getattr(entry, "count", 0) - - if item.label != new_label: - item.label = new_label - changed_roles.add(self.LabelRole) - if item.count != new_count: - item.count = new_count - changed_roles.add(self.CountRole) - if item.badge_bg != bg: - item.badge_bg = bg - changed_roles.add(self.BadgeBgRole) - if item.badge_fg != fg: - item.badge_fg = fg - changed_roles.add(self.BadgeFgRole) - if item.icon_name != icon_name: - item.icon_name = icon_name - changed_roles.add(self.IconRole) - if item.icon_color != icon_color: - item.icon_color = icon_color - changed_roles.add(self.IconColorRole) - - item.entry = entry - - if changed_roles and self._items: - top = self.index(0, 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit(top, bottom, list(changed_roles)) - - def get_entry(self, row: int) -> object | None: - if 0 <= row < len(self._items): - return self._items[row].entry - return None diff --git a/legacy/pxmodrim/ui/navigation.py b/legacy/pxmodrim/ui/navigation.py deleted file mode 100644 index 77822a9..0000000 --- a/legacy/pxmodrim/ui/navigation.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from urllib.parse import quote, unquote, urlsplit - -SCHEME = "modrim" -SETTINGS_VIEW_ID = "settings" - - -@dataclass(frozen=True, slots=True) -class Route: - view_id: str - path: tuple[str, ...] = () - - @property - def url(self) -> str: - return build_route(self.view_id, *self.path) - - -def build_route(view_id: str, *path: str) -> str: - segments = "".join(f"/{quote(part, safe='')}" for part in path) - return f"{SCHEME}://{view_id}{segments}" - - -def parse_route(url: str) -> Route | None: - """Parse ``modrim://[/…]``; anything else is not a route.""" - try: - parts = urlsplit(url) - except ValueError: - return None - if parts.scheme != SCHEME or not parts.netloc: - return None - path = tuple(unquote(part) for part in parts.path.split("/") if part) - return Route(parts.netloc, path) diff --git a/legacy/pxmodrim/ui/panels/ModInfo.qml b/legacy/pxmodrim/ui/panels/ModInfo.qml deleted file mode 100644 index 2f27af8..0000000 --- a/legacy/pxmodrim/ui/panels/ModInfo.qml +++ /dev/null @@ -1,539 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../components/controls" - -Rectangle { - id: root - color: Theme.elevate2 - clip: true - - property var info: null - property string description: "" - property var startup: null - readonly property bool startupKnown: startup !== null && startup !== undefined - property bool descExpanded: false - property bool downloadsBusy: false - property bool updatingThis: false - property bool neededExpanded: false - - signal openFolder() - signal openUrl() - signal updateMods() - signal selectMod(string uuid) - signal copyText(string text) - signal openStartupDetails() - signal descToggled(bool expanded) - - readonly property bool hasInfo: info !== null && info !== undefined - readonly property var statusData: hasInfo ? info.status : null - readonly property color levelColor: !statusData ? Theme.textMuted - : statusData.level === "error" ? Theme.danger - : statusData.level === "warning" ? Theme.warning - : Theme.success - readonly property color levelBg: !statusData ? Theme.elevate3 - : statusData.level === "error" ? Theme.dangerBg - : statusData.level === "warning" ? Theme.warningBg - : Theme.successBg - - function stateColor(state) { - return state === "active" ? Theme.success : state === "inactive" ? Theme.warning : Theme.danger - } - function stateBg(state) { - return state === "active" ? Theme.successBg : state === "inactive" ? Theme.warningBg : Theme.dangerBg - } - function stateMark(state) { - return state === "active" ? "\u2713 " : state === "inactive" ? "\u25CF " : "\u2715 " - } - function stateHint(state) { - return state === "active" ? "Active" : state === "inactive" ? "Installed but not active" : "Not installed" - } - - component Card: Rectangle { - default property alias content: column.data - property color tint: "transparent" - property color edge: Theme.border - Layout.fillWidth: true - implicitHeight: column.implicitHeight + 24 - radius: Theme.radiusMd - color: Theme.elevate3 - border.width: 1 - border.color: edge - - Rectangle { - anchors.fill: parent - radius: parent.radius - color: parent.tint - } - - ColumnLayout { - id: column - anchors.fill: parent - anchors.margins: 12 - spacing: 8 - } - } - - // Badge that jumps to another mod when it has a uuid. - component ModBadge: PxBadge { - property string uuid: "" - readonly property bool linked: uuid.length > 0 - - HoverHandler { - id: badgeHover - enabled: parent.linked - cursorShape: Qt.PointingHandCursor - } - TapHandler { - enabled: parent.linked - onTapped: root.selectMod(parent.uuid) - } - Rectangle { - anchors.fill: parent - radius: parent.radius - color: Theme.textMain - opacity: badgeHover.hovered ? 0.12 : 0 - } - } - - component CardLabel: Text { - Layout.fillWidth: true - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Bold - font.capitalization: Font.AllUppercase - font.letterSpacing: 0.8 - } - - function resetScroll() { - flick.contentY = 0 - neededExpanded = false - } - - Flickable { - id: flick - objectName: "flick" - anchors.fill: parent - visible: root.hasInfo - contentHeight: page.implicitHeight + 28 - clip: true - boundsBehavior: Flickable.StopAtBounds - - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - - ColumnLayout { - id: page - x: 14 - y: 14 - width: parent.width - 28 - spacing: 12 - - // ── Status ── - Card { - tint: root.levelBg - edge: root.statusData && root.statusData.level !== "ok" ? root.levelColor : Theme.border - - Flow { - Layout.fillWidth: true - spacing: 6 - - PxBadge { - text: root.statusData ? root.statusData.title : "" - textColor: root.levelColor - fillColor: root.levelBg - } - PxBadge { - visible: root.hasInfo && root.info.version.known - text: root.hasInfo && root.info.version.ok - ? "\u2713 Supports " + root.info.version.target - : "\u2715 No " + (root.hasInfo ? root.info.version.target : "") + " support" - textColor: root.hasInfo && root.info.version.ok ? Theme.success : Theme.warning - fillColor: root.hasInfo && root.info.version.ok ? Theme.successBg : Theme.warningBg - } - PxBadge { - visible: root.statusData !== null && root.statusData.obsolete - text: "Obsolete" - textColor: Theme.warning - fillColor: Theme.warningBg - } - } - - Text { - Layout.fillWidth: true - text: !root.statusData ? "" - : root.statusData.active - ? "Active \u00B7 position #" + root.statusData.position + " of " + root.statusData.total - : "Inactive" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - - Repeater { - model: root.statusData ? root.statusData.issues : [] - - delegate: ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Text { - Layout.fillWidth: true - text: modelData.title - color: modelData.isError ? Theme.danger : Theme.warning - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: Font.Bold - wrapMode: Text.WordWrap - } - Text { - Layout.fillWidth: true - visible: modelData.detail.length > 0 - text: modelData.detail - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - wrapMode: Text.WordWrap - } - } - } - } - - // ── Relationships ── - Card { - CardLabel { text: "Requires" } - Text { - visible: root.hasInfo && root.info.needs.length === 0 - text: "No dependencies" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - Flow { - Layout.fillWidth: true - spacing: 6 - visible: root.hasInfo && root.info.needs.length > 0 - - Repeater { - model: root.hasInfo ? root.info.needs : [] - delegate: ModBadge { - uuid: modelData.uuid - text: root.stateMark(modelData.state) + modelData.name - textColor: root.stateColor(modelData.state) - fillColor: root.stateBg(modelData.state) - tooltip: root.stateHint(modelData.state) + (modelData.uuid.length > 0 ? " \u2014 click to open" : "") - } - } - } - - CardLabel { - Layout.topMargin: 4 - visible: root.hasInfo && root.info.conflicts.length > 0 - text: "Conflicts with (active)" - } - Flow { - Layout.fillWidth: true - spacing: 6 - visible: root.hasInfo && root.info.conflicts.length > 0 - - Repeater { - model: root.hasInfo ? root.info.conflicts : [] - delegate: PxBadge { - text: "\u2715 " + modelData - textColor: Theme.danger - fillColor: Theme.dangerBg - } - } - } - - CardLabel { - Layout.topMargin: 4 - visible: root.hasInfo && root.info.neededBy.length > 0 - text: "Needed by" - } - Flow { - Layout.fillWidth: true - spacing: 6 - visible: root.hasInfo && root.info.neededBy.length > 0 - - Repeater { - model: !root.hasInfo ? [] : root.neededExpanded ? root.info.neededBy : root.info.neededBy.slice(0, 6) - delegate: ModBadge { - uuid: modelData.uuid - text: modelData.name - tooltip: "Click to open" - textColor: Theme.textMuted - fillColor: Theme.elevate4 - } - } - Text { - visible: root.hasInfo && root.info.neededBy.length > 6 - text: root.neededExpanded ? "Show less" : "+" + (root.hasInfo ? root.info.neededBy.length - 6 : 0) + " more" - color: Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - height: 20 - verticalAlignment: Text.AlignVCenter - - TapHandler { onTapped: root.neededExpanded = !root.neededExpanded } - HoverHandler { cursorShape: Qt.PointingHandCursor } - } - } - } - - // ── Description ── - Card { - visible: root.description.length > 0 - - CardLabel { text: "Description" } - Item { - id: descClip - FontMetrics { id: descMetrics; font: descText.font } - readonly property real collapsedHeight: root.description.indexOf("= 0 ? 220 : descMetrics.lineSpacing * 4 - readonly property bool overflows: descText.implicitHeight > collapsedHeight + 1 - Layout.fillWidth: true - Layout.preferredHeight: root.descExpanded ? descText.implicitHeight - : Math.min(descText.implicitHeight, collapsedHeight) - clip: true - - Rectangle { - z: 1 - visible: descClip.overflows && !root.descExpanded - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 24 - gradient: Gradient { - GradientStop { position: 0; color: "transparent" } - GradientStop { position: 1; color: Theme.elevate3 } - } - } - - ModDescription { - id: descText - objectName: "descText" - width: parent.width - description: root.hasInfo ? root.description : "" - onLinkActivated: link => Qt.openUrlExternally(link) - } - } - Text { - visible: descClip.overflows - text: root.descExpanded ? "Show less" : "Show more" - color: Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - - TapHandler { - onTapped: { - root.descExpanded = !root.descExpanded - root.descToggled(root.descExpanded) - } - } - HoverHandler { cursorShape: Qt.PointingHandCursor } - } - } - - // ── Startup impact ── - Card { - id: startupCard - readonly property bool ready: root.startupKnown && root.startup.available - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - CardLabel { text: "Startup impact" } - Text { - visible: startupCard.ready - text: "Full breakdown \u203A" - color: startupHover.hovered ? Theme.primaryHover : Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - } - - Text { - Layout.fillWidth: true - visible: !startupCard.ready - text: !root.startupKnown ? "Loading\u2026" : (root.startup.message || "") - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: Text.WordWrap - } - - ColumnLayout { - visible: startupCard.ready - Layout.fillWidth: true - spacing: 8 - - RowLayout { - Layout.fillWidth: true - - Text { - text: startupCard.ready ? root.startup.own : "" - color: startupCard.ready ? root.startup.color : Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXl - font.weight: Font.Bold - } - Item { Layout.fillWidth: true } - Text { - text: startupCard.ready ? root.startup.rank : "" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - Text { - Layout.fillWidth: true - text: startupCard.ready && root.startup.estimated - ? "Estimated extra game load time if you activate this mod" - : "Extra game load time caused by this mod" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - wrapMode: Text.WordWrap - } - - Repeater { - model: startupCard.ready ? root.startup.rows : [] - - delegate: ColumnLayout { - Layout.fillWidth: true - spacing: 3 - - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: modelData.label - elide: Text.ElideRight - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - Text { - text: modelData.value - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - Rectangle { - Layout.fillWidth: true - height: 4 - radius: 2 - color: Theme.elevate4 - - Rectangle { - width: parent.width * modelData.fraction - height: parent.height - radius: 2 - color: root.startup.color - } - } - } - } - } - - HoverHandler { - id: startupHover - enabled: startupCard.ready - cursorShape: Qt.PointingHandCursor - } - TapHandler { - enabled: startupCard.ready - onTapped: root.openStartupDetails() - } - } - - // ── Details ── - Card { - RowLayout { - Layout.fillWidth: true - spacing: 4 - - CardLabel { text: "Details" } - PxButton { - visible: root.hasInfo && root.info.canOpenFolder - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" - iconName: "folder" - ToolTip.text: "Open mod folder" - onClicked: root.openFolder() - } - PxButton { - visible: root.hasInfo && root.info.url.length > 0 - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" - iconName: "link" - ToolTip.text: root.hasInfo ? "Open mod URL: " + root.info.url : "Open mod URL" - onClicked: root.openUrl() - } - - PxButton { - visible: root.hasInfo && root.info.canUpdate && !root.updatingThis - enabled: !root.downloadsBusy - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" - iconName: "download" - ToolTip.text: root.downloadsBusy ? "Download in progress" : "Update from " + (root.hasInfo ? root.info.updateSource : "source") - onClicked: root.updateMods() - } - - BusyIndicator { - visible: root.updatingThis - running: visible - implicitHeight: 24 - implicitWidth: 24 - ToolTip.text: "Updating\u2026" - ToolTip.visible: hovered - } - } - - Repeater { - model: root.hasInfo ? root.info.details : [] - - delegate: RowLayout { - Layout.fillWidth: true - spacing: 8 - - Text { - Layout.preferredWidth: 80 - Layout.alignment: Qt.AlignTop - text: modelData.label - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - Text { - Layout.fillWidth: true - text: modelData.value - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: Text.WrapAnywhere - } - PxButton { - visible: modelData.copy === true - Layout.preferredHeight: 24 - Layout.preferredWidth: 24 - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" - iconName: "copy" - ToolTip.text: "Copy" - onClicked: root.copyText(modelData.value) - } - } - } - } - } - } -} diff --git a/legacy/pxmodrim/ui/panels/ModList.qml b/legacy/pxmodrim/ui/panels/ModList.qml deleted file mode 100644 index c60af00..0000000 --- a/legacy/pxmodrim/ui/panels/ModList.qml +++ /dev/null @@ -1,764 +0,0 @@ -import QtQuick -import QtQuick.Controls -import "../components/controls" - -Rectangle { - id: root - color: Theme.elevate1 - clip: true - - property bool keyboardActive: modListHasFocus && listView.activeFocus - readonly property bool compactMode: Boolean(modListPanel && modListPanel.compactMode) - ListView { - id: listView - objectName: "listView" - anchors.fill: parent - spacing: 2 - model: modListModel - delegate: dragDelegate - boundsBehavior: Flickable.StopAtBounds - flickDeceleration: 2000 - focus: true - currentIndex: -1 - Accessible.role: Accessible.List - Accessible.name: "Mods" - - reuseItems: true - cacheBuffer: 100 - - property int dragSourceIndex: -1 - property int dragTargetIndex: -1 - property real autoScrollSpeed: 0 - property var selectedIndices: [] - property var selectedUuids: [] - property int anchorIndex: -1 - property string anchorUuid: "" - property string currentUuid: "" - - ScrollBar.vertical: PxScrollBar { - id: scrollBar - policy: ScrollBar.AsNeeded - active: true - } - section.property: "sectionName" - section.criteria: ViewSection.FullString - section.delegate: Component { - Item { - width: listView.width - height: section !== "" ? 28 : 0 - visible: section !== "" - - Row { - anchors.left: parent.left - anchors.leftMargin: 16 - anchors.right: parent.right - anchors.rightMargin: 16 - anchors.verticalCenter: parent.verticalCenter - spacing: 8 - - Text { - id: sectionLabel - text: section - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Bold - font.letterSpacing: 0.5 - font.capitalization: Font.AllUppercase - anchors.verticalCenter: parent.verticalCenter - } - - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - sectionLabel.width - parent.spacing - height: 1 - color: Theme.border - } - } - } - } - - moveDisplaced: Transition { - NumberAnimation { properties: "y"; duration: 120; easing.type: Easing.InOutQuad } - } - } - - EmptyState { - id: emptyStateText - objectName: "emptyStateText" - anchors.fill: parent - visible: listView.count === 0 - iconName: "mods" - title: "No mods to show" - Accessible.name: title - } - - Timer { - id: autoScrollTimer - interval: 16 - repeat: true - running: dragProxy.visible && listView.autoScrollSpeed !== 0 - onTriggered: { - var newContentY = listView.contentY + listView.autoScrollSpeed - var maxContentY = Math.max(0, listView.contentHeight - listView.height) - listView.contentY = Math.max(0, Math.min(maxContentY, newContentY)) - var center = dragProxy.mapToItem( - listView.contentItem, - dragProxy.width / 2, - dragProxy.height / 2 - ) - listView.dragTargetIndex = root.targetIndexForPoint(center.x, center.y) - } - } - - Connections { - target: modListModel - function onLayoutChanged() { root.reconcileSelection() } - function onModelReset() { root.reconcileSelection() } - function onRowsMoved() { root.reconcileSelection() } - } - - // ── Drag proxy ── - Rectangle { - id: dragProxy - objectName: "dragProxy" - visible: false - width: listView.width - height: root.compactMode ? 32 : 52 - radius: Theme.radiusMd - color: Theme.elevate3 - opacity: 0.92 - z: 1000 - - property int pressOffsetY: 0 - property string modName: "" - property string modPackageId: "" - property string providerColor: "" - - Rectangle { - x: 28; y: 10; width: 32; height: 32 - radius: Theme.radiusSm - color: dragProxy.providerColor || Theme.elevate3 - Text { - anchors.centerIn: parent - text: dragProxy.modName ? dragProxy.modName.charAt(0).toUpperCase() : "?" - color: Theme.elevate0 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.weight: Font.Bold - } - } - - Column { - x: 68; y: 8 - width: parent.width - 78 - Text { - width: parent.width - text: dragProxy.modName - color: Theme.textMain - font.family: Theme.fontFamily - font.bold: true - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - maximumLineCount: 1 - } - Text { - width: parent.width - text: dragProxy.modPackageId - visible: text !== "" - color: Theme.textDim - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontMono - elide: Text.ElideRight - maximumLineCount: 1 - } - } - } - - Rectangle { - visible: dragProxy.visible && listView.dragTargetIndex >= 0 && (modListModel ? listView.dragTargetIndex < modListModel.activeCount : true) - x: 0 - y: { - var targetItem = listView.itemAtIndex(listView.dragTargetIndex) - if (targetItem) { - return targetItem.y - listView.contentY - } - var rowH = root.compactMode ? 34 : 54 - return Math.max(0, Math.min( - listView.height - height, - listView.dragTargetIndex * rowH - listView.contentY - )) - } - width: listView.width - height: 2 - color: Theme.primary - z: 999 - } - // ── Delegate ── - Component { - id: dragDelegate - - Rectangle { - id: delegateRect - width: listView.width - height: root.compactMode ? 32 : 52 - radius: Theme.radiusMd - - color: { - var highlighted = false - if (modListPanel) { - modListPanel.highlightRevision // re-evaluate when highlights change - highlighted = modListPanel.isHighlighted(model.uuid) - } - if (listView.selectedIndices.indexOf(index) >= 0) - return highlighted - ? Qt.tint(Theme.elevate4, Qt.alpha(Theme.primary, 0.25)) - : Theme.elevate4 - if (highlighted) - return Qt.alpha(Theme.primary, 0.25) - if (mouseArea.containsMouse && !dragProxy.visible) - return Theme.elevate3 - return "transparent" - } - opacity: listView.dragSourceIndex === index && dragProxy.visible ? 0.0 : 1.0 - Accessible.role: Accessible.ListItem - Accessible.name: (model.loadIndex > 0 ? "#" + model.loadIndex + ", " : "") - + (model.name || "") - + (model.packageId ? ", " + model.packageId : "") - Accessible.selected: listView.selectedIndices.indexOf(index) >= 0 - - MouseArea { - objectName: "rowMouseArea" - id: mouseArea - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - if (listView.selectedUuids.indexOf(model.uuid) < 0) - root.selectRow(index, model.uuid, Qt.NoModifier) - modListPanel.showContextMenu(listView.selectedUuids) - return - } - root.selectRow(index, model.uuid, mouse.modifiers) - } - } - - Item { - id: dragHandle - x: 0 - width: 24 - height: parent.height - visible: model.loadIndex > 0 - - Image { - anchors.centerIn: parent - width: 16 - height: 16 - sourceSize.width: 16 - sourceSize.height: 16 - opacity: (modListModel && modListModel.isFiltered) ? Theme.disabledOpacity : 1.0 - source: "image://icons/grip?color=" + encodeURIComponent( - (modListModel && modListModel.isFiltered) ? Theme.textDim : Theme.textMuted) - } - - Loader { - active: Boolean(modListModel && modListModel.isFiltered) - sourceComponent: PxToolTip { - text: "Clear search and filters to reorder" - visible: dragArea.containsMouse - } - } - - MouseArea { - id: dragArea - objectName: "dragArea" - anchors.fill: parent - hoverEnabled: true - preventStealing: true - cursorShape: (modListModel && modListModel.isFiltered) - ? Qt.ArrowCursor - : (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor) - - property real pressRootX: 0 - property real pressRootY: 0 - - onPressed: (mouse) => { - root.selectRow(index, model.uuid, mouse.modifiers) - if (modListModel && modListModel.isFiltered) - return - var point = dragArea.mapToItem(root, mouse.x, mouse.y) - pressRootX = point.x - pressRootY = point.y - - var delegatePosition = delegateRect.mapToItem(root, 0, 0) - dragProxy.modName = model.name || "" - dragProxy.modPackageId = model.packageId || "" - dragProxy.providerColor = model.providerColor || "" - dragProxy.pressOffsetY = mouse.y - dragProxy.x = delegatePosition.x - dragProxy.y = delegatePosition.y - listView.dragSourceIndex = index - listView.dragTargetIndex = index - } - - onPositionChanged: (mouse) => { - if (!pressed) - return - var point = dragArea.mapToItem(root, mouse.x, mouse.y) - if (!dragProxy.visible) { - var distance = Math.abs(point.x - pressRootX) - + Math.abs(point.y - pressRootY) - if (distance < Qt.styleHints.startDragDistance) - return - dragProxy.visible = true - } - - dragProxy.y = point.y - dragProxy.pressOffsetY - var center = dragProxy.mapToItem( - listView.contentItem, - dragProxy.width / 2, - dragProxy.height / 2 - ) - listView.dragTargetIndex = root.targetIndexForPoint(center.x, center.y) - - var edgeThreshold = 40 - var bottomEdge = listView.height - edgeThreshold - if (point.y < edgeThreshold) { - var topDistance = edgeThreshold - point.y - listView.autoScrollSpeed = -Math.min( - 15, - Math.max(3, topDistance / 2) - ) - } else if (point.y > bottomEdge) { - var bottomDistance = point.y - bottomEdge - listView.autoScrollSpeed = Math.min( - 15, - Math.max(3, bottomDistance / 2) - ) - } else { - listView.autoScrollSpeed = 0 - } - } - - onReleased: { - var sourceIndex = listView.dragSourceIndex - var targetIndex = listView.dragTargetIndex - var shouldMove = dragProxy.visible && sourceIndex !== targetIndex - dragProxy.visible = false - listView.autoScrollSpeed = 0 - listView.dragSourceIndex = -1 - listView.dragTargetIndex = -1 - if (shouldMove) { - modListPanel.moveRow(sourceIndex, targetIndex) - modListPanel.dragEnded() - } - } - - onCanceled: { - dragProxy.visible = false - listView.autoScrollSpeed = 0 - listView.dragSourceIndex = -1 - listView.dragTargetIndex = -1 - } - } - } - - Item { - id: loadIndexItem - x: 24 - width: 32 - height: parent.height - - Text { - anchors.centerIn: parent - text: model.loadIndex > 0 ? ("#" + model.loadIndex) : "–" - color: model.loadIndex > 0 ? Theme.textMuted : Theme.textDim - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontMono - font.weight: model.loadIndex > 0 ? Font.Medium : Font.Normal - } - } - - Item { - x: 56 - width: 32 - height: parent.height - - PxCheckIndicator { - anchors.centerIn: parent - checkState: model.checkState - hovered: checkboxArea.containsMouse - } - - MouseArea { - id: checkboxArea - objectName: "checkboxArea" - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - Accessible.role: Accessible.CheckBox - Accessible.name: ( - model.checkState === Qt.Checked ? "Disable " : "Enable " - ) + (model.name || "mod") - Accessible.checked: model.checkState === Qt.Checked - onClicked: (mouse) => { - root.selectRow(index, model.uuid, mouse.modifiers) - modListPanel.toggleCheck(index) - } - Accessible.onPressAction: { - root.selectRow(index, model.uuid, Qt.NoModifier) - modListPanel.toggleCheck(index) - } - } - } - - // ── Avatar (hidden in compact mode) ── - Rectangle { - visible: !root.compactMode - x: 88; y: 8; width: 36; height: 36 - radius: Theme.radiusSm - color: model.providerColor || Theme.elevate3 - - Text { - anchors.centerIn: parent - text: model.name ? model.name.charAt(0).toUpperCase() : "?" - color: Theme.elevate0 - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.weight: Font.Bold - } - } - - // ── Standard 2-line layout (name + packageId) ── - Column { - visible: !root.compactMode - x: 132; y: 8 - width: parent.width - badgesRow.width - 142 - spacing: 1 - - Text { - width: parent.width - text: model.name || "" - color: Theme.textMain - font.family: Theme.fontFamily - font.bold: true - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - maximumLineCount: 1 - } - - Text { - width: parent.width - text: model.packageId || "" - visible: text !== "" - color: Theme.textDim - font.pixelSize: Theme.fontSizeSm - font.family: Theme.fontMono - elide: Text.ElideRight - maximumLineCount: 1 - } - } - - // ── Compact single-line layout ── - Row { - visible: root.compactMode - x: 92 - width: parent.width - badgesRow.width - 102 - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - clip: true - - Text { - id: compactNameText - text: model.name || "" - color: Theme.textMain - font.family: Theme.fontFamily - font.bold: true - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - maximumLineCount: 1 - anchors.verticalCenter: parent.verticalCenter - width: Math.min(implicitWidth, parent.width - (compactSubText.visible ? Math.min(compactSubText.implicitWidth + 6, parent.width * 0.45) : 0)) - } - - Text { - id: compactSubText - text: (model.modVersion ? model.modVersion + " " : "") + (model.packageId ? "(" + model.packageId + ")" : "") - visible: text !== "" && (parent.width - compactNameText.width - 6) >= 40 - width: Math.min(implicitWidth, Math.max(0, parent.width - compactNameText.width - 6)) - color: Theme.textDim - font.pixelSize: Theme.fontSizeXs - font.family: Theme.fontMono - elide: Text.ElideRight - maximumLineCount: 1 - anchors.verticalCenter: parent.verticalCenter - } - } - - // ── Badges row (right-aligned) ── - Row { - id: badgesRow - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 6 - - // Startup impact pill - Loader { - id: startupImpactLoader - active: model.startupImpact > 0 - visible: active - anchors.verticalCenter: parent.verticalCenter - sourceComponent: PxBadge { - compact: root.compactMode - text: model.startupImpactText - textColor: Theme.onAccent - fillColor: model.startupImpactColor - tooltip: "Startup impact: adds " + model.startupImpactText + " to game load" - } - } - - PxBadge { - visible: !!model.providerLabel - anchors.verticalCenter: parent.verticalCenter - compact: root.compactMode - outlined: true - text: model.providerLabel || "" - accent: model.providerColor || Theme.border - textColor: model.providerColor || Theme.textMuted - } - - PxBadge { - visible: !!model.modVersion - anchors.verticalCenter: parent.verticalCenter - compact: root.compactMode - text: model.modVersion || "" - } - - // Error badge - Loader { - active: !!model.hasError - visible: active - anchors.verticalCenter: parent.verticalCenter - sourceComponent: DiagnosticBadge { - level: "error" - tooltip: model.errorTooltip || "" - } - } - - // Warning badge - Loader { - active: !!model.hasWarning - visible: active - anchors.verticalCenter: parent.verticalCenter - sourceComponent: DiagnosticBadge { - level: "warning" - tooltip: model.warningTooltip || "" - } - } - } - } - } - - // ── Keyboard navigation ── - Shortcut { - sequence: "Up" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: navigate(false, -1) - } - Shortcut { - sequence: "Down" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: navigate(false, 1) - } - Shortcut { - sequence: "Shift+Up" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: navigate(true, -1) - } - Shortcut { - sequence: "Shift+Down" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: navigate(true, 1) - } - Shortcut { - sequence: "Return" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: modListPanel.toggleChecked(listView.selectedIndices) - } - Shortcut { - sequence: "Space" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: modListPanel.toggleChecked(listView.selectedIndices) - } - Shortcut { - sequence: "Ctrl+A" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: { - var indices = [] - var uuids = [] - for (var i = 0; i < listView.count; ++i) { - indices.push(i) - uuids.push(modListPanel.uuidAt(i)) - } - listView.selectedIndices = indices - listView.selectedUuids = uuids - listView.anchorIndex = listView.count - 1 - listView.anchorUuid = uuids.length > 0 ? uuids[uuids.length - 1] : "" - listView.currentIndex = listView.count - 1 - listView.currentUuid = listView.anchorUuid - modListPanel.selectionChanged(uuids) - if (listView.currentUuid) - modListPanel.modSelected(listView.currentUuid) - } - } - - function selectRow(index, uuid, modifiers) { - listView.forceActiveFocus() - if (modifiers & Qt.ControlModifier) { - var indices = listView.selectedIndices.slice() - var uuids = listView.selectedUuids.slice() - var selectedPosition = uuids.indexOf(uuid) - if (selectedPosition >= 0) { - uuids.splice(selectedPosition, 1) - indices.splice(selectedPosition, 1) - } else { - indices.push(index) - uuids.push(uuid) - } - listView.selectedIndices = indices - listView.selectedUuids = uuids - listView.anchorIndex = index - listView.anchorUuid = uuid - } else if (modifiers & Qt.ShiftModifier && listView.anchorIndex >= 0) { - var start = Math.min(listView.anchorIndex, index) - var end = Math.max(listView.anchorIndex, index) - var range = [] - var rangeUuids = [] - for (var row = start; row <= end; ++row) { - range.push(row) - rangeUuids.push(modListPanel.uuidAt(row)) - } - listView.selectedIndices = range - listView.selectedUuids = rangeUuids - } else { - listView.selectedIndices = [index] - listView.selectedUuids = [uuid] - listView.anchorIndex = index - listView.anchorUuid = uuid - } - - listView.currentIndex = index - listView.currentUuid = uuid - modListPanel.selectionChanged(listView.selectedUuids) - modListPanel.modSelected(uuid) - } - - function revealRow(index, uuid) { - selectRow(index, uuid, 0) - listView.positionViewAtIndex(index, ListView.Center) - } - - function navigate(extend, direction) { - var newIndex = listView.currentIndex + direction - if (newIndex < 0 || newIndex >= listView.count) - return - - var uuid = modListPanel.uuidAt(newIndex) - if (extend) { - if (listView.anchorIndex < 0) { - listView.anchorIndex = newIndex - listView.anchorUuid = uuid - } - var start = Math.min(listView.anchorIndex, newIndex) - var end = Math.max(listView.anchorIndex, newIndex) - var range = [] - var rangeUuids = [] - for (var row = start; row <= end; ++row) { - range.push(row) - rangeUuids.push(modListPanel.uuidAt(row)) - } - listView.selectedIndices = range - listView.selectedUuids = rangeUuids - } else { - listView.selectedIndices = [newIndex] - listView.selectedUuids = [uuid] - listView.anchorIndex = newIndex - listView.anchorUuid = uuid - } - - listView.currentIndex = newIndex - listView.currentUuid = uuid - listView.positionViewAtIndex(newIndex, ListView.Contain) - modListPanel.selectionChanged(listView.selectedUuids) - modListPanel.modSelected(uuid) - } - - function reconcileSelection() { - var indices = [] - var uuids = [] - for (var i = 0; i < listView.selectedUuids.length; ++i) { - var uuid = listView.selectedUuids[i] - var row = modListPanel.rowForUuid(uuid) - if (row >= 0) { - indices.push(row) - uuids.push(uuid) - } - } - listView.selectedIndices = indices - listView.selectedUuids = uuids - - var currentRow = listView.currentUuid ? modListPanel.rowForUuid(listView.currentUuid) : -1 - if (currentRow < 0 && indices.length > 0) { - currentRow = indices[0] - listView.currentUuid = uuids[0] - modListPanel.modSelected(listView.currentUuid) - } else if (currentRow < 0) { - listView.currentIndex = -1 - listView.currentUuid = "" - listView.anchorIndex = -1 - listView.anchorUuid = "" - modListPanel.selectionChanged([]) - modListPanel.modSelected("") - return - } - listView.currentIndex = currentRow - - var anchorRow = listView.anchorUuid ? modListPanel.rowForUuid(listView.anchorUuid) : -1 - if (anchorRow < 0) { - anchorRow = currentRow - listView.anchorUuid = listView.currentUuid - } - listView.anchorIndex = anchorRow - modListPanel.selectionChanged(uuids) - } - - function targetIndexForPoint(x, y) { - if (listView.count === 0 || !modListModel || modListModel.activeCount <= 0) - return -1 - var maxActiveTarget = modListModel.activeCount - 1 - var idx = listView.indexAt(x, y) - if (idx >= 0) - return Math.max(0, Math.min(idx, maxActiveTarget)) - if (y <= 0) - return 0 - if (y >= listView.contentHeight) - return maxActiveTarget - idx = listView.indexAt(x, y + 16) - if (idx >= 0) - return Math.max(0, Math.min(idx, maxActiveTarget)) - idx = listView.indexAt(x, y - 16) - if (idx >= 0) - return Math.max(0, Math.min(idx, maxActiveTarget)) - var rowH = root.compactMode ? 34 : 54 - return Math.max(0, Math.min(Math.floor(y / rowH), maxActiveTarget)) - } -} diff --git a/legacy/pxmodrim/ui/panels/Settings.qml b/legacy/pxmodrim/ui/panels/Settings.qml deleted file mode 100644 index b5e9a19..0000000 --- a/legacy/pxmodrim/ui/panels/Settings.qml +++ /dev/null @@ -1,419 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../components/controls" - -Rectangle { - id: root - objectName: "settingsRoot" - - color: Theme.elevate2 - - property int tab: 0 - readonly property var initial: settings.initial - - function values() { - return { - game: game.text.trim(), - local: local.text.trim(), - workshop: workshop.text.trim(), - config: configFolder.text.trim(), - compact: compact.checked, - launchArgs: launchArgs.text.trim(), - launchWrapper: launchWrapper.text.trim(), - afterLaunch: afterLaunch.currentValue, - confirmErrors: confirmErrors.checked, - confirmUnsaved: confirmUnsaved.checked, - confirmRunning: confirmRunning.checked, - useAltIds: useAltIds.checked, - checkMissing: checkMissing.checked, - useCommunity: useCommunity.checked - } - } - - Connections { - target: settings - function onPathPicked(key, path) { - const fields = { game: game, local: local, workshop: workshop, config: configFolder } - if (fields[key]) - fields[key].text = path - if (key === "game" && local.text === "") { - const candidate = settings.localCandidate(path) - if (candidate) - local.text = candidate - } - } - } - - component Group: Rectangle { - default property alias content: body.data - property string title: "" - - Layout.fillWidth: true - implicitHeight: body.implicitHeight + 52 - color: "transparent" - - Text { - x: 16 - y: 12 - text: parent.title - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.Bold - font.capitalization: Font.AllUppercase - font.letterSpacing: 0.5 - } - - ColumnLayout { - id: body - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 16 - anchors.topMargin: 36 - spacing: 10 - } - } - - component PathRow: RowLayout { - property string label: "" - property string key: "" - property alias text: field.text - - Layout.fillWidth: true - spacing: 8 - - Text { - Layout.preferredWidth: 150 - text: parent.label - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - id: field - Layout.fillWidth: true - monospace: true - Accessible.name: parent.label - } - PxButton { - text: "Browse…" - onClicked: settings.browse(parent.key) - } - } - - component Tab: Item { - id: tabItem - property string label: "" - property int page: 0 - readonly property bool current: root.tab === page - - implicitWidth: tabText.implicitWidth + 32 - implicitHeight: 40 - - Text { - id: tabText - anchors.centerIn: parent - text: tabItem.label - color: tabItem.current ? Theme.primary : tabMouse.containsMouse ? Theme.textMain : Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: tabItem.current ? Font.Medium : Font.Normal - } - Rectangle { - visible: tabItem.current - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: 2 - color: Theme.primary - } - MouseArea { - id: tabMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - Accessible.role: Accessible.PageTab - Accessible.name: tabItem.label - onClicked: root.tab = tabItem.page - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - Rectangle { - Layout.fillWidth: true - implicitHeight: 41 - color: "transparent" - - Row { - x: 8 - Tab { label: "General"; page: 0 } - Tab { label: "Sorting"; page: 1 } - Repeater { - model: settings.sections - delegate: Tab { - required property var modelData - required property int index - label: modelData.title - page: index + 2 - } - } - } - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: 1 - color: Theme.border - } - } - - StackLayout { - Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: root.tab - - ScrollView { - id: generalScroll - clip: true - contentWidth: availableWidth - ScrollBar.vertical: PxScrollBar {} - - ColumnLayout { - width: generalScroll.availableWidth - 32 - x: 16 - y: 16 - spacing: 12 - - Group { - title: "Folders" - PathRow { id: game; label: "Game folder"; key: "game"; text: root.initial.game } - PathRow { id: local; label: "Local mods folder"; key: "local"; text: root.initial.local } - PathRow { id: workshop; label: "Workshop mods folder"; key: "workshop"; text: root.initial.workshop } - PathRow { id: configFolder; label: "Config folder"; key: "config"; text: root.initial.config } - PxButton { - text: "Auto-detect" - onClicked: settings.autoDetect() - } - } - - Group { - title: "Launch" - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Arguments" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - id: launchArgs - Layout.fillWidth: true - monospace: true - text: root.initial.launchArgs - placeholderText: "e.g. -popupwindow -logfile" - Accessible.name: "Game launch arguments" - } - } - Text { - Layout.fillWidth: true - text: "Passed to the game. With the Steam strategy they go through steam://run." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Wrapper" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - id: launchWrapper - Layout.fillWidth: true - monospace: true - text: root.initial.launchWrapper - placeholderText: "e.g. gamemoderun %command%" - Accessible.name: "Launch wrapper command" - } - } - Text { - Layout.fillWidth: true - text: "Direct launch only. Leading KEY=VALUE tokens set environment variables; %command% marks where the game goes, otherwise the wrapper is a prefix." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "After launch" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxComboBox { - id: afterLaunch - Layout.preferredWidth: 220 - Accessible.name: "Window behaviour after launch" - textRole: "text" - valueRole: "value" - model: [ - { text: "Keep PxModRim open", value: 0 }, - { text: "Minimize while the game runs", value: 1 }, - { text: "Close PxModRim", value: 2 } - ] - Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.afterLaunch)) - } - } - PxCheckBox { - id: confirmErrors - text: "Confirm launch when the mod list has errors" - checked: root.initial.confirmErrors - } - PxCheckBox { - id: confirmUnsaved - text: "Confirm launch when the mod list is unsaved" - checked: root.initial.confirmUnsaved - } - PxCheckBox { - id: confirmRunning - text: "Confirm launch when the game is already running" - checked: root.initial.confirmRunning - } - } - - Group { - title: "Appearance" - PxCheckBox { - id: compact - text: "Compact mod list" - checked: root.initial.compact - } - } - } - } - - ScrollView { - id: sortingScroll - clip: true - contentWidth: availableWidth - ScrollBar.vertical: PxScrollBar {} - - ColumnLayout { - width: sortingScroll.availableWidth - 32 - x: 16 - y: 16 - spacing: 12 - - Group { - title: "Sorting options" - PxCheckBox { - id: useAltIds - text: "Use alternative package IDs" - checked: root.initial.useAltIds - } - PxCheckBox { - id: checkMissing - text: "Check missing dependencies" - checked: root.initial.checkMissing - } - PxCheckBox { - id: useCommunity - text: "Use community rules database" - checked: root.initial.useCommunity - } - } - - Group { - title: "Community rules database" - Text { - Layout.fillWidth: true - text: settings.communityStatus - wrapMode: Text.Wrap - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxButton { - text: "Download or update" - enabled: !settings.communityBusy - onClicked: settings.downloadCommunityRules() - } - } - - Group { - title: "Startup impact cache" - Text { - Layout.fillWidth: true - text: settings.cacheStatus - wrapMode: Text.Wrap - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxButton { - text: "Clear cache" - enabled: settings.cacheAvailable && !settings.cacheBusy - onClicked: settings.clearCache() - } - } - } - } - - Repeater { - model: settings.sections - delegate: ScrollView { - id: sectionScroll - required property var modelData - clip: true - contentWidth: availableWidth - ScrollBar.vertical: PxScrollBar {} - - ColumnLayout { - width: sectionScroll.availableWidth - 32 - x: 16 - y: 16 - spacing: 12 - - Group { - title: sectionScroll.modelData.title - Loader { - Layout.fillWidth: true - Component.onCompleted: setSource(sectionScroll.modelData.source, { section: sectionScroll.modelData.section }) - } - } - } - } - } - } - - PxDialogFooter { - Layout.fillWidth: true - PxButton { - text: "Cancel" - onClicked: settings.cancel() - } - PxButton { - objectName: "settingsSave" - text: "Save" - variant: "primary" - onClicked: settings.save(root.values()) - } - } - } -} diff --git a/legacy/pxmodrim/ui/panels/TimeAnalytics.qml b/legacy/pxmodrim/ui/panels/TimeAnalytics.qml deleted file mode 100644 index bff43ed..0000000 --- a/legacy/pxmodrim/ui/panels/TimeAnalytics.qml +++ /dev/null @@ -1,832 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../components/controls" - -Rectangle { - id: root - color: Theme.elevate1 - clip: true - - property var sourceData: null - - readonly property bool hasData: sourceData !== null && sourceData !== undefined - - property string view: "mods" - property string filterText: "" - property bool activeOnly: false - property bool slowOnly: false - property string sortBy: "time" - property bool includeBase: true - - // JS objects are not observable; bump the revision to re-evaluate bindings. - property var expandedMods: ({}) - property var expandedPhases: ({}) - property var showAllPhases: ({}) - property int expandRev: 0 - - readonly property real slowThreshold: 3.0 - readonly property int phasePreview: 6 - - readonly property var visibleMods: { - if (!hasData) - return [] - const q = filterText.trim().toLowerCase() - const rows = sourceData.mods.filter(m => - (!activeOnly || m.active) - && (!slowOnly || m.seconds >= slowThreshold) - && (q.length === 0 || m.name.toLowerCase().indexOf(q) !== -1)) - if (sortBy === "name") - rows.sort((a, b) => a.name.localeCompare(b.name)) - return rows - } - - readonly property var phases: hasData - ? (includeBase ? sourceData.phases_with_base : sourceData.phases_mods_only) - : [] - - onSourceDataChanged: { - if (!sourceData) - return - const key = sourceData.selected_key - const selectedRow = sourceData.mods.find(m => m.key === key) - expandedMods = key ? { [key]: true } : {} - expandedPhases = {} - showAllPhases = {} - expandRev++ - filterText = "" - activeOnly = sourceData.active_count > 0 && (!selectedRow || selectedRow.active) - Qt.callLater(scrollToMod, key) - } - - function toggle(map, key) { - map[key] = !map[key] - expandRev++ - } - - function scrollToMod(key) { - if (!key) - return - const idx = visibleMods.findIndex(m => m.key === key) - if (idx >= 0) - modsList.positionViewAtIndex(idx, ListView.Center) - } - - function openMod(key) { - const row = sourceData.mods.find(m => m.key === key) - if (!row) - return - filterText = "" - slowOnly = false - if (!row.active) - activeOnly = false - expandedMods[key] = true - expandRev++ - view = "mods" - Qt.callLater(scrollToMod, key) - } - - component Chip: Rectangle { - id: chip - property string text - property bool checked: false - signal clicked() - - implicitHeight: 28 - implicitWidth: chipLabel.implicitWidth + 24 - radius: Theme.radiusPill - color: checked ? Qt.alpha(Theme.primary, 0.14) - : chipMouse.containsMouse ? Theme.elevate4 : Theme.elevate3 - - Text { - id: chipLabel - anchors.centerIn: parent - text: chip.text - color: chip.checked ? Theme.primary : Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - font.weight: Font.Medium - } - MouseArea { - id: chipMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: chip.clicked() - } - } - - component Caption: Text { - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - font.weight: Font.DemiBold - font.letterSpacing: 0.6 - font.capitalization: Font.AllUppercase - color: Theme.textDim - } - - component Bar: Rectangle { - property real fraction: 0 - property color fill: Theme.primary - implicitHeight: 8 - radius: height / 2 - color: Theme.elevate4 - Rectangle { - width: Math.max(parent.fraction > 0 ? parent.height : 0, parent.width * Math.min(1, parent.fraction)) - height: parent.height - radius: parent.radius - color: parent.fill - } - } - - component Divider: Rectangle { - Layout.fillWidth: true - implicitHeight: 1 - color: Theme.border - } - - component Card: Rectangle { - id: card - property string title - property string value - property string detail - Layout.fillWidth: true - implicitHeight: cardCol.implicitHeight + 24 - radius: Theme.radiusLg - color: Theme.elevate2 - border.width: 1 - border.color: Theme.border - ColumnLayout { - id: cardCol - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - Caption { text: card.title } - Text { - Layout.fillWidth: true - text: card.value - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXl - font.weight: Font.DemiBold - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - text: card.detail - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - wrapMode: Text.Wrap - } - } - } - - EmptyState { - anchors.fill: parent - visible: !hasData - iconName: "clock" - title: "No startup impact data" - detail: "Launch the game with the loading progress mod to record it." - } - - ColumnLayout { - anchors.fill: parent - visible: hasData - spacing: 0 - - // -- Summary -- - ColumnLayout { - Layout.fillWidth: true - Layout.margins: 20 - Layout.bottomMargin: 16 - spacing: 12 - - RowLayout { - Layout.fillWidth: true - spacing: 12 - Text { - text: hasData ? sourceData.last_launch : "" - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: 28 - font.weight: Font.Bold - } - ColumnLayout { - Layout.alignment: Qt.AlignVCenter - spacing: 0 - Text { - text: "last launch" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - Text { - visible: text.length > 0 - text: hasData && sourceData.estimated - ? "≈ " + sourceData.estimated + " estimated for your current mod list" : "" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - Item { Layout.fillWidth: true } - Text { - visible: text.length > 0 - text: hasData && sourceData.timestamp ? "report from " + sourceData.timestamp : "" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - Item { - id: summaryBar - Layout.fillWidth: true - implicitHeight: 10 - readonly property var parts: hasData ? sourceData.summary : [] - Row { - anchors.fill: parent - spacing: 2 - Repeater { - model: summaryBar.parts - delegate: Rectangle { - width: Math.max(2, modelData.fraction * (summaryBar.width - 2 * (summaryBar.parts.length - 1))) - height: summaryBar.height - radius: 3 - color: modelData.color - opacity: modelData.parallel ? 0.45 : 1 - } - } - } - } - - Flow { - Layout.fillWidth: true - spacing: 18 - Repeater { - model: summaryBar.parts - delegate: Row { - spacing: 6 - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 10; height: 10; radius: 2 - color: modelData.color - opacity: modelData.parallel ? 0.45 : 1 - } - Text { - text: modelData.label + " " + modelData.value - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - } - } - } - - Divider {} - - // -- Toolbar -- - RowLayout { - Layout.fillWidth: true - Layout.leftMargin: 20 - Layout.rightMargin: 20 - Layout.topMargin: 10 - Layout.bottomMargin: 10 - spacing: 8 - - Chip { text: "Mods"; checked: view === "mods"; onClicked: view = "mods" } - Chip { text: "Phases"; checked: view === "phases"; onClicked: view = "phases" } - Item { Layout.fillWidth: true } - - PxTextField { - visible: view === "mods" - Layout.preferredWidth: 220 - implicitHeight: 28 - placeholderText: "Filter mods…" - text: root.filterText - onTextEdited: root.filterText = text - } - Chip { - visible: view === "mods" && hasData && sourceData.active_count > 0 - text: "Active only" - checked: activeOnly - onClicked: activeOnly = !activeOnly - } - Chip { - visible: view === "mods" - text: "Slow > 3 s" - checked: slowOnly - onClicked: slowOnly = !slowOnly - } - Chip { - visible: view === "mods" - text: sortBy === "time" ? "Sort: Time" : "Sort: Name" - onClicked: sortBy = sortBy === "time" ? "name" : "time" - } - Chip { - visible: view === "phases" - text: "Include base game" - checked: includeBase - onClicked: includeBase = !includeBase - } - } - - Divider {} - - // -- Mods view -- - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - visible: view === "mods" - spacing: 0 - - RowLayout { - Layout.fillWidth: true - Layout.leftMargin: 20 - Layout.rightMargin: 20 + Theme.scrollbarWidth - Layout.topMargin: 10 - Layout.bottomMargin: 4 - spacing: 12 - Item { Layout.preferredWidth: 28 } - Caption { Layout.fillWidth: true; text: "Mod" } - Caption { Layout.preferredWidth: modsList.width * 0.36; text: "Phase breakdown" } - Caption { Layout.preferredWidth: 72; horizontalAlignment: Text.AlignRight; text: "Time" } - } - - ListView { - id: modsList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - boundsBehavior: Flickable.StopAtBounds - model: root.visibleMods - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - - delegate: Item { - id: modRow - required property var modelData - readonly property bool expanded: root.expandRev >= 0 && root.expandedMods[modelData.key] === true - - width: ListView.view.width - height: rowContent.implicitHeight - - ColumnLayout { - id: rowContent - width: parent.width - spacing: 0 - - Rectangle { - Layout.fillWidth: true - Layout.leftMargin: 12 - Layout.rightMargin: 12 + Theme.scrollbarWidth - implicitHeight: 36 - radius: Theme.radiusMd - color: modRow.modelData.selected ? Qt.alpha(Theme.primary, 0.10) - : rowMouse.containsMouse ? Theme.elevate3 : "transparent" - - Rectangle { - visible: modRow.modelData.selected - width: 3; height: parent.height - 12 - anchors.verticalCenter: parent.verticalCenter - radius: 2 - color: Theme.primary - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 12 - opacity: modRow.modelData.active || !root.hasData || root.sourceData.active_count === 0 ? 1 : 0.55 - - Text { - Layout.preferredWidth: 28 - horizontalAlignment: Text.AlignRight - text: modRow.modelData.rank - color: Theme.textDim - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeSm - } - Text { - Layout.fillWidth: true - text: modRow.modelData.name - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - Rectangle { - Layout.preferredWidth: modsList.width * 0.36 - implicitHeight: 8 - radius: 4 - color: Theme.elevate4 - clip: true - Row { - height: parent.height - Repeater { - model: modRow.modelData.segments - delegate: Rectangle { - required property var modelData - width: modelData.fraction * parent.parent.width - height: parent.height - color: modelData.color - } - } - } - } - Text { - Layout.preferredWidth: 72 - horizontalAlignment: Text.AlignRight - text: modRow.modelData.time - color: modRow.modelData.color - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeMd - font.weight: Font.Bold - } - } - - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.expandedMods, modRow.modelData.key) - } - } - - Rectangle { - visible: modRow.expanded - Layout.fillWidth: true - Layout.leftMargin: 60 - Layout.rightMargin: 12 + Theme.scrollbarWidth - Layout.topMargin: 2 - Layout.bottomMargin: 8 - implicitHeight: visible ? detailCol.implicitHeight + 24 : 0 - radius: Theme.radiusMd - color: Theme.elevate0 - border.width: 1 - border.color: Theme.border - - ColumnLayout { - id: detailCol - anchors.fill: parent - anchors.margins: 12 - spacing: 6 - - Caption { - Layout.fillWidth: true - text: "Where " + modRow.modelData.name + " spends its time" - elide: Text.ElideRight - } - Repeater { - model: modRow.expanded ? modRow.modelData.phases : [] - delegate: RowLayout { - id: phaseLine - required property var modelData - Layout.fillWidth: true - spacing: 10 - Rectangle { width: 10; height: 10; radius: 2; color: phaseLine.modelData.color } - Text { - Layout.preferredWidth: 150 - text: phaseLine.modelData.label - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - HoverHandler { id: phaseHover } - PxToolTip { - visible: phaseHover.hovered && phaseLine.modelData.tooltip.length > 0 - text: phaseLine.modelData.tooltip - } - } - Bar { - Layout.fillWidth: true - implicitHeight: 6 - fraction: phaseLine.modelData.fraction - fill: phaseLine.modelData.color - } - Text { - Layout.preferredWidth: 72 - horizontalAlignment: Text.AlignRight - text: phaseLine.modelData.value - color: Theme.textMain - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeSm - } - } - } - Text { - visible: modRow.modelData.phases.length === 0 - text: "Under 1 ms in every phase" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - Text { - visible: modRow.modelData.off_thread.length > 0 - text: "Plus " + modRow.modelData.off_thread + " off the main thread" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - } - } - } - - EmptyState { - anchors.centerIn: parent - width: parent.width - visible: modsList.count === 0 - iconName: "search" - title: "No mods match" - } - } - } - - // -- Phases view -- - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - visible: view === "phases" - spacing: 0 - - ListView { - id: phasesList - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - boundsBehavior: Flickable.StopAtBounds - model: root.phases - topMargin: 8 - bottomMargin: 8 - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - - header: RowLayout { - width: ListView.view.width - spacing: 12 - Item { Layout.preferredWidth: 32 } - Caption { Layout.fillWidth: true; text: "Phase" } - Caption { Layout.preferredWidth: 72; horizontalAlignment: Text.AlignRight; text: "Time" } - Caption { Layout.preferredWidth: 44; Layout.rightMargin: 20 + Theme.scrollbarWidth; horizontalAlignment: Text.AlignRight; text: "Share" } - } - - delegate: Item { - id: phaseRow - required property var modelData - readonly property bool expanded: root.expandRev >= 0 && root.expandedPhases[modelData.label] === true - readonly property bool showAll: root.expandRev >= 0 && root.showAllPhases[modelData.label] === true - readonly property var shownMods: showAll ? modelData.mods : modelData.mods.slice(0, root.phasePreview) - - width: ListView.view.width - height: phaseContent.implicitHeight - - Rectangle { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 + Theme.scrollbarWidth - anchors.bottomMargin: 2 - radius: Theme.radiusMd - color: phaseRow.expanded ? Theme.elevate2 : "transparent" - } - - ColumnLayout { - id: phaseContent - width: parent.width - spacing: 0 - - Item { - Layout.fillWidth: true - implicitHeight: 38 - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 20 - anchors.rightMargin: 20 + Theme.scrollbarWidth - spacing: 12 - - Text { - Layout.preferredWidth: 10 - text: phaseRow.expanded ? "▾" : "▸" - color: Theme.textDim - font.pixelSize: Theme.fontSizeSm - } - Rectangle { width: 10; height: 10; radius: 2; color: phaseRow.modelData.color } - Text { - Layout.fillWidth: true - Layout.minimumWidth: 120 - text: phaseRow.modelData.label - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - Bar { - Layout.preferredWidth: phasesList.width * 0.28 - fraction: phaseRow.modelData.fraction - fill: phaseRow.modelData.color - } - Text { - Layout.preferredWidth: 72 - horizontalAlignment: Text.AlignRight - text: phaseRow.modelData.value - color: Theme.textMain - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeMd - font.weight: Font.Bold - } - Text { - Layout.preferredWidth: 44 - horizontalAlignment: Text.AlignRight - text: phaseRow.modelData.share - color: Theme.textMuted - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeSm - } - } - - MouseArea { - id: phaseMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.expandedPhases, phaseRow.modelData.label) - } - PxToolTip { - visible: phaseMouse.containsMouse && phaseRow.modelData.tooltip.length > 0 - text: phaseRow.modelData.tooltip - } - } - - ColumnLayout { - visible: phaseRow.expanded - Layout.fillWidth: true - Layout.leftMargin: 54 - Layout.rightMargin: 20 + Theme.scrollbarWidth - Layout.bottomMargin: 10 - spacing: 2 - - Repeater { - model: phaseRow.expanded ? phaseRow.shownMods : [] - delegate: RowLayout { - id: contributor - required property var modelData - Layout.fillWidth: true - implicitHeight: 26 - spacing: 12 - - Text { - Layout.fillWidth: true - text: contributor.modelData.name - color: contributor.modelData.selected ? Theme.primary - : contributor.modelData.base ? Theme.textDim - : nameMouse.containsMouse ? Theme.textMain : Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - font.italic: contributor.modelData.base - font.weight: contributor.modelData.selected ? Font.DemiBold : Font.Normal - font.underline: nameMouse.containsMouse && !contributor.modelData.base - elide: Text.ElideRight - MouseArea { - id: nameMouse - anchors.fill: parent - enabled: !contributor.modelData.base - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.openMod(contributor.modelData.key) - } - } - Bar { - Layout.preferredWidth: phasesList.width * 0.22 - implicitHeight: 5 - fraction: contributor.modelData.fraction - fill: Qt.alpha(phaseRow.modelData.color, 0.7) - } - Text { - Layout.preferredWidth: 72 + 44 + 12 - horizontalAlignment: Text.AlignRight - text: contributor.modelData.value - color: Theme.textMuted - font.family: Theme.fontMono - font.pixelSize: Theme.fontSizeSm - } - } - } - - Text { - readonly property int hidden: phaseRow.modelData.mods.length - root.phasePreview - visible: hidden > 0 - text: phaseRow.showAll ? "Show fewer" : "+ " + hidden + " more mods…" - color: moreMouse.containsMouse ? Theme.primary : Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - topPadding: 4 - MouseArea { - id: moreMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.showAllPhases, phaseRow.modelData.label) - } - } - } - } - } - } - - Rectangle { - Layout.fillHeight: true - implicitWidth: 1 - color: Theme.border - } - - ColumnLayout { - Layout.fillWidth: false - Layout.preferredWidth: 260 - Layout.maximumWidth: 260 - Layout.fillHeight: true - Layout.margins: 16 - spacing: 12 - - Card { - title: "Last launch" - value: hasData ? sourceData.last_launch : "" - detail: hasData ? "Base game " + sourceData.base_value + " · Mods " + sourceData.mods_value : "" - } - Card { - visible: root.phases.length > 0 - title: "Costliest phase" - value: visible ? root.phases[0].label : "" - detail: visible ? root.phases[0].value + " · " + root.phases[0].share + " of measured time" : "" - } - Card { - visible: hasData && sourceData.mods.length > 0 - title: "Costliest mod" - value: visible ? sourceData.mods[0].name : "" - detail: visible ? sourceData.mods[0].time - + (sourceData.mods[0].dominant ? " · mostly " + sourceData.mods[0].dominant.toLowerCase() : "") - : "" - } - Card { - title: "Off main thread" - value: hasData ? sourceData.off_value : "" - detail: "Runs in parallel with loading" - } - Item { Layout.fillHeight: true } - Text { - Layout.fillWidth: true - text: "Hover a phase to see the game's own step names. Click a mod to jump to it." - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - wrapMode: Text.Wrap - } - } - } - - Divider {} - - // -- Footer -- - RowLayout { - Layout.fillWidth: true - Layout.leftMargin: 20 - Layout.rightMargin: 20 - Layout.topMargin: 8 - Layout.bottomMargin: 8 - spacing: 16 - - Flow { - Layout.fillWidth: true - spacing: 14 - Repeater { - model: hasData ? sourceData.legend : [] - delegate: Row { - required property var modelData - spacing: 6 - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 10; height: 10; radius: 2 - color: parent.modelData.color - } - Text { - text: parent.modelData.label - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - } - } - } - Text { - visible: view === "mods" - text: hasData ? root.visibleMods.length + " of " + sourceData.mods.length + " mods shown" : "" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - } - } -} diff --git a/legacy/pxmodrim/ui/panels/__init__.py b/legacy/pxmodrim/ui/panels/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/ui/panels/about_panel.py b/legacy/pxmodrim/ui/panels/about_panel.py deleted file mode 100644 index 6925637..0000000 --- a/legacy/pxmodrim/ui/panels/about_panel.py +++ /dev/null @@ -1,318 +0,0 @@ -from __future__ import annotations - -import platform -from importlib.metadata import version -from importlib.resources import files as resource_files -from typing import TYPE_CHECKING - -from PySide6.QtCore import Qt, QUrl, qVersion -from PySide6.QtGui import QDesktopServices, QPalette, QPixmap -from PySide6.QtWidgets import ( - QApplication, - QDialog, - QFrame, - QGridLayout, - QHBoxLayout, - QLabel, - QScrollArea, - QTextBrowser, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.support import get_app_version -from pxmodrim.ui.components import AppButton -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.panels.upload_report_dialog import handle_upload_report - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.components.toast import ToastManager - - -class AboutPanel(QDialog): - _REPOSITORY_URL = "https://github.com/PyXiion/PxModRim" - _ISSUES_URL = f"{_REPOSITORY_URL}/issues" - _DEPENDENCIES = ( - ("aiosqlite", "MIT"), - ("httpx", "BSD-3-Clause"), - ("loguru", "MIT"), - ("lxml", "BSD-3-Clause"), - ("msgspec", "BSD-3-Clause"), - ("PySide6", "LGPL-3.0 / GPL-2.0 / GPL-3.0"), - ("qasync", "BSD-2-Clause"), - ("toposort", "Apache-2.0"), - ("ttimer", "MIT"), - ) - - def __init__( - self, - parent: QWidget | None, - *, - ctx: CoreContext, - toast_manager: ToastManager, - ) -> None: - super().__init__(parent) - self._ctx = ctx - self._toast_manager = toast_manager - self.setObjectName("aboutPanel") - self.setWindowTitle("About PxModRim") - self.setModal(True) - self.resize(700, 740) - self.setMinimumSize(640, 620) - - layout = QVBoxLayout(self) - layout.setContentsMargins(24, 24, 24, 20) - layout.setSpacing(18) - layout.addWidget(self._create_hero()) - layout.addWidget(self._create_project_story(), 1) - layout.addWidget(self._create_details()) - self._credits_dialog = self._create_credits_dialog() - layout.addLayout(self._create_actions()) - - disclaimer = QLabel( - "PxModRim is an unofficial fan-made tool. RimWorld is a trademark " - "of Ludeon Studios.", - self, - ) - disclaimer.setObjectName("aboutDisclaimer") - disclaimer.setWordWrap(True) - layout.addWidget(disclaimer) - - footer = QHBoxLayout() - footer.addStretch() - close_button = AppButton("Close", self) - close_button.setObjectName("primaryAction") - close_button.clicked.connect(self.reject) - footer.addWidget(close_button) - layout.addLayout(footer) - - def _create_hero(self) -> QFrame: - hero = QFrame(self) - hero.setObjectName("aboutHero") - layout = QHBoxLayout(hero) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(20) - - logo = QLabel(hero) - pixmap = QPixmap(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - logo.setPixmap( - pixmap.scaled( - 96, - 96, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - layout.addWidget(logo, alignment=Qt.AlignmentFlag.AlignTop) - - identity = QVBoxLayout() - identity.setSpacing(6) - - title_row = QHBoxLayout() - title = QLabel("PxModRim", hero) - title.setObjectName("aboutTitle") - title_row.addWidget(title) - title_row.addStretch() - - version_badge = QLabel(f"v{get_app_version()}", hero) - version_badge.setObjectName("aboutVersionBadge") - title_row.addWidget(version_badge, alignment=Qt.AlignmentFlag.AlignVCenter) - identity.addLayout(title_row) - - tagline = QLabel("A friendly, modern mod manager for RimWorld.", hero) - tagline.setObjectName("aboutTagline") - identity.addWidget(tagline) - - description = QLabel( - "Scan, sort, resolve dependencies, and manage large mod lists easily.", - hero, - ) - description.setObjectName("aboutDescription") - description.setWordWrap(True) - identity.addWidget(description) - identity.addStretch() - - layout.addLayout(identity, stretch=1) - return hero - - def _create_project_story(self) -> QWidget: - section = QWidget(self) - layout = QVBoxLayout(section) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - heading = QLabel("THE PROJECT", section) - heading.setObjectName("aboutSectionTitle") - layout.addWidget(heading) - - story = QLabel( - "I used RimPy and RimSort for a long time myself, but with recent " - "updates it kept getting slower and worse — some features even broke " - "my game 🙁

    " - "I initially started working on a PR to fix most of the parallelism " - "issues in RimSort, but realized it didn't make much sense. So I decided " - "to try creating my own alternative with the help of AI. The result " - "is what you see on your screen.

    " - "This is all just my personal opinion based on my experience with " - "RimSort at the time, and things may have changed for the better since " - "then (I hope so!).

    " - "— PyXiion", - ) - story.setObjectName("aboutStory") - story.setWordWrap(True) - story.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) - - scroll = QScrollArea(section) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll.setWidget(story) - scroll.viewport().setAutoFillBackground(False) - scroll.viewport().setBackgroundRole(QPalette.ColorRole.NoRole) - story.setAutoFillBackground(False) - story.setBackgroundRole(QPalette.ColorRole.NoRole) - layout.addWidget(scroll, 1) - return section - - def _create_details(self) -> QFrame: - details = QFrame(self) - details.setObjectName("aboutDetails") - layout = QGridLayout(details) - layout.setContentsMargins(16, 14, 16, 14) - layout.setHorizontalSpacing(14) - layout.setVerticalSpacing(8) - - environment = " • ".join(self._environment()) - rows = ( - ("CREATED BY", "PyXiion"), - ("LICENSE", "GPL-3.0-or-later"), - ("INSPIRED BY", "RimSort"), - ("ENVIRONMENT", environment), - ) - for row, (label_text, value_text) in enumerate(rows): - label = QLabel(label_text, details) - label.setObjectName("aboutMetaLabel") - layout.addWidget(label, row, 0) - - value = QLabel(value_text, details) - value.setObjectName("aboutMetaValue") - value.setWordWrap(True) - layout.addWidget(value, row, 1) - - layout.setColumnStretch(1, 1) - return details - - def _create_actions(self) -> QHBoxLayout: - actions = QHBoxLayout() - actions.setSpacing(8) - - github_button = AppButton("GitHub", self) - github_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl(self._REPOSITORY_URL)) - ) - actions.addWidget(github_button) - - issues_button = AppButton("Report issue", self) - issues_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl(self._ISSUES_URL)) - ) - actions.addWidget(issues_button) - - self._copy_button = AppButton("Copy system info", self) - self._copy_button.clicked.connect(self._copy_system_information) - actions.addWidget(self._copy_button) - - self._upload_button = AppButton("Upload log && system info…", self) - self._upload_button.clicked.connect(self._on_upload_clicked) - actions.addWidget(self._upload_button) - - credits_button = AppButton("Open-source credits", self) - credits_button.clicked.connect(self._credits_dialog.open) - actions.addWidget(credits_button) - actions.addStretch() - return actions - - def _create_credits_dialog(self) -> QDialog: - dialog = QDialog(self) - dialog.setObjectName("creditsDialog") - dialog.setWindowTitle("Open-Source Credits") - dialog.setModal(True) - dialog.resize(560, 500) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 16) - layout.setSpacing(14) - - title = QLabel("Open-source credits", dialog) - title.setObjectName("creditsTitle") - layout.addWidget(title) - - credits = QTextBrowser(dialog) - credits.setObjectName("creditsBrowser") - credits.setOpenExternalLinks(True) - credits.setHtml(self._credits_html()) - layout.addWidget(credits) - - footer = QHBoxLayout() - footer.addStretch() - close_button = AppButton("Close", dialog) - close_button.clicked.connect(dialog.reject) - footer.addWidget(close_button) - layout.addLayout(footer) - install_dialog_chrome(dialog) - return dialog - - def _credits_html(self) -> str: - dependencies = "".join( - "" - f'
    {name}' - f"{version(name)}" - f"{license_name}" - "" - for name, license_name in self._DEPENDENCIES - ) - return f""" -

    PxModRim

    -

    Created and maintained by PyXiion.

    -

    RimSort

    -

    - Portions are derived from - RimSort - and used under the MIT license. -

    -

    Runtime dependencies

    - - - - - - - {dependencies} -
    PackageVersionLicense
    - """ - - def _copy_system_information(self) -> None: - QApplication.clipboard().setText(self._system_information()) - self._toast_manager.success("System info copied") - - @staticmethod - def _environment() -> tuple[str, ...]: - return ( - f"Python {platform.python_version()}", - f"PySide6 {version('PySide6')}", - f"Qt {qVersion()}", - f"{platform.system()} {platform.release()} ({platform.machine()})", - ) - - def _system_information(self) -> str: - return "\n".join((f"PxModRim {get_app_version()}", *self._environment())) - - @asyncSlot() - async def _on_upload_clicked(self) -> None: - self._upload_button.setEnabled(False) - try: - await handle_upload_report(self, self._ctx, self._toast_manager) - finally: - self._upload_button.setEnabled(True) diff --git a/legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py b/legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py deleted file mode 100644 index 3eb5c28..0000000 --- a/legacy/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QHeaderView, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.button import AppButton - -QML_SHORTCUTS = ( - ("Up / Down", "Navigate mod list"), - ("Shift+Up / Shift+Down", "Extend mod selection"), - ("Return / Space", "Toggle selected mods"), - ("Ctrl+A", "Select all mods"), -) - - -class KeyboardShortcutsDialog(QDialog): - def __init__( - self, rows: tuple[tuple[str, str], ...], parent: QWidget | None = None - ) -> None: - super().__init__(parent) - self.setWindowTitle("Keyboard Shortcuts") - self.resize(520, 600) - layout = QVBoxLayout(self) - table = QTableWidget(len(rows), 2, self) - table.setHorizontalHeaderLabels(("Shortcut", "Action")) - table.verticalHeader().hide() - table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) - table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.ResizeToContents - ) - table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - for row, (shortcut, action) in enumerate(rows): - table.setItem(row, 0, QTableWidgetItem(shortcut)) - table.setItem(row, 1, QTableWidgetItem(action)) - layout.addWidget(table) - - buttons = QHBoxLayout() - buttons.addStretch() - close_btn = AppButton("Close", self) - close_btn.setObjectName("primaryAction") - close_btn.setDefault(True) - close_btn.clicked.connect(self.accept) - buttons.addWidget(close_btn) - layout.addLayout(buttons) diff --git a/legacy/pxmodrim/ui/panels/mod_info_data.py b/legacy/pxmodrim/ui/panels/mod_info_data.py deleted file mode 100644 index 9f57671..0000000 --- a/legacy/pxmodrim/ui/panels/mod_info_data.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import html -import re -import time -from typing import TYPE_CHECKING, Any - -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.components.unity_rich_text import unity_rich_text_to_html -from pxmodrim.ui.models.mod_list_model import provider_label - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.view.diagnostics import ModIssueView - -_URL_RE = re.compile(r"https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>\"])+") -_IMG_RE = re.compile(r"\[img\]((https?)://[^\s\[<\"]+)\[/img\]", re.IGNORECASE) -_URL_TAG_RE = re.compile( - r"\[url=(https?://[^\]\s\"<]+)\](.*?)\[/url\]", re.IGNORECASE | re.DOTALL -) -_BARE_URL_TAG_RE = re.compile(r"\[url\](https?://[^\[\s\"<]+)\[/url\]", re.IGNORECASE) -_ENTITY_RE = re.compile(r"&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});") -_QUOTE_AUTHOR_RE = re.compile(r"\[quote=([^\]]+)\]", re.IGNORECASE) -_TABLE_OPEN_RE = re.compile(r"\[table[^\]]*\]", re.IGNORECASE) -# BBCode name -> HTML tag, for tags that map one-to-one. -_PAIRED_TAGS = { - "b": "b", - "i": "i", - "u": "u", - "s": "s", - "strike": "s", - "h1": "h1", - "h2": "h2", - "h3": "h3", - "code": "pre", - "quote": "blockquote", - "list": "ul", - "olist": "ol", - "table": "table", - "tr": "tr", - "td": "td", - "th": "th", -} -_PAIRED_RE = re.compile(r"\[(/?)(" + "|".join(_PAIRED_TAGS) + r")\]", re.IGNORECASE) -# Tags that only exist to be stripped (Steam spoiler/noparse have no HTML form). -_STRIP_RE = re.compile(r"\[/?(?:spoiler|noparse)\]", re.IGNORECASE) -_BLOCK = r"(?:ul|ol|li|table|tr|td|th|blockquote|pre|h[1-3])" -_BLOCK_AFTER = r"(?:/h[1-3]|/?ul|/?ol|/?table|/?tr|/?blockquote|/?pre)" -# Line breaks the text converter emits around block tags would add blank lines. -_BR_BEFORE_BLOCK_RE = re.compile(r"(?:
    \s*)+(?=)(?:
    \s*)+") -# Replaced by the QML page with the description's pixel width. -IMG_WIDTH_TOKEN = "__IMG_WIDTH__" -_TAG_RE = re.compile(r"(<[^>]+>)") -_TRAILING_PUNCT = ".,;:!?)" -_UNITS = ( - ("year", 365 * 86400), - ("month", 30 * 86400), - ("week", 7 * 86400), - ("day", 86400), - ("hour", 3600), -) - - -def bbcode_images(html_text: str) -> str: - """Turn Steam-style [img]url[/img] into width-constrained tags. - - Only https images are loaded; plain-http ones become a link instead. - """ - - def image(m: re.Match[str]) -> str: - url = m.group(1) - if m.group(2).lower() == "https": - return f'' - return f'{url}' - - return _IMG_RE.sub(image, html_text) - - -def restore_entities(html_text: str) -> str: - """Un-escape entities like • that authors wrote into plain-text fields.""" - return _ENTITY_RE.sub(r"&\1;", html_text) - - -def bbcode_markup(html_text: str) -> str: - """Convert Steam-style BBCode (links, formatting, lists, tables) to HTML.""" - html_text = _URL_TAG_RE.sub(r'\2', html_text) - html_text = _BARE_URL_TAG_RE.sub(r'\1', html_text) - html_text = _QUOTE_AUTHOR_RE.sub(r"
    \1 wrote:
    ", html_text) - html_text = _TABLE_OPEN_RE.sub("", html_text) - html_text = _STRIP_RE.sub("", html_text) - html_text = re.sub(r"\[hr\]", "
    ", html_text, flags=re.IGNORECASE) - html_text = html_text.replace("[*]", "
  • ") - html_text = _PAIRED_RE.sub( - lambda m: f"<{m.group(1)}{_PAIRED_TAGS[m.group(2).lower()]}>", html_text - ) - html_text = _BR_BEFORE_BLOCK_RE.sub("", html_text) - return _BR_AFTER_BLOCK_RE.sub(r"\1", html_text) - - -def autolink(html_text: str) -> str: - """Wrap bare http(s) URLs in anchors, leaving text inside existing alone.""" - - def link(match: re.Match[str]) -> str: - url = match.group(0) - stripped = url.rstrip(_TRAILING_PUNCT) - return f'{stripped}{url[len(stripped) :]}' - - out = [] - in_anchor = False - for part in _TAG_RE.split(html_text): - if part.startswith("<"): - lowered = part.lower() - if lowered.startswith("": - in_anchor = True - elif lowered == "": - in_anchor = False - out.append(part) - else: - out.append(part if in_anchor else _URL_RE.sub(link, part)) - return "".join(out) - - -def description_to_html(text: str, description_format: str = "bbcode") -> str: - """Render descriptions as safe Qt rich text, preserving plain-text content.""" - if description_format != "bbcode": - return html.escape(text).replace("\n", "
    ") - return autolink( - bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text)))) - ) - - -def format_age(mtime: float, now: float | None = None) -> str: - if mtime <= 0: - return "" - delta = (time.time() if now is None else now) - mtime - for name, seconds in _UNITS: - if delta >= seconds: - count = int(delta // seconds) - return f"{count} {name}{'s' if count != 1 else ''} ago" - return "just now" - - -def _issue_dicts(issues: list[ModIssueView]) -> list[dict[str, Any]]: - return [ - { - "title": i.category_display_name, - "detail": i.detail or "", - "isError": i.is_error, - } - for i in issues - ] - - -def _status_title(issues: list[ModIssueView]) -> tuple[str, str]: - errors = sum(1 for i in issues if i.is_error) - warnings = len(issues) - errors - if errors: - return "error", f"{errors} error{'s' if errors != 1 else ''}" - if warnings: - return "warning", f"{warnings} warning{'s' if warnings != 1 else ''}" - return "ok", "No issues" - - -def _pid_index(ctx: CoreContext) -> dict[str, ListedMod]: - index: dict[str, ListedMod] = {} - for m in ctx.all_mods.values(): - if isinstance(m, AboutXmlMod): - index.setdefault(str(m.package_id).lower(), m) - return index - - -def _needs( - mod: AboutXmlMod, index: dict[str, ListedMod], active: set[str] -) -> list[dict[str, str]]: - result = [] - for dep in mod.about_rules.dependencies.values(): - candidates = [str(dep.package_id), *map(str, dep.alternative_package_ids)] - found = next((index[c.lower()] for c in candidates if c.lower() in index), None) - if found is None: - state = "missing" - elif found.uuid in active: - state = "active" - else: - state = "inactive" - result.append( - { - "name": dep.name or str(dep.package_id), - "state": state, - "uuid": found.uuid if found is not None else "", - } - ) - return result - - -def _conflicts( - mod: AboutXmlMod, index: dict[str, ListedMod], active: set[str] -) -> list[str]: - names = [] - for pid in mod.about_rules.incompatible_with: - other = index.get(str(pid).lower()) - if other is not None and other.uuid in active: - names.append(other.name) - return sorted(names, key=str.lower) - - -def _needed_by(mod: AboutXmlMod, ctx: CoreContext) -> list[dict[str, str]]: - pid = str(mod.package_id).lower() - found = [ - m - for m in ctx.all_mods.values() - if isinstance(m, AboutXmlMod) - and m.uuid != mod.uuid - and any( - pid - in (str(d.package_id).lower(), *map(str.lower, d.alternative_package_ids)) - for d in m.about_rules.dependencies.values() - ) - ] - found.sort(key=lambda m: m.name.lower()) - return [{"name": m.name, "uuid": m.uuid} for m in found] - - -def _version_support(mod: ListedMod, target: str) -> dict[str, Any]: - if not mod.supported_versions or target == "Unknown": - return {"known": False, "ok": False, "target": target} - return {"known": True, "ok": target in mod.supported_versions, "target": target} - - -def _details(mod: ListedMod, last_synced: float | None = None) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - if isinstance(mod, AboutXmlMod): - if mod.authors: - rows.append({"label": "Author", "value": ", ".join(mod.authors)}) - if mod.mod_version: - rows.append({"label": "Version", "value": mod.mod_version}) - source = provider_label(mod.provider_id, str(mod.package_id)) - if source: - rows.append({"label": "Source", "value": source}) - if mod.mtime > 0: - rows.append({"label": "Updated", "value": format_age(mod.mtime)}) - if last_synced: - rows.append({"label": "Last synced", "value": format_age(last_synced)}) - rows.append({"label": "Package ID", "value": str(mod.package_id), "copy": True}) - if mod.mod_path is not None: - rows.append({"label": "Path", "value": str(mod.mod_path), "copy": True}) - return rows - - -def build_mod_info( - ctx: CoreContext, mod: ListedMod, issues: list[ModIssueView] -) -> dict[str, Any]: - active_list = ctx.active_uuids - active = set(active_list) - is_active = mod.uuid in active - level, title = _status_title(issues) - is_about = isinstance(mod, AboutXmlMod) - - needs: list[dict[str, str]] = [] - needed_by: list[dict[str, str]] = [] - conflicts: list[str] = [] - if isinstance(mod, AboutXmlMod): - index = _pid_index(ctx) - needs = _needs(mod, index, active) - conflicts = _conflicts(mod, index, active) - needed_by = _needed_by(mod, ctx) - - downloads = download_manager(ctx) - source = downloads.source_of(mod) - return { - "name": mod.name, - "author": ", ".join(mod.authors) if isinstance(mod, AboutXmlMod) else "", - "provider": ( - provider_label(mod.provider_id, str(mod.package_id)) if is_about else "" - ), - "status": { - "level": level, - "title": title, - "active": is_active, - "position": active_list.index(mod.uuid) + 1 if is_active else 0, - "total": len(active_list), - "issues": _issue_dicts(issues), - "obsolete": mod.obsolete, - "valid": mod.valid, - }, - "version": _version_support(mod, ctx.target_version), - "needs": needs, - "conflicts": conflicts, - "neededBy": needed_by, - "description": description_to_html(mod.description), - "details": _details(mod, downloads.last_synced(mod)), - "canOpenFolder": mod.mod_path is not None and mod.mod_path.exists(), - "canUpdate": source is not None, - "updateSource": source.label if source is not None else "", - "url": mod.url if isinstance(mod, AboutXmlMod) else "", - } diff --git a/legacy/pxmodrim/ui/panels/mod_info_panel.py b/legacy/pxmodrim/ui/panels/mod_info_panel.py deleted file mode 100644 index 545b813..0000000 --- a/legacy/pxmodrim/ui/panels/mod_info_panel.py +++ /dev/null @@ -1,328 +0,0 @@ -from __future__ import annotations - -import asyncio -import html -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from loguru import logger -from PySide6.QtCore import QMetaObject, Qt, QUrl, Signal -from PySide6.QtGui import QColor, QDesktopServices, QGuiApplication, QImage, QPixmap -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.ui.components import AspectRatioBanner, generate_preview -from pxmodrim.ui.config import save_ui_prefs -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.panels.mod_info_data import build_mod_info -from pxmodrim.ui.panels.time_analytics_panel import StartupImpactDialog -from pxmodrim.ui.theme.constants import BANNER_MAX_HEIGHT -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.core.models.view.diagnostics import ModIssueView - -_QML = Path(__file__).parent / "ModInfo.qml" -_TOP_METRICS = 3 -_OPENABLE_SCHEMES = frozenset({"http", "https"}) -_NO_STARTUP = {"available": False, "message": "Startup impact is unavailable."} - - -_MARKUP_RE = re.compile( - r"\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\]", - re.IGNORECASE | re.DOTALL, -) - - -def _first_sentence(text: str, max_len: int = 80) -> str: - if not text: - return "" - stripped = " ".join(html.unescape(_MARKUP_RE.sub(" ", text)).split()) - end = stripped.find(".") - result = stripped[: end + 1] if end != -1 else stripped - if len(result) > max_len: - result = result[:max_len].rsplit(" ", 1)[0] + "\u2026" - return result - - -def _find_preview(mod_path: Path | None) -> Path | None: - if mod_path is None: - return None - candidate = mod_path / "About" / "Preview.png" - return candidate if candidate.exists() else None - - -class ModInfoPanel(QWidget): - mod_requested = Signal(str) - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - ui_prefs: UIPrefs | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._ctx = ctx - self._qml_engine = qml_engine - self._ui_prefs = ui_prefs or UIPrefs() - self._mod: ListedMod | None = None - self._current_mod_id: str | None = None - self._preview_task: asyncio.Task[None] | None = None - self._startup_token = 0 - self._startup_args: tuple[str | None, list[str]] = (None, []) - self._description = "" - self._startup_dialog: StartupImpactDialog | None = None - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._banner = AspectRatioBanner(self, max_height=BANNER_MAX_HEIGHT) - self._banner.hide() - layout.addWidget(self._banner, 0, Qt.AlignmentFlag.AlignTop) - - self._placeholder = QLabel("Select a mod to view details", self) - self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._placeholder.setObjectName("placeholder") - layout.addWidget(self._placeholder, 0, Qt.AlignmentFlag.AlignTop) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - self._qml.hide() - layout.addWidget(self._qml, 1) - - self._downloads = download_manager(ctx) - - root: Any = self._qml.rootObject() - if root is None: - logger.warning("ModInfo QML failed to load: {}", _QML) - return - root.setProperty("descExpanded", self._ui_prefs.desc_expanded) - root.openFolder.connect(self._on_open_folder) - root.openUrl.connect(self._on_open_url) - root.selectMod.connect(self.mod_requested) - root.copyText.connect(self._on_copy_text) - root.openStartupDetails.connect(self._on_open_startup_details) - root.descToggled.connect(self._on_desc_toggled) - root.updateMods.connect(self._on_update_mod) - root.setProperty("downloadsBusy", self._downloads.is_downloading) - self._downloads.busy_changed.connect(self._on_downloads_busy) - - def _set_qml(self, name: str, value: object) -> None: - root = self._qml.rootObject() - if root is None: - logger.warning("ModInfo QML root object is missing; dropping '{}'", name) - return - root.setProperty(name, value) - - def show_mod(self, mod: ListedMod, issues: list[ModIssueView]) -> None: - if self._preview_task and not self._preview_task.done(): - self._preview_task.cancel() - - mod_id = getattr(mod, "package_id", None) - if mod_id is None: - mod_id = mod.name - - self._mod = mod - self._current_mod_id = mod_id - - self._placeholder.hide() - self._banner.show() - self._qml.show() - - self._banner.setTitle(mod.name) - self._banner.setSubtitle( - _first_sentence(mod.description) - if mod.description - else (str(mod.package_id) if isinstance(mod, AboutXmlMod) else "") - ) - - self._set_qml("startup", None) - self.set_issues(issues) - self._sync_updating() - root: Any = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "resetScroll") - - self._preview_task = asyncio.ensure_future( - self._load_preview(mod.mod_path, mod_id, mod.name) - ) - - def set_issues(self, issues: list[ModIssueView]) -> None: - if self._mod is None: - return - data = build_mod_info(self._ctx, self._mod, issues) - description = data.pop("description") - self._set_qml("info", data) - if description != self._description: - self._description = description - self._set_qml("description", description) - - def _on_downloads_busy(self, busy: bool) -> None: - self._set_qml("downloadsBusy", busy) - self._sync_updating() - - def _sync_updating(self) -> None: - mod_id = self._downloads.updatable_id(self._mod) if self._mod else None - updating = mod_id is not None and mod_id in self._downloads.active_ids - self._set_qml("updatingThis", updating) - - @asyncSlot() - async def _on_update_mod(self) -> None: - if self._mod is None: - return - pid = self._downloads.updatable_id(self._mod) - if pid is None: - return - try: - await self._downloads.download_mods([pid]) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] update of {} not started: {}", pid, exc) - - def _on_open_folder(self) -> None: - if self._mod is not None and self._mod.mod_path is not None: - QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._mod.mod_path))) - - def _on_open_url(self) -> None: - if not isinstance(self._mod, AboutXmlMod) or not self._mod.url: - return - url = QUrl(self._mod.url) - if url.scheme().lower() not in _OPENABLE_SCHEMES: - logger.warning("Refusing to open non-http(s) mod URL: {}", self._mod.url) - return - QDesktopServices.openUrl(url) - - def _on_copy_text(self, text: str) -> None: - QGuiApplication.clipboard().setText(text) - - @asyncSlot() - async def _on_open_startup_details(self) -> None: - pid, active_pids = self._startup_args - dialog = self._startup_dialog - if dialog is None: - dialog = StartupImpactDialog( - self._ctx.mod_service.startup_impact, self._qml_engine, self - ) - dialog.destroyed.connect(self._on_startup_dialog_destroyed) - self._startup_dialog = dialog - dialog.show() - dialog.raise_() - dialog.activateWindow() - await dialog.panel.set_data(pid, active_pids) - - def _on_startup_dialog_destroyed(self) -> None: - self._startup_dialog = None - - async def _load_preview( - self, mod_path: Path | None, mod_id: str, mod_name: str - ) -> None: - """Load preview image in background thread. Only updates UI if current mod.""" - try: - preview_path = await asyncio.to_thread(_find_preview, mod_path) - - if preview_path is not None: - image = await asyncio.to_thread(QImage, str(preview_path)) - if self._current_mod_id == mod_id and not image.isNull(): - self._banner.setPixmap(QPixmap.fromImage(image)) - self._banner.setShowOverlay(False) - return - - # No usable preview — show fallback with overlay - if self._current_mod_id == mod_id: - w = self._banner.width() or 300 - h = self._banner.heightForWidth(w) - fallback = await asyncio.to_thread(generate_preview, mod_name, w, h) - if self._current_mod_id == mod_id: - self._banner.setShowOverlay(True) - self._banner.setPixmap(QPixmap.fromImage(fallback)) - except asyncio.CancelledError: - pass - - async def set_time_analytics( - self, - pid: str | None, - active_pids: list[str], - ) -> None: - self._startup_token += 1 - token = self._startup_token - self._startup_args = (pid, active_pids) - - sis = self._ctx.mod_service.startup_impact - if not sis or not pid: - self._set_qml("startup", _NO_STARTUP) - return - - report, *_, own = await sis.snapshot(active_pids, pid) - if token != self._startup_token: - return - - if report is None: - self._set_qml( - "startup", - { - "available": False, - "message": "No startup data recorded yet. " - "Launch the game to generate it.", - }, - ) - return - - mod = report.find(pid, None) - if mod is None or own < 0.001: - self._set_qml( - "startup", - { - "available": False, - "message": "This mod wasn't measured in the last recorded launch.", - }, - ) - return - - rank = 1 + sum(1 for m in report.mods if m.total_impact_s > own) - top = sorted(mod.metrics.items(), key=lambda kv: kv[1], reverse=True) - self._set_qml( - "startup", - { - "available": True, - "own": format_duration(own), - "rank": f"#{rank} slowest of {len(report.mods)} mods", - "estimated": pid.lower() not in {a.lower() for a in active_pids}, - "color": impact_color(own), - "rows": [ - { - "label": metric_label(name), - "value": format_duration(value), - "fraction": min(1.0, value / own), - } - for name, value in top[:_TOP_METRICS] - if value >= 0.001 - ], - }, - ) - - def clear(self) -> None: - self._mod = None - self._current_mod_id = None - self._startup_token += 1 - self._placeholder.show() - self._banner.hide() - self._qml.hide() - self._description = "" - self._set_qml("info", None) - self._set_qml("description", "") - self._set_qml("startup", None) - - def _on_desc_toggled(self, expanded: bool) -> None: - self._ui_prefs.desc_expanded = expanded - save_ui_prefs(self._ui_prefs, self._ctx.config_service) diff --git a/legacy/pxmodrim/ui/panels/mod_list_panel.py b/legacy/pxmodrim/ui/panels/mod_list_panel.py deleted file mode 100644 index 026b3c5..0000000 --- a/legacy/pxmodrim/ui/panels/mod_list_panel.py +++ /dev/null @@ -1,351 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Q_ARG, - Property, - QEvent, - QMetaObject, - QObject, - Qt, - QUrl, - Signal, - Slot, -) -from PySide6.QtGui import QAction, QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import ( - QHBoxLayout, - QLineEdit, - QMenu, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.components.mod_activation import toggle_mods -from pxmodrim.ui.components.mod_updates import ( - add_update_action, - update_mods, -) -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.config import AppConfig -_QML_DIR = Path(__file__).parent -_MOD_LIST_QML = _QML_DIR / "ModList.qml" - -_LIST_VIEW_CONTAIN = 4 - - -class ModListPanel(QWidget): - mod_selected = Signal(str) - active_mods_changed = Signal() - order_changed = Signal() - selection_changed = Signal(list) - compact_mode_changed = Signal(bool) - highlighted_changed = Signal() - - @Property(bool, notify=compact_mode_changed) - def compactMode(self) -> bool: - return self._compact_mode - - @Slot(bool) - def setCompactMode(self, enabled: bool) -> None: - if self._compact_mode != enabled: - self._compact_mode = enabled - self.compact_mode_changed.emit(enabled) - - @Property(int, notify=highlighted_changed) - def highlightRevision(self) -> int: - return self._highlight_revision - - @Slot(str, result=bool) - def isHighlighted(self, uuid: str) -> bool: - return uuid in self._highlighted - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._ctx = ctx - cfg = getattr(ctx, "config", None) - self._compact_mode: bool = ( - getattr(cfg, "compact_mod_list", False) if cfg else False - ) - self._highlighted: frozenset[str] = frozenset() - self._highlight_revision = 0 - self._highlight_generation = 0 - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Search bar - search_container = QWidget(self) - search_container.setObjectName("searchBox") - search_container.setFixedHeight(56) - search_layout = QHBoxLayout(search_container) - search_layout.setContentsMargins(16, 8, 16, 8) - - self.search_input = QLineEdit(search_container) - self.search_input.setObjectName("searchInput") - self.search_input.setPlaceholderText("Search mods, package ID, author…") - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self._on_search_changed) - - # Leading search icon - search_icon = icon("search", 16, PALETTE["TEXT_DIM"]) - search_action = QAction(search_icon, "", self) - self.search_input.addAction( - search_action, QLineEdit.ActionPosition.LeadingPosition - ) - - search_layout.addWidget(self.search_input) - layout.addWidget(search_container) - - # Models: source + proxy - self._model = ModListModel({}, ctx.diagnostics_service, self) - self._proxy = ModListProxyModel(self._model, self) - self._model.active_mods_changed.connect(self.active_mods_changed) - - # QML view - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self._qml.setAcceptDrops(True) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("modListPanel", self) - qml_ctx.setContextProperty("modListModel", self._proxy) - qml_ctx.setContextProperty("modListHasFocus", False) - self._qml.setSource(QUrl.fromLocalFile(str(_MOD_LIST_QML))) - - layout.addWidget(self._qml) - - self._qml.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self._qml.installEventFilter(self) - - ctx.active_state_changed.connect(self._on_core_state_changed) - if hasattr(ctx, "config_changed"): - ctx.config_changed.connect(self._on_config_changed) - # Reactive bindings - ctx.mod_service.mods_changed.connect(self._on_mods_changed) - - # ── Public API ──────────────────────────────────────────── - - def load_mods(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - self._model.load_mods(mods, active_uuids) - - def active_uuids(self) -> list[str]: - return self._model.active_uuids() - - @property - def model(self) -> ModListModel: - return self._model - - @property - def proxy_model(self) -> ModListProxyModel: - return self._proxy - - def set_sidebar_filter(self, uuids: set[str] | None) -> None: - self._proxy.set_sidebar_filter(uuids) - - def set_search_filter(self, text: str) -> None: - self._proxy.set_search_filter(text) - - # ── Reactive ────────────────────────────────────────────── - - def _on_mods_changed(self, _: None = None) -> None: - asyncio.ensure_future(self._refresh()) - - async def _refresh(self) -> None: - self._model.update_provider_colors(self._ctx.mod_service.provider_colors) - self._model.load_mods(self._ctx.all_mods, self._ctx.active_uuids) - impacts = await self._ctx.mod_service.startup_impact.get_all_averages() - self._model.set_startup_impact(impacts) - - # ── Slots called from QML ───────────────────────────────── - - @Slot(str) - def modSelected(self, uuid: str) -> None: - self.mod_selected.emit(uuid) - - def select_uuid(self, uuid: str) -> bool: - """Select and scroll to a mod; False if it is hidden by a filter.""" - row = self.rowForUuid(uuid) - root: Any = self._qml.rootObject() - if row < 0 or root is None: - return False - root.revealRow(row, uuid) - return True - - @Slot() - def clearSelection(self) -> None: - root = self._qml.rootObject() - if root is not None: - list_view = root.findChild(QObject, "listView") - if list_view is not None: - list_view.setProperty("currentIndex", -1) - list_view.setProperty("anchorIndex", -1) - list_view.setProperty("selectedIndices", []) - list_view.setProperty("currentUuid", "") - list_view.setProperty("anchorUuid", "") - list_view.setProperty("selectedUuids", []) - self.selection_changed.emit([]) - self.mod_selected.emit("") - - def _proxy_to_source_row(self, proxy_row: int) -> int: - proxy_index = self._proxy.index(proxy_row, 0) - source_index = self._proxy.mapToSource(proxy_index) - return source_index.row() - - @Slot(int, result=str) - def uuidAt(self, row: int) -> str: - source_row = self._proxy_to_source_row(row) - item = self._model.get_item(source_row) - return item.uuid if item else "" - - @Slot(str, result=int) - def rowForUuid(self, uuid: str) -> int: - row = self._proxy.proxy_row_for_uuid(uuid) - return row if row is not None else -1 - - @asyncSlot(int) - async def toggleCheck(self, row: int) -> None: - await self._toggle_rows([row]) - - @asyncSlot(list) - async def toggleChecked(self, rows: list[int]) -> None: - await self._toggle_rows(rows) - - async def _toggle_rows(self, rows: list[int]) -> None: - selected_uuids: list[str] = [] - for proxy_row in rows: - item = self._model.get_item(self._proxy_to_source_row(proxy_row)) - if item is not None: - selected_uuids.append(item.uuid) - if not selected_uuids: - return - - previously_active = set(self._ctx.active_uuids) - await toggle_mods(self._ctx, self, selected_uuids) - active_after = set(self._ctx.active_uuids) - newly_enabled = [ - uuid - for uuid in selected_uuids - if uuid not in previously_active and uuid in active_after - ] - if newly_enabled: - self._highlight_rows(newly_enabled) - - def _highlight_rows(self, uuids: list[str]) -> None: - rows = [ - row - for uuid in uuids - if (row := self._proxy.proxy_row_for_uuid(uuid)) is not None - ] - root = self._qml.rootObject() - if not rows or root is None: - return - list_view = root.findChild(QObject, "listView") - if list_view is not None: - QMetaObject.invokeMethod( - list_view, - "positionViewAtIndex", - Qt.ConnectionType.DirectConnection, - Q_ARG("int", rows[-1]), - Q_ARG("int", _LIST_VIEW_CONTAIN), - ) - self._set_highlighted(uuids) - self._highlight_generation += 1 - asyncio.create_task(self._clear_highlight(self._highlight_generation)) - - def _set_highlighted(self, uuids: list[str]) -> None: - self._highlighted = frozenset(uuids) - self._highlight_revision += 1 - self.highlighted_changed.emit() - - async def _clear_highlight(self, generation: int) -> None: - await asyncio.sleep(1.2) - if generation == self._highlight_generation: - self._set_highlighted([]) - - @Slot("QVariantList") - def showContextMenu(self, uuids: list[str]) -> None: - if not download_manager(self._ctx).available: - return - menu = QMenu(self) - menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - menu.setToolTipsVisible(True) - add_update_action(menu, self._ctx, uuids, lambda: self._update_selected(uuids)) - menu.popup(self.cursor().pos()) - - @asyncSlot() - async def _update_selected(self, uuids: list[str]) -> None: - await update_mods(self._ctx, uuids, self) - - @Slot(int, result=bool) - def isChecked(self, row: int) -> bool: - source_row = self._proxy_to_source_row(row) - item = self._model.get_item(source_row) - return item.checked if item else False - - def _on_core_state_changed(self, active_uuids: object) -> None: - if not isinstance(active_uuids, tuple): - return - current = frozenset(self._model.active_uuids()) - new = frozenset(active_uuids) - self._model.set_checkable(new - current, True) - self._model.set_checkable(current - new, False) - self._model.commitOrder(list(active_uuids)) - - def _on_config_changed(self, cfg: AppConfig) -> None: - if self._compact_mode != cfg.compact_mod_list: - self._compact_mode = cfg.compact_mod_list - self.compact_mode_changed.emit(self._compact_mode) - - @Slot(list) - def commitOrder(self, uuids: list[str]) -> None: - self._model.commitOrder(uuids) - self._ctx.set_active(self._model.active_uuids()) - self.order_changed.emit() - - @Slot(int, int) - def moveRow(self, source_row: int, target_row: int) -> None: - self._proxy.move_row(source_row, target_row, clamp_to_active=True) - - @Slot() - def dragEnded(self) -> None: - self._ctx.set_active(self._model.active_uuids()) - self.order_changed.emit() - - @Slot(list) - def selectionChanged(self, uuids: list[str]) -> None: - self.selection_changed.emit([uuid for uuid in uuids if uuid]) - - # ── Private slots ───────────────────────────────────────── - - def _on_search_changed(self, text: str) -> None: - self._proxy.set_search_filter(text) - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - if obj is self._qml: - if event.type() == QEvent.Type.FocusIn: - self._qml.rootContext().setContextProperty("modListHasFocus", True) - elif event.type() == QEvent.Type.FocusOut: - self._qml.rootContext().setContextProperty("modListHasFocus", False) - return super().eventFilter(obj, event) diff --git a/legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py b/legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py deleted file mode 100644 index cece224..0000000 --- a/legacy/pxmodrim/ui/panels/restore_snapshot_dialog.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QMessageBox, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.mods_config import parse_mods_config -from pxmodrim.ui.components import AppButton - - -class RestoreSnapshotDialog(QDialog): - """Let the user choose a saved mod list to restore.""" - - def __init__(self, snapshots: list[Path], parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("restoreSnapshotDialog") - self.setWindowTitle("Restore Mod List") - self.resize(560, 420) - - self._snapshots = snapshots - self._selected_snapshot: Path | None = None - - layout = QVBoxLayout(self) - - instruction = QLabel( - "Choose a saved mod list. Your current list will be backed up before " - "it is replaced.", - self, - ) - instruction.setWordWrap(True) - layout.addWidget(instruction) - - self._list_widget = QListWidget(self) - self._populate_list() - layout.addWidget(self._list_widget) - - self._details_label = QLabel(self) - self._details_label.setWordWrap(True) - layout.addWidget(self._details_label) - - buttons = QHBoxLayout() - buttons.addStretch() - - self._restore_btn = AppButton("Restore", self) - self._restore_btn.setObjectName("primaryAction") - self._restore_btn.setEnabled(False) - self._restore_btn.clicked.connect(self._on_restore_clicked) - - cancel_btn = AppButton("Cancel", self) - cancel_btn.clicked.connect(self.reject) - - buttons.addWidget(cancel_btn) - buttons.addWidget(self._restore_btn) - layout.addLayout(buttons) - - self._list_widget.currentItemChanged.connect(self._on_item_changed) - self._list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) - - if self._list_widget.count() > 0: - self._list_widget.setCurrentRow(0) - - @property - def selected_snapshot(self) -> Path | None: - return self._selected_snapshot - - def _format_timestamp(self, path: Path) -> str: - stem = path.stem - if stem.startswith("ModsConfig_"): - parts = stem[len("ModsConfig_") :].split("_") - if len(parts) >= 2 and len(parts[0]) == 8 and len(parts[1]) >= 6: - d, t = parts[0], parts[1][:6] - try: - dt = datetime.strptime(f"{d}_{t}", "%Y%m%d_%H%M%S").replace( - tzinfo=UTC - ) - return dt.strftime("%Y-%m-%d %H:%M:%S UTC") - except ValueError: - pass - try: - modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) - return modified.strftime("%Y-%m-%d %H:%M:%S UTC") - except OSError: - return stem - - def _populate_list(self) -> None: - for path in self._snapshots: - item = QListWidgetItem(self._list_widget) - parsed = parse_mods_config(path) - ts = self._format_timestamp(path) - - if parsed is not None: - active_count = len(parsed.activeMods) - noun = "mod" if active_count == 1 else "mods" - text = f"{ts} — {active_count} active {noun}, RimWorld {parsed.version}" - item.setData(Qt.ItemDataRole.UserRole, (path, True, parsed)) - else: - text = f"{ts} — Cannot be read" - item.setData(Qt.ItemDataRole.UserRole, (path, False, None)) - - item.setText(text) - item.setToolTip(path.name) - - def _on_item_changed( - self, current: QListWidgetItem | None, _: QListWidgetItem | None - ) -> None: - if current is None: - self._selected_snapshot = None - self._restore_btn.setEnabled(False) - self._details_label.setText("") - return - - path, is_valid, parsed = current.data(Qt.ItemDataRole.UserRole) - if is_valid and parsed is not None: - self._selected_snapshot = path - self._restore_btn.setEnabled(True) - self._details_label.setText( - f"File: {path.name}
    " - f"Active mods: {len(parsed.activeMods)} | " - f"Game version: {parsed.version}" - ) - else: - self._selected_snapshot = None - self._restore_btn.setEnabled(False) - warn_msg = ( - f"This saved mod list cannot be read. " - f"{path.name} will not be restored." - ) - self._details_label.setText(warn_msg) - - def _on_restore_clicked(self) -> None: - if self._selected_snapshot is not None: - self.accept() - - def _on_item_double_clicked(self, _: QListWidgetItem) -> None: - self._on_restore_clicked() - - -class ConfirmRestoreDialog(QMessageBox): - """Ask for confirmation before replacing the current mod list.""" - - def __init__(self, snapshot_name: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - # QMessageBox's setter is a no-op on macOS. - QDialog.setWindowTitle(self, "Restore Mod List") - self.setText(f"Restore the mod list saved in {snapshot_name}?") - self.setInformativeText( - "This replaces your current active mod list. PxModRim will save a " - "backup of the current list first." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - yes_btn = self.button(QMessageBox.StandardButton.Yes) - if yes_btn: - yes_btn.setText("Restore") - yes_btn.setObjectName("primaryAction") diff --git a/legacy/pxmodrim/ui/panels/settings_panel.py b/legacy/pxmodrim/ui/panels/settings_panel.py deleted file mode 100644 index 35c58a8..0000000 --- a/legacy/pxmodrim/ui/panels/settings_panel.py +++ /dev/null @@ -1,267 +0,0 @@ -from __future__ import annotations - -import dataclasses -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -import msgspec -from PySide6.QtCore import Property, QObject, Qt, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QDialog, QFileDialog, QVBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.config import ( - AppConfig, - community_rules_file, - detect_game_paths, -) -from pxmodrim.core.constants import AfterLaunch -from pxmodrim.core.context import CoreContext -from pxmodrim.core.loading import LoadingState -from pxmodrim.core.sort.community_service import CommunityRulesService -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.progress_dialog import ProgressDialog -from pxmodrim.ui.settings_section import SettingsSection, SettingsSectionFactory -from pxmodrim.ui.theme.palette import PALETTE - -_QML = Path(__file__).parent / "Settings.qml" - -_FOLDER_TITLES = { - "game": "Select RimWorld Game Folder", - "local": "Select Local Mods Folder", - "workshop": "Select Workshop Mods Folder", - "config": "Select RimWorld Config Folder", -} - - -class _FolderDialog(QFileDialog): - def __init__(self, parent: QWidget, title: str) -> None: - super().__init__(parent, title) - self.setFileMode(QFileDialog.FileMode.Directory) - self.setOption(QFileDialog.Option.ShowDirsOnly) - - -class _SettingsBackend(QObject): - """State and actions behind Settings.qml; the dialog owns accept/reject.""" - - pathPicked = Signal(str, str) - saveRequested = Signal(dict) - cancelRequested = Signal() - communityChanged = Signal() - cacheChanged = Signal() - - def __init__( - self, ctx: CoreContext, dialog: QDialog, sections: Sequence[SettingsSection] - ) -> None: - super().__init__(dialog) - self._ctx = ctx - self._dialog = dialog - self._sections = [ - { - "title": section.title, - "source": QUrl.fromLocalFile(str(section.source)).toString(), - "section": section, - } - for section in sections - ] - cfg = ctx.config - self._initial: dict[str, Any] = { - "game": cfg.paths.game, - "local": cfg.paths.local, - "workshop": cfg.paths.workshop, - "config": cfg.paths.config_folder, - "compact": cfg.compact_mod_list, - "launchArgs": cfg.launch_args, - "launchWrapper": cfg.launch_wrapper, - "afterLaunch": int(cfg.after_launch), - "confirmErrors": cfg.launch_confirm_errors, - "confirmUnsaved": cfg.launch_confirm_unsaved, - "confirmRunning": cfg.launch_confirm_running, - "useAltIds": cfg.sort.use_alternative_package_ids, - "checkMissing": cfg.sort.check_missing_dependencies, - "useCommunity": cfg.sort.use_community_rules, - } - rules = community_rules_file() - self._community_status = ( - f"Found: {rules}" if rules.exists() else "Not downloaded" - ) - self._community_busy = False - self._cache_available = bool(cfg.paths.config_folder) - self._cache_status = ( - "Cache ready" - if self._cache_available - else "No config folder — not available" - ) - self._cache_busy = False - - @Property(dict, constant=True) # type: ignore[arg-type] - def initial(self) -> dict[str, Any]: - return self._initial - - @Property(list, constant=True) # type: ignore[arg-type] - def sections(self) -> list[dict[str, Any]]: - return self._sections - - @Property(str, notify=communityChanged) # type: ignore[arg-type] - def communityStatus(self) -> str: - return self._community_status - - @Property(bool, notify=communityChanged) # type: ignore[arg-type] - def communityBusy(self) -> bool: - return self._community_busy - - @Property(str, notify=cacheChanged) # type: ignore[arg-type] - def cacheStatus(self) -> str: - return self._cache_status - - @Property(bool, notify=cacheChanged) # type: ignore[arg-type] - def cacheAvailable(self) -> bool: - return self._cache_available - - @Property(bool, notify=cacheChanged) # type: ignore[arg-type] - def cacheBusy(self) -> bool: - return self._cache_busy - - def _set_community(self, status: str, busy: bool) -> None: - self._community_status = status - self._community_busy = busy - self.communityChanged.emit() - - def _set_cache(self, status: str, busy: bool) -> None: - self._cache_status = status - self._cache_busy = busy - self.cacheChanged.emit() - - @asyncSlot(str) - async def browse(self, key: str) -> None: - title = _FOLDER_TITLES.get(key) - if title is None: - return - result, dialog = await await_dialog(_FolderDialog, self._dialog, title) - files = dialog.selectedFiles() - if result == QDialog.DialogCode.Accepted and files: - self.pathPicked.emit(key, files[0]) - - @Slot(str, result=str) - def localCandidate(self, game_path: str) -> str: - candidate = Path(game_path) / "Mods" - return str(candidate) if candidate.is_dir() else "" - - @Slot() - def autoDetect(self) -> None: - detected = detect_game_paths() - for key, value in ( - ("game", detected.game), - ("local", detected.local), - ("workshop", detected.workshop), - ("config", detected.config_folder), - ): - if value: - self.pathPicked.emit(key, value) - - @asyncSlot() - async def downloadCommunityRules(self) -> None: - self._set_community("Downloading…", True) - status = "Download failed" - try: - async with ProgressDialog(LoadingState(self), self._dialog) as dialog: - service = CommunityRulesService(self._ctx.config_service) - path = await service.ensure_rules(dialog.loading, force=True) - QTimer.singleShot(1000, dialog, dialog.deleteLater) - if path: - status = f"Downloaded: {path}" - finally: - self._set_community(status, False) - - @asyncSlot() - async def clearCache(self) -> None: - self._set_cache("Clearing…", True) - status = "Cache cleared" - try: - await self._ctx.mod_service.startup_impact.clear() - except OSError as exc: - status = f"Error: {exc}" - finally: - self._set_cache(status, False) - - @Slot("QVariantMap") - def save(self, values: dict[str, Any]) -> None: - self.saveRequested.emit(values) - - @Slot() - def cancel(self) -> None: - self.cancelRequested.emit() - - -class SettingsPanel(QDialog): - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine, - parent: QWidget, - sections: Sequence[SettingsSectionFactory] = (), - ) -> None: - super().__init__(parent) - self.setObjectName("settingsPanel") - self.setWindowTitle("Settings") - self.setModal(True) - self.resize(700, 560) - - self._ctx = ctx - self._config = ctx.config - - self._sections = [factory(self) for factory in sections] - self._backend = _SettingsBackend(ctx, self, self._sections) - self._backend.saveRequested.connect(self._save) - self._backend.cancelRequested.connect(self.reject) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._qml.rootContext().setContextProperty("settings", self._backend) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - layout.addWidget(self._qml) - # Unload before the backend and sections (children of this dialog) are - # destroyed, or every binding to them re-evaluates against null. Queued: - # Save/Cancel finish the dialog from inside a QML click handler. - self.finished.connect(self._unload, Qt.ConnectionType.QueuedConnection) - - def _unload(self) -> None: - self._qml.setSource(QUrl()) - - def _save(self, values: dict[str, Any]) -> None: - self._config = msgspec.structs.replace( - self._config, - paths=msgspec.structs.replace( - self._config.paths, - game=values["game"].strip(), - local=values["local"].strip(), - workshop=values["workshop"].strip(), - config_folder=values["config"].strip(), - ), - sort=dataclasses.replace( - self._config.sort, - use_alternative_package_ids=bool(values["useAltIds"]), - check_missing_dependencies=bool(values["checkMissing"]), - use_community_rules=bool(values["useCommunity"]), - ), - compact_mod_list=bool(values["compact"]), - launch_args=values["launchArgs"].strip(), - launch_wrapper=values["launchWrapper"].strip(), - after_launch=AfterLaunch(int(values["afterLaunch"])), - launch_confirm_errors=bool(values["confirmErrors"]), - launch_confirm_unsaved=bool(values["confirmUnsaved"]), - launch_confirm_running=bool(values["confirmRunning"]), - ) - for section in self._sections: - section.apply() - self.accept() - - def get_config(self) -> AppConfig: - return self._config diff --git a/legacy/pxmodrim/ui/panels/sidebar_panel.py b/legacy/pxmodrim/ui/panels/sidebar_panel.py deleted file mode 100644 index 1eda5b3..0000000 --- a/legacy/pxmodrim/ui/panels/sidebar_panel.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QObject, Signal -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.view.sidebar import AllModsEntry, SidebarEntry -from pxmodrim.ui.components.filter_sidebar import FilterSidebar -from pxmodrim.ui.models.sidebar_model import SidebarModel - - -class SidebarPanel(FilterSidebar): - entry_selected = Signal(object) # SidebarEntry - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - self._model = SidebarModel() - super().__init__(qml_engine, self._model, parent) - self._model.setParent(self) - self._entries: list[SidebarEntry] = [] - self.activated.connect(self._on_activated) - ctx.diagnostics_service.sidebar_entries_changed.connect(self.set_entries) - - def set_entries(self, entries: list[SidebarEntry]) -> None: - reset = len(entries) != len(self._entries) - self._entries = entries - self._model.update_entries(entries) - if reset: - current = self.current_entry() - if current is not None: - self.entry_selected.emit(current) - - def current_entry(self) -> SidebarEntry | None: - selected = self.current_index() - if 0 <= selected < len(self._entries): - return self._entries[selected] - return None - - def select_all_entry(self) -> None: - """Highlight and activate the unfiltered "All" entry.""" - row = next( - (i for i, e in enumerate(self._entries) if isinstance(e, AllModsEntry)), -1 - ) - root = self.rootObject() - view = root.findChild(QObject, "listView") if root is not None else None - if row < 0 or view is None: - return - view.setProperty("currentIndex", row) - self._on_activated(row) - - def _on_activated(self, row: int) -> None: - if 0 <= row < len(self._entries): - self.entry_selected.emit(self._entries[row]) diff --git a/legacy/pxmodrim/ui/panels/startup_impact_data.py b/legacy/pxmodrim/ui/panels/startup_impact_data.py deleted file mode 100644 index bd6b107..0000000 --- a/legacy/pxmodrim/ui/panels/startup_impact_data.py +++ /dev/null @@ -1,225 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from datetime import datetime -from typing import Any - -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.phases import ( - PHASE_LABELS, - Phase, - metric_phase, - phase_totals, -) -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.theme.palette import PALETTE - -PHASE_COLORS: dict[Phase, str] = { - Phase.CONTENT: "#b57bee", - Phase.MOD_CONSTRUCTORS: "#f06ba8", - Phase.STATIC_CONSTRUCTORS: "#43b581", - Phase.PATCHES: "#f0883e", - Phase.DEFS: "#4fb3e8", - Phase.DEFERRED: "#e8c547", - Phase.OTHER: PALETTE["TEXT_DIM"], -} - -BASE_GAME_LABEL = "Base game" -_MIN_S = 0.001 -_TOOLTIP_METRICS = 8 - - -def build_startup_impact( - report: StartupImpactReport, - *, - active_pids: list[str], - selected_pid: str | None, - estimated_s: float, -) -> dict[str, Any]: - active = {normalize_package_id(p) for p in active_pids} - selected = normalize_package_id(selected_pid) if selected_pid else None - base = next((m for m in report.mods if m.package_id == BASE_GAME_PID), None) - mods = sorted( - (m for m in report.mods if m.package_id != BASE_GAME_PID), - key=lambda m: m.total_impact_s, - reverse=True, - ) - - base_s = base.total_impact_s if base else 0.0 - mods_s = sum(m.total_impact_s for m in mods) - off_s = sum(m.off_thread_total_impact_s for m in report.mods) - bar_total = base_s + mods_s + off_s - max_mod_s = mods[0].total_impact_s if mods else 0.0 - - rows = [ - _mod_row(m, rank, max_mod_s, active, selected) - for rank, m in enumerate(mods, start=1) - ] - - entries = [(m.mod_name, _key(m), False, m.metrics) for m in mods] - base_entry = (BASE_GAME_LABEL, BASE_GAME_PID, True, base.metrics if base else {}) - - return { - "timestamp": _format_timestamp(report.timestamp), - "last_launch": format_duration(report.loading_time_s or base_s + mods_s), - "estimated": format_duration(estimated_s) if active_pids else "", - "summary": [ - { - "label": label, - "value": format_duration(seconds), - "fraction": seconds / bar_total if bar_total > 0 else 0.0, - "color": color, - "parallel": parallel, - } - for label, seconds, color, parallel in ( - (BASE_GAME_LABEL, base_s, PALETTE["TEXT_DIM"], False), - ("Mods, main thread", mods_s, PALETTE["PRIMARY"], False), - ("Off-thread (parallel)", off_s, PALETTE["PRIMARY"], True), - ) - if seconds >= _MIN_S - ], - "base_value": format_duration(base_s), - "mods_value": format_duration(mods_s), - "off_value": format_duration(off_s), - "legend": [{"label": PHASE_LABELS[p], "color": PHASE_COLORS[p]} for p in Phase], - "mods": rows, - "active_count": sum(1 for r in rows if r["active"]), - "selected_key": next((r["key"] for r in rows if r["selected"]), ""), - "phases_with_base": _phase_rows([base_entry, *entries], selected), - "phases_mods_only": _phase_rows(entries, selected), - } - - -def _key(mod: StartupImpactMod) -> str: - return mod.package_id or mod.mod_name - - -def _mod_row( - mod: StartupImpactMod, - rank: int, - max_mod_s: float, - active: set[str], - selected: str | None, -) -> dict[str, Any]: - totals = phase_totals(mod.metrics) - by_phase = _metrics_by_phase(mod.metrics) - ranked = sorted( - ((p, s) for p, s in totals.items() if s >= _MIN_S), - key=lambda ps: ps[1], - reverse=True, - ) - top_phase_s = ranked[0][1] if ranked else 0.0 - pid = mod.package_id - return { - "key": _key(mod), - "name": mod.mod_name, - "rank": rank, - "seconds": mod.total_impact_s, - "time": format_duration(mod.total_impact_s), - "color": impact_color(mod.total_impact_s), - "active": pid is not None and pid in active, - "selected": pid is not None and pid == selected, - "off_thread": format_duration(mod.off_thread_total_impact_s) - if mod.off_thread_total_impact_s >= _MIN_S - else "", - "dominant": PHASE_LABELS[ranked[0][0]] if ranked else "", - "segments": [ - { - "color": PHASE_COLORS[p], - "fraction": totals[p] / max_mod_s if max_mod_s > 0 else 0.0, - } - for p in Phase - if totals[p] >= _MIN_S - ], - "phases": [ - { - "label": PHASE_LABELS[p], - "color": PHASE_COLORS[p], - "value": format_duration(s), - "fraction": s / top_phase_s, - "tooltip": _tooltip(by_phase[p]), - } - for p, s in ranked - ], - } - - -def _phase_rows( - entries: list[tuple[str, str, bool, dict[str, float]]], - selected: str | None, -) -> list[dict[str, Any]]: - per_phase: dict[Phase, list[tuple[float, str, str, bool]]] = defaultdict(list) - metric_sums: dict[Phase, dict[str, float]] = defaultdict(lambda: defaultdict(float)) - for name, key, is_base, metrics in entries: - for phase, seconds in phase_totals(metrics).items(): - if seconds >= _MIN_S: - per_phase[phase].append((seconds, name, key, is_base)) - for metric, seconds in metrics.items(): - label = metric_label(metric.split("|", 1)[0]) - metric_sums[metric_phase(metric)][label] += seconds - - grand = sum(s for items in per_phase.values() for s, *_ in items) - totals = {p: sum(s for s, *_ in items) for p, items in per_phase.items()} - top_phase_s = max(totals.values(), default=0.0) - rows = [] - for phase, seconds in sorted(totals.items(), key=lambda ps: ps[1], reverse=True): - contributors = sorted(per_phase[phase], reverse=True) - top_s = contributors[0][0] - rows.append( - { - "label": PHASE_LABELS[phase], - "color": PHASE_COLORS[phase], - "seconds": seconds, - "value": format_duration(seconds), - "share": f"{round(100 * seconds / grand)}%" if grand > 0 else "", - "fraction": seconds / top_phase_s, - "tooltip": _tooltip(metric_sums[phase]), - "mods": [ - { - "name": name, - "key": key, - "value": format_duration(s), - "fraction": s / top_s, - "base": is_base, - "selected": selected is not None and key == selected, - } - for s, name, key, is_base in contributors - ], - } - ) - return rows - - -def _metrics_by_phase(metrics: dict[str, float]) -> dict[Phase, dict[str, float]]: - grouped: dict[Phase, dict[str, float]] = defaultdict(lambda: defaultdict(float)) - for key, seconds in metrics.items(): - grouped[metric_phase(key)][metric_label(key)] += seconds - return grouped - - -def _tooltip(metrics: dict[str, float]) -> str: - top = sorted(metrics.items(), key=lambda kv: kv[1], reverse=True) - lines = [ - f"{label}: {format_duration(s)}" - for label, s in top[:_TOOLTIP_METRICS] - if s >= _MIN_S - ] - if len(top) > _TOOLTIP_METRICS: - lines.append(f"+ {len(top) - _TOOLTIP_METRICS} more") - return "\n".join(lines) - - -def _format_timestamp(raw: str) -> str: - if not raw: - return "" - try: - parsed = datetime.fromisoformat(raw) - except ValueError: - return raw - return parsed.astimezone().strftime("%Y-%m-%d %H:%M") diff --git a/legacy/pxmodrim/ui/panels/time_analytics_panel.py b/legacy/pxmodrim/ui/panels/time_analytics_panel.py deleted file mode 100644 index 174023e..0000000 --- a/legacy/pxmodrim/ui/panels/time_analytics_panel.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QDialog, QVBoxLayout, QWidget -from shiboken6 import isValid - -from pxmodrim.core.services.startup_impact_service import StartupImpactService -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.panels.startup_impact_data import build_startup_impact -from pxmodrim.ui.theme.palette import PALETTE - -_TIMING_QML = Path(__file__).parent / "TimeAnalytics.qml" - - -class TimeAnalyticsPanel(QWidget): - def __init__( - self, - sis: StartupImpactService | None = None, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._sis = sis - self._request_token = 0 - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self._qml.setSource(QUrl.fromLocalFile(str(_TIMING_QML))) - layout.addWidget(self._qml) - - async def set_data(self, pid: str | None, active_pids: list[str]) -> None: - self._request_token += 1 - request_token = self._request_token - root_obj = self._qml.rootObject() - if not root_obj: - return - - sis = self._sis - if not sis: - root_obj.setProperty("sourceData", None) - return - - report, base, totals, _ = await sis.snapshot(active_pids) - if request_token != self._request_token or not isValid(root_obj): - return - if not report: - root_obj.setProperty("sourceData", None) - return - - estimated = base + sum(on + off for on, off in totals.values()) - root_obj.setProperty( - "sourceData", - build_startup_impact( - report, - active_pids=active_pids, - selected_pid=pid, - estimated_s=estimated, - ), - ) - - def clear(self) -> None: - self._request_token += 1 - root_obj = self._qml.rootObject() - if root_obj: - root_obj.setProperty("sourceData", None) - - -class StartupImpactDialog(QDialog): - """Non-modal window showing the full startup impact breakdown.""" - - def __init__( - self, - sis: StartupImpactService | None, - qml_engine: QQmlEngine | None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Startup impact") - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.resize(900, 720) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.panel = TimeAnalyticsPanel(sis, qml_engine, self) - layout.addWidget(self.panel) - install_dialog_chrome(self) diff --git a/legacy/pxmodrim/ui/panels/update_dialog.py b/legacy/pxmodrim/ui/panels/update_dialog.py deleted file mode 100644 index a7ff88e..0000000 --- a/legacy/pxmodrim/ui/panels/update_dialog.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import QUrl -from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.button import AppButton - -if TYPE_CHECKING: - from pxmodrim.core.services.update_service import ReleaseInfo - -SKIP_RESULT = 2 - -_AUTO_UPDATE_NOTE = ( - "Automatic updating may be added later. For now, downloading the release " - "yourself is the safest way to avoid update bugs." -) - -_WEB_SCHEMES = frozenset({"http", "https"}) - - -def _open_web_link(url: QUrl) -> None: - if url.scheme().lower() in _WEB_SCHEMES: - QDesktopServices.openUrl(url) - - -class UpdateDialog(QDialog): - """Offers a newer release. Accepted = open release page, SKIP_RESULT = skip it.""" - - def __init__( - self, - release: ReleaseInfo, - current_version: str, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Update Available") - self.resize(560, 460) - layout = QVBoxLayout(self) - - title = QLabel(f"PxModRim {release.tag} is available", self) - title.setStyleSheet("font-size: 16px; font-weight: 600;") - layout.addWidget(title) - layout.addWidget(QLabel(f"You are running {current_version}.", self)) - - notes = QTextBrowser(self) - notes.setOpenLinks(False) - notes.setOpenExternalLinks(False) - notes.anchorClicked.connect(_open_web_link) - notes.setMarkdown(release.notes or "No release notes.") - layout.addWidget(notes, 1) - - note = QLabel(_AUTO_UPDATE_NOTE, self) - note.setWordWrap(True) - layout.addWidget(note) - - buttons = QHBoxLayout() - skip_btn = AppButton("Skip This Version", self) - skip_btn.clicked.connect(lambda: self.done(SKIP_RESULT)) - later_btn = AppButton("Later", self) - later_btn.clicked.connect(self.reject) - download_btn = AppButton("Download", self) - download_btn.setObjectName("primaryAction") - download_btn.setDefault(True) - download_btn.clicked.connect(self.accept) - buttons.addWidget(skip_btn) - buttons.addStretch() - buttons.addWidget(later_btn) - buttons.addWidget(download_btn) - layout.addLayout(buttons) diff --git a/legacy/pxmodrim/ui/panels/upload_report_dialog.py b/legacy/pxmodrim/ui/panels/upload_report_dialog.py deleted file mode 100644 index 02d2c27..0000000 --- a/legacy/pxmodrim/ui/panels/upload_report_dialog.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import annotations - -import asyncio -from enum import IntEnum -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, -) -from pxmodrim.ui.components.button import AppButton -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.components.toast import ToastManager - -_DEFAULT_ENDPOINT = "https://paste.rs/" - - -class UploadChoice(IntEnum): - CANCEL = int(QDialog.DialogCode.Rejected) - UPLOAD = int(QDialog.DialogCode.Accepted) - SAVE_TO_FILE = 2 - - -class UploadConfirmDialog(QDialog): - """Confirmation dialog explaining what will be uploaded and anonymized. - - Finishes with an UploadChoice value. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("uploadConfirmDialog") - self.setWindowTitle("Upload Log & System Info") - self.setModal(True) - self.resize(520, 320) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(16) - - description_text = ( - "This action generates and uploads an anonymized diagnostic report " - "to help diagnose issues.\n\n" - "The report includes:\n" - " • Recent application log contents (last ~2 MB)\n" - " • System details (OS, architecture, Python, Qt, PySide6)\n" - " • RimWorld version and configured directory paths\n" - " • Mod collection count and active mod list in load order\n\n" - "Privacy & security:\n" - " • Your home directory path is replaced with '~'\n" - " • Your username in other user-directory paths is replaced " - "with ''\n\n" - "Notice: The uploaded report will be publicly viewable " - "by anyone with the link." - ) - desc_label = QLabel(description_text, self) - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - layout.addStretch() - - button_row = QHBoxLayout() - button_row.setSpacing(10) - - self._save_file_btn = AppButton("Save to file instead…", self) - self._save_file_btn.clicked.connect( - lambda: self.done(UploadChoice.SAVE_TO_FILE) - ) - button_row.addWidget(self._save_file_btn) - - button_row.addStretch() - - cancel_btn = AppButton("Cancel", self) - cancel_btn.clicked.connect(self.reject) - button_row.addWidget(cancel_btn) - - self._upload_btn = AppButton("Upload", self) - self._upload_btn.setObjectName("primaryAction") - self._upload_btn.clicked.connect(self.accept) - button_row.addWidget(self._upload_btn) - - layout.addLayout(button_row) - - -class UploadFailedDialog(QDialog): - """Error dialog shown on upload failure; Accepted means save to a file.""" - - def __init__(self, error_message: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("uploadFailedDialog") - self.setWindowTitle("Upload Failed") - self.setModal(True) - self.resize(460, 220) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(16) - - title_label = QLabel("Couldn't upload the report", self) - title_label.setObjectName("dialogTitle") - layout.addWidget(title_label) - - msg_label = QLabel( - f"An error occurred while uploading:\n\n{error_message}\n\n" - "Would you like to save the report to a file instead?", - self, - ) - msg_label.setWordWrap(True) - layout.addWidget(msg_label) - - layout.addStretch() - - button_row = QHBoxLayout() - button_row.addStretch() - - close_btn = AppButton("Close", self) - close_btn.clicked.connect(self.reject) - button_row.addWidget(close_btn) - - save_btn = AppButton("Save to file…", self) - save_btn.setObjectName("primaryAction") - save_btn.clicked.connect(self.accept) - button_row.addWidget(save_btn) - - layout.addLayout(button_row) - - -class _SaveReportFileDialog(QFileDialog): - def __init__(self, parent: QWidget) -> None: - super().__init__( - parent, - "Save Log & System Info", - "pxmodrim-report.txt", - "Text Files (*.txt);;All Files (*)", - ) - self.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) - - -async def _save_report_via_file_dialog( - parent: QWidget, report: str, toast: ToastManager -) -> None: - """Prompt user for a destination file and write the report.""" - result, dialog = await await_dialog(_SaveReportFileDialog, parent) - files = dialog.selectedFiles() - if result != QDialog.DialogCode.Accepted or not files: - return - - path = Path(files[0]) - try: - await asyncio.to_thread(path.write_text, report, "utf-8") - except OSError as exc: - toast.error(f"Failed to save file: {exc}", 5000) - return - toast.success(f"Report saved to {path.name}", 3000) - - -async def handle_upload_report( - parent: QWidget, ctx: CoreContext | Any, toast: ToastManager -) -> str | None: - """Confirm, then upload or save the report; return the uploaded URL if any.""" - choice, _ = await await_dialog(UploadConfirmDialog, parent) - if choice == UploadChoice.CANCEL: - return None - - report = await asyncio.to_thread(build_report, ctx) - - if choice == UploadChoice.SAVE_TO_FILE: - await _save_report_via_file_dialog(parent, report, toast) - return None - - toast.info("Uploading log & system info…", 2000) - cfg = getattr(ctx, "config", None) - endpoint = getattr(cfg, "log_upload_endpoint", "") or _DEFAULT_ENDPOINT - try: - url = await PasteUploader(endpoint=endpoint).async_upload(report) - except (ReportUploadError, OSError, RuntimeError) as exc: - failed_result, _ = await await_dialog(UploadFailedDialog, str(exc), parent) - if failed_result == QDialog.DialogCode.Accepted: - await _save_report_via_file_dialog(parent, report, toast) - return None - - clipboard = QGuiApplication.clipboard() - if clipboard is not None: - clipboard.setText(url) - toast.success(f"Link copied: {url}", 5000) - return url diff --git a/legacy/pxmodrim/ui/plugins/__init__.py b/legacy/pxmodrim/ui/plugins/__init__.py deleted file mode 100644 index ab1c8e0..0000000 --- a/legacy/pxmodrim/ui/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""UI-layer plugins for PxModRim.""" - -from __future__ import annotations diff --git a/legacy/pxmodrim/ui/plugins/downloads/Downloads.qml b/legacy/pxmodrim/ui/plugins/downloads/Downloads.qml deleted file mode 100644 index 635aacf..0000000 --- a/legacy/pxmodrim/ui/plugins/downloads/Downloads.qml +++ /dev/null @@ -1,278 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: root - color: Theme.elevate0 - - readonly property var stateLabels: ({ - queued: "Queued", - checking: "Checking", - downloading: "Downloading", - updated: "Updated", - unchanged: "Up to date", - failed: "Failed", - cancelled: "Cancelled" - }) - - function stateColor(state) { - if (state === "updated") return Theme.success - if (state === "failed") return Theme.danger - if (state === "downloading" || state === "checking") return Theme.primary - return Theme.textDim - } - - function summary() { - var m = downloadsModel - if (m.total === 0) - return "No downloads yet" - var parts = [m.updated + " updated", m.unchanged + " up to date"] - if (m.failed > 0) - parts.push(m.failed + " failed") - return (m.busy ? "Working: " : "Last run: ") + m.finished + " / " + m.total - + " · " + parts.join(" · ") - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 12 - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - ColumnLayout { - spacing: 2 - - Text { - text: "Downloads" - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.weight: Font.Bold - } - Text { - text: root.summary() - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - Item { Layout.fillWidth: true } - - PxButton { - visible: downloadsModel.busy - variant: "danger" - iconName: "close" - text: "Stop" - onClicked: downloadsPanel.stop() - } - PxButton { - visible: !downloadsModel.busy && downloadsModel.failed > 0 - text: "Retry failed (" + downloadsModel.failed + ")" - iconName: "refresh" - onClicked: downloadsPanel.retryFailed() - } - PxButton { - visible: !downloadsModel.busy && downloadsModel.total > 0 - variant: "ghost" - text: "Clear" - onClicked: downloadsModel.clear() - } - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 96 - visible: downloadsModel.total > 0 - radius: Theme.radiusMd - color: Theme.elevate2 - - function formatBytes(n) { - if (n >= 1073741824) return (n / 1073741824).toFixed(2) + " GB" - if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB" - return Math.round(n / 1024) + " KB" - } - - Canvas { - id: graph - anchors.fill: parent - anchors.margins: 1 - onPaint: { - var ctx = getContext("2d") - ctx.reset() - var h = downloadsModel.speedHistory - if (h.length < 2) return - var peak = Math.max.apply(null, h.concat([1])) - var stepX = width / (90 - 1) - var x0 = width - (h.length - 1) * stepX - ctx.beginPath() - ctx.moveTo(x0, height) - for (var i = 0; i < h.length; i++) - ctx.lineTo(x0 + i * stepX, height - (h[i] / peak) * (height - 28) - 2) - ctx.lineTo(width, height) - ctx.closePath() - ctx.fillStyle = Qt.alpha(Theme.primary, 0.25) - ctx.fill() - ctx.beginPath() - for (var j = 0; j < h.length; j++) { - var y = height - (h[j] / peak) * (height - 28) - 2 - if (j === 0) ctx.moveTo(x0, y) - else ctx.lineTo(x0 + j * stepX, y) - } - ctx.strokeStyle = Theme.primary - ctx.lineWidth = 2 - ctx.stroke() - } - Connections { - target: downloadsModel - function onSpeed_changed() { graph.requestPaint() } - } - } - - Text { - anchors.left: parent.left - anchors.top: parent.top - anchors.margins: 10 - text: parent.formatBytes(downloadsModel.speed) + "/s" - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.weight: Font.Bold - } - Text { - anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.margins: 10 - visible: text.length > 0 - text: downloadsModel.phase === "login" ? "Logging in to Steam\u2026" - : downloadsModel.phase === "query" - ? "Querying mod info from Steam\u2026 " + downloadsModel.resolved + " / " + downloadsModel.total - : "" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - Text { - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 10 - text: "Downloaded " + parent.formatBytes(downloadsModel.bytesDone) - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - PxProgressBar { - Layout.fillWidth: true - visible: downloadsModel.total > 0 - thickness: 6 - from: 0 - to: Math.max(downloadsModel.total, 1) - value: downloadsModel.finished - } - - RowLayout { - Layout.fillWidth: true - visible: downloadsModel.total > 0 - spacing: 6 - - Repeater { - model: [ - { key: "all", label: "All", count: downloadsModel.total }, - { key: "active", label: "Active", count: downloadsModel.active }, - { key: "updated", label: "Updated", count: downloadsModel.updated }, - { key: "failed", label: "Failed", count: downloadsModel.failed } - ] - PxButton { - required property var modelData - variant: downloadsModel.filter === modelData.key ? "primary" : "ghost" - text: modelData.label + " " + modelData.count - implicitHeight: 28 - onClicked: downloadsModel.setFilter(modelData.key) - } - } - Item { Layout.fillWidth: true } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - - EmptyState { - anchors.fill: parent - visible: listView.count === 0 - title: downloadsModel.total === 0 ? "No downloads yet" : "Nothing in this filter" - detail: downloadsModel.total === 0 - ? "Updates from the mod list, organizer, header, auto-update and download-source tabs show up here." - : "" - } - - ListView { - id: listView - objectName: "downloadsList" - anchors.fill: parent - spacing: 2 - model: downloadsModel - visible: count > 0 - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - - delegate: Rectangle { - width: listView.width - height: 36 - radius: Theme.radiusMd - color: model.state === "downloading" ? Theme.elevate3 : "transparent" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 12 - spacing: 10 - - Text { - Layout.fillWidth: true - text: model.title - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - Text { - visible: model.state === "failed" && model.error.length > 0 - Layout.maximumWidth: 320 - text: model.error - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - elide: Text.ElideRight - } - Text { - Layout.preferredWidth: 90 - horizontalAlignment: Text.AlignRight - text: root.stateLabels[model.state] || model.state - color: root.stateColor(model.state) - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - Rectangle { - anchors.left: parent.left - anchors.bottom: parent.bottom - width: parent.width * model.progress - height: 2 - radius: Theme.radiusXs - color: Theme.primary - visible: model.state === "downloading" - } - } - } - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/downloads/__init__.py b/legacy/pxmodrim/ui/plugins/downloads/__init__.py deleted file mode 100644 index 808d2e2..0000000 --- a/legacy/pxmodrim/ui/plugins/downloads/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.downloads.plugin import DownloadsUiPlugin - -__all__ = ["DownloadsUiPlugin"] diff --git a/legacy/pxmodrim/ui/plugins/downloads/model.py b/legacy/pxmodrim/ui/plugins/downloads/model.py deleted file mode 100644 index 6483733..0000000 --- a/legacy/pxmodrim/ui/plugins/downloads/model.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QPersistentModelIndex, - Qt, - QTimer, - Signal, - Slot, -) - -if TYPE_CHECKING: - from pxmodrim.core.downloads import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadResult, - ) - -_ACTIVE = frozenset({"queued", "checking", "downloading"}) -_FLUSH_MS = 150 -_SPEED_TICK_MS = 1000 -HISTORY_LEN = 90 - - -@dataclass(slots=True) -class _Row: - pid: str - title: str - state: str = "queued" - progress: float = 0.0 - error: str = "" - bytes_done: int = 0 - - -def _final_state(bytes_total: int) -> str: - # pxsteamdl reports 0/0 for an item that is already current. - return "updated" if bytes_total > 0 else "unchanged" - - -class DownloadsModel(QAbstractListModel): - """Per-mod state of the current or last download batch.""" - - summary_changed = Signal() - speed_changed = Signal() - phase_changed = Signal() - - _IdRole = Qt.ItemDataRole.UserRole + 1 - _TitleRole = Qt.ItemDataRole.UserRole + 2 - _StateRole = Qt.ItemDataRole.UserRole + 3 - _ProgressRole = Qt.ItemDataRole.UserRole + 4 - _ErrorRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: Any = None) -> None: - super().__init__(parent) - self._rows: list[_Row] = [] - self._by_id: dict[str, _Row] = {} - self._visible: list[_Row] = [] - self._filter = "all" - self._busy = False - self._counts: dict[str, int] = {} - self._dirty: set[str] = set() - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.setInterval(_FLUSH_MS) - self._timer.timeout.connect(self._flush) - self._phase = "" - self._bytes = 0 - self._sampled_bytes = 0 - self._speed = 0.0 - self._history: list[float] = [] - self._speed_timer = QTimer(self) - self._speed_timer.setInterval(_SPEED_TICK_MS) - self._speed_timer.timeout.connect(self._sample_speed) - - # ── batch lifecycle ─────────────────────────────── - - def begin(self, ids: list[str], titles: dict[str, str]) -> None: - self._timer.stop() - self._dirty.clear() - self._reset_speed() - self.set_phase("") - self.beginResetModel() - self._rows = [_Row(pid, titles.get(pid, pid)) for pid in ids] - self._by_id = {row.pid: row for row in self._rows} - self._recount() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - def apply(self, status: DownloadItemStatus) -> None: - row = self._by_id.get(status.mod_id) - if row is None: - return - done = status.bytes_done if status.status != "error" else row.bytes_done - self._bytes += done - row.bytes_done - row.bytes_done = done - if status.status == "error": - row.state, row.error, row.progress = "failed", status.error, 0.0 - elif status.status == "success": - row.state, row.progress = _final_state(status.bytes_total), 1.0 - elif status.bytes_total > 0 and status.bytes_done < status.bytes_total: - row.state = "downloading" - row.progress = status.bytes_done / status.bytes_total - else: - row.state, row.progress = _final_state(status.bytes_total), 1.0 - self._mark(row) - - def set_title(self, item: DownloadItemTitle) -> None: - row = self._by_id.get(item.mod_id) - if row is None: - return - if row.title == row.pid: - row.title = item.title - if row.state == "queued": - row.state = "checking" - self._mark(row) - - def set_phase(self, phase: str) -> None: - if phase != self._phase: - self._phase = phase - self.phase_changed.emit() - - def finish(self, result: DownloadResult) -> None: - for row in self._rows: - if row.state in _ACTIVE: - row.state, row.progress = "cancelled", 0.0 - self._dirty.add(row.pid) - self._timer.stop() - self._flush() - - def set_busy(self, busy: bool) -> None: - if self._busy != busy: - self._busy = busy - if busy: - self._sampled_bytes = self._bytes - self._speed_timer.start() - else: - self._speed_timer.stop() - self._speed = 0.0 - self.speed_changed.emit() - self.summary_changed.emit() - - def failed_ids(self) -> list[str]: - return [row.pid for row in self._rows if row.state in {"failed", "cancelled"}] - - @Slot() - def clear(self) -> None: - if not self._busy: - self.begin([], {}) - - @Slot(str) - def setFilter(self, name: str) -> None: - if name != self._filter: - self._filter = name - self.beginResetModel() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - # ── internals ───────────────────────────────────── - - def _reset_speed(self) -> None: - self._bytes = self._sampled_bytes = 0 - self._speed = 0.0 - self._history = [] - self.speed_changed.emit() - - def _sample_speed(self) -> None: - delta = self._bytes - self._sampled_bytes - self._sampled_bytes = self._bytes - self._speed = delta * 1000 / _SPEED_TICK_MS - self._history = [*self._history[-(HISTORY_LEN - 1) :], self._speed] - self.speed_changed.emit() - - def _mark(self, row: _Row) -> None: - self._dirty.add(row.pid) - if not self._timer.isActive(): - self._timer.start() - - def _matches(self, row: _Row) -> bool: - return { - "all": True, - "active": row.state in _ACTIVE, - "updated": row.state == "updated", - "failed": row.state in {"failed", "cancelled"}, - }.get(self._filter, True) - - def _select(self) -> list[_Row]: - return [row for row in self._rows if self._matches(row)] - - def _recount(self) -> None: - counts: dict[str, int] = {} - for row in self._rows: - counts[row.state] = counts.get(row.state, 0) + 1 - self._counts = counts - - def _flush(self) -> None: - if not self._dirty: - return - dirty, self._dirty = self._dirty, set() - self._recount() - if self._filter == "all": - for pos, row in enumerate(self._visible): - if row.pid in dirty: - idx = self.index(pos) - self.dataChanged.emit(idx, idx) - else: - self.beginResetModel() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - # ── QAbstractListModel ──────────────────────────── - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return len(self._visible) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._visible): - return None - row = self._visible[index.row()] - if role == self._IdRole: - return row.pid - if role == self._TitleRole: - return row.title - if role == self._StateRole: - return row.state - if role == self._ProgressRole: - return row.progress - if role == self._ErrorRole: - return row.error - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self._IdRole: QByteArray(b"id"), - self._TitleRole: QByteArray(b"title"), - self._StateRole: QByteArray(b"state"), - self._ProgressRole: QByteArray(b"progress"), - self._ErrorRole: QByteArray(b"error"), - } - - # ── QML summary ─────────────────────────────────── - - def _count(self, *states: str) -> int: - return sum(self._counts.get(s, 0) for s in states) - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def total(self) -> int: - return len(self._rows) - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def finished(self) -> int: - return self._count("updated", "unchanged", "failed", "cancelled") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def updated(self) -> int: - return self._count("updated") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def unchanged(self) -> int: - return self._count("unchanged") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def failed(self) -> int: - return self._count("failed", "cancelled") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def active(self) -> int: - return self._count("queued", "checking", "downloading") - - @Property(str, notify=phase_changed) # type: ignore[arg-type] - def phase(self) -> str: - return self._phase - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def resolved(self) -> int: - return len(self._rows) - self._count("queued") - - @Property(float, notify=speed_changed) # type: ignore[arg-type] - def speed(self) -> float: - return self._speed - - @Property(float, notify=speed_changed) # type: ignore[arg-type] - def bytesDone(self) -> float: - return float(self._bytes) - - @Property(list, notify=speed_changed) # type: ignore[arg-type] - def speedHistory(self) -> list[float]: - return self._history - - @Property(bool, notify=summary_changed) # type: ignore[arg-type] - def busy(self) -> bool: - return self._busy - - @Property(str, notify=summary_changed) # type: ignore[arg-type] - def filter(self) -> str: - return self._filter diff --git a/legacy/pxmodrim/ui/plugins/downloads/plugin.py b/legacy/pxmodrim/ui/plugins/downloads/plugin.py deleted file mode 100644 index 3b8aece..0000000 --- a/legacy/pxmodrim/ui/plugins/downloads/plugin.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.plugins.downloads.view import DownloadsViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class DownloadsUiPlugin(Plugin): - name = "downloads_ui" - dependencies: ClassVar[list[str]] = ["downloads"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(DownloadsViewPanel) diff --git a/legacy/pxmodrim/ui/plugins/downloads/view.py b/legacy/pxmodrim/ui/plugins/downloads/view.py deleted file mode 100644 index 456d2c8..0000000 --- a/legacy/pxmodrim/ui/plugins/downloads/view.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from PySide6.QtCore import QUrl, Slot -from PySide6.QtGui import QColor -from PySide6.QtQuickWidgets import QQuickWidget -from qasync import asyncSlot - -from pxmodrim.core.downloads import download_manager -from pxmodrim.ui.plugins.downloads.model import DownloadsModel -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - from PySide6.QtWidgets import QWidget - - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadResult - from pxmodrim.ui.context import AppContext - -_QML = Path(__file__).parent / "Downloads.qml" - - -class DownloadsViewPanel(BaseViewPanel): - view_id = "downloads" - lists_downloads = True - icon_name = "download" - label = "Downloads" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._downloads = download_manager(ctx) - self.model = DownloadsModel(self) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setObjectName("downloadsView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("downloadsPanel", self) - qml_ctx.setContextProperty("downloadsModel", self.model) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - self._root.addWidget(self._qml, 1) - - self.model.set_busy(self._downloads.is_downloading) - self._downloads.batch_started.connect(self._on_batch_started) - self._downloads.download_item_status_changed.connect(self.model.apply) - self._downloads.download_item_titled.connect(self.model.set_title) - self._downloads.download_phase_changed.connect(self.model.set_phase) - self._downloads.download_finished.connect(self._on_finished) - self._downloads.busy_changed.connect(self.model.set_busy) - - def _on_batch_started(self, ids: list[str]) -> None: - self.model.begin(ids, self._downloads.names_by_id(self._ctx.all_mods.values())) - - def _on_finished(self, result: DownloadResult) -> None: - self.model.finish(result) - - @Slot() - def stop(self) -> None: - self._downloads.cancel() - - @asyncSlot() - async def retryFailed(self) -> None: - ids = self.model.failed_ids() - if not ids: - return - logger.info("[downloads] retrying {} failed mods", len(ids)) - try: - await self._downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] retry not started: {}", exc) diff --git a/legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml b/legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml deleted file mode 100644 index bc01f91..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/ErrorBanner.qml +++ /dev/null @@ -1,35 +0,0 @@ -import QtQuick -import QtQuick.Layouts - -// Inline error strip; hidden while `message` is empty. -Rectangle { - id: root - - property string text: "" - - visible: text !== "" - color: Theme.dangerBg - border.color: Theme.danger - border.width: 1 - radius: Theme.radiusSm - implicitHeight: errorText.implicitHeight + 14 - - Accessible.role: Accessible.AlertMessage - Accessible.name: text - - RowLayout { - anchors.fill: parent - anchors.margins: 6 - - Text { - id: errorText - - Layout.fillWidth: true - text: root.text - color: Theme.danger - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - wrapMode: Text.Wrap - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/organizer/ModTree.qml b/legacy/pxmodrim/ui/plugins/organizer/ModTree.qml deleted file mode 100644 index 5005d34..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/ModTree.qml +++ /dev/null @@ -1,410 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: root - color: Theme.elevate1 - clip: true - - property int anchorRow: -1 - property int focusedRow: -1 - property int dropRow: -1 - property var dropTarget: null - - function openTags() { tagEditor.open() } - - function expandAll() { - tree.expandRecursively() - organizerPanel.persistAllCollapsed(false) - } - - function collapseAll() { - tree.collapseRecursively() - root.anchorRow = -1 - root.focusedRow = -1 - organizerPanel.persistAllCollapsed(true) - } - - function restoreExpansion() { - var indexes = organizerPanel.expandedIndexes() - for (var i = 0; i < indexes.length; ++i) { - var row = tree.rowAtIndex(indexes[i]) - if (row >= 0 && !tree.isExpanded(row)) - tree.expand(row) - } - } - - function clickRow(index, row, modifiers) { - var range = [] - if ((modifiers & Qt.ShiftModifier) && root.anchorRow >= 0) { - var from = Math.min(root.anchorRow, row) - var to = Math.max(root.anchorRow, row) - for (var r = from; r <= to; ++r) - range.push(tree.index(r, 0)) - } else { - root.anchorRow = row - } - root.focusedRow = row - organizerPanel.selectRow(index, modifiers, range) - tree.forceActiveFocus() - } - - function toggleRow(index, row) { - if (tree.isExpanded(row)) { - tree.collapse(row) - organizerPanel.persistCollapsed(index, true) - } else { - tree.expand(row) - organizerPanel.persistCollapsed(index, false) - root.restoreExpansion() - } - } - - function navigateRow(row, modifiers) { - if (row < 0 || row >= tree.rows) - return - clickRow(tree.index(row, 0), row, modifiers) - tree.positionViewAtRow(row, TableView.Contain) - } - - function activeRow() { - if (root.focusedRow >= 0 && root.focusedRow < tree.rows) - return root.focusedRow - return -1 - } - - property int modelRevision: 0 - readonly property bool noModsInstalled: { - modelRevision - if (tree.rows !== 1) - return false - var idx = tree.index(0, 0) - return modTreeModel.data(idx, Qt.UserRole + 1) === "ungrouped" - && modTreeModel.data(idx, Qt.UserRole + 7) === 0 - } - - function selectAll() { - if (tree.rows === 0) - return - var all = [] - for (var r = 0; r < tree.rows; ++r) - all.push(tree.index(r, 0)) - organizerPanel.selectRow(all[0], Qt.NoModifier, []) - organizerPanel.selectRow(all[all.length - 1], Qt.ShiftModifier, all) - root.anchorRow = 0 - root.focusedRow = tree.rows - 1 - } - - Connections { - target: modTreeModel - function onDataChanged() { root.modelRevision++ } - function onRowsInserted() { root.modelRevision++ } - function onRowsRemoved() { root.modelRevision++ } - function onModelReset() { - root.modelRevision++ - root.anchorRow = -1 - Qt.callLater(root.restoreExpansion) - root.focusedRow = -1 - } - } - - TreeView { - id: tree - objectName: "organizerTreeView" - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - clip: true - model: modTreeModel - columnWidthProvider: function() { return tree.width } - onWidthChanged: forceLayout() - boundsBehavior: Flickable.StopAtBounds - reuseItems: false - focus: true - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } - Keys.onPressed: function(event) { - var row = root.activeRow() - if (event.key === Qt.Key_Down || event.key === Qt.Key_Up) { - root.navigateRow(row < 0 ? 0 : row + (event.key === Qt.Key_Down ? 1 : -1), event.modifiers) - event.accepted = true - } else if (event.key === Qt.Key_Right && row >= 0) { - if (!tree.isExpanded(row)) - root.toggleRow(tree.index(row, 0), row) - event.accepted = true - } else if (event.key === Qt.Key_Left && row >= 0) { - if (tree.isExpanded(row)) { - root.toggleRow(tree.index(row, 0), row) - } else { - var depth = tree.depth(row) - for (var parentRow = row - 1; parentRow >= 0; --parentRow) { - if (tree.depth(parentRow) < depth) { - root.navigateRow(parentRow, Qt.NoModifier) - break - } - } - } - event.accepted = true - } else if (event.key === Qt.Key_Space && row >= 0) { - organizerPanel.toggleCheck(tree.index(row, 0)) - event.accepted = true - } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && row >= 0) { - var enterIndex = tree.index(row, 0) - if (tree.hasChildren(row)) - root.toggleRow(enterIndex, row) - else - organizerPanel.toggleCheck(enterIndex) - event.accepted = true - } else if (event.key === Qt.Key_A && (event.modifiers & Qt.ControlModifier)) { - root.selectAll() - event.accepted = true - } - } - - delegate: Rectangle { - id: item - required property int row - required property int column - required property int depth - required property bool expanded - required property bool hasChildren - required property var model - - implicitWidth: tree.width - implicitHeight: item.model.kind === "mod" ? 30 : 32 - radius: Theme.radiusSm - color: root.dropRow === item.row - ? Theme.primaryBg - : (item.model.selected ? Theme.primaryBg - : (rowMouse.containsMouse ? Theme.elevate3 : "transparent")) - border.width: root.dropRow === item.row ? 1 : 0 - border.color: Theme.primary - Accessible.role: Accessible.TreeItem - Accessible.name: item.model.name || "" - Accessible.selected: !!item.model.selected - - Repeater { - model: item.depth - delegate: Rectangle { - required property int index - x: 12 + index * 20 - width: 1 - height: item.height - color: Theme.border - } - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 4 + item.depth * 20 - anchors.rightMargin: 10 - spacing: 7 - Item { - Layout.preferredWidth: 18 - Layout.preferredHeight: 28 - Image { - anchors.centerIn: parent - visible: item.hasChildren - source: "image://icons/chevron?color=" + encodeURIComponent(Theme.textDim) - sourceSize.width: 13 - sourceSize.height: 13 - rotation: item.expanded ? 90 : 0 - } - MouseArea { - anchors.fill: parent - enabled: item.hasChildren - cursorShape: Qt.PointingHandCursor - onClicked: root.toggleRow(tree.index(item.row, 0), item.row) - } - } - - Item { - Layout.preferredWidth: 20 - Layout.preferredHeight: 28 - PxCheckIndicator { - anchors.centerIn: parent - checkState: item.model.checkState - hovered: checkMouse.containsMouse - } - MouseArea { - id: checkMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - Accessible.role: Accessible.CheckBox - Accessible.name: (item.model.checkState === Qt.Checked ? "Disable " : "Enable ") + item.model.name - Accessible.checked: item.model.checkState === Qt.Checked - onClicked: organizerPanel.toggleCheck(tree.index(item.row, 0)) - Accessible.onPressAction: organizerPanel.toggleCheck(tree.index(item.row, 0)) - } - } - - Image { - Layout.preferredWidth: 16 - Layout.preferredHeight: 16 - source: "image://icons/" - + (item.model.kind === "mod" ? (item.model.providerIcon || "folder") : "folder") - + "?color=" + encodeURIComponent( - item.model.kind === "mod" ? (item.model.providerColor || Theme.textDim) - : item.model.kind === "ungrouped" ? Theme.textDim : Theme.primary) - sourceSize.width: 16 - sourceSize.height: 16 - fillMode: Image.PreserveAspectFit - } - - Text { - text: item.model.name || "" - Layout.fillWidth: true - color: item.model.kind === "ungrouped" || (item.model.kind === "mod" && item.model.checkState !== Qt.Checked) - ? Theme.textMuted : Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.bold: item.model.kind !== "mod" - font.italic: item.model.kind === "ungrouped" - elide: Text.ElideRight - } - - PxBadge { - visible: !!item.model.hasRule - text: "Auto" - textColor: Theme.warning - fillColor: Theme.warningBg - } - - PxBadge { - visible: item.model.kind === "mod" && !!item.model.providerLabel - outlined: true - text: item.model.providerLabel || "" - accent: item.model.providerColor || Theme.border - textColor: item.model.providerColor || Theme.textMuted - } - - PxBadge { - visible: item.model.kind === "mod" && !!item.model.modVersion - text: item.model.modVersion || "" - } - - Repeater { - model: item.model.kind === "mod" ? (item.model.tags || []) : [] - delegate: PxBadge { - required property var modelData - property color tagColor: modelData.color - text: modelData.name - textColor: tagColor - fillColor: Qt.rgba(tagColor.r, tagColor.g, tagColor.b, 0.14) - } - } - - DiagnosticBadge { - visible: !!item.model.hasError - level: "error" - tooltip: item.model.errorTooltip || "" - } - - DiagnosticBadge { - visible: !!item.model.hasWarning - level: "warning" - tooltip: item.model.warningTooltip || "" - } - - PxBadge { - visible: item.model.kind !== "mod" - textColor: Theme.textMuted - fillColor: Theme.elevate3 - text: item.model.filtering - ? item.model.visibleCount + " of " + item.model.totalCount - : item.model.enabledCount + "/" + item.model.totalCount - } - } - - MouseArea { - id: rowMouse - anchors.fill: parent - z: -1 - acceptedButtons: Qt.LeftButton | Qt.RightButton - hoverEnabled: true - preventStealing: true - property real pressX: 0 - property real pressY: 0 - property bool dragging: false - property bool pendingSingle: false - onPressed: function(mouse) { - pressX = mouse.x - pressY = mouse.y - dragging = false - pendingSingle = false - if (mouse.button === Qt.RightButton) { - organizerPanel.contextMenu(tree.index(item.row, 0)) - return - } - if (mouse.modifiers === Qt.NoModifier && item.model.selected) - pendingSingle = true - else - root.clickRow(tree.index(item.row, 0), item.row, mouse.modifiers) - } - onPositionChanged: function(mouse) { - if (!(pressedButtons & Qt.LeftButton)) - return - if (!dragging && Math.abs(mouse.x - pressX) + Math.abs(mouse.y - pressY) < Qt.styleHints.startDragDistance) - return - dragging = true - var pos = rowMouse.mapToItem(tree.contentItem, mouse.x, mouse.y) - var cell = tree.cellAtPosition(Qt.point(pos.x, pos.y)) - var target = cell.y >= 0 ? tree.index(cell.y, 0) : null - root.dropTarget = target !== null && organizerPanel.canDrop(tree.index(item.row, 0), target) ? target : null - root.dropRow = root.dropTarget !== null ? cell.y : -1 - } - onReleased: function(mouse) { - if (dragging && root.dropTarget !== null) - organizerPanel.dropOn(tree.index(item.row, 0), root.dropTarget) - else if (!dragging && pendingSingle) - root.clickRow(tree.index(item.row, 0), item.row, Qt.NoModifier) - dragging = false - pendingSingle = false - root.dropTarget = null - root.dropRow = -1 - } - onCanceled: { - dragging = false - pendingSingle = false - root.dropTarget = null - root.dropRow = -1 - } - onDoubleClicked: { - if (item.model.kind !== "mod") - root.toggleRow(tree.index(item.row, 0), item.row) - } - } - } - } - - TagEditor { - id: tagEditor - tagRows: organizerPanel ? organizerPanel.tagRows : [] - selectedCount: organizerPanel ? organizerPanel.selectedTagCount : 0 - errorMessage: organizerPanel ? organizerPanel.editorError : "" - onCreateRequested: (name, color) => organizerPanel.createTag(name, color) - onUpdateRequested: (id, name, color) => organizerPanel.updateTag(id, name, color) - onDeleteRequested: id => organizerPanel.deleteTag(id) - onAssignmentRequested: (id, add) => organizerPanel.assignTag(id, add) - } - - - Connections { - target: organizerPanel - function onTagCreated() { tagEditor.clearInputs() } - } - - EmptyState { - anchors.centerIn: parent - width: parent.width - 48 - visible: organizerPanel && (!organizerPanel.ready || tree.rows === 0 || root.noModsInstalled) - iconName: organizerPanel && organizerPanel.ready ? "empty" : "" - title: !organizerPanel || !organizerPanel.ready ? "Loading organizer…" - : root.noModsInstalled ? "No mods installed" - : "No mods match this filter" - detail: organizerPanel && organizerPanel.ready && !root.noModsInstalled - ? "Try a different filter or search." : "" - } -} diff --git a/legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml b/legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml deleted file mode 100644 index f18ba7e..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/RuleEditor.qml +++ /dev/null @@ -1,481 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -FocusScope { - id: root - objectName: "organizerRuleEditor" - implicitWidth: 900 - implicitHeight: 640 - property bool opened: false - visible: opened - focus: opened - - property var ruleRows: organizerPanel ? organizerPanel.ruleRows : [] - property var ruleFolders: organizerPanel ? organizerPanel.ruleFolders : [] - property string errorMessage: organizerPanel ? organizerPanel.editorError : "" - - - readonly property var fieldOptions: [ - { text: "Package ID", value: "package_id" }, - { text: "Name", value: "name" }, - { text: "Author", value: "author" } - ] - - readonly property var opOptions: [ - { text: "Prefix", value: "prefix" }, - { text: "Contains", value: "contains" }, - { text: "Equals", value: "equals" } - ] - - readonly property bool hasFolders: ruleFolders.length > 0 - property var preview: ({ counts: [], assignable: 0 }) - property int previewRevision: 0 - property bool previewPending: false - - function schedulePreview() { - if (!opened || loadingDraft || !organizerPanel || !organizerPanel.visible || !organizerPanel.ready) - return - previewRevision++ - previewPending = true - previewTimer.restart() - } - - Timer { - id: previewTimer - interval: 100 - onTriggered: { - if (root.opened && organizerPanel && organizerPanel.visible && organizerPanel.ready) - organizerPanel.requestRulePreview(root.draftRules(), root.previewRevision) - } - } - - property bool loadingDraft: false - property int draftRevision: 0 - property var loadedRows: [] - property bool draftDirty: false - - function refreshDirty() { - draftDirty = !draftMatches(ruleRows) - } - - - function draftMatches(rows) { - if (draftModel.count !== rows.length) - return false - for (var i = 0; i < rows.length; ++i) { - var d = draftModel.get(i) - var r = rows[i] - if (d.field !== r.field || d.op !== r.op || d.pattern !== r.pattern - || d.folder_id !== r.folder_id) - return false - } - return true - } - - function findFieldIndex(val) { - for (var i = 0; i < fieldOptions.length; ++i) { - if (fieldOptions[i].value === val) - return i - } - return 0 - } - - function findOpIndex(val) { - for (var i = 0; i < opOptions.length; ++i) { - if (opOptions[i].value === val) - return i - } - return 0 - } - - function findFolderIndex(fid) { - for (var i = 0; i < ruleFolders.length; ++i) { - if (ruleFolders[i].id === fid) - return i - } - return 0 - } - - ListModel { - id: draftModel - onDataChanged: { if (!root.loadingDraft) root.draftRevision++ } - onRowsInserted: { if (!root.loadingDraft) root.draftRevision++ } - onRowsRemoved: { if (!root.loadingDraft) root.draftRevision++ } - onRowsMoved: { if (!root.loadingDraft) root.draftRevision++ } - onModelReset: { if (!root.loadingDraft) root.draftRevision++ } - } - - function loadDraft() { - loadingDraft = true - draftModel.clear() - for (var i = 0; i < ruleRows.length; ++i) { - var r = ruleRows[i] - draftModel.append({ - field: r.field, - op: r.op, - pattern: r.pattern, - folder_id: r.folder_id - }) - } - loadedRows = ruleRows - loadingDraft = false - draftRevision++ - refreshDirty() - } - - onRuleRowsChanged: { - if (!opened && !draftMatches(ruleRows)) - loadDraft() - else if (opened && draftMatches(loadedRows) && !draftMatches(ruleRows)) - loadDraft() - refreshDirty() - schedulePreview() - } - onDraftRevisionChanged: { - refreshDirty() - schedulePreview() - } - - function addRule() { - if (!hasFolders) - return - draftModel.append({ - field: "package_id", - op: "prefix", - pattern: "", - folder_id: ruleFolders[0].id - }) - } - - function removeRule(index) { - if (index >= 0 && index < draftModel.count) - draftModel.remove(index) - } - function setRulePattern(index, pattern) { - draftModel.setProperty(index, "pattern", pattern) - } - - function moveUp(index) { - if (index > 0 && index < draftModel.count) - draftModel.move(index, index - 1, 1) - } - - function moveDown(index) { - if (index >= 0 && index < draftModel.count - 1) - draftModel.move(index, index + 1, 1) - } - - function draftRules() { - var rules = [] - for (var i = 0; i < draftModel.count; ++i) { - var row = draftModel.get(i) - rules.push({ - field: String(row.field), - op: String(row.op), - pattern: String(row.pattern), - folder_id: parseInt(row.folder_id, 10) - }) - } - return rules - } - - function submitRules() { - organizerPanel.saveRules(draftRules()) - } - - - Connections { - target: organizerPanel - function onRulesSaved() { - organizerPanel.closeRules() - } - function onRulePreviewReady(revision, result) { - if (root.opened && revision === root.previewRevision) { - root.preview = result - root.previewPending = false - } - } - } - - function prepareForOpen() { - if (!draftMatches(ruleRows)) - loadDraft() - opened = true - schedulePreview() - refreshDirty() - } - - function closeEditor() { - opened = false - previewTimer.stop() - previewRevision++ - previewPending = false - } - - Rectangle { - anchors.fill: parent - color: Theme.elevate2 - } - - ColumnLayout { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: footer.top - anchors.margins: 16 - spacing: 10 - - Text { - Layout.fillWidth: true - text: "Evaluated top to bottom: first match wins. Rules choose folders only for mods without a manual placement. Moving a mod manually always takes priority; saving updates rule-based folders immediately." - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - wrapMode: Text.WordWrap - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - PxButton { - objectName: "organizerAddRule" - text: "Add rule" - variant: "secondary" - enabled: root.hasFolders - ToolTip.text: root.hasFolders ? "" : "Create a folder first" - onClicked: root.addRule() - } - - PxButton { - objectName: "organizerAddStandardRules" - text: "Add standard rules" - variant: "secondary" - enabled: !root.draftDirty - ToolTip.text: "Add the built-in set of common folders and rules" - onClicked: organizerPanel.addStandardRules() - } - - Item { Layout.fillWidth: true } - - Text { - text: draftModel.count === 1 ? "1 rule" : draftModel.count + " rules" - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - ErrorBanner { - objectName: "organizerRuleError" - Layout.fillWidth: true - text: root.errorMessage - } - - ScrollView { - id: scrollArea - ScrollBar.vertical: PxScrollBar {} - objectName: "organizerRuleScrollArea" - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - contentWidth: availableWidth - contentHeight: rulesColumn.height - - Column { - id: rulesColumn - width: scrollArea.availableWidth - height: childrenRect.height - spacing: 6 - - Repeater { - objectName: "organizerRuleRepeater" - model: draftModel - - delegate: Rectangle { - id: rowItem - objectName: "organizerRuleRow" - required property int index - required property string field - required property string op - required property string pattern - required property int folder_id - - width: rulesColumn.width - height: implicitHeight - implicitHeight: rowCol.implicitHeight + 12 - color: Theme.elevate1 - border.color: Theme.border - border.width: 1 - radius: Theme.radiusSm - - ColumnLayout { - id: rowCol - anchors.fill: parent - anchors.margins: 6 - spacing: 3 - - RowLayout { - Layout.fillWidth: true - spacing: 5 - - Text { - text: String(rowItem.index + 1) - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - Layout.preferredWidth: 16 - horizontalAlignment: Text.AlignHCenter - } - - PxComboBox { - objectName: "organizerRuleField" - Layout.preferredWidth: 76 - model: root.fieldOptions - textRole: "text" - valueRole: "value" - currentIndex: root.findFieldIndex(rowItem.field) - onActivated: draftModel.setProperty(rowItem.index, "field", currentValue) - } - - PxComboBox { - objectName: "organizerRuleOperator" - Layout.preferredWidth: 72 - model: root.opOptions - textRole: "text" - valueRole: "value" - currentIndex: root.findOpIndex(rowItem.op) - onActivated: draftModel.setProperty(rowItem.index, "op", currentValue) - } - - PxTextField { - objectName: "organizerRulePattern" - Layout.fillWidth: true - Layout.minimumWidth: 60 - text: rowItem.pattern - placeholderText: "Pattern…" - onTextEdited: root.setRulePattern(rowItem.index, text) - } - - } - - RowLayout { - Layout.fillWidth: true - Layout.leftMargin: 24 - spacing: 5 - Text { - text: "→" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - - PxComboBox { - objectName: "organizerRuleFolder" - Layout.preferredWidth: 120 - Layout.minimumWidth: 90 - model: root.ruleFolders - textRole: "name" - valueRole: "id" - currentIndex: root.findFolderIndex(rowItem.folder_id) - onActivated: draftModel.setProperty(rowItem.index, "folder_id", currentValue) - } - - PxButton { - objectName: "organizerRuleUp" - variant: "ghost" - iconName: "chevron-down" - iconRotation: 180 - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - ToolTip.text: "Move rule up" - enabled: rowItem.index > 0 - onClicked: root.moveUp(rowItem.index) - } - - PxButton { - objectName: "organizerRuleDown" - variant: "ghost" - iconName: "chevron-down" - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - ToolTip.text: "Move rule down" - enabled: rowItem.index < draftModel.count - 1 - onClicked: root.moveDown(rowItem.index) - } - - PxButton { - objectName: "organizerRuleRemove" - variant: "danger" - iconName: "trash" - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - ToolTip.text: "Remove rule" - onClicked: root.removeRule(rowItem.index) - } - } - - Text { - objectName: "organizerRuleHits" - Layout.leftMargin: 24 - text: { - if (root.previewPending) - return "Updating matches…" - var hits = root.preview.counts[rowItem.index] || 0 - return hits + (hits === 1 ? " mod matches first here" : " mods match first here") - } - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - } - } - } - } - - Text { - objectName: "organizerRuleAssignable" - Layout.fillWidth: true - text: root.previewPending ? "Updating matches…" : - root.preview.assignable + " currently ungrouped " - + (root.preview.assignable === 1 ? "mod would" : "mods would") - + " be assigned by these rules" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - } - } - - EmptyState { - anchors.centerIn: parent - width: parent.width - 64 - visible: draftModel.count === 0 - iconName: "folder" - title: root.hasFolders ? "No rules configured" : "No folders available" - detail: root.hasFolders ? "Click “Add rule” to create one." : "Create a folder first." - } - - PxDialogFooter { - id: footer - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - - PxButton { - objectName: "organizerCancelRules" - text: "Cancel" - variant: "ghost" - onClicked: organizerPanel.closeRules() - } - - PxButton { - objectName: "organizerSaveRules" - text: "Save" - variant: "primary" - onClicked: root.submitRules() - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml b/legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml deleted file mode 100644 index 9e820d9..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/TagEditor.qml +++ /dev/null @@ -1,208 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -PxDialog { - id: editor - objectName: "organizerTagEditor" - width: Math.min(520, parent ? parent.width - 32 : 520) - height: Math.min(570, parent ? parent.height - 32 : 570) - title: "Manage Tags" - standardButtons: Dialog.Close - - property var tagRows: [] - property int selectedCount: 0 - function clearInputs() { - nameInput.text = "" - colorInput.text = String(Theme.primary) - } - property string errorMessage: "" - signal createRequested(string name, string color) - signal updateRequested(int id, string name, string color) - signal deleteRequested(int id) - signal assignmentRequested(int id, bool add) - - onOpened: clearInputs() - - ColumnLayout { - anchors.fill: parent - spacing: 10 - - Text { - Layout.fillWidth: true - text: editor.selectedCount - ? "Toggle tags for " + editor.selectedCount + " selected " - + (editor.selectedCount === 1 ? "mod" : "mods") + ". Changes apply immediately." - : "Create and edit tags. Select mods to assign tags." - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - wrapMode: Text.WordWrap - } - - ScrollView { - ScrollBar.vertical: PxScrollBar {} - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - ColumnLayout { - width: parent.width - spacing: 6 - Repeater { - objectName: "organizerTagRepeater" - model: editor.tagRows.length - delegate: RowLayout { - id: tagRow - required property int index - readonly property var row: editor.tagRows[index] - Layout.fillWidth: true - property bool editing: false - property string editName: tagRow.row ? tagRow.row.name : "" - property string editColor: tagRow.row ? tagRow.row.color : "" - - PxCheckBox { - objectName: "organizerTagAssignment" - visible: editor.selectedCount > 0 - tristate: true - checkState: tagRow.row.checkState === 2 ? Qt.PartiallyChecked : tagRow.row.checkState === 1 ? Qt.Checked : Qt.Unchecked - nextCheckState: function() { return checkState === Qt.Checked ? Qt.Unchecked : Qt.Checked } - onClicked: editor.assignmentRequested(tagRow.row.id, checkState === Qt.Checked) - PxToolTip { - visible: parent.hovered - text: parent.checkState === Qt.PartiallyChecked ? "Assigned to some selected mods" : "Assign or remove for selected mods" - } - } - Rectangle { - Layout.preferredWidth: 14 - Layout.preferredHeight: 14 - radius: 7 - color: tagRow.row.color - } - PxTextField { - id: tagName - objectName: "organizerTagName" - Layout.fillWidth: true - text: tagRow.editing ? tagRow.editName : (tagRow.row ? tagRow.row.name : "") - onTextEdited: tagRow.editName = text - readOnly: !tagRow.editing - } - PxTextField { - id: tagColor - objectName: "organizerTagColor" - visible: tagRow.editing - Layout.preferredWidth: 90 - monospace: true - text: tagRow.editing ? tagRow.editColor : (tagRow.row ? tagRow.row.color : "") - onTextEdited: tagRow.editColor = text - placeholderText: "#RRGGBB" - } - PxButton { - objectName: "organizerEditTag" - text: tagRow.editing ? "Save" : "Edit" - variant: tagRow.editing ? "primary" : "secondary" - onClicked: { - if (tagRow.editing) { - editor.updateRequested(tagRow.row.id, tagName.text, tagColor.text) - } else { - tagRow.editName = tagRow.row.name - tagRow.editColor = tagRow.row.color - tagRow.editing = true - } - } - } - PxButton { - objectName: "organizerDeleteTag" - text: "Delete" - variant: "danger" - onClicked: { - deleteConfirm.tagId = tagRow.row.id - deleteConfirm.tagName = tagRow.row.name - deleteConfirm.open() - } - } - Connections { - target: organizerPanel - function onTagUpdated(id) { - if (id === tagRow.row.id) - tagRow.editing = false - } - } - } - } - } - } - - Text { - text: "New tag" - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: Font.Medium - } - RowLayout { - Layout.fillWidth: true - PxTextField { - id: nameInput - objectName: "organizerNewTagName" - Layout.fillWidth: true - placeholderText: "Tag name" - onAccepted: addTag.clicked() - } - PxTextField { - id: colorInput - objectName: "organizerNewTagColor" - Layout.preferredWidth: 100 - monospace: true - placeholderText: "#RRGGBB" - } - PxButton { - id: addTag - objectName: "organizerAddTag" - text: "Add" - variant: "secondary" - enabled: nameInput.text.trim().length > 0 - onClicked: editor.createRequested(nameInput.text, colorInput.text) - } - } - ErrorBanner { - objectName: "organizerTagError" - Layout.fillWidth: true - text: editor.errorMessage - } - } - - PxDialog { - id: deleteConfirm - objectName: "organizerDeleteTagConfirm" - width: Math.min(360, parent ? parent.width - 32 : 360) - title: "Delete Tag?" - property int tagId: -1 - property string tagName: "" - - Text { - width: parent.width - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - text: "Delete ‘" + deleteConfirm.tagName + "’ from all mods?" - color: Theme.textMain - wrapMode: Text.WordWrap - } - - footer: PxDialogFooter { - PxButton { - text: "Cancel" - variant: "ghost" - onClicked: deleteConfirm.reject() - } - PxButton { - objectName: "organizerDeleteTagConfirmButton" - text: "Delete" - variant: "dangerSolid" - onClicked: deleteConfirm.accept() - } - } - - onAccepted: editor.deleteRequested(tagId) - } -} diff --git a/legacy/pxmodrim/ui/plugins/organizer/__init__.py b/legacy/pxmodrim/ui/plugins/organizer/__init__.py deleted file mode 100644 index e5b4340..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.organizer.plugin import OrganizerUiPlugin -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel - -__all__ = ["ModTreeModel", "OrganizerUiPlugin", "OrganizerViewPanel"] diff --git a/legacy/pxmodrim/ui/plugins/organizer/dialogs.py b/legacy/pxmodrim/ui/plugins/organizer/dialogs.py deleted file mode 100644 index 6fcf6e0..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/dialogs.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QLineEdit, - QMessageBox, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.organizer import ROOT_ID, FolderNode -from pxmodrim.ui.components.button import AppButton - - -class FolderNameDialog(QDialog): - def __init__( - self, title: str, initial: str, parent: QWidget, verb: str | None = None - ) -> None: - super().__init__(parent) - self.setWindowTitle(title) - self.setMinimumWidth(360) - layout = QVBoxLayout(self) - layout.addWidget(QLabel("Folder name", self)) - self._edit = QLineEdit(initial, self) - layout.addWidget(self._edit) - primary_text = verb or title.split()[0] - self._ok = AppButton(primary_text, self) - self._ok.setObjectName("primaryAction") - self._ok.setDefault(True) - cancel = AppButton("Cancel", self) - self._ok.clicked.connect(self.accept) - cancel.clicked.connect(self.reject) - row = QHBoxLayout() - row.addStretch(1) - row.addWidget(cancel) - row.addWidget(self._ok) - layout.addLayout(row) - self._edit.textChanged.connect(self._validate) - self._edit.returnPressed.connect(self._ok.click) - self._validate(initial) - self._edit.selectAll() - - def _validate(self, text: str) -> None: - self._ok.setEnabled(bool(text.strip())) - - def textValue(self) -> str: - return self._edit.text().strip() - - -class DeleteFolderDialog(QMessageBox): - def __init__(self, name: str, parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle("Delete Folder") - self.setText(f"Delete ‘{name}’ and its subfolders?") - self.setInformativeText( - "Their mods become ungrouped. Load order is not affected." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - self.setEscapeButton(QMessageBox.StandardButton.Cancel) - yes = self.button(QMessageBox.StandardButton.Yes) - if yes: - yes.setText("Delete") - yes.setObjectName("primaryAction") - - -class FolderPickerDialog(QDialog): - def __init__( - self, - tree: FolderNode, - title: str, - allowed: frozenset[int], - parent: QWidget, - verb: str = "Move", - ) -> None: - super().__init__(parent) - self.setWindowTitle(title) - self.resize(380, 450) - layout = QVBoxLayout(self) - layout.addWidget(QLabel("Destination folder")) - self._tree = QTreeWidget(self) - self._tree.setHeaderHidden(True) - self._tree.setRootIsDecorated(True) - layout.addWidget(self._tree) - top = QTreeWidgetItem(self._tree, ["Ungrouped (top level)"]) - top.setData(0, Qt.ItemDataRole.UserRole, ROOT_ID) - if ROOT_ID not in allowed: - top.setDisabled(True) - - def add(parent: QTreeWidgetItem, node: FolderNode) -> None: - item = QTreeWidgetItem(parent, [node.folder.name]) - item.setData(0, Qt.ItemDataRole.UserRole, node.folder.id) - if node.folder.id not in allowed: - item.setDisabled(True) - for child in node.children: - add(item, child) - - for folder in tree.children: - add(top, folder) - self._tree.expandAll() - self._ok = AppButton(verb, self) - self._ok.setObjectName("primaryAction") - self._ok.setDefault(True) - self._ok.setEnabled(False) - cancel = AppButton("Cancel", self) - self._tree.currentItemChanged.connect(self._update_ok) - self._tree.itemDoubleClicked.connect( - lambda _item, _column: self._accept_valid() - ) - self._ok.clicked.connect(self._accept_valid) - cancel.clicked.connect(self.reject) - row = QHBoxLayout() - row.addStretch(1) - row.addWidget(cancel) - row.addWidget(self._ok) - layout.addLayout(row) - - def _update_ok( - self, item: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None - ) -> None: - self._ok.setEnabled(item is not None and not item.isDisabled()) - - def _accept_valid(self) -> None: - if self._ok.isEnabled(): - self.accept() - - @property - def folder_id(self) -> int: - item = self._tree.currentItem() - assert item is not None - return int(item.data(0, Qt.ItemDataRole.UserRole)) diff --git a/legacy/pxmodrim/ui/plugins/organizer/filter_model.py b/legacy/pxmodrim/ui/plugins/organizer/filter_model.py deleted file mode 100644 index f9ef7f9..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/filter_model.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.core.organizer import TreeFilter - -_ICONS = {"all": "grid", "active": "check-circle", "inactive": "ban"} -_PROVIDER_ICONS = { - "steam": "steam", - "downloaded": "steam", - "core": "grid", - "git": "git", -} -_TAG_ICON = "tag" - - -class OrganizerFilterModel(QAbstractListModel): - LabelRole = Qt.ItemDataRole.UserRole + 1 - CountRole = Qt.ItemDataRole.UserRole + 2 - IconRole = Qt.ItemDataRole.UserRole + 3 - SectionRole = Qt.ItemDataRole.UserRole + 4 - KeyRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[TreeFilter] = [] - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return 0 if parent is not None and parent.isValid() else len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or not 0 <= index.row() < len(self._items): - return None - entry = self._items[index.row()] - if role in (self.LabelRole, Qt.ItemDataRole.DisplayRole): - return entry.label - if role == self.CountRole: - return entry.count - if role == self.IconRole: - if entry.tag_id is not None or entry.key.startswith("tag:"): - return _TAG_ICON - return _ICONS.get( - entry.key, _PROVIDER_ICONS.get(entry.provider_id or "", "folder") - ) - if role == self.SectionRole: - if entry.tag_id is not None or entry.key.startswith("tag:"): - return "Tags" - if entry.provider_id is not None or entry.key.startswith("provider:"): - return "Providers" - return "Status" - if role == self.KeyRole: - return entry.key - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"filterLabel"), - self.CountRole: QByteArray(b"count"), - self.IconRole: QByteArray(b"iconName"), - self.SectionRole: QByteArray(b"sectionName"), - self.KeyRole: QByteArray(b"key"), - } - - def update(self, entries: list[TreeFilter]) -> None: - if [item.key for item in self._items] != [item.key for item in entries]: - self.beginResetModel() - self._items = entries - self.endResetModel() - else: - self._items = entries - if entries: - self.dataChanged.emit( - self.index(0), - self.index(len(entries) - 1), - [self.CountRole, self.LabelRole, Qt.ItemDataRole.DisplayRole], - ) - - def key_at(self, row: int) -> str | None: - return self._items[row].key if 0 <= row < len(self._items) else None - - def for_key(self, key: str) -> TreeFilter: - for item in self._items: - if item.key == key: - return item - if self._items: - return self._items[0] - return TreeFilter("all", "All", 0) diff --git a/legacy/pxmodrim/ui/plugins/organizer/plugin.py b/legacy/pxmodrim/ui/plugins/organizer/plugin.py deleted file mode 100644 index 5462440..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/plugin.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class OrganizerUiPlugin(Plugin): - name = "organizer_ui" - dependencies: ClassVar[list[str]] = ["organizer"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(OrganizerViewPanel, position=0) diff --git a/legacy/pxmodrim/ui/plugins/organizer/tree_model.py b/legacy/pxmodrim/ui/plugins/organizer/tree_model.py deleted file mode 100644 index 42109b6..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/tree_model.py +++ /dev/null @@ -1,453 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Literal - -from PySide6.QtCore import ( - QAbstractItemModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.organizer import ROOT_ID, FolderNode, ModLeaf, Tag -from pxmodrim.ui.models.mod_list_model import provider_label -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.models.metadata.structures import ListedMod - from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView - -NodeKind = Literal["root", "folder", "ungrouped", "mod"] -AnyIndex = QModelIndex | QPersistentModelIndex - -UNGROUPED_KEY = "ungrouped" - -PROVIDER_ICONS: dict[str, str] = { - "steam": "steam", - "downloaded": "steam", - "local": "folder", - "core": "grid", - "git": "git", -} - -_CHECK_STATES = { - "on": Qt.CheckState.Checked, - "off": Qt.CheckState.Unchecked, - "partial": Qt.CheckState.PartiallyChecked, -} - - -class TreeNode: - """One row of the organizer tree; the model's internal pointer target.""" - - __slots__ = ( - "children", - "folder", - "key", - "kind", - "leaf", - "mod_version", - "parent", - "provider_id", - "provider_label", - "row", - ) - - def __init__( - self, - kind: NodeKind, - key: str, - parent: TreeNode | None, - folder: FolderNode | None = None, - leaf: ModLeaf | None = None, - provider_id: str = "", - ) -> None: - self.kind: NodeKind = kind - self.key = key - self.parent = parent - self.folder = folder - self.leaf = leaf - self.provider_id = provider_id - self.provider_label = "" - self.mod_version = "" - self.children: list[TreeNode] = [] - self.row = 0 - - def add(self, child: TreeNode) -> None: - child.row = len(self.children) - self.children.append(child) - - def walk(self) -> Iterator[TreeNode]: - for child in self.children: - yield child - yield from child.walk() - - @property - def folder_id(self) -> int: - """Folder this node is (folder/ungrouped) or sits in (mod).""" - if self.kind == "folder" and self.folder is not None: - return self.folder.folder.id - if self.kind == "mod" and self.parent is not None: - return self.parent.folder_id - return ROOT_ID - - @property - def is_container(self) -> bool: - return self.kind in ("folder", "ungrouped") - - -def _build( - root: FolderNode, mods: Mapping[str, ListedMod], filtering: bool -) -> TreeNode: - top = TreeNode("root", "", None) - - def add_mods(parent: TreeNode, leaves: Iterable[ModLeaf]) -> None: - for leaf in leaves: - mod = mods.get(leaf.uuid) - provider = mod.provider_id if mod is not None else "" - node = TreeNode( - "mod", f"m:{leaf.uuid}", parent, leaf=leaf, provider_id=provider - ) - if mod is not None: - node.provider_label = provider_label( - provider, str(getattr(mod, "package_id", "")) - ) - node.mod_version = ( - mod.mod_version if isinstance(mod, AboutXmlMod) else "" - ) - parent.add(node) - - def add_folder(parent: TreeNode, node: FolderNode) -> None: - item = TreeNode("folder", f"f:{node.folder.id}", parent, folder=node) - parent.add(item) - for child in node.children: - add_folder(item, child) - add_mods(item, node.mods) - - for child in root.children: - add_folder(top, child) - if root.mods or not filtering: - ungrouped = TreeNode("ungrouped", UNGROUPED_KEY, top, folder=root) - top.add(ungrouped) - add_mods(ungrouped, root.mods) - return top - - -def _same_shape(a: TreeNode, b: TreeNode) -> bool: - if a.key != b.key or len(a.children) != len(b.children): - return False - return all(_same_shape(x, y) for x, y in zip(a.children, b.children, strict=True)) - - -def _copy_payload(dst: TreeNode, src: TreeNode) -> None: - dst.folder = src.folder - dst.leaf = src.leaf - dst.provider_id = src.provider_id - dst.provider_label = src.provider_label - dst.mod_version = src.mod_version - for d, s in zip(dst.children, src.children, strict=True): - _copy_payload(d, s) - - -class ModTreeModel(QAbstractItemModel): - """Organizer folders (and the trailing Ungrouped node) with their mods.""" - - KindRole = Qt.ItemDataRole.UserRole + 1 - KeyRole = Qt.ItemDataRole.UserRole + 2 - UuidRole = Qt.ItemDataRole.UserRole + 3 - PackageIdRole = Qt.ItemDataRole.UserRole + 4 - CheckStateRole = Qt.ItemDataRole.UserRole + 5 - EnabledCountRole = Qt.ItemDataRole.UserRole + 6 - TotalCountRole = Qt.ItemDataRole.UserRole + 7 - VisibleCountRole = Qt.ItemDataRole.UserRole + 8 - FilteringRole = Qt.ItemDataRole.UserRole + 9 - HasRuleRole = Qt.ItemDataRole.UserRole + 10 - ProviderIconRole = Qt.ItemDataRole.UserRole + 11 - ProviderColorRole = Qt.ItemDataRole.UserRole + 12 - HasErrorRole = Qt.ItemDataRole.UserRole + 13 - ErrorTooltipRole = Qt.ItemDataRole.UserRole + 14 - CanPlaceRole = Qt.ItemDataRole.UserRole + 15 - SelectedRole = Qt.ItemDataRole.UserRole + 16 - HasWarningRole = Qt.ItemDataRole.UserRole + 17 - WarningTooltipRole = Qt.ItemDataRole.UserRole + 18 - TagsRole = Qt.ItemDataRole.UserRole + 19 - ProviderLabelRole = Qt.ItemDataRole.UserRole + 20 - ModVersionRole = Qt.ItemDataRole.UserRole + 21 - - _SELECTION_ROLES = (SelectedRole,) - _ERROR_ROLES = (HasErrorRole, ErrorTooltipRole, HasWarningRole, WarningTooltipRole) - - def __init__( - self, - summary_for: Callable[[str], ModDiagnosticsView | None], - parent: QObject | None = None, - ) -> None: - super().__init__(parent) - self._summary_for = summary_for - self._root = TreeNode("root", "", None) - self._filtering = False - self._provider_colors: Mapping[str, str] = {} - self._tag_rows: dict[str, list[dict[str, str]]] = {} - self._selected: set[str] = set() - - def _node(self, index: AnyIndex | None) -> TreeNode: - if index is None or not index.isValid(): - return self._root - return index.internalPointer() - - def index( - self, row: int, column: int, parent: AnyIndex | None = None - ) -> QModelIndex: - node = self._node(parent) - if column != 0 or not 0 <= row < len(node.children): - return QModelIndex() - return self.createIndex(row, 0, node.children[row]) - - def parent(self, child: AnyIndex | None = None) -> QModelIndex: # type: ignore[override] # pyright: ignore[reportIncompatibleMethodOverride] - if child is None or not child.isValid(): - return QModelIndex() - up = self._node(child).parent - if up is None or up is self._root: - return QModelIndex() - return self.createIndex(up.row, 0, up) - - def rowCount(self, parent: AnyIndex | None = None) -> int: - if parent is not None and parent.column() > 0: - return 0 - return len(self._node(parent).children) - - def columnCount(self, parent: AnyIndex | None = None) -> int: - return 1 - - def data(self, index: AnyIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - node: TreeNode = index.internalPointer() - if role == Qt.ItemDataRole.DisplayRole: - return self._name(node) - if role == self.KindRole: - return node.kind - if role == self.KeyRole: - return node.key - if role == self.SelectedRole: - return node.key in self._selected - if role == self.FilteringRole: - return self._filtering - if node.kind == "mod": - return self._mod_data(node, role) - return self._folder_data(node, role) - - def _name(self, node: TreeNode) -> str: - if node.kind == "ungrouped": - return "Ungrouped" - if node.kind == "folder" and node.folder is not None: - return node.folder.folder.name - return node.leaf.name if node.leaf is not None else "" - - def _folder_data(self, node: TreeNode, role: int) -> Any: - folder = node.folder - if folder is None: - return None - own = node.kind == "ungrouped" - if role == self.CheckStateRole: - return _CHECK_STATES[folder.own_check if own else folder.check] - if role == self.EnabledCountRole: - return folder.own_enabled if own else folder.enabled - if role == self.TotalCountRole: - return folder.own_total if own else folder.total - if role == self.VisibleCountRole: - return len(folder.mods) if own else folder.visible_total - if role == self.HasRuleRole: - return folder.has_rules and not own - return None - - def _mod_data(self, node: TreeNode, role: int) -> Any: - leaf = node.leaf - if leaf is None: - return None - if role == self.UuidRole: - return leaf.uuid - if role == self.PackageIdRole: - return leaf.package_id or "" - if role == self.CheckStateRole: - return Qt.CheckState.Checked if leaf.enabled else Qt.CheckState.Unchecked - if role == self.ProviderLabelRole: - return node.provider_label - if role == self.ModVersionRole: - return node.mod_version - if role == self.ProviderIconRole: - return PROVIDER_ICONS.get(node.provider_id, "folder") - if role == self.ProviderColorRole: - return self._provider_colors.get(node.provider_id, PALETTE["TEXT_DIM"]) - if role == self.CanPlaceRole: - return leaf.package_id is not None - if role == self.TagsRole: - return self._tag_rows.get(leaf.package_id or "", []) - if role in self._ERROR_ROLES: - summary = self._summary_for(leaf.uuid) if leaf.enabled else None - if role == self.HasErrorRole: - return summary is not None and summary.has_errors - if role == self.HasWarningRole: - return summary is not None and summary.has_warnings - if role == self.ErrorTooltipRole: - return summary.error_tooltip if summary is not None else "" - return summary.warning_tooltip if summary is not None else "" - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"name"), - self.KindRole: QByteArray(b"kind"), - self.KeyRole: QByteArray(b"key"), - self.UuidRole: QByteArray(b"uuid"), - self.PackageIdRole: QByteArray(b"packageId"), - self.CheckStateRole: QByteArray(b"checkState"), - self.EnabledCountRole: QByteArray(b"enabledCount"), - self.TotalCountRole: QByteArray(b"totalCount"), - self.VisibleCountRole: QByteArray(b"visibleCount"), - self.FilteringRole: QByteArray(b"filtering"), - self.HasRuleRole: QByteArray(b"hasRule"), - self.ProviderIconRole: QByteArray(b"providerIcon"), - self.ProviderLabelRole: QByteArray(b"providerLabel"), - self.ModVersionRole: QByteArray(b"modVersion"), - self.ProviderColorRole: QByteArray(b"providerColor"), - self.HasErrorRole: QByteArray(b"hasError"), - self.ErrorTooltipRole: QByteArray(b"errorTooltip"), - self.HasWarningRole: QByteArray(b"hasWarning"), - self.WarningTooltipRole: QByteArray(b"warningTooltip"), - self.CanPlaceRole: QByteArray(b"canPlace"), - self.SelectedRole: QByteArray(b"selected"), - self.TagsRole: QByteArray(b"tags"), - } - - @property - def filtering(self) -> bool: - return self._filtering - - def set_tree( - self, - root: FolderNode, - mods: Mapping[str, ListedMod], - provider_colors: Mapping[str, str], - filtering: bool, - tags: Mapping[int, Tag] | None = None, - ) -> None: - """Swap in a freshly built tree. - - Same-shape rebuilds (check toggles, counts, collapse flags) update in - place so the view keeps its expansion and scroll position. - """ - fresh = _build(root, mods, filtering) - self._provider_colors = provider_colors - tag_map = tags if tags is not None else {} - self._tag_rows = { - leaf.package_id: [ - {"name": tag_map[tag_id].name, "color": tag_map[tag_id].color} - for tag_id in sorted(leaf.tag_ids) - if tag_id in tag_map - ] - for leaf in root.all_mods() - if leaf.package_id and leaf.tag_ids - } - if self._filtering == filtering and _same_shape(self._root, fresh): - _copy_payload(self._root, fresh) - self._emit_all_changed(self._root) - else: - self.beginResetModel() - self._root = fresh - self._filtering = filtering - self.endResetModel() - live = {node.key for node in self._root.walk()} - self._selected &= live - - def _emit_all_changed(self, node: TreeNode, roles: Iterable[int] = ()) -> None: - if not node.children: - return - parent = self._index_of(node) - top = self.index(0, 0, parent) - bottom = self.index(len(node.children) - 1, 0, parent) - self.dataChanged.emit(top, bottom, list(roles)) - for child in node.children: - self._emit_all_changed(child, roles) - - def refresh_diagnostics(self) -> None: - self._emit_all_changed(self._root, self._ERROR_ROLES) - - def _index_of(self, node: TreeNode) -> QModelIndex: - if node is self._root: - return QModelIndex() - return self.createIndex(node.row, 0, node) - - def node_at(self, index: AnyIndex) -> TreeNode | None: - return index.internalPointer() if index.isValid() else None - - def index_for_key(self, key: str) -> QModelIndex: - for node in self._root.walk(): - if node.key == key: - return self._index_of(node) - return QModelIndex() - - def index_for_uuid(self, uuid: str) -> QModelIndex: - for node in self._root.walk(): - if node.kind == "mod" and node.leaf is not None and node.leaf.uuid == uuid: - return self._index_of(node) - return QModelIndex() - - def expanded_indexes(self, parent: AnyIndex | None = None) -> list[QModelIndex]: - """Containers under *parent* the view should expand, parents first. - - Everything opens while filtering so matches are never hidden; otherwise - the persisted ``collapsed`` flag decides and closed folders hide their - subtree. - """ - result: list[QModelIndex] = [] - - def visit(node: TreeNode) -> None: - for child in node.children: - if child.is_container and self._is_open(child): - result.append(self._index_of(child)) - visit(child) - - visit(self._node(parent)) - return result - - def _is_open(self, node: TreeNode) -> bool: - if self._filtering: - return True - return node.folder is not None and not node.folder.folder.collapsed - - def selected_nodes(self) -> list[TreeNode]: - return [node for node in self._root.walk() if node.key in self._selected] - - def is_selected(self, index: AnyIndex) -> bool: - node = self.node_at(index) - return node is not None and node.key in self._selected - - def select(self, indexes: Iterable[AnyIndex], mode: str = "replace") -> None: - """Apply a click to the selection: ``replace``, ``toggle`` or ``extend``.""" - keys = [n.key for n in map(self.node_at, indexes) if n is not None] - before = set(self._selected) - if mode == "replace": - self._selected = set(keys) - elif mode == "toggle": - self._selected ^= set(keys) - else: - self._selected |= set(keys) - self._notify_selection(before ^ self._selected) - - def clear_selection(self) -> None: - before = set(self._selected) - self._selected.clear() - self._notify_selection(before) - - def _notify_selection(self, changed: set[str]) -> None: - if not changed: - return - for node in self._root.walk(): - if node.key in changed: - index = self._index_of(node) - self.dataChanged.emit(index, index, list(self._SELECTION_ROLES)) diff --git a/legacy/pxmodrim/ui/plugins/organizer/view.py b/legacy/pxmodrim/ui/plugins/organizer/view.py deleted file mode 100644 index c8ccf4e..0000000 --- a/legacy/pxmodrim/ui/plugins/organizer/view.py +++ /dev/null @@ -1,902 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from loguru import logger -from PySide6.QtCore import ( - Property, - QMetaObject, - QModelIndex, - Qt, - QTimer, - QUrl, - Signal, - Slot, -) -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QLineEdit, - QMenu, - QMessageBox, - QSizePolicy, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.organizer import ( - MAX_DEPTH, - ROOT_ID, - OrganizerError, - OrganizerService, - RuleField, - RuleOp, - RuleSpec, -) -from pxmodrim.core.organizer.resolve import preview_rule_matches -from pxmodrim.ui.components.button import AppButton -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.filter_sidebar import FilterSidebar -from pxmodrim.ui.components.icon_button import IconButton -from pxmodrim.ui.components.mod_activation import apply_activation, toggle_mods -from pxmodrim.ui.components.mod_updates import ( - add_update_action, - update_mods, - update_state, -) -from pxmodrim.ui.mod_selection import ModSelectionPresenter -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel -from pxmodrim.ui.plugins.organizer.dialogs import ( - DeleteFolderDialog, - FolderNameDialog, - FolderPickerDialog, -) -from pxmodrim.ui.plugins.organizer.filter_model import OrganizerFilterModel -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel, TreeNode -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - -_QML_DIR = Path(__file__).parent - - -def _plural(count: int, singular: str, plural: str | None = None) -> str: - return f"{count} {singular if count == 1 else plural or singular + 's'}" - - -class OrganizerViewPanel(BaseViewPanel): - view_id = "organizer" - icon_name = "folder" - label = "Organizer" - - readyChanged = Signal() - editorChanged = Signal() - tagCreated = Signal() - tagUpdated = Signal(int) - rulesSaved = Signal() - rulePreviewReady = Signal(int, "QVariantMap") # pyright: ignore[reportArgumentType] - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._service = cast(OrganizerService, ctx.plugins.get("organizer")) - self.model = ModTreeModel(ctx.diagnostics_service.summary_for, self) - self.filters = OrganizerFilterModel(self) - self._filter_key = "all" - self._search_text = "" - self._dirty = True - self._selection_anchor = QModelIndex() - self._selected_uuid: str | None = None - self._editor_error = "" - - self._tasks: set[asyncio.Task[None]] = set() - content = QWidget(self) - content.setObjectName("contentArea") - row = QHBoxLayout(content) - row.setContentsMargins(0, 0, 0, 0) - row.setSpacing(0) - - self._sidebar = FilterSidebar(qml_engine, self.filters, content) - self._sidebar.setFixedWidth(SIDEBAR_WIDTH) - self._sidebar.activated.connect(self._choose_filter_at) - row.addWidget(self._sidebar) - - main = QWidget(content) - main_layout = QVBoxLayout(main) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - toolbar = QWidget(main) - toolbar.setObjectName("searchBox") - toolbar.setFixedHeight(56) - toolbar_layout = QHBoxLayout(toolbar) - toolbar_layout.setContentsMargins(16, 8, 16, 8) - self.search_input = QLineEdit(toolbar) - self.search_input.setPlaceholderText("Search mods, package ID, author…") - self.search_input.setClearButtonEnabled(True) - toolbar_layout.addWidget(self.search_input, 1) - create = AppButton("New folder", toolbar) - create.setObjectName("primaryAction") - create.clicked.connect(self.newFolder) - toolbar_layout.addWidget(create) - self.rules_button = AppButton("Auto-folder rules", toolbar) - self.rules_button.clicked.connect(self.openRules) - toolbar_layout.addWidget(self.rules_button) - for label, method in ( - ("Expand all", "expandAll"), - ("Collapse all", "collapseAll"), - ): - button = AppButton(label, toolbar) - button.clicked.connect( - lambda _checked=False, name=method: self._tree_command(name) - ) - toolbar_layout.addWidget(button) - main_layout.addWidget(toolbar) - - self._qml = QQuickWidget(qml_engine, main) # pyright: ignore[reportCallIssue, reportArgumentType] - self._qml.setObjectName("organizerTree") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("organizerPanel", self) - qml_ctx.setContextProperty("modTreeModel", self.model) - self._qml.setSource(QUrl.fromLocalFile(str(_QML_DIR / "ModTree.qml"))) - rules_flags = ( - Qt.WindowType.Dialog - | Qt.WindowType.WindowTitleHint - | Qt.WindowType.WindowSystemMenuHint - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMinMaxButtonsHint - ) - self._rules_dialog = QDialog(self, rules_flags) - self._rules_dialog.setObjectName("organizerRuleEditorWindow") - self._rules_dialog.setWindowTitle("Auto-Folder Rules") - self._rules_dialog.setWindowModality(Qt.WindowModality.WindowModal) - self._rules_dialog.setMinimumSize(720, 520) - self._rules_dialog.setSizeGripEnabled(True) - self._rules_dialog_opened_once = False - rules_layout = QVBoxLayout(self._rules_dialog) - rules_layout.setContentsMargins(0, 0, 0, 0) - self._rules_qml = QQuickWidget(qml_engine, self._rules_dialog) # pyright: ignore[reportCallIssue, reportArgumentType] - self._rules_qml.setObjectName("organizerRuleEditorQuickWidget") - self._rules_qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._rules_qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._rules_qml.rootContext().setContextProperty("organizerPanel", self) - self._rules_qml.setSource(QUrl.fromLocalFile(str(_QML_DIR / "RuleEditor.qml"))) - rules_layout.addWidget(self._rules_qml) - install_dialog_chrome(self._rules_dialog) - self._rules_dialog.finished.connect(self._on_rules_dialog_finished) - hint = QWidget(main) - hint.setObjectName("organizerHint") - hint_layout = QHBoxLayout(hint) - hint_layout.setContentsMargins(12, 5, 12, 5) - hint_label = QLabel( - "Folders and tags are for browsing only. " - "Edit load order in Mods; checkboxes enable or disable the same mods.", - hint, - ) - hint_label.setWordWrap(True) - hint_layout.addWidget(hint_label, 1) - dismiss = IconButton("close", "Dismiss", size=24, parent=hint) - dismiss.clicked.connect(hint.hide) - hint_layout.addWidget(dismiss) - main_layout.addWidget(hint) - main_layout.addWidget(self._qml, 1) - self._selection_bar = QWidget(main) - bar_layout = QHBoxLayout(self._selection_bar) - bar_layout.setContentsMargins(12, 6, 12, 6) - self._selection_label = QLabel(self._selection_bar) - bar_layout.addWidget(self._selection_label) - for label, action in ( - ("Enable", "enable"), - ("Disable", "disable"), - ("Move to folder…", "move"), - ("Manage tags…", "tags"), - ("Create folder from selection", "create"), - ("Update", "update"), - ): - button = AppButton(label, self._selection_bar) - if action == "update": - self._update_button = button - button.clicked.connect( - lambda _checked=False, name=action: self.selectionAction(name) - ) - bar_layout.addWidget(button) - self._selection_bar.hide() - main_layout.addWidget(self._selection_bar) - row.addWidget(main, 3) - self.mod_info = ModInfoPanel( - self._ctx, self._qml_engine, ui_prefs=self._ui_prefs - ) - self.mod_info.setObjectName("modInfoPanel") - self.mod_info.setMinimumWidth(300) - row.addWidget(self.mod_info, 2) - self._selection = ModSelectionPresenter(self._ctx, self.mod_info) - self.mod_info.mod_requested.connect(self._reveal_mod) - self._root.addWidget(content, 1) - self._status = QLabel(self) - self._status.setObjectName("organizerStatus") - self._root.addWidget(self._status) - - self._menu = QMenu(self) - self._menu.setToolTipsVisible(True) - self._search_timer = QTimer(self) - self.model.modelReset.connect(self._clear_selection_anchor) - self._search_timer.setSingleShot(True) - self._search_timer.setInterval(220) - self.search_input.textChanged.connect(self._search_timer.start) - self._search_timer.timeout.connect(self._search) - self._service.changed.connect(self._invalidate) - ctx.active_state_changed.connect(self._invalidate) - ctx.mod_service.mods_changed.connect(self._invalidate) - ctx.diagnostics_service.diagnostics_summary_changed.connect( - self._refresh_diagnostics - ) - self._service.changed.connect(self._notify_editor) - download_manager(ctx).busy_changed.connect(lambda _busy: self._sync_info()) - - @Property(bool, notify=readyChanged) - def ready(self) -> bool: - return self._service.ready - - def _notify_editor(self, _event: object) -> None: - self.editorChanged.emit() - - def showEvent(self, event: object) -> None: - super().showEvent(event) # type: ignore[arg-type] - if self._dirty: - self._rebuild() - rules_root = self._rules_qml.rootObject() - if rules_root is not None: - QMetaObject.invokeMethod(rules_root, "schedulePreview") - - def _position_rules_dialog(self) -> None: - host = self.window() - host_geometry = host.frameGeometry() - if not self._rules_dialog_opened_once: - available = host.screen().availableGeometry() - width = min( - 960, - max(800, host_geometry.width() - 48), - available.width() - 32, - ) - height = min( - 760, - max(600, host_geometry.height() - 80), - available.height() - 32, - ) - self._rules_dialog.resize(width, height) - self._rules_dialog_opened_once = True - self._rules_dialog.move( - host_geometry.center() - self._rules_dialog.rect().center() - ) - - def _reveal_mod(self, uuid: str) -> None: - index = self.model.index_for_uuid(uuid) - if index.isValid(): - self.selectSingle(index) - - def _clear_selection_anchor(self) -> None: - self._selection_anchor = QModelIndex() - - def _sync_info(self, force: bool = False) -> None: - selected = self.model.selected_nodes() - self.editorChanged.emit() - mod_count = sum(node.kind == "mod" for node in selected) - self._selection_label.setText(f"{_plural(mod_count, 'mod')} selected") - self._selection_bar.setVisible(mod_count > 1) - state = update_state( - self._ctx, [n.leaf.uuid for n in selected if n.kind == "mod" and n.leaf] - ) - self._update_button.setVisible(download_manager(self._ctx).available) - self._update_button.setText(state.label) - self._update_button.setEnabled(state.enabled) - self._update_button.setToolTip(state.tooltip) - node = selected[0] if len(selected) == 1 else None - uuid = node.leaf.uuid if node is not None and node.leaf is not None else None - if uuid == self._selected_uuid and not force: - return - self._selected_uuid = uuid - if uuid is None: - self._selection.clear() - else: - task = asyncio.create_task(self._selection.show(uuid)) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - def _invalidate(self, _event: object = None) -> None: - self._dirty = True - if self.isVisible(): - self._rebuild() - - def _refresh_diagnostics(self, _event: object) -> None: - self.model.refresh_diagnostics() - if self._selected_uuid is not None: - self.mod_info.set_issues( - self._ctx.diagnostics_service.issues_for(self._selected_uuid) - ) - - def _rebuild(self) -> None: - if not self._service.ready: - return - self._dirty = False - self.filters.update(self._service.tree_filters()) - query = self.filters.for_key(self._filter_key).query(self._search_text) - tree = self._service.tree(query) - self.model.set_tree( - tree, - self._ctx.all_mods, - self._ctx.mod_service.provider_colors, - not query.is_empty, - self._service.state.tags, - ) - full_tree = tree if query.is_empty else self._service.tree() - self._status.setText( - f"{_plural(len(self._ctx.all_mods), 'mod')} · " - f"{len(self._ctx.active_uuids)} active · " - f"{_plural(len(self._service.state.folders) - 1, 'folder')} · " - f"{len(full_tree.mods)} ungrouped" - ) - self._sync_info(force=True) - self.readyChanged.emit() - - def _search(self) -> None: - text = self.search_input.text() - if text != self._search_text: - self._search_text = text - self._invalidate() - - def _choose_filter_at(self, row: int) -> None: - key = self.filters.key_at(row) - if key is not None and key != self._filter_key: - self._filter_key = key - self._invalidate() - - @asyncSlot(str) - async def selectionAction(self, action: str) -> None: - nodes = [node for node in self.model.selected_nodes() if node.leaf is not None] - if not nodes: - return - if action == "tags": - self.openTags() - elif action in ("move", "create"): - pids = self._selected_package_ids() - if pids: - if action == "move": - await self._move_mods(pids) - else: - await self._new_folder_from(pids) - elif action == "update": - await update_mods( - self._ctx, [n.leaf.uuid for n in nodes if n.leaf is not None], self - ) - elif action in ("enable", "disable"): - uuids = [node.leaf.uuid for node in nodes if node.leaf is not None] - if action == "enable": - await self._enable_many(uuids) - else: - await self._disable_many(uuids) - - def _selected_package_ids(self) -> list[str]: - return list( - dict.fromkeys( - node.leaf.package_id - for node in self.model.selected_nodes() - if node.kind == "mod" and node.leaf and node.leaf.package_id - ) - ) - - @Property(int, notify=editorChanged) - def selectedTagCount(self) -> int: - return len(self._selected_package_ids()) - - @Property(list, notify=editorChanged) - def tagRows(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - pids = self._selected_package_ids() - rows: list[dict[str, str | int]] = [] - tags = sorted( - self._service.state.tags.values(), key=lambda t: t.name.casefold() - ) - for tag in tags: - matched = sum( - tag.id in self._service.state.mod_tags.get(pid, frozenset()) - for pid in pids - ) - rows.append( - { - "id": tag.id, - "name": tag.name, - "color": tag.color, - "checkState": 2 if 0 < matched < len(pids) else int(bool(matched)), - } - ) - return rows - - @Property(list, notify=editorChanged) - def ruleRows(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - return [ - { - "field": rule.field, - "op": rule.op, - "pattern": rule.pattern, - "folder_id": rule.folder_id, - } - for rule in self._service.state.rules - ] - - @Property(list, notify=editorChanged) - def ruleFolders(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - folders = self._service.state.folders - - def path(folder_id: int) -> str: - folder = folders[folder_id] - if folder.parent_id == ROOT_ID: - return folder.name - assert folder.parent_id is not None - return f"{path(folder.parent_id)} / {folder.name}" - - return [ - {"id": folder.id, "name": path(folder.id)} - for folder in sorted( - (f for f in folders.values() if f.id != ROOT_ID), - key=lambda f: path(f.id).casefold(), - ) - ] - - @Slot("QVariantList", int) - def requestRulePreview( - self, rows: list[dict[str, str | int]], revision: int - ) -> None: - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - loop.create_task(self._calculate_rule_preview(rows, revision)) - - async def _calculate_rule_preview( - self, rows: list[dict[str, str | int]], revision: int - ) -> None: - specs = [ - RuleSpec( - cast(RuleField, row["field"]), - cast(RuleOp, row["op"]), - str(row["pattern"]), - int(row["folder_id"]), - ) - for row in rows - ] - counts, assignable = await asyncio.to_thread( - preview_rule_matches, self._service.state, self._ctx.all_mods, specs - ) - self.rulePreviewReady.emit( - revision, {"counts": counts, "assignable": assignable} - ) - - @Property(str, notify=editorChanged) - def editorError(self) -> str: - return self._editor_error - - def _set_editor_error(self, message: str = "") -> None: - if message != self._editor_error: - self._editor_error = message - self.editorChanged.emit() - - @Slot() - def openTags(self) -> None: - self._set_editor_error() - root = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "openTags") - - @Slot() - def openRules(self) -> None: - self._set_editor_error() - root = self._rules_qml.rootObject() - if root is None: - return - self._position_rules_dialog() - self._rules_dialog.open() - self._rules_dialog.activateWindow() - self._rules_qml.setFocus(Qt.FocusReason.OtherFocusReason) - QMetaObject.invokeMethod(root, "prepareForOpen") - - @Slot() - def closeRules(self) -> None: - self._rules_dialog.close() - - @Slot(int) - def _on_rules_dialog_finished(self, _result: int) -> None: - root = self._rules_qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "closeEditor") - - @asyncSlot(str, str) - async def createTag(self, name: str, color: str) -> None: - try: - await self._service.create_tag(name, color) - self.tagCreated.emit() - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int, str, str) - async def updateTag(self, tag_id: int, name: str, color: str) -> None: - try: - await self._service.update_tag(tag_id, name, color) - self.tagUpdated.emit(tag_id) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int) - async def deleteTag(self, tag_id: int) -> None: - try: - await self._service.delete_tag(tag_id) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int, bool) - async def assignTag(self, tag_id: int, add: bool) -> None: - pids = self._selected_package_ids() - try: - if add: - await self._service.set_mod_tags(pids, add=(tag_id,)) - else: - await self._service.set_mod_tags(pids, remove=(tag_id,)) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot("QVariantList") - async def saveRules(self, rows: list[dict[str, str | int]]) -> None: - specs = [ - RuleSpec( - cast(RuleField, row["field"]), - cast(RuleOp, row["op"]), - str(row["pattern"]), - int(row["folder_id"]), - ) - for row in rows - ] - try: - await self._service.set_rules(specs) - self._set_editor_error() - self.rulesSaved.emit() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot() - async def addStandardRules(self) -> None: - try: - await self._service.add_standard_rules() - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @Slot(result="QVariantList") - def expandedIndexes(self) -> list[QModelIndex]: - return self.model.expanded_indexes() - - @Slot(QModelIndex, int, "QVariantList") - def selectRow( - self, index: QModelIndex, modifiers: int, range_indexes: list[QModelIndex] - ) -> None: - if ( - modifiers & Qt.KeyboardModifier.ShiftModifier.value - and self._selection_anchor.isValid() - ): - self.model.select(range_indexes, "replace") - elif modifiers & Qt.KeyboardModifier.ControlModifier.value: - self.model.select((index,), "toggle") - self._selection_anchor = index - else: - self.model.select((index,)) - self._selection_anchor = index - self._sync_info() - - @Slot(QModelIndex) - def selectSingle(self, index: QModelIndex) -> None: - if not self.model.is_selected(index): - self.model.select((index,)) - self._selection_anchor = index - self._sync_info() - - def _tree_command(self, method: str) -> None: - root = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, method) - - @asyncSlot(bool) - async def persistAllCollapsed(self, collapsed: bool) -> None: - await self._service.set_all_collapsed(collapsed) - - @asyncSlot(QModelIndex, bool) - async def persistCollapsed(self, index: QModelIndex, collapsed: bool) -> None: - node = self.model.node_at(index) - if node is not None and node.is_container and not self.model.filtering: - try: - await self._service.set_collapsed(node.folder_id, collapsed) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot(QModelIndex) - async def toggleCheck(self, index: QModelIndex) -> None: - node = self.model.node_at(index) - if node is None: - return - if node.kind == "mod" and node.leaf is not None: - await toggle_mods(self._ctx, self, (node.leaf.uuid,)) - elif node.is_container: - await self._toggle_folder(node) - - async def _toggle_folder(self, node: TreeNode) -> None: - enable, disable = self._service.folder_toggle( - node.folder_id, recursive=node.kind != "ungrouped" - ) - if enable or disable: - await apply_activation(self._ctx, self, enable=enable, disable=disable) - - @Slot(QModelIndex) - def contextMenu(self, index: QModelIndex) -> None: - node = self.model.node_at(index) - if node is None: - self._menu.clear() - self._menu.addAction("New folder…", self.newFolder) - elif node.kind == "mod": - self.selectSingle(index) - self._mod_menu() - else: - self.model.select((index,)) - self._folder_menu(node) - self._sync_info() - self._menu.popup(self.cursor().pos()) - - def _mod_menu(self) -> None: - nodes = [n for n in self.model.selected_nodes() if n.kind == "mod" and n.leaf] - uuids = [n.leaf.uuid for n in nodes if n.leaf] - active = set(self._ctx.active_uuids) - self._menu.clear() - if any(uuid not in active for uuid in uuids): - self._menu.addAction("Enable", lambda: self._enable_many(uuids)) - if any(uuid in active for uuid in uuids): - self._menu.addAction("Disable", lambda: self._disable_many(uuids)) - add_update_action( - self._menu, self._ctx, uuids, lambda: self._update_selected(uuids) - ) - self._menu.addSeparator() - pids = list( - dict.fromkeys( - n.leaf.package_id for n in nodes if n.leaf and n.leaf.package_id - ) - ) - placeable = bool(pids) and all(n.leaf and n.leaf.package_id for n in nodes) - tooltip = "Mods without a package ID cannot be placed in folders" - move = self._menu.addAction("Move to folder…", lambda: self._move_mods(pids)) - remove = self._menu.addAction("Remove from folder", lambda: self._ungroup(pids)) - create = self._menu.addAction( - "Create folder from selection…", lambda: self._new_folder_from(pids) - ) - for action in (move, remove, create): - action.setEnabled(placeable) - action.setToolTip(tooltip if not placeable else "") - remove.setEnabled(placeable and any(n.folder_id != ROOT_ID for n in nodes)) - tag_action = self._menu.addAction("Manage tags…", self.openTags) - tag_action.setEnabled(placeable) - manual = list( - dict.fromkeys( - n.leaf.package_id - for n in nodes - if n.leaf and n.leaf.package_id and n.leaf.placement == "manual" - ) - ) - if placeable and manual: - self._menu.addAction("Use auto rules", lambda: self._reset_rules(manual)) - - def _folder_menu(self, node: TreeNode) -> None: - self._menu.clear() - recursive = node.kind != "ungrouped" - uuids = self._service.folder_mod_uuids(node.folder_id, recursive) - self._menu.addAction("Enable all", lambda: self._enable_many(uuids)).setEnabled( - bool(uuids) - ) - self._menu.addAction( - "Disable all", lambda: self._disable_many(uuids) - ).setEnabled(bool(uuids)) - add_update_action( - self._menu, self._ctx, uuids, lambda: self._update_selected(uuids) - ) - self._menu.addSeparator() - if node.kind == "ungrouped": - self._menu.addAction("New folder…", self.newFolder) - return - folder_id = node.folder_id - if node.folder is not None and node.folder.depth < MAX_DEPTH: - self._menu.addAction("New subfolder…", lambda: self._new_folder(folder_id)) - self._menu.addAction("Rename…", lambda: self._rename(folder_id)) - move = self._menu.addAction("Move to…", lambda: self._move_folder(folder_id)) - move.setEnabled(bool(self._service.folder_move_targets(folder_id))) - self._menu.addAction("Delete…", lambda: self._delete(folder_id)) - - @asyncSlot() - async def newFolder(self) -> None: - await self._new_folder(ROOT_ID) - - @asyncSlot() - async def _new_folder(self, parent_id: int) -> None: - result, dialog = await await_dialog( - FolderNameDialog, "New Folder", "", self, "Create" - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.create_folder(dialog.textValue(), parent_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _new_folder_from(self, package_ids: list[str]) -> None: - result, dialog = await await_dialog( - FolderNameDialog, "Create Folder from Selection", "", self, "Create" - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.create_folder_from(dialog.textValue(), package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _rename(self, folder_id: int) -> None: - current = self._service.state.folders[folder_id] - result, dialog = await await_dialog( - FolderNameDialog, "Rename Folder", current.name, self - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.rename_folder(folder_id, dialog.textValue()) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _move_mods(self, package_ids: list[str]) -> None: - allowed = frozenset(self._service.state.folders) - result, dialog = await await_dialog( - FolderPickerDialog, - self._service.tree(), - "Move to Folder", - allowed, - self, - ) - if result == QDialog.DialogCode.Accepted: - try: - if dialog.folder_id == ROOT_ID: - await self._service.ungroup(package_ids) - else: - await self._service.place(package_ids, dialog.folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _move_folder(self, folder_id: int) -> None: - targets = self._service.folder_move_targets(folder_id) - result, dialog = await await_dialog( - FolderPickerDialog, self._service.tree(), "Move Folder", targets, self - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.move_folder(folder_id, dialog.folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _delete(self, folder_id: int) -> None: - folder = self._service.state.folders[folder_id] - result, _ = await await_dialog(DeleteFolderDialog, folder.name, self) - if result == QMessageBox.StandardButton.Yes: - try: - await self._service.delete_folder(folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _ungroup(self, package_ids: list[str]) -> None: - try: - await self._service.ungroup(package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _reset_rules(self, package_ids: list[str]) -> None: - try: - await self._service.reset_to_rules(package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _update_selected(self, uuids: list[str]) -> None: - await update_mods(self._ctx, uuids, self) - - @asyncSlot() - async def _enable_many(self, uuids: list[str]) -> None: - await apply_activation(self._ctx, self, enable=uuids) - - @asyncSlot() - async def _disable_many(self, uuids: list[str]) -> None: - await apply_activation(self._ctx, self, disable=uuids) - - @Slot(QModelIndex, QModelIndex, result=bool) - def canDrop(self, source: QModelIndex, target: QModelIndex) -> bool: - src = self.model.node_at(source) - dst = self.model.node_at(target) - if src is None or dst is None or not dst.is_container or src is dst: - return False - if src.kind == "mod": - selected = ( - self.model.selected_nodes() if self.model.is_selected(source) else [src] - ) - nodes = [n for n in selected if n.kind == "mod"] - return bool(nodes) and all(n.leaf and n.leaf.package_id for n in nodes) - if src.kind == "folder": - return dst.folder_id in self._service.folder_move_targets(src.folder_id) - return False - - @asyncSlot(QModelIndex, QModelIndex) - async def dropOn(self, source: QModelIndex, target: QModelIndex) -> None: - if not self.canDrop(source, target): - return - src = self.model.node_at(source) - dst = self.model.node_at(target) - if src is None or dst is None: - return - try: - if src.kind == "folder": - await self._service.move_folder(src.folder_id, dst.folder_id) - else: - selected = ( - self.model.selected_nodes() - if self.model.is_selected(source) - else [src] - ) - pids = list( - dict.fromkeys( - n.leaf.package_id - for n in selected - if n.kind == "mod" and n.leaf and n.leaf.package_id - ) - ) - if dst.folder_id == ROOT_ID: - await self._service.ungroup(pids) - else: - await self._service.place(pids, dst.folder_id) - except OrganizerError as exc: - await self._error(exc) - - async def _error(self, exc: OrganizerError) -> None: - toast = self._app_ctx.toasts if self._app_ctx is not None else None - if toast is not None: - toast.error(str(exc)) - else: - logger.warning("organizer: {}", exc) diff --git a/legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml b/legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml deleted file mode 100644 index 947bff0..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml +++ /dev/null @@ -1,114 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../components/controls" - -ColumnLayout { - id: root - required property QtObject section - readonly property var initial: section.initial - spacing: 10 - - component Label: Text { - Layout.preferredWidth: 150 - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - component Hint: Text { - Layout.fillWidth: true - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Auto-update mods" } - PxComboBox { - Layout.preferredWidth: 180 - Accessible.name: "Auto-update Workshop mods" - textRole: "text" - valueRole: "value" - model: [ - { text: "Off", value: 0 }, - { text: "Every 6 hours", value: 6 }, - { text: "Every 12 hours", value: 12 }, - { text: "Daily", value: 24 }, - { text: "Every 3 days", value: 72 }, - { text: "Weekly", value: 168 } - ] - Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.auto_update_hours)) - onActivated: root.section.set("auto_update_hours", currentValue) - } - } - Hint { text: "Re-syncs mods downloaded by PxModRim while the app is running." } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Parallel mods" } - PxSpinBox { - from: 1 - to: 8 - value: root.initial.parallel_items - Accessible.name: "Parallel Workshop mod downloads" - toolTipText: "How many mods are downloaded at the same time. Raise it for many small mods; lower it if Steam throttles or drops the connection." - onValueModified: root.section.set("parallel_items", value) - } - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Threads per mod" } - PxSpinBox { - from: 1 - to: 16 - value: root.initial.threads_per_item - Accessible.name: "Download threads per Workshop mod" - toolTipText: "Connections used to fetch chunks of a single mod. Raise it for large mods on a fast link; total load is parallel mods \u00d7 threads." - onValueModified: root.section.set("threads_per_item", value) - } - } - Hint { text: "Total connections = parallel mods \u00d7 threads per mod. Applies to the next download." } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Proxy" } - PxTextField { - Layout.fillWidth: true - monospace: true - text: root.initial.proxy - placeholderText: "Use system proxy" - Accessible.name: "Workshop download proxy" - onTextEdited: root.section.set("proxy", text.trim()) - } - } - Hint { text: "URL such as http://host:3128 or socks5h://host:1080. Empty uses https_proxy / all_proxy from the environment." } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Connect timeout (s)" } - PxSpinBox { - from: 1 - to: 120 - value: root.initial.connect_timeout - Accessible.name: "Workshop connect timeout in seconds" - toolTipText: "How long to wait for a connection before trying another server." - onValueModified: root.section.set("connect_timeout", value) - } - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Label { text: "Stall timeout (s)" } - PxSpinBox { - from: 1 - to: 300 - value: root.initial.stall_timeout - Accessible.name: "Workshop stall timeout in seconds" - toolTipText: "How long a transfer may stay below 1 byte/s before it is abandoned and retried." - onValueModified: root.section.set("stall_timeout", value) - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py b/legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py deleted file mode 100644 index 3d72aa1..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_downloader/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.steam_downloader.plugin import SteamDownloaderUiPlugin - -__all__ = ["SteamDownloaderUiPlugin"] diff --git a/legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py b/legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py deleted file mode 100644 index e5ee0d4..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_downloader/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, cast - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.settings_section import PluginConfigSection - -if TYPE_CHECKING: - from PySide6.QtCore import QObject - - from pxmodrim.core.downloads.steam import SteamDownloader - from pxmodrim.ui.context import AppContext - - -class SteamSettingsSection(PluginConfigSection): - title = "Steam Workshop" - source = Path(__file__).with_name("SteamSettings.qml") - - -class SteamDownloaderUiPlugin(Plugin): - """Settings for the Steam downloader; imports nothing from it at runtime.""" - - name = "steam_downloader_ui" - dependencies: ClassVar[list[str]] = ["steam_downloader"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - downloader = cast("SteamDownloader", ctx.core.plugins.get("steam_downloader")) - - def section(parent: QObject) -> SteamSettingsSection: - return SteamSettingsSection(downloader.settings, parent) - - ctx.add_settings_section(section) diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml b/legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml deleted file mode 100644 index 5c9c267..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml +++ /dev/null @@ -1,150 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: root - color: Theme.elevate2 - property bool downloadEnabled: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: 8 - spacing: 0 - - Text { - id: titleLabel - text: "Download queue" + (listView.count > 0 ? " (" + listView.count + ")" : "") - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: Font.Bold - Layout.bottomMargin: 8 - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.bottomMargin: 8 - clip: true - - EmptyState { - anchors.fill: parent - visible: listView.count === 0 - title: "Download queue is empty" - detail: "Click + on a mod to add it" - } - - ListView { - id: listView - objectName: "downloadList" - anchors.fill: parent - visible: listView.count > 0 - spacing: 2 - model: downloadQueueModel - - ScrollBar.vertical: PxScrollBar { - policy: ScrollBar.AsNeeded - } - - delegate: Rectangle { - width: listView.width - height: 28 - radius: Theme.radiusMd - color: model.status === "downloading" - ? Theme.elevate3 : removeArea.containsMouse - ? Theme.elevate3 : "transparent" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 8 - spacing: 4 - clip: true - - Text { - Layout.fillWidth: true - text: model.display - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - maximumLineCount: 1 - } - - Image { - readonly property string iconName: model.status === "downloading" ? "refresh" - : model.status === "success" ? "check" - : model.status === "error" ? "close" : "" - visible: iconName.length > 0 - sourceSize.width: 12 - sourceSize.height: 12 - source: visible ? "image://icons/" + iconName + "?color=" - + encodeURIComponent(model.status === "error" ? Theme.danger - : model.status === "success" ? Theme.success - : Theme.textDim) : "" - } - - Image { - visible: removeArea.containsMouse && removeArea.enabled - sourceSize.width: 12 - sourceSize.height: 12 - source: visible ? "image://icons/close?color=" + encodeURIComponent(Theme.textDim) : "" - } - } - - Rectangle { - anchors.left: parent.left - anchors.bottom: parent.bottom - width: parent.width * model.progress - height: 2 - radius: Theme.radiusXs - color: Theme.primary - visible: model.status === "downloading" - opacity: 0.5 - } - - MouseArea { - id: removeArea - anchors.fill: parent - enabled: model.status !== "queued" && model.status !== "downloading" - hoverEnabled: true - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: downloadSidebar.removeItem(model.id) - } - } - } - } - - PxButton { - id: clearButton - Layout.fillWidth: true - variant: "danger" - text: "Clear queue" - visible: listView.count > 0 && root.downloadEnabled - onClicked: downloadSidebar.clearQueue() - } - - PxButton { - id: downloadButton - Layout.fillWidth: true - Layout.topMargin: visible ? 4 : 0 - variant: "success" - text: "Download all" - visible: root.downloadEnabled - enabled: listView.count > 0 - onClicked: downloadSidebar.downloadRequested() - } - - PxButton { - id: stopButton - Layout.fillWidth: true - Layout.topMargin: 4 - variant: "dangerSolid" - text: "Stop" - visible: !root.downloadEnabled - onClicked: downloadSidebar.stopRequested() - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml b/legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml deleted file mode 100644 index 68f5f33..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml +++ /dev/null @@ -1,214 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtWebEngine -import QtWebChannel -import "../../components/controls" - -Rectangle { - id: root - color: Theme.elevate0 - - property bool _firstLoad: true - property string _placeholderText: "Initializing Steam Workshop browser\u2026" - property bool _showError: false - property bool _loadedOnce: false - property bool _pendingReload: false - - function _navigate() { - if (!root._loadedOnce) { - root._loadedOnce = true - webView.url = "https://steamcommunity.com/workshop/browse/?appid=294100" - } else if (root._pendingReload) { - root._pendingReload = false - webView.reload() - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 36 - color: Theme.elevate2 - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - spacing: 4 - - PxButton { - id: homeBtn - variant: "ghost" - iconName: "home" - implicitWidth: 28 - implicitHeight: 28 - ToolTip.text: "Home" - onClicked: steamWorkshopPanel.navigateHome() - } - - PxButton { - id: backBtn - variant: "ghost" - iconName: "chevron-left" - implicitWidth: 28 - implicitHeight: 28 - ToolTip.text: "Back" - enabled: webView.canGoBack - onClicked: webView.goBack() - } - - PxButton { - id: fwdBtn - variant: "ghost" - iconName: "chevron" - implicitWidth: 28 - implicitHeight: 28 - ToolTip.text: "Forward" - enabled: webView.canGoForward - onClicked: webView.goForward() - } - - PxButton { - id: clearCacheBtn - variant: "ghost" - iconName: "trash" - implicitWidth: 28 - implicitHeight: 28 - ToolTip.text: "Clear cache" - onClicked: { - // Stale Steam SSR JS in the disk cache fails SRI and gets - // blocked; drop it and reload once the clear completes. - root._pendingReload = true - webView.profile.clearHttpCache() - } - } - - PxButton { - id: reloadBtn - variant: "ghost" - iconName: "refresh" - implicitWidth: 28 - implicitHeight: 28 - ToolTip.text: "Reload" - onClicked: webView.reload() - } - - PxTextField { - id: urlInput - Layout.fillWidth: true - implicitHeight: 24 - font.pixelSize: Theme.fontSizeSm - text: webView.url.toString() - onAccepted: steamWorkshopPanel.navigateToUrl(text) - } - - PxProgressBar { - Layout.preferredWidth: 48 - indeterminate: true - visible: webView.loading - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - WebChannel { - id: channel - - Component.onCompleted: { - if (_bridge) { - channel.registerObject("bridge", _bridge) - } - } - } - - WebEngineView { - id: webView - objectName: "workshopWeb" - webChannel: channel - anchors.fill: parent - - profile: WebEngineProfile { - id: steamProfile - storageName: "pxmodrim-steam" - httpCacheType: WebEngineProfile.DiskHttpCache - httpCacheMaximumSize: 52428800 - offTheRecord: false - } - - // Steam redeploys its SSR JS with new SRI hashes, so the persistent - // disk cache can hold stale bytes that fail the integrity check and - // get blocked. Clear it before the first navigation; url is set only - // after clearHttpCacheCompleted to avoid navigating mid-clear. - - settings { - pluginsEnabled: false - pdfViewerEnabled: false - fullScreenSupportEnabled: false - hyperlinkAuditingEnabled: false - errorPageEnabled: false - localStorageEnabled: true - } - - Component.onCompleted: { - var inj = WebEngine.script() - inj.name = "inject" - inj.sourceCode = _injectCode - inj.injectionPoint = WebEngineScript.DocumentCreation - inj.worldId = WebEngineScript.MainWorld - inj.runsOnSubFrames = false - webView.userScripts.insert(inj) - - webView.profile.clearHttpCache() - } - - Connections { - target: steamProfile - - function onClearHttpCacheCompleted() { - root._navigate() - } - } - - onLoadingChanged: function(loadRequest) { - if (loadRequest.status === WebEngineView.LoadStartedStatus) { - root._placeholderText = "Initializing Steam Workshop browser\u2026" - root._showError = false - } else if (loadRequest.status === WebEngineView.LoadSucceededStatus) { - root._firstLoad = false - } else if (loadRequest.status === WebEngineView.LoadFailedStatus) { - console.warn("[steam] load failed:", loadRequest.url, loadRequest.errorString, - "domain:", loadRequest.errorDomain, "code:", loadRequest.errorCode) - root._placeholderText = "Failed to load Steam Workshop" - root._showError = true - } - } - - onLoadProgressChanged: { - if (root._firstLoad && webView.loadProgress < 100) { - root._placeholderText = "Initializing Steam Workshop browser\u2026 " + webView.loadProgress + "%" - } - } - } - - Rectangle { - anchors.fill: parent - color: Theme.elevate0 - visible: root._firstLoad || root._showError - - EmptyState { - anchors.fill: parent - iconName: root._showError ? "warning" : "steam" - title: root._placeholderText - detail: root._showError ? "Check your network connection and try again." : "" - } - } - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py b/legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py deleted file mode 100644 index ab35a83..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.steam_workshop.plugin import ( - ItemStatus, - SidebarSync, - SteamWorkshopUiPlugin, -) -from pxmodrim.ui.plugins.steam_workshop.view import SteamWorkshopViewPanel - -__all__ = [ - "ItemStatus", - "SidebarSync", - "SteamWorkshopUiPlugin", - "SteamWorkshopViewPanel", -] diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py b/legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py deleted file mode 100644 index 97af870..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING - -from PySide6.QtCore import QObject, Signal, Slot - -if TYPE_CHECKING: - from pxmodrim.ui.plugins.steam_workshop.plugin import SteamWorkshopUiPlugin - - -def _is_published_file_id(value: object) -> bool: - return isinstance(value, str) and value.isascii() and value.isdecimal() - - -class PxModRimBridge(QObject): - result_ready = Signal(str, str) - - def __init__( - self, plugin: SteamWorkshopUiPlugin, parent: QObject | None = None - ) -> None: - super().__init__(parent) - self._plugin = plugin - self._tasks: set[asyncio.Task[None]] = set() - - @Slot(str, str, str) - def call(self, request_id: str, method: str, payload: str) -> None: - task = asyncio.create_task(self._handle(request_id, method, payload)) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - async def _handle(self, request_id: str, method: str, payload: str) -> None: - try: - params = json.loads(payload) if payload else {} - - if method == "init_ready": - self._plugin.sync_all() - result = None - elif method == "toggle_download_checked": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - self._plugin.toggle_download_checked( - mod_id, - params.get("title", ""), - params.get("checked", False), - ) - result = None - elif method == "batch_toggle_download_checked": - valid_items = [ - (mod_id, title) - for mod_id, title in zip( - params["mod_ids"], params["titles"], strict=True - ) - if _is_published_file_id(mod_id) - ] - if valid_items: - self._plugin.batch_toggle_download_checked( - [mod_id for mod_id, _title in valid_items], - [title for _mod_id, title in valid_items], - params.get("checked", False), - ) - result = None - elif method == "toggle_active": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - self._plugin.toggle_active( - mod_id, - params.get("active", False), - ) - result = None - elif method == "batch_toggle_active": - mod_ids = [ - mod_id - for mod_id in params["mod_ids"] - if _is_published_file_id(mod_id) - ] - if mod_ids: - self._plugin.batch_toggle_active( - mod_ids, - params.get("active", False), - ) - result = None - elif method == "fetch_mod_deps": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - raw = await asyncio.wait_for( - self._plugin.fetch_mod_deps(mod_id), - timeout=30, - ) - result = json.loads(raw) if raw else None - else: - result = None - else: - raise RuntimeError(f"Unknown method: {method}") - - self.result_ready.emit( - request_id, - json.dumps({"ok": True, "result": result}), - ) - except TimeoutError: - self.result_ready.emit( - request_id, - json.dumps({"ok": False, "error": "Timeout"}), - ) - except Exception as exc: - self.result_ready.emit( - request_id, - json.dumps({"ok": False, "error": str(exc)}), - ) diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py b/legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py deleted file mode 100644 index 8ed32df..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - - -class DownloadQueueModel(QAbstractListModel): - _IdRole = Qt.ItemDataRole.UserRole + 1 - _TitleRole = Qt.ItemDataRole.UserRole + 2 - _DisplayRole = Qt.ItemDataRole.UserRole + 3 - _StatusRole = Qt.ItemDataRole.UserRole + 4 - _ProgressRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[dict[str, Any]] = [] - - def rowCount( - self, - parent: QModelIndex | QPersistentModelIndex | None = None, - ) -> int: - return len(self._items) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._items): - return None - item = self._items[index.row()] - if role == self._IdRole: - return item["id"] - if role == self._TitleRole: - return item["title"] - if role == self._DisplayRole: - return item["display"] - if role == self._StatusRole: - return item["status"] - if role == self._ProgressRole: - return item["progress"] - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self._IdRole: QByteArray(b"id"), - self._TitleRole: QByteArray(b"title"), - self._DisplayRole: QByteArray(b"display"), - self._StatusRole: QByteArray(b"status"), - self._ProgressRole: QByteArray(b"progress"), - } - - def sync_from( - self, - checked_ids: dict[str, str], - statuses: dict[str, str] | None = None, - ) -> None: - statuses = statuses or {} - self.beginResetModel() - self._items = [ - { - "id": mid, - "title": t or mid, - "display": f"{t} ({mid})" if t else mid, - "status": statuses.get(mid, ""), - "progress": 0.0, - } - for mid, t in checked_ids.items() - ] - self.endResetModel() - - def update_status( - self, mod_id: str, status: str, bytes_done: int = 0, bytes_total: int = 0 - ) -> None: - for row, item in enumerate(self._items): - if item["id"] == mod_id: - item["status"] = status - item["progress"] = bytes_done / bytes_total if bytes_total else 0.0 - idx = self.index(row) - self.dataChanged.emit(idx, idx, [self._StatusRole, self._ProgressRole]) - return - - @property - def count(self) -> int: - return len(self._items) diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py b/legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py deleted file mode 100644 index 153b74a..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from pxmodrim.ui.plugins.steam_workshop.download_queue_model import ( - DownloadQueueModel, -) -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_DL_SIDEBAR_QML = _QML_DIR / "DownloadSidebar.qml" - - -class DownloadSidebar(QWidget): - download_requested = Signal() - item_removed = Signal(str) - stop_requested = Signal() - clear_requested = Signal() - - def __init__( - self, qml_engine: QQmlEngine | None = None, parent: QWidget | None = None - ) -> None: - """Initialize the download sidebar with queue model and QML view.""" - super().__init__(parent) - self.setObjectName("downloadSidebar") - self.setFixedWidth(SIDEBAR_WIDTH) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._model = DownloadQueueModel(self) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("downloadSidebar", self) - qml_ctx.setContextProperty("downloadQueueModel", self._model) - self._qml.setSource(QUrl.fromLocalFile(str(_DL_SIDEBAR_QML))) - - layout.addWidget(self._qml) - - @Slot() - def downloadRequested(self) -> None: - self.download_requested.emit() - - @Slot() - def stopRequested(self) -> None: - self.stop_requested.emit() - - @Slot(str) - def removeItem(self, mod_id: str) -> None: - self.item_removed.emit(mod_id) - - @Slot() - def clearQueue(self) -> None: - self.clear_requested.emit() - - def set_download_enabled(self, enabled: bool) -> None: - root = self._qml.rootObject() - if root is not None: - root.setProperty("downloadEnabled", enabled) - - def sync_from( - self, - checked_ids: dict[str, str], - statuses: dict[str, str] | None = None, - ) -> None: - self._model.sync_from(checked_ids, statuses) - - def update_status( - self, mod_id: str, status: str, bytes_done: int = 0, bytes_total: int = 0 - ) -> None: - self._model.update_status(mod_id, status, bytes_done, bytes_total) diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/inject.css b/legacy/pxmodrim/ui/plugins/steam_workshop/inject.css deleted file mode 100644 index 406fb9d..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/inject.css +++ /dev/null @@ -1,393 +0,0 @@ -/* Instant-hide styles to prevent flicker */ - -/* stylelint-disable-next-line selector-id-pattern, selector-class-pattern */ -#global_header, -header, -.sharedfiles_item_page_header { - display: none !important; -} - -/* stylelint-disable-next-line selector-id-pattern */ -#footer [style*='--grid-area'] { - display: none !important; -} - -/* Loading placeholder button -- replaces Steam button while bridge data loads */ - -.rimsort-loading-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 36px; - border-radius: 6px; - pointer-events: none; - background: linear-gradient( - 90deg, - rgb(255 255 255 / 4%) 0%, - rgb(255 255 255 / 10%) 50%, - rgb(255 255 255 / 4%) 100% - ); - background-size: 200% 100%; - animation: rimsort-loading-shimmer 1.2s ease-in-out infinite; -} - -.rimsort-error-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 36px; - border-radius: 6px; - pointer-events: none; - background-color: #d32f2f; - color: white; - font-weight: bold; - font-size: 14px; -} - -@keyframes rimsort-loading-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -/* Grid badge styles */ - -.rimsort-modstatus-badge { - position: absolute; - top: 5px; - right: 5px; - color: white; - min-width: 28px; - height: 28px; - padding: 0 6px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - font-weight: bold; - font-size: 16px; - box-shadow: 0 0 4px black; - user-select: none; - transition: transform 0.1s ease, box-shadow 0.1s ease, opacity 0.2s ease, visibility 0.2s ease, background-color 0.2s ease; - z-index: 10; - opacity: 0; - visibility: hidden; -} - -.rimsort-modstatus-badge.pressed { - transform: scale(0.9); -} - -.rimsort-mod-active { - background-color: #4CAF50; - cursor: pointer; - opacity: 1; - visibility: visible; -} - -.rimsort-mod-inactive { - background-color: #666; - cursor: pointer; - opacity: 1; - visibility: visible; -} - -.rimsort-mod-checked { - background-color: #FFA500; - cursor: pointer; - opacity: 1; - visibility: visible; -} - -.rimsort-mod-default { - background-color: #2196F3; - cursor: pointer; -} - -.rimsort-mod-resolving { - background-color: #555; - cursor: wait; - opacity: 1; - visibility: visible; - pointer-events: none; -} - -.collectionItem { - position: relative; -} - -.rimsort-tile:hover .rimsort-modstatus-badge, -.collectionItem:hover .rimsort-modstatus-badge { - opacity: 1; - visibility: visible; -} - -/* Detail page button styles */ - -.rimsort-detail-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-width: 130px; - height: 36px; - padding: 0 16px; - border-radius: 6px; - font-weight: bold; - font-size: 14px; - user-select: none; - cursor: pointer; - color: white; - border: none; - text-decoration: none; - box-shadow: 0 0 4px rgb(0 0 0 / 30%); - transition: transform 0.1s ease, box-shadow 0.1s ease, background-color 0.2s ease; -} - -.rimsort-detail-btn.rimsort-mod-active { - background-color: #4CAF50; - cursor: pointer; -} - -.rimsort-detail-btn.rimsort-mod-inactive { - background-color: #666; - cursor: pointer; -} - -.rimsort-detail-btn.rimsort-mod-checked { - background-color: #FFA500; - cursor: pointer; -} - -.rimsort-detail-btn.rimsort-mod-default { - background-color: #2196F3; - cursor: pointer; -} - -.rimsort-detail-btn.pressed { - transform: scale(0.95); - box-shadow: 0 0 2px rgb(0 0 0 / 50%); -} - -/* Dependency tree styles */ - -.rimsort-deps-section { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgb(255 255 255 / 10%); - font-size: 13px; -} - -.rimsort-deps-header { - font-weight: bold; - margin-bottom: 8px; - color: #ccc; -} - -.rimsort-dep-node { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 0; - white-space: nowrap; -} - -.rimsort-dep-expand { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - cursor: pointer; - color: #888; - user-select: none; - transition: transform 0.15s ease, color 0.15s ease; -} - -.rimsort-dep-expand.expanded { - transform: rotate(90deg); - color: #ccc; -} - -.rimsort-dep-expand:empty { - visibility: hidden; -} - -.rimsort-dep-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - border-radius: 4px; - font-weight: bold; - font-size: 11px; - color: white; - user-select: none; - cursor: pointer; - transition: transform 0.1s ease, box-shadow 0.1s ease, background-color 0.2s ease; - flex-shrink: 0; -} - -.rimsort-dep-badge.pressed { - transform: scale(0.9); -} - -.rimsort-dep-title { - color: #fff; - text-decoration: none; - transition: color 0.15s ease; - max-width: 250px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: inline-block; - vertical-align: middle; -} - -.rimsort-dep-title:hover { - color: #4CAF50; -} - -.rimsort-dep-children { - margin-left: 24px; - border-left: 1px solid rgb(255 255 255 / 10%); - padding-left: 8px; -} - -.rimsort-dep-loading-container { - display: flex; - align-items: center; - gap: 10px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgb(255 255 255 / 10%); - color: #888; - font-size: 13px; -} - -.rimsort-dep-spinner { - width: 18px; - height: 18px; - border: 2px solid rgb(255 255 255 / 15%); - border-top-color: #4CAF50; - border-radius: 50%; - flex-shrink: 0; - animation: rimsort-spin 0.7s linear infinite; -} - -@keyframes rimsort-spin { - to { transform: rotate(360deg); } -} - -.rimsort-dep-loading { - color: #888; - font-style: italic; - font-size: 12px; - padding: 4px 0; -} - -.rimsort-dep-error { - color: #f44336; - font-size: 12px; - padding: 4px 0; - cursor: pointer; -} - -.rimsort-dep-circular { - color: #888; - font-size: 12px; - font-style: italic; - padding: 4px 0; -} - -.rimsort-dep-maxdepth { - color: #888; - font-size: 12px; - padding: 4px 0; - border-top: 1px dashed rgb(255 255 255 / 10%); - margin-top: 4px; -} - -.rimsort-deps-resolving { - position: relative; - pointer-events: none; - opacity: 0.8; -} - -.rimsort-deps-resolving::after { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: linear-gradient(90deg, transparent, rgb(255 255 255 / 15%), transparent); - animation: rimsort-shimmer 1.5s infinite; -} - -@keyframes rimsort-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} - -.rimsort-solo-link { - display: block; - margin-top: 8px; - padding: 6px 12px; - background: rgb(255 255 255 / 5%); - border: 1px solid rgb(255 255 255 / 10%); - border-radius: 4px; - color: #aaa; - text-decoration: none; - font-size: 12px; - text-align: center; - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; - cursor: pointer; -} - -.rimsort-solo-link:hover { - background: rgb(255 255 255 / 10%); - color: #fff; - border-color: rgb(255 255 255 / 20%); -} - -/* Hide native Steam collection buttons — replaced by RimSort badges */ - -.subscribeCollection { - display: none !important; -} - -.collectionItem .subscriptionControls { - display: none !important; -} - -/* Collection page batch bar */ - -.rimsort-collection-bar { - display: flex; - gap: 8px; - margin: 0 0 12px; - padding: 8px 0; - border-bottom: 1px solid rgb(255 255 255 / 10%); -} - -.rimsort-only-btn { - padding: 6px 14px; - border: 1px solid #FFA500; - border-radius: 4px; - background: transparent; - color: #FFA500; - font-size: 13px; - cursor: pointer; - transition: background 0.15s ease, opacity 0.15s ease; -} - -.rimsort-only-btn:hover { - background: rgb(255 165 0 / 10%); -} - -.rimsort-only-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py b/legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py deleted file mode 100644 index 8fd04a9..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/plugin.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -import contextlib -from typing import TYPE_CHECKING, NamedTuple, cast - -import httpx -from loguru import logger - -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.navigation import build_route - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult - from pxmodrim.core.downloads.steam import SteamDownloader - from pxmodrim.ui.context import AppContext - - -class SidebarSync(NamedTuple): - checked_ids: dict[str, str] - statuses: dict[str, str] - - -class ItemStatus(NamedTuple): - mod_id: str - status: str - bytes_done: int = 0 - bytes_total: int = 0 - - -class SteamWorkshopUiPlugin(Plugin): - name = "steamworkshop" - dependencies = ["steam_downloader"] - - badges_refresh_requested: Event[list[str]] - sidebar_sync_requested: Event[SidebarSync] - item_status_changed: Event[ItemStatus] - download_busy_changed: Event[bool] - uncheck_mod_requested: Event[str] - clear_checked_requested: Event[None] - active_refresh_requested: Event[list[str]] - - _svc: SteamDownloader - - def __init__(self) -> None: - self.badges_refresh_requested = Event() - self.sidebar_sync_requested = Event() - self.item_status_changed = Event() - self.download_busy_changed = Event() - self.uncheck_mod_requested = Event() - self.clear_checked_requested = Event() - self.active_refresh_requested = Event() - - self._core: CoreContext | None = None - self._app_ctx: AppContext | None = None - - self._installed_ids: set[str] = set() - self._active_ids: set[str] = set() - self._checked_ids: dict[str, str] = {} - self._download_statuses: dict[str, str] = {} - # The service is shared with MainWindow's update actions; only batches - # started from this view may drive the queue sidebar. - self._own_download = False - - # ── Plugin lifecycle ───────────────────────────────── - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - self._core = ctx.core - self._app_ctx = ctx - self._svc = cast("SteamDownloader", ctx.core.plugins.get("steam_downloader")) - from pxmodrim.ui.plugins.steam_workshop.view import SteamWorkshopViewPanel - - ctx.add_rail_view(SteamWorkshopViewPanel) - - ctx.core.mod_service.mods_changed.connect(self._on_mods_changed) - ctx.core.active_state_changed.connect(self._on_active_state_changed) - self._svc.download_item_status_changed.connect(self._on_item_status) - self._svc.download_finished.connect(self._on_download_finished) - self._svc.busy_changed.connect(self.download_busy_changed.emit) - - async def init(self, ctx: AppContext) -> None: - self._refresh_cached_ids() - - async def shutdown(self) -> None: - if self._core is None: - return - with contextlib.suppress(ValueError): - self._svc.download_item_status_changed.disconnect(self._on_item_status) - self._svc.download_finished.disconnect(self._on_download_finished) - self._svc.busy_changed.disconnect(self.download_busy_changed.emit) - self._core.mod_service.mods_changed.disconnect(self._on_mods_changed) - self._core.active_state_changed.disconnect( - self._on_active_state_changed - ) - - # ── JS action handlers (called by action_handler) ──── - - def toggle_download_checked(self, mod_id: str, title: str, checked: bool) -> None: - if checked: - self._checked_ids[mod_id] = title - else: - self._checked_ids.pop(mod_id, None) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - def batch_toggle_download_checked( - self, mod_ids: list[str], titles: list[str], checked: bool - ) -> None: - for mod_id, title in zip(mod_ids, titles, strict=True): - if checked: - self._checked_ids[mod_id] = title - else: - self._checked_ids.pop(mod_id, None) - if mod_ids: - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - def toggle_active(self, mod_id: str, active: bool) -> None: - if self._core is None: - return - uuid = next( - ( - u - for u, m in self._core.all_mods.items() - if m.published_file_id == mod_id - ), - None, - ) - if uuid is None: - return - current = set(self._core.active_uuids) - if active: - current.add(uuid) - else: - current.discard(uuid) - self._core.set_active(list(current)) - - def batch_toggle_active(self, mod_ids: list[str], active: bool) -> None: - if self._core is None: - return - pub_to_uuid = { - m.published_file_id: u - for u, m in self._core.all_mods.items() - if m.published_file_id - } - current = set(self._core.active_uuids) - for mid in mod_ids: - uuid = pub_to_uuid.get(mid) - if uuid is None: - continue - if active: - current.add(uuid) - else: - current.discard(uuid) - self._core.set_active(list(current)) - - # ── Sidebar callbacks (called by view) ─────────────── - - async def request_download(self) -> None: - ids = list(self._checked_ids.keys()) - if not ids: - logger.debug("[steam] download requested with empty queue") - return - if self._core is None or not self._core.config.paths.local: - self._svc.status_message_changed.emit("Local mods path is not configured") - return - if self._svc.is_downloading: - self._svc.status_message_changed.emit("A download is already running") - return - logger.debug("[steam] download requested: {}", ids) - - self._own_download = True - if self._app_ctx is not None: - self._app_ctx.navigate(build_route("downloads")) - try: - for mod_id in ids: - self._download_statuses[mod_id] = "queued" - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - await self._svc.download_mods(ids) - finally: - self._own_download = False - - def stop_download(self) -> None: - logger.info("[steam] download stop requested") - self._svc.cancel() - - def remove_item(self, mod_id: str) -> None: - logger.debug("[steam] download item removed: %s", mod_id) - self._checked_ids.pop(mod_id, None) - self._download_statuses.pop(mod_id, None) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - self.uncheck_mod_requested.emit(mod_id) - - def clear_queue(self) -> None: - logger.info("[steam] download queue cleared (%d items)", len(self._checked_ids)) - self._checked_ids.clear() - self._download_statuses.clear() - self.sidebar_sync_requested.emit(SidebarSync({}, {})) - self.clear_checked_requested.emit(None) - - def sync_all(self) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - # ── Service event handlers ─────────────────────────── - - def _on_active_state_changed(self, _: tuple[str, ...]) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - - def _on_mods_changed(self, _: None) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - - def _on_item_status(self, item: DownloadItemStatus) -> None: - if not self._own_download: - return - self._download_statuses[item.mod_id] = item.status - self.item_status_changed.emit( - ItemStatus(item.mod_id, item.status, item.bytes_done, item.bytes_total) - ) - - def _on_download_finished(self, result: DownloadResult) -> None: - if not self._own_download: - return - logger.info( - "[steam] download finished: {} ok, {} failed", - len(result.succeeded), - len(result.failed), - ) - for mid in result.succeeded: - self._checked_ids.pop(mid, None) - self._download_statuses.pop(mid, None) - for mod_id, status in tuple(self._download_statuses.items()): - if status in {"queued", "downloading"}: - self._download_statuses.pop(mod_id) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - # ── Internal helpers ───────────────────────────────── - - def _refresh_cached_ids(self) -> None: - if self._core is None: - return - self._installed_ids = { - m.published_file_id - for m in self._core.all_mods.values() - if m.published_file_id - } - active_uuids = set(self._core.active_uuids) - self._active_ids = { - m.published_file_id - for m in self._core.all_mods.values() - if m.published_file_id and m.uuid in active_uuids - } - - async def fetch_mod_deps(self, mod_id: str) -> str | None: - url = f"https://deps.modrim.pyxiion.ru/deps?id={mod_id}" - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.get(url, headers={"User-Agent": "PxModRim/1.0"}) - if resp.status_code != 200: - logger.warning( - "[steam] fetch_mod_deps HTTP {} for {}", - resp.status_code, - mod_id, - ) - return None - return resp.text diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts deleted file mode 100644 index 7b04244..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/api.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { DepItemsResult } from "./types"; -import { PxModRimRPC } from "./bridge"; - -let _rpc: PxModRimRPC | null = null; - -export function initAPI(rpc: PxModRimRPC): void { - _rpc = rpc; -} - -export namespace PxModRimAPI { - export async function initReady(): Promise { - await _rpc!.call("init_ready"); - } - - export async function toggleDownloadChecked( - modId: string, - title: string, - checked: boolean, - ): Promise { - await _rpc!.call("toggle_download_checked", { - mod_id: modId, - title, - checked, - }); - } - - export async function batchToggleDownloadChecked( - modIds: string[], - titles: string[], - checked: boolean, - ): Promise { - await _rpc!.call("batch_toggle_download_checked", { - mod_ids: modIds, - titles, - checked, - }); - } - - export async function toggleActive( - modId: string, - active: boolean, - ): Promise { - await _rpc!.call("toggle_active", { - mod_id: modId, - active, - }); - } - - export async function batchToggleActive( - modIds: string[], - active: boolean, - ): Promise { - await _rpc!.call("batch_toggle_active", { - mod_ids: modIds, - active, - }); - } - - export async function fetchModDeps( - modId: string, - ): Promise { - const r = await _rpc!.call("fetch_mod_deps", { mod_id: modId }); - if (!r.ok) { - console.error("[pxmodrim] fetchModDeps failed:", r.error); - return null; - } - return r.result ?? null; - } -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts deleted file mode 100644 index 50919c1..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ModState } from "./types"; -import { log, getModId, getModTitle, findItemCard } from "./utils"; -import { getModState, applyModState, handleClick } from "./controls"; -import { isBridgeDataReady } from "./bridge"; -import { isPageKind } from "./lifecycle"; - -let _badgeTimer: ReturnType | null = null; -const BADGE_DEBOUNCE_MS = 300; - -export function updateModBadge(modId: string): void { - if (!isPageKind("grid")) return; - const state = getModState(modId); - - const link = document.querySelector( - `a[href*="sharedfiles/filedetails/?id=${modId}"]`, - ); - if (!link) return; - - const tile = findItemCard(link); - if (!tile) return; - - let badge = tile.querySelector(".rimsort-modstatus-badge"); - if (!badge) { - badge = document.createElement("div"); - badge.className = "rimsort-modstatus-badge"; - if (getComputedStyle(tile).position === "static") { - (tile as HTMLElement).style.position = "relative"; - } - tile.classList.add("rimsort-tile"); - tile.appendChild(badge); - - badge.addEventListener("click", async (e) => { - e.stopPropagation(); - e.preventDefault(); - await handleClick(badge!, modId, () => getModTitle(tile)); - updateAllModBadges(); - }); - } - - applyModState(badge, state); -} - -export function updateAllModBadges(): void { - log("updateAllModBadges ENTER"); - const links = document.querySelectorAll( - 'a[href*="sharedfiles/filedetails/?id="]', - ); - let badged = 0; - links.forEach((link) => { - const modId = getModId(link); - if (!modId) return; - updateModBadge(modId); - badged++; - }); - log(`Badges updated: ${badged}`); -} - -export function scheduleBadgeUpdate(): void { - if (_badgeTimer !== null) return; - if (!isPageKind("grid")) return; - _badgeTimer = setTimeout(() => { - _badgeTimer = null; - if (isBridgeDataReady()) updateAllModBadges(); - }, BADGE_DEBOUNCE_MS); -} - -export function refreshAllDepsBadges(): void { - document.querySelectorAll(".rimsort-dep-badge").forEach((badge) => { - const el = badge as HTMLElement; - const modId = el.dataset.modid; - if (!modId) return; - applyModState(el, getModState(modId)); - }); -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts deleted file mode 100644 index dce12ee..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts +++ /dev/null @@ -1,138 +0,0 @@ -import "./qwebchannel.js"; -import { getModState, applyModState } from "./controls"; -import { refreshAllDepsBadges, updateAllModBadges } from "./badges"; -import { log } from "./utils"; - -// Ensure __pxmodrim exists (modules may initialize before main.ts guard) -if (!window.__pxmodrim) { - window.__pxmodrim = { - installedIds: new Set(), - checkedIds: new Set(), - activeIds: new Set(), - onStateChange: null, - }; -} - -let _bridgeDataReady = false; -let _bridgeDataResolve: (() => void) | null = null; -let _bridgeDataReject: ((reason: Error) => void) | null = null; -let _bridgeDataPromise: Promise | null = null; - -export function isBridgeDataReady(): boolean { - return _bridgeDataReady; -} - -export function setBridgeDataReady(): void { - _bridgeDataReady = true; - _bridgeDataResolve?.(); -} - -export function waitForBridgeData(timeoutMs = 15000): Promise { - if (!_bridgeDataPromise) { - _bridgeDataPromise = new Promise((resolve, reject) => { - _bridgeDataResolve = resolve; - _bridgeDataReject = reject; - setTimeout(() => { - _bridgeDataPromise = null; - _bridgeDataResolve = null; - _bridgeDataReject = null; - reject(new Error("Bridge data timeout")); - }, timeoutMs); - }); - } - if (_bridgeDataReady) { - _bridgeDataResolve?.(); - } - return _bridgeDataPromise; -} - -// ── QWebChannel RPC ──────────────────────────────────── - -export class PxModRimRPC { - private bridge: any; - private pending = new Map) => void>(); - - constructor(channel: any) { - this.bridge = channel.objects.bridge; - this.bridge.result_ready.connect((id: string, json: string) => { - const resolve = this.pending.get(id); - if (resolve) { - this.pending.delete(id); - resolve(JSON.parse(json)); - } - }); - } - - call( - method: string, - payload: Record = {}, - ): Promise> { - return new Promise(resolve => { - const id = crypto.randomUUID(); - this.pending.set(id, resolve); - this.bridge.call(id, method, JSON.stringify(payload)); - }); - } -} - -export function initRPC(): Promise { - return new Promise(resolve => { - const qt = (window as any).qt; - if (qt && qt.webChannelTransport) { - new (window as any).QWebChannel( - qt.webChannelTransport, - (channel: any) => { - resolve(new PxModRimRPC(channel)); - }, - ); - } else { - setTimeout(() => initRPC().then(resolve), 100); - } - }); -} - -// ── Python integration points ───────────────────────────────────── - -export function __pxmSetInstalled(modIds: string[]): void { - window.__pxmodrim.installedIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.installedIds.add(id)); - if (_bridgeDataReady) { - updateAllModBadges(); - refreshAllDepsBadges(); - window.__pxmodrim.onStateChange?.(); - } -} - -export function __pxmUncheckMod(modId: string): void { - window.__pxmodrim.checkedIds.delete(modId); - window.updateModBadge(modId); - refreshAllDepsBadges(); - window.__pxmodrim.onStateChange?.(); -} - -export function __pxmClearChecked(): void { - window.__pxmodrim.checkedIds.clear(); - updateAllModBadges(); - refreshAllDepsBadges(); - window.__pxmodrim.onStateChange?.(); -} - -export function __pxmSetActive(modIds: string[]): void { - window.__pxmodrim.activeIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.activeIds.add(id)); - if (_bridgeDataReady) { - updateAllModBadges(); - refreshAllDepsBadges(); - window.__pxmodrim.onStateChange?.(); - } -} - -// Render detail page button on state changes from Python -window.__pxmodrim.onStateChange = function () { - const modId = (window.location.href.match(/[?&]id=(\d+)/) || [])[1]; - const btn = document.getElementById("pxmodrim-subscribe-btn"); - if (btn && modId) { - applyModState(btn, getModState(modId)); - } - refreshAllDepsBadges(); -}; diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts deleted file mode 100644 index f847ef1..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { ModState, type ModStatus } from "./types"; -import { log, warn, getModId } from "./utils"; -import { getModState, applyModState, handleClick } from "./controls"; -import { PxModRimAPI } from "./api"; - -function scrapeChildIds(): string[] { - const ids: string[] = []; - document.querySelectorAll('[id^="sharedfile_"]').forEach((el) => { - const id = el.id.replace("sharedfile_", ""); - if (id) ids.push(id); - }); - return ids; -} - -function getCollectionState(): ModStatus { - const ids = scrapeChildIds(); - if (!ids.length) return ModState.DEFAULT; - - const installed = window.__pxmodrim.installedIds; - const checked = window.__pxmodrim.checkedIds; - const active = window.__pxmodrim.activeIds; - - if (ids.some((id) => checked.has(id))) return ModState.CHECKED; - if (ids.some((id) => !installed.has(id) && !checked.has(id))) return ModState.DEFAULT; - if (ids.every((id) => active.has(id))) return ModState.ACTIVE; - return ModState.INACTIVE; -} - -function addBatchBar(childrenEl: Element): void { - const existing = document.querySelector(".rimsort-collection-bar"); - if (existing) return; - - const bar = document.createElement("div"); - bar.className = "rimsort-collection-bar"; - - let resolving = false; - - const btn = document.createElement("button"); - btn.className = "rimsort-detail-btn"; - btn.dataset.rimsortVariant = "detail"; - - btn.addEventListener("click", async () => { - if (resolving) return; - resolving = true; - - const state = getCollectionState(); - - btn.classList.add("pressed"); - requestAnimationFrame(() => btn.classList.remove("pressed")); - - if (state === ModState.DEFAULT) { - applyModState(btn, ModState.RESOLVING); - } - - try { - switch (state) { - case ModState.DEFAULT: { - const collectionId = new URL(window.location.href).searchParams.get("id"); - if (!collectionId) return; - - const result = await PxModRimAPI.fetchModDeps(collectionId); - if (!result?.items) return; - - const idsToQueue = new Set(); - const titles: Record = {}; - const installed = window.__pxmodrim.installedIds; - const checked = window.__pxmodrim.checkedIds; - - for (const item of Object.values(result.items)) { - if (!installed.has(item.id) && !checked.has(item.id)) { - idsToQueue.add(item.id); - titles[item.id] = item.title || item.id; - } - for (const depId of item.deps || []) { - if (!installed.has(depId) && !checked.has(depId)) { - idsToQueue.add(depId); - titles[depId] = result.items[depId]?.title || depId; - } - } - } - - const queueList = [...idsToQueue]; - if (queueList.length) { - for (const id of queueList) window.__pxmodrim.checkedIds.add(id); - await PxModRimAPI.batchToggleDownloadChecked( - queueList, - queueList.map((id) => titles[id] || id), - true, - ); - } - break; - } - case ModState.CHECKED: { - const checkedIds = scrapeChildIds().filter((id) => - window.__pxmodrim.checkedIds.has(id), - ); - if (checkedIds.length) { - for (const id of checkedIds) window.__pxmodrim.checkedIds.delete(id); - await PxModRimAPI.batchToggleDownloadChecked( - checkedIds, - checkedIds.map(() => ""), - false, - ); - } - break; - } - case ModState.INACTIVE: { - const toActivate = scrapeChildIds().filter( - (id) => - window.__pxmodrim.installedIds.has(id) && - !window.__pxmodrim.activeIds.has(id), - ); - if (toActivate.length) { - for (const id of toActivate) window.__pxmodrim.activeIds.add(id); - await PxModRimAPI.batchToggleActive(toActivate, true); - } - break; - } - case ModState.ACTIVE: { - const toDeactivate = scrapeChildIds().filter((id) => - window.__pxmodrim.activeIds.has(id), - ); - if (toDeactivate.length) { - for (const id of toDeactivate) window.__pxmodrim.activeIds.delete(id); - await PxModRimAPI.batchToggleActive(toDeactivate, false); - } - break; - } - } - } catch (e) { - warn("Collection batch action failed", e); - } finally { - resolving = false; - updateCollectionUI(); - } - }); - - const onlyBtn = document.createElement("button"); - onlyBtn.className = "rimsort-only-btn"; - onlyBtn.textContent = "Activate only this pack"; - onlyBtn.title = "Activate this modpack + deps, deactivate everything else"; - - onlyBtn.addEventListener("click", async () => { - if (resolving) return; - resolving = true; - - const hasInstalled = scrapeChildIds().some((id) => - window.__pxmodrim.installedIds.has(id), - ); - if (!hasInstalled) { - resolving = false; - return; - } - - onlyBtn.disabled = true; - onlyBtn.textContent = "Resolving..."; - - try { - const collectionId = new URL(window.location.href).searchParams.get("id"); - if (!collectionId) return; - - const result = await PxModRimAPI.fetchModDeps(collectionId); - if (!result?.items) return; - - const packIds = new Set(); - for (const item of Object.values(result.items)) { - packIds.add(item.id); - for (const depId of item.deps || []) packIds.add(depId); - } - - const installedPack = [...packIds].filter((id) => - window.__pxmodrim.installedIds.has(id), - ); - - const toDeactivate = [...window.__pxmodrim.activeIds].filter( - (id) => !installedPack.includes(id), - ); - - onlyBtn.textContent = "Applying..."; - - if (toDeactivate.length) { - for (const id of toDeactivate) window.__pxmodrim.activeIds.delete(id); - await PxModRimAPI.batchToggleActive(toDeactivate, false); - } - - const needActivate = installedPack.filter( - (id) => !window.__pxmodrim.activeIds.has(id), - ); - if (needActivate.length) { - for (const id of needActivate) window.__pxmodrim.activeIds.add(id); - await PxModRimAPI.batchToggleActive(needActivate, true); - } - } catch (e) { - warn("Activate-only action failed", e); - } finally { - resolving = false; - updateCollectionUI(); - onlyBtn.disabled = false; - onlyBtn.textContent = "Activate only this pack"; - } - }); - - bar.appendChild(btn); - bar.appendChild(onlyBtn); - updateCollectionUI(); - childrenEl.before(bar); -} - -function updateCollectionButton(btn: HTMLElement, state: ModStatus): void { - applyModState(btn, state); - switch (state) { - case ModState.ACTIVE: - btn.textContent = "✓ Deactivate all"; - btn.title = "Deactivate all active mods in this collection"; - break; - case ModState.INACTIVE: - btn.textContent = "⊗ Activate all"; - btn.title = "Activate all inactive mods in this collection"; - break; - } -} - -function updateCollectionUI(): void { - document.querySelectorAll(".collectionItem .rimsort-modstatus-badge").forEach((badge) => { - const modId = (badge as HTMLElement).dataset.modid; - if (modId) applyModState(badge as HTMLElement, getModState(modId)); - }); - const btn = document.querySelector(".rimsort-collection-bar .rimsort-detail-btn"); - if (btn) updateCollectionButton(btn, getCollectionState()); -} - -function addChildBadges(childrenEl: Element): void { - const items = childrenEl.querySelectorAll(".collectionItem"); - items.forEach((item) => { - const link = item.querySelector( - 'a[href*="sharedfiles/filedetails/?id="]', - ); - if (!link) return; - const modId = getModId(link); - if (!modId) return; - - const state = getModState(modId); - - let badge = item.querySelector(".rimsort-modstatus-badge"); - if (!badge) { - badge = document.createElement("div"); - badge.className = "rimsort-modstatus-badge"; - badge.dataset.modid = modId; - const titleEl = item.querySelector(".workshopItemTitle"); - badge.addEventListener("click", async (e) => { - e.stopPropagation(); - e.preventDefault(); - await handleClick(badge!, modId, () => titleEl?.textContent?.trim() || ""); - }); - item.appendChild(badge); - } - applyModState(badge, state); - }); -} - -export function initCollectionPage(): void { - log("initCollectionPage ENTER"); - const container = document.querySelector(".collectionChildren"); - if (!container) return; - addBatchBar(container); - addChildBadges(container); - log("initCollectionPage DONE"); -} - -export function updateCollectionBadges(): void { - const container = document.querySelector(".collectionChildren"); - if (!container) return; - addChildBadges(container); - updateCollectionUI(); -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts deleted file mode 100644 index d0a0a97..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ModState, type ModStatus } from "./types"; -import { log, warn } from "./utils"; -import { getDepsFor, flattenDepTree } from "./deps"; -import { PxModRimAPI } from "./api"; - -export function getModState(modId: string): ModStatus { - if (window.__pxmodrim.activeIds.has(modId)) return ModState.ACTIVE; - if (window.__pxmodrim.installedIds.has(modId)) return ModState.INACTIVE; - if (window.__pxmodrim.checkedIds.has(modId)) return ModState.CHECKED; - return ModState.DEFAULT; -} - -export function applyModState(el: HTMLElement, state: ModStatus): void { - if (el.dataset.state === state && state !== ModState.RESOLVING) return; - el.className = el.className - .split(" ") - .filter((c) => !c.startsWith("rimsort-mod-")) - .join(" "); - el.classList.add(`rimsort-mod-${state}`); - el.dataset.state = state; - el.style.pointerEvents = ""; - - if (state === ModState.RESOLVING) { - el.title = "Resolving dependencies\u2026"; - el.innerHTML = - '
    '; - el.style.pointerEvents = "none"; - return; - } - - if (el.dataset.rimsortVariant === "detail") { - switch (state) { - case ModState.ACTIVE: - el.title = "Active \u2014 click to deactivate"; - el.textContent = "\u2713 Active"; - break; - case ModState.INACTIVE: - el.title = "Inactive \u2014 click to activate"; - el.textContent = "\u2298 Inactive"; - break; - case ModState.CHECKED: - el.title = "In download queue \u2014 click to remove"; - el.textContent = "\u2713 In Queue"; - break; - default: - el.title = el.dataset.rimsortTitle ?? "Add to download queue"; - el.textContent = el.dataset.rimsortLabel || "Add to Queue"; - break; - } - } else { - switch (state) { - case ModState.ACTIVE: - el.title = "Active \u2014 click to deactivate"; - el.textContent = "\u2713"; - break; - case ModState.INACTIVE: - el.title = "Inactive \u2014 click to activate"; - el.textContent = "\u2298"; - break; - case ModState.CHECKED: - el.title = "In download queue \u2014 click to remove"; - el.textContent = "\u2212"; - break; - default: - el.title = "Add to download queue"; - el.textContent = "+"; - break; - } - } -} - -export async function performModAction( - modId: string, - getTitle: () => string, - opts?: { skipDeps?: boolean }, -): Promise { - const state = getModState(modId); - - switch (state) { - case ModState.ACTIVE: - window.__pxmodrim.activeIds.delete(modId); - try { - await PxModRimAPI.toggleActive(modId, false); - } catch (e) { - window.__pxmodrim.activeIds.add(modId); - throw e; - } - break; - case ModState.INACTIVE: - window.__pxmodrim.activeIds.add(modId); - try { - await PxModRimAPI.toggleActive(modId, true); - } catch (e) { - window.__pxmodrim.activeIds.delete(modId); - throw e; - } - break; - case ModState.CHECKED: - window.__pxmodrim.checkedIds.delete(modId); - try { - await PxModRimAPI.toggleDownloadChecked(modId, "", false); - } catch (e) { - window.__pxmodrim.checkedIds.add(modId); - throw e; - } - break; - case ModState.DEFAULT: { - const title = getTitle(); - const addedChecked = new Set(); - addedChecked.add(modId); - window.__pxmodrim.checkedIds.add(modId); - try { - if (opts?.skipDeps) { - await PxModRimAPI.toggleDownloadChecked(modId, title, true); - } else { - const toggles: { id: string; title: string }[] = [{ id: modId, title }]; - const tree = await getDepsFor(modId); - if (tree) { - for (const dep of flattenDepTree(tree, new Set([modId]))) { - addedChecked.add(dep.id); - window.__pxmodrim.checkedIds.add(dep.id); - toggles.push(dep); - } - } else { - warn(`Failed to resolve deps for ${modId}`); - } - await PxModRimAPI.batchToggleDownloadChecked( - toggles.map((m) => m.id), - toggles.map((m) => m.title), - true, - ); - } - } catch (e) { - for (const id of addedChecked) window.__pxmodrim.checkedIds.delete(id); - throw e; - } - break; - } - case ModState.RESOLVING: - break; - } -} - -export async function handleClick( - el: HTMLElement, - modId: string, - getTitle: () => string, - opts?: { skipDeps?: boolean }, -): Promise { - const state = getModState(modId); - if (state === ModState.RESOLVING) return; - - el.classList.add("pressed"); - requestAnimationFrame(() => el.classList.remove("pressed")); - - if (state === ModState.DEFAULT) { - applyModState(el, ModState.RESOLVING); - } - - try { - await performModAction(modId, getTitle, opts); - } finally { - applyModState(el, getModState(modId)); - } -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts deleted file mode 100644 index 37a30e4..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { DepNode, DepItemsResult } from "./types"; -import { Config } from "./types"; -import { log, warn, parseHTML, scrapeDepsFromContainer } from "./utils"; -import { PxModRimAPI } from "./api"; - -export const DepState: { - cache: Map; - fetching: Set; - aborters: Map; -} = { - cache: new Map(), - fetching: new Set(), - aborters: new Map(), -}; - -const _resolveTreeLocks = new Set(); -const _fetchResolvers = new Map void>>(); - -function convertItemsToTree( - items: Record, - rootId: string, - maxDepth = Config.DEPTH_MAX, -): DepNode { - function buildNode( - id: string, - depth: number, - seen: Set, - ): DepNode { - if (seen.has(id)) { - return { id, title: items[id]?.title || `Mod ${id}`, deps: [] }; - } - if (depth >= maxDepth) { - const item = items[id]; - if (!item) return { id, title: `Mod ${id}`, deps: [] }; - return { id: item.id, title: item.title || `Mod ${id}`, deps: [] }; - } - seen.add(id); - const item = items[id]; - if (!item) return { id, title: `Mod ${id}`, deps: [] }; - return { - id: item.id, - title: item.title || `Mod ${id}`, - deps: (item.deps || []).map((depId) => - buildNode(depId, depth + 1, new Set(seen)), - ), - }; - } - return buildNode(rootId, 0, new Set()); -} - -const apiStrategy = { - name: "api", - async fetch(modId: string): Promise { - try { - const result = await PxModRimAPI.fetchModDeps(modId); - if (!result || !result.items || !result.rootId) { - warn(`apiStrategy: invalid response for ${modId}`, result); - return null; - } - if (!result.isComplete) { - warn(`apiStrategy: incomplete for ${modId}`); - return null; - } - log(`apiStrategy: complete for ${modId}`); - return convertItemsToTree(result.items, result.rootId); - } catch (e) { - warn(`apiStrategy: failed for ${modId}:`, e); - return null; - } - }, -}; - -const domStrategy = { - name: "dom", - async fetch(modId: string): Promise { - log(`domStrategy: building tree for ${modId}`); - return await buildDomDepTree(modId, 0, new Set()); - }, -}; - -async function buildDomDepTree( - modId: string, - depth: number, - seen: Set, -): Promise { - if (depth >= Config.DEPTH_MAX) { - log(`buildDomDepTree depth limit for ${modId}`); - return null; - } - if (seen.has(modId)) { - log(`buildDomDepTree circular for ${modId}`); - return null; - } - seen.add(modId); - - let tree = DepState.cache.get(modId); - if (tree) { - log(`buildDomDepTree cache hit for ${modId}`); - return tree; - } - - if (DepState.fetching.has(modId)) { - log(`buildDomDepTree waiting for ${modId}`); - await new Promise((resolve) => { - const resolvers = _fetchResolvers.get(modId) || []; - resolvers.push(resolve); - _fetchResolvers.set(modId, resolvers); - }); - tree = DepState.cache.get(modId); - if (tree) return tree; - } - - DepState.fetching.add(modId); - const controller = new AbortController(); - DepState.aborters.set(modId, controller); - try { - const resp = await fetch( - `https://steamcommunity.com/sharedfiles/filedetails/?id=${modId}`, - { signal: controller.signal }, - ); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const html = await resp.text(); - const doc = parseHTML(html); - - const titleEl = doc.querySelector(".workshopItemTitle"); - const title = titleEl - ? titleEl.textContent?.trim() ?? `Mod ${modId}` - : `Mod ${modId}`; - - const container = doc.getElementById("RequiredItems"); - const deps = container ? scrapeDepsFromContainer(container) : []; - - tree = { id: modId, title, deps: [] }; - for (const dep of deps) { - const child = await buildDomDepTree(dep.id, depth + 1, new Set(seen)); - if (child) tree.deps.push(child); - else tree.deps.push({ id: dep.id, title: dep.title, deps: [] }); - } - - DepState.cache.set(modId, tree); - return tree; - } catch (e) { - if ((e as Error).name === "AbortError") return null; - warn(`DOM dep fetch failed for ${modId}:`, e); - return null; - } finally { - DepState.fetching.delete(modId); - DepState.aborters.delete(modId); - const resolvers = _fetchResolvers.get(modId); - if (resolvers) { - _fetchResolvers.delete(modId); - resolvers.forEach((r) => r()); - } - } -} - -export async function getDepsFor(modId: string): Promise { - const cached = DepState.cache.get(modId); - if (cached) { - log(`getDepsFor cache hit for ${modId}`); - return cached; - } - - if (_resolveTreeLocks.has(modId)) { - log(`getDepsFor waiting for concurrent fetch of ${modId}`); - while (_resolveTreeLocks.has(modId)) { - await new Promise((r) => setTimeout(r, 50)); - } - return DepState.cache.get(modId) || null; - } - - _resolveTreeLocks.add(modId); - try { - for (const strategy of [apiStrategy, domStrategy]) { - try { - log(`getDepsFor trying ${strategy.name} for ${modId}`); - const tree = await strategy.fetch(modId); - if (tree) { - log(`getDepsFor ${strategy.name} succeeded for ${modId}`); - DepState.cache.set(modId, tree); - return tree; - } - log(`getDepsFor ${strategy.name} returned null for ${modId}`); - } catch (e) { - warn(`${strategy.name} strategy failed for ${modId}:`, e); - } - } - warn(`getDepsFor all strategies failed for ${modId}`); - return null; - } finally { - _resolveTreeLocks.delete(modId); - } -} - -export function flattenDepTree( - node: DepNode, - seen: Set, -): { id: string; title: string }[] { - const result: { id: string; title: string }[] = []; - if (!node.deps) return result; - for (const dep of node.deps) { - if (seen.has(dep.id)) continue; - if (window.__pxmodrim.installedIds.has(dep.id)) continue; - if (window.__pxmodrim.checkedIds.has(dep.id)) continue; - seen.add(dep.id); - result.push({ id: dep.id, title: dep.title }); - result.push(...flattenDepTree(dep, new Set(seen))); - } - return result; -} - -export function cancelPendingDepFetches(): void { - DepState.aborters.forEach((controller) => controller.abort()); - DepState.aborters.clear(); - DepState.fetching.clear(); -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts deleted file mode 100644 index b5e0766..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { ModState, Config } from "./types"; -import { log, warn } from "./utils"; -import { getModState, applyModState, handleClick, performModAction } from "./controls"; -import { getDepsFor } from "./deps"; -import { refreshAllDepsBadges } from "./badges"; -import { - waitForBridgeData, - isBridgeDataReady, -} from "./bridge"; -import { buildDepSection } from "./rendering"; - -export const DetailState: { - injectedUrl: string | null; - depsInjected: boolean; -} = { - injectedUrl: null, - depsInjected: false, -}; - -let _depsLoading = false; - -function updateSoloLinkVisibility(): void { - const soloLink = document.getElementById(Config.DEP_SOLO_LINK_ID); - const requiredContainer = document.getElementById("RequiredItems"); - const btn = document.getElementById("pxmodrim-subscribe-btn"); - if (!soloLink) return; - const isInstalled = btn?.classList.contains("rimsort-mod-active") || btn?.classList.contains("rimsort-mod-inactive"); - const isChecked = btn?.classList.contains("rimsort-mod-checked"); - if (requiredContainer && !isInstalled && !isChecked) { - soloLink.classList.remove("hidden"); - } else { - soloLink.classList.add("hidden"); - } -} - -function finalizeButton( - btn: HTMLElement, - container: HTMLElement, - modId: string, - title: string, - hasDeps: boolean, -): void { - btn.id = "pxmodrim-subscribe-btn"; - btn.className = "rimsort-detail-btn"; - btn.style.textAlign = "center"; - - btn.dataset.rimsortVariant = "detail"; - btn.dataset.rimsortLabel = hasDeps ? "Add to Queue (with deps)" : "Add to Queue"; - btn.dataset.rimsortTitle = "Add to download queue"; - - function updateButtonVisuals(): void { - applyModState(btn, getModState(modId)); - } - - btn.addEventListener("click", function (e) { - e.preventDefault(); - e.stopPropagation(); - - (async () => { - await handleClick(btn, modId, () => title); - updateButtonVisuals(); - updateSoloLinkVisibility(); - })(); - }); - - updateButtonVisuals(); - - if (hasDeps) { - const soloLink = document.createElement("a"); - soloLink.id = Config.DEP_SOLO_LINK_ID; - soloLink.className = "rimsort-solo-link"; - soloLink.textContent = "Queue only this mod"; - soloLink.href = "#"; - soloLink.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - (async () => { - await performModAction(modId, () => title, { skipDeps: true }); - updateButtonVisuals(); - updateSoloLinkVisibility(); - })(); - }); - container.appendChild(soloLink); - updateSoloLinkVisibility(); - } -} - -function createLoadingPlaceholder( - loadingFactory: () => HTMLElement, - afterLoadedFactory: ( - loadingEl: HTMLElement, - container: HTMLElement, - ) => void, - event: () => Promise, -): HTMLElement { - const loadingEl = loadingFactory(); - const container = document.createElement("div"); - container.id = "pxmodrim-loading-container"; - container.style.display = "flex"; - container.style.flexDirection = "column"; - container.style.gap = "8px"; - container.appendChild(loadingEl); - - event().then( - () => { - if (!document.contains(container)) return; - afterLoadedFactory(loadingEl, container); - }, - () => { - if (!document.contains(container)) return; - warn("Bridge data timeout \u2014 showing error state"); - loadingEl.className = "rimsort-error-btn"; - loadingEl.textContent = "ERROR"; - }, - ); - - return container; -} - -export async function createDetailButton(): Promise { - if ((createDetailButton as Record)._running) return; - (createDetailButton as Record)._running = true; - try { - log("createDetailButton ENTER url=" + window.location.href); - const currentUrl = window.location.href; - if (DetailState.injectedUrl !== currentUrl) { - DetailState.injectedUrl = currentUrl; - DetailState.depsInjected = false; - document.getElementById("pxmodrim-subscribe-btn")?.remove(); - document.getElementById(Config.DEP_SECTION_ID)?.remove(); - document.getElementById("pxmodrim-loading-container")?.remove(); - } - - let btn = document.getElementById( - "pxmodrim-subscribe-btn", - ) as HTMLAnchorElement | null; - let container: HTMLElement | null = null; - - if (btn) { - const modId = ( - window.location.href.match(/[?&]id=(\d+)/) || [] - )[1]; - if (modId) { - applyModState(btn, getModState(modId)); - } - updateSoloLinkVisibility(); - container = - btn.closest("div[style*='flex']") || btn.parentElement; - } else if (document.getElementById("pxmodrim-loading-container")) { - return; - } else { - const steamBtn = document.getElementById("SubscribeItemBtn"); - if (!steamBtn) return; - - const modId = ( - window.location.href.match(/[?&]id=(\d+)/) || [] - )[1]; - if (!modId) return; - - const h1 = document.querySelector( - ".game_area_purchase_game h1", - ); - const title = h1 - ? h1.textContent - ?.replace(/Subscribe to download\s*/i, "") - .trim() || "" - : ""; - const hasDeps = !!document.getElementById("RequiredItems"); - - if (isBridgeDataReady()) { - container = document.createElement("div"); - container.style.display = "flex"; - container.style.flexDirection = "column"; - container.style.gap = "8px"; - btn = document.createElement("a"); - container.appendChild(btn); - finalizeButton(btn, container, modId, title, hasDeps); - steamBtn.replaceWith(container); - } else { - container = createLoadingPlaceholder( - () => { - const el = document.createElement("a"); - el.className = "rimsort-loading-btn"; - el.textContent = "Loading..."; - return el; - }, - (loadingEl: HTMLElement, ctr: HTMLElement) => { - finalizeButton(loadingEl, ctr, modId, title, hasDeps); - createDetailButton(); - }, - () => waitForBridgeData(10000), - ); - steamBtn.replaceWith(container); - return; - } - } - - if (!document.getElementById("RequiredItems")) return; - - const modId = ( - window.location.href.match(/[?&]id=(\d+)/) || [] - )[1]; - if (!modId) return; - - if (document.getElementById(Config.DEP_SECTION_ID)) { - refreshAllDepsBadges(); - updateSoloLinkVisibility(); - return; - } - - if (_depsLoading) return; - _depsLoading = true; - const loading = document.createElement("div"); - loading.className = "rimsort-dep-loading-container"; - loading.innerHTML = - '
    Resolving dependencies...'; - container?.insertAdjacentElement("afterend", loading); - - try { - const tree = await getDepsFor(modId); - - loading.remove(); - const section = buildDepSection(tree); - if (section && container) { - container.insertAdjacentElement("afterend", section); - DetailState.depsInjected = true; - } - updateSoloLinkVisibility(); - } finally { - _depsLoading = false; - } - } finally { - (createDetailButton as Record)._running = false; - } -} - - diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts deleted file mode 100644 index f292860..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare const CSS_STYLES: string; -declare const qt: { webChannelTransport: any }; -declare class QWebChannel { - constructor(transport: any, initCallback: (channel: any) => void); -} - -interface PxmodrimState { - installedIds: Set; - checkedIds: Set; - activeIds: Set; - onStateChange: (() => void) | null; -} - -interface Window { - __pxmodrim: PxmodrimState; - __pxmodrimInited?: boolean; - QWebChannel: typeof QWebChannel; - updateModBadge(modId: string): void; - updateAllModBadges(): void; - __pxmSetInstalled(modIds: string[]): void; - __pxmSetActive(modIds: string[]): void; - __pxmUncheckMod(modId: string): void; - __pxmClearChecked(): void; -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts deleted file mode 100644 index cd73175..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Config } from "./types"; -import { log, findItemCard } from "./utils"; -import { - refreshAllDepsBadges, - scheduleBadgeUpdate, - updateAllModBadges, -} from "./badges"; -import { createDetailButton, DetailState } from "./detail"; -import { initCollectionPage, updateCollectionBadges } from "./collection"; -import { setBridgeDataReady } from "./bridge"; -import { PxModRimAPI } from "./api"; - -type PageKindKey = "collection" | "mod-details" | "grid"; - -interface PageKind { - init(): void; - onMutation(mutations: MutationRecord[]): void; -} - -let _pageKind: PageKindKey | null = null; - -function handleGridMutations(mutations: MutationRecord[]): void { - for (const m of mutations) { - for (const node of m.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - const el = node as Element; - const link = el.querySelector('a[href*="sharedfiles/filedetails/?id="]'); - if (!link || !el.querySelector("img")) continue; - if (!findItemCard(link)) continue; - scheduleBadgeUpdate(); - return; - } - } -} - -function handleCollectionMutations(mutations: MutationRecord[]): void { - for (const m of mutations) { - for (const node of m.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - const el = node as Element; - if (el.matches?.(".collectionItem") || el.querySelector?.(".collectionItem")) { - updateCollectionBadges(); - return; - } - } - } -} - -function handleModDetailMutations(mutations: MutationRecord[]): void { - const RELEVANT_IDS = new Set([ - "SubscribeItemBtn", - "RequiredItems", - "pxmodrim-subscribe-btn", - Config.DEP_SECTION_ID, - "pxmodrim-loading-container", - "pxmodrim-solo-link", - ]); - const hasRelevant = mutations.some((m) => { - if (m.type === "childList") { - for (const list of [m.addedNodes, m.removedNodes]) { - for (const node of list) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - const el = node as Element; - if (RELEVANT_IDS.has(el.id)) return true; - if (el.querySelector?.("[id]")) { - const nested = el.querySelector( - "#SubscribeItemBtn,#RequiredItems,#pxmodrim-subscribe-btn,#" + - CSS.escape(Config.DEP_SECTION_ID), - ); - if (nested) return true; - } - } - } - } - if (m.type === "attributes" && RELEVANT_IDS.has((m.target as Element).id)) return true; - return false; - }); - if (!hasRelevant) return; - - const currentUrl = window.location.href; - if (document.getElementById("SubscribeItemBtn") && !document.getElementById("pxmodrim-subscribe-btn")) { - createDetailButton(); - return; - } - if (DetailState.injectedUrl !== currentUrl) return; - if ( - document.getElementById("RequiredItems") && - !document.getElementById(Config.DEP_SECTION_ID) && - !DetailState.depsInjected - ) { - createDetailButton(); - } -} - -const PAGE_KINDS: Record = { - collection: { - init: initCollectionPage, - onMutation: handleCollectionMutations, - }, - "mod-details": { - init: createDetailButton, - onMutation: handleModDetailMutations, - }, - grid: { - init: updateAllModBadges, - onMutation: handleGridMutations, - }, -}; - -function resolveKind(mutations: MutationRecord[]): PageKindKey | null { - for (const m of mutations) { - for (const node of m.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - const el = node as Element; - if (el.matches?.(".collectionChildren") || el.querySelector?.(".collectionChildren")) { - return "collection"; - } - if (el.matches?.("#SubscribeItemBtn") || el.querySelector?.("#SubscribeItemBtn")) { - return "mod-details"; - } - } - } - return null; -} - -function activateKind(kind: PageKindKey): void { - _pageKind = kind; - PAGE_KINDS[kind].init(); -} - -function runDomCleanup() { - const footer = document.getElementById("footer"); - footer - ?.querySelectorAll("[style*='--grid-area']") - .forEach((el) => { - const area = el.style.getPropertyValue("--grid-area"); - if (area && area !== "main") el.remove(); - }); -} - -function injectStyles() { - const style = document.createElement("style"); - style.id = "pxmodrim-styles"; - style.textContent = CSS_STYLES; - document.documentElement.appendChild(style); -} - -function initObservers() { - log("initObservers START"); - const isSharedFile = window.location.href.startsWith( - "https://steamcommunity.com/sharedfiles/filedetails/?id=", - ); - if (!isSharedFile) { - activateKind("grid"); - log("initObservers grid page"); - } - - const mainObserver = new MutationObserver((mutations) => { - if (!_pageKind) { - const kind = resolveKind(mutations); - if (kind) { - activateKind(kind); - log("initObservers resolved pageKind=%s", kind); - } - } - if (_pageKind) PAGE_KINDS[_pageKind].onMutation(mutations); - runDomCleanup(); - }); - - mainObserver.observe(document.documentElement, { - childList: true, - subtree: true, - }); - log("initObservers OBSERVER ATTACHED"); - - runDomCleanup(); - - if (!_pageKind) { - if (document.querySelector(".collectionChildren")) { - activateKind("collection"); - log("initObservers immediate: collection"); - } else if (document.querySelector("#SubscribeItemBtn")) { - activateKind("mod-details"); - log("initObservers immediate: mod-details"); - } - } - - log("initObservers DONE pageKind=%s", _pageKind); -} - -function initState() { - log("initState ENTER"); - log( - "initState installed=" + - window.__pxmodrim.installedIds.size + - " checked=" + - window.__pxmodrim.checkedIds.size, - ); - - setBridgeDataReady(); - if (_pageKind) PAGE_KINDS[_pageKind].init(); - refreshAllDepsBadges(); - PxModRimAPI.initReady(); - log("initState DONE"); -} - -function waitForRoot() { - const rootWaiter = new MutationObserver(() => { - if (document.documentElement) { - rootWaiter.disconnect(); - startScript(); - } - }); - rootWaiter.observe(document, { childList: true }); -} - -function startScript() { - log("startScript ENTER"); - injectStyles(); - log("Base styles injected at Document Creation. Starting lifecycle..."); - initObservers(); - initState(); - log("startScript DONE"); -} - -export function isPageKind(kind: PageKindKey): boolean { - return _pageKind === kind; -} - -export { startScript, waitForRoot }; diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts deleted file mode 100644 index 2000136..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/main.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { startScript, waitForRoot } from "./lifecycle"; -import { - __pxmSetInstalled, - __pxmSetActive, - __pxmUncheckMod, - __pxmClearChecked, - initRPC, -} from "./bridge"; -import { initAPI } from "./api"; -import { updateAllModBadges, updateModBadge } from "./badges"; - -if (!window.__pxmodrimInited) { - window.__pxmodrimInited = true; - - window.__pxmSetInstalled = __pxmSetInstalled; - window.__pxmSetActive = __pxmSetActive; - window.__pxmUncheckMod = __pxmUncheckMod; - window.__pxmClearChecked = __pxmClearChecked; - window.updateAllModBadges = updateAllModBadges; - window.updateModBadge = updateModBadge; - - (async () => { - try { - const rpc = await initRPC(); - initAPI(rpc); - } catch (e) { - console.error("[pxmodrim] QWebChannel init failed:", e); - return; - } - - if (document.documentElement) { - startScript(); - } else { - waitForRoot(); - } - })(); -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js deleted file mode 100644 index 05716d6..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js +++ /dev/null @@ -1,457 +0,0 @@ -// Copyright (C) 2016 The Qt Company Ltd. -// Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff -// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only - -"use strict"; - -var QWebChannelMessageTypes = { - signal: 1, - propertyUpdate: 2, - init: 3, - idle: 4, - debug: 5, - invokeMethod: 6, - connectToSignal: 7, - disconnectFromSignal: 8, - setProperty: 9, - response: 10, -}; - -var QWebChannel = function(transport, initCallback, converters) -{ - if (typeof transport !== "object" || typeof transport.send !== "function") { - console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + - " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send)); - return; - } - - var channel = this; - this.transport = transport; - - var converterRegistry = - { - Date : function(response) { - if (typeof response === "string" - && response.match( - /^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+\u2212](\d{2}):(\d{2})|Z)?$/)) { - var date = new Date(response); - if (!isNaN(date)) - return date; - } - return undefined; // Return undefined if current converter is not applicable - } - }; - - this.usedConverters = []; - - this.addConverter = function(converter) - { - if (typeof converter === "string") { - if (converterRegistry.hasOwnProperty(converter)) - this.usedConverters.push(converterRegistry[converter]); - else - console.error("Converter '" + converter + "' not found"); - } else if (typeof converter === "function") { - this.usedConverters.push(converter); - } else { - console.error("Invalid converter object type " + typeof converter); - } - } - - if (Array.isArray(converters)) { - for (const converter of converters) - this.addConverter(converter); - } else if (converters !== undefined) { - this.addConverter(converters); - } - - this.send = function(data) - { - if (typeof(data) !== "string") { - data = JSON.stringify(data); - } - channel.transport.send(data); - } - - this.transport.onmessage = function(message) - { - var data = message.data; - if (typeof data === "string") { - data = JSON.parse(data); - } - switch (data.type) { - case QWebChannelMessageTypes.signal: - channel.handleSignal(data); - break; - case QWebChannelMessageTypes.response: - channel.handleResponse(data); - break; - case QWebChannelMessageTypes.propertyUpdate: - channel.handlePropertyUpdate(data); - break; - default: - console.error("invalid message received:", message.data); - break; - } - } - - this.execCallbacks = {}; - this.execId = 0; - this.exec = function(data, callback) - { - if (!callback) { - // if no callback is given, send directly - channel.send(data); - return; - } - if (channel.execId === Number.MAX_VALUE) { - // wrap - channel.execId = Number.MIN_VALUE; - } - if (data.hasOwnProperty("id")) { - console.error("Cannot exec message with property id: " + JSON.stringify(data)); - return; - } - data.id = channel.execId++; - channel.execCallbacks[data.id] = callback; - channel.send(data); - }; - - this.objects = {}; - - this.handleSignal = function(message) - { - var object = channel.objects[message.object]; - if (object) { - object.signalEmitted(message.signal, message.args); - } else { - console.warn("Unhandled signal: " + message.object + "::" + message.signal); - } - } - - this.handleResponse = function(message) - { - if (!message.hasOwnProperty("id")) { - console.error("Invalid response message received: ", JSON.stringify(message)); - return; - } - channel.execCallbacks[message.id](message.data); - delete channel.execCallbacks[message.id]; - } - - this.handlePropertyUpdate = function(message) - { - message.data.forEach(data => { - var object = channel.objects[data.object]; - if (object) { - object.propertyUpdate(data.signals, data.properties); - } else { - console.warn("Unhandled property update: " + data.object + "::" + data.signal); - } - }); - channel.exec({type: QWebChannelMessageTypes.idle}); - } - - this.debug = function(message) - { - channel.send({type: QWebChannelMessageTypes.debug, data: message}); - }; - - channel.exec({type: QWebChannelMessageTypes.init}, function(data) { - for (const objectName of Object.keys(data)) { - new QObject(objectName, data[objectName], channel); - } - - // now unwrap properties, which might reference other registered objects - for (const objectName of Object.keys(channel.objects)) { - channel.objects[objectName].unwrapProperties(); - } - - if (initCallback) { - initCallback(channel); - } - channel.exec({type: QWebChannelMessageTypes.idle}); - }); -}; - -function QObject(name, data, webChannel) -{ - this.__id__ = name; - webChannel.objects[name] = this; - - // List of callbacks that get invoked upon signal emission - this.__objectSignals__ = {}; - - // Cache of all properties, updated when a notify signal is emitted - this.__propertyCache__ = {}; - - var object = this; - - // ---------------------------------------------------------------------- - - this.unwrapQObject = function(response) - { - for (const converter of webChannel.usedConverters) { - var result = converter(response); - if (result !== undefined) - return result; - } - - if (response instanceof Array) { - // support list of objects - return response.map(qobj => object.unwrapQObject(qobj)) - } - if (!(response instanceof Object)) - return response; - - if (!response["__QObject*__"] || response.id === undefined) { - var jObj = {}; - for (const propName of Object.keys(response)) { - jObj[propName] = object.unwrapQObject(response[propName]); - } - return jObj; - } - - var objectId = response.id; - if (webChannel.objects[objectId]) - return webChannel.objects[objectId]; - - if (!response.data) { - console.error("Cannot unwrap unknown QObject " + objectId + " without data."); - return; - } - - var qObject = new QObject( objectId, response.data, webChannel ); - qObject.destroyed.connect(function() { - if (webChannel.objects[objectId] === qObject) { - delete webChannel.objects[objectId]; - // reset the now deleted QObject to an empty {} object - // just assigning {} though would not have the desired effect, but the - // below also ensures all external references will see the empty map - // NOTE: this detour is necessary to workaround QTBUG-40021 - Object.keys(qObject).forEach(name => delete qObject[name]); - } - }); - // here we are already initialized, and thus must directly unwrap the properties - qObject.unwrapProperties(); - return qObject; - } - - this.unwrapProperties = function() - { - for (const propertyIdx of Object.keys(object.__propertyCache__)) { - object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]); - } - } - - function addSignal(signalData, isPropertyNotifySignal) - { - var signalName = signalData[0]; - var signalIndex = signalData[1]; - object[signalName] = { - connect: function(callback) { - if (typeof(callback) !== "function") { - console.error("Bad callback given to connect to signal " + signalName); - return; - } - - object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || []; - object.__objectSignals__[signalIndex].push(callback); - - // only required for "pure" signals, handled separately for properties in propertyUpdate - if (isPropertyNotifySignal) - return; - - // also note that we always get notified about the destroyed signal - if (signalName === "destroyed" || signalName === "destroyed()" || signalName === "destroyed(QObject*)") - return; - - // and otherwise we only need to be connected only once - if (object.__objectSignals__[signalIndex].length == 1) { - webChannel.exec({ - type: QWebChannelMessageTypes.connectToSignal, - object: object.__id__, - signal: signalIndex - }); - } - }, - disconnect: function(callback) { - if (typeof(callback) !== "function") { - console.error("Bad callback given to disconnect from signal " + signalName); - return; - } - // This makes a new list. This is important because it won't interfere with - // signal processing if a disconnection happens while emittig a signal - object.__objectSignals__[signalIndex] = (object.__objectSignals__[signalIndex] || []).filter(function(c) { - return c != callback; - }); - if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) { - // only required for "pure" signals, handled separately for properties in propertyUpdate - webChannel.exec({ - type: QWebChannelMessageTypes.disconnectFromSignal, - object: object.__id__, - signal: signalIndex - }); - } - } - }; - } - - /** - * Invokes all callbacks for the given signalname. Also works for property notify callbacks. - */ - function invokeSignalCallbacks(signalName, signalArgs) - { - var connections = object.__objectSignals__[signalName]; - if (connections) { - connections.forEach(function(callback) { - callback.apply(callback, signalArgs); - }); - } - } - - this.propertyUpdate = function(signals, propertyMap) - { - // update property cache - for (const propertyIndex of Object.keys(propertyMap)) { - var propertyValue = propertyMap[propertyIndex]; - object.__propertyCache__[propertyIndex] = this.unwrapQObject(propertyValue); - } - - for (const signalName of Object.keys(signals)) { - // Invoke all callbacks, as signalEmitted() does not. This ensures the - // property cache is updated before the callbacks are invoked. - invokeSignalCallbacks(signalName, signals[signalName]); - } - } - - this.signalEmitted = function(signalName, signalArgs) - { - invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs)); - } - - function addMethod(methodData) - { - var methodName = methodData[0]; - var methodIdx = methodData[1]; - - // Fully specified methods are invoked by id, others by name for host-side overload resolution - var invokedMethod = methodName[methodName.length - 1] === ')' ? methodIdx : methodName - - object[methodName] = function() { - var args = []; - var callback; - var errCallback; - for (var i = 0; i < arguments.length; ++i) { - var argument = arguments[i]; - if (typeof argument === "function") - callback = argument; - else - args.push(argument); - } - - var result; - // during test, webChannel.exec synchronously calls the callback - // therefore, the promise must be constucted before calling - // webChannel.exec to ensure the callback is set up - if (!callback && (typeof(Promise) === 'function')) { - result = new Promise(function(resolve, reject) { - callback = resolve; - errCallback = reject; - }); - } - - webChannel.exec({ - "type": QWebChannelMessageTypes.invokeMethod, - "object": object.__id__, - "method": invokedMethod, - "args": args - }, function(response) { - if (response !== undefined) { - var result = object.unwrapQObject(response); - if (callback) { - (callback)(result); - } - } else if (errCallback) { - (errCallback)(); - } - }); - - return result; - }; - } - - function bindGetterSetter(propertyInfo) - { - var propertyIndex = propertyInfo[0]; - var propertyName = propertyInfo[1]; - var notifySignalData = propertyInfo[2]; - // initialize property cache with current value - // NOTE: if this is an object, it is not directly unwrapped as it might - // reference other QObject that we do not know yet - object.__propertyCache__[propertyIndex] = propertyInfo[3]; - - if (notifySignalData) { - if (notifySignalData[0] === 1) { - // signal name is optimized away, reconstruct the actual name - notifySignalData[0] = propertyName + "Changed"; - } - addSignal(notifySignalData, true); - } - - Object.defineProperty(object, propertyName, { - configurable: true, - get: function () { - var propertyValue = object.__propertyCache__[propertyIndex]; - if (propertyValue === undefined) { - // This shouldn't happen - console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__); - } - - return propertyValue; - }, - set: function(value) { - if (value === undefined) { - console.warn("Property setter for " + propertyName + " called with undefined value!"); - return; - } - object.__propertyCache__[propertyIndex] = value; - var valueToSend = value; - webChannel.exec({ - "type": QWebChannelMessageTypes.setProperty, - "object": object.__id__, - "property": propertyIndex, - "value": valueToSend - }); - } - }); - - } - - // ---------------------------------------------------------------------- - - data.methods.forEach(addMethod); - - data.properties.forEach(bindGetterSetter); - - data.signals.forEach(function(signal) { addSignal(signal, false); }); - - Object.assign(object, data.enums); -} - -QObject.prototype.toJSON = function() { - if (this.__id__ === undefined) return {}; - return { - id: this.__id__, - "__QObject*__": true - }; -}; - -//required for use with nodejs -if (typeof module === 'object') { - module.exports = { - QWebChannel: QWebChannel - }; -} - -window.QWebChannel = QWebChannel; diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts deleted file mode 100644 index 955373a..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Config } from "./types"; -import type { DepNode } from "./types"; -import { scrapeDepsFromContainer } from "./utils"; -import { getModState, applyModState, handleClick } from "./controls"; - -export function buildDepSection( - tree: DepNode | null, -): HTMLDivElement | null { - const section = document.createElement("div"); - section.id = Config.DEP_SECTION_ID; - section.className = "rimsort-deps-section"; - - if (tree && tree.deps?.length) { - const header = document.createElement("div"); - header.className = "rimsort-deps-header"; - header.textContent = `Dependencies (${tree.deps.length}):`; - section.appendChild(header); - const list = document.createElement("div"); - list.className = "rimsort-dep-list"; - renderDepTree(list, tree.deps, 0, new Set()); - section.appendChild(list); - return section; - } - - if (tree) { - const header = document.createElement("div"); - header.className = "rimsort-deps-header"; - header.textContent = "No dependencies found"; - section.appendChild(header); - const list = document.createElement("div"); - list.className = "rimsort-dep-list"; - const empty = document.createElement("div"); - empty.className = "rimsort-dep-empty"; - empty.textContent = "This mod has no required items"; - empty.style.padding = "8px"; - empty.style.color = "#888"; - list.appendChild(empty); - section.appendChild(list); - return section; - } - - const required = document.getElementById("RequiredItems"); - if (!required) return null; - const deps = scrapeDepsFromContainer(required); - const header = document.createElement("div"); - header.className = "rimsort-deps-header"; - header.textContent = deps.length - ? `Dependencies (${deps.length}):` - : "No dependencies found"; - section.appendChild(header); - const list = document.createElement("div"); - list.className = "rimsort-dep-list"; - if (deps.length) { - deps.forEach((d) => { - const node = createDepNode({ id: d.id, title: d.title, deps: [] }, 0); - list.appendChild(node); - }); - } else { - const empty = document.createElement("div"); - empty.className = "rimsort-dep-empty"; - empty.textContent = "This mod has no required items"; - empty.style.padding = "8px"; - empty.style.color = "#888"; - list.appendChild(empty); - } - section.appendChild(list); - return section; -} - -function createDepNode(dep: DepNode, depth: number): HTMLDivElement { - const node = document.createElement("div"); - node.className = "rimsort-dep-node"; - node.style.paddingLeft = `${depth * 16}px`; - - const hasChildren = - dep.deps && dep.deps.length > 0 && depth + 1 < Config.DEPTH_MAX; - - const expand = document.createElement("span"); - expand.className = "rimsort-dep-expand"; - expand.textContent = hasChildren ? "\u25b8" : ""; - if (!hasChildren) expand.classList.add("empty"); - - expand.addEventListener("click", (e) => { - e.stopPropagation(); - if (expand.classList.contains("empty")) return; - expand.classList.toggle("expanded"); - const children = node.nextElementSibling; - if ( - children && - children.classList.contains("rimsort-dep-children") - ) { - children.style.display = expand.classList.contains("expanded") - ? "block" - : "none"; - } - }); - - const badge = document.createElement("span"); - badge.className = "rimsort-dep-badge"; - badge.dataset.modid = dep.id; - badge.title = dep.id; - applyModState(badge, getModState(dep.id)); - - badge.addEventListener("click", async (e) => { - e.stopPropagation(); - e.preventDefault(); - await handleClick(badge, dep.id, () => dep.title); - }); - - const titleLink = document.createElement("a"); - titleLink.className = "rimsort-dep-title"; - titleLink.href = `https://steamcommunity.com/sharedfiles/filedetails/?id=${dep.id}`; - titleLink.target = "_blank"; - titleLink.rel = "noopener noreferrer"; - titleLink.textContent = dep.title; - titleLink.title = dep.title; - - node.appendChild(expand); - node.appendChild(badge); - node.appendChild(titleLink); - return node; -} - -function renderDepTree( - container: HTMLElement, - deps: DepNode[], - depth: number, - seenIds: Set, -): void { - deps.forEach((dep) => { - if (seenIds.has(dep.id)) { - const circ = document.createElement("div"); - circ.className = "rimsort-dep-circular"; - circ.textContent = `\u21bb ${dep.title} (circular)`; - circ.style.paddingLeft = `${depth * 16}px`; - container.appendChild(circ); - return; - } - seenIds.add(dep.id); - - const node = createDepNode(dep, depth); - container.appendChild(node); - - if (dep.deps && dep.deps.length) { - if (depth + 1 >= Config.DEPTH_MAX) { - const maxDepth = document.createElement("div"); - maxDepth.className = "rimsort-dep-maxdepth"; - maxDepth.style.paddingLeft = `${(depth + 1) * 16}px`; - maxDepth.textContent = `${dep.deps.length} required item(s) (max depth ${Config.DEPTH_MAX})`; - container.appendChild(maxDepth); - } else { - const childrenContainer = document.createElement("div"); - childrenContainer.className = "rimsort-dep-children"; - childrenContainer.style.display = "none"; - renderDepTree( - childrenContainer, - dep.deps, - depth + 1, - new Set(seenIds), - ); - container.appendChild(childrenContainer); - } - } - }); -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json deleted file mode 100644 index dd91096..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2020", "DOM"], - "strict": true, - "noEmit": true, - "isolatedModules": true, - "skipLibCheck": true - }, - "include": ["./**/*.ts"] -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts deleted file mode 100644 index 305adea..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const ModState = { - ACTIVE: "active", - INACTIVE: "inactive", - CHECKED: "checked", - DEFAULT: "default", - RESOLVING: "resolving", -} as const; - -export type ModStatus = (typeof ModState)[keyof typeof ModState]; - -export const Config = { - DEPTH_MAX: 3, - DEP_SECTION_ID: "pxmodrim-deps", - DEP_SOLO_LINK_ID: "pxmodrim-solo-link", -} as const; - -export interface ModToggle { - id: string; - title: string; -} - -export interface DepNode { - id: string; - title: string; - deps: DepNode[]; -} - -export interface DepItemsResult { - items: Record; - rootId: string; - isComplete: boolean; -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts b/legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts deleted file mode 100644 index 428dd3a..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ModToggle } from "./types"; - -const DEBUG = false; - -export const log = DEBUG - ? console.log.bind(console, "[pxmodrim]") - : () => {}; -export const warn = console.warn.bind(console, "[pxmodrim]"); - -export function getModId(link: HTMLAnchorElement): string | null { - const m = link.href.match(/[?&]id=(\d+)/); - return m ? m[1] : null; -} - -export function getModTitle(tile: Element): string { - const links = tile.querySelectorAll( - 'a[href*="sharedfiles/filedetails/?id="]', - ); - for (const link of links) { - const text = link.textContent?.trim(); - if (text) return text; - } - return ""; -} - -export function findItemCard(link: HTMLAnchorElement): Element | null { - let el = link.parentElement; - while (el) { - if ( - el.querySelector("img") && - el.querySelectorAll('a[href*="sharedfiles/filedetails/?id="]').length === 2 - ) { - return el; - } - el = el.parentElement; - } - return null; -} - -export function parseHTML(htmlText: string): Document { - return new DOMParser().parseFromString(htmlText, "text/html"); -} - -export function scrapeDepsFromContainer(containerEl: Element): ModToggle[] { - const deps: ModToggle[] = []; - const links = containerEl.querySelectorAll( - 'a[href*="filedetails/?id="]', - ); - links.forEach((link) => { - const match = link.href.match(/[?&]id=(\d+)/); - const modId = match ? match[1] : null; - const titleEl = link.querySelector(".requiredItem"); - const title = titleEl - ? titleEl.textContent?.trim() - : link.textContent?.trim(); - if (modId) deps.push({ id: modId, title: title || `Mod ${modId}` }); - }); - return deps; -} diff --git a/legacy/pxmodrim/ui/plugins/steam_workshop/view.py b/legacy/pxmodrim/ui/plugins/steam_workshop/view.py deleted file mode 100644 index 051e86a..0000000 --- a/legacy/pxmodrim/ui/plugins/steam_workshop/view.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -import json -from importlib.resources import files as resource_files -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from loguru import logger -from PySide6.QtCore import QObject, QUrl, Slot -from PySide6.QtGui import QColor -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QHBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.plugins.steam_workshop.bridge_obj import PxModRimBridge -from pxmodrim.ui.plugins.steam_workshop.download_sidebar import DownloadSidebar -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - - from pxmodrim.ui.context import AppContext - from pxmodrim.ui.plugins.steam_workshop.plugin import ( - ItemStatus, - SidebarSync, - SteamWorkshopUiPlugin, - ) - -_WORKSHOP_URL = "https://steamcommunity.com/workshop/browse/?appid=294100" - -_QML_DIR = Path(__file__).parent -_STEAM_WORKSHOP_QML = _QML_DIR / "SteamWorkshop.qml" - -_INJECT_JS = ( - resource_files("pxmodrim.ui.plugins.steam_workshop") / "inject.js" -).read_text(encoding="utf-8") - -_INJECT_CSS = ( - resource_files("pxmodrim.ui.plugins.steam_workshop") / "inject.css" -).read_text(encoding="utf-8") - -_INJECTION = f"var CSS_STYLES = {json.dumps(_INJECT_CSS)};\n{_INJECT_JS}" - - -class SteamWorkshopViewPanel(BaseViewPanel): - view_id = "steam_workshop" - icon_name = "steam" - label = "Steam Workshop" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - - self._plugin: SteamWorkshopUiPlugin | None = None - self._initialized = False - - content = QWidget(self) - h_layout = QHBoxLayout(content) - h_layout.setContentsMargins(0, 0, 0, 0) - h_layout.setSpacing(0) - - self._download_sidebar = DownloadSidebar(self._qml_engine, content) - h_layout.addWidget(self._download_sidebar) - - self._qml = QQuickWidget(self._qml_engine, content) # pyright: ignore[reportCallIssue, reportArgumentType] - self._qml.setObjectName("workshopView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - h_layout.addWidget(self._qml, stretch=1) - - self._root.addWidget(content, stretch=1) - - # ── QML object access ──────────────────────────────── - - def _web(self) -> QObject | None: - if not self._initialized: - return None - root = self._qml.rootObject() - if root is None: - return None - return root.findChild(QObject, "workshopWeb") - - def _run_js(self, code: str) -> None: - web = self._web() - if web is not None: - web.runJavaScript(code, 0) # pyright: ignore[reportAttributeAccessIssue] - - # ── Initialization ─────────────────────────────────── - - def _ensure_initialized(self) -> None: - if self._initialized: - return - self._initialized = True - - self._plugin = cast( - "SteamWorkshopUiPlugin | None", - self._app_ctx.plugins.get("steamworkshop") if self._app_ctx else None, - ) - if self._plugin is not None: - self._download_sidebar.download_requested.connect( - self._on_download_requested - ) - self._download_sidebar.stop_requested.connect(self._plugin.stop_download) - self._download_sidebar.item_removed.connect(self._plugin.remove_item) - self._download_sidebar.clear_requested.connect(self._plugin.clear_queue) - - self._plugin.badges_refresh_requested.connect(self._push_badges_to_js) - self._plugin.active_refresh_requested.connect(self._push_active_to_js) - self._plugin.sidebar_sync_requested.connect(self._on_sidebar_sync) - self._plugin.item_status_changed.connect(self._on_item_status_changed) - self._plugin.download_busy_changed.connect( - lambda busy: self._download_sidebar.set_download_enabled(not busy) - ) - self._plugin.uncheck_mod_requested.connect(self._push_uncheck_to_js) - self._plugin.clear_checked_requested.connect(self._push_clear_checked_to_js) - - self._bridge: PxModRimBridge | None = None - if self._plugin is not None: - self._bridge = PxModRimBridge(self._plugin, self) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("steamWorkshopPanel", self) - qml_ctx.setContextProperty("_bridge", self._bridge) - qml_ctx.setContextProperty("_injectCode", _INJECTION) - - self._qml.setSource(QUrl.fromLocalFile(str(_STEAM_WORKSHOP_QML))) - - # ── QML-invokable slots ────────────────────────────── - - @Slot() - def navigateHome(self) -> None: - web = self._web() - if web is not None: - web.setProperty("url", QUrl(_WORKSHOP_URL)) - - @Slot(str) - def navigateToUrl(self, text: str) -> None: - if not text: - return - url = QUrl.fromUserInput(text) - if url.scheme() == "http": - url.setScheme("https") - if not url.isValid(): - logger.warning("[steam] invalid URL: %s", text) - return - logger.debug("[steam] navigating to URL: %s", url.toString()) - web = self._web() - if web is not None: - web.setProperty("url", url) - - # ── Public API ─────────────────────────────────────── - - def preload(self) -> None: - self._ensure_initialized() - - # ── Plugin event handlers ──────────────────────────── - - def _push_badges_to_js(self, ids: list[str]) -> None: - js = json.dumps(ids) - self._run_js(f"if (window.__pxmSetInstalled) window.__pxmSetInstalled({js})") - - def _push_active_to_js(self, ids: list[str]) -> None: - js = json.dumps(ids) - self._run_js(f"if (window.__pxmSetActive) window.__pxmSetActive({js})") - - def _push_uncheck_to_js(self, mod_id: str) -> None: - self._run_js(f"window.__pxmUncheckMod({json.dumps(mod_id)});") - - def _push_clear_checked_to_js(self, _: None) -> None: - self._run_js("window.__pxmClearChecked();") - - def _on_sidebar_sync(self, sync: SidebarSync) -> None: - self._download_sidebar.sync_from(sync.checked_ids, sync.statuses) - - def _on_item_status_changed(self, status: ItemStatus) -> None: - self._download_sidebar.update_status( - status.mod_id, status.status, status.bytes_done, status.bytes_total - ) - - # ── Sidebar signal handler ─────────────────────────── - - @asyncSlot() - async def _on_download_requested(self) -> None: - if self._plugin is not None: - await self._plugin.request_download() - - # ── Lifecycle ──────────────────────────────────────── - - def showEvent(self, event: object) -> None: - logger.debug("[steam] view shown") - super().showEvent(event) # type: ignore[attr-defined] - self._ensure_initialized() - - def teardown(self) -> None: - logger.debug("[steam] view teardown") - - if not self._initialized: - return - - web = self._web() - if web is not None: - web.stop() # pyright: ignore[reportAttributeAccessIssue] - web.setProperty("url", QUrl("about:blank")) - - self._qml.setSource(QUrl()) - self._qml.setParent(None) - self._qml.deleteLater() - - self._plugin = None - self._initialized = False diff --git a/legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml deleted file mode 100644 index 825aea7..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ /dev/null @@ -1,135 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: card - required property string itemId - required property string kind - required property string title - required property string author - required property string previewUrl - required property var collage - required property string summary - required property string compatLabel - required property string state - required property string actionLabel - required property bool incompatible - required property bool queued - required property int memberCount - required property string votes - required property string fileSize - required property bool active - - readonly property bool isMod: kind === "mod" - readonly property string meta: [ - isMod ? author : memberCount + " mods", - isMod ? "" : author, - isMod && votes !== "Unrated" ? votes + " liked" : "", - fileSize !== "Unknown" ? fileSize : "" - ].filter(part => part.length > 0).join(" · ") - - color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 - radius: Theme.radiusMd - border.color: hover.hovered ? Theme.elevate4 : Theme.border - clip: true - HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } - Accessible.role: Accessible.Button - Accessible.name: card.title - Accessible.onPressAction: workshopPanel.openItem(card.itemId, card.kind) - - FontMetrics { id: bodyMetrics; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeMd } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 6 - CatalogThumb { - Layout.fillWidth: true - Layout.preferredHeight: 100 - previewUrl: card.previewUrl - collage: card.collage - } - Text { - Layout.fillWidth: true - Layout.preferredHeight: bodyMetrics.lineSpacing * 2 - text: card.title - textFormat: Text.PlainText - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.bold: true - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - verticalAlignment: Text.AlignTop - } - Text { - Layout.fillWidth: true - text: card.meta - textFormat: Text.PlainText - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - Layout.preferredHeight: bodyMetrics.lineSpacing * 2 - text: card.summary - textFormat: Text.PlainText - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - verticalAlignment: Text.AlignTop - } - RowLayout { - Layout.fillWidth: true - spacing: 6 - Text { - Layout.fillWidth: true - visible: card.compatLabel.length > 0 - text: card.compatLabel - color: card.incompatible ? Theme.warning : card.compatLabel.indexOf("Works") === 0 ? Theme.success : Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - } - PxBadge { - visible: card.isMod && card.state !== "missing" - compact: true - text: card.state === "outdated" ? "UPDATE" : "INSTALLED" - textColor: card.state === "outdated" ? Theme.primary : Theme.success - fillColor: card.state === "outdated" ? Theme.primaryBg : Theme.successBg - } - PxBadge { - visible: !card.isMod - compact: true - text: "COLLECTION" - textColor: Theme.textMuted - fillColor: Theme.elevate3 - } - } - Item { Layout.fillHeight: true } - PxButton { - Layout.fillWidth: true - visible: card.state !== "installed" - text: card.actionLabel - variant: card.state === "outdated" ? "warning" : "primary" - enabled: !card.queued - onClicked: workshopPanel.downloadItem(card.itemId, card.kind) - } - PxButton { - Layout.fillWidth: true - visible: card.isMod && card.state === "installed" - text: card.active ? "Deactivate" : "Activate" - variant: card.active ? "danger" : "success" - onClicked: workshopPanel.toggleActivation(card.itemId) - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml deleted file mode 100644 index 9f99994..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/CatalogImage.qml +++ /dev/null @@ -1,18 +0,0 @@ -import QtQuick -import "thumbs.js" as Thumbs - -Image { - property string previewUrl: "" - property int widthStep: 64 - property int heightStep: 32 - // Qt updates sourceSize during reloads; URL dimensions must stay independent. - readonly property int pixelWidth: Thumbs.bucket(width * Screen.devicePixelRatio, widthStep) - readonly property int pixelHeight: Thumbs.bucket(height * Screen.devicePixelRatio, heightStep) - - asynchronous: true - fillMode: Image.PreserveAspectCrop - sourceSize.width: pixelWidth - sourceSize.height: pixelHeight - source: visible && width > 0 && height > 0 - ? Thumbs.sized(previewUrl, pixelWidth, pixelHeight) : "" -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml deleted file mode 100644 index 9aa0df2..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/CatalogSettings.qml +++ /dev/null @@ -1,36 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../components/controls" - -ColumnLayout { - id: root - required property QtObject section - spacing: 10 - - RowLayout { - Layout.fillWidth: true - Text { - Layout.preferredWidth: 150 - text: "Workshop catalog URL" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - objectName: "catalogUrlField" - Layout.fillWidth: true - text: root.section.initial.url - placeholderText: "https://api.modrim.pyxiion.dev" - Accessible.name: "Workshop catalog URL" - onTextEdited: root.section.set("url", text.trim()) - } - } - Text { - Layout.fillWidth: true - text: "Catalog service used by the native Workshop browser. Leave empty to disable catalog network requests. Applies on Save." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml b/legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml deleted file mode 100644 index 7fb0a9e..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/CatalogThumb.qml +++ /dev/null @@ -1,45 +0,0 @@ -import QtQuick -import "../../components/controls" - -Rectangle { - id: thumb - property string previewUrl: "" - property var collage: [] - property int widthStep: 64 - property int heightStep: 32 - - color: Theme.elevate3 - radius: Theme.radiusMd - clip: true - - CatalogImage { - id: preview - anchors.fill: parent - previewUrl: thumb.previewUrl - widthStep: thumb.widthStep - heightStep: thumb.heightStep - } - Grid { - id: collageGrid - anchors.fill: parent - visible: thumb.previewUrl === "" && thumb.collage.length > 0 - columns: thumb.collage.length === 1 ? 1 : 2 - readonly property int rowCount: thumb.collage.length > 2 ? 2 : 1 - Repeater { - model: collageGrid.visible ? thumb.collage : [] - CatalogImage { - required property string modelData - width: collageGrid.width / collageGrid.columns - height: collageGrid.height / collageGrid.rowCount - previewUrl: modelData - } - } - } - Image { - anchors.centerIn: parent - width: Math.min(32, parent.height * 0.6) - height: width - visible: preview.status !== Image.Ready && !collageGrid.visible - source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml deleted file mode 100644 index bdaab60..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ /dev/null @@ -1,126 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: banner - required property string itemId - required property string kind - required property string title - required property string author - required property string previewUrl - required property var collage - required property string summary - required property string sourceLabel - required property string compatLabel - required property bool incompatible - required property bool queued - required property int memberCount - required property string fileSize - required property string actionLabel - required property bool favourite - required property string state - - color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 - radius: Theme.radiusMd - border.color: hover.hovered ? Theme.elevate4 : Theme.border - clip: true - HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: workshopPanel.openItem(banner.itemId, banner.kind) } - Accessible.role: Accessible.Button - Accessible.name: banner.title - Accessible.onPressAction: workshopPanel.openItem(banner.itemId, banner.kind) - - RowLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 12 - CatalogThumb { - Layout.fillHeight: true - Layout.preferredWidth: Math.min(180, banner.width * 0.4) - previewUrl: banner.previewUrl - collage: banner.collage - widthStep: 128 - heightStep: 64 - } - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 4 - Text { - Layout.fillWidth: true - text: banner.title - textFormat: Text.PlainText - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeLg - font.bold: true - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - text: banner.memberCount + " mods" + (banner.fileSize !== "Unknown" ? " · " + banner.fileSize : "") - textFormat: Text.PlainText - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - text: banner.sourceLabel + " · " + banner.author - textFormat: Text.PlainText - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - Layout.fillHeight: true - text: banner.summary - textFormat: Text.PlainText - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: Text.Wrap - elide: Text.ElideRight - verticalAlignment: Text.AlignTop - } - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: banner.compatLabel - color: banner.incompatible ? Theme.warning : Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - } - PxButton { - objectName: "favouriteToggle" - variant: "ghost" - iconName: banner.favourite ? "star-filled" : "star" - iconColor: banner.favourite ? Theme.warning : "transparent" - ToolTip.text: banner.favourite ? "Remove from favourites" : "Add to favourites" - onClicked: workshopPanel.toggleFavourite(banner.itemId) - } - Text { - visible: banner.state === "installed" - text: "Installed" - color: Theme.success - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxButton { - visible: banner.state !== "installed" - text: banner.actionLabel - variant: "primary" - enabled: !banner.queued - onClicked: workshopPanel.downloadItem(banner.itemId, banner.kind) - } - } - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/ModRow.qml b/legacy/pxmodrim/ui/plugins/workshop/ModRow.qml deleted file mode 100644 index 5be102d..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/ModRow.qml +++ /dev/null @@ -1,89 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: row - required property string itemId - required property string kind - required property string title - required property string author - required property string previewUrl - required property string state - required property string actionLabel - required property bool incompatible - required property bool queued - required property bool active - required property string fileSize - - color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 - radius: Theme.radiusMd - border.color: row.active ? Theme.success : Theme.border - HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: workshopPanel.openItem(row.itemId, row.kind) } - Accessible.role: Accessible.Button - Accessible.name: row.title - Accessible.onPressAction: workshopPanel.openItem(row.itemId, row.kind) - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 10 - anchors.topMargin: 6 - anchors.bottomMargin: 6 - spacing: 10 - Item { - implicitWidth: activeBox.implicitWidth - implicitHeight: activeBox.implicitHeight - PxCheckBox { - id: activeBox - objectName: "activeToggle" - anchors.centerIn: parent - visible: row.kind === "mod" && row.state !== "missing" - checked: row.active - Accessible.name: "Active" - Accessible.description: "Enable or disable " + row.title - onToggled: { - workshopPanel.toggleActivation(row.itemId) - checked = Qt.binding(() => row.active) - } - } - } - CatalogThumb { - Layout.fillHeight: true - Layout.preferredWidth: height - previewUrl: row.previewUrl - } - ColumnLayout { - Layout.fillWidth: true - spacing: 1 - Text { - Layout.fillWidth: true - text: row.title - textFormat: Text.PlainText - color: Theme.textMain - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.bold: true - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - text: row.incompatible ? "Not for this game version" : row.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") - textFormat: Text.PlainText - color: row.incompatible ? Theme.warning : Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight - } - } - PxButton { - visible: row.state !== "installed" - text: row.actionLabel - variant: row.state === "outdated" ? "warning" : "primary" - enabled: !row.queued - onClicked: workshopPanel.downloadItem(row.itemId, row.kind) - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml b/legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml deleted file mode 100644 index c576549..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/TagPicker.qml +++ /dev/null @@ -1,94 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -PxComboBox { - id: control - property var options: [] - property string selectedTag: "" - displayText: selectedTag || "All tags" - model: options.filter(function (tag) { - return tag === "All tags" || tag.toLowerCase().indexOf(tagSearch.text.toLowerCase()) >= 0 - }) - Accessible.name: "Tag filter" - onActivated: selectedTag = currentText === "All tags" ? "" : currentText - delegate: ItemDelegate { - id: option - required property int index - required property var model - width: ListView.view ? ListView.view.width : control.width - height: 32 - font: control.font - text: model.modelData - highlighted: optionsList.currentIndex === index - onClicked: control.choose(index) - contentItem: Text { - text: option.text - font: option.font - color: option.text === control.displayText ? Theme.primary : Theme.textMain - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - background: Rectangle { - radius: Theme.radiusSm - color: option.highlighted ? Theme.elevate3 : "transparent" - } - } - - function choose(index) { - if (index < 0 || index >= count) - return - currentIndex = index - activated(index) - popup.close() - } - - popup: Popup { - popupType: Popup.Window - y: control.height + 4 - width: control.width - padding: 8 - implicitHeight: contents.implicitHeight + 16 - onOpened: { - tagSearch.text = "" - tagSearch.forceActiveFocus() - } - onClosed: control.forceActiveFocus() - contentItem: ColumnLayout { - id: contents - spacing: 8 - PxTextField { - id: tagSearch - Layout.fillWidth: true - placeholderText: "Find a tag…" - Accessible.name: "Search known tags" - onAccepted: control.choose(control.count > 1 && text.length > 0 ? 1 : 0) - Keys.onDownPressed: { - optionsList.currentIndex = 0 - optionsList.forceActiveFocus() - } - Keys.onEscapePressed: control.popup.close() - } - ListView { - id: optionsList - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, 240) - clip: true - model: control.popup.visible ? control.delegateModel : null - currentIndex: 0 - keyNavigationEnabled: true - Keys.onReturnPressed: control.choose(currentIndex) - Keys.onSpacePressed: control.choose(currentIndex) - Keys.onEscapePressed: control.popup.close() - ScrollBar.vertical: PxScrollBar {} - } - } - background: Rectangle { - radius: Theme.radiusMd - color: Theme.elevate2 - border.width: 1 - border.color: Theme.border - } - } -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/Workshop.qml b/legacy/pxmodrim/ui/plugins/workshop/Workshop.qml deleted file mode 100644 index 8528bc8..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/Workshop.qml +++ /dev/null @@ -1,582 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "../../components/controls" - -Rectangle { - id: root - color: Theme.elevate0 - - readonly property var detail: workshopPanel.detail - - function applyFilters() { - workshopPanel.filter(search.text.trim(), version.currentIndex === 0 ? "" : version.currentText, - source.currentValue, sort.currentValue, tag.selectedTag) - } - - component Copy: Text { - textFormat: Text.PlainText - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: Text.Wrap - Layout.fillWidth: true - } - component Heading: Copy { - color: Theme.textMain - font.pixelSize: Theme.fontSizeLg - font.bold: true - } - component CatalogGrid: GridView { - property int columns: Math.max(1, Math.floor(width / 230)) - Layout.fillWidth: true - Layout.preferredHeight: Math.ceil(count / columns) * cellHeight - cellWidth: width / columns - cellHeight: 312 - interactive: false - clip: true - delegate: CatalogCard { - width: GridView.view.cellWidth - 12 - height: GridView.view.cellHeight - 12 - } - } - component CollectionGrid: GridView { - property int columns: Math.max(1, Math.floor(width / 400)) - Layout.fillWidth: true - Layout.preferredHeight: Math.ceil(count / columns) * cellHeight - cellWidth: width / columns - cellHeight: 162 - interactive: false - clip: true - delegate: CollectionBanner { - width: GridView.view.cellWidth - 12 - height: GridView.view.cellHeight - 12 - } - } - component ListingHeader: Item { - height: pageHeading.implicitHeight + 16 - Heading { id: pageHeading; width: parent.width; text: workshopPanel.tab } - } - component ListingFooter: ColumnLayout { - required property var listing - height: implicitHeight - spacing: 12 - Copy { - visible: !workshopPanel.busy && !workshopPanel.error && listing.count === 0 - text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." - : workshopPanel.tab === "Favourites" ? (workshopPanel.searchQuery ? "No favourite matches this search." : "No favourites yet. Star a collection to keep it here.") - : "No matching items. Try another search or filter." - } - PxButton { - Layout.alignment: Qt.AlignHCenter - text: "Load more" - visible: listing.hasMore - enabled: !workshopPanel.busy - onClicked: workshopPanel.loadMore() - } - Item { implicitHeight: 12 } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 20 - spacing: 12 - RowLayout { - Layout.fillWidth: true - ColumnLayout { - spacing: 2 - Heading { text: "Workshop" } - Copy { text: "Mods & collections for RimWorld" } - } - PxTextField { - id: search - Layout.fillWidth: true - Layout.maximumWidth: 540 - placeholderText: "Search mods or collections…" - Accessible.name: "Search Workshop" - maximumLength: 200 - enabled: workshopPanel.configured - onAccepted: root.applyFilters() - } - PxButton { text: "Search"; variant: "primary"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } - PxProgressBar { - Layout.preferredWidth: 48 - Layout.alignment: Qt.AlignVCenter - thickness: 3 - indeterminate: true - opacity: workshopPanel.busy || workshopPanel.planning ? 1 : 0 - Accessible.name: "Loading" - } - } - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - RowLayout { - Layout.fillWidth: true - spacing: 4 - Repeater { - model: ["Discover", "Mods", "Collections", "Favourites", "Installed"] - Item { - id: tabItem - required property string modelData - readonly property bool current: workshopPanel.tab === modelData - activeFocusOnTab: true - Keys.onReturnPressed: workshopPanel.selectTab(modelData) - Keys.onSpacePressed: workshopPanel.selectTab(modelData) - Rectangle { - anchors.fill: parent - visible: tabItem.activeFocus - color: "transparent" - border.color: Theme.primary - radius: Theme.radiusSm - } - implicitWidth: tabLabel.implicitWidth + 32 - implicitHeight: 36 - Text { - id: tabLabel - anchors.centerIn: parent - text: tabItem.modelData - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: tabItem.current ? Font.DemiBold : Font.Medium - color: tabItem.current ? Theme.primary : tabHover.hovered ? Theme.textMain : Theme.textMuted - } - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 2 - color: tabItem.current ? Theme.primary : "transparent" - } - HoverHandler { id: tabHover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: workshopPanel.selectTab(tabItem.modelData) } - Accessible.role: Accessible.PageTab - Accessible.name: modelData - Accessible.selected: current - Accessible.onPressAction: workshopPanel.selectTab(modelData) - } - } - Item { Layout.fillWidth: true } - PxButton { - visible: workshopPanel.tab === "Installed" - text: "Update all (" + workshopPanel.updateCount + ")" - variant: "warning" - enabled: workshopPanel.updateCount > 0 && !workshopPanel.planning - onClicked: workshopPanel.updateAll() - } - } - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.border } - } - RowLayout { - Layout.fillWidth: true - visible: workshopPanel.configured && !workshopPanel.hasDetail && (workshopPanel.tab === "Mods" || workshopPanel.tab === "Collections") - Copy { Layout.fillWidth: false; text: "Game version" } - PxComboBox { - id: version - model: workshopPanel.versionOptions - Accessible.name: "Game version filter" - Component.onCompleted: currentIndex = Math.max(0, model.indexOf(workshopPanel.gameVersion)) - onActivated: root.applyFilters() - } - PxComboBox { - id: source - visible: workshopPanel.tab === "Collections" - textRole: "text" - valueRole: "value" - model: [ {text: "All sources", value: "all"}, {text: "Steam collections", value: "steam"}, {text: "PxModRim picks", value: "picked"} ] - Accessible.name: "Collection source" - onActivated: root.applyFilters() - } - TagPicker { - id: tag - Layout.fillWidth: true - Layout.maximumWidth: 230 - options: workshopPanel.tagOptions - onSelectedTagChanged: root.applyFilters() - } - Item { Layout.fillWidth: true } - PxComboBox { - id: sort - textRole: "text" - valueRole: "value" - model: [ {text: "Popular", value: "popular"}, {text: "Recently updated", value: "updated"}, {text: "Newest", value: "newest"}, {text: "Trending", value: "trending"}, {text: "Relevance", value: "relevance"} ] - Accessible.name: "Catalog sort order" - onActivated: root.applyFilters() - } - } - Rectangle { - Layout.fillWidth: true - implicitHeight: errorRow.implicitHeight + 24 - visible: workshopPanel.error.length > 0 - color: Theme.elevate2 - radius: Theme.radiusMd - RowLayout { - id: errorRow - anchors.fill: parent - anchors.margins: 12 - Copy { text: workshopPanel.error; color: Theme.danger } - PxButton { text: "Retry"; enabled: !workshopPanel.busy; onClicked: workshopPanel.retry() } - } - } - Rectangle { - Layout.fillWidth: true - implicitHeight: noticeColumn.implicitHeight + 24 - visible: workshopPanel.notice.length > 0 - color: Theme.elevate2 - radius: Theme.radiusMd - ColumnLayout { - id: noticeColumn - anchors.fill: parent - anchors.margins: 12 - Copy { text: workshopPanel.notice; color: workshopPanel.pendingPlan ? Theme.warning : Theme.textMuted } - RowLayout { - visible: workshopPanel.pendingPlan - PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable; onClicked: workshopPanel.downloadAvailable() } - PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } - } - } - } - ScrollView { - id: contentScroll - objectName: "discoverScroll" - visible: !workshopPanel.hasDetail && (!workshopPanel.configured || workshopPanel.tab === "Discover") - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - contentWidth: availableWidth - ColumnLayout { - width: contentScroll.availableWidth - spacing: 16 - ColumnLayout { - Layout.fillWidth: true - visible: !workshopPanel.configured - Heading { text: "Workshop catalog is disabled" } - Copy { text: "Set a Workshop catalog URL in Settings to browse and download mods." } - PxButton { text: "Open Settings"; onClicked: workshopPanel.openSettings() } - } - ColumnLayout { - Layout.fillWidth: true - visible: workshopPanel.configured - Heading { text: "Discover your next colony" } - Copy { text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } - RowLayout { - Heading { text: "Collections" } - PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } - } - CollectionGrid { model: visible ? catalogCollections : null } - RowLayout { - Heading { text: "Popular mods" } - PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } - } - CatalogGrid { model: visible ? catalogMods : null } - } - } - } - GridView { - id: listingGrid - objectName: "listingScroll" - readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab === "Mods" - property int columns: Math.max(1, Math.floor(width / 230)) - visible: !workshopPanel.hasDetail && listingTab - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns - cellHeight: 312 - model: listingTab ? catalogMods : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: listingGrid.width } - footer: ListingFooter { width: listingGrid.width; listing: catalogMods } - delegate: CatalogCard { - width: GridView.view.cellWidth - 12 - height: GridView.view.cellHeight - 12 - } - } - GridView { - id: collectionsGrid - objectName: "collectionsScroll" - readonly property bool listingTab: workshopPanel.configured && (workshopPanel.tab === "Collections" || workshopPanel.tab === "Favourites") - property int columns: Math.max(1, Math.floor(width / 400)) - visible: !workshopPanel.hasDetail && listingTab - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns - cellHeight: 162 - model: listingTab ? catalogCollections : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: collectionsGrid.width } - footer: ListingFooter { width: collectionsGrid.width; listing: catalogCollections } - delegate: CollectionBanner { - width: GridView.view.cellWidth - 12 - height: GridView.view.cellHeight - 12 - } - } - GridView { - id: installedGrid - objectName: "installedScroll" - readonly property bool active: workshopPanel.configured && workshopPanel.tab === "Installed" - property int columns: Math.max(1, Math.floor(width / 340)) - visible: !workshopPanel.hasDetail && active - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns - cellHeight: 62 - model: active ? catalogMods : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: installedGrid.width } - footer: ListingFooter { width: installedGrid.width; listing: catalogMods } - delegate: ModRow { - width: GridView.view.cellWidth - 8 - height: GridView.view.cellHeight - 8 - } - } - ScrollView { - id: detailScroll - objectName: "detailScroll" - visible: workshopPanel.hasDetail - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - contentWidth: availableWidth - function jumpToMembers() { - const flickable = contentItem - const target = membersHeading.mapToItem(flickable.contentItem, 0, 0).y - flickable.contentY = Math.max(0, Math.min(target, flickable.contentHeight - flickable.height)) - } - ColumnLayout { - width: detailScroll.availableWidth - ColumnLayout { - Layout.fillWidth: true - Layout.maximumWidth: 1200 - Layout.alignment: Qt.AlignHCenter - visible: workshopPanel.configured && workshopPanel.hasDetail - spacing: 16 - PxButton { - text: workshopPanel.backLabel - iconName: "chevron-left" - Accessible.description: "Return to the previous page, keeping the listing's scroll position and filters" - onClicked: workshopPanel.back() - } - RowLayout { - Layout.fillWidth: true - spacing: 16 - Rectangle { - Layout.fillWidth: true - Layout.preferredWidth: 3 - Layout.alignment: Qt.AlignTop - Layout.preferredHeight: Math.max(220, Math.min(340, width * 0.5)) - color: Theme.elevate2 - border.color: Theme.border - radius: Theme.radiusMd - clip: true - CatalogImage { - id: detailImage - anchors.fill: parent - previewUrl: root.detail.previewUrl || "" - widthStep: 128 - heightStep: 64 - fillMode: Image.PreserveAspectFit - } - Grid { - id: detailCollage - anchors.fill: parent - visible: !root.detail.previewUrl && (root.detail.collage || []).length > 0 - columns: (root.detail.collage || []).length === 1 ? 1 : 2 - readonly property int rowCount: (root.detail.collage || []).length > 2 ? 2 : 1 - Repeater { - model: detailCollage.visible ? root.detail.collage : [] - CatalogImage { - required property string modelData - width: detailCollage.width / detailCollage.columns - height: detailCollage.height / detailCollage.rowCount - previewUrl: modelData - } - } - } - Image { - anchors.centerIn: parent - width: 48 - height: 48 - visible: detailImage.status !== Image.Ready && !detailCollage.visible - source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) - } - } - ColumnLayout { - Layout.fillWidth: true - Layout.preferredWidth: 2 - Layout.alignment: Qt.AlignTop - spacing: 10 - Flow { - Layout.fillWidth: true - spacing: 6 - PxBadge { text: (root.detail.sourceLabel || "").toUpperCase(); textColor: Theme.textMuted } - PxBadge { visible: root.detail.state !== "missing"; text: "INSTALLED"; textColor: Theme.success } - PxBadge { visible: root.detail.state === "outdated"; text: "UPDATE AVAILABLE" } - PxBadge { visible: !!root.detail.incompatible; text: "INCOMPATIBLE"; textColor: Theme.warning } - } - Heading { Layout.fillWidth: true; text: root.detail.title || ""; font.pixelSize: 26; wrapMode: Text.Wrap } - Copy { text: "by " + (root.detail.author || "") } - Copy { - objectName: "detailTags" - Layout.fillWidth: true - text: [root.detail.tags, root.detail.versions === "Not specified" ? "" : root.detail.versions].filter(s => !!s).join(" · ") - visible: !!text - } - Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version."; color: Theme.warning } - Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } - Flow { - Layout.fillWidth: true - spacing: 8 - PxButton { - readonly property bool pack: root.detail.kind === "collection" - visible: root.detail.state !== "missing" - text: (root.detail.active ? "Deactivate" : "Activate") + (pack ? " all" : "") - variant: root.detail.active ? "danger" : "success" - onClicked: pack ? workshopPanel.toggleCollection() : workshopPanel.toggleActivation(root.detail.itemId) - } - PxButton { - visible: root.detail.kind === "collection" && root.detail.state === "installed" - text: "Activate only this pack" - variant: "warning" - ToolTip.text: "Activate this collection and its dependencies, deactivate every other Workshop mod" - onClicked: workshopPanel.activateOnlyCollection() - } - PxButton { - visible: root.detail.state !== "installed" - text: root.detail.actionLabel || "Download" - variant: root.detail.state === "outdated" ? "warning" : "primary" - enabled: root.detail.state !== "installed" && !root.detail.queued - onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) - } - PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; variant: "ghost"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } - PxButton { - objectName: "detailFavourite" - visible: root.detail.kind === "collection" - text: root.detail.favourite ? "Favourited" : "Favourite" - variant: "ghost" - iconName: root.detail.favourite ? "star-filled" : "star" - iconColor: root.detail.favourite ? Theme.warning : "transparent" - onClicked: workshopPanel.toggleFavourite(root.detail.itemId) - } - } - Copy { Layout.fillWidth: true; text: "Local mods · downloading does not enable or subscribe." } - RowLayout { - visible: root.detail.kind === "mod" - spacing: 8 - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } - Copy { Layout.fillWidth: false; text: root.detail.votes || "Unrated" } - Copy { Layout.fillWidth: false; text: (root.detail.fileSize || "Unknown") } - } - RowLayout { - visible: root.detail.kind === "collection" - spacing: 8 - Copy { Layout.fillWidth: false; text: root.detail.memberCount + " mods" } - Copy { Layout.fillWidth: false; visible: root.detail.fileSize !== "Unknown"; text: "· " + root.detail.fileSize + " total" } - } - RowLayout { - visible: !!root.detail.votesUp - spacing: 8 - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } - Copy { Layout.fillWidth: false; text: root.detail.votesUp || ""; Accessible.name: text + " positive votes" } - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - rotation: 180 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } - Copy { Layout.fillWidth: false; text: root.detail.votesDown || ""; Accessible.name: text + " negative votes" } - } - } - } - GridLayout { - id: detailBody - readonly property bool pack: root.detail.kind === "collection" - Layout.fillWidth: true - columns: pack ? 1 : 2 - columnSpacing: 16 - rowSpacing: 16 - ColumnLayout { - id: descColumn - objectName: "detailDescription" - Layout.row: 0 - Layout.column: 0 - Layout.fillWidth: true - Layout.preferredWidth: 3 - Layout.alignment: Qt.AlignTop - spacing: 10 - readonly property bool tall: descriptionText.implicitHeight > detailScroll.availableHeight - RowLayout { - Layout.fillWidth: true - Heading { text: "Description" } - PxButton { - objectName: "jumpToMembers" - visible: descColumn.tall && catalogMembers.count > 0 - text: detailBody.pack ? "Jump to mods" : "Jump to required mods" - variant: "ghost" - font.pixelSize: Theme.fontSizeSm - onClicked: detailScroll.jumpToMembers() - } - } - ModDescription { - id: descriptionText - Layout.fillWidth: true - description: root.detail.description || "" - onLinkActivated: link => workshopPanel.openLink(link) - } - } - ColumnLayout { - objectName: "detailMembers" - Layout.row: detailBody.pack ? 1 : 0 - Layout.column: detailBody.pack ? 0 : 1 - Layout.fillWidth: true - Layout.preferredWidth: 2 - Layout.alignment: Qt.AlignTop - spacing: 8 - RowLayout { - id: membersHeading - objectName: "membersHeading" - Layout.fillWidth: true - Heading { text: (detailBody.pack ? "Collection members · " : "Required mods · ") + catalogMembers.count } - PxButton { - objectName: "detailBackToTop" - visible: detailScroll.contentItem.contentY > 0 - text: "Back to top" - variant: "ghost" - font.pixelSize: Theme.fontSizeSm - onClicked: detailScroll.contentItem.contentY = 0 - } - } - Copy { visible: catalogMembers.count === 0; text: "None listed." } - GridLayout { - objectName: "membersGrid" - Layout.fillWidth: true - columns: detailBody.pack ? Math.max(1, Math.floor(Math.min(1200, detailScroll.availableWidth) / 340)) : 1 - columnSpacing: 8 - rowSpacing: 8 - Repeater { - model: catalogMembers - ModRow { - Layout.fillWidth: true - Layout.preferredWidth: 1 - Layout.preferredHeight: 54 - } - } - } - } - } - } - } - } - } - Component.onCompleted: workshopPanel.refresh() -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/__init__.py b/legacy/pxmodrim/ui/plugins/workshop/__init__.py deleted file mode 100644 index 3bb8167..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.workshop.plugin import WorkshopUiPlugin -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel - -__all__ = ["WorkshopUiPlugin", "WorkshopViewPanel"] diff --git a/legacy/pxmodrim/ui/plugins/workshop/details.py b/legacy/pxmodrim/ui/plugins/workshop/details.py deleted file mode 100644 index 0d15799..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/details.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -import msgspec - -from pxmodrim.core.workshop import CatalogCollection, CatalogError, CatalogMod - -if TYPE_CHECKING: - from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog - - -class Detail(msgspec.Struct, frozen=True): - item: CatalogMod | CatalogCollection - members: list[CatalogMod | CatalogCollection] - warning: str - complete: bool - - -async def fetch_detail(catalog: WorkshopCatalog, item_id: str, kind: str) -> Detail: - if kind == "collection": - return await _collection_detail(catalog, item_id) - return await _mod_detail(catalog, item_id) - - -async def _collection_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: - detail = await catalog.collection(item_id) - if detail is None: - raise CatalogError("This collection is no longer available.", 404) - warning = "" - if not detail.is_complete: - warning = ( - "This collection is incomplete. Some members are unavailable or not listed." - ) - if detail.unavailable_ids: - warning += " Unavailable: " + ", ".join(detail.unavailable_ids) - return Detail(detail.collection, list(detail.members), warning, detail.is_complete) - - -async def _mod_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: - mod = await catalog.mod(item_id) - if mod is None: - raise CatalogError("This mod is no longer available.", 404) - dependencies = await asyncio.gather(*(catalog.mod(pid) for pid in mod.dependencies)) - missing = [ - pid - for pid, item in zip(mod.dependencies, dependencies, strict=True) - if item is None - ] - warning = "Unavailable dependencies: " + ", ".join(missing) if missing else "" - found = [item for item in dependencies if item is not None] - return Detail(mod, list(found), warning, not missing) - - -def incomplete_plan_notice(plan: DownloadPlan) -> str: - parts = ["The download plan is incomplete."] - if plan.unavailable_ids: - parts.append("Unavailable: " + ", ".join(plan.unavailable_ids)) - if plan.incomplete_collection_ids: - parts.append( - "Incomplete collections: " + ", ".join(plan.incomplete_collection_ids) - ) - return " ".join(parts) - - -def queued_notice(count: int) -> str: - return ( - f"Queued {count} mod{'s' if count != 1 else ''}. Progress is shown " - "in the header and results in Downloads. Mods are not activated." - ) diff --git a/legacy/pxmodrim/ui/plugins/workshop/installed.py b/legacy/pxmodrim/ui/plugins/workshop/installed.py deleted file mode 100644 index a8023a9..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/installed.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pxmodrim.core.workshop import CatalogMod - -PAGE = 48 - - -class InstalledList: - """Installed Workshop mods with a text filter and incremental paging.""" - - def __init__(self) -> None: - self._mods: list[CatalogMod] = [] - self._matches: list[CatalogMod] = [] - self._query = "" - self._shown = PAGE - - @property - def mods(self) -> list[CatalogMod]: - return self._mods - - @property - def loaded(self) -> bool: - return bool(self._mods) - - @property - def total(self) -> int: - return len(self._matches) - - @property - def has_more(self) -> bool: - return self._shown < len(self._matches) - - def replace(self, mods: list[CatalogMod]) -> None: - self._mods = mods - self._rematch() - - def search(self, query: str) -> None: - self._query = query - self._shown = PAGE - self._rematch() - - def reset_paging(self) -> None: - self._shown = PAGE - - def visible(self) -> list[CatalogMod]: - return self._matches[: self._shown] - - def next_page(self) -> list[CatalogMod]: - start = self._shown - self._shown = min(start + PAGE, len(self._matches)) - return self._matches[start : self._shown] - - def _rematch(self) -> None: - needle = self._query.casefold() - self._matches = [mod for mod in self._mods if needle in _haystack(mod)] - - -def _haystack(mod: CatalogMod) -> str: - return " ".join((mod.title, mod.author.name or "", mod.id, *mod.tags)).casefold() diff --git a/legacy/pxmodrim/ui/plugins/workshop/models.py b/legacy/pxmodrim/ui/plugins/workshop/models.py deleted file mode 100644 index dd094dd..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/models.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Callable, Sequence -from typing import Any -from urllib.parse import urlsplit - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.workshop import CatalogCollection, CatalogMod, InstallState -from pxmodrim.ui.panels.mod_info_data import description_to_html - - -def safe_url(value: str) -> str: - if any(char.isspace() or ord(char) < 32 for char in value): - return "" - try: - parsed = urlsplit(value) - except ValueError: - return "" - return value if parsed.scheme.lower() in {"http", "https"} and parsed.netloc else "" - - -_BBCODE_TAG = re.compile(r"\[/?[a-zA-Z*][^\]]*\]") -_BBCODE_MEDIA = re.compile( - r"\[(img|video|previewimage)[^\]]*\].*?\[/\1\]", re.IGNORECASE | re.DOTALL -) -_SUMMARY_LENGTH = 140 - - -def summarize(text: str, description_format: str = "bbcode") -> str: - """First sentence-ish of a description as plain text, for card blurbs.""" - if description_format == "bbcode": - text = _BBCODE_TAG.sub(" ", _BBCODE_MEDIA.sub(" ", text)) - text = " ".join(text.split()) - if len(text) <= _SUMMARY_LENGTH: - return text - cut = text[:_SUMMARY_LENGTH].rsplit(" ", 1)[0].rstrip(".,;:-–— ") - return f"{cut}…" - - -def _compat_label( - supported: set[str], game_version: str, incompatible: bool, no_common: bool -) -> str: - if not game_version: - return "" - if no_common: - return "No common version" - if incompatible: - return f"Not for {game_version}" - if not supported: - return "Version not stated" - return f"Works with {game_version}" - - -def item_row( - item: CatalogMod | CatalogCollection, - install_state: Callable[[CatalogMod], InstallState], - game_version: str, - *, - detail: bool = False, -) -> dict[str, Any]: - versions = set(item.supported_versions) - if isinstance(item, CatalogCollection): - versions.update( - tag for tag in item.tags if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", tag) - ) - incompatible = bool(game_version and versions and game_version not in versions) - file_size, votes, member_count = "Unknown", "Unrated", 0 - votes_up = votes_down = "0" - if isinstance(item, CatalogMod): - state = install_state(item) - incompatible = incompatible or item.incompatible - source_label = "Steam Workshop" - action_label = { - "missing": "Download", - "installed": "Installed", - "outdated": "Update", - }[state] - if item.file_size is not None: - file_size = f"{int(item.file_size) / 1048576:.1f} MB" - if item.votes is not None: - votes_up, votes_down = f"{item.votes.up:,}", f"{item.votes.down:,}" - total = item.votes.up + item.votes.down - if total: - votes = f"{100 * item.votes.up / total:.0f}%" - else: - state = "missing" - source_label = ( - "PxModRim pick" if item.source == "picked" else "Steam collection" - ) - action_label = "Download all" - member_count = item.member_count - if item.total_size is not None: - file_size = f"{int(item.total_size) / 1048576:.1f} MB" - no_common = ( - isinstance(item, CatalogCollection) and item.no_common_version and not versions - ) - incompatible = incompatible or no_common - return { - "itemId": item.id, - "kind": item.kind, - "title": item.title, - "author": item.author.name or item.author.id or "Unknown author", - "previewUrl": safe_url( - item.preview_url - or next((p.url for p in item.previews if p.type == "image"), "") - ), - "collage": [ - url - for url in ( - safe_url(u) - for u in ( - item.member_previews if isinstance(item, CatalogCollection) else [] - ) - ) - if url - ], - "sourceLabel": source_label, - "versions": ", ".join( - sorted(versions, key=lambda version: tuple(map(int, version.split(".")))) - ) - or "Not specified", - "tags": " · ".join(tag for tag in item.tags if tag not in versions), - "state": state, - "active": False, - "queued": False, - "favourite": False, - "stateLabel": { - "missing": "Not installed", - "installed": "Installed", - "outdated": "Update available", - }[state], - "actionLabel": action_label, - "incompatible": incompatible, - "memberCount": member_count, - "description": description_to_html(item.description, item.description_format) - if detail - else "", - "workshopUrl": safe_url(item.workshop_url or ""), - "fileSize": file_size, - "votes": votes, - "votesUp": votes_up, - "votesDown": votes_down, - "summary": summarize(item.description, item.description_format), - "compatLabel": _compat_label(versions, game_version, incompatible, no_common), - } - - -class CatalogListModel(QAbstractListModel): - changed = Signal() - _roles = ( - "itemId", - "kind", - "title", - "author", - "previewUrl", - "collage", - "sourceLabel", - "versions", - "tags", - "state", - "active", - "stateLabel", - "actionLabel", - "incompatible", - "memberCount", - "description", - "workshopUrl", - "fileSize", - "votes", - "votesUp", - "votesDown", - "queued", - "favourite", - "summary", - "compatLabel", - ) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._rows: list[dict[str, Any]] = [] - self._cursor: str | None = None - self._total = 0 - - def set_page( - self, - rows: Sequence[dict[str, Any]], - total: int, - cursor: str | None, - *, - append: bool = False, - ) -> None: - if append: - known = {row["itemId"] for row in self._rows} - additions = [] - for row in rows: - if row["itemId"] not in known: - additions.append(row) - known.add(row["itemId"]) - if additions: - self.beginInsertRows( - QModelIndex(), len(self._rows), len(self._rows) + len(additions) - 1 - ) - self._rows.extend(additions) - self.endInsertRows() - elif [row["itemId"] for row in rows] == [row["itemId"] for row in self._rows]: - # A background refresh of the same page: resetting would rebuild every - # delegate and reload its thumbnail, so only changed rows are updated. - for index, row in enumerate(rows): - if row != self._rows[index]: - self._rows[index] = row - self.dataChanged.emit(self.index(index), self.index(index)) - else: - self.beginResetModel() - self._rows = list(rows) - self.endResetModel() - self._total, self._cursor = total, cursor - self.changed.emit() - - def refresh_rows(self, convert: Callable[[dict[str, Any]], dict[str, Any]]) -> None: - self._rows = [convert(row) for row in self._rows] - if self._rows: - self.dataChanged.emit(self.index(0), self.index(len(self._rows) - 1)) - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return len(self._rows) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - offset = role - int(Qt.ItemDataRole.UserRole) - 1 - if ( - not index.isValid() - or not 0 <= index.row() < len(self._rows) - or not 0 <= offset < len(self._roles) - ): - return None - return self._rows[index.row()][self._roles[offset]] - - def roleNames(self) -> dict[int, QByteArray]: - return { - int(Qt.ItemDataRole.UserRole) + offset + 1: QByteArray(name.encode()) - for offset, name in enumerate(self._roles) - } - - @property - def cursor(self) -> str | None: - return self._cursor - - @Property(bool, notify=changed) # type: ignore[arg-type] - def hasMore(self) -> bool: - return self._cursor is not None - - @Property(int, notify=changed) # type: ignore[arg-type] - def count(self) -> int: - return len(self._rows) - - @Property(int, notify=changed) # type: ignore[arg-type] - def total(self) -> int: - return self._total diff --git a/legacy/pxmodrim/ui/plugins/workshop/plugin.py b/legacy/pxmodrim/ui/plugins/workshop/plugin.py deleted file mode 100644 index 2d29648..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, cast - -from pxmodrim.core.plugin import Plugin -from pxmodrim.core.workshop import WorkshopCatalog -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel -from pxmodrim.ui.settings_section import PluginConfigSection - -if TYPE_CHECKING: - from PySide6.QtCore import QObject - - from pxmodrim.ui.context import AppContext - - -class CatalogSettingsSection(PluginConfigSection): - title = "Workshop catalog" - source = Path(__file__).with_name("CatalogSettings.qml") - - -class WorkshopUiPlugin(Plugin): - name = "workshop_ui" - dependencies: ClassVar[list[str]] = ["workshop_catalog", "downloads"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(WorkshopViewPanel) - catalog = cast(WorkshopCatalog, ctx.core.plugins.get("workshop_catalog")) - - def section(parent: QObject) -> CatalogSettingsSection: - return CatalogSettingsSection(catalog.settings, parent) - - ctx.add_settings_section(section) diff --git a/legacy/pxmodrim/ui/plugins/workshop/thumbs.js b/legacy/pxmodrim/ui/plugins/workshop/thumbs.js deleted file mode 100644 index fb16142..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/thumbs.js +++ /dev/null @@ -1,24 +0,0 @@ -.pragma library - -// Steam's image CDN resizes on request. Asking for the size that is shown keeps -// both the download and the decoded pixmap small. -function sized(url, width, height) { - if (!/^https:\/\/(?:[a-z0-9-]+\.)*(?:steamusercontent\.com|steamstatic\.com)\//i.test(url)) - return url - - const fragmentAt = url.indexOf("#") - const fragment = fragmentAt < 0 ? "" : url.slice(fragmentAt) - const address = fragmentAt < 0 ? url : url.slice(0, fragmentAt) - const queryAt = address.indexOf("?") - const path = queryAt < 0 ? address : address.slice(0, queryAt) - const query = queryAt < 0 ? [] : address.slice(queryAt + 1).split("&").filter(function (part) { - return part !== "" && !/^(imw|imh|ima|impolicy)=/.test(part) - }) - query.push("imw=" + width, "imh=" + height, "ima=fit", "impolicy=Letterbox") - return path + "?" + query.join("&") + fragment -} - -// Round up to a step so that small layout changes do not reload the image. -function bucket(value, step) { - return Math.max(step, Math.ceil(value / step) * step) -} diff --git a/legacy/pxmodrim/ui/plugins/workshop/view.py b/legacy/pxmodrim/ui/plugins/workshop/view.py deleted file mode 100644 index 03dfad8..0000000 --- a/legacy/pxmodrim/ui/plugins/workshop/view.py +++ /dev/null @@ -1,867 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from hashlib import sha256 -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import msgspec -from loguru import logger -from PySide6.QtCore import Property, QUrl, Signal, Slot -from PySide6.QtGui import QColor, QDesktopServices -from PySide6.QtQuickWidgets import QQuickWidget -from qasync import asyncSlot - -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.workshop import ( - CatalogCollection, - CatalogError, - CatalogMod, - CatalogPage, - CatalogQuery, - Discover, - DownloadPlan, - WorkshopCatalog, -) -from pxmodrim.core.workshop.catalog import FRESH_SECONDS -from pxmodrim.ui.components.mod_activation import apply_activation -from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route -from pxmodrim.ui.plugins.workshop.details import ( - Detail, - fetch_detail, - incomplete_plan_notice, - queued_notice, -) -from pxmodrim.ui.plugins.workshop.installed import InstalledList -from pxmodrim.ui.plugins.workshop.models import CatalogListModel, item_row, safe_url -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - from PySide6.QtWidgets import QWidget - - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.context import AppContext - -_FAILURES = (CatalogError, RuntimeError, ValueError) -_TABS = ("Discover", "Mods", "Collections", "Favourites", "Installed") - - -def _message(exc: Exception) -> str: - return exc.message if isinstance(exc, CatalogError) else str(exc) - - -class WorkshopViewPanel(BaseViewPanel): - view_id = "workshop" - icon_name = "steam" - label = "Workshop" - changed = Signal() - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - *, - catalog: WorkshopCatalog | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._catalog = catalog or cast( - WorkshopCatalog, ctx.plugins.get("workshop_catalog") - ) - self._downloads = download_manager(ctx) - self.mods_model = CatalogListModel(self) - self.collections_model = CatalogListModel(self) - self.members_model = CatalogListModel(self) - self._tab = "Discover" - self._criteria = CatalogQuery(version=self._game_version or None) - self._busy = False - self._error = "" - self._notice = "" - self._generation = 0 - self._torn_down = False - self._retry: Callable[[], Awaitable[None]] | None = None - self._detail: dict[str, Any] = {} - self._detail_data: Detail | None = None - self._installed = InstalledList() - self._installed_dirty = False - self._route_tasks: set[asyncio.Future[None]] = set() - self._current: tuple[str, str] | None = None - self._trail: list[tuple[str, str, str]] = [] - self._update_count = 0 - self._pending: DownloadPlan | None = None - self._planning = 0 - self._preparing: set[str] = set() - self._collection_mods: dict[str, frozenset[str]] = {} - self._items: dict[str, CatalogMod | CatalogCollection] = {} - self._known_tags: set[str] = set() - self._installed_ids: set[str] = set() - self._active_ids: set[str] = set() - self._pack_ids: frozenset[str] = frozenset() - self._refresh_mod_ids() - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setObjectName("workshopView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - qml_ctx = self._qml.rootContext() - for name, value in ( - ("workshopPanel", self), - ("catalogMods", self.mods_model), - ("catalogCollections", self.collections_model), - ("catalogMembers", self.members_model), - ): - qml_ctx.setContextProperty(name, value) - self._qml.setSource( - QUrl.fromLocalFile(str(Path(__file__).with_name("Workshop.qml"))) - ) - self._root.addWidget(self._qml, 1) - self._downloads.busy_changed.connect(self._on_queue_changed) - self._catalog.queue_changed.connect(self._on_queue_changed) - self._catalog.catalog_url_changed.connect(self._on_url_changed) - self._catalog.installed_changed.connect(self._on_installed_changed) - self._favourites = self._catalog.favourites - self._favourites.changed.connect(self._on_favourites_changed) - self._ctx.active_state_changed.connect(self._on_active_state_changed) - self.destroyed.connect(lambda: self.teardown()) - - def teardown(self) -> None: - if self._torn_down: - return - self._torn_down = True - self._generation += 1 - self._retry = None - self._downloads.busy_changed.disconnect(self._on_queue_changed) - self._catalog.queue_changed.disconnect(self._on_queue_changed) - self._catalog.catalog_url_changed.disconnect(self._on_url_changed) - self._catalog.installed_changed.disconnect(self._on_installed_changed) - self._favourites.changed.disconnect(self._on_favourites_changed) - self._ctx.active_state_changed.disconnect(self._on_active_state_changed) - - @property - def _game_version(self) -> str: - version = self._ctx.target_version - return "" if version == "Unknown" else version - - # -- rows ------------------------------------------------------------- - - def _refresh_mod_ids(self) -> None: - mods = self._ctx.all_mods - self._installed_ids = { - published_id - for mod in mods.values() - if (published_id := mod.published_file_id) is not None - } - self._active_ids = { - published_id - for uuid in self._ctx.active_uuids - if uuid in mods - and (published_id := mods[uuid].published_file_id) is not None - } - - def _row( - self, item: CatalogMod | CatalogCollection, *, detail: bool = False - ) -> dict[str, Any]: - self._items[item.id] = item - self._known_tags.update(set(item.tags) - set(item.supported_versions)) - row = item_row( - item, self._catalog.install_state, self._game_version, detail=detail - ) - row["active"] = isinstance(item, CatalogMod) and item.id in self._active_ids - if isinstance(item, CatalogCollection): - if not detail: - installed_count = sum( - member_id in self._installed_ids for member_id in item.member_ids - ) - if item.member_ids and installed_count == len(item.member_ids): - row["state"], row["stateLabel"] = "installed", "Installed" - row["actionLabel"] = "Installed" - elif installed_count: - row["actionLabel"] = "Complete download" - if item.id in self._favourites: - row["favourite"] = True - self._favourites.remember(item) - if isinstance(item, CatalogCollection) or row["state"] != "installed": - self._mark_progress(item, row) - return row - - def _mark_progress( - self, item: CatalogMod | CatalogCollection, row: dict[str, Any] - ) -> None: - if item.id in self._preparing: - row["queued"], row["actionLabel"] = True, "Preparing…" - return - if isinstance(item, CatalogMod): - ids: frozenset[str] = frozenset((item.id,)) - else: - ids = frozenset(item.member_ids) | self._collection_mods.get( - item.id, frozenset() - ) - if self._detail_data is not None and self._detail_data.item.id == item.id: - ids |= self._pack_ids - if not ids.isdisjoint(self._downloads.active_ids): - row["queued"], row["actionLabel"] = True, "Downloading" - elif not ids.isdisjoint(self._catalog.queued_ids): - row["queued"], row["actionLabel"] = True, "Queued" - - def _rows(self, items: Iterable[CatalogMod | CatalogCollection]) -> list[Any]: - self._refresh_mod_ids() - return [self._row(item) for item in items] - - def _detail_row(self, detail: Detail) -> dict[str, Any]: - row = self._row(detail.item, detail=True) - row["complete"], row["warning"] = detail.complete, detail.warning - if isinstance(detail.item, CatalogCollection): - if not row["previewUrl"] and not row["collage"]: - for member in detail.members: - if not isinstance(member, CatalogMod): - continue - preview = safe_url( - member.preview_url - or next( - (p.url for p in member.previews if p.type == "image"), "" - ) - ) - if preview: - row["collage"].append(preview) - if len(row["collage"]) == 4: - break - started = any( - isinstance(member, CatalogMod) - and self._catalog.install_state(member) != "missing" - for member in detail.members - ) - if not row["queued"]: - row["actionLabel"] = "Complete download" if started else "Download all" - copies = self._pack_copies() - if copies is not None: - active = set(self._ctx.active_uuids) - row["state"], row["stateLabel"] = "installed", "Installed" - if not row["queued"]: - row["actionLabel"] = "Installed" - row["active"] = all(uuid in active for uuid in copies) - return row - - def _refresh_rows(self) -> None: - for model in (self.mods_model, self.collections_model, self.members_model): - model.refresh_rows(lambda row: self._row(self._items[row["itemId"]])) - if self._detail_data is not None: - self._detail = self._detail_row(self._detail_data) - - def _clear_detail(self) -> None: - self._detail, self._detail_data = {}, None - self._current = None - self._trail.clear() - self._pack_ids = frozenset() - - def _pack_copies(self) -> list[str] | None: - """One installed copy per collection mod, or None until all are downloaded.""" - if self._torn_down or not self._pack_ids: - return None - installed: dict[str, list[str]] = {} - for uuid, mod in self._ctx.all_mods.items(): - published_id = mod.published_file_id - if published_id is not None and published_id in self._pack_ids: - installed.setdefault(published_id, []).append(uuid) - if len(installed) < len(self._pack_ids): - return None - active = set(self._ctx.active_uuids) - return [ - next((uuid for uuid in uuids if uuid in active), uuids[0]) - for uuids in installed.values() - ] - - async def _load_pack(self) -> None: - """Resolve the collection's mods and dependencies for the activation buttons.""" - detail, generation = self._detail_data, self._generation - if detail is None: - return - try: - plan = await self._catalog.plan([], [detail.item.id]) - except _FAILURES as exc: - logger.warning( - "[workshop] cannot resolve collection mods: {}", _message(exc) - ) - return - if generation != self._generation: - return - self._pack_ids = ( - frozenset((*plan.to_download, *plan.already_current)) - if plan.is_complete - else frozenset() - ) - self._refresh_rows() - self.changed.emit() - - # -- running fetches -------------------------------------------------- - - async def _run( - self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] - ) -> None: - async def once() -> AsyncIterator[Any]: - yield await fetch() - - await self._run_stream(once, apply) - - async def _run_stream( - self, - stream: Callable[[], AsyncIterator[Any]], - apply: Callable[[Any], None], - ) -> None: - """Apply each value the stream yields; only a failure before the first - value is shown to the user, later ones are background refreshes.""" - if self._torn_down: - return - self._generation += 1 - generation = self._generation - self._busy, self._error = True, "" - self.changed.emit() - - async def retry() -> None: - await self._run_stream(stream, apply) - - self._retry = retry - shown = False - try: - async for result in stream(): - if generation != self._generation: - return - apply(result) - shown = True - self._busy = False - self.changed.emit() - except _FAILURES as exc: - if generation == self._generation: - if shown: - logger.warning( - "[workshop] background refresh failed: {}", _message(exc) - ) - else: - self._error = _message(exc) - finally: - if generation == self._generation: - self._busy = False - self.changed.emit() - if ( - self._installed_dirty - and self._tab == "Installed" - and self._detail_data is None - ): - await self.load() - - async def _run_cached( - self, - key: str, - produce: Callable[[], Awaitable[Any]], - value_type: Any, - apply: Callable[[Any], None], - *, - fresh_for: float = FRESH_SECONDS, - ) -> None: - await self._run_stream( - lambda: self._catalog.cached( - key, produce, fresh_for=fresh_for, value_type=value_type - ), - apply, - ) - - # -- loading tabs ----------------------------------------------------- - - async def load(self, *, append: bool = False) -> None: - if self._torn_down: - return - if not self._catalog.configured: - self._generation += 1 - self._busy = False - self.changed.emit() - return - self._clear_detail() - match self._tab: - case "Discover": - await self._load_discover() - case "Installed": - await self._load_installed(append) - case "Favourites": - self._generation += 1 - self._busy, self._error = False, "" - self._show_favourites() - case _: - await self._load_listing(append) - - async def _load_discover(self) -> None: - def apply(result: Discover) -> None: - self.mods_model.set_page( - self._rows(result.mods.items), result.mods.total, None - ) - self.collections_model.set_page( - self._rows(result.collections.items), result.collections.total, None - ) - - await self._run_cached("discover", self._catalog.discover, Discover, apply) - - def _show_favourites(self) -> None: - query = self._criteria.query.casefold() - items = [ - item for item in self._favourites.items if query in item.title.casefold() - ] - self.collections_model.set_page(self._rows(items), len(items), None) - self.changed.emit() - - async def _load_listing(self, append: bool) -> None: - collections = self._tab == "Collections" - model = self.collections_model if collections else self.mods_model - fetch = self._catalog.collections if collections else self._catalog.mods - query = msgspec.structs.replace( - self._criteria, cursor=model.cursor if append else None - ) - - def apply(page: CatalogPage[Any]) -> None: - model.set_page( - self._rows(page.items), page.total, page.next_cursor, append=append - ) - - if append: - await self._run(lambda: fetch(query), apply) - return - page_type = ( - CatalogPage[CatalogCollection] if collections else CatalogPage[CatalogMod] - ) - await self._run_cached( - f"{self._tab}|{query!r}", lambda: fetch(query), page_type, apply - ) - - async def _load_installed(self, append: bool) -> None: - if append: - self.mods_model.set_page( - self._rows(self._installed.next_page()), - self._installed.total, - self._installed_cursor(), - append=True, - ) - return - - self._installed_dirty = False - - def apply(mods: list[CatalogMod]) -> None: - self._installed.replace(mods) - self._recount() - self._show_installed() - - installed_ids = { - mod.published_file_id - for mod in self._ctx.all_mods.values() - if mod.published_file_id is not None - } - fingerprint = sha256(",".join(sorted(installed_ids)).encode()).hexdigest() - - await self._run_cached( - f"installed|{fingerprint}", - self._catalog.installed_with_updates, - list[CatalogMod], - apply, - ) - - def _installed_cursor(self) -> str | None: - return "more" if self._installed.has_more else None - - def _show_installed(self) -> None: - self._clear_detail() - self.mods_model.set_page( - self._rows(self._installed.visible()), - self._installed.total, - self._installed_cursor(), - ) - self.changed.emit() - - def _recount(self) -> None: - self._update_count = sum( - self._catalog.install_state(mod) == "outdated" - for mod in self._installed.mods - ) - - # -- QML slots -------------------------------------------------------- - - @asyncSlot() - async def refresh(self) -> None: - await self.load() - - @Slot(str) - def selectTab(self, tab: str) -> None: - self._go(tab.lower()) - - async def select_tab(self, tab: str) -> None: - self._tab, self._notice, self._pending = tab, "", None - self._clear_detail() - self._installed.reset_paging() - await self.load() - - @asyncSlot(str, str, str, str, str) - async def filter( - self, query: str, version: str, source: str, sort: str, tag: str - ) -> None: - self._criteria = CatalogQuery( - query=query, - tag=tag or None, - version=version or None, - sort=sort, - source=source, - ) - self._installed.search(query) - if self._tab == "Installed" and self._installed.loaded: - self._show_installed() - return - if self._tab == "Discover": - self._tab = "Mods" - await self.load() - - @asyncSlot() - async def loadMore(self) -> None: - await self.load(append=True) - - @asyncSlot() - async def retry(self) -> None: - if self._retry: - await self._retry() - - @Slot(str, str) - def openItem(self, item_id: str, kind: str) -> None: - self._go(kind, item_id) - - def _go(self, *path: str) -> None: - if self._app_ctx is None: - self.open_route(path) - else: - self._app_ctx.navigate(build_route(self.view_id, *path)) - - async def open_item(self, item_id: str, kind: str, *, record: bool = True) -> None: - self._pack_ids = frozenset() - - def apply(detail: Detail) -> None: - ref = (kind, item_id) - if self._current != ref: - if not self._detail: - self._trail.clear() - elif record and self._current is not None: - title = str(self._detail.get("title", "")) - self._trail.append((*self._current, title)) - self._current = ref - members = self._rows(detail.members) - self._detail_data = detail - self._detail = self._detail_row(detail) - self.members_model.set_page(members, len(members), None) - - await self._run_cached( - f"detail|{kind}|{item_id}", - lambda: fetch_detail(self._catalog, item_id, kind), - Detail, - apply, - ) - if kind == "collection": - await self._load_pack() - - def open_route(self, path: tuple[str, ...]) -> None: - task = asyncio.ensure_future(self.follow_route(path)) - self._route_tasks.add(task) - task.add_done_callback(self._route_tasks.discard) - - async def follow_route(self, path: tuple[str, ...]) -> None: - if self._torn_down or not path: - return - if len(path) == 1 and path[0].title() in _TABS: - await self.select_tab(path[0].title()) - elif len(path) == 2 and path[0] in ("mod", "collection"): - await self.open_item(path[1], path[0]) - else: - logger.warning( - "[workshop] unknown route {}", build_route(self.view_id, *path) - ) - - @asyncSlot() - async def back(self) -> None: - if self._torn_down: - return - if self._trail: - kind, item_id, _ = self._trail[-1] - await self.open_item(item_id, kind, record=False) - if self._current == (kind, item_id) and self._trail: - self._trail.pop() - self.changed.emit() - return - self._generation += 1 - self._clear_detail() - self._error, self._busy = "", False - self.changed.emit() - if self._tab == "Favourites": - self._show_favourites() - elif self._tab == "Installed" and self._installed_dirty: - await self.load() - - @asyncSlot(str, str) - async def downloadItem(self, item_id: str, kind: str) -> None: - await self._download( - [item_id] if kind == "mod" else [], - [item_id] if kind == "collection" else [], - ) - - @asyncSlot(str) - async def toggleActivation(self, item_id: str) -> None: - if self._torn_down: - return - installed = [ - uuid - for uuid, mod in self._ctx.all_mods.items() - if mod.published_file_id == item_id - ] - if not installed: - return - active = set(self._ctx.active_uuids) - active_copies = [uuid for uuid in installed if uuid in active] - if active_copies: - await apply_activation(self._ctx, self, disable=active_copies) - else: - await apply_activation(self._ctx, self, enable=[installed[0]]) - - @asyncSlot() - async def toggleCollection(self) -> None: - copies = self._pack_copies() - if copies is None: - return - active = set(self._ctx.active_uuids) - if all(uuid in active for uuid in copies): - await apply_activation( - self._ctx, - self, - disable=[ - uuid - for uuid in self._ctx.active_uuids - if (mod := self._ctx.all_mods.get(uuid)) is not None - and mod.published_file_id in self._pack_ids - ], - ) - else: - await apply_activation(self._ctx, self, enable=copies) - - @asyncSlot() - async def activateOnlyCollection(self) -> None: - copies = self._pack_copies() - if copies is None: - return - keep = set(copies) - mods = self._ctx.all_mods - others = [ - uuid - for uuid in self._ctx.active_uuids - if uuid not in keep - and (mod := mods.get(uuid)) is not None - and mod.published_file_id is not None - ] - await apply_activation(self._ctx, self, enable=copies, disable=others) - - @asyncSlot() - async def updateAll(self) -> None: - ids = [ - mod.id - for mod in self._installed.mods - if self._catalog.install_state(mod) == "outdated" - ] - if ids: - await self._download(ids, []) - - @asyncSlot() - async def downloadAvailable(self) -> None: - if self._torn_down: - return - plan, self._pending = self._pending, None - if plan and plan.to_download: - self._queue_plan(plan) - self.changed.emit() - - @Slot() - def dismissPlan(self) -> None: - self._pending, self._notice = None, "" - self.changed.emit() - - @Slot(str) - def openLink(self, url: str) -> None: - if safe_url(url): - QDesktopServices.openUrl(QUrl(url)) - - @Slot() - def openSettings(self) -> None: - if self._app_ctx is not None: - self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) - - @Slot(str) - def toggleFavourite(self, item_id: str) -> None: - item = self._items.get(item_id) - if isinstance(item, CatalogCollection): - self._favourites.set(item, item_id not in self._favourites) - - # -- downloads -------------------------------------------------------- - - def _queue_plan(self, plan: DownloadPlan) -> None: - self._catalog.enqueue(plan) - self._notice = queued_notice(len(plan.to_download)) - - async def _download(self, ids: list[str], collections: list[str]) -> None: - if self._torn_down: - return - self._notice, self._pending = "Preparing download…", None - self._planning += 1 - requested = {*ids, *collections} - self._preparing |= requested - self._refresh_rows() - self.changed.emit() - try: - plan = await self._catalog.plan(ids, collections) - except _FAILURES as exc: - if not self._torn_down: - self._notice = f"Could not prepare the download: {_message(exc)}" - return - finally: - self._planning -= 1 - self._preparing -= requested - if not self._torn_down: - self._refresh_rows() - self.changed.emit() - for collection in collections: - self._collection_mods[collection] = frozenset(plan.to_download) - if self._torn_down: - return - if not plan.is_complete: - self._pending, self._notice = plan, incomplete_plan_notice(plan) - elif not plan.to_download: - self._notice = "All available mods are already installed and up to date." - else: - self._queue_plan(plan) - self.changed.emit() - - # -- catalog events --------------------------------------------------- - - def _on_active_state_changed(self, _uuids: tuple[str, ...]) -> None: - self._refresh_mod_ids() - self._refresh_rows() - self.changed.emit() - - def _on_queue_changed(self, *_: object) -> None: - self._refresh_rows() - self.changed.emit() - - def _on_favourites_changed(self, _value: object) -> None: - if self._tab == "Favourites" and self._detail_data is None: - self._show_favourites() - return - self._refresh_rows() - self.changed.emit() - - @asyncSlot(str) - async def _on_url_changed(self, _url: str) -> None: - if self._torn_down: - return - self._clear_detail() - self._error, self._notice, self._pending = "", "", None - self._items.clear() - self._known_tags.clear() - for model in (self.mods_model, self.collections_model, self.members_model): - model.set_page([], 0, None) - await self.load() - - @asyncSlot(object) - async def _on_installed_changed(self, _value: None) -> None: - if self._torn_down: - return - self._installed_dirty = True - self._refresh_mod_ids() - self._recount() - self._refresh_rows() - self.changed.emit() - if self._tab == "Installed" and not self._busy and self._detail_data is None: - await self.load() - - # -- QML properties --------------------------------------------------- - - @Property(str, notify=changed) # type: ignore[arg-type] - def tab(self) -> str: - return self._tab - - @Property(str, notify=changed) # type: ignore[arg-type] - def url(self) -> str: - if self._current is not None: - return build_route(self.view_id, *self._current) - return build_route(self.view_id, self._tab.lower()) - - @Property(str, notify=changed) # type: ignore[arg-type] - def backLabel(self) -> str: - if self._trail: - return f"Back to {self._trail[-1][2]}" - return f"Back to {self._tab}" - - @Property(bool, notify=changed) # type: ignore[arg-type] - def configured(self) -> bool: - return self._catalog.configured - - @Property(bool, notify=changed) # type: ignore[arg-type] - def busy(self) -> bool: - return self._busy - - @Property(str, notify=changed) # type: ignore[arg-type] - def error(self) -> str: - return self._error - - @Property(str, notify=changed) # type: ignore[arg-type] - def notice(self) -> str: - return self._notice - - @Property(dict, notify=changed) # type: ignore[arg-type] - def detail(self) -> dict[str, Any]: - return self._detail - - @Property(bool, notify=changed) # type: ignore[arg-type] - def hasDetail(self) -> bool: - return bool(self._detail) - - @Property(bool, notify=changed) # type: ignore[arg-type] - def pendingPlan(self) -> bool: - return self._pending is not None - - @Property(bool, notify=changed) # type: ignore[arg-type] - def canDownloadAvailable(self) -> bool: - return self._pending is not None and bool(self._pending.to_download) - - @Property(str, notify=changed) # type: ignore[arg-type] - def searchQuery(self) -> str: - return self._criteria.query - - @Property(list, notify=changed) # type: ignore[arg-type] - def tagOptions(self) -> list[str]: - return ["All tags", *sorted(self._known_tags)] - - @Property(str, constant=True) # type: ignore[arg-type] - def gameVersion(self) -> str: - return self._game_version - - @Property(list, constant=True) # type: ignore[arg-type] - def versionOptions(self) -> list[str]: - current = self._criteria.version - return list( - dict.fromkeys( - [ - "All versions", - *([current] if current else []), - "1.6", - "1.5", - "1.4", - "1.3", - ] - ) - ) - - @Property(int, notify=changed) # type: ignore[arg-type] - def updateCount(self) -> int: - return self._update_count - - @Property(bool, notify=changed) # type: ignore[arg-type] - def planning(self) -> bool: - return self._planning > 0 diff --git a/legacy/pxmodrim/ui/settings_section.py b/legacy/pxmodrim/ui/settings_section.py deleted file mode 100644 index 03610e5..0000000 --- a/legacy/pxmodrim/ui/settings_section.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from pathlib import Path -from typing import Any, ClassVar - -import msgspec -from PySide6.QtCore import Property, QObject, Slot - -from pxmodrim.core.plugin_config import PluginConfig - - -class SettingsSection(QObject): - """A plugin's group on the Settings dialog. - - ``source`` is loaded into a group titled ``title`` with this object as its - required ``section`` property. Edits stay on the object until the dialog is - saved, when ``apply()`` commits them; a cancelled dialog just drops it. - """ - - title: ClassVar[str] - source: ClassVar[Path] - - def apply(self) -> None: ... - - -type SettingsSectionFactory = Callable[[QObject], SettingsSection] - - -class PluginConfigSection(SettingsSection): - """Edits a ``PluginConfig``: QML reads ``initial`` and reports edits via ``set``.""" - - def __init__(self, config: PluginConfig[Any], parent: QObject) -> None: - super().__init__(parent) - self._config = config - self._initial: dict[str, Any] = msgspec.to_builtins(config.value) - self._draft: dict[str, Any] = {} - - @Property(dict, constant=True) # type: ignore[arg-type] - def initial(self) -> dict[str, Any]: - return self._initial - - @Slot(str, "QVariant") - def set(self, key: str, value: Any) -> None: - if key not in self._initial: - raise KeyError(key) - self._draft[key] = value - - def apply(self) -> None: - if self._draft: - self._config.update( - msgspec.convert( - {**self._initial, **self._draft}, - type(self._config.value), - strict=False, - ) - ) diff --git a/legacy/pxmodrim/ui/theme/__init__.py b/legacy/pxmodrim/ui/theme/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/ui/theme/constants.py b/legacy/pxmodrim/ui/theme/constants.py deleted file mode 100644 index c5dd91c..0000000 --- a/legacy/pxmodrim/ui/theme/constants.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEasingCurve - -# Animation -ACCORDION_ANIMATION_DURATION_MS = 200 -ACCORDION_EASING = QEasingCurve.Type.OutCubic - -# Banner -BANNER_MAX_HEIGHT = 260 -BANNER_ASPECT_W = 16 -BANNER_ASPECT_H = 9 -BANNER_OVERLAY_HEIGHT = 120 - -# Layout -RAIL_MIN_WIDTH = 52 -RAIL_MAX_WIDTH = 180 -RAIL_COLLAPSE_WIDTH = 116 -SIDEBAR_WIDTH = 240 - -# Interaction -TOOLTIP_DELAY_MS = 400 diff --git a/legacy/pxmodrim/ui/theme/palette.py b/legacy/pxmodrim/ui/theme/palette.py deleted file mode 100644 index a486711..0000000 --- a/legacy/pxmodrim/ui/theme/palette.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from string import Template - -from PySide6.QtGui import QColor - - -def _tint(r: int, g: int, b: int, alpha: float) -> tuple[str, str]: - """(QSS rgba() string, QML #AARRGGBB string) for one translucent color.""" - qml = f"#{round(alpha * 255):02x}{r:02x}{g:02x}{b:02x}" - return f"rgba({r},{g},{b},{alpha})", qml - - -_PRIMARY_BG = _tint(102, 192, 244, 0.1) -_SUCCESS_BG = _tint(59, 165, 93, 0.15) -_WARNING_BG = _tint(249, 168, 37, 0.15) -_DANGER_BG = _tint(237, 66, 69, 0.15) - -# *_BG entries are rgba() for QSS only; QML rejects rgba() strings (renders opaque -# black), so QML consumers must use the *_BG_QML entries. -PALETTE: dict[str, str] = { - "ELEVATE_0": "#0b0d10", - "ELEVATE_1": "#121418", - "ELEVATE_2": "#1a1d21", - "ELEVATE_3": "#22262b", - "ELEVATE_4": "#2c3036", - "SURFACE": "#25282e", - "BORDER": "#2f333a", - "TEXT_MAIN": "#f2f3f5", - "TEXT_MUTED": "#949ba4", - "TEXT_DIM": "#6c737f", - "TEXT_ON_ACCENT": "#0b0d10", - "NEUTRAL": "#6b7280", - "PRIMARY": "#66c0f4", - "PRIMARY_HOVER": "#4aa8d8", - "PRIMARY_BG": _PRIMARY_BG[0], - "PRIMARY_BG_QML": _PRIMARY_BG[1], - "SUCCESS": "#3ba55d", - "SUCCESS_HOVER": "#2f8a4c", - "SUCCESS_BG": _SUCCESS_BG[0], - "SUCCESS_BG_QML": _SUCCESS_BG[1], - "WARNING": "#f9a825", - "WARNING_BG": _WARNING_BG[0], - "WARNING_BG_QML": _WARNING_BG[1], - "DANGER": "#ed4245", - "DANGER_HOVER": "#d1383b", - "DANGER_BG": _DANGER_BG[0], - "DANGER_BG_QML": _DANGER_BG[1], -} - -# QColor variants for QPainter delegates -PANEL_BG_Q = QColor(PALETTE["ELEVATE_2"]) -TEXT_MAIN_Q = QColor(PALETTE["TEXT_MAIN"]) -TEXT_MUTED_Q = QColor(PALETTE["TEXT_MUTED"]) - - -def get_stylesheet() -> str: - qss_path = Path(__file__).parent / "style.qss" - raw = qss_path.read_text() - return Template(raw).safe_substitute(PALETTE) diff --git a/legacy/pxmodrim/ui/theme/qml_theme.py b/legacy/pxmodrim/ui/theme/qml_theme.py deleted file mode 100644 index 0d6f705..0000000 --- a/legacy/pxmodrim/ui/theme/qml_theme.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import base64 -import random -from importlib.resources import files as resource_files - -from PySide6.QtCore import Property, QObject, Signal - -from pxmodrim.ui.theme import constants -from pxmodrim.ui.theme.palette import PALETTE - - -class Theme(QObject): - changed = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._logo_variants = self._load_logos() - self._logo_data_uri = random.choice(self._logo_variants) - - @staticmethod - def _load_logos() -> list[str]: - uris = [] - for n in range(1, 4 + 1): - raw = ( - resource_files("pxmodrim.ui.assets") / f"logo_nobg_{n}.svg" - ).read_bytes() - encoded = base64.b64encode(raw).decode("ascii") - uris.append(f"data:image/svg+xml;base64,{encoded}") - return uris - - # ── Elevation backgrounds ──────────────────────────── - - @Property(str, notify=changed) - def elevate0(self) -> str: - return PALETTE["ELEVATE_0"] - - @Property(str, notify=changed) - def elevate1(self) -> str: - return PALETTE["ELEVATE_1"] - - @Property(str, notify=changed) - def elevate2(self) -> str: - return PALETTE["ELEVATE_2"] - - @Property(str, notify=changed) - def elevate3(self) -> str: - return PALETTE["ELEVATE_3"] - - @Property(str, notify=changed) - def elevate4(self) -> str: - return PALETTE["ELEVATE_4"] - - @Property(str, notify=changed) - def border(self) -> str: - return PALETTE["BORDER"] - - # ── Text ───────────────────────────────────────────── - - @Property(str, notify=changed) - def textMain(self) -> str: - return PALETTE["TEXT_MAIN"] - - @Property(str, notify=changed) - def textMuted(self) -> str: - return PALETTE["TEXT_MUTED"] - - @Property(str, notify=changed) - def textDim(self) -> str: - return PALETTE["TEXT_DIM"] - - @Property(str, notify=changed) - def onAccent(self) -> str: - return PALETTE["TEXT_ON_ACCENT"] - - @Property(str, notify=changed) - def neutral(self) -> str: - return PALETTE["NEUTRAL"] - - # ── Accent / Semantic (*Bg are #AARRGGBB, valid QML colors) ── - - @Property(str, notify=changed) - def primary(self) -> str: - return PALETTE["PRIMARY"] - - @Property(str, notify=changed) - def primaryHover(self) -> str: - return PALETTE["PRIMARY_HOVER"] - - @Property(str, notify=changed) - def primaryBg(self) -> str: - return PALETTE["PRIMARY_BG_QML"] - - @Property(str, notify=changed) - def success(self) -> str: - return PALETTE["SUCCESS"] - - @Property(str, notify=changed) - def successHover(self) -> str: - return PALETTE["SUCCESS_HOVER"] - - @Property(str, notify=changed) - def successBg(self) -> str: - return PALETTE["SUCCESS_BG_QML"] - - @Property(str, notify=changed) - def warning(self) -> str: - return PALETTE["WARNING"] - - @Property(str, notify=changed) - def warningBg(self) -> str: - return PALETTE["WARNING_BG_QML"] - - @Property(str, notify=changed) - def danger(self) -> str: - return PALETTE["DANGER"] - - @Property(str, notify=changed) - def dangerHover(self) -> str: - return PALETTE["DANGER_HOVER"] - - @Property(str, notify=changed) - def dangerBg(self) -> str: - return PALETTE["DANGER_BG_QML"] - - # ── Design tokens ──────────────────────────────────── - - @Property(str, constant=True) - def fontFamily(self) -> str: - return "Source Sans 3, Liberation Sans, sans-serif" - - @Property(str, constant=True) - def fontMono(self) -> str: - return "DejaVu Sans Mono, Liberation Mono, monospace" - - @Property(int, constant=True) - def fontSizeXs(self) -> int: - return 10 - - @Property(int, constant=True) - def fontSizeSm(self) -> int: - return 11 - - @Property(int, constant=True) - def fontSizeMd(self) -> int: - return 13 - - @Property(int, constant=True) - def fontSizeLg(self) -> int: - return 14 - - @Property(int, constant=True) - def fontSizeXl(self) -> int: - return 18 - - @Property(int, constant=True) - def radiusXs(self) -> int: - return 3 - - @Property(int, constant=True) - def radiusSm(self) -> int: - return 4 - - @Property(int, constant=True) - def radiusMd(self) -> int: - return 6 - - @Property(int, constant=True) - def radiusLg(self) -> int: - return 8 - - @Property(int, constant=True) - def radiusPill(self) -> int: - return 999 - - @Property(int, constant=True) - def scrollbarWidth(self) -> int: - return 6 - - @Property(int, constant=True) - def tooltipDelay(self) -> int: - return constants.TOOLTIP_DELAY_MS - - @Property(float, constant=True) - def disabledOpacity(self) -> float: - return 0.45 - - @Property(str, constant=True) - def overlay(self) -> str: - return "#8c000000" - - @Property(int, constant=True) - def railMinWidth(self) -> int: - return constants.RAIL_MIN_WIDTH - - @Property(int, constant=True) - def railMaxWidth(self) -> int: - return constants.RAIL_MAX_WIDTH - - @Property(int, constant=True) - def railCollapseWidth(self) -> int: - return constants.RAIL_COLLAPSE_WIDTH - - @Property(int, constant=True) - def sidebarWidth(self) -> int: - return constants.SIDEBAR_WIDTH - - @Property(list, constant=True) - def logoVariants(self) -> list[str]: - return self._logo_variants - - @Property(str, constant=True) - def logoFileDataUri(self) -> str: - return self._logo_data_uri diff --git a/legacy/pxmodrim/ui/theme/style.qss b/legacy/pxmodrim/ui/theme/style.qss deleted file mode 100644 index 8071ea9..0000000 --- a/legacy/pxmodrim/ui/theme/style.qss +++ /dev/null @@ -1,663 +0,0 @@ -/* ── Base ── */ -QMainWindow, -QDialog { - background-color: $ELEVATE_0; - color: $TEXT_MAIN; -} - -QWidget { - color: $TEXT_MAIN; - font-family: "Source Sans 3", "Liberation Sans", sans-serif; -} - -/* ── Panel containers ── */ -QWidget#outerContainer { - background-color: $ELEVATE_0; -} -QWidget#contentArea { - background-color: $ELEVATE_0; -} -QWidget#sidebarPanel { - background-color: $ELEVATE_2; -} -QWidget#modListPanel { - background-color: $ELEVATE_1; -} -QWidget#modInfoPanel { - background-color: $ELEVATE_2; -} - -/* ── Transparent overrides ── */ -QScrollArea, -QWidget#accordionContent, -QWidget#toastManager { - background-color: transparent; -} - -/* ── Menu Bar ── */ -QMenuBar { - background-color: $ELEVATE_1; - border-bottom: 1px solid $BORDER; - padding: 2px 4px; - color: $TEXT_MUTED; - font-size: 13px; -} -QMenuBar::item { - padding: 4px 10px; - border-radius: 4px; - background: transparent; -} -QMenuBar::item:selected { - background-color: $ELEVATE_3; - color: $TEXT_MAIN; -} - -QMenu { - background-color: $ELEVATE_2; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 4px; - color: $TEXT_MAIN; -} -QMenu::item { - padding: 6px 28px 6px 12px; - border-radius: 4px; -} -QMenu::item:selected { - background-color: $ELEVATE_3; -} -QMenu::item:disabled { - color: $TEXT_DIM; -} -QMenu::item:disabled:selected { - background-color: transparent; -} -QMenu::separator { - height: 1px; - background-color: $BORDER; - margin: 4px 8px; -} - -/* ── Buttons ── */ -QPushButton { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - color: $TEXT_MAIN; - font-size: 13px; - font-weight: 500; -} -QPushButton#appBtn, -QPushButton#primaryAction { - padding: 6px 14px; - min-height: 18px; -} -QPushButton:hover { - background-color: $ELEVATE_4; - border-color: $ELEVATE_4; -} -QPushButton:pressed { - background-color: $ELEVATE_4; -} -QPushButton#primaryAction { - background-color: $PRIMARY; - border-color: $PRIMARY; - color: $ELEVATE_0; - font-weight: 600; -} -QPushButton#primaryAction:hover { - background-color: $PRIMARY_HOVER; - border-color: $PRIMARY_HOVER; -} -/* Platform themes (e.g. Breeze) add dark icons to standard dialog buttons that - vanish on our dark background; drop them and match AppButton sizing. */ -QDialogButtonBox { - dialogbuttonbox-buttons-have-icons: 0; -} -QMessageBox QPushButton, -QDialogButtonBox QPushButton { - padding: 6px 14px; - min-height: 18px; - min-width: 64px; -} - -/* ── Line Edit ── */ -QLineEdit { - background-color: $ELEVATE_0; - border: 1px solid $BORDER; - border-radius: 6px; - color: $TEXT_MAIN; - padding: 7px 10px; - font-size: 13px; - selection-background-color: $PRIMARY; -} -QLineEdit:focus { - border-color: $PRIMARY; -} -QWidget#searchBox { - background-color: $ELEVATE_1; - border-bottom: 1px solid $BORDER; -} - -/* ── Combo Box ── */ -QComboBox { - background-color: $ELEVATE_0; - border: 1px solid $BORDER; - border-radius: 6px; - color: $TEXT_MAIN; - padding: 6px 10px; - font-size: 13px; - min-width: 80px; -} -QComboBox:hover { - border-color: $ELEVATE_4; -} -QComboBox:focus { - border-color: $PRIMARY; -} -QComboBox::drop-down { - subcontrol-origin: padding; - subcontrol-position: top right; - width: 24px; - border-left: 1px solid $BORDER; - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; -} -QComboBox::down-arrow { - width: 8px; - height: 8px; -} -QComboBox QAbstractItemView { - background-color: $ELEVATE_2; - border: 1px solid $BORDER; - border-radius: 6px; - selection-background-color: $ELEVATE_3; - selection-color: $TEXT_MAIN; - color: $TEXT_MAIN; - outline: none; - padding: 4px; -} - -/* ── Item Views ── */ -QTableWidget, -QTableView, -QListWidget, -QListView, -QTreeWidget, -QTreeView { - background-color: $ELEVATE_0; - alternate-background-color: $ELEVATE_1; - border: 1px solid $BORDER; - border-radius: 6px; - color: $TEXT_MAIN; - gridline-color: $BORDER; - selection-background-color: $PRIMARY; - selection-color: $ELEVATE_0; - outline: none; -} -QTableWidget::item, -QTableView::item { - padding: 4px 8px; -} -QListWidget::item:disabled, -QListView::item:disabled, -QTreeWidget::item:disabled, -QTreeView::item:disabled { - color: $TEXT_DIM; -} -QListWidget::item, -QListView::item, -QTreeWidget::item, -QTreeView::item { - padding: 4px 8px; - border-radius: 4px; -} -QListWidget::item:hover, -QListView::item:hover, -QTreeWidget::item:hover, -QTreeView::item:hover { - background-color: $ELEVATE_3; -} -QTableWidget::item:selected, -QTableView::item:selected, -QListWidget::item:selected, -QListView::item:selected, -QTreeWidget::item:selected, -QTreeView::item:selected { - background-color: $PRIMARY; - color: $ELEVATE_0; -} -QHeaderView { - background-color: $ELEVATE_2; -} -QHeaderView::section { - background-color: $ELEVATE_2; - color: $TEXT_DIM; - border: none; - border-bottom: 1px solid $BORDER; - padding: 6px 8px; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.3px; -} -QTableCornerButton::section { - background-color: $ELEVATE_2; - border: none; -} - -/* ── Scroll Area ── */ -QScrollArea { - border: none; - background-color: transparent; -} - -/* ── Scroll Bars ── */ -QScrollBar:vertical { - background-color: transparent; - width: 6px; - margin: 0; -} -QScrollBar::handle:vertical { - background-color: $ELEVATE_4; - border-radius: 3px; - min-height: 30px; - margin: 2px; -} -QScrollBar::handle:vertical:hover { - background-color: $TEXT_DIM; -} -QScrollBar::add-line:vertical, -QScrollBar::sub-line:vertical { - height: 0; -} -QScrollBar::add-page:vertical, -QScrollBar::sub-page:vertical { - background: none; -} -QScrollBar:horizontal { - background-color: transparent; - height: 6px; - margin: 0; -} -QScrollBar::handle:horizontal { - background-color: $ELEVATE_4; - border-radius: 3px; - min-width: 30px; - margin: 2px; -} -QScrollBar::handle:horizontal:hover { - background-color: $TEXT_DIM; -} -QScrollBar::add-line:horizontal, -QScrollBar::sub-line:horizontal { - width: 0; -} -QScrollBar::add-page:horizontal, -QScrollBar::sub-page:horizontal { - background: none; -} - -/* ── Dialog / Organizer labels ── */ -QLabel#dialogTitle { - font-size: 15px; - font-weight: 600; -} -QLabel#organizerStatus { - color: $TEXT_DIM; - padding: 5px 12px; -} - -/* ── Info Panel ── */ -QWidget#infoArea { - background-color: $ELEVATE_2; -} -QWidget#depsSection { - background-color: transparent; -} - -/* ── Placeholder ── */ -QLabel#placeholder { - color: $TEXT_DIM; - font-size: 14px; - padding: 32px; -} - -/* ── Description Browser ── */ -QTextBrowser#descriptionBrowser { - background: transparent; - border: none; - padding: 0; - font-size: 13px; - color: $TEXT_MUTED; - line-height: 1.5; -} - -/* ── Meta Chip ── */ -QWidget#metaChip { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 0; - min-width: 120px; -} -QLabel#metaChipLabel { - color: $TEXT_DIM; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; -} -QLabel#metaChipValue { - font-weight: 500; - font-size: 13px; - color: $TEXT_MAIN; -} - -/* ── Issues Label ── */ -QLabel#issuesLabel { - color: $TEXT_DIM; - font-style: italic; - font-size: 12px; -} - -/* ── Meta Labels ── */ -QLabel#metaLabel { - color: $TEXT_DIM; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; -} -QLabel#metaValue { - font-weight: 500; - font-size: 13px; - color: $TEXT_MAIN; -} -QWidget#metaWidget { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 8px 12px; -} - -/* ── Status Bar ── */ -QStatusBar { - background-color: $ELEVATE_2; - border-top: 1px solid $BORDER; - color: $TEXT_MUTED; - font-size: 11px; - padding: 2px 8px; -} - -/* ── Tool Tip ── */ -QToolTip { - background-color: $ELEVATE_2; - border: 1px solid $BORDER; - border-radius: 6px; - color: $TEXT_MAIN; - padding: 6px 10px; - font-size: 11px; -} - -/* ── Accordion ── */ -QWidget#accordionHeader { - background: transparent; - border: none; - border-bottom: 1px solid $BORDER; -} -QWidget#accordionHeader:hover { - background: $ELEVATE_3; -} -QLabel#accordionTitle { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: $TEXT_DIM; -} -QLabel#accordionChevron { - font-size: 10px; - font-weight: 600; - color: $TEXT_DIM; - background: transparent; -} -QWidget#accordionContent { - background: transparent; -} - -/* ── Group Box ── */ -QGroupBox { - border: 1px solid $BORDER; - border-radius: 6px; - margin-top: 12px; - padding: 16px 12px 12px; - font-size: 12px; - font-weight: 600; - color: $TEXT_MAIN; -} -QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 0 8px; - color: $TEXT_DIM; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.3px; -} - -/* ── Check Box ── */ -QCheckBox { - color: $TEXT_MAIN; - font-size: 13px; - spacing: 8px; -} -QCheckBox::indicator { - width: 16px; - height: 16px; - border: 1.5px solid $BORDER; - border-radius: 3px; - background: transparent; -} -QCheckBox::indicator:hover { - border-color: $TEXT_DIM; -} -QCheckBox::indicator:checked { - background: $PRIMARY; - border-color: $PRIMARY; -} - -/* ── Tab Widget ── */ -QTabWidget::pane { - border: none; - background: transparent; -} -QTabBar::tab { - background: transparent; - color: $TEXT_MUTED; - padding: 8px 16px; - border-bottom: 2px solid transparent; - font-size: 12px; -} -QTabBar::tab:selected { - color: $PRIMARY; - border-bottom-color: $PRIMARY; -} -QTabBar::tab:hover { - color: $TEXT_MAIN; -} - - - -/* ── Dialog chrome (frameless dialogs) ── */ -QDialog[chromed="true"] { - border: 1px solid $BORDER; -} -QWidget#dialogTitleBar { - background-color: $ELEVATE_1; - border-bottom: 1px solid $BORDER; -} -QLabel#dialogTitleText { - background: transparent; - font-weight: 600; - font-size: 13px; -} -QPushButton#dialogCloseBtn { - background-color: $DANGER_BG; - border: 1px solid $DANGER_BG; - border-radius: 6px; - padding: 0; -} -QPushButton#dialogCloseBtn:hover { - background-color: $DANGER; - border-color: $DANGER; -} - -/* ── Icon Buttons ── */ -QPushButton#iconBtn { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 0; - min-height: 0; -} -QPushButton#iconBtn:hover { - background-color: $ELEVATE_4; - border-color: $ELEVATE_4; -} -QPushButton#iconBtn[primary="true"] { - background-color: $PRIMARY; - border-color: $PRIMARY; -} -QPushButton#iconBtn[primary="true"]:hover { - background-color: $PRIMARY_HOVER; - border-color: $PRIMARY_HOVER; -} - -/* ── Toast ── */ -QWidget#toast { - background-color: $SURFACE; - border: 1px solid $BORDER; - border-radius: 8px; -} -QLabel#toastMessage { - color: $TEXT_MAIN; - font-size: 12px; -} -QPushButton#toastClose { - background: transparent; - border: none; - border-radius: 3px; - padding: 0; - min-height: 0; -} -QPushButton#toastClose:hover { - background-color: $ELEVATE_3; -} -QWidget#toastManager { - background: transparent; - border: none; -} - -/* ── Settings Dialog ── */ -QWidget#settingsPanel { - background-color: $ELEVATE_1; -} -QWidget#settingsPanel QTabWidget::pane { - background-color: $ELEVATE_1; -} -QWidget#settingsPanel QGroupBox { - background-color: $ELEVATE_2; -} -QWidget#settingsPanel QFormLayout { - background-color: transparent; -} - -/* ── Progress Bar ── */ -QProgressBar { - border: 1px solid $BORDER; - border-radius: 4px; - background: $ELEVATE_0; - text-align: center; - font-size: 11px; - height: 18px; -} -QProgressBar::chunk { - background: $PRIMARY; - border-radius: 3px; -} -QProgressBar#thinProgress { - border: none; - border-radius: 2px; - background: $ELEVATE_3; - height: 4px; - max-height: 4px; - min-height: 4px; - text-align: left; -} -QProgressBar#thinProgress::chunk { - border-radius: 2px; -} - -/* ── About Dialog ── */ -QDialog#aboutPanel, -QDialog#creditsDialog { - background-color: $ELEVATE_1; -} -QFrame#aboutHero { - background-color: $ELEVATE_2; - border: 1px solid $BORDER; - border-radius: 10px; -} -QLabel#aboutTitle, -QLabel#creditsTitle { - font-size: 24px; - font-weight: 700; - color: $TEXT_MAIN; -} -QLabel#aboutVersionBadge { - background-color: $PRIMARY; - border-radius: 8px; - color: $ELEVATE_0; - font-size: 11px; - font-weight: 700; - padding: 3px 8px; -} -QLabel#aboutTagline { - color: $TEXT_MAIN; - font-size: 15px; - font-weight: 600; -} -QLabel#aboutDescription, -QLabel#aboutStory { - color: $TEXT_MUTED; - font-size: 13px; -} -QLabel#aboutSectionTitle, -QLabel#aboutMetaLabel { - color: $TEXT_DIM; - font-size: 10px; - font-weight: 700; -} -QFrame#aboutDetails { - background-color: $ELEVATE_0; - border: 1px solid $BORDER; - border-radius: 8px; -} -QLabel#aboutMetaValue { - color: $TEXT_MAIN; - font-size: 12px; -} -QLabel#aboutDisclaimer { - color: $TEXT_DIM; - font-size: 11px; - font-style: italic; -} -QTextBrowser#creditsBrowser { - background-color: $ELEVATE_0; - border: 1px solid $BORDER; - border-radius: 8px; - color: $TEXT_MAIN; - padding: 12px; -} diff --git a/legacy/pxmodrim/ui/ui_prefs.py b/legacy/pxmodrim/ui/ui_prefs.py deleted file mode 100644 index 0eefe23..0000000 --- a/legacy/pxmodrim/ui/ui_prefs.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.constants import LaunchStrategy - - -class UIPrefs(msgspec.Struct): - desc_expanded: bool = False - launch_strategy: LaunchStrategy = LaunchStrategy.DIRECT - skipped_update_tag: str = "" - rail_collapsed: bool = False diff --git a/legacy/pxmodrim/ui/views/__init__.py b/legacy/pxmodrim/ui/views/__init__.py deleted file mode 100644 index fc7e15f..0000000 --- a/legacy/pxmodrim/ui/views/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""UI view panels for PxModRim.""" - -from __future__ import annotations - -from pxmodrim.ui.views.base import BaseViewPanel -from pxmodrim.ui.views.mods_view import ModsViewPanel - -__all__ = [ - "BaseViewPanel", - "ModsViewPanel", -] diff --git a/legacy/pxmodrim/ui/views/base.py b/legacy/pxmodrim/ui/views/base.py deleted file mode 100644 index 0bf0c0b..0000000 --- a/legacy/pxmodrim/ui/views/base.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.context import RailView -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class BaseViewPanel(RailView): - """ - UI-side whole-window view contributed to the left icon rail. - - Core never imports this; views are discovered entirely within ``ui/``. - Subclasses must define ``view_id``, ``icon_name`` and ``label``. - """ - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx) - self._ctx = ctx - self._app_ctx = app_ctx - self._qml_engine = qml_engine - self._ui_prefs = app_ctx.ui_prefs if app_ctx is not None else UIPrefs() - self.setAutoFillBackground(True) - self._root = QVBoxLayout(self) - self._root.setContentsMargins(0, 0, 0, 0) - self._root.setSpacing(0) - self.set_clear_color() - - def set_clear_color(self, color: str = PALETTE["ELEVATE_0"]) -> None: - from PySide6.QtGui import QPalette - - palette = self.palette() - palette.setColor(QPalette.ColorRole.Window, QColor(color)) - self.setPalette(palette) diff --git a/legacy/pxmodrim/ui/views/mods_view.py b/legacy/pxmodrim/ui/views/mods_view.py deleted file mode 100644 index fcc94f5..0000000 --- a/legacy/pxmodrim/ui/views/mods_view.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import Qt, Signal - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QHBoxLayout, QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel -from pxmodrim.ui.panels.mod_list_panel import ModListPanel -from pxmodrim.ui.panels.sidebar_panel import SidebarPanel -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.views.base import BaseViewPanel - - -class ModsViewPanel(BaseViewPanel): - view_id = "mods" - icon_name = "mods" - label = "Mods" - - entry_selected = Signal(object) - mod_selected = Signal(str) - active_mods_changed = Signal() - order_changed = Signal() - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - """Initialize mods view with sidebar, mod list, and mod info panels.""" - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - - content = QWidget(self) - content.setObjectName("contentArea") - h_layout = QHBoxLayout(content) - h_layout.setContentsMargins(0, 0, 0, 0) - h_layout.setSpacing(0) - - self.sidebar = SidebarPanel(self._ctx, self._qml_engine, content) - self.sidebar.setObjectName("sidebarPanel") - self.sidebar.setFixedWidth(SIDEBAR_WIDTH) - self.sidebar.entry_selected.connect(self.entry_selected) - h_layout.addWidget(self.sidebar) - - self.mod_list = ModListPanel(self._ctx, self._qml_engine, content) - self.mod_list.setObjectName("modListPanel") - self.mod_list.mod_selected.connect(self.mod_selected) - self.mod_list.active_mods_changed.connect( - self.active_mods_changed, Qt.ConnectionType.QueuedConnection - ) - self.mod_list.order_changed.connect( - self.order_changed, Qt.ConnectionType.QueuedConnection - ) - h_layout.addWidget(self.mod_list, stretch=3) - - self.mod_info = ModInfoPanel( - self._ctx, self._qml_engine, ui_prefs=self._ui_prefs, parent=content - ) - self.mod_info.setObjectName("modInfoPanel") - self.mod_info.setMinimumWidth(300) - h_layout.addWidget(self.mod_info, stretch=2) - - self._root.addWidget(content, stretch=1) - - def apply_sidebar_entry(self, entry: object) -> None: - self.mod_list.clearSelection() - from pxmodrim.core.models.view.sidebar import SidebarEntry - - if isinstance(entry, SidebarEntry): - self.mod_list.set_sidebar_filter(entry.visible_uuids) - - def apply_current_sidebar_filter(self) -> None: - entry = self.sidebar.current_entry() - if entry is not None: - self.mod_list.set_sidebar_filter(entry.visible_uuids) diff --git a/legacy/pxmodrim/ui/window/__init__.py b/legacy/pxmodrim/ui/window/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/pxmodrim/ui/window/actions.py b/legacy/pxmodrim/ui/window/actions.py deleted file mode 100644 index 8350438..0000000 --- a/legacy/pxmodrim/ui/window/actions.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum - -from PySide6.QtGui import QAction, QKeySequence -from PySide6.QtWidgets import QWidget - - -class ActionId(StrEnum): - SAVE = "save" - RESTORE = "restore" - SETTINGS = "settings" - QUIT = "quit" - REFRESH = "refresh" - FULL_RESCAN = "full_rescan" - AUTO_SORT = "auto_sort" - UPDATE_MODS = "update_mods" - FOCUS_SEARCH = "focus_search" - NEXT_VIEW = "next_view" - PREV_VIEW = "prev_view" - FULLSCREEN = "fullscreen" - REPORT_ISSUE = "report_issue" - UPLOAD_LOGS = "upload_logs" - OPEN_LOGS = "open_logs" - SHORTCUTS = "shortcuts" - CHECK_UPDATES = "check_updates" - ABOUT = "about" - - -@dataclass(frozen=True, slots=True) -class ActionSpec: - id: ActionId - menu_text: str - label: str - shortcut: str = "" - - def tooltip(self) -> str: - if not self.shortcut: - return self.label - return f"{self.label} ({_native(self.shortcut)})" - - -ACTION_SPECS: tuple[ActionSpec, ...] = ( - ActionSpec(ActionId.SAVE, "&Save Mod List", "Save mod list", "Ctrl+S"), - ActionSpec(ActionId.RESTORE, "&Restore Mod List\u2026", "Restore mod list"), - ActionSpec(ActionId.SETTINGS, "&Settings\u2026", "Settings", "Ctrl+,"), - ActionSpec(ActionId.QUIT, "&Quit", "Quit", "Ctrl+Q"), - ActionSpec(ActionId.REFRESH, "&Refresh Mods", "Refresh mods", "F5"), - ActionSpec( - ActionId.FULL_RESCAN, "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R" - ), - ActionSpec(ActionId.AUTO_SORT, "&Auto-Sort Mods", "Auto-sort mods"), - ActionSpec(ActionId.UPDATE_MODS, "&Update Mods", "Update downloaded mods"), - ActionSpec(ActionId.FOCUS_SEARCH, "Focus &Search", "Focus search", "Ctrl+F"), - ActionSpec(ActionId.NEXT_VIEW, "&Next View", "Next view", "Ctrl+Tab"), - ActionSpec(ActionId.PREV_VIEW, "&Previous View", "Previous view", "Ctrl+Shift+Tab"), - ActionSpec(ActionId.FULLSCREEN, "Toggle &Fullscreen", "Toggle fullscreen", "F11"), - ActionSpec(ActionId.REPORT_ISSUE, "&Report Issue", "Report issue"), - ActionSpec( - ActionId.UPLOAD_LOGS, - "Log && System &Info\u2026", - "Log & system info", - ), - ActionSpec(ActionId.OPEN_LOGS, "Open &Logs Folder", "Open logs folder"), - ActionSpec(ActionId.SHORTCUTS, "&Keyboard Shortcuts", "Keyboard shortcuts"), - ActionSpec(ActionId.CHECK_UPDATES, "Check for &Updates\u2026", "Check for updates"), - ActionSpec(ActionId.ABOUT, "&About PxModRim", "About PxModRim", "F1"), -) - -ACTIONS: dict[ActionId, ActionSpec] = {spec.id: spec for spec in ACTION_SPECS} - -VIEW_SWITCH_KEYS: tuple[str, ...] = tuple(f"Ctrl+{i}" for i in range(1, 10)) - -_WINDOW_ROWS: tuple[tuple[str, str], ...] = ( - ("Ctrl+1\u20139", "Switch to view"), - ("Alt", "Show or hide menu bar"), - ("Esc", "Hide menu bar"), -) - - -def _native(sequence: str) -> str: - return QKeySequence(sequence).toString(QKeySequence.SequenceFormat.NativeText) - - -def create_actions(window: QWidget) -> dict[ActionId, QAction]: - """Build every window action and register it on *window*. - - Adding the actions to the window (not just to the hidden menu bar) keeps - their shortcuts live while the menu bar is hidden. - """ - actions: dict[ActionId, QAction] = {} - for spec in ACTION_SPECS: - action = QAction(spec.menu_text, window) - if spec.shortcut: - action.setShortcut(QKeySequence(spec.shortcut)) - window.addAction(action) - actions[spec.id] = action - return actions - - -def shortcut_rows() -> tuple[tuple[str, str], ...]: - rows = tuple((_native(s.shortcut), s.label) for s in ACTION_SPECS if s.shortcut) - return rows + _WINDOW_ROWS diff --git a/legacy/pxmodrim/ui/window/main_window.py b/legacy/pxmodrim/ui/window/main_window.py deleted file mode 100644 index b1fd429..0000000 --- a/legacy/pxmodrim/ui/window/main_window.py +++ /dev/null @@ -1,943 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from importlib.resources import files as resource_files -from typing import TYPE_CHECKING - -import msgspec -from loguru import logger -from PySide6.QtCore import QEvent, QObject, Qt, QUrl -from PySide6.QtGui import ( - QAction, - QCloseEvent, - QDesktopServices, - QIcon, - QKeyEvent, - QKeySequence, - QResizeEvent, -) -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import ( - QApplication, - QCheckBox, - QDialog, - QLineEdit, - QMainWindow, - QMessageBox, - QSplitter, - QStackedWidget, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.config import config_dir -from pxmodrim.core.constants import AfterLaunch, LaunchStrategy -from pxmodrim.core.downloads import DownloadProgress, DownloadResult, download_manager -from pxmodrim.core.models.view.sidebar import SidebarEntry -from pxmodrim.core.services.update_service import UpdateCheckError, UpdateService -from pxmodrim.core.support import get_app_version -from pxmodrim.ui.components import ( - HeaderController, - HeaderPanel, - ToastManager, - ViewRailPanel, - create_qml_engine, -) -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.mod_updates import ( - confirm_update, -) -from pxmodrim.ui.config import save_ui_prefs -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.mod_selection import ModSelectionPresenter -from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route, parse_route -from pxmodrim.ui.panels.about_panel import AboutPanel -from pxmodrim.ui.panels.keyboard_shortcuts_dialog import ( - QML_SHORTCUTS, - KeyboardShortcutsDialog, -) -from pxmodrim.ui.panels.restore_snapshot_dialog import ( - ConfirmRestoreDialog, - RestoreSnapshotDialog, -) -from pxmodrim.ui.panels.settings_panel import SettingsPanel -from pxmodrim.ui.panels.update_dialog import SKIP_RESULT, UpdateDialog -from pxmodrim.ui.panels.upload_report_dialog import handle_upload_report -from pxmodrim.ui.theme.constants import ( - RAIL_COLLAPSE_WIDTH, - RAIL_MAX_WIDTH, - RAIL_MIN_WIDTH, -) -from pxmodrim.ui.theme.qml_theme import Theme -from pxmodrim.ui.window.actions import ( - ACTIONS, - VIEW_SWITCH_KEYS, - ActionId, - create_actions, - shortcut_rows, -) -from pxmodrim.ui.window.menu_bar import MenuBar - -_ISSUES_URL = "https://github.com/PyXiion/PxModRim/issues" -_HELP_ACTIONS = ( - ActionId.REPORT_ISSUE, - ActionId.UPLOAD_LOGS, - ActionId.OPEN_LOGS, - ActionId.SHORTCUTS, - ActionId.CHECK_UPDATES, - ActionId.ABOUT, -) -_WINDOW_TITLE = "PxModRim[*]" - - -if TYPE_CHECKING: - from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView - - -class UnsavedChangesDialog(QMessageBox): - def __init__(self, parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - self.setWindowTitle("Unsaved Changes") - self.setText("The active mod list has unsaved changes.") - self.setInformativeText( - "Save before closing, discard the changes, or cancel closing." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Save) - - -class LaunchConfirmDialog(QMessageBox): - def __init__(self, parent: QWidget, title: str, text: str, info: str, accept: str): - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle(title) - self.setText(text) - self.setInformativeText(info) - self.setStandardButtons( - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel - ) - self.button(QMessageBox.StandardButton.Ok).setText(accept) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - self.setCheckBox(QCheckBox("Don't ask again", self)) - - def dont_ask_again(self) -> bool: - box = self.checkBox() - return box is not None and box.isChecked() - - -class MainWindow(QMainWindow): - def __init__(self, app_ctx: AppContext) -> None: - """Initialize the main application window.""" - super().__init__() - - self._app_quit_callback: Callable[[], None] | None = None - self._is_frameless: bool = False - self._app_ctx = app_ctx - self._ctx = app_ctx.core - self._ui_prefs = app_ctx.ui_prefs - self._selected_uuid: str | None = None - self._mods_view = None - self._selection: ModSelectionPresenter | None = None - self._saved_active_uuids = self._ctx.loaded_active_uuids - self._unsaved_changes = False - self._close_prompt_open = False - self._close_confirmed = False - self._close_task: asyncio.Task[None] | None = None - self._update_service = UpdateService(get_app_version()) - self._update_task: asyncio.Task[None] | None = None - self._settings_task: asyncio.Task[None] | None = None - self._launch_task: asyncio.Task[None] | None = None - - self._setup_window_basics() - self._setup_qml() - self._setup_header_and_shortcuts() - self._ctx.active_state_changed.connect(self._on_active_state_changed) - self._ctx.mod_service.mods_changed.connect(self._on_mods_reloaded) - self._setup_content_and_views() - self._setup_toast_and_events() - - def _setup_window_basics(self) -> None: - logger.debug("main_window: setting up window basics") - self.setWindowTitle(_WINDOW_TITLE) - self.setWindowIcon( - QIcon(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - ) - self.resize(1400, 850) - - self._ctx.diagnostics_service.diagnostics_summary_changed.connect( - self._on_diagnostics_summary_changed - ) - - self._is_frameless = self._try_frameless() - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - - def _setup_qml(self) -> None: - logger.debug("main_window: setting up QML engine and theme") - self._qml_engine = create_qml_engine(self) - self._theme = Theme(self) - self._qml_engine.rootContext().setContextProperty("Theme", self._theme) - - @property - def qml_engine(self) -> QQmlEngine: - return self._qml_engine - - def _setup_header_and_shortcuts(self) -> None: - logger.debug("main_window: setting up header and shortcuts") - self._downloads = download_manager(self._ctx) - self._downloads_refresh: asyncio.Task[int] | None = None - self._header_controller = HeaderController( - is_frameless=self._is_frameless, - initial_strategy=int(self._ui_prefs.launch_strategy), - app_version=get_app_version(), - tooltips={ - "refresh": ACTIONS[ActionId.REFRESH].tooltip(), - "sort": ACTIONS[ActionId.AUTO_SORT].tooltip(), - "save": ACTIONS[ActionId.SAVE].tooltip(), - "update_mods": ACTIONS[ActionId.UPDATE_MODS].tooltip(), - }, - downloads_available=self._downloads.available, - ) - self._header_controller.refresh_requested.connect(self._refresh_mods) - self._header_controller.sort_requested.connect(self._auto_sort) - self._header_controller.save_requested.connect(self._save_mods_config) - self._header_controller.launch_requested.connect(self._launch_game) - self._header_controller.strategy_changed.connect(self._on_strategy_changed) - self._header_controller.minimize_requested.connect(self.showMinimized) - self._header_controller.maximize_requested.connect(self._toggle_maximized) - self._header_controller.close_requested.connect(self.close) - self._header_controller.drag_started.connect(self._start_system_move) - self._header_controller.downloads_requested.connect(self._open_downloads) - self._app_ctx.set_navigator(self._open_route) - self._header_controller.update_mods_requested.connect(self._update_mods) - - self._header = HeaderPanel(self._header_controller, self._qml_engine) - - self._actions = create_actions(self) - handlers = { - ActionId.SAVE: self._save_mods_config, - ActionId.RESTORE: self._restore_snapshot, - ActionId.SETTINGS: lambda: self._app_ctx.navigate( - build_route(SETTINGS_VIEW_ID) - ), - ActionId.QUIT: self.close, - ActionId.REFRESH: self._refresh_mods, - ActionId.FULL_RESCAN: self._full_rescan, - ActionId.UPDATE_MODS: self._update_mods, - ActionId.AUTO_SORT: self._auto_sort, - ActionId.FOCUS_SEARCH: self._focus_search, - ActionId.NEXT_VIEW: lambda: self._cycle_view(1), - ActionId.PREV_VIEW: lambda: self._cycle_view(-1), - ActionId.FULLSCREEN: self._toggle_fullscreen, - ActionId.REPORT_ISSUE: self._open_issue_tracker, - ActionId.UPLOAD_LOGS: self._upload_log_and_system_info, - ActionId.OPEN_LOGS: self._open_logs_folder, - ActionId.SHORTCUTS: self._show_shortcuts, - ActionId.CHECK_UPDATES: self._check_updates_manually, - ActionId.ABOUT: self._show_about, - } - for action_id, handler in handlers.items(): - self._actions[action_id].triggered.connect(handler) - self._actions[ActionId.UPDATE_MODS].setEnabled(self._downloads.available) - - for index, key in enumerate(VIEW_SWITCH_KEYS): - switch = QAction(self) - switch.setShortcut(QKeySequence(key)) - switch.triggered.connect(lambda _=False, i=index: self._select_view(i)) - self.addAction(switch) - - self._menu_bar = MenuBar(self._actions, self) - - def _focus_search(self) -> None: - search = getattr(self._stack.currentWidget(), "search_input", None) - if isinstance(search, QLineEdit): - search.setFocus() - elif self._mods_view is not None: - self._mods_view.mod_list.search_input.setFocus() - - def _toggle_fullscreen(self) -> None: - if self.isFullScreen(): - self.showNormal() - else: - self.showFullScreen() - - def _open_route(self, url: str) -> None: - route = parse_route(url) - if route is None: - logger.warning("main_window: ignoring non-route link {}", url) - return - logger.debug("main_window: navigating to {}", url) - if route.view_id == SETTINGS_VIEW_ID: - self._start_settings() - return - for index, view in enumerate(self._views): - if view.view_id == route.view_id: - self._show_view(index) - view.open_route(route.path) - return - logger.warning("main_window: no view handles {}", url) - - def _open_downloads(self) -> None: - view = next((v for v in self._views if v.lists_downloads), None) - if view is not None: - self._app_ctx.navigate(build_route(view.view_id)) - - def _select_view(self, index: int) -> None: - if 0 <= index < self._stack.count(): - self._app_ctx.navigate(build_route(self._views[index].view_id)) - - def _show_view(self, index: int) -> None: - self._rail.set_current(index) - self._on_rail_tab_changed(index) - - def _cycle_view(self, direction: int) -> None: - count = self._stack.count() - if count: - self._select_view((self._stack.currentIndex() + direction) % count) - - @asyncSlot() - async def _full_rescan(self) -> None: - await self._reload_mods(full=True) - - @asyncSlot() - async def _show_shortcuts(self) -> None: - await await_dialog( - KeyboardShortcutsDialog, shortcut_rows() + QML_SHORTCUTS, self - ) - - @staticmethod - def _open_issue_tracker() -> None: - QDesktopServices.openUrl(QUrl(_ISSUES_URL)) - - @staticmethod - def _open_logs_folder() -> None: - QDesktopServices.openUrl(QUrl.fromLocalFile(str(config_dir() / "logs"))) - - def _on_downloads_progress(self, progress: DownloadProgress) -> None: - self._header_controller.set_downloads_progress( - f"Updating mods: {progress.completed} / {progress.total}" - f" · {progress.bytes_done / 2**20:.0f} MB", - progress.completed, - progress.total, - ) - - def _setup_content_and_views(self) -> None: - logger.debug("main_window: setting up content and views") - rail_views = self._app_ctx.rail_views - - outer = QWidget() - outer.setObjectName("outerContainer") - outer_layout = QVBoxLayout(outer) - outer_layout.setContentsMargins(0, 0, 0, 0) - outer_layout.setSpacing(0) - outer_layout.addWidget(self._header) - outer_layout.addWidget(self._menu_bar) - self._menu_bar.hide() - - rail_tabs = [ - {"viewId": v.view_id, "icon": v.icon_name, "label": v.label} - for v in rail_views - ] - self._rail = ViewRailPanel( - rail_tabs, - [{"id": str(aid), "label": ACTIONS[aid].label} for aid in _HELP_ACTIONS], - self._qml_engine, - ) - self._rail.setObjectName("viewRail") - self._rail.setMinimumWidth(RAIL_MIN_WIDTH) - self._rail.setMaximumWidth(RAIL_MAX_WIDTH) - self._stack = QStackedWidget() - self._stack.setObjectName("viewStack") - - self._splitter = QSplitter(Qt.Orientation.Horizontal) - self._splitter.setContentsMargins(0, 0, 0, 0) - self._splitter.setHandleWidth(1) - self._splitter.addWidget(self._rail) - self._splitter.addWidget(self._stack) - self._splitter.setStretchFactor(0, 0) - self._splitter.setStretchFactor(1, 1) - self._splitter.setCollapsible(0, False) - self._splitter.setCollapsible(1, False) - rail_width = RAIL_MIN_WIDTH if self._ui_prefs.rail_collapsed else RAIL_MAX_WIDTH - self._splitter.setSizes([rail_width, self.width() - rail_width]) - self._rail.currentChanged.connect(self._select_view) - self._rail.hovered.connect(self._on_rail_hovered) - self._rail.settings_requested.connect( - lambda: self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) - ) - self._rail.help_action_requested.connect( - lambda action_id: self._actions[ActionId(action_id)].trigger() - ) - self._splitter.splitterMoved.connect(self._snap_rail) - - self._views: list = [] - for view_cls in rail_views: - view = view_cls( - self._ctx, self._qml_engine, parent=self._stack, app_ctx=self._app_ctx - ) - self._views.append(view) - self._stack.addWidget(view) - - self._mods_view = next((v for v in self._views if v.view_id == "mods"), None) - if self._mods_view is not None: - self.sidebar = self._mods_view.sidebar - self.mod_list = self._mods_view.mod_list - self.mod_info = self._mods_view.mod_info - self._mods_view.entry_selected.connect(self._on_entry_selected) - self._mods_view.mod_selected.connect(self._on_mod_selected) - self.mod_info.mod_requested.connect(self._on_mod_requested) - self._selection = ModSelectionPresenter(self._ctx, self.mod_info) - - outer_layout.addWidget(self._splitter, stretch=1) - self.setCentralWidget(outer) - - def _setup_toast_and_events(self) -> None: - logger.debug("main_window: setting up toast manager and events") - self._toast_manager = ToastManager(self.centralWidget()) - self._toast_manager.resize_to_parent() - self._app_ctx.toasts = self._toast_manager - - self._ctx.diagnostics_service.status_message_changed.connect( - self._on_status_message - ) - self._ctx.diagnostics_service.background_task_changed.connect( - self._header_controller.set_task_text - ) - if self._downloads.available: - self._downloads.busy_changed.connect(self._on_downloads_busy) - self._downloads.download_progress.connect(self._on_downloads_progress) - self._downloads.download_finished.connect(self._on_downloads_finished) - self._downloads.status_message_changed.connect(self._on_status_message) - - self.installEventFilter(self) - - def _try_frameless(self) -> bool: - try: - self.setWindowFlags( - Qt.WindowType.FramelessWindowHint - | Qt.WindowType.Window - | Qt.WindowType.WindowMinimizeButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - | Qt.WindowType.WindowCloseButtonHint - ) - if self.windowFlags() & Qt.WindowType.FramelessWindowHint: - logger.debug("Frameless window enabled") - return True - logger.info("Frameless not supported, using native frame") - return False - except (OSError, AttributeError) as exc: - logger.warning("Failed to set frameless window: {}", exc) - return False - - def _toggle_maximized(self) -> None: - if self.isMaximized(): - self.showNormal() - else: - self.showMaximized() - self._header_controller.set_maximized(self.isMaximized()) - - def _start_system_move(self) -> None: - if not self._is_frameless: - return - win = self.windowHandle() - if win is not None: - win.startSystemMove() - - def set_app_quit_callback(self, callback: Callable[[], None]) -> None: - """Store the callback used to end the async run loop on close.""" - self._app_quit_callback = callback - - async def _confirm_close(self) -> None: - try: - result, _ = await await_dialog(UnsavedChangesDialog, self) - finally: - self._close_prompt_open = False - - if result == QMessageBox.StandardButton.Save: - if not await self._save_active_mods(): - return - elif result != QMessageBox.StandardButton.Discard: - return - - self._close_confirmed = True - self.close() - - def closeEvent(self, event: QCloseEvent) -> None: - if self._unsaved_changes and not self._close_confirmed: - event.ignore() - if not self._close_prompt_open: - self._close_prompt_open = True - self._close_task = asyncio.create_task(self._confirm_close()) - return - logger.info("main_window: shutting down") - if self._update_task is not None: - self._update_task.cancel() - if self._launch_task is not None: - self._launch_task.cancel() - # Release WebEngine views (their dedicated profiles) before the - # Qt widget tree is torn down. Then disconnect aboutToQuit: on this - # Chromium/Qt build, WebEngine registers an aboutToQuit handler - # that dereferences a freed object and SIGSEGVs during shutdown - # once the Steam tab has been opened. We drop every aboutToQuit - # connection (removing Chromium's) and instead fire our own quit - # callback directly, so the async run loop ends cleanly and the - # process exits without the crashing dispatch. - for view in self._views: - teardown = getattr(view, "teardown", None) - if callable(teardown): - teardown() - - app = QApplication.instance() - if app is not None: - app.aboutToQuit.disconnect() - if self._app_quit_callback is not None: - self._app_quit_callback() - event.accept() - self.deleteLater() - - # ── Event filter (Alt → toggle menu bar) ───────────── - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - if isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress: - if ( - event.key() == Qt.Key.Key_Alt - and isinstance(obj, QWidget) - and obj.window() is self - ): - self._toggle_menu_bar() - return True - if ( - event.key() == Qt.Key.Key_Escape - and self._menu_bar.isVisible() - and isinstance(obj, QWidget) - and obj.window() is self - ): - self._menu_bar.hide() - return True - return super().eventFilter(obj, event) - - def _toggle_menu_bar(self) -> None: - shown = not self._menu_bar.isVisible() - self._menu_bar.setVisible(shown) - if shown: - self._menu_bar.setFocus() - - # ── Public ────────────────────────────────────────────── - - @asyncSlot() - async def _refresh_mods(self) -> None: - await self._reload_mods(full=False) - - async def _reload_mods(self, *, full: bool) -> None: - self._toast_manager.info( - "Rescanning all mods\u2026" if full else "Refreshing mods\u2026" - ) - count = await self._app_ctx.refresh_mods(full=full) - if not count: - self._toast_manager.warning("No mods found") - return - self._toast_manager.success( - f"Rescanned {count} mods" if full else f"Reloaded {count} mods" - ) - - # ── Private slots ─────────────────────────────────────── - - def _on_status_message(self, message: str) -> None: - self._toast_manager.info(message) - - @asyncSlot() - async def _update_mods(self) -> None: - downloads = self._downloads - if not downloads.available: - return - if downloads.is_downloading: - logger.info("[downloads] stop requested from the header") - downloads.cancel() - return - ids = downloads.updatable_ids(self._ctx.all_mods.values()) - if not ids: - self._toast_manager.info("No downloaded mods to update") - return - if not await confirm_update(len(ids), self): - return - logger.info("[downloads] update-all requested for {} mods", len(ids)) - self._toast_manager.info(f"Updating {len(ids)} mods\u2026") - try: - await downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - self._toast_manager.error(str(exc)) - - def _on_downloads_busy(self, busy: bool) -> None: - self._header_controller.set_downloads_busy(busy) - self._actions[ActionId.UPDATE_MODS].setEnabled(not busy) - if busy: - self._header_controller.set_downloads_progress("Updating mods…", 0, 0) - - def _on_downloads_finished(self, result: DownloadResult) -> None: - ok, failed = len(result.succeeded), len(result.failed) - changed = len(result.changed) - if not ok and not failed: - self._toast_manager.info("Download cancelled") - return - summary = f"{changed} updated, {ok - changed} unchanged" - if failed: - by_id = download_manager(self._ctx).names_by_id(self._ctx.all_mods.values()) - shown = ", ".join(by_id.get(pid, pid) for pid in result.failed[:3]) - more = "…" if failed > 3 else "" - self._toast_manager.warning(f"{summary}, {failed} failed: {shown}{more}") - else: - self._toast_manager.success(summary) - if changed: - self._downloads_refresh = asyncio.ensure_future( - self._app_ctx.refresh_mods() - ) - - def _start_settings(self) -> None: - if self._settings_task is not None and not self._settings_task.done(): - return - self._settings_task = asyncio.create_task(self._open_settings()) - - async def _open_settings(self) -> None: - result, dialog = await await_dialog( - SettingsPanel, - self._ctx, - self._qml_engine, - self, - self._app_ctx.settings_sections, - ) - if result != QDialog.DialogCode.Accepted: - return - cfg = dialog.get_config() - if not cfg.paths.game: - logger.warning("Settings saved without a game path") - self._ctx.config_service.save("config.json", cfg) - self._ctx.update_config(cfg) - self._ctx.reset_providers(cfg.paths) - await self._ctx.mod_service.reload() - self._toast_manager.success("Settings saved") - - @asyncSlot() - async def _show_about(self) -> None: - await await_dialog( - AboutPanel, self, ctx=self._ctx, toast_manager=self._toast_manager - ) - - def start_startup_update_check(self) -> None: - self._start_update_check(manual=False) - - def _check_updates_manually(self) -> None: - self._start_update_check(manual=True) - - def _start_update_check(self, *, manual: bool) -> None: - if self._update_task is not None and not self._update_task.done(): - return - self._update_task = asyncio.create_task(self._check_for_updates(manual=manual)) - - async def _check_for_updates(self, *, manual: bool) -> None: - try: - release = await self._update_service.check() - except UpdateCheckError as exc: - logger.warning("Update check failed: {}", exc) - if manual: - self._toast_manager.error("Could not check for updates") - return - if release is None: - if manual: - self._toast_manager.success("PxModRim is up to date") - return - if not manual and release.tag == self._ui_prefs.skipped_update_tag: - logger.debug("Update {} was skipped by the user", release.tag) - return - - result, _ = await await_dialog( - UpdateDialog, release, self._update_service.current_version, self - ) - if result == QDialog.DialogCode.Accepted: - QDesktopServices.openUrl(QUrl(release.url)) - elif result == SKIP_RESULT: - self._ui_prefs.skipped_update_tag = release.tag - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - @asyncSlot() - async def _upload_log_and_system_info(self) -> None: - await handle_upload_report(self, self._ctx, self._toast_manager) - - @asyncSlot() - async def _restore_snapshot(self) -> None: - snapshots = self._ctx.mod_service.get_snapshots() - if not snapshots: - self._toast_manager.warning("No saved mod lists are available") - return - - result, dialog = await await_dialog(RestoreSnapshotDialog, snapshots, self) - if result != QDialog.DialogCode.Accepted: - return - - chosen = dialog.selected_snapshot - if chosen is None: - return - confirm_result, _ = await await_dialog(ConfirmRestoreDialog, chosen.name, self) - if confirm_result != QMessageBox.StandardButton.Yes: - return - - if await self._ctx.mod_service.restore_snapshot(chosen): - self._toast_manager.success(f"Restored mod list from {chosen.name}") - else: - self._toast_manager.error("The saved mod list is invalid or cannot be read") - - @asyncSlot() - async def _auto_sort(self) -> None: - count, elapsed = await self._ctx.auto_sort() - self._toast_manager.success(f"Sorted {count} mods in {elapsed:.0f}ms") - - def _on_active_state_changed(self, _active_uuids: tuple[str, ...]) -> None: - self._refresh_unsaved_state() - - def _on_mods_reloaded(self, _: None = None) -> None: - # mods_changed fires after the refresh finishes, so edits made since - # ctx.load() are already in ctx.active_uuids; the baseline must be the - # list that was read from disk. - self._saved_active_uuids = self._ctx.loaded_active_uuids - self._refresh_unsaved_state() - - def _refresh_unsaved_state(self) -> None: - self._unsaved_changes = self._ctx.active_uuids != self._saved_active_uuids - self._header_controller.set_unsaved_changes(self._unsaved_changes) - self.setWindowModified(self._unsaved_changes) - - async def _write_active_layout(self) -> list[str] | None: - """Persist the active list; return it on success, None if not saved.""" - active_ids = self.mod_list.active_uuids() - if not await self._ctx.mod_service.save_active_layout(active_ids): - return None - self._saved_active_uuids = list(active_ids) - self._refresh_unsaved_state() - return active_ids - - @asyncSlot() - async def _save_mods_config(self) -> None: - await self._save_active_mods() - - async def _save_active_mods(self) -> bool: - saved = await self._write_active_layout() - if saved is None: - self._toast_manager.warning("Config folder not set") - return False - self._toast_manager.success(f"Saved {len(saved)} active mods") - return True - - @asyncSlot() - async def _launch_game(self) -> None: - if self._header_controller.launchState != "idle": - return - logger.info("Launch requested") - self._header_controller.set_launch_state("launching") - tracking = False - try: - tracking = await self._start_game() - finally: - if not tracking: - self._header_controller.set_launch_state("idle") - - async def _confirm_launch( - self, setting: str, title: str, text: str, info: str, accept: str - ) -> bool: - result, dialog = await await_dialog( - LaunchConfirmDialog, self, title, text, info, accept - ) - if result != QMessageBox.StandardButton.Ok: - return False - if dialog.dont_ask_again(): - cfg = msgspec.structs.replace(self._ctx.config, **{setting: False}) - self._ctx.config_service.save("config.json", cfg) - self._ctx.update_config(cfg) - return True - - async def _start_game(self) -> bool: - launcher = self._ctx.game_launcher - - cfg = self._ctx.config - running = cfg.launch_confirm_running and await asyncio.to_thread( - launcher.is_running - ) - errors = self._ctx.diagnostics_service.active_error_count() - prompts = [ - ( - running, - "launch_confirm_running", - "Game Already Running", - "RimWorld is already running.", - "A second copy can overwrite the first one's saves and settings.", - "Launch Anyway", - ), - ( - cfg.launch_confirm_errors and errors > 0, - "launch_confirm_errors", - "Mod List Has Errors", - f"{errors} active mod(s) have errors.", - "Missing dependencies or wrong load order can crash the game.", - "Launch Anyway", - ), - ( - cfg.launch_confirm_unsaved and self._unsaved_changes, - "launch_confirm_unsaved", - "Unsaved Changes", - "The active mod list has unsaved changes.", - "The list is saved to ModsConfig.xml before the game starts.", - "Save and Launch", - ), - ] - for needed, setting, title, text, info, accept in prompts: - if needed and not await self._confirm_launch( - setting, title, text, info, accept - ): - return False - - if await self._write_active_layout() is None: - logger.warning("Config folder not set — mod list not saved before launch") - self._toast_manager.warning( - "Config folder not set — mod list won't be saved" - ) - - success, msg = await launcher.launch(self._ui_prefs.launch_strategy) - if not success: - logger.warning(msg) - self._toast_manager.warning(msg) - return False - - logger.info(msg) - self._toast_manager.success(msg) - self._launch_task = asyncio.create_task(self._track_game()) - return True - - async def _track_game(self) -> None: - launcher = self._ctx.game_launcher - after = self._ctx.config.after_launch - try: - if not await launcher.wait_for_start(): - self._toast_manager.warning("Game did not start") - return - self._header_controller.set_launch_state("running") - if after == AfterLaunch.CLOSE and not self._unsaved_changes: - self._close_confirmed = True - self.close() - return - if after == AfterLaunch.MINIMIZE: - self.showMinimized() - code = await launcher.wait_for_exit() - if code: - logger.warning("Game exited with code {}", code) - self._toast_manager.warning(f"Game exited with code {code}") - else: - self._toast_manager.info("Game closed") - if after == AfterLaunch.MINIMIZE: - self.showNormal() - self.activateWindow() - finally: - self._header_controller.set_launch_state("idle") - - def _on_strategy_changed(self, index: int) -> None: - new_strategy = LaunchStrategy(index) - if self._ui_prefs.launch_strategy != new_strategy: - logger.info("Launch strategy changed to {}", new_strategy.name) - self._ui_prefs.launch_strategy = new_strategy - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - @asyncSlot(str) - async def _on_mod_requested(self, uuid: str) -> None: - if self.mod_list.select_uuid(uuid): - return - # Hidden by the search box or a sidebar filter: reset both, then retry. - self.mod_list.search_input.clear() - self.sidebar.select_all_entry() - if not self.mod_list.select_uuid(uuid): - await self._on_mod_selected(uuid) - - @asyncSlot() - async def _on_mod_selected(self, uuid: str) -> None: - self._selected_uuid = uuid or None - if self._selection is None: - return - if uuid: - await self._selection.show(uuid) - else: - self._selection.clear() - - def _apply_current_sidebar_filter(self) -> None: - if self._mods_view is not None: - self._mods_view.apply_current_sidebar_filter() - - def _apply_entry(self, entry: SidebarEntry) -> None: - if self._mods_view is not None: - self._mods_view.apply_sidebar_entry(entry) - - def _on_entry_selected(self, entry: SidebarEntry) -> None: - logger.debug( - "main_window: sidebar entry selected: {}", - entry.label if entry else None, - ) - self._apply_entry(entry) - - def _on_rail_tab_changed(self, index: int) -> None: - logger.debug("main_window: rail tab changed to {}", index) - self._stack.setCurrentIndex(index) - self._header_controller.set_downloads_progress_shown( - not self._views[index].lists_downloads - ) - # Preload an adjacent tab (e.g. the Steam view next to Mods) so its - # content is already warm when the user moves to it. - for adjacent in (index - 1, index + 1): - if 0 <= adjacent < len(self._views): - preload = getattr(self._views[adjacent], "preload", None) - if callable(preload): - preload() - - def _on_rail_hovered(self, index: int) -> None: - # Preload view content (e.g. spin up Chromium for the Steam tab) - # when the user hovers its rail entry, so the first click is fast - # without paying the startup cost up front. - if 0 <= index < len(self._views): - preload = getattr(self._views[index], "preload", None) - if callable(preload): - preload() - - def _snap_rail(self) -> None: - target = ( - RAIL_MAX_WIDTH - if self._rail.width() >= RAIL_COLLAPSE_WIDTH - else RAIL_MIN_WIDTH - ) - self._splitter.setSizes([target, self._splitter.width() - target]) - collapsed = target == RAIL_MIN_WIDTH - if self._ui_prefs.rail_collapsed != collapsed: - self._ui_prefs.rail_collapsed = collapsed - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - # ── Diagnostics summary callback ───────────────────────── - - def _on_diagnostics_summary_changed( - self, diagnostics: dict[str, ModDiagnosticsView] - ) -> None: - self._apply_current_sidebar_filter() - if self._mods_view is not None and self._selected_uuid is not None: - self.mod_info.set_issues( - self._ctx.diagnostics_service.issues_for(self._selected_uuid) - ) - - # ── Resize ─────────────────────────────────────────────── - - def resizeEvent(self, event: QResizeEvent) -> None: - super().resizeEvent(event) - if hasattr(self, "_toast_manager"): - self._toast_manager.resize_to_parent() - - def changeEvent(self, event: QEvent) -> None: - super().changeEvent(event) - if event.type() == QEvent.Type.WindowStateChange: - self._header_controller.set_maximized(self.isMaximized()) diff --git a/legacy/pxmodrim/ui/window/menu_bar.py b/legacy/pxmodrim/ui/window/menu_bar.py deleted file mode 100644 index 8a83aa6..0000000 --- a/legacy/pxmodrim/ui/window/menu_bar.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping - -from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMenuBar, QWidget - -from pxmodrim.ui.window.actions import ActionId - -MENU_LAYOUT: tuple[tuple[str, tuple[ActionId | None, ...]], ...] = ( - ( - "&File", - ( - ActionId.SAVE, - ActionId.RESTORE, - ActionId.SETTINGS, - None, - ActionId.QUIT, - ), - ), - ( - "&Mods", - ( - ActionId.REFRESH, - ActionId.FULL_RESCAN, - ActionId.AUTO_SORT, - None, - ActionId.UPDATE_MODS, - ), - ), - ( - "&View", - ( - ActionId.FOCUS_SEARCH, - ActionId.NEXT_VIEW, - ActionId.PREV_VIEW, - None, - ActionId.FULLSCREEN, - ), - ), - ( - "&Help", - ( - ActionId.REPORT_ISSUE, - ActionId.UPLOAD_LOGS, - ActionId.OPEN_LOGS, - ActionId.SHORTCUTS, - ActionId.CHECK_UPDATES, - None, - ActionId.ABOUT, - ), - ), -) - - -class MenuBar(QMenuBar): - def __init__( - self, actions: Mapping[ActionId, QAction], parent: QWidget | None = None - ) -> None: - super().__init__(parent) - for title, action_ids in MENU_LAYOUT: - menu = self.addMenu(title) - for action_id in action_ids: - if action_id is None: - menu.addSeparator() - else: - menu.addAction(actions[action_id]) diff --git a/legacy/tests/__init__.py b/legacy/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/tests/benchmarks/bench_discovery.py b/legacy/tests/benchmarks/bench_discovery.py deleted file mode 100644 index e424763..0000000 --- a/legacy/tests/benchmarks/bench_discovery.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import random -import string -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from loguru import logger - -logger.remove() - -from pxmodrim.core.models.metadata.parsing import ( # noqa: E402 - create_listed_mod_from_path, -) -from pxmodrim.core.services.mod_discovery import scan_mod_directory # noqa: E402 - -ABOUT_XML = """\ - - - {name} - {author} - {author}.{name} - https://steamcommunity.com/sharedfiles/filedetails/?id={sid} - -
  • 1.4
  • -
  • 1.5
  • - - -
  • {dep}
  • -
    - A benchmark mod #{i} - 1.0.0 -""" - - -def make_mod_dir(root: Path, i: int, dep_pid: str) -> Path: - author = random.choice(string.ascii_lowercase[:5]) - name = f"benchmark_mod_{i:04d}" - - about = ABOUT_XML.format(name=name, author=author, sid=i, dep=dep_pid, i=i) - - mod_dir = root / name - about_dir = mod_dir / "About" - about_dir.mkdir(parents=True, exist_ok=True) - (about_dir / "About.xml").write_text(about) - (about_dir / "PublishedFileId.txt").write_text(str(i)) - return mod_dir - - -def make_mods(root: Path, count: int) -> None: - """Create count mod directories with dependency chains.""" - prev_pid = "ludeon.rimworld" - for i in range(count): - make_mod_dir(root, i, prev_pid) - author = random.choice(string.ascii_lowercase[:5]) - prev_pid = f"{author}.benchmark_mod_{i:04d}" - - -def bench(count: int) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "Mods" - root.mkdir() - make_mods(root, count) - - t0 = time.perf_counter_ns() - scanned = scan_mod_directory(root) - t1 = time.perf_counter_ns() - - mods = {} - t2 = time.perf_counter_ns() - for d, about in scanned.items(): - _, mod = create_listed_mod_from_path(d, "1.5", about_xml_path=about) - mods[mod.uuid] = mod - t3 = time.perf_counter_ns() - - scan_ms = (t1 - t0) / 1e6 - parse_ms = (t3 - t2) / 1e6 - total_ms = (t3 - t0) / 1e6 - print( - f"{count:>5} mods: scan_dir={scan_ms:>6.1f}ms" - f" parse_xml={parse_ms:>6.1f}ms total={total_ms:>6.1f}ms" - ) - print( - f" scan avg: {scan_ms / count * 1000:.1f} us/mod" - f" parse avg: {parse_ms / count * 1000:.1f} us/mod" - ) - - -if __name__ == "__main__": - for count in (50, 100, 200, 500, 1000, 2000): - bench(count) diff --git a/legacy/tests/benchmarks/bench_sort.py b/legacy/tests/benchmarks/bench_sort.py deleted file mode 100644 index c671044..0000000 --- a/legacy/tests/benchmarks/bench_sort.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import random -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from loguru import logger - -logger.remove() - -from pxmodrim.core.checker.graph import ConstraintGraph # noqa: E402 -from pxmodrim.core.checker.sort import topological_sort # noqa: E402 -from pxmodrim.core.models.metadata.structures import ( # noqa: E402 - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings, TierConfig # noqa: E402 -from pxmodrim.core.sort.models import CommunityRule # noqa: E402 - -PackageId = CaseInsensitiveStr - - -def _tier_config(pids: list[PackageId]) -> TierConfig: - return TierConfig( - tier_0=tuple(pids[:3]) if len(pids) >= 3 else tuple(pids), - tier_1=tuple(pids[3:6]) if len(pids) >= 6 else (), - tier_3=(), - ) - - -def make_mods( - count: int, edges_per_mod: int -) -> tuple[ - dict[PackageId, AboutXmlMod], - dict[PackageId, CommunityRule], -]: - rng = random.Random(42) - pids = [PackageId(f"bench.mod.{i:04d}") for i in range(count)] - community_rules: dict[PackageId, CommunityRule] = {} - mods: dict[PackageId, AboutXmlMod] = {} - - for i, pid in enumerate(pids): - deps: dict[PackageId, DependencyMod] = {} - load_after = CaseInsensitiveSet() - - num_edges = min(edges_per_mod, i) - if num_edges: - targets = rng.sample(range(i), k=num_edges) - dep_count = max(1, num_edges // 2) - for idx in targets[:dep_count]: - deps[pids[idx]] = DependencyMod( - name=str(pids[idx]), - package_id=pids[idx], - ) - for idx in targets[dep_count:]: - load_after.add(pids[idx]) - - mods[pid] = AboutXmlMod( - name=f"Bench Mod {i:04d}", - package_id=pid, - provider_id="bench", - valid=True, - about_rules=BaseRules(dependencies=deps, load_after=load_after), - ) - - if rng.random() < 0.2: - cr_la: set[PackageId] = set() - if i > 0 and rng.random() < 0.5: - extra = rng.randint(1, min(2, i)) - for idx in rng.sample(range(i), k=extra): - cr_la.add(pids[idx]) - - community_rules[pid] = CommunityRule( - package_id=pid, - load_after=cr_la, - load_before=set(), - load_first=rng.random() < 0.1, - load_last=rng.random() < 0.1, - incompatible_with=set(), - ) - - return mods, community_rules - - -def bench(count: int, edges_per_mod: int) -> None: - mods, community_rules = make_mods(count, edges_per_mod) - pids = list(mods.keys()) - settings = SortSettings( - use_community_rules=True, - use_alternative_package_ids=False, - tier_config=_tier_config(pids), - ) - - graph = ConstraintGraph() - - t0 = time.perf_counter_ns() - graph.build(mods, pids, settings, community_rules) - t1 = time.perf_counter_ns() - - t2 = time.perf_counter_ns() - result = topological_sort(mods, graph, settings, community_rules) - t3 = time.perf_counter_ns() - - build_ms = (t1 - t0) / 1e6 - sort_ms = (t3 - t2) / 1e6 - total_ms = (t3 - t0) / 1e6 - - assert len(result) == count - - print( - f"{count:>5} mods ({edges_per_mod} e/m): " - f"graph={build_ms:>6.1f}ms " - f"sort={sort_ms:>6.1f}ms " - f"total={total_ms:>6.1f}ms" - ) - print( - f" avg per mod: " - f"graph={build_ms / count * 1000:>5.1f}us " - f"sort={sort_ms / count * 1000:>5.1f}us " - f"total={total_ms / count * 1000:>5.1f}us" - ) - - -if __name__ == "__main__": - print("=== bench_sort ===") - for count in (200, 500, 1000, 2000): - bench(count, edges_per_mod=5) - bench(2000, edges_per_mod=10) diff --git a/legacy/tests/conftest.py b/legacy/tests/conftest.py deleted file mode 100644 index 6fdc7b5..0000000 --- a/legacy/tests/conftest.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import gc -import os -from collections.abc import Iterator -from pathlib import Path - -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") -import pytest - -from pxmodrim.core.config import ConfigService - -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") - - -@pytest.fixture -def config_service(tmp_path: Path) -> ConfigService: - """A ``ConfigService`` backed by a temporary directory. - - All services that would normally use ``~/.config/pxmodrim`` - are isolated to this temp directory instead. - """ - return ConfigService(tmp_path) - - -@pytest.fixture(autouse=True, scope="module") -def _collect_qobjects() -> Iterator[None]: - """Destroy unparented QObjects at module end, not at an arbitrary later GC.""" - yield - gc.collect() diff --git a/legacy/tests/test_checker/__init__.py b/legacy/tests/test_checker/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/legacy/tests/test_checker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/legacy/tests/test_checker/test_checker.py b/legacy/tests/test_checker/test_checker.py deleted file mode 100644 index 2b74fe7..0000000 --- a/legacy/tests/test_checker/test_checker.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -import random - -from pxmodrim.core.checker.checker import ModChecker -from pxmodrim.core.checker.graph import EdgeType -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ModIssueChecker, -) -from pxmodrim.core.checker.models import ModDiagnostics, PackageId -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.sort.config import SortSettings - - -def _make_mod(package_id: str, name: str | None = None) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = name or package_id - m._mod_path = None - return m - - -def _settings() -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - - -def _as_listed(mods: list[AboutXmlMod]) -> dict[str, ListedMod]: - return {str(i): m for i, m in enumerate(mods)} - - -def _uuid_for(mods: dict[str, ListedMod], name: str) -> str: - for u, m in mods.items(): - if m.name == name: - return u - raise ValueError(f"Mod {name} not found") - - -class TestModChecker: - def test_basic_rebuild(self): - checkers = [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.c") - a.about_rules.dependencies = {CaseInsensitiveStr("mod.c"): dep} - mods = _as_listed([a, b]) - active_uuids = [u for u, m in mods.items() if m.name in ("mod.a", "mod.b")] - - collected_diags: dict[str, ModDiagnostics] = {} - - def on_change(diags: dict[str, ModDiagnostics]) -> None: - collected_diags.update(diags) - - checker = ModChecker(checkers, _settings(), on_diagnostics_changed=on_change) - checker.rebuild(mods, active_uuids) - - # 'a' has a missing dependency on 'mod.c' - a_uuid = _uuid_for(mods, "mod.a") - diag_a = checker.diagnostics_for(a_uuid) - assert diag_a is not None - assert diag_a.has_errors - assert len(diag_a.errors) == 1 - assert diag_a.errors[0].category == "missing_dependency" - - # 'b' has no issues - b_uuid = _uuid_for(mods, "mod.b") - diag_b = checker.diagnostics_for(b_uuid) - assert diag_b is not None - assert not diag_b.has_errors - assert not diag_b.has_warnings - - def test_toggle_mod_rebuilds_diagnostics_and_constraints(self): - checkers = [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {CaseInsensitiveStr("mod.b"): dep} - b.about_rules.load_after = CaseInsensitiveSet(["mod.a"]) - mods = _as_listed([a, b]) - a_uuid = _uuid_for(mods, "mod.a") - b_uuid = _uuid_for(mods, "mod.b") - - checker = ModChecker(checkers, _settings()) - checker.rebuild(mods, [a_uuid, b_uuid]) - - checker.toggle_mod(mods[b_uuid], False, [a_uuid]) - - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and diag.has_errors - assert diag.errors[0].category == "missing_dependency" - - checker.toggle_mod(mods[b_uuid], True, [a_uuid, b_uuid]) - - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and not diag.has_errors - dependency_sources = { - edge.source - for edge in checker.graph.incoming_of_type( - PackageId("mod.b"), EdgeType.DEPENDENCY - ) - } - assert dependency_sources == {PackageId("mod.a")} - load_after_targets = { - edge.target - for edge in checker.graph.edges_of_type( - PackageId("mod.b"), EdgeType.LOAD_AFTER - ) - } - assert load_after_targets == {PackageId("mod.a")} - - def test_move_mod_updates_load_order(self): - checkers: list[ModIssueChecker] = [ - LoadOrderIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - # Order: b first, then a -> satisfies load_after - mods = _as_listed([b, a]) - active_uuids = list(mods) - - checker = ModChecker(checkers, _settings()) - checker.rebuild(mods, active_uuids) - - a_uuid = _uuid_for(mods, "mod.a") - - # Initially b is before a, so no load_after violation for a - diag = checker.diagnostics_for(a_uuid) - assert diag is None or not diag.has_warnings - - # Move a before b - checker.move_mod(a_uuid, 0, 1) - - # Now a is before b, violating load_after - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and diag.has_warnings - assert diag.warnings[0].category == "load_order" - - def test_reorder_matches_full_rebuild(self): - # reorder() reuses cached results for mods whose indices and load-order - # targets did not move; it must still equal a from-scratch rebuild. - def make_checkers() -> list[ModIssueChecker]: - return [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - ] - - rng = random.Random(0) - pids = [f"mod.{i}" for i in range(12)] - mods_list = [_make_mod(pid) for pid in pids] - for mod in mods_list: - others = [p for p in pids if p != str(mod.package_id)] - mod.about_rules.load_after = CaseInsensitiveSet(rng.sample(others, 2)) - mod.about_rules.load_before = CaseInsensitiveSet(rng.sample(others, 1)) - mods = _as_listed(mods_list) - order = list(mods) - - incremental = ModChecker(make_checkers(), _settings()) - incremental.rebuild(mods, order) - for _ in range(30): - if rng.random() < 0.5: - i, j = rng.sample(range(len(order)), 2) - order[i], order[j] = order[j], order[i] - else: - order.insert( - rng.randrange(len(order)), order.pop(rng.randrange(len(order))) - ) - incremental.reorder(order) - - full = ModChecker(make_checkers(), _settings()) - full.rebuild(mods, order) - for uuid in order: - got = incremental.diagnostics_for(uuid) - want = full.diagnostics_for(uuid) - assert got is not None and want is not None - assert sorted(map(repr, got.errors)) == sorted(map(repr, want.errors)) - assert sorted(map(repr, got.warnings)) == sorted( - map(repr, want.warnings) - ) diff --git a/legacy/tests/test_checker/test_graph.py b/legacy/tests/test_checker/test_graph.py deleted file mode 100644 index 996d488..0000000 --- a/legacy/tests/test_checker/test_graph.py +++ /dev/null @@ -1,252 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.checker.graph import ( - ConstraintGraph, - EdgeOrigin, - EdgeType, - PackageId, -) -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _settings(*, use_alternative_package_ids: bool = False) -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=use_alternative_package_ids, - ) - - -class TestConstraintGraph: - def test_empty_graph(self): - g = ConstraintGraph() - assert len(g.nodes) == 0 - - def test_build_no_edges(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert PackageId("mod.a") in g.nodes - assert PackageId("mod.b") in g.nodes - assert len(g.nodes) == 2 - assert g.index_of(PackageId("mod.a")) == 0 - assert g.index_of(PackageId("mod.b")) == 1 - - def test_dependency_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.DEPENDENCY) - assert len(edges) == 1 - edge = next(iter(edges)) - assert edge.source == PackageId("mod.a") - assert edge.target == PackageId("mod.b") - assert edge.type == EdgeType.DEPENDENCY - assert edge.origin == EdgeOrigin.ABOUT_XML - - def test_dependency_alternative_resolution_respects_setting(self): - g = ConstraintGraph() - consumer = _make_mod("mod.consumer") - alternative = _make_mod("mod.alternative") - dependency = DependencyMod() - dependency.package_id = CaseInsensitiveStr("mod.dependency") - dependency.alternative_package_ids = {CaseInsensitiveStr("mod.alternative")} - consumer.about_rules.dependencies = {PackageId("mod.dependency"): dependency} - mods = { - PackageId("mod.consumer"): consumer, - PackageId("mod.alternative"): alternative, - } - ordered_pids = list(mods) - - g.build( - mods, - ordered_pids, - _settings(use_alternative_package_ids=True), - ) - enabled_targets = { - edge.target - for edge in g.edges_of_type(PackageId("mod.consumer"), EdgeType.DEPENDENCY) - } - assert enabled_targets == {PackageId("mod.alternative")} - - g.build( - mods, - ordered_pids, - _settings(use_alternative_package_ids=False), - ) - disabled_targets = { - edge.target - for edge in g.edges_of_type(PackageId("mod.consumer"), EdgeType.DEPENDENCY) - } - assert disabled_targets == {PackageId("mod.dependency")} - - def test_incompatibility_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.INCOMPATIBILITY) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_load_after_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.LOAD_AFTER) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_load_before_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_before = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.LOAD_BEFORE) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_neighbors(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - nbrs = g.neighbors(PackageId("mod.a")) - assert PackageId("mod.b") in nbrs - nbrs_b = g.neighbors(PackageId("mod.b")) - assert PackageId("mod.a") in nbrs_b - - def test_incoming(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - incoming = g.incoming(PackageId("mod.b")) - assert len(incoming) == 1 - assert next(iter(incoming)).source == PackageId("mod.a") - - def test_add_remove_node(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert PackageId("mod.b") in g.nodes - - g.remove_mod(PackageId("mod.b")) - assert PackageId("mod.b") not in g.nodes - # The edge from 'a' to the removed node should be gone too - assert not g.outgoing(PackageId("mod.a")) - - def test_update_order(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert g.index_of(PackageId("mod.a")) == 0 - assert g.index_of(PackageId("mod.b")) == 1 - - g.update_order([PackageId("mod.b"), PackageId("mod.a")]) - assert g.index_of(PackageId("mod.a")) == 1 - assert g.index_of(PackageId("mod.b")) == 0 - - def test_cycle_detection(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - b.about_rules.load_after = CaseInsensitiveSet(["mod.c"]) - c.about_rules.load_after = CaseInsensitiveSet(["mod.a"]) - mods = { - PackageId("mod.a"): a, - PackageId("mod.b"): b, - PackageId("mod.c"): c, - } - g.build(mods, list(mods.keys()), _settings()) - cycles = g.find_cycles() - assert len(cycles) >= 1 - cycle_pids = {str(p) for cycle in cycles for p in cycle} - for pid in ("mod.a", "mod.b", "mod.c"): - assert pid in cycle_pids - - def test_cycle_detection_reports_overlapping_cycles(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - b.about_rules.load_after = CaseInsensitiveSet(["mod.a", "mod.c"]) - c.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = { - PackageId("mod.a"): a, - PackageId("mod.b"): b, - PackageId("mod.c"): c, - } - g.build(mods, list(mods), _settings()) - - cycles = g.find_cycles() - - assert len(cycles) == 1 - assert set(cycles[0]) == set(mods) - - def test_no_cycle(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, list(mods.keys()), _settings()) - cycles = g.find_cycles() - assert len(cycles) == 0 - - def test_cycle_detection_handles_long_chain(self): - nodes = [PackageId(f"mod.{index}") for index in range(5000)] - mods = {pid: _make_mod(str(pid)) for pid in nodes} - for index in range(len(nodes) - 1): - mods[nodes[index]].about_rules.load_after = CaseInsensitiveSet( - [str(nodes[index + 1])] - ) - g = ConstraintGraph() - g.build(mods, nodes, _settings()) - - assert g.find_cycles() == [] diff --git a/legacy/tests/test_checker/test_issues.py b/legacy/tests/test_checker/test_issues.py deleted file mode 100644 index 62d5f77..0000000 --- a/legacy/tests/test_checker/test_issues.py +++ /dev/null @@ -1,312 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pxmodrim.core.checker.graph import ConstraintGraph, EdgeOrigin, EdgeType -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - GameVersionIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ReplacementIssueChecker, -) -from pxmodrim.core.checker.models import CheckContext, PackageId -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings -from pxmodrim.core.sort.models import CommunityRule - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _settings() -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - - -def _ctx( - mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId] | None = None, - graph: ConstraintGraph | None = None, - settings: SortSettings | None = None, - **kwargs: Any, -) -> CheckContext: - if ordered_pids is None: - ordered_pids = list(mods.keys()) - if graph is None: - graph = ConstraintGraph() - graph.build(mods, ordered_pids, settings or _settings()) - pid_to_index = {p: i for i, p in enumerate(ordered_pids)} - return CheckContext( - active_mods=mods, - ordered_pids=ordered_pids, - pid_to_index=pid_to_index, - graph=graph, - settings=settings or _settings(), - **kwargs, - ) - - -class TestDependencyIssueChecker: - def test_no_issues_no_deps(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = DependencyIssueChecker() - assert not checker.should_check(a, ctx) - assert checker.check(a, ctx) == [] - - def test_missing_dep(self): - a = _make_mod("mod.a") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - # b is not added to active_mods — mimics missing dependency - ctx = CheckContext( - active_mods={PackageId("mod.a"): a}, - ordered_pids=[PackageId("mod.a")], - pid_to_index={PackageId("mod.a"): 0}, - graph=ConstraintGraph(), - settings=_settings(), - ) - checker = DependencyIssueChecker() - assert checker.should_check(a, ctx) - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "missing_dependency" - assert issues[0].severity == "error" - - def test_dep_satisfied(self): - a = _make_mod("mod.a") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = DependencyIssueChecker() - assert checker.check(a, ctx) == [] - - -class TestIncompatibilityIssueChecker: - def test_no_incompatibilities(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - assert checker.check(a, ctx) == [] - - def test_self_declared(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "incompatibility" - assert issues[0].severity == "error" - - def test_reverse_declared(self): - declarer = _make_mod("mod.declarer") - target = _make_mod("mod.target") - declarer.about_rules.incompatible_with = CaseInsensitiveSet(["mod.target"]) - mods = { - PackageId("mod.declarer"): declarer, - PackageId("mod.target"): target, - } - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - - issues = checker.check(target, ctx) - - assert len(issues) == 1 - assert issues[0].category == "incompatibility" - assert issues[0].related_package_ids == (PackageId("mod.declarer"),) - - def test_reverse_declared_is_deduplicated_across_origins(self): - declarer = _make_mod("mod.declarer") - target = _make_mod("mod.target") - declarer_pid = PackageId("mod.declarer") - target_pid = PackageId("mod.target") - declarer.about_rules.incompatible_with = CaseInsensitiveSet([target_pid]) - mods = {declarer_pid: declarer, target_pid: target} - settings = SortSettings( - use_community_rules=True, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - community_rules = { - declarer_pid: CommunityRule( - package_id=declarer_pid, - load_after=set(), - load_before=set(), - load_first=False, - load_last=False, - incompatible_with={target_pid}, - ) - } - graph = ConstraintGraph() - graph.build(mods, list(mods), settings, community_rules) - origins = { - edge.origin - for edge in graph.incoming_of_type(target_pid, EdgeType.INCOMPATIBILITY) - } - assert origins == {EdgeOrigin.ABOUT_XML, EdgeOrigin.COMMUNITY_RULES} - ctx = _ctx(mods, graph=graph, settings=settings) - - issues = IncompatibilityIssueChecker().check(target, ctx) - - assert len(issues) == 1 - assert issues[0].related_package_ids == (declarer_pid,) - - -class TestLoadOrderIssueChecker: - def test_no_violations(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ordered = [PackageId("mod.b"), PackageId("mod.a")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert issues == [] - - def test_load_after_violation(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ordered = [PackageId("mod.a"), PackageId("mod.b")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "load_order" - assert issues[0].severity == "warning" - - def test_load_before_violation(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_before = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - # A should load before B but appears after B - ordered = [PackageId("mod.b"), PackageId("mod.a")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "load_order" - assert issues[0].severity == "warning" - - -class TestCycleIssueChecker: - def test_no_cycle(self): - a = _make_mod("mod.a") - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, cycles=[]) - checker = CycleIssueChecker() - assert checker.check(a, ctx) == [] - - def test_cycle_member(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx( - mods, - cycles=[ - [PackageId("mod.a"), PackageId("mod.b")], - ], - ) - checker = CycleIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "cycle" - assert issues[0].severity == "warning" - - def test_non_member_no_issue(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b, PackageId("mod.c"): c} - ctx = _ctx( - mods, - cycles=[ - [PackageId("mod.a"), PackageId("mod.b")], - ], - ) - checker = CycleIssueChecker() - assert checker.check(c, ctx) == [] - - -class TestGameVersionIssueChecker: - def test_no_versions_no_issue(self): - a = _make_mod("mod.a") - a.supported_versions = set() - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = GameVersionIssueChecker() - assert not checker.should_check(a, ctx) - assert checker.check(a, ctx) == [] - - def test_version_match(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.5"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5") - checker = GameVersionIssueChecker() - assert checker.check(a, ctx) == [] - - def test_version_mismatch(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.4"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5") - checker = GameVersionIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "version_mismatch" - assert issues[0].severity == "warning" - - def test_no_version_warning_suppressed(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.4"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5", no_version_warning={PackageId("mod.a")}) - checker = GameVersionIssueChecker() - assert checker.check(a, ctx) == [] - - -class TestReplacementIssueChecker: - def test_no_replacement(self): - a = _make_mod("mod.a") - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = ReplacementIssueChecker() - assert checker.check(a, ctx) == [] - - def test_replacement_available(self): - a = _make_mod("mod.a") - a._mod_path = None # No published_file_id - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = ReplacementIssueChecker() - assert checker.check(a, ctx) == [] diff --git a/legacy/tests/test_compat/test_config.py b/legacy/tests/test_compat/test_config.py deleted file mode 100644 index 6857c8c..0000000 --- a/legacy/tests/test_compat/test_config.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -import pxmodrim.core.config as config_module -from pxmodrim.core.config import ( - AppConfig, - AppConfigService, - ConfigService, - PathConfig, - read_game_version, -) -from pxmodrim.core.constants import LaunchStrategy -from pxmodrim.ui.config import UIPrefsService -from pxmodrim.ui.ui_prefs import UIPrefs - - -class TestReadGameVersion: - def test_parses_file(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text("1.6.4871 rev598\n", encoding="utf-8") - result = read_game_version(tmp_path) - assert result == "1.6.4871 rev598" - - def test_strips_whitespace(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text(" 1.6.4871 rev598 \n", encoding="utf-8") - result = read_game_version(tmp_path) - assert result == "1.6.4871 rev598" - - def test_missing_file_returns_none(self, tmp_path: Path) -> None: - result = read_game_version(tmp_path) - assert result is None - - @pytest.mark.skipif( - sys.platform == "win32", reason="chmod has no effect on Windows" - ) - def test_unreadable_file_returns_none(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text("1.6.4871 rev598\n") - ver_file.chmod(0o000) - try: - result = read_game_version(tmp_path) - assert result is None - finally: - ver_file.chmod(0o644) - - -async def test_legacy_app_config_migrates_with_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - expected = AppConfig( - paths=PathConfig( - game="/legacy/game", - local="/legacy/mods", - workshop="/legacy/workshop", - config_folder="/legacy/config", - community_rules_file="/legacy/rules", - no_version_warning_file="/legacy/no-warning", - use_this_instead_file="/legacy/use-instead", - ) - ) - service.save("config.json", expected) - path = tmp_path / "config.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - legacy["paths"]["steamcmd_prefix"] = "/legacy/steamcmd" - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.setattr(config_module, "time", lambda: 1_700_000_000) - - app_service = AppConfigService(service) - await app_service.setup() - - assert app_service.cfg() == expected - assert json.loads(path.read_text())["schema_version"] == 1 - assert (tmp_path / "config.json.bak.1700000000").read_bytes() == before - - -async def test_legacy_ui_prefs_migrates_with_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - expected = UIPrefs( - desc_expanded=True, - launch_strategy=LaunchStrategy.STEAM, - ) - service.save("ui_prefs.json", expected) - path = tmp_path / "ui_prefs.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - legacy["validate_downloads"] = True - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.setattr(config_module, "time", lambda: 1_700_000_000) - - prefs_service = UIPrefsService(service) - await prefs_service.setup() - - assert prefs_service.prefs() == expected - assert json.loads(path.read_text())["schema_version"] == 1 - assert (tmp_path / "ui_prefs.json.bak.1700000000").read_bytes() == before - - -async def test_fresh_services_return_defaults_without_creating_files( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - app_service = AppConfigService(service) - prefs_service = UIPrefsService(service) - await app_service.setup() - await prefs_service.setup() - - assert app_service.cfg() == AppConfig() - assert prefs_service.prefs() == UIPrefs() - assert not (tmp_path / "config.json").exists() - assert not (tmp_path / "ui_prefs.json").exists() - - -def test_managed_saves_stamp_schema_version_and_round_trips_values( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - expected_config = AppConfig(paths=PathConfig(game="/game", local="/mods")) - expected_prefs = UIPrefs(desc_expanded=True, launch_strategy=LaunchStrategy.STEAM) - service.save("config.json", expected_config) - service.save("ui_prefs.json", expected_prefs) - - assert json.loads((tmp_path / "config.json").read_text())["schema_version"] == 1 - assert json.loads((tmp_path / "ui_prefs.json").read_text())["schema_version"] == 1 - assert service.load("config.json", AppConfig) == expected_config - assert service.load("ui_prefs.json", UIPrefs) == expected_prefs - - -def test_current_config_load_does_not_rewrite(tmp_path: Path) -> None: - path = tmp_path / "config.json" - path.write_text( - json.dumps({"schema_version": 1, "paths": {"game": "/current"}}), - encoding="utf-8", - ) - before = path.read_bytes() - - assert ( - ConfigService(tmp_path).load("config.json", AppConfig).paths.game == "/current" - ) - assert path.read_bytes() == before - assert not list(tmp_path.glob("config.json.bak.*")) - - -def test_current_ui_prefs_load_does_not_rewrite(tmp_path: Path) -> None: - path = tmp_path / "ui_prefs.json" - path.write_text( - json.dumps({"schema_version": 1, "desc_expanded": True}), - encoding="utf-8", - ) - before = path.read_bytes() - - assert ConfigService(tmp_path).load("ui_prefs.json", UIPrefs).desc_expanded is True - assert path.read_bytes() == before - assert not list(tmp_path.glob("ui_prefs.json.bak.*")) - - -def test_corrupt_config_returns_safe_defaults(tmp_path: Path) -> None: - (tmp_path / "config.json").write_text("{invalid", encoding="utf-8") - - assert ConfigService(tmp_path).load("config.json", AppConfig) == AppConfig() - - -def test_unreadable_config_returns_safe_defaults( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "config.json" - path.write_text("{}", encoding="utf-8") - - def unreadable(_: Path) -> bytes: - raise OSError("permission denied") - - monkeypatch.setattr(Path, "read_bytes", unreadable) - assert ConfigService(tmp_path).load("config.json", AppConfig) == AppConfig() - - -def test_missing_json_migration_step_fails_without_rewrite( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - service.save("config.json", AppConfig()) - path = tmp_path / "config.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.delitem(config_module._JSON_MIGRATIONS, 1) - - with pytest.raises(KeyError, match="Missing JSON migration step"): - service.load("config.json", AppConfig) - - assert path.read_bytes() == before - assert not list(tmp_path.glob("config.json.bak.*")) - - -def test_unsupported_config_schema_fails_without_mutation(tmp_path: Path) -> None: - path = tmp_path / "config.json" - path.write_text(json.dumps({"schema_version": 2}), encoding="utf-8") - before = path.read_bytes() - - with pytest.raises(ValueError, match="Unsupported JSON schema version"): - ConfigService(tmp_path).load("config.json", AppConfig) - - assert path.read_bytes() == before - - -def test_ui_prefs_skipped_update_tag_round_trips(tmp_path: Path) -> None: - service = ConfigService(tmp_path) - service.save("ui_prefs.json", UIPrefs(skipped_update_tag="v0.2.0")) - - assert service.load("ui_prefs.json", UIPrefs).skipped_update_tag == "v0.2.0" - - -def test_ui_prefs_with_removed_key_still_loads(tmp_path: Path) -> None: - (tmp_path / "ui_prefs.json").write_text( - json.dumps( - { - "schema_version": 1, - "deps_expanded": True, - "desc_expanded": True, - "skipped_update_tag": "v0.3.0", - } - ), - encoding="utf-8", - ) - - prefs = ConfigService(tmp_path).load("ui_prefs.json", UIPrefs) - - assert prefs.desc_expanded is True - assert prefs.skipped_update_tag == "v0.3.0" diff --git a/legacy/tests/test_compat/test_migrator.py b/legacy/tests/test_compat/test_migrator.py deleted file mode 100644 index c4c9665..0000000 --- a/legacy/tests/test_compat/test_migrator.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import aiosqlite -import pytest - -import pxmodrim.core.migrator as migrator_module -from pxmodrim.core.migrator import ensure_schema - - -async def _noop() -> None: - return None - - -async def test_db_fresh_init_creates_schema_and_stamps_target( - tmp_path: Path, -) -> None: - path = tmp_path / "cache.db" - events: list[str] = [] - - async def future_step() -> None: - events.append("future") - - async with aiosqlite.connect(path) as conn: - await ensure_schema( - conn, - schema_sql="CREATE TABLE state (value TEXT);", - schema_version=2, - steps={1: _noop, 2: future_step}, - backup_path=path, - ) - await conn.commit() - - version = await (await conn.execute("PRAGMA user_version")).fetchone() - table = await ( - await conn.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'" - ) - ).fetchone() - - assert version == (2,) - assert table == ("state",) - assert events == [] - - assert not list(tmp_path.glob("cache.db.bak.*")) - - -async def test_db_migrations_checkpoint_wal_before_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "cache.db" - monkeypatch.setattr(migrator_module, "time", lambda: 1_700_000_000) - backup_path = tmp_path / "cache.db.bak.1700000000" - - async with aiosqlite.connect(path) as conn: - journal_mode = await ( - await conn.execute("PRAGMA journal_mode = WAL") - ).fetchone() - assert journal_mode == ("wal",) - await conn.execute("PRAGMA wal_autocheckpoint = 0") - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("INSERT INTO state VALUES ('before')") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - assert path.with_name(f"{path.name}-wal").stat().st_size > 0 - events: list[str] = [] - - async def step_two() -> None: - events.append("two") - await conn.execute("ALTER TABLE state ADD COLUMN second TEXT") - - async def step_three() -> None: - events.append("three") - await conn.execute("ALTER TABLE state ADD COLUMN third TEXT") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE IF NOT EXISTS state (value TEXT);", - schema_version=3, - steps={2: step_two, 3: step_three}, - backup_path=path, - ) - await conn.commit() - - version = await (await conn.execute("PRAGMA user_version")).fetchone() - columns = await (await conn.execute("PRAGMA table_info(state)")).fetchall() - - assert version == (3,) - assert [column[1] for column in columns] == ["value", "second", "third"] - assert events == ["two", "three"] - - for suffix in ("-wal", "-shm"): - backup_path.with_name(f"{backup_path.name}{suffix}").unlink(missing_ok=True) - - async with aiosqlite.connect(backup_path) as backup_conn: - backup_version = await ( - await backup_conn.execute("PRAGMA user_version") - ).fetchone() - backup_columns = await ( - await backup_conn.execute("PRAGMA table_info(state)") - ).fetchall() - backup_rows = await ( - await backup_conn.execute("SELECT value FROM state") - ).fetchall() - - assert backup_version == (1,) - assert [column[1] for column in backup_columns] == ["value"] - assert backup_rows == [("before",)] - - -async def test_db_missing_migration_step_fails_without_advancing_version( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "cache.db" - monkeypatch.setattr(migrator_module, "time", lambda: 1_700_000_000) - backup_path = tmp_path / "cache.db.bak.1700000000" - - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - before = path.read_bytes() - - with pytest.raises(KeyError, match="Missing migration step"): - await ensure_schema( - conn, - schema_sql="CREATE TABLE IF NOT EXISTS state (value TEXT);", - schema_version=3, - steps={2: _noop}, - backup_path=path, - ) - version = await (await conn.execute("PRAGMA user_version")).fetchone() - - assert version == (1,) - - assert path.read_bytes() == before - assert backup_path.read_bytes() == before - - -async def test_db_current_schema_is_a_noop(tmp_path: Path) -> None: - path = tmp_path / "cache.db" - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - before = path.read_bytes() - events: list[str] = [] - - async def unexpected_step() -> None: - events.append("called") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE should_not_be_created (value TEXT);", - schema_version=1, - steps={1: unexpected_step}, - backup_path=path, - ) - - assert await (await conn.execute("PRAGMA user_version")).fetchone() == (1,) - assert events == [] - - assert path.read_bytes() == before - assert not list(tmp_path.glob("cache.db.bak.*")) - - -async def test_db_newer_schema_is_a_noop(tmp_path: Path) -> None: - path = tmp_path / "cache.db" - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 2") - await conn.commit() - before = path.read_bytes() - events: list[str] = [] - - async def unexpected_step() -> None: - events.append("called") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE should_not_be_created (value TEXT);", - schema_version=1, - steps={1: unexpected_step}, - backup_path=path, - ) - - assert await (await conn.execute("PRAGMA user_version")).fetchone() == (2,) - assert events == [] - - assert path.read_bytes() == before - assert not list(tmp_path.glob("cache.db.bak.*")) diff --git a/legacy/tests/test_downloads/test_manager.py b/legacy/tests/test_downloads/test_manager.py deleted file mode 100644 index f7ad274..0000000 --- a/legacy/tests/test_downloads/test_manager.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.downloads import DownloadManager, DownloadResult -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.models.metadata.structures import ListedMod - - -class FakeDownloader(Downloader): - prefix = "" - - def __init__(self) -> None: - super().__init__() - self._busy = False - self.calls: list[list[str]] = [] - self.cancelled = False - - @property - def is_downloading(self) -> bool: - return self._busy - - @property - def active_ids(self) -> frozenset[str]: - return frozenset() - - def accepts(self, mod_id: str) -> bool: - return mod_id.startswith(self.prefix) - - def updatable_id(self, mod: ListedMod) -> str | None: - mod_id = mod.mod_path.name if mod.mod_path else "" - return mod_id if self.accepts(mod_id) else None - - def last_synced(self, mod: ListedMod) -> float | None: - return 1.0 if self.updatable_id(mod) else None - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - self.calls.append(mod_ids) - self._busy = True - self.busy_changed.emit(True) - self._busy = False - self.busy_changed.emit(False) - result = DownloadResult(succeeded=list(mod_ids), failed=[]) - self.download_finished.emit(result) - return result - - def cancel(self) -> None: - self.cancelled = True - - -class FakeSteam(FakeDownloader): - name = "steam" - label = "Steam" - prefix = "s" - - -class FakeGithub(FakeDownloader): - name = "github" - label = "Github" - prefix = "g" - - -def _mod(tmp_path, name: str) -> ListedMod: - return ListedMod(_mod_path=tmp_path / name) - - -def test_empty_manager_offers_nothing(tmp_path) -> None: - manager = DownloadManager() - assert not manager.available - assert manager.updatable_ids([_mod(tmp_path, "s1")]) == [] - assert manager.source_of(_mod(tmp_path, "s1")) is None - - -async def test_download_routes_ids_to_the_accepting_downloader() -> None: - manager = DownloadManager() - steam, github = FakeSteam(), FakeGithub() - manager.register(steam) - manager.register(github) - - result = await manager.download_mods(["s1", "g1", "s2", "s1"]) - - assert steam.calls == [["s1", "s2"]] - assert github.calls == [["g1"]] - assert sorted(result.succeeded) == ["g1", "s1", "s2"] - - -async def test_unclaimed_id_is_rejected_before_anything_starts() -> None: - manager = DownloadManager() - steam = FakeSteam() - manager.register(steam) - - with pytest.raises(ValueError, match="x1"): - await manager.download_mods(["s1", "x1"]) - assert steam.calls == [] - - -def test_source_and_ids_come_from_the_owning_downloader(tmp_path) -> None: - manager = DownloadManager() - manager.register(FakeSteam()) - manager.register(FakeGithub()) - mods = [_mod(tmp_path, n) for n in ("s1", "g1", "other")] - - source = manager.source_of(mods[1]) - assert source is not None and source.label == "Github" - assert manager.updatable_ids(mods) == ["s1", "g1"] - assert manager.last_synced(mods[2]) is None - - -async def test_busy_reflects_any_downloader_and_cancel_stops_the_rest() -> None: - manager = DownloadManager() - steam, github = FakeSteam(), FakeGithub() - manager.register(steam) - manager.register(github) - busy: list[bool] = [] - manager.busy_changed.connect(busy.append) - - steam.download_finished.connect(lambda _r: manager.cancel()) - await manager.download_mods(["s1", "g1"]) - - assert github.calls == [] - assert busy == [True, False] - - -def test_names_are_keyed_by_source_id_not_workshop_id(tmp_path) -> None: - manager = DownloadManager() - manager.register(FakeGithub()) - mod = _mod(tmp_path, "g1") - object.__setattr__(mod, "name", "Git Mod") - - assert manager.names_by_id([mod, _mod(tmp_path, "s1")]) == {"g1": "Git Mod"} diff --git a/legacy/tests/test_downloads/test_steam.py b/legacy/tests/test_downloads/test_steam.py deleted file mode 100644 index 45b0ed9..0000000 --- a/legacy/tests/test_downloads/test_steam.py +++ /dev/null @@ -1,506 +0,0 @@ -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Callable, Sequence -from os import PathLike -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pxsteamdl -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult -from pxmodrim.core.downloads.manager import download_manager -from pxmodrim.core.downloads.steam import ( - ClientOptions, - SteamDownloader, - SteamSettings, - WorkshopSyncState, -) -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog - - -class FakeClient: - def __init__( - self, - errors: dict[int, str] | None = None, - gates: dict[int, threading.Event] | None = None, - network_failures: frozenset[int] = frozenset(), - ) -> None: - self.errors = errors or {} - self.gates = gates or {} - self.network_failures = network_failures - self.stopped = False - self.calls: list[tuple[list[int], Path, int]] = [] - self.blocked = threading.Event() - - def download( - self, - ids: Sequence[int], - root: str | PathLike[str], - *, - parallel_items: int = 4, - threads_per_item: int = 8, - on_progress: Callable[[Any], None] | None = None, - on_resolved: Callable[[Any], None] | None = None, - cancel: Any = None, - ) -> list[Any]: - self.calls.append((list(ids), Path(root), parallel_items * threads_per_item)) - results = [] - for item_id in ids: - if on_resolved is not None: - on_resolved( - SimpleNamespace(item_id=item_id, title=f"Title {item_id}", error="") - ) - if on_progress is not None: - on_progress( - SimpleNamespace( - item_id=item_id, unpacked_bytes=5, unpacked_total=10 - ) - ) - gate = self.gates.get(item_id) - if gate is not None: - self.blocked.set() - gate.wait(timeout=5) - error = "cancelled" if self.stopped else self.errors.get(item_id, "") - if not error: - (Path(root) / str(item_id)).mkdir(parents=True, exist_ok=True) - results.append( - SimpleNamespace( - item_id=item_id, - error=error, - cancelled=self.stopped, - error_kind=( - pxsteamdl.ErrorKind.NETWORK - if item_id in self.network_failures - else pxsteamdl.ErrorKind.NONE - ), - ) - ) - return results - - -def _cfg(tmp_path: Path, *, local: bool = True) -> AppConfig: - cfg = AppConfig() - if local: - cfg.paths.local = str(tmp_path / "Mods") - return cfg - - -def _service( - tmp_path: Path, client: FakeClient | None = None, *, local: bool = True -) -> tuple[SteamDownloader, list[ClientOptions]]: - cfg = _cfg(tmp_path, local=local) - logins: list[ClientOptions] = [] - - async def factory(options: ClientOptions) -> FakeClient: - logins.append(options) - if client is None: - raise RuntimeError("logon denied") - return client - - svc = SteamDownloader(factory) - ctx = CoreContext(cfg, ConfigService(tmp_path)) - svc.setup(ctx) - return svc, logins - - -async def test_login_uses_configured_proxy_and_timeouts(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - assert svc._ctx is not None - await svc.download_mods(["111"]) - svc.settings.update( - SteamSettings(proxy=" socks5h://host:1080 ", connect_timeout=3, stall_timeout=7) - ) - await svc.download_mods(["222"]) - assert logins == [(None, 10, 30), ("socks5h://host:1080", 3, 7)] - - -async def test_network_failure_triggers_new_login(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient(network_failures=frozenset({111}))) - await svc.download_mods(["111"]) - await svc.download_mods(["222"]) - assert len(logins) == 2 - - -async def test_downloads_into_local_mods_and_splits_results(tmp_path: Path) -> None: - client = FakeClient(errors={222: "item not found"}) - svc, _ = _service(tmp_path, client) - statuses: list[DownloadItemStatus] = [] - finished: list[DownloadResult] = [] - svc.download_item_status_changed.connect(statuses.append) - svc.download_finished.connect(finished.append) - - result = await svc.download_mods(["111", "222", "111"]) - - assert sorted(result.succeeded) == ["111"] - assert result.failed == ["222"] - assert finished == [result] - assert (tmp_path / "Mods" / "111").is_dir() - assert client.calls[0][0] == [111, 222] - assert {call[1] for call in client.calls} == {tmp_path / "Mods"} - final = {s.mod_id: s for s in statuses if s.status != "downloading"} - assert final["111"].status == "success" - assert final["222"].status == "error" - assert final["222"].error == "item not found" - - -@pytest.mark.parametrize( - "ids", [["12a"], ["١٢٣"], ["1 2"], [], ["18446744073709551616"]] -) -async def test_rejects_invalid_ids(tmp_path: Path, ids: list[str]) -> None: - svc, logins = _service(tmp_path, FakeClient()) - with pytest.raises(ValueError): - await svc.download_mods(ids) - assert logins == [] - - -async def test_requires_local_mods_path(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient(), local=False) - with pytest.raises(ValueError, match="Local mods path"): - await svc.download_mods(["111"]) - assert logins == [] - - -async def test_accepts_uint64_max_id(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - result = await svc.download_mods(["18446744073709551615"]) - assert result.succeeded == ["18446744073709551615"] - assert client.calls[0][0] == [2**64 - 1] - - -async def test_unwritable_local_path_fails_every_item(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - (tmp_path / "Mods").write_text("not a directory") - result = await svc.download_mods(["111", "222"]) - assert sorted(result.failed) == ["111", "222"] - assert logins == [] - - -async def test_login_failure_fails_every_item_and_retries_next_time( - tmp_path: Path, -) -> None: - svc, logins = _service(tmp_path, None) - messages: list[str] = [] - svc.status_message_changed.connect(messages.append) - - result = await svc.download_mods(["111", "222"]) - await svc.download_mods(["111"]) - - assert result.succeeded == [] - assert sorted(result.failed) == ["111", "222"] - assert any("logon denied" in m for m in messages) - assert len(logins) == 2 - - -async def test_client_session_is_reused(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - await svc.download_mods(["111"]) - await svc.download_mods(["222"]) - assert len(logins) == 1 - - -async def test_uses_at_most_eight_download_workers(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - await svc.download_mods([str(i) for i in range(1, 20)]) - assert len(client.calls) == 1 - assert client.calls[0][2] <= 8 - - -async def test_cancel_keeps_finished_items_and_returns(tmp_path: Path) -> None: - gate = threading.Event() - client = FakeClient(gates={222: gate}) - svc, _ = _service(tmp_path, client) - finished: list[DownloadResult] = [] - svc.download_finished.connect(finished.append) - - async def cancel_when_blocked() -> None: - while not client.blocked.is_set(): - await asyncio.sleep(0.01) - svc.cancel() - client.stopped = True - gate.set() - - canceller = asyncio.create_task(cancel_when_blocked()) - result = await svc.download_mods(["111", "222", "333"]) - await canceller - - assert result.succeeded == ["111"] - assert result.failed == [] - assert finished == [result] - assert not svc.is_downloading - - -async def test_second_download_while_running_is_rejected(tmp_path: Path) -> None: - gate = threading.Event() - svc, _ = _service(tmp_path, FakeClient(gates={111: gate})) - first = asyncio.create_task(svc.download_mods(["111"])) - while not svc.is_downloading: - await asyncio.sleep(0) - - with pytest.raises(RuntimeError, match="already running"): - await svc.download_mods(["222"]) - - gate.set() - assert (await first).succeeded == ["111"] - - -def _mod_at(path: Path, pfid: str | None = None) -> ListedMod: - if pfid is not None: - (path / "About").mkdir(parents=True) - (path / "About" / "PublishedFileId.txt").write_text(pfid) - else: - path.mkdir(parents=True) - return ListedMod(_mod_path=path) - - -def _synced(tmp_path: Path, svc: SteamDownloader, pid: str) -> float | None: - return svc.last_synced(ListedMod(_mod_path=tmp_path / "Mods" / pid)) - - -def _load(svc: SteamDownloader, mods: list[ListedMod]) -> None: - assert svc._ctx is not None - svc._ctx.load({m.uuid: m for m in mods}, []) - - -def test_updatable_id_only_for_downloads_in_local_mods(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - mods = [ - _mod_at(tmp_path / "Mods" / "111", "111"), - _mod_at(tmp_path / "Mods" / "Renamed", "222"), - _mod_at(tmp_path / "Mods" / "333"), - _mod_at(tmp_path / "Workshop" / "444", "444"), - ] - _load(svc, mods) - assert [svc.updatable_id(m) for m in mods] == ["111", None, "333", None] - assert svc._ctx is not None - assert svc.updatable_ids(svc._ctx.all_mods.values()) == ["111", "333"] - - -def test_updatable_id_none_without_local_path(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, local=False) - assert svc.updatable_id(_mod_at(tmp_path / "Mods" / "111", "111")) is None - - -async def test_busy_changed_brackets_download(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - events: list[object] = [] - svc.busy_changed.connect(lambda busy: events.append(("busy", busy))) - svc.download_finished.connect(lambda _r: events.append("finished")) - await svc.download_mods(["111"]) - assert events == [("busy", True), ("busy", False), "finished"] - - -async def test_invalid_ids_do_not_toggle_busy(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - busy: list[bool] = [] - svc.busy_changed.connect(busy.append) - with pytest.raises(ValueError): - await svc.download_mods(["12a"]) - assert busy == [] - - -def test_progress_counts_complete_and_up_to_date_items() -> None: - from pxmodrim.core.downloads.steam import _Batch - - batch = _Batch(3) - batch.bytes = {"1": (10, 10), "2": (5, 10), "3": (0, 0)} - assert batch.progress().completed == 2 - batch.succeeded.append("1") - assert batch.progress().completed == 2 - - -def test_updatable_ids_limited_to_given_mods_and_deduplicated(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - a = _mod_at(tmp_path / "Mods" / "111", "111") - b = _mod_at(tmp_path / "Mods" / "222", "222") - _load(svc, [a, b]) - assert svc.updatable_ids([b, b, _mod_at(tmp_path / "Other" / "9", "9")]) == ["222"] - - -async def test_result_changed_excludes_up_to_date_items(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - assert svc.active_ids == frozenset() - result = await svc.download_mods(["111"]) - assert result.changed == ["111"] - assert svc.active_ids == frozenset() - - -def test_batch_changed_ignores_zero_byte_items() -> None: - from pxmodrim.core.downloads.steam import _Batch - - batch = _Batch(2) - batch.succeeded = ["1", "2"] - batch.bytes = {"1": (0, 0), "2": (5, 5)} - assert batch.changed() == ["2"] - - -async def test_success_records_sync_time_and_persists(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient(errors={222: "gone"})) - await svc.download_mods(["111", "222"]) - assert _synced(tmp_path, svc, "111") is not None - assert _synced(tmp_path, svc, "222") is None - reloaded = ConfigService(tmp_path).load("workshop_sync.json", WorkshopSyncState) - assert set(reloaded.synced) == {"111"} - - -def test_stale_ids_never_synced_first_then_oldest(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - mods = [_mod_at(tmp_path / "Mods" / n, n) for n in ("1", "2", "3", "4")] - _load(svc, mods) - svc._sync.synced.update({"1": 1000.0, "2": 9000.0, "3": 500.0}) - assert svc.stale_ids(3600, now=10000.0) == ["4", "3", "1"] - - -async def test_auto_update_only_syncs_stale_mods_when_enabled(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") is None - - svc.settings.update(SteamSettings(auto_update_hours=6)) - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") is not None - synced = _synced(tmp_path, svc, "1") - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") == synced - - -@pytest.mark.parametrize("cancel_auto_update", [False, True]) -async def test_cancelled_manual_batch_does_not_discard_queue_after_auto_update( - tmp_path: Path, - cancel_auto_update: bool, -) -> None: - manual_gate = threading.Event() - auto_gate = threading.Event() - client = FakeClient(gates={111: manual_gate, 1: auto_gate}) - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - ctx = svc._ctx - ctx._mod_service = ModService(ctx, []) - _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) - svc.settings.update(SteamSettings(auto_update_hours=6)) - manager = download_manager(ctx) - catalog = WorkshopCatalog() - catalog.setup(ctx) - tasks: list[asyncio.Task[Any]] = [] - try: - manual = asyncio.create_task(manager.download_mods(["111"])) - tasks.append(manual) - assert await asyncio.to_thread(client.blocked.wait, 5) - manager.cancel() - client.stopped = True - manual_gate.set() - assert (await asyncio.wait_for(manual, 5)).succeeded == [] - assert manager.cancelled - - client.stopped = False - client.blocked.clear() - auto_update = asyncio.create_task(svc._auto_update_once()) - tasks.append(auto_update) - assert await asyncio.to_thread(client.blocked.wait, 5) - assert manager.is_downloading - catalog.enqueue(DownloadPlan(["222"], [], [], [], {}, True)) - runner = catalog._runner - assert runner is not None - await asyncio.sleep(0) - assert not runner.done() - assert catalog.queued_ids == {"222"} - if cancel_auto_update: - manager.cancel() - client.stopped = True - - auto_gate.set() - await asyncio.wait_for(auto_update, 5) - await asyncio.wait_for(runner, 5) - assert not catalog.queued_ids - if cancel_auto_update: - assert [call[0] for call in client.calls] == [[111], [1]] - assert manager.cancelled - assert _synced(tmp_path, svc, "222") is None - else: - assert [call[0] for call in client.calls] == [[111], [1], [222]] - assert not manager.cancelled - assert _synced(tmp_path, svc, "222") is not None - finally: - manual_gate.set() - auto_gate.set() - await asyncio.gather(*tasks, return_exceptions=True) - await catalog.shutdown() - await svc.shutdown() - - -async def test_titles_from_steam_are_emitted(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - titles: dict[str, str] = {} - svc.download_item_titled.connect(lambda t: titles.update({t.mod_id: t.title})) - - phases: list[str] = [] - svc.download_phase_changed.connect(phases.append) - - await svc.download_mods(["111", "222"]) - - assert phases == ["login", "query", "run"] - assert titles == {"111": "Title 111", "222": "Title 222"} - - -async def test_client_is_relogged_after_download_raises(tmp_path: Path) -> None: - class Dying(FakeClient): - def download(self, ids, root, **kwargs): # type: ignore[no-untyped-def] - raise RuntimeError("session lost") - - clients: list[FakeClient] = [Dying(), FakeClient()] - logins: list[int] = [] - - async def factory(_options: ClientOptions) -> FakeClient: - logins.append(1) - return clients[len(logins) - 1] - - svc = SteamDownloader(factory) - svc.setup(CoreContext(_cfg(tmp_path), ConfigService(tmp_path))) - await svc.download_mods(["1"]) - await svc.download_mods(["2"]) - assert len(logins) == 2 - - -async def test_auto_update_loop_survives_unexpected_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr("pxmodrim.core.downloads.steam.AUTO_STARTUP_DELAY_S", 0) - monkeypatch.setattr("pxmodrim.core.downloads.steam.AUTO_CHECK_INTERVAL_S", 0) - svc, _ = _service(tmp_path, FakeClient()) - calls = 0 - - async def flaky() -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise KeyError("boom") - - monkeypatch.setattr(svc, "_auto_update_once", flaky) - task = asyncio.create_task(svc._auto_update_loop()) - for _ in range(20): - await asyncio.sleep(0) - task.cancel() - assert calls >= 2 - - -async def test_download_uses_configured_parallelism_clamped(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - svc.settings.update(SteamSettings(parallel_items=3, threads_per_item=5)) - await svc.download_mods(["1"]) - svc.settings.update(SteamSettings(parallel_items=0, threads_per_item=999)) - await svc.download_mods(["2"]) - assert [c[2] for c in client.calls] == [15, 16] diff --git a/legacy/tests/test_logging.py b/legacy/tests/test_logging.py deleted file mode 100644 index a443a68..0000000 --- a/legacy/tests/test_logging.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from loguru import logger - -from pxmodrim._app import _configure_file_logging - - -def _write_record(tmp_path: Path, monkeypatch, level: str, message: str) -> dict: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("LOGURU_LEVEL", level) - sink_id = _configure_file_logging() - try: - logger.log(level, message) - finally: - logger.remove(sink_id) - - log_path = tmp_path / "pxmodrim" / "logs" / "pxmodrim.log" - return json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1]) - - -def test_file_sink_writes_parseable_info_record(tmp_path: Path, monkeypatch) -> None: - event = _write_record(tmp_path, monkeypatch, "INFO", "startup context") - record = event["record"] - - assert record["level"]["name"] == "INFO" - assert record["message"] == "startup context" - assert record["process"]["id"] > 0 - assert record["time"]["timestamp"] > 0 - assert isinstance(record["line"], int) - - -def test_file_sink_defaults_to_info(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("LOGURU_LEVEL", raising=False) - sink_id = _configure_file_logging() - try: - logger.debug("hidden debug context") - logger.info("visible info context") - finally: - logger.remove(sink_id) - - log_path = tmp_path / "pxmodrim" / "logs" / "pxmodrim.log" - levels = [ - json.loads(line)["record"]["level"]["name"] - for line in log_path.read_text(encoding="utf-8").splitlines() - ] - assert levels == ["INFO"] - - -def test_file_sink_includes_debug_when_requested(tmp_path: Path, monkeypatch) -> None: - event = _write_record(tmp_path, monkeypatch, "DEBUG", "debug context") - - assert event["record"]["level"]["name"] == "DEBUG" diff --git a/legacy/tests/test_metadata/__init__.py b/legacy/tests/test_metadata/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml b/legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml deleted file mode 100644 index c162c11..0000000 --- a/legacy/tests/test_metadata/data/local_mods/valid_mod/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml b/legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml deleted file mode 100644 index c162c11..0000000 --- a/legacy/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/legacy/tests/test_metadata/data/valid_mod/About/About.xml b/legacy/tests/test_metadata/data/valid_mod/About/About.xml deleted file mode 100644 index c162c11..0000000 --- a/legacy/tests/test_metadata/data/valid_mod/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/legacy/tests/test_metadata/test_metadata_cache.py b/legacy/tests/test_metadata/test_metadata_cache.py deleted file mode 100644 index 68987e2..0000000 --- a/legacy/tests/test_metadata/test_metadata_cache.py +++ /dev/null @@ -1,391 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from unittest.mock import patch - -import aiosqlite -import pytest - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.providers import base as base_module -from pxmodrim.core.providers.local import DownloadedModProvider, LocalModProvider -from pxmodrim.core.services.metadata_cache import ( - MetadataCache, - normalize_path, -) - -ABOUT_XML_V1 = """ - - Sample Mod One - TestAuthor - Author.SampleModOne - -
  • 1.4
  • -
  • 1.5
  • -
    - Initial description - 1.0.0 - https://example.com/mod - UI/Icon.png - -
  • - ludeon.rimworld - RimWorld -
  • -
    -
    -""" - -ABOUT_XML_V2 = """ - - Sample Mod One Updated - TestAuthor - Author.SampleModOne - -
  • 1.4
  • -
  • 1.5
  • -
    - Updated description -
    -""" - -ABOUT_XML_TWO = """ - - Sample Mod Two - OtherAuthor - Author.SampleModTwo - -
  • 1.5
  • -
    - Second mod description -
    -""" - - -def _create_mod(mod_dir: Path, xml_content: str, pfid: str | None = None) -> Path: - about_dir = mod_dir / "About" - about_dir.mkdir(parents=True, exist_ok=True) - xml_path = about_dir / "About.xml" - xml_path.write_text(xml_content, encoding="utf-8") - if pfid is not None: - (about_dir / "PublishedFileId.txt").write_text(pfid, encoding="utf-8") - return mod_dir - - -@pytest.mark.asyncio -async def test_warm_cache_avoids_reparse(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - real_create_mod = base_module.create_listed_mod_from_path - parse_call_count = 0 - - def counting_create_mod(*args, **kwargs): - nonlocal parse_call_count - parse_call_count += 1 - return real_create_mod(*args, **kwargs) - - try: - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - discovered1 = await provider.discover("1.5") - - assert len(discovered1) == 1 - mod1 = discovered1[str(mod_a)] - assert isinstance(mod1, AboutXmlMod) - assert mod1.name == "Sample Mod One" - assert str(mod1.package_id) == "author.samplemodone" - assert mod1.provider_id == "local" - assert parse_call_count == 1 - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - discovered2 = await provider.discover("1.5") - assert len(discovered2) == 1 - mod2 = discovered2[str(mod_a)] - assert isinstance(mod2, AboutXmlMod) - assert mod2.name == "Sample Mod One" - assert str(mod2.package_id) == "author.samplemodone" - assert mod2.provider_id == "local" - assert mod2.mtime == mod1.mtime - assert mod2.authors == ["TestAuthor"] - assert mod2.supported_versions == {"1.4", "1.5"} - assert mod2.description == "Initial description" - assert mod2.mod_version == "1.0.0" - assert mod2.url == "https://example.com/mod" - assert mod2.mod_icon_path == Path("UI/Icon.png") - assert "ludeon.rimworld" in mod2.about_rules.dependencies - assert parse_call_count == 1 - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_force_reparse_bypasses_cache_and_refreshes_mod(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - cache = MetadataCache(tmp_path / "metadata-cache.db") - provider = LocalModProvider(mods_root, metadata_cache=cache) - real_create_mod = base_module.create_listed_mod_from_path - parse_call_count = 0 - - def counting_create_mod(*args, **kwargs): - nonlocal parse_call_count - parse_call_count += 1 - _, mod = real_create_mod(*args, **kwargs) - if parse_call_count == 2: - mod.name = "Fresh parse result" - return _, mod - - try: - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - first = await provider.discover("1.5") - assert first[str(mod_a)].name == "Sample Mod One" - assert parse_call_count == 1 - - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - cached = await provider.discover("1.5") - assert cached[str(mod_a)].name == "Sample Mod One" - assert parse_call_count == 1 - - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - reparsed = await provider.discover("1.5", force_reparse=True) - assert reparsed[str(mod_a)].name == "Fresh parse result" - assert parse_call_count == 2 - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_changed_metadata_reparses(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - try: - discovered1 = await provider.discover("1.5") - mod1 = discovered1[str(mod_a)] - assert mod1.name == "Sample Mod One" - - # Modify About.xml and bump mtime - xml_path = mod_a / "About" / "About.xml" - xml_path.write_text(ABOUT_XML_V2, encoding="utf-8") - new_mtime = xml_path.stat().st_mtime + 5.0 - os.utime(xml_path, (new_mtime, new_mtime)) - - discovered2 = await provider.discover("1.5") - mod2 = discovered2[str(mod_a)] - assert isinstance(mod2, AboutXmlMod) - assert mod2.name == "Sample Mod One Updated" - assert mod2.description == "Updated description" - assert mod2.mtime == pytest.approx(new_mtime, abs=1e-3) - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_deleted_mods_are_invalidated(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - mod_b = _create_mod(mods_root / "ModB", ABOUT_XML_TWO) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - cfg = AppConfig() - cfg.paths.local = str(mods_root) - ctx = CoreContext(cfg) - - provider = LocalModProvider(mods_root, metadata_cache=cache) - mod_service = ModService(ctx, [provider], metadata_cache=cache) - - try: - await mod_service.discover() - assert len(ctx.all_mods) == 2 - assert str(mod_a) in ctx.all_mods - assert str(mod_b) in ctx.all_mods - - # Verify DB has entries for both mods - async with ( - aiosqlite.connect(str(db_path)) as conn, - conn.execute("SELECT mod_path FROM mod_metadata") as cur, - ): - rows = await cur.fetchall() - cached_paths = {r[0] for r in rows} - assert normalize_path(mod_a) in cached_paths - assert normalize_path(mod_b) in cached_paths - - # Delete ModB from disk - for p in (mod_b / "About").iterdir(): - p.unlink() - (mod_b / "About").rmdir() - mod_b.rmdir() - - # Run discover again - await mod_service.discover() - assert len(ctx.all_mods) == 1 - assert str(mod_a) in ctx.all_mods - assert str(mod_b) not in ctx.all_mods - - # Verify ModB was pruned from the database! - async with ( - aiosqlite.connect(str(db_path)) as conn, - conn.execute("SELECT mod_path FROM mod_metadata") as cur, - ): - rows_after = await cur.fetchall() - cached_paths_after = {r[0] for r in rows_after} - assert normalize_path(mod_a) in cached_paths_after - assert normalize_path(mod_b) not in cached_paths_after - finally: - await mod_service.close() - - -@pytest.mark.asyncio -async def test_corrupt_cache_recovers_safely(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - try: - # Populate cache - provider = LocalModProvider(mods_root, metadata_cache=cache) - discovered1 = await provider.discover("1.5") - assert len(discovered1) == 1 - - # Close cache and corrupt the file - await cache.close() - db_path.write_bytes(b"corrupt non-sqlite data" * 50) - - # New cache instance pointing to corrupted file - new_cache = MetadataCache(db_path) - new_provider = LocalModProvider(mods_root, metadata_cache=new_cache) - - # Discovery must not crash; it recovers and parses mods - discovered2 = await new_provider.discover("1.5") - assert len(discovered2) == 1 - mod = discovered2[str(mod_a)] - assert mod.name == "Sample Mod One" - discovered3 = await new_provider.discover("1.5") - assert len(discovered3) == 1 - assert discovered3[str(mod_a)].name == "Sample Mod One" - await new_cache.close() - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_provider_distinctions_preserved(tmp_path: Path) -> None: - local_root = tmp_path / "local" - steam_root = tmp_path / "downloaded" - - local_mod = _create_mod(local_root / "LocalMod", ABOUT_XML_V1) - steam_mod = _create_mod(steam_root / "SteamMod", ABOUT_XML_TWO, pfid="123456") - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - local_prov = LocalModProvider(local_root, metadata_cache=cache) - steam_prov = DownloadedModProvider(steam_root, metadata_cache=cache) - - try: - # Initial cold discovery - local_mods = await local_prov.discover("1.5") - steam_mods = await steam_prov.discover("1.5") - - assert len(local_mods) == 1 - assert len(steam_mods) == 1 - assert local_mods[str(local_mod)].provider_id == "local" - assert steam_mods[str(steam_mod)].provider_id == "downloaded" - - # Second warm discovery - local_mods_warm = await local_prov.discover("1.5") - steam_mods_warm = await steam_prov.discover("1.5") - - assert len(local_mods_warm) == 1 - assert len(steam_mods_warm) == 1 - assert local_mods_warm[str(local_mod)].provider_id == "local" - assert steam_mods_warm[str(steam_mod)].provider_id == "downloaded" - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_target_version_change_reparses(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_dir = mods_root / "ModVersioned" - about_xml = """ - - Versioned Mod - Author - Author.Versioned - - Description for 1.4 - Description for 1.5 - - -""" - _create_mod(mod_dir, about_xml) - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - try: - d14 = await provider.discover("1.4") - assert d14[str(mod_dir)].description == "Description for 1.4" - - # Discovering for 1.5 misses cache because target_version differs - d15 = await provider.discover("1.5") - assert d15[str(mod_dir)].description == "Description for 1.5" - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_minimal_context_does_not_use_user_cache(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - _create_mod(mods_root / "ModA", ABOUT_XML_V1) - cfg = AppConfig() - ctx = CoreContext(cfg) - assert not ctx.has_config_service - provider = LocalModProvider(mods_root) - mod_service = ModService(ctx, [provider]) - assert mod_service._metadata_cache is None - - -def test_normalize_paths_matches_normalize_path(tmp_path: Path) -> None: - from pxmodrim.core.services.metadata_cache import normalize_path, normalize_paths - - real = tmp_path / "real" - (real / "mod").mkdir(parents=True) - (tmp_path / "other").mkdir() - (tmp_path / "linkdir").symlink_to(real) - (real / "linkmod").symlink_to(tmp_path / "other") - paths = [ - real / "mod", - tmp_path / "linkdir" / "mod", - tmp_path / "linkdir" / "linkmod", - tmp_path / "linkdir" / ".." / "real" / "mod", - tmp_path / "missing" / "x", - Path("relative/mod"), - ] - assert normalize_paths(paths) == [normalize_path(p) for p in paths] diff --git a/legacy/tests/test_metadata/test_mods_config.py b/legacy/tests/test_metadata/test_mods_config.py deleted file mode 100644 index 8a8e42e..0000000 --- a/legacy/tests/test_metadata/test_mods_config.py +++ /dev/null @@ -1,480 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core import mods_config as mods_config_module -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.mods_config import ( - create_snapshot, - get_snapshots, - parse_mods_config, - restore_snapshot, - write_mods_config, -) - -SAMPLE_XML = """ - - 1.6.4871 rev600 - -
  • ludeon.rimworld
  • -
  • ludeon.rimworld.royalty
  • -
  • ludeon.rimworld.ideology
  • -
  • ludeon.rimworld.biotech
  • -
  • ludeon.rimworld.anomaly
  • -
  • ludeon.rimworld.odyssey
  • -
    - -
  • ludeon.rimworld.royalty
  • -
  • ludeon.rimworld.ideology
  • -
  • ludeon.rimworld.biotech
  • -
  • ludeon.rimworld.anomaly
  • -
  • ludeon.rimworld.odyssey
  • -
    -
    -""" - - -def _parse_str(content: str, tmp_path: Path) -> ModsConfig | None: - path = tmp_path / "ModsConfig.xml" - path.write_text(content, encoding="utf-8") - return parse_mods_config(path) - - -class TestParseModsConfig: - def test_parse_full(self, tmp_path: Path) -> None: - config = _parse_str(SAMPLE_XML, tmp_path) - assert config is not None - assert config.version == "1.6.4871 rev600" - assert config.activeMods == [ - CaseInsensitiveStr("ludeon.rimworld"), - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - CaseInsensitiveStr("ludeon.rimworld.odyssey"), - ] - assert config.knownExpansions == [ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - CaseInsensitiveStr("ludeon.rimworld.odyssey"), - ] - - def test_parse_single_active_mod(self, tmp_path: Path) -> None: - xml = """ - - 1.5 - -
  • ludeon.rimworld
  • -
    - -
  • ludeon.rimworld.royalty
  • -
    -
    """ - config = _parse_str(xml, tmp_path) - assert config is not None - assert config.activeMods == [CaseInsensitiveStr("ludeon.rimworld")] - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_parse_empty_config(self, tmp_path: Path) -> None: - xml = """ - - 1.5 - -""" - config = _parse_str(xml, tmp_path) - assert config is not None - assert config.activeMods == [] - assert len(config.knownExpansions) > 0 - - def test_parse_missing_file(self) -> None: - result = parse_mods_config(Path("/nonexistent/ModsConfig.xml")) - assert result is None - - -class TestModsConfigToDict: - def test_to_dict_structure_with_li(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - d = config.to_dict() - assert d == { - "version": "1.5", - "activeMods": {"li": ["ludeon.rimworld"]}, - "knownExpansions": {"li": ["ludeon.rimworld.royalty"]}, - } - - def test_to_dict_empty_lists(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[], - ) - d = config.to_dict() - assert d["activeMods"] == {"li": []} - assert d["knownExpansions"] == {"li": []} - - -class TestModsConfigRoundTrip: - def test_round_trip(self, tmp_path: Path) -> None: - original = ModsConfig( - version="1.6.4871 rev600", - activeMods=[ - CaseInsensitiveStr("ludeon.rimworld"), - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - ], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - ], - ) - path = tmp_path / "ModsConfig.xml" - write_mods_config(path, original, tmp_path / "snapshots") - - loaded = parse_mods_config(path) - assert loaded is not None - assert loaded.version == original.version - assert loaded.activeMods == original.activeMods - assert loaded.knownExpansions == original.knownExpansions - - def test_round_trip_preserves_unknown_xml(self, tmp_path: Path) -> None: - path = tmp_path / "ModsConfig.xml" - path.write_text( - SAMPLE_XML.replace( - "", - '' - '42', - ), - encoding="utf-8", - ) - loaded = parse_mods_config(path) - assert loaded is not None - loaded.activeMods = [CaseInsensitiveStr("author.updated")] - - write_mods_config(path, loaded, tmp_path / "snapshots") - - root = mods_config_module.ET.parse(str(path)).getroot() - assert root.get("custom") == "preserve" - assert root.findtext("./otherSetting/value") == "42" - assert root.find("./otherSetting").get("enabled") == "true" - assert root.findtext("./activeMods/li") == "author.updated" - - -class TestModsConfigProperties: - def test_active_mods_getter_returns_copy(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[], - ) - mods = config.activeMods - mods.append(CaseInsensitiveStr("test.extra")) - assert config.activeMods == [CaseInsensitiveStr("ludeon.rimworld")] - - def test_known_expansions_getter_returns_copy(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - exps = config.knownExpansions - exps.clear() - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_setter_normalizes_case(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[], - ) - config.activeMods = ["Test.UPPERCASE.Mod"] - assert config.activeMods == [CaseInsensitiveStr("test.uppercase.mod")] - - def test_clear_active_mods(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - config.clear_active_mods() - assert config.activeMods == [] - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_clear_all(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - config.clear_all() - assert config.activeMods == [] - assert config.knownExpansions == [] - - def test_check_active_duplicates_true(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[ - CaseInsensitiveStr("mod.a"), - CaseInsensitiveStr("Mod.A"), - ], - knownExpansions=[], - ) - assert config.check_active_duplicates() is True - - def test_check_active_duplicates_false(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[ - CaseInsensitiveStr("mod.a"), - CaseInsensitiveStr("mod.b"), - ], - knownExpansions=[], - ) - assert config.check_active_duplicates() is False - - def test_check_expansions_duplicates_true(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("Ludeon.RimWorld.Royalty"), - ], - ) - assert config.check_expansions_duplicates() is True - - def test_check_expansions_duplicates_false(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - ], - ) - assert config.check_expansions_duplicates() is False - - -def _mods_config(package_id: str) -> ModsConfig: - return ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(package_id)], - knownExpansions=[], - ) - - -class TestModListSnapshotAndRollback: - def test_replacing_config_preserves_previous_contents(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - - write_mods_config(config_path, _mods_config("mod.alpha"), snapshots_dir) - assert get_snapshots(snapshots_dir) == [] - - write_mods_config(config_path, _mods_config("mod.beta"), snapshots_dir) - - [snapshot] = get_snapshots(snapshots_dir) - previous = parse_mods_config(snapshot) - current = parse_mods_config(config_path) - assert previous is not None - assert previous.activeMods == [CaseInsensitiveStr("mod.alpha")] - assert current is not None - assert current.activeMods == [CaseInsensitiveStr("mod.beta")] - - def test_replacement_retains_only_newest_ten_snapshots( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.0"), snapshots_dir) - - for index in range(1, 12): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - ) - - snapshots = get_snapshots(snapshots_dir) - retained_ids = [] - for snapshot in snapshots: - parsed = parse_mods_config(snapshot) - assert parsed is not None - retained_ids.append(parsed.activeMods[0]) - - assert len(snapshots) == 10 - assert retained_ids == [ - CaseInsensitiveStr(f"mod.{index}") for index in range(10, 0, -1) - ] - - def test_configurable_retention_limit_honored(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config( - config_path, _mods_config("mod.0"), snapshots_dir, max_snapshots=3 - ) - - for index in range(1, 6): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - max_snapshots=3, - ) - - snapshots = get_snapshots(snapshots_dir) - assert len(snapshots) == 3 - retained_ids = [ - parsed.activeMods[0] - for snapshot in snapshots - if (parsed := parse_mods_config(snapshot)) is not None - ] - assert retained_ids == [ - CaseInsensitiveStr("mod.4"), - CaseInsensitiveStr("mod.3"), - CaseInsensitiveStr("mod.2"), - ] - - # Lowering limit on duplicate content prunes excess snapshots before returning - config_path.write_bytes(snapshots[0].read_bytes()) - duplicate = create_snapshot(config_path, snapshots_dir, max_snapshots=2) - assert duplicate == snapshots[0] - assert len(get_snapshots(snapshots_dir)) == 2 - - def test_restoring_oldest_at_full_retention_preserves_snapshot_and_backup( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - max_snapshots = 3 - write_mods_config( - config_path, - _mods_config("mod.0"), - snapshots_dir, - max_snapshots=max_snapshots, - ) - for index in range(1, 4): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - max_snapshots=max_snapshots, - ) - - selected_snapshot = get_snapshots(snapshots_dir)[-1] - selected_data = selected_snapshot.read_bytes() - - assert ( - restore_snapshot( - selected_snapshot, - config_path, - snapshots_dir, - max_snapshots=max_snapshots, - ) - is True - ) - - restored = parse_mods_config(config_path) - assert restored is not None - assert restored.activeMods == [CaseInsensitiveStr("mod.0")] - assert selected_snapshot.exists() - assert selected_snapshot.read_bytes() == selected_data - - snapshots = get_snapshots(snapshots_dir) - retained_ids = [] - for snapshot in snapshots: - parsed = parse_mods_config(snapshot) - assert parsed is not None - retained_ids.append(parsed.activeMods[0]) - - assert len(snapshots) == max_snapshots - assert retained_ids == [ - CaseInsensitiveStr("mod.3"), - CaseInsensitiveStr("mod.2"), - CaseInsensitiveStr("mod.0"), - ] - - def test_restore_preserves_exact_snapshot_and_backs_up_current( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.current"), snapshots_dir) - - snapshots_dir.mkdir(parents=True) - snapshot_path = snapshots_dir / "ModsConfig_20260922_100000.xml" - snapshot_data = ( - b"\n" - b"1.5" - b"
  • mod.saved
  • \n" - ) - snapshot_path.write_bytes(snapshot_data) - - assert restore_snapshot(snapshot_path, config_path, snapshots_dir) is True - assert config_path.read_bytes() == snapshot_data - - backups = [ - path for path in get_snapshots(snapshots_dir) if path != snapshot_path - ] - assert len(backups) == 1 - backup = parse_mods_config(backups[0]) - assert backup is not None - assert backup.activeMods == [CaseInsensitiveStr("mod.current")] - - def test_invalid_snapshot_never_changes_current_file(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.safe"), snapshots_dir) - original = config_path.read_bytes() - - invalid_snapshots = [ - snapshots_dir / "ModsConfig_missing.xml", - snapshots_dir / "ModsConfig_corrupt.xml", - snapshots_dir / "ModsConfig_wrong_root.xml", - snapshots_dir / "ModsConfig_missing_active_mods.xml", - ] - snapshots_dir.mkdir(parents=True, exist_ok=True) - invalid_snapshots[1].write_text("not XML", encoding="utf-8") - invalid_snapshots[2].write_text("", encoding="utf-8") - invalid_snapshots[3].write_text( - "1.5", - encoding="utf-8", - ) - - for snapshot in invalid_snapshots: - before = set(get_snapshots(snapshots_dir)) - assert restore_snapshot(snapshot, config_path, snapshots_dir) is False - assert config_path.read_bytes() == original - assert set(get_snapshots(snapshots_dir)) == before - - def test_failed_atomic_replace_leaves_current_file_intact( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.safe"), snapshots_dir) - original = config_path.read_bytes() - real_replace = mods_config_module.os.replace - - def fail_target_replace(source: Path, target: Path) -> None: - if Path(target) == config_path: - raise OSError("replace failed") - real_replace(source, target) - - monkeypatch.setattr(mods_config_module.os, "replace", fail_target_replace) - - with pytest.raises(OSError, match="replace failed"): - write_mods_config(config_path, _mods_config("mod.new"), snapshots_dir) - - assert config_path.read_bytes() == original - assert not list(tmp_path.glob(".*.tmp")) diff --git a/legacy/tests/test_metadata/test_parsing.py b/legacy/tests/test_metadata/test_parsing.py deleted file mode 100644 index f31c13b..0000000 --- a/legacy/tests/test_metadata/test_parsing.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import lxml.etree as ET -import pytest - -from pxmodrim.core.models.metadata.parsing import ( - _match_byversion_raw, - _match_versioned_child, - create_about_mod, - create_listed_mod_from_path, - match_version, - value_extractor, -) -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.utils import find_about_xml - -TEST_DATA = Path(__file__).parent / "data" - - -class TestValueExtractor: - def test_nested_dict(self) -> None: - assert value_extractor({"key": "value"}) == "value" - - def test_ignore_if_no_matching_field(self) -> None: - result = value_extractor( - {"@IgnoreIfNoMatchingField": "True", "#text": "actual"} - ) - assert result == "actual" - - def test_none(self) -> None: - assert value_extractor(None) is None - - -class TestCreateAboutMod: - def test_basic_mod(self) -> None: - data = { - "name": "Test Mod", - "author": "Tester", - "packageId": "test.mod", - "description": "A test mod.", - "supportedVersions": {"li": "1.5"}, - } - valid, mod = create_about_mod(data, "1.5") - assert valid is True - assert mod.name == "Test Mod" - assert mod.authors == ["Tester"] - assert str(mod.package_id) == "test.mod" - assert mod.supported_versions == {"1.5"} - - def test_missing_package_id_is_invalid(self) -> None: - valid, mod = create_about_mod({"name": "No ID Mod"}, "1.5") - assert valid is False - assert mod.valid is False - assert str(mod.package_id) == "missing.packageid" - - def test_empty_package_id_is_invalid(self) -> None: - valid, mod = create_about_mod({"packageId": ""}, "1.5") - assert valid is False - assert mod.valid is False - - def test_version_keys_must_match_exactly(self) -> None: - assert match_version({"v1.50": "wrong", "v1x5-beta": "wrong"}, "1.5") == ( - False, - None, - ) - assert _match_byversion_raw( - {"v1.50": "wrong", "v1x5-beta": "wrong"}, "1.5" - ) == (False, None) - parent = ET.Element("versions") - parent.extend([ET.Element("v1.50"), ET.Element("v1x5-beta")]) - assert _match_versioned_child(parent, "1.5") is None - - def test_unprefixed_exact_version_key_is_supported(self) -> None: - assert match_version({"1.5": "right"}, "1.5") == (True, "right") - - def test_missing_package_id_in_xml_is_invalid(self, tmp_path: Path) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - about_path = about_dir / "About.xml" - about_path.write_text( - "No ID Mod", encoding="utf-8" - ) - valid, mod = create_listed_mod_from_path( - tmp_path, "1.5", about_xml_path=about_path - ) - assert valid is False - assert isinstance(mod, AboutXmlMod) - assert mod.valid is False - assert str(mod.package_id) == "missing.packageid" - - def test_dlc_mod(self) -> None: - data = {"packageId": "ludeon.rimworld.royalty"} - valid, mod = create_about_mod(data, "1.5") - assert valid is True - assert mod.name == "Royalty" - assert mod.steam_app_id == 1149640 - - @pytest.mark.parametrize( - ("package_id", "expected_name"), - [ - ("Ludeon.RimWorld", "Core"), - ("Ludeon.RimWorld.Royalty", "Royalty"), - ("Ludeon.RimWorld.Ideology", "Ideology"), - ("Ludeon.RimWorld.Biotech", "Biotech"), - ("Ludeon.RimWorld.Anomaly", "Anomaly"), - ("Ludeon.RimWorld.Odyssey", "Odyssey"), - ], - ) - def test_official_mod_without_name_uses_package_id_mapping( - self, tmp_path: Path, package_id: str, expected_name: str - ) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - (about_dir / "About.xml").write_text( - f"{package_id}" - "Ludeon Studios", - encoding="utf-8", - ) - - valid, mod = create_listed_mod_from_path(tmp_path, "1.6") - - assert valid - assert mod.name == expected_name - - def test_explicit_name_wins_for_official_mod(self) -> None: - valid, mod = create_about_mod( - {"packageId": "ludeon.rimworld.royalty", "name": "Custom official name"}, - "1.6", - ) - - assert valid - assert mod.name == "Custom official name" - - -class TestFindAboutXml: - def test_valid_mod(self) -> None: - about_path = find_about_xml(TEST_DATA / "valid_mod") - assert about_path is not None - assert about_path.name == "About.xml" - - def test_nonexistent(self) -> None: - result = find_about_xml(TEST_DATA / "nonexistent") - assert result is None - - -class TestCreateListedModFromPath: - def test_valid_mod(self) -> None: - mod_path = TEST_DATA / "valid_mod" - valid, mod = create_listed_mod_from_path(mod_path, "1.5") - assert valid is True - assert isinstance(mod, AboutXmlMod) - assert mod.name == "Test Mod" - assert mod.authors == ["TestAuthor"] - assert str(mod.package_id) == "test.mod" - assert mod.mod_path == mod_path - assert mod.provider_id == "" - - def test_invalid_path(self) -> None: - with pytest.raises(ValueError, match="Path must be a directory"): - create_listed_mod_from_path( - TEST_DATA / "valid_mod" / "About" / "About.xml", - "1.5", - ) - - def test_malformed_about_xml(self, tmp_path: Path) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - (about_dir / "About.xml").write_text("", encoding="utf-8") - - valid, mod = create_listed_mod_from_path(tmp_path, "1.5") - - assert valid is False - assert isinstance(mod, AboutXmlMod) - assert mod.valid is False - assert mod.mod_path == tmp_path - assert mod.uuid == str(tmp_path) diff --git a/legacy/tests/test_metadata/test_sidebar.py b/legacy/tests/test_metadata/test_sidebar.py deleted file mode 100644 index baca75a..0000000 --- a/legacy/tests/test_metadata/test_sidebar.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.models.view.sidebar import AllModsEntry, ProviderModsEntry - - -def test_sidebar_entries_own_their_visible_uuids() -> None: - first = AllModsEntry() - second = AllModsEntry() - - first.visible_uuids.add("first") - - assert second.visible_uuids == set() - provider = ProviderModsEntry("local", "Local", {"provider"}) - assert provider.visible_uuids == {"provider"} - assert provider.count == 1 diff --git a/legacy/tests/test_metadata/test_startup_impact.py b/legacy/tests/test_metadata/test_startup_impact.py deleted file mode 100644 index e7a4144..0000000 --- a/legacy/tests/test_metadata/test_startup_impact.py +++ /dev/null @@ -1,247 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.services.startup_impact_service import ( - StartupImpactReport, - format_impact, - get_startup_impact_path, - normalize_package_id, - parse_startup_impact, -) - - -def _write_json(path: Path, content: str) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return path - - -_SAMPLE_JSON = """{ - "timestamp": "2026-07-16T14:30:45.123Z", - "loadingTime": 42500.5, - "metrics": { - "LoadModXml": 10200.0, - "ResolveReferences": 1500.0 - }, - "totalImpact": 0.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0, - "mods": [ - { - "modName": "Harmony", - "modPackageId": "brrainz.harmony", - "metrics": { - "ModConstructor": 1000.0 - }, - "totalImpact": 1230.5, - "offThreadMetrics": { - "LoadPatches": 300.0 - }, - "offThreadTotalImpact": 300.0 - }, - { - "modName": "Some Mod", - "modPackageId": "author.somemod", - "metrics": { - "LoadModXml": 4500.0 - }, - "totalImpact": 4500.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - }, - { - "modName": "Fast Mod", - "modPackageId": "author.fast", - "metrics": {}, - "totalImpact": 50.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - }, - { - "modName": "No Package ID Mod", - "totalImpact": 200.0, - "metrics": {}, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - } - ] -} -""" - - -class TestNormalizePackageId: - def test_lowercase(self) -> None: - assert normalize_package_id("Author.Mod") == "author.mod" - - def test_steam_suffix(self) -> None: - assert normalize_package_id("author.mod_steam") == "author.mod" - - def test_already_normalized(self) -> None: - assert normalize_package_id("author.mod") == "author.mod" - - -class TestFormatImpact: - def test_seconds_to_ms(self) -> None: - assert format_impact(1.234) == "1234ms" - - def test_fractional(self) -> None: - assert format_impact(0.05) == "50ms" - - def test_zero(self) -> None: - assert format_impact(0.0) == "0ms" - - -class TestGetStartupImpactPath: - def test_derives_from_config_parent(self) -> None: - result = get_startup_impact_path(Path("/home/user/Config")) - assert result == Path("/home/user/StartupImpactData.json") - - def test_accepts_str(self) -> None: - result = get_startup_impact_path("/home/user/Config") - assert result == Path("/home/user/StartupImpactData.json") - - -class TestParseStartupImpact: - def test_parse_full_report(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - assert isinstance(report, StartupImpactReport) - assert report.loading_time_s == 42.5005 - assert report.timestamp == "2026-07-16T14:30:45.123Z" - assert report.metrics == {"LoadModXml": 10.2, "ResolveReferences": 1.5} - assert report.total_impact == 0.0 - assert len(report.mods) == 4 - - def test_base_game_time_from_metrics(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - # Base game time is the sum of the base-game profiler metrics, not a - # subtraction of loading time minus mod impacts. - assert report.base_game_time == pytest.approx(11.7) - # loading_time is the true wall-clock total and is NOT equal to the - # sum of mod on-thread impacts (there is a real unaccounted "Others"). - assert report.loading_time_s != pytest.approx(report.total_time) - - def test_mods_total_includes_off_thread(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - harmony = report.mods[0] - assert harmony.total_impact_s == 1.2305 - assert harmony.off_thread_total_impact_s == 0.3 - # total_time counts only on-thread mod impact; mods_total_time adds - # off-thread impact. - assert report.total_time == pytest.approx(1.2305 + 4.5 + 0.05 + 0.2) - assert report.off_thread_total_time == pytest.approx(0.3) - assert report.mods_total_time == pytest.approx(report.total_time + 0.3) - - def test_mod_values(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - - harmony = report.mods[0] - assert harmony.mod_name == "Harmony" - assert harmony.package_id == "brrainz.harmony" - assert harmony.total_impact_s == 1.2305 - assert harmony.metrics == {"ModConstructor": 1.0} - - some_mod = report.mods[1] - assert some_mod.mod_name == "Some Mod" - assert some_mod.package_id == "author.somemod" - assert some_mod.total_impact_s == 4.5 - assert some_mod.metrics == {"LoadModXml": 4.5} - - fast = report.mods[2] - assert fast.mod_name == "Fast Mod" - assert fast.package_id == "author.fast" - assert fast.total_impact_s == 0.05 - assert fast.metrics == {} - - def test_missing_package_id(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - - no_pid = report.mods[3] - assert no_pid.mod_name == "No Package ID Mod" - assert no_pid.package_id is None - assert no_pid.total_impact_s == 0.2 - assert no_pid.metrics == {} - - def test_file_not_found(self, tmp_path: Path) -> None: - report = parse_startup_impact(tmp_path / "nonexistent.json") - assert report is None - - def test_empty_mods(self, tmp_path: Path) -> None: - data = """{"loadingTime": 1000.0, "mods": []}""" - path = _write_json(tmp_path / "empty_mods.json", data) - report = parse_startup_impact(path) - assert report is not None - assert report.loading_time_s == 1.0 - assert len(report.mods) == 0 - - def test_not_a_dict(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "not_a_dict.json", "[]") - report = parse_startup_impact(path) - assert report is None - - def test_malformed_json(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "bad.json", "{invalid") - report = parse_startup_impact(path) - assert report is None - - def test_invalid_json_structure(self, tmp_path: Path) -> None: - data = """{"wrongKey": "value"}""" - path = _write_json(tmp_path / "invalid.json", data) - report = parse_startup_impact(path) - assert report is not None - assert len(report.mods) == 0 - - def test_total_time(self, tmp_path: Path) -> None: - data = """{ - "loadingTime": 50000.0, - "mods": [ - {"modName": "A", "modPackageId": "a.a", - "totalImpact": 2000.0, "metrics": {}, - "offThreadMetrics": {}, "offThreadTotalImpact": 0.0}, - {"modName": "B", "modPackageId": "b.b", - "totalImpact": 3000.0, "metrics": {}, - "offThreadMetrics": {}, "offThreadTotalImpact": 0.0} - ] - }""" - r = parse_startup_impact(_write_json(tmp_path / "total.json", data)) - assert r is not None - assert r.total_time == pytest.approx(5.0) - - def test_total_time_empty(self, tmp_path: Path) -> None: - data = """{"loadingTime": 1000.0, "mods": []}""" - r = parse_startup_impact(_write_json(tmp_path / "total_empty.json", data)) - assert r is not None - assert r.total_time == 0.0 - - def test_mod_off_thread_fields(self, tmp_path: Path) -> None: - data = """{ - "loadingTime": 10000.0, - "mods": [ - { - "modName": "Test Mod", - "modPackageId": "author.test", - "totalImpact": 2000.0, - "metrics": {"A": 1000.0, "B": 1000.0}, - "offThreadMetrics": {"C": 500.0}, - "offThreadTotalImpact": 500.0 - } - ] - }""" - path = _write_json(tmp_path / "offthread.json", data) - report = parse_startup_impact(path) - assert report is not None - mod = report.mods[0] - assert mod.off_thread_metrics == {"C": 0.5} - assert mod.off_thread_total_impact_s == 0.5 diff --git a/legacy/tests/test_metadata/test_startup_impact_db.py b/legacy/tests/test_metadata/test_startup_impact_db.py deleted file mode 100644 index bcfe1b4..0000000 --- a/legacy/tests/test_metadata/test_startup_impact_db.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest - -from pxmodrim.core.services.startup_impact_service.db import StartupImpactDb -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) - - -@pytest.mark.asyncio -async def test_store_report_and_clear_are_serialized(tmp_path: Path) -> None: - db = StartupImpactDb() - path = tmp_path / "cache.db" - report = StartupImpactReport( - path="", - loading_time_s=1.0, - timestamp="now", - mods=(StartupImpactMod("Example", "author.example", 0.5),), - ) - try: - await asyncio.gather(db.store_report(path, report), db.clear(path)) - latest = await db.get_latest_report(path) - assert latest is None or latest.mods[0].package_id == "author.example" - await db.store_report(path, report) - latest = await db.get_latest_report(path) - assert latest is not None - assert latest.mods[0].package_id == "author.example" - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_base_game_keeps_sentinel_package_id_after_round_trip( - tmp_path: Path, -) -> None: - db = StartupImpactDb() - path = tmp_path / "cache.db" - report = StartupImpactReport( - path="", - loading_time_s=1.0, - timestamp="now", - mods=( - StartupImpactMod("Example", "author.example", 0.5), - StartupImpactMod(BASE_GAME_PID, BASE_GAME_PID, 2.0), - ), - ) - try: - await db.store_report(path, report) - latest = await db.get_latest_report(path) - finally: - await db.close() - assert latest is not None - by_name = {m.mod_name: m.package_id for m in latest.mods} - assert by_name[BASE_GAME_PID] == BASE_GAME_PID diff --git a/legacy/tests/test_metadata/test_startup_impact_phases.py b/legacy/tests/test_metadata/test_startup_impact_phases.py deleted file mode 100644 index 065b310..0000000 --- a/legacy/tests/test_metadata/test_startup_impact_phases.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.phases import ( - Phase, - metric_phase, - phase_totals, -) - -_P = "LoadingProgress.StartupImpact." - - -@pytest.mark.parametrize( - ("key", "phase"), - [ - (f"{_P}ModContentPackReloadContentInt.Textures", Phase.CONTENT), - (f"{_P}GlobalTextureAtlasManagerBakeStaticAtlases", Phase.CONTENT), - (f"{_P}ModConstructor", Phase.MOD_CONSTRUCTORS), - (f"{_P}StaticConstructorOnStartupUtilityCallAll", Phase.STATIC_CONSTRUCTORS), - (f"{_P}ApplyPatches", Phase.PATCHES), - (f"{_P}ErrorCheckPatches", Phase.PATCHES), - (f"{_P}LoadDefs", Phase.DEFS), - (f"{_P}ResolveAllWantedCrossReferences.NonImplied", Phase.DEFS), - ( - f"{_P}ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", - Phase.DEFERRED, - ), - (f"{_P}LanguageDatabaseInitAllMetadata", Phase.OTHER), - ("SomethingNew", Phase.OTHER), - ], -) -def test_metric_phase(key: str, phase: Phase) -> None: - assert metric_phase(key) is phase - - -def test_phase_totals_sums_per_phase_and_zero_fills() -> None: - totals = phase_totals( - {f"{_P}ApplyPatches": 1.5, f"{_P}LoadPatches": 0.5, f"{_P}LoadDefs": 2.0} - ) - assert totals[Phase.PATCHES] == pytest.approx(2.0) - assert totals[Phase.DEFS] == pytest.approx(2.0) - assert totals[Phase.CONTENT] == 0.0 - assert set(totals) == set(Phase) - - -def test_metric_label_names_deferred_task_target() -> None: - key = ( - f"{_P}ExecuteToExecuteWhenFinished|RimWorld.AbilityDef" - " -> Void b__88_0()" - ) - expected = "Running delayed initialization task (RimWorld.AbilityDef)" - assert metric_label(key) == expected diff --git a/legacy/tests/test_metadata/test_structures.py b/legacy/tests/test_metadata/test_structures.py deleted file mode 100644 index 47e0826..0000000 --- a/legacy/tests/test_metadata/test_structures.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - ListedMod, -) - - -class TestCaseInsensitiveStr: - def test_lowercase_storage(self) -> None: - pid = CaseInsensitiveStr("Test.Mod.PackageId") - assert str(pid) == "test.mod.packageid" - - def test_equality(self) -> None: - a = CaseInsensitiveStr("Test.Mod") - b = CaseInsensitiveStr("test.mod") - assert a == b - - def test_dict_key(self) -> None: - d: dict[CaseInsensitiveStr, str] = {} - d[CaseInsensitiveStr("Test.Mod")] = "value" - assert d[CaseInsensitiveStr("test.mod")] == "value" - - -class TestCaseInsensitiveSet: - def test_add_and_contains(self) -> None: - s = CaseInsensitiveSet() - s.add("Test.Mod") - assert "test.mod" in s - assert "Test.Mod" in s - - def test_discard(self) -> None: - s = CaseInsensitiveSet(["Test.Mod"]) - s.discard("test.mod") - assert len(s) == 0 - - def test_union(self) -> None: - a = CaseInsensitiveSet(["mod.a"]) - b = CaseInsensitiveSet(["Mod.B"]) - c = a | b - assert "mod.a" in c - assert "mod.b" in c - - def test_empty_eq(self) -> None: - assert CaseInsensitiveSet() == CaseInsensitiveSet() - - def test_unhashable(self) -> None: - with pytest.raises(TypeError): - hash(CaseInsensitiveSet()) - - -class TestListedMod: - def test_default_creation(self) -> None: - mod = ListedMod() - assert mod.name == "Unknown Mod Name" - assert mod.valid is True - assert mod.provider_id == "" - assert mod.mod_path is None - - def test_provider_id_assignable(self) -> None: - mod = ListedMod() - mod.provider_id = "local" - assert mod.provider_id == "local" - - def test_pathless_mod_uuids_are_unique(self) -> None: - assert ListedMod().uuid != ListedMod().uuid - - -class TestAboutXmlMod: - def test_default_creation(self) -> None: - mod = AboutXmlMod() - assert mod.name == "Unknown Mod Name" - assert mod.authors == [] - assert mod.mod_version == "" - assert isinstance(mod.package_id, CaseInsensitiveStr) - - def test_dlc_name(self) -> None: - mod = AboutXmlMod() - mod.package_id = CaseInsensitiveStr("ludeon.rimworld.royalty") - name = mod.get_dlc_name() - assert name == "RimWorld - Royalty" diff --git a/legacy/tests/test_organizer/__init__.py b/legacy/tests/test_organizer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/tests/test_organizer/test_db.py b/legacy/tests/test_organizer/test_db.py deleted file mode 100644 index 5a1fe1f..0000000 --- a/legacy/tests/test_organizer/test_db.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import cast - -import pytest - -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import StandardRule -from pxmodrim.core.organizer.models import ( - ROOT_ID, - OrganizerError, - RuleField, - RuleSpec, -) - - -@pytest.fixture -def db(tmp_path: Path) -> OrganizerDb: - return OrganizerDb(tmp_path / "organizer.db") - - -@pytest.mark.asyncio -async def test_fresh_db_and_root_invariants(db: OrganizerDb) -> None: - try: - state = await db.load() - assert len(state.folders) == 1 - root = state.folders[ROOT_ID] - assert root.id == ROOT_ID - assert root.parent_id is None - assert root.name == "Root" - assert root.collapsed is False - assert state.placements == {} - assert state.tags == {} - assert state.mod_tags == {} - assert state.rules == () - - with pytest.raises(OrganizerError): - await db.delete_folder(ROOT_ID) - - with pytest.raises(OrganizerError): - await db.rename_folder(ROOT_ID, "CustomRoot") - - with pytest.raises(OrganizerError): - await db.set_parent(ROOT_ID, 2) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_duplicate_sibling_names_and_different_parents(db: OrganizerDb) -> None: - try: - f_a = await db.create_folder(ROOT_ID, "FolderA") - assert f_a.id > ROOT_ID - assert f_a.parent_id == ROOT_ID - assert f_a.name == "FolderA" - - # Case-insensitive duplicate sibling under root - with pytest.raises(OrganizerError): - await db.create_folder(ROOT_ID, "foldera") - - # Create another sibling - f_b = await db.create_folder(ROOT_ID, "FolderB") - - # Renaming sibling to duplicate existing sibling raises OrganizerError - with pytest.raises(OrganizerError): - await db.rename_folder(f_b.id, "foldera") - - # Same name under different parents is allowed - f_sub = await db.create_folder(f_a.id, "FolderB") - assert f_sub.parent_id == f_a.id - assert f_sub.name == "FolderB" - - # Moving f_sub to root should fail because root already has FolderB - with pytest.raises(OrganizerError): - await db.set_parent(f_sub.id, ROOT_ID) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_placement_upsert_and_unplace(db: OrganizerDb) -> None: - try: - f1 = await db.create_folder(ROOT_ID, "Folder1") - f2 = await db.create_folder(ROOT_ID, "Folder2") - - # Lowercases package IDs on place - await db.place(["Package.One", "PACKAGE.TWO"], f1.id) - st = await db.load() - assert st.placements["package.one"] == f1.id - assert st.placements["package.two"] == f1.id - - # Upsert: move Package.One to Folder2 - await db.place(["package.ONE"], f2.id) - st = await db.load() - assert st.placements["package.one"] == f2.id - assert st.placements["package.two"] == f1.id - - # Unplace removes rows - await db.unplace(["PACKAGE.ONE"]) - st = await db.load() - assert "package.one" not in st.placements - assert st.placements["package.two"] == f1.id - - # Placing into non-existent folder raises OrganizerError - with pytest.raises(OrganizerError): - await db.place(["package.three"], 9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_create_folder_with(db: OrganizerDb) -> None: - try: - f = await db.create_folder_with( - ROOT_ID, "CoreMods", ["Package.One", "package.two", "PACKAGE.THREE"] - ) - assert f.id > ROOT_ID - assert f.name == "CoreMods" - assert f.parent_id == ROOT_ID - - st = await db.load() - assert f.id in st.folders - assert st.placements["package.one"] == f.id - assert st.placements["package.two"] == f.id - assert st.placements["package.three"] == f.id - - with pytest.raises(OrganizerError): - await db.create_folder_with( - ROOT_ID, "coremods", ["package.four", "package.five"] - ) - - st2 = await db.load() - assert "package.four" not in st2.placements - assert "package.five" not in st2.placements - assert len(st2.folders) == len(st.folders) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_delete_folder_subtree_placements_and_descendants( - db: OrganizerDb, -) -> None: - try: - # Build hierarchy: Root -> f_parent -> f_child -> f_grandchild - f_parent = await db.create_folder(ROOT_ID, "Parent") - f_child = await db.create_folder(f_parent.id, "Child") - f_grandchild = await db.create_folder(f_child.id, "Grandchild") - f_other = await db.create_folder(ROOT_ID, "Other") - - await db.place(["mod.parent"], f_parent.id) - await db.place(["mod.child"], f_child.id) - await db.place(["mod.grandchild"], f_grandchild.id) - await db.place(["mod.other"], f_other.id) - await db.place(["mod.root"], ROOT_ID) - - # Deleting parent moves subtree placements to ROOT_ID and drops descendants - await db.delete_folder(f_parent.id) - - st = await db.load() - assert f_parent.id not in st.folders - assert f_child.id not in st.folders - assert f_grandchild.id not in st.folders - assert f_other.id in st.folders - - assert st.placements["mod.parent"] == ROOT_ID - assert st.placements["mod.child"] == ROOT_ID - assert st.placements["mod.grandchild"] == ROOT_ID - assert st.placements["mod.other"] == f_other.id - assert st.placements["mod.root"] == ROOT_ID - - # Deleting non-existent folder raises OrganizerError - with pytest.raises(OrganizerError): - await db.delete_folder(9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_tags_lifecycle_and_mod_tags(db: OrganizerDb) -> None: - try: - t1 = await db.create_tag("Tag1", "#ff0000") - t2 = await db.create_tag("Tag2", "#00ff00") - assert t1.name == "Tag1" - assert t1.color == "#ff0000" - - # Duplicate tag name raises OrganizerError - with pytest.raises(OrganizerError): - await db.create_tag("tag1", "#123456") - - # Set mod tags - await db.set_mod_tags(["Mod.Alpha", "Mod.Beta"], add=[t1.id, t2.id]) - st = await db.load() - assert st.mod_tags["mod.alpha"] == frozenset({t1.id, t2.id}) - assert st.mod_tags["mod.beta"] == frozenset({t1.id, t2.id}) - - # Remove a tag from Mod.Alpha - await db.set_mod_tags(["mod.alpha"], remove=[t1.id]) - st = await db.load() - assert st.mod_tags["mod.alpha"] == frozenset({t2.id}) - - # Invalid tag ID in add raises OrganizerError - with pytest.raises(OrganizerError): - await db.set_mod_tags(["mod.alpha"], add=[9999]) - - # Update tag - await db.update_tag(t1.id, "Tag1Updated", "#aaaaaa") - st = await db.load() - assert st.tags[t1.id].name == "Tag1Updated" - - # Update tag to duplicate raises OrganizerError - with pytest.raises(OrganizerError): - await db.update_tag(t1.id, "tag2", "#aaaaaa") - - # Deleting tag removes it and cascades removal in mod_tags - await db.delete_tag(t2.id) - st = await db.load() - assert t2.id not in st.tags - assert "mod.alpha" not in st.mod_tags or st.mod_tags["mod.alpha"] == frozenset() - assert st.mod_tags["mod.beta"] == frozenset({t1.id}) - - # Deleting non-existent tag raises OrganizerError - with pytest.raises(OrganizerError): - await db.delete_tag(9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_replace_rules_and_cascade_on_delete_folder(db: OrganizerDb) -> None: - try: - f1 = await db.create_folder(ROOT_ID, "Target1") - f2 = await db.create_folder(ROOT_ID, "Target2") - - spec1 = RuleSpec(field="name", op="prefix", pattern="Vanilla", folder_id=f1.id) - spec2 = RuleSpec( - field="author", op="contains", pattern="Oskar", folder_id=f2.id - ) - spec3 = RuleSpec( - field="package_id", - op="equals", - pattern="ludeon.rimworld", - folder_id=f1.id, - ) - - rules = await db.replace_rules([spec1, spec2, spec3]) - assert len(rules) == 3 - assert [r.position for r in rules] == [0, 1, 2] - assert rules[0].field == "name" - assert rules[1].field == "author" - assert rules[2].field == "package_id" - - st = await db.load() - assert st.rules == rules - - # Invalid field or op raises OrganizerError - with pytest.raises(OrganizerError): - bad_spec = RuleSpec( - field=cast(RuleField, "invalid"), - op="prefix", - pattern="x", - folder_id=f1.id, - ) - await db.replace_rules([bad_spec]) - with pytest.raises(OrganizerError): - bad_spec = RuleSpec(field="name", op="prefix", pattern="x", folder_id=9999) - await db.replace_rules([bad_spec]) - - # Deleting Target1 drops rule 0 and 2; rule 1 targeting Target2 remains - await db.delete_folder(f1.id) - st = await db.load() - assert len(st.rules) == 1 - assert st.rules[0].id == rules[1].id - assert st.rules[0].folder_id == f2.id - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_state_survives_close_and_reopen(tmp_path: Path) -> None: - db_file = tmp_path / "organizer.db" - db1 = OrganizerDb(db_file) - try: - f = await db1.create_folder(ROOT_ID, "PersistFolder") - await db1.set_collapsed(f.id, True) - await db1.place(["persist.pkg"], f.id) - tag = await db1.create_tag("PersistTag", "#abcdef") - await db1.set_mod_tags(["persist.pkg"], add=[tag.id]) - await db1.replace_rules( - [RuleSpec(field="name", op="contains", pattern="mod", folder_id=f.id)] - ) - finally: - await db1.close() - - db2 = OrganizerDb(db_file) - try: - st = await db2.load() - assert f.id in st.folders - assert st.folders[f.id].name == "PersistFolder" - assert st.folders[f.id].collapsed is True - assert st.placements["persist.pkg"] == f.id - assert tag.id in st.tags - assert st.tags[tag.id].name == "PersistTag" - assert st.mod_tags["persist.pkg"] == frozenset({tag.id}) - assert len(st.rules) == 1 - assert st.rules[0].pattern == "mod" - assert st.rules[0].folder_id == f.id - finally: - db2.close_sync() - - -@pytest.mark.asyncio -async def test_append_named_rules_is_atomic(db: OrganizerDb) -> None: - try: - existing = await db.create_folder(ROOT_ID, "Existing") - await db.replace_rules( - [RuleSpec(field="name", op="contains", pattern="a", folder_id=existing.id)] - ) - before = await db.load() - - with pytest.raises(OrganizerError, match="Missing"): - await db.append_named_rules( - ROOT_ID, - ["New"], - [ - StandardRule("New", "name", "contains", "b"), - StandardRule("Missing", "name", "contains", "c"), - ], - ) - assert await db.load() == before - - await db.append_named_rules( - ROOT_ID, - ["New"], - [ - StandardRule("existing", "name", "contains", "b"), - StandardRule("NEW", "name", "contains", "c"), - ], - ) - state = await db.load() - new_id = next(f.id for f in state.folders.values() if f.name == "New") - assert [(r.position, r.pattern, r.folder_id) for r in state.rules] == [ - (0, "a", existing.id), - (1, "b", existing.id), - (2, "c", new_id), - ] - finally: - await db.close() diff --git a/legacy/tests/test_organizer/test_resolve.py b/legacy/tests/test_organizer/test_resolve.py deleted file mode 100644 index 011c496..0000000 --- a/legacy/tests/test_organizer/test_resolve.py +++ /dev/null @@ -1,364 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerState, - Rule, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - FolderNode, - TreeFilter, - TreeQuery, - build_tree, - folder_for, - preview_rule_matches, - tree_filters, -) - -ROOT = Folder(id=ROOT_ID, parent_id=None, name="") - - -def _state( - folders: list[Folder] | None = None, - placements: dict[str, int] | None = None, - rules: list[tuple[RuleField, RuleOp, str, int]] | None = None, - mod_tags: dict[str, frozenset[int]] | None = None, - tags: list[Tag] | dict[int, Tag] | None = None, -) -> OrganizerState: - all_folders = {ROOT_ID: ROOT} - for f in folders or []: - all_folders[f.id] = f - tag_dict: dict[int, Tag] = {} - if isinstance(tags, dict): - tag_dict = tags - elif tags: - tag_dict = {t.id: t for t in tags} - return OrganizerState( - folders=all_folders, - placements=placements or {}, - tags=tag_dict, - mod_tags=mod_tags or {}, - rules=tuple( - Rule(id=i + 1, position=i, field=fld, op=op, pattern=pat, folder_id=fid) - for i, (fld, op, pat, fid) in enumerate(rules or []) - ), - ) - - -def _mod( - uuid: str, - name: str, - pid: str | None = None, - author: str = "", - provider: str = "local", -) -> ListedMod: - if pid is None: - return ListedMod(name=name, _uuid=uuid, provider_id=provider) - return AboutXmlMod( - name=name, - _uuid=uuid, - provider_id=provider, - package_id=CaseInsensitiveStr(pid), - authors=[author] if author else [], - ) - - -def _find(root: FolderNode, folder_id: int) -> FolderNode: - return next(n for n in root.walk() if n.folder.id == folder_id) - - -def test_rule_preview_first_match_and_current_ungrouped() -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - placements={"manual.mod": 3, "root.mod": ROOT_ID}, - rules=[("package_id", "prefix", "old.", 2)], - ) - mods = { - "a": _mod("a", "Core Alpha", "new.alpha", "Author"), - "b": _mod("b", "Core Beta", "old.beta", "Other"), - "c": _mod("c", "Core Manual", "manual.mod", "Author"), - "d": _mod("d", "Core Ungrouped", "root.mod", "Author"), - "e": _mod("e", "Core No ID"), - } - draft = [ - RuleSpec("author", "equals", "author", 3), - RuleSpec("name", "contains", "core", 2), - ] - assert preview_rule_matches(state, mods, draft) == ([3, 1], 1) - assert preview_rule_matches(state, mods, draft[::-1]) == ([4, 0], 1) - assert preview_rule_matches( - state, mods, [RuleSpec("name", "contains", " ", 2), *draft] - ) == ([0, 3, 1], 1) - - -class TestFolderFor: - def test_manual_beats_rule(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - placements={"x.mod": 3}, - rules=[("package_id", "prefix", "x.", 2)], - ) - assert folder_for(state, "X.Mod", "Mod", "") == (3, "manual") - - def test_root_placement_blocks_rules(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - placements={"x.mod": ROOT_ID}, - rules=[("package_id", "prefix", "x.", 2)], - ) - assert folder_for(state, "x.mod", "Mod", "") == (ROOT_ID, "manual") - - def test_first_rule_by_position_wins(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - rules=[ - ("author", "equals", "OSKAR", 3), - ("name", "contains", "core", 2), - ], - ) - assert folder_for(state, "o.core", "Core Stuff", "Oskar") == (3, "rule") - assert folder_for(state, "p.core", "Core Stuff", "Someone") == (2, "rule") - - def test_ops_are_case_insensitive(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("name", "equals", "hello world", 2)], - ) - assert folder_for(state, "a.b", "Hello World", "") == (2, "rule") - assert folder_for(state, "a.b", "Hello World!", "") == (ROOT_ID, "none") - - def test_no_package_id_is_ungrouped(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("name", "contains", "", 2)], - ) - assert folder_for(state, None, "Anything", "") == (ROOT_ID, "none") - - def test_no_match_is_ungrouped(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("package_id", "prefix", "zzz", 2)], - ) - assert folder_for(state, "a.b", "Mod", "") == (ROOT_ID, "none") - - -class TestBuildTree: - def _nested(self) -> tuple[OrganizerState, dict[str, ListedMod]]: - state = _state( - folders=[ - Folder(2, ROOT_ID, "Zeta"), - Folder(3, 2, "inner"), - Folder(4, ROOT_ID, "alpha"), - ], - placements={"a.one": 2, "a.two": 3, "a.three": 3, "b.one": 4}, - ) - mods = { - "u1": _mod("u1", "One", "a.one"), - "u2": _mod("u2", "two", "a.two"), - "u3": _mod("u3", "Three", "a.three"), - "u4": _mod("u4", "B One", "b.one"), - "u5": _mod("u5", "loose"), - } - return state, mods - - def test_tri_state_across_nested_folders(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, {"u1", "u2", "u3"}) - zeta, inner, alpha = _find(root, 2), _find(root, 3), _find(root, 4) - assert (inner.total, inner.enabled, inner.check) == (2, 2, "on") - assert (zeta.total, zeta.enabled, zeta.check) == (3, 3, "on") - assert (alpha.total, alpha.enabled, alpha.check) == (1, 0, "off") - assert (root.total, root.enabled, root.check) == (5, 3, "partial") - - root = build_tree(state, mods, ["u2"]) - assert _find(root, 3).check == "partial" - assert _find(root, 2).check == "partial" - - def test_depths_and_root_holds_ungrouped(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, []) - assert root.depth == 0 - assert _find(root, 2).depth == 1 - assert _find(root, 3).depth == 2 - assert [m.uuid for m in root.mods] == ["u5"] - assert root.mods[0].placement == "none" - assert root.mods[0].package_id is None - - def test_sorted_by_name_casefold(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, []) - assert [c.folder.name for c in root.children] == ["alpha", "Zeta"] - assert [m.name for m in _find(root, 3).mods] == ["Three", "two"] - - def test_query_prunes_but_counts_cover_all_mods(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, {"u1"}, TreeQuery(text="TWO")) - assert [c.folder.id for c in root.children] == [2] - zeta = _find(root, 2) - assert zeta.mods == () - assert [m.uuid for m in _find(root, 3).mods] == ["u2"] - assert (zeta.total, zeta.enabled, zeta.visible_total) == (3, 1, 1) - assert (root.total, root.visible_total) == (5, 1) - assert root.mods == () - - def test_empty_query_does_not_prune(self) -> None: - state = _state(folders=[Folder(2, ROOT_ID, "Empty")]) - root = build_tree(state, {}, [], TreeQuery()) - assert [c.folder.id for c in root.children] == [2] - assert _find(root, 2).check == "off" - - def test_query_filters_status_tags_and_provider(self) -> None: - state = _state(mod_tags={"a.one": frozenset({1, 2}), "a.two": frozenset({1})}) - mods = { - "u1": _mod("u1", "One", "a.one", provider="steam"), - "u2": _mod("u2", "Two", "a.two", author="Ludeon"), - "u3": _mod("u3", "Three"), - } - - def visible(query: TreeQuery) -> set[str]: - root = build_tree(state, mods, {"u1"}, query) - return {m.uuid for m in root.all_mods()} - - assert visible(TreeQuery(status="active")) == {"u1"} - assert visible(TreeQuery(status="inactive")) == {"u2", "u3"} - assert visible(TreeQuery(tag_ids=frozenset({1}))) == {"u1", "u2"} - assert visible(TreeQuery(tag_ids=frozenset({1, 2}))) == {"u1"} - assert visible(TreeQuery(provider_ids=frozenset({"steam"}))) == {"u1"} - assert visible(TreeQuery(text="ludeon")) == {"u2"} - assert visible(TreeQuery(text="a.one")) == {"u1"} - - def test_shared_package_id_shares_placement_and_tags(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - placements={"dup.mod": 2}, - mod_tags={"dup.mod": frozenset({7})}, - ) - mods = { - "u1": _mod("u1", "Copy 1", "Dup.Mod"), - "u2": _mod("u2", "Copy 2", "dup.mod"), - } - folder = _find(build_tree(state, mods, []), 2) - assert [m.uuid for m in folder.mods] == ["u1", "u2"] - assert all(m.tag_ids == frozenset({7}) for m in folder.mods) - - def test_own_counts_and_rule_flag(self) -> None: - state, mods = self._nested() - state = _state( - folders=list(state.folders.values())[1:], - placements=state.placements, - rules=[("name", "contains", "zzz", 3)], - ) - root = build_tree(state, mods, {"u5", "u1"}, TreeQuery(text="nothing")) - assert (root.own_total, root.own_enabled, root.own_check) == (1, 1, "on") - assert (root.total, root.check) == (5, "partial") - full = build_tree(state, mods, set()) - assert [n.folder.id for n in full.walk() if n.has_rules] == [3] - assert _find(full, 2).own_total == 1 - - -class TestTreeFilters: - def test_query_includes_tag_id(self) -> None: - tf = TreeFilter("tag:10", "Medieval", 3, tag_id=10) - q = tf.query("sword") - assert q.tag_ids == frozenset({10}) - assert q.text == "sword" - assert q.status == "all" - assert q.provider_ids == frozenset() - assert not q.is_empty - - def test_tree_filters_preserves_order_and_counts_tags(self) -> None: - state = _state( - tags=[ - Tag(id=1, name="Medieval", color="#5eead4"), - Tag(id=2, name="Combat", color="#fb923c"), - Tag(id=3, name="Magic", color="#a3e635"), - ], - mod_tags={ - "a.one": frozenset({1, 2}), - "a.two": frozenset({1}), - }, - ) - mods = { - "u1": _mod("u1", "One", "a.one", provider="steam"), - "u2": _mod("u2", "Two", "a.two", provider="local"), - "u3": _mod("u3", "Three", "a.three", provider="steam"), - } - filters = tree_filters(mods, {"u1"}, state) - assert [f.key for f in filters[:3]] == ["all", "active", "inactive"] - assert [f.count for f in filters[:3]] == [3, 1, 2] - - providers = [f for f in filters if f.provider_id is not None] - assert [f.key for f in providers] == ["provider:local", "provider:steam"] - assert [f.count for f in providers] == [1, 2] - - tag_filters = [f for f in filters if f.tag_id is not None] - assert [f.key for f in tag_filters] == ["tag:2", "tag:3", "tag:1"] - assert [f.label for f in tag_filters] == ["Combat", "Magic", "Medieval"] - assert [f.count for f in tag_filters] == [1, 0, 2] - - def test_tree_filters_shared_package_ids_count_all_mods(self) -> None: - state = _state( - tags=[Tag(id=5, name="Overhaul", color="#5eead4")], - mod_tags={"shared.mod": frozenset({5})}, - ) - mods = { - "u1": _mod("u1", "Steam Copy", "Shared.Mod", provider="steam"), - "u2": _mod("u2", "Local Copy", "shared.mod", provider="local"), - "u3": _mod("u3", "Other Mod", "other.mod", provider="local"), - } - filters = tree_filters(mods, set(), state) - tag_filter = next(f for f in filters if f.key == "tag:5") - assert tag_filter.count == 2 - assert tag_filter.label == "Overhaul" - assert tag_filter.tag_id == 5 - - def test_tree_filters_without_state_or_without_tags(self) -> None: - mods = {"u1": _mod("u1", "One", "a.one")} - assert [f.key for f in tree_filters(mods, set(), None)] == [ - "all", - "active", - "inactive", - "provider:local", - ] - assert [f.key for f in tree_filters(mods, set(), _state())] == [ - "all", - "active", - "inactive", - "provider:local", - ] - - def test_filtering_tree_with_tag_query(self) -> None: - state = _state( - tags=[ - Tag(id=1, name="Tagged", color="#5eead4"), - Tag(id=2, name="Empty", color="#fb923c"), - ], - mod_tags={"a.one": frozenset({1}), "a.two": frozenset({1})}, - ) - mods = { - "u1": _mod("u1", "Mod One", "a.one"), - "u2": _mod("u2", "Mod Two", "a.two"), - "u3": _mod("u3", "Mod Three", "a.three"), - } - filters = tree_filters(mods, set(), state) - tag1_filter = next(f for f in filters if f.key == "tag:1") - tree = build_tree(state, mods, set(), tag1_filter.query()) - assert {m.uuid for m in tree.all_mods()} == {"u1", "u2"} - - empty_filter = next(f for f in filters if f.key == "tag:2") - empty_tree = build_tree(state, mods, set(), empty_filter.query()) - assert list(empty_tree.all_mods()) == [] - assert empty_tree.visible_total == 0 - - narrowed = build_tree(state, mods, set(), tag1_filter.query("Two")) - assert {m.uuid for m in narrowed.all_mods()} == {"u2"} diff --git a/legacy/tests/test_organizer/test_service.py b/legacy/tests/test_organizer/test_service.py deleted file mode 100644 index 8087955..0000000 --- a/legacy/tests/test_organizer/test_service.py +++ /dev/null @@ -1,470 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from pathlib import Path -from typing import TYPE_CHECKING, cast - -import pytest - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import STANDARD_RULES -from pxmodrim.core.organizer.models import ROOT_ID, OrganizerError, RuleSpec -from pxmodrim.core.organizer.resolve import folder_for -from pxmodrim.core.organizer.service import OrganizerService - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -class _FakeCtx: - def __init__(self) -> None: - self.all_mods: dict[str, ListedMod] = {} - self.active_uuids: list[str] = [] - - def add(self, uuid: str, name: str, pid: str) -> None: - self.all_mods[uuid] = AboutXmlMod( - name=name, _uuid=uuid, package_id=CaseInsensitiveStr(pid) - ) - - -class _Counter: - def __init__(self) -> None: - self.count = 0 - - def __call__(self, _: None) -> None: - self.count += 1 - - -@pytest.fixture -def ctx() -> _FakeCtx: - return _FakeCtx() - - -@pytest.fixture -async def svc(tmp_path: Path, ctx: _FakeCtx) -> AsyncIterator[OrganizerService]: - service = OrganizerService( - cast("CoreContext", ctx), OrganizerDb(tmp_path / "organizer.db") - ) - await service.init(cast("CoreContext", ctx)) - yield service - await service.shutdown() - - -@pytest.fixture -def changes(svc: OrganizerService) -> _Counter: - counter = _Counter() - svc.changed.connect(counter) - return counter - - -async def test_bulk_folder_expansion_persists(svc: OrganizerService) -> None: - parent = await svc.create_folder("Parent") - child = await svc.create_folder("Child", parent.id) - other = await svc.create_folder("Other") - - await svc.set_all_collapsed(True) - persisted = await svc._db.load() - assert all( - persisted.folders[folder.id].collapsed for folder in (parent, child, other) - ) - - await svc.set_collapsed(child.id, False) - await svc.set_all_collapsed(False) - persisted = await svc._db.load() - assert all( - not persisted.folders[folder.id].collapsed for folder in (parent, child, other) - ) - - -async def test_depth_limit(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - c = await svc.create_folder("C", b.id) - assert changes.count == 3 - with pytest.raises(OrganizerError, match="at most 3 levels"): - await svc.create_folder("D", c.id) - assert changes.count == 3 - assert len(svc.state.folders) == 4 - - -async def test_move_respects_subtree_height( - svc: OrganizerService, changes: _Counter -) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - x = await svc.create_folder("X") - y = await svc.create_folder("Y", x.id) - with pytest.raises(OrganizerError, match="at most 3 levels"): - await svc.move_folder(x.id, b.id) - await svc.move_folder(x.id, a.id) - assert svc.state.folders[x.id].parent_id == a.id - assert svc.state.folders[y.id].parent_id == x.id - assert changes.count == 5 - - -async def test_cycle_rejected(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - for target in (a.id, b.id): - with pytest.raises(OrganizerError, match="into itself"): - await svc.move_folder(a.id, target) - with pytest.raises(OrganizerError): - await svc.move_folder(ROOT_ID, a.id) - assert changes.count == 2 - assert svc.state.folders[a.id].parent_id == ROOT_ID - - -async def test_duplicate_name_message(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("Graphics") - with pytest.raises(OrganizerError, match='A folder named "graphics" already'): - await svc.create_folder(" graphics ") - other = await svc.create_folder("Other") - with pytest.raises(OrganizerError, match="already exists"): - await svc.rename_folder(other.id, "GRAPHICS") - await svc.create_folder("Graphics", other.id) - await svc.rename_folder(a.id, "graphics") - assert svc.state.folders[a.id].name == "graphics" - assert changes.count == 4 - - -async def test_empty_name_rejected(svc: OrganizerService, changes: _Counter) -> None: - with pytest.raises(OrganizerError, match="cannot be empty"): - await svc.create_folder(" ") - assert changes.count == 0 - - -async def test_create_folder_from_emits_once_and_rejects_duplicate( - svc: OrganizerService, changes: _Counter -) -> None: - initial_changes = changes.count - f = await svc.create_folder_from("Core", ["mod.a", "mod.b"]) - assert changes.count == initial_changes + 1 - assert f.name == "Core" - assert svc.state.placements["mod.a"] == f.id - assert svc.state.placements["mod.b"] == f.id - - state_before = svc.state - changes_before = changes.count - - with pytest.raises(OrganizerError): - await svc.create_folder_from("core", ["mod.c"]) - - assert changes.count == changes_before - assert svc.state == state_before - db_state = await svc._db.load() - assert "mod.c" not in db_state.placements - assert len(db_state.folders) == len(state_before.folders) - - -async def test_delete_folder_ungroups_mods( - svc: OrganizerService, ctx: _FakeCtx, changes: _Counter -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.active_uuids = ["u1"] - a = await svc.create_folder_from("A", ["A.One"]) - b = await svc.create_folder("B", a.id) - await svc.place(["a.two"], b.id) - await svc.set_rules([RuleSpec("package_id", "prefix", "a.", a.id)]) - assert changes.count == 4 - assert sorted(svc.folder_mod_uuids(a.id)) == ["u1", "u2"] - - await svc.delete_folder(a.id) - - assert changes.count == 5 - assert set(svc.state.folders) == {ROOT_ID} - assert svc.state.rules == () - root = svc.tree() - assert sorted(m.uuid for m in root.mods) == ["u1", "u2"] - assert all(m.placement == "manual" for m in root.mods) - assert (root.total, root.enabled, root.check) == (2, 1, "partial") - - -async def test_place_ungroup_and_reset( - svc: OrganizerService, ctx: _FakeCtx, changes: _Counter -) -> None: - ctx.add("u1", "One", "a.one") - a = await svc.create_folder("A") - await svc.set_rules([RuleSpec("name", "equals", " one ", a.id)]) - assert svc.state.rules[0].pattern == "one" - assert svc.tree().children[0].mods[0].placement == "rule" - - await svc.ungroup(["a.one"]) - assert svc.tree().mods[0].placement == "manual" - - await svc.reset_to_rules(["a.one"]) - assert svc.tree().children[0].mods[0].placement == "rule" - assert changes.count == 4 - - -async def test_rules_validation(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - with pytest.raises(OrganizerError, match="root"): - await svc.set_rules([RuleSpec("name", "equals", "x", ROOT_ID)]) - with pytest.raises(OrganizerError, match="pattern cannot be empty"): - await svc.set_rules([RuleSpec("name", "equals", " ", a.id)]) - with pytest.raises(OrganizerError): - await svc.set_rules([RuleSpec("name", "equals", "x", 999)]) - assert changes.count == 1 - - -async def test_tags(svc: OrganizerService, changes: _Counter) -> None: - tag = await svc.create_tag("QoL", "#aabbcc") - with pytest.raises(OrganizerError, match="already exists"): - await svc.create_tag("qol", "#000000") - with pytest.raises(OrganizerError, match="not a valid color"): - await svc.create_tag("Other", "red") - await svc.set_mod_tags(["A.Mod"], add=[tag.id]) - assert svc.state.mod_tags["a.mod"] == frozenset({tag.id}) - with pytest.raises(OrganizerError): - await svc.set_mod_tags(["a.mod"], add=[999]) - await svc.delete_tag(tag.id) - assert svc.state.tags == {} - assert not svc.state.mod_tags.get("a.mod") - assert changes.count == 3 - - -async def test_state_persists(tmp_path: Path, ctx: _FakeCtx) -> None: - path = tmp_path / "organizer.db" - first = OrganizerService(cast("CoreContext", ctx), OrganizerDb(path)) - await first.init(cast("CoreContext", ctx)) - folder = await first.create_folder("Kept") - await first.shutdown() - - second = OrganizerService(cast("CoreContext", ctx), OrganizerDb(path)) - await second.init(cast("CoreContext", ctx)) - assert second.state.folders[folder.id].name == "Kept" - await second.shutdown() - - -async def test_init_signals_readiness(tmp_path: Path, ctx: _FakeCtx) -> None: - service = OrganizerService( - cast("CoreContext", ctx), OrganizerDb(tmp_path / "organizer.db") - ) - counter = _Counter() - service.changed.connect(counter) - assert not service.ready - await service.init(cast("CoreContext", ctx)) - assert service.ready - assert counter.count == 1 - await service.shutdown() - - -async def test_folder_toggle_enables_partial_and_disables_full( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.add("u3", "Loose", "loose.mod") - a = await svc.create_folder_from("A", ["a.one"]) - b = await svc.create_folder("B", a.id) - await svc.place(["a.two"], b.id) - - ctx.active_uuids = ["u1"] - enable, disable = svc.folder_toggle(a.id) - assert (sorted(enable), disable) == (["u1", "u2"], []) - - ctx.active_uuids = ["u1", "u2"] - enable, disable = svc.folder_toggle(a.id) - assert (enable, sorted(disable)) == ([], ["u1", "u2"]) - - assert svc.folder_toggle(ROOT_ID, recursive=False) == (["u3"], []) - ctx.active_uuids = ["u3"] - assert svc.folder_toggle(ROOT_ID, recursive=False) == ([], ["u3"]) - assert svc.folder_mod_uuids(ROOT_ID, recursive=False) == ["u3"] - - -async def test_folder_move_targets_respect_subtree_depth_and_names( - svc: OrganizerService, -) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - c = await svc.create_folder("C", b.id) - x = await svc.create_folder("X") - y = await svc.create_folder("Y", x.id) - clash = await svc.create_folder("Clash") - inner_x = await svc.create_folder("X", clash.id) - - assert svc.folder_move_targets(b.id) == {x.id, clash.id, ROOT_ID} - assert svc.folder_move_targets(x.id) == {a.id} - assert svc.folder_move_targets(c.id) == { - a.id, - x.id, - y.id, - clash.id, - inner_x.id, - ROOT_ID, - } - with pytest.raises(OrganizerError): - svc.folder_move_targets(ROOT_ID) - - -async def test_tree_filters_count_status_and_providers( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.active_uuids = ["u1"] - filters = {f.key: f for f in svc.tree_filters()} - assert [(k, f.count) for k, f in filters.items()][:3] == [ - ("all", 2), - ("active", 1), - ("inactive", 1), - ] - provider = next(f for f in filters.values() if f.provider_id is not None) - assert provider.count == 2 - assert provider.query("x").provider_ids == frozenset({provider.provider_id}) - assert filters["active"].query("one").status == "active" - - -async def test_tree_filters_with_persisted_tags_and_empty_tag( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.add("u3", "Three", "a.three") - - t1 = await svc.create_tag("Medieval", "#5eead4") - t2 = await svc.create_tag("Combat", "#fb923c") - t3 = await svc.create_tag("Empty", "#a3e635") - - await svc.set_mod_tags(["a.one"], add=[t1.id, t2.id]) - await svc.set_mod_tags(["a.two"], add=[t1.id]) - - filters = {f.key: f for f in svc.tree_filters()} - assert f"tag:{t1.id}" in filters - assert filters[f"tag:{t1.id}"].count == 2 - assert filters[f"tag:{t1.id}"].label == "Medieval" - assert filters[f"tag:{t1.id}"].tag_id == t1.id - - assert filters[f"tag:{t2.id}"].count == 1 - assert filters[f"tag:{t2.id}"].label == "Combat" - - assert filters[f"tag:{t3.id}"].count == 0 - assert filters[f"tag:{t3.id}"].label == "Empty" - - await svc.delete_tag(t2.id) - after_del = {f.key: f for f in svc.tree_filters()} - assert f"tag:{t2.id}" not in after_del - assert f"tag:{t1.id}" in after_del - - -async def test_tree_filters_shared_package_ids_count_in_service( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "Steam Copy", "shared.pkg") - ctx.add("u2", "Local Copy", "shared.pkg") - ctx.add("u3", "Other Mod", "other.pkg") - - tag = await svc.create_tag("Overhaul", "#5eead4") - await svc.set_mod_tags(["shared.pkg"], add=[tag.id]) - - filters = {f.key: f for f in svc.tree_filters()} - assert filters[f"tag:{tag.id}"].count == 2 - - -def _rule_rows(svc: OrganizerService) -> list[tuple[str, str, str, str]]: - folders = svc.state.folders - return [ - (folders[r.folder_id].name, r.field, r.op, r.pattern) for r in svc.state.rules - ] - - -def _standard_rows() -> list[tuple[str, str, str, str]]: - return [(r.folder, r.field, r.op, r.pattern) for r in STANDARD_RULES] - - -async def test_add_standard_rules_fresh_and_idempotent( - svc: OrganizerService, changes: _Counter -) -> None: - total = len(STANDARD_RULES) - names = {r.folder for r in STANDARD_RULES} - assert await svc.add_standard_rules() == total - assert changes.count == 1 - assert _rule_rows(svc) == _standard_rows() - top = [f for f in svc.state.folders.values() if f.parent_id == ROOT_ID] - assert sorted(f.name for f in top) == sorted(names) - assert len(svc.state.folders) == len(names) + 1 - assert [r.position for r in svc.state.rules] == list(range(total)) - - assert await svc.add_standard_rules() == 0 - assert changes.count == 1 - assert len(svc.state.rules) == total - assert len(svc.state.folders) == len(names) + 1 - - -async def test_add_standard_rules_reuses_folder_and_keeps_user_rules( - svc: OrganizerService, changes: _Counter -) -> None: - official = await svc.create_folder("OFFICIAL") - mine = await svc.create_folder("Mine") - await svc.set_rules( - [ - RuleSpec("author", "contains", "me", mine.id), - RuleSpec("package_id", "prefix", "SARG.", mine.id), - ] - ) - changes.count = 0 - - assert await svc.add_standard_rules() == len(STANDARD_RULES) - 1 - assert changes.count == 1 - names = [f.name for f in svc.state.folders.values()] - assert "Official" not in names - assert "Alpha Mods" not in names - assert names.count("OFFICIAL") == 1 - rows = _rule_rows(svc) - assert rows[:2] == [ - ("Mine", "author", "contains", "me"), - ("Mine", "package_id", "prefix", "SARG."), - ] - expected = [row for row in _standard_rows() if row[3] != "sarg."] - expected[0] = ("OFFICIAL", *expected[0][1:]) - assert rows[2:] == expected - assert svc.state.rules[2].folder_id == official.id - - -async def test_standard_rules_resolve_mods(svc: OrganizerService) -> None: - await svc.add_standard_rules() - state = svc.state - - def folder(pid: str, name: str = "Mod", author: str = "") -> str: - return state.folders[folder_for(state, pid, name, author)[0]].name - - assert folder("oskarpotocki.vfe.core") == "Vanilla Expanded" - assert folder("someone.vfe", author="Oskar Potocki, Taranchuk") == ( - "Vanilla Expanded" - ) - assert folder("ludeon.rimworld.biotech", "Biotech") == "Official" - assert folder("brrainz.harmony", "Harmony") == "Frameworks & Libraries" - assert folder("ceteam.combatextended", "Combat Extended") == "Combat Extended" - assert folder("sarg.alphagenes") == "Alpha Mods" - assert folder("bs.performance", "Performance Fish") == "Performance" - assert folder("oskarpotocki.vanillafactionsexpanded.core") == ( - "Frameworks & Libraries" - ) - assert folder("vanillaracesexpanded.sanguophage") == "Vanilla Expanded" - assert folder("dubwise.dubsperformanceanalyzer.steam") == "Performance" - assert folder("dubwise.rimatomics") == "Dubs Mods" - assert folder("brrainz.achtung", "Achtung!") == "Quality of Life" - assert folder("brrainz.jobsofopportunity") == "Root" - assert folder("smartkar.athena.framework", "Athena Framework") == "Root" - assert folder("someone.perf", "Performance Tweaks") == "Root" - assert folder("someone.other", "Other") == "Root" - - -async def test_standard_package_id_rules_are_not_shadowed( - svc: OrganizerService, -) -> None: - await svc.add_standard_rules() - state = svc.state - for rule in STANDARD_RULES: - if rule.field != "package_id": - continue - probe = rule.pattern if rule.op == "equals" else rule.pattern + "x" - folder_id, _ = folder_for(state, probe, "Mod", "") - assert state.folders[folder_id].name == rule.folder, rule.pattern diff --git a/legacy/tests/test_packaging.py b/legacy/tests/test_packaging.py deleted file mode 100644 index 9f5f0bf..0000000 --- a/legacy/tests/test_packaging.py +++ /dev/null @@ -1,288 +0,0 @@ -from __future__ import annotations - -import configparser -import importlib.util -import plistlib -import struct -import xml.etree.ElementTree as ET -import zipfile -from pathlib import Path - -import pytest - -_build_py = Path(__file__).parent.parent / "packaging" / "build.py" -_spec = importlib.util.spec_from_file_location("local_packaging_build", _build_py) -assert _spec and _spec.loader -pkg_build = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(pkg_build) - - -def test_get_version_from_pyproject(tmp_path: Path, monkeypatch) -> None: - monkeypatch.delenv("GITHUB_REF_NAME", raising=False) - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") - assert pkg_build.get_version(tmp_path) == "1.2.3" - - -def test_get_version_from_git_tag(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("GITHUB_REF_NAME", "v2.0.1") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "1.0.0"\n', encoding="utf-8") - assert pkg_build.get_version(tmp_path) == "2.0.1" - - -@pytest.mark.parametrize( - ("machine", "architecture"), - [("x86_64", "amd64"), ("aarch64", "arm64")], -) -def test_debian_architecture_mapping(machine: str, architecture: str) -> None: - assert pkg_build.debian_architecture(machine) == architecture - - -def test_deb_creation_uses_native_architecture(tmp_path: Path, monkeypatch) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (dist_dir / "PxModRim").write_bytes(b"application") - (tmp_path / "pyproject.toml").write_text( - '[project]\nversion = "0.5.0"\n', encoding="utf-8" - ) - desktop_dir = tmp_path / "packaging" / "linux" - desktop_dir.mkdir(parents=True) - (desktop_dir / "pxmodrim.desktop").write_text("[Desktop Entry]\n", encoding="utf-8") - icon = tmp_path / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - icon.parent.mkdir(parents=True) - icon.write_text("", encoding="utf-8") - - control: dict[str, str] = {} - monkeypatch.setattr(pkg_build.platform, "machine", lambda: "aarch64") - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: "dpkg-deb") - - def build_package(args: list[str], check: bool) -> None: - control["metadata"] = (Path(args[-2]) / "DEBIAN" / "control").read_text( - encoding="utf-8" - ) - Path(args[-1]).touch() - - monkeypatch.setattr(pkg_build.subprocess, "run", build_package) - - output = pkg_build.create_deb(tmp_path) - - assert output is not None - assert output.name == "pxmodrim_0.5.0_arm64.deb" - assert "Architecture: arm64" in control["metadata"] - - -def test_get_standalone_platform_args(monkeypatch) -> None: - monkeypatch.setattr(pkg_build.platform, "system", lambda: "Darwin") - args = pkg_build.get_standalone_args() - assert "--macos-create-app-bundle" in args - assert "--macos-app-name=PxModRim" in args - assert "--macos-signed-app-name=com.github.PyXiion.PxModRim" in args - - monkeypatch.setattr(pkg_build.platform, "system", lambda: "Windows") - args = pkg_build.get_standalone_args() - assert "--windows-company-name=PxModRim" in args - assert "--windows-product-name=PxModRim" in args - - -@pytest.mark.parametrize( - ("workshop", "left_out", "shipped"), - [ - ( - "NoWorkshop", - [ - "pxsteamdl", - "ui.plugins.steam_workshop", - "ui.plugins.workshop", - "ui.plugins.downloads", - "QtWebEngine", - ], - [], - ), - ( - "SteamWorkshop", - ["ui.plugins.workshop"], - ["pxsteamdl", "ui.plugins.steam_workshop", "ui.plugins.downloads"], - ), - ( - "NativeWorkshop", - ["ui.plugins.steam_workshop", "QtWebEngine"], - ["pxsteamdl", "ui.plugins.workshop", "ui.plugins.downloads"], - ), - ], -) -def test_workshop_variants_leave_out_only_what_they_do_not_ship( - workshop: str, left_out: list[str], shipped: list[str] -) -> None: - options = " ".join(pkg_build.get_standalone_args(workshop=workshop)) - assert all(name in options for name in left_out) - assert not any(name in options for name in shipped) - - -def test_strip_unused_qml_modules_leaves_missing_directory_unchanged( - tmp_path: Path, -) -> None: - sentinel = tmp_path / "keep.txt" - sentinel.write_text("keep", encoding="utf-8") - - pkg_build.strip_unused_qml_modules(tmp_path) - - assert sentinel.read_text(encoding="utf-8") == "keep" - - -def test_windows_executable_normalization(tmp_path: Path) -> None: - dist_dir = tmp_path / "entrypoint.dist" - dist_dir.mkdir() - source = dist_dir / "entrypoint.exe" - source.write_bytes(b"windows executable") - - result = pkg_build.normalize_executable(dist_dir, system="Windows") - - assert result == dist_dir / "PxModRim.exe" - assert result.read_bytes() == b"windows executable" - assert not source.exists() - - with pytest.raises(FileNotFoundError, match="Executable not found"): - pkg_build.normalize_executable(tmp_path / "missing", system="Windows") - - -def test_macos_executable_normalization_preserves_runtime_directory( - tmp_path: Path, -) -> None: - entrypoint = tmp_path / "entrypoint" - entrypoint.write_bytes(b"macOS executable") - runtime_dir = tmp_path / "PxModRim" - runtime_dir.mkdir() - runtime_file = runtime_dir / "module.pyd" - runtime_file.write_bytes(b"runtime module") - - result = pkg_build.normalize_executable(tmp_path, system="Darwin") - - assert result == entrypoint - assert result.read_bytes() == b"macOS executable" - assert runtime_file.read_bytes() == b"runtime module" - - -def test_deb_creation_requires_dpkg_deb(tmp_path: Path, monkeypatch) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (tmp_path / "pyproject.toml").write_text( - '[project]\nversion = "0.5.0"\n', encoding="utf-8" - ) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="dpkg-deb"): - pkg_build.create_deb(tmp_path) - - -def test_appimage_creation_requires_appimagetool_for_release( - tmp_path: Path, monkeypatch -) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (dist_dir / "PxModRim").write_bytes(b"application") - icon = tmp_path / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - icon.parent.mkdir(parents=True) - icon.write_text("", encoding="utf-8") - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="appimagetool"): - pkg_build.create_appimage(tmp_path, release=True) - - -def test_rpm_creation_requires_rpmbuild_for_release( - tmp_path: Path, monkeypatch -) -> None: - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="rpmbuild"): - pkg_build.create_rpm(tmp_path, release=True) - - -def test_flatpak_metadata_contract() -> None: - metadata = ET.parse( - "packaging/flatpak/com.github.PyXiion.PxModRim.metainfo.xml" - ).getroot() - assert metadata.findtext("id") == "com.github.PyXiion.PxModRim" - assert metadata.findtext("project_license") == "GPL-3.0-or-later" - urls = {url.attrib["type"]: url.text for url in metadata.findall("url")} - assert urls == { - "homepage": "https://github.com/PyXiion/PxModRim", - "bugtracker": "https://github.com/PyXiion/PxModRim/issues", - } - - -def test_desktop_entry_contract() -> None: - desktop = configparser.ConfigParser(interpolation=None) - desktop.read("packaging/flatpak/com.github.PyXiion.PxModRim.desktop") - entry = desktop["Desktop Entry"] - assert entry["Icon"] == "com.github.PyXiion.PxModRim" - assert entry["Exec"] == "PxModRim" - - -def test_logo_icns_header() -> None: - icns = Path("packaging/logo.icns").read_bytes() - assert icns[:4] == b"icns" - total_len = struct.unpack(">I", icns[4:8])[0] - assert total_len == len(icns) - assert icns[8:12] == b"ic09" - - -def test_macos_bundle_plist_update(tmp_path: Path, monkeypatch) -> None: - project_root = tmp_path - pyproject = project_root / "pyproject.toml" - pyproject.write_text('[project]\nversion = "0.9.0"\n', encoding="utf-8") - - app_dir = project_root / "dist" / "PxModRim.app" - contents = app_dir / "Contents" - macos_dir = contents / "MacOS" - macos_dir.mkdir(parents=True) - (macos_dir / "entrypoint").write_bytes(b"macOS executable") - runtime_dir = macos_dir / "PxModRim" - runtime_dir.mkdir() - (runtime_dir / "module.pyd").write_bytes(b"runtime module") - plist_file = contents / "Info.plist" - - with open(plist_file, "wb") as f: - plistlib.dump({}, f) - - # Mock the platform tools while exercising app metadata and archive creation. - commands: list[list[str]] = [] - monkeypatch.setattr( - pkg_build.subprocess, - "run", - lambda command, check: commands.append(command), - ) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - res = pkg_build.create_macos_bundle(project_root) - assert res == app_dir - assert (macos_dir / "entrypoint").read_bytes() == b"macOS executable" - assert (runtime_dir / "module.pyd").read_bytes() == b"runtime module" - - with open(plist_file, "rb") as f: - pl = plistlib.load(f) - - assert pl["CFBundleIdentifier"] == "com.github.PyXiion.PxModRim" - assert pl["CFBundleName"] == "PxModRim" - assert pl["CFBundleVersion"] == "0.9.0" - assert pl["CFBundleExecutable"] == "entrypoint" - assert pl["CFBundlePackageType"] == "APPL" - - assert any(command[0] == "codesign" and "-" in command for command in commands) - with zipfile.ZipFile(project_root / "dist" / "PxModRim-macOS.zip") as archive: - assert "PxModRim.app/Contents/MacOS/entrypoint" in archive.namelist() - - -def test_macos_bundle_requires_hdiutil_for_release(tmp_path: Path, monkeypatch) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "0.9.0"\n', encoding="utf-8") - app_dir = tmp_path / "dist" / "PxModRim.app" - macos_dir = app_dir / "Contents" / "MacOS" - macos_dir.mkdir(parents=True) - (macos_dir / "entrypoint").write_bytes(b"macOS executable") - monkeypatch.setattr(pkg_build.subprocess, "run", lambda command, check: None) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="hdiutil"): - pkg_build.create_macos_bundle(tmp_path, release=True) diff --git a/legacy/tests/test_plugin.py b/legacy/tests/test_plugin.py deleted file mode 100644 index 2660f7d..0000000 --- a/legacy/tests/test_plugin.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from pathlib import Path -from unittest.mock import patch - -import pytest - -from pxmodrim._app import _parse_disabled_plugins -from pxmodrim.core.plugin import Plugin, PluginRegistry - - -class _RecorderPlugin(Plugin): - def __init__(self, name: str, dependencies: list[str] | None = None) -> None: - object.__setattr__(self, "name", name) - object.__setattr__(self, "dependencies", dependencies or []) - - def setup(self, ctx: list[str]) -> None: - ctx.append(self.name) - - async def init(self, ctx: list[str]) -> None: - ctx.append(self.name) - - -class TestPluginRegistryToposort: - def test_empty_registry(self) -> None: - reg = PluginRegistry() - order: list[str] = [] - reg.setup_all(order) - assert order == [] - - def test_no_dependencies(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b")) - reg.register(_RecorderPlugin("c")) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "b", "c"] - - def test_linear_chain(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["b"])) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "b", "c"] - - def test_complex_dag(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["a"])) - reg.register(_RecorderPlugin("d", ["b", "c"])) - reg.register(_RecorderPlugin("e", ["d"])) - reg.register(_RecorderPlugin("f")) - reg.register(_RecorderPlugin("g", ["e", "f"])) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "f", "b", "c", "d", "e", "g"] - - def test_missing_dependency_raises(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a", ["nonexistent"])) - with pytest.raises(RuntimeError, match="depends on 'nonexistent'"): - reg.setup_all(None) - - def test_extra_deps(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a", ["external_dep"])) - order: list[str] = [] - reg.setup_all(order, extra_deps={"external_dep"}) - assert order == ["a"] - - def test_init_all_order(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["b"])) - order: list[str] = [] - asyncio.run(reg.init_all(order)) - assert order == ["a", "b", "c"] - - def test_shutdown_all_reverse_dependency_order(self) -> None: - reg = PluginRegistry() - shutdown_order: list[str] = [] - - class ShutdownRecorder(_RecorderPlugin): - async def shutdown(self) -> None: - shutdown_order.append(self.name) - - reg.register(ShutdownRecorder("dependent", ["dependency"])) - reg.register(ShutdownRecorder("dependency")) - - asyncio.run(reg.shutdown_all()) - - assert shutdown_order == ["dependent", "dependency"] - - -class TestParseDisabledPlugins: - def test_empty(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": ""}): - assert _parse_disabled_plugins() == set() - - def test_single(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "workshop_download"}): - assert _parse_disabled_plugins() == {"workshop_download"} - - def test_multiple(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "a, b, c"}): - assert _parse_disabled_plugins() == {"a", "b", "c"} - - def test_empty_entries(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "a,,b"}): - assert _parse_disabled_plugins() == {"a", "b"} - - def test_whitespace_around_names(self) -> None: - with patch.dict( - os.environ, - {"PX_DISABLED_PLUGINS": " workshop_download , steamworkshop "}, - ): - assert _parse_disabled_plugins() == {"workshop_download", "steamworkshop"} - - def test_not_set(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": ""}): - os.environ.pop("PX_DISABLED_PLUGINS", None) - assert _parse_disabled_plugins() == set() - - -@pytest.mark.parametrize( - ("variant", "disabled"), - [ - ( - "NoWorkshop", - { - "steam_downloader", - "steamworkshop", - "downloads_ui", - "workshop_catalog", - "workshop_ui", - }, - ), - ("SteamWorkshop", {"workshop_catalog", "workshop_ui"}), - ("NativeWorkshop", {"steamworkshop"}), - ], -) -def test_baked_workshop_variant_disables_the_plugins_it_omits( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, variant: str, disabled: set[str] -) -> None: - (tmp_path / "workshop-variant.txt").write_text(variant, encoding="utf-8") - monkeypatch.setattr("pxmodrim._app.resource_files", lambda _: tmp_path) - monkeypatch.delenv("PX_DISABLED_PLUGINS", raising=False) - assert _parse_disabled_plugins() == disabled diff --git a/legacy/tests/test_providers.py b/legacy/tests/test_providers.py deleted file mode 100644 index 0bc834d..0000000 --- a/legacy/tests/test_providers.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.config import PathConfig -from pxmodrim.core.models.view.sidebar import PROVIDER_LABELS -from pxmodrim.core.providers import SteamWorkshopModProvider, create_providers - - -@pytest.mark.asyncio -async def test_workshop_path_has_distinct_provider_and_local_roots( - tmp_path: Path, -) -> None: - local_path = tmp_path / "Mods" - workshop_path = tmp_path / "workshop" - about_path = workshop_path / "123456" / "About" - about_path.mkdir(parents=True) - (about_path / "About.xml").write_text( - """ - - Workshop Mod - Author - author.workshop -
  • 1.5
  • -
    -""", - encoding="utf-8", - ) - (about_path / "PublishedFileId.txt").write_text("123456", encoding="utf-8") - - providers = create_providers( - PathConfig(local=str(local_path), workshop=str(workshop_path)) - ) - providers_by_id = {provider.provider_id: provider for provider in providers} - - assert len(providers_by_id) == len(providers) - assert providers_by_id["local"]._path == local_path - assert providers_by_id["downloaded"]._path == local_path - - workshop_provider = providers_by_id["steam"] - assert isinstance(workshop_provider, SteamWorkshopModProvider) - assert workshop_provider._path == workshop_path - assert PROVIDER_LABELS[workshop_provider.provider_id] == "Steam Workshop" - - discovered = await workshop_provider.discover("1.5") - assert len(discovered) == 1 - mod = next(iter(discovered.values())) - assert mod.mod_path == workshop_path / "123456" - assert mod.provider_id == "steam" diff --git a/legacy/tests/test_services/test_activation_service.py b/legacy/tests/test_services/test_activation_service.py deleted file mode 100644 index 4d1cd1c..0000000 --- a/legacy/tests/test_services/test_activation_service.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.activation_service import ActivationService -from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -def _mod( - name: str, - pid: str, - *deps: str, - load_after: tuple[str, ...] = (), - load_before: tuple[str, ...] = (), -) -> AboutXmlMod: - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - load_after=CaseInsensitiveSet(load_after), - load_before=CaseInsensitiveSet(load_before), - dependencies={ - CaseInsensitiveStr(dep): DependencyMod( - name=dep, package_id=CaseInsensitiveStr(dep) - ) - for dep in deps - }, - ), - ) - - -_MODS: dict[str, ListedMod] = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b", "mod.a"), - "uuid-c": _mod("Mod C", "mod.c", "mod.b"), - "uuid-d": _mod("Mod D", "mod.d"), - "uuid-e": _mod("Mod E", "mod.e", "mod.a"), - "uuid-f": _mod("Mod F", "mod.f"), - "uuid-g": _mod("Mod G", "mod.g", "mod.f"), - "uuid-h": _mod( - "Mod H", - "mod.h", - load_after=("mod.b",), - load_before=("mod.a",), - ), - "uuid-i": _mod("Mod I", "mod.i", load_after=("mod.d",)), - "uuid-j": _mod("Mod J", "mod.j", load_before=("mod.a",)), -} - - -@pytest.fixture -def ctx(tmp_path: Path) -> CoreContext: - ctx = CoreContext(AppConfig(), ConfigService(tmp_path)) - ctx._diagnostics_service = DiagnosticsService(ctx) - ctx.load(_MODS, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - return ctx - - -@pytest.fixture -def activation(ctx: CoreContext) -> ActivationService: - return ActivationService(ctx) - - -class TestApply: - def test_enabling_inserts_after_dependency_without_reordering_existing_mods( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert activation.apply(enable=["uuid-f", "uuid-e"]) is True - - expected = ["uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_unconstrained_enable_appends( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-f"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-f", - ] - - def test_enabling_mod_with_load_after_rule_inserts_after_target( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-i"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-i", - "uuid-a", - "uuid-b", - ] - - def test_enabling_mod_with_load_before_rule_inserts_before_target( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-j"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-j", - "uuid-a", - "uuid-b", - ] - - def test_enabling_dependency_before_active_dependent( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - ctx.load(_MODS, ["uuid-b", "uuid-c", "uuid-d"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-a"]) is True - - assert ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c", "uuid-d"] - - def test_conflicting_constraints_fall_back_to_appending( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-h"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-h", - ] - - def test_batch_enables_respect_dependency_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-g", "uuid-f"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-f", - "uuid-g", - ] - - def test_enabling_already_active_mod_keeps_its_position( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-c", "uuid-f"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"] - - def test_disabling_removes_and_keeps_remaining_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(disable=["uuid-d", "uuid-a"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-b"] - - def test_unknown_ids_are_ignored( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-missing", "uuid-f"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"] - - def test_mixed_selection_applies_in_single_emission( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert ( - activation.apply( - enable=["uuid-f", "uuid-e"], - disable=["uuid-a", "uuid-d"], - ) - is True - ) - - expected = ["uuid-c", "uuid-b", "uuid-f", "uuid-e"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_uuid_in_both_enable_and_disable_favors_disable( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert ( - activation.apply( - enable=["uuid-a", "uuid-f", "uuid-e"], - disable=["uuid-a", "uuid-f"], - ) - is True - ) - - expected = ["uuid-c", "uuid-d", "uuid-b", "uuid-e"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_duplicate_enable_deduped_in_input_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-f", "uuid-e", "uuid-f"]) is True - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-e", - "uuid-b", - "uuid-f", - ] - - @pytest.mark.parametrize( - ("enable", "disable"), - [ - (["uuid-a", "uuid-c"], []), - ([], ["uuid-e", "uuid-f"]), - (["uuid-missing"], []), - ([], ["uuid-missing"]), - ([], []), - (["uuid-missing"], ["uuid-missing"]), - ], - ) - def test_noop_returns_false_without_emitting( - self, - ctx: CoreContext, - activation: ActivationService, - enable: list[str], - disable: list[str], - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert activation.apply(enable=enable, disable=disable) is False - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b"] - assert emitted == [] - - def test_edges_through_uninstalled_mod_add_no_constraint( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods: dict[str, ListedMod] = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-w": _mod("Mod W", "mod.w", load_after=("ce.absent",)), - "uuid-y": _mod("Mod Y", "mod.y", load_before=("ce.absent",)), - } - ctx.load(mods, ["uuid-a", "uuid-w"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-y"]) is True - - assert ctx.active_uuids == ["uuid-a", "uuid-w", "uuid-y"] - - def test_bounds_ignore_inconsistent_active_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - # Active c depends on b but sits before it; X must still land in d..b. - mods = dict(_MODS) - mods["uuid-x"] = _mod("Mod X", "mod.x", "mod.d", load_before=("mod.b",)) - ctx.load(mods, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-x"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"] - - def test_batch_cycle_does_not_unplace_downstream_mods( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = dict(_MODS) - mods["uuid-p"] = _mod("Mod P", "mod.p", "mod.q") - mods["uuid-q"] = _mod("Mod Q", "mod.q", "mod.p") - mods["uuid-z"] = _mod("Mod Z", "mod.z", "mod.p") - mods["uuid-k"] = _mod("Mod K", "mod.k", "mod.d", load_before=("mod.a",)) - ctx.load(mods, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-z", "uuid-k", "uuid-q", "uuid-p"]) - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-k", - "uuid-a", - "uuid-b", - "uuid-q", - "uuid-p", - "uuid-z", - ] - - -class TestDependentsOf: - def test_transitive_dependents_in_active_order( - self, activation: ActivationService - ) -> None: - assert activation.dependents_of(["uuid-a"]) == ["uuid-c", "uuid-b"] - - def test_excludes_inputs(self, activation: ActivationService) -> None: - assert activation.dependents_of(["uuid-a", "uuid-b"]) == ["uuid-c"] - - def test_excludes_inactive_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert "uuid-e" not in ctx.active_uuids - - assert "uuid-e" not in activation.dependents_of(["uuid-a"]) - - def test_reflects_graph_after_state_change( - self, activation: ActivationService - ) -> None: - activation.apply(enable=["uuid-e"]) - - assert activation.dependents_of(["uuid-a"]) == ["uuid-c", "uuid-e", "uuid-b"] - - def test_mod_without_dependents(self, activation: ActivationService) -> None: - assert activation.dependents_of(["uuid-d", "uuid-missing"]) == [] - - def test_removing_duplicate_copy_keeps_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} - ctx.load(mods, ["uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"]) - ctx.diagnostics_service.rebuild() - - assert activation.dependents_of(["uuid-a-copy"]) == [] - assert activation.apply(disable=["uuid-a-copy"]) - assert ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c"] - assert activation.dependents_of(["uuid-a"]) == ["uuid-b", "uuid-c"] - - def test_enabling_replacement_copy_keeps_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} - ctx.load(mods, ["uuid-a", "uuid-b", "uuid-c"]) - ctx.diagnostics_service.rebuild() - - assert activation.dependents_of(["uuid-a"], enable=["uuid-a-copy"]) == [] - assert activation.apply(enable=["uuid-a-copy"], disable=["uuid-a"]) - assert ctx.active_uuids == ["uuid-a-copy", "uuid-b", "uuid-c"] - - -def test_sidebar_entries_are_published_before_summary( - ctx: CoreContext, activation: ActivationService -) -> None: - """The summary handler re-applies the sidebar filter, so entries must be fresh.""" - diagnostics = ctx.diagnostics_service - inactive: set[str] = set() - seen_at_summary: list[set[str]] = [] - - def on_entries(entries: list) -> None: - inactive.clear() - inactive.update(next(e for e in entries if e.label == "Inactive").visible_uuids) - - diagnostics.sidebar_entries_changed.connect(on_entries) - diagnostics.diagnostics_summary_changed.connect( - lambda _: seen_at_summary.append(set(inactive)) - ) - - diagnostics.rebuild(ctx.active_uuids) - seen_at_summary.clear() - activation.apply(enable=["uuid-f"]) - - assert seen_at_summary - assert "uuid-f" not in seen_at_summary[-1] diff --git a/legacy/tests/test_services/test_diagnostics_databases.py b/legacy/tests/test_services/test_diagnostics_databases.py deleted file mode 100644 index ba7608c..0000000 --- a/legacy/tests/test_services/test_diagnostics_databases.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -from pxmodrim.core.checker.models import PackageId -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.services.diagnostics_service import ( - DATABASE_MAX_AGE_S, - DiagnosticsService, -) - - -class _FakeDb: - def __init__( - self, - cached: Any, - downloaded: Any, - gate: asyncio.Event, - age_s: float | None = 0.0, - ) -> None: - self.age_s = age_s - self.cached = cached - self.downloaded = downloaded - self.gate = gate - self.downloads = 0 - - def cache_age_s(self) -> float | None: - return self.age_s - - def load_if_exists(self) -> Any: - return self.cached - - async def ensure(self, force: bool = False) -> Any: - self.downloads += 1 - await self.gate.wait() - return self.downloaded - - -def _service(tmp_path: Path, nvw: _FakeDb, uti: _FakeDb) -> DiagnosticsService: - service = DiagnosticsService(CoreContext(AppConfig(), ConfigService(tmp_path))) - service._no_version_warning_service = nvw # type: ignore[assignment] - service._use_this_instead_service = uti # type: ignore[assignment] - return service - - -async def test_initialize_does_not_wait_for_database_download(tmp_path: Path) -> None: - gate = asyncio.Event() - nvw = _FakeDb(set(), {PackageId("a.b")}, gate, age_s=None) - uti = _FakeDb({}, {}, gate, age_s=None) - service = _service(tmp_path, nvw, uti) - events: list[str] = [] - service.background_task_changed.connect(events.append) - - await asyncio.wait_for(service.initialize(), timeout=1) - await asyncio.sleep(0) - assert events == ["Downloading rule databases\u2026"] - - gate.set() - assert service._db_task is not None - await service._db_task - assert events[-1] == "" - assert PackageId("a.b") in service._checker._no_version_warning - - -async def test_cached_databases_are_applied_without_download(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - nvw = _FakeDb({PackageId("x.y")}, set(), gate) - uti = _FakeDb({"1": object()}, {}, gate) - service = _service(tmp_path, nvw, uti) - events: list[str] = [] - service.background_task_changed.connect(events.append) - - await service.initialize() - await asyncio.sleep(0) - - assert PackageId("x.y") in service._checker._no_version_warning - assert nvw.downloads == uti.downloads == 0 - assert events == [] - - -async def test_stale_cache_is_used_immediately_and_refreshed(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - week = DATABASE_MAX_AGE_S - nvw = _FakeDb({PackageId("old.one")}, {PackageId("new.one")}, gate, week + 1) - uti = _FakeDb({"1": object()}, {"2": object()}, gate, 60.0) - service = _service(tmp_path, nvw, uti) - - await service.initialize() - assert PackageId("old.one") in service._checker._no_version_warning - assert service._db_task is not None - await service._db_task - - assert (nvw.downloads, uti.downloads) == (1, 0) - assert PackageId("new.one") in service._checker._no_version_warning - - -async def test_failed_refresh_keeps_cached_data(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - nvw = _FakeDb({PackageId("old.one")}, set(), gate, DATABASE_MAX_AGE_S + 1) - uti = _FakeDb({"1": object()}, {}, gate, 60.0) - service = _service(tmp_path, nvw, uti) - - await service.initialize() - assert service._db_task is not None - await service._db_task - - assert PackageId("old.one") in service._checker._no_version_warning diff --git a/legacy/tests/test_services/test_game_launcher.py b/legacy/tests/test_services/test_game_launcher.py deleted file mode 100644 index 632010a..0000000 --- a/legacy/tests/test_services/test_game_launcher.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import sys -import types -from pathlib import Path - -import pytest - -from pxmodrim.core.config import AppConfig, PathConfig -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, LaunchStrategy -from pxmodrim.core.services.game_launcher import GameLauncher, build_direct_command - - -def test_no_wrapper_appends_args() -> None: - argv, env = build_direct_command("/g/RimWorldLinux", "", "-popupwindow -logfile") - assert argv == ["/g/RimWorldLinux", "-popupwindow", "-logfile"] - assert env == {} - - -def test_wrapper_without_placeholder_is_prefix() -> None: - argv, _ = build_direct_command("/g/rw", "gamemoderun", "-x") - assert argv == ["gamemoderun", "/g/rw", "-x"] - - -def test_placeholder_position_and_env_split() -> None: - argv, env = build_direct_command( - "/g/rw", "MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail", "-x" - ) - assert argv == ["mangohud", "/g/rw", "-x", "--tail"] - assert env == {"MANGOHUD": "1", "DXVK_HUD": "fps"} - - -@pytest.mark.skipif(sys.platform == "win32", reason="posix quoting") -def test_quoted_args_stay_single_token() -> None: - argv, _ = build_direct_command("/g/rw", "", '-savedatafolder="/tmp/my saves"') - assert argv == ["/g/rw", "-savedatafolder=/tmp/my saves"] - - -def test_option_with_equals_is_not_an_env_var() -> None: - argv, env = build_direct_command("/g/rw", "--cfg=/p wrap", "") - assert argv == ["--cfg=/p", "wrap", "/g/rw"] - assert env == {} - - -def test_unbalanced_quotes_raise() -> None: - with pytest.raises(ValueError): - build_direct_command("/g/rw", "", '"oops') - - -@pytest.mark.parametrize( - ("platform", "name"), - [ - ("linux", "RimWorld"), - ("linux", "RimWorldLinux"), - ("win32", "RimWorld.exe"), - ("win32", "RimWorldWin64.exe"), - ("win32", "RimWorldWin.exe"), - ("darwin", "RimWorld.app"), - ("darwin", "RimWorldMac.app"), - ], -) -def test_find_executable_supports_current_and_legacy_installs( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, platform: str, name: str -) -> None: - monkeypatch.setattr(sys, "platform", platform) - exe = tmp_path / name - if platform == "darwin": - exe.mkdir() - else: - exe.touch() - exe.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == exe - - -@pytest.mark.parametrize( - ("platform", "current", "legacy"), - [ - ("linux", "RimWorld", "RimWorldLinux"), - ("win32", "RimWorld.exe", "RimWorldWin64.exe"), - ("darwin", "RimWorld.app", "RimWorldMac.app"), - ], -) -def test_find_executable_prefers_waylith_over_leftover_unity_files( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - platform: str, - current: str, - legacy: str, -) -> None: - monkeypatch.setattr(sys, "platform", platform) - for name in (legacy, current): - exe = tmp_path / name - if platform == "darwin": - exe.mkdir() - else: - exe.touch() - exe.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == tmp_path / current - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable permissions") -def test_linux_lookup_skips_non_executable_waylith_file( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setattr(sys, "platform", "linux") - (tmp_path / "RimWorld").touch() - legacy = tmp_path / "RimWorldLinux" - legacy.touch() - legacy.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == legacy - - -class _Proc: - def __init__(self, name: str, status: str = "running", pid: int = 1) -> None: - self.pid = pid - self.info = {"name": name, "status": status} - - -def _patch_procs(monkeypatch: pytest.MonkeyPatch, procs: list[list[_Proc]]) -> None: - snapshots = iter(procs) - last: list[_Proc] = [] - - def fake_iter(_attrs: object = None) -> list[_Proc]: - nonlocal last - last = next(snapshots, last) - return last - - monkeypatch.setattr( - "pxmodrim.core.services.game_launcher.psutil.process_iter", fake_iter - ) - - -@pytest.mark.parametrize( - "name", - [ - "RimWorld", - "RimWorld.exe", - "RimWorldLinux", - "RimWorldWin64.exe", - "RimWorldWin.exe", - "RimWorld by Ludeon Studios", - ], -) -def test_recognizes_current_and_legacy_game_processes( - monkeypatch: pytest.MonkeyPatch, name: str -) -> None: - _patch_procs(monkeypatch, [[_Proc(name), _Proc("bash")]]) - assert GameLauncher.is_running() is True - - -@pytest.mark.parametrize("name", ["RimWorld", "RimWorldLinux"]) -def test_zombie_game_process_is_not_running( - monkeypatch: pytest.MonkeyPatch, name: str -) -> None: - _patch_procs(monkeypatch, [[_Proc(name, "zombie"), _Proc("bash")]]) - assert GameLauncher.is_running() is False - - -@pytest.mark.asyncio -async def test_wait_for_exit_returns_once_process_disappears( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - _patch_procs(monkeypatch, [[_Proc("RimWorldLinux")], [_Proc("RimWorldLinux")], []]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - assert await launcher.wait_for_exit() is None - - -@pytest.mark.asyncio -async def test_steam_launch_passes_encoded_args( - monkeypatch: pytest.MonkeyPatch, -) -> None: - opened: list[str] = [] - monkeypatch.setattr( - "pxmodrim.core.services.game_launcher.webbrowser.open", opened.append - ) - cfg = AppConfig(paths=PathConfig(game="/g"), launch_args="-savedatafolder=/a b") - launcher = GameLauncher(types.SimpleNamespace(config=cfg)) # type: ignore[arg-type] - ok, _ = await launcher.launch(LaunchStrategy.STEAM) - assert ok - assert opened == [ - f"steam://run/{RIMWORLD_STEAM_APP_ID}//-savedatafolder%3D%2Fa%20b/" - ] - - -class _Popen: - def __init__(self, code: int | None) -> None: - self.code = code - - def poll(self) -> int | None: - return self.code - - -@pytest.mark.asyncio -async def test_wait_for_exit_returns_spawned_exit_code( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - _patch_procs(monkeypatch, [[_Proc("RimWorldLinux")], []]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._proc = _Popen(7) # type: ignore[assignment] - assert await launcher.wait_for_exit() == 7 - - -@pytest.mark.asyncio -async def test_wait_for_start_true_once_process_appears( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_START", 0) - _patch_procs(monkeypatch, [[], [], [_Proc("RimWorldLinux")]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - assert await launcher.wait_for_start(5) is True - - -@pytest.mark.asyncio -async def test_wait_for_start_false_when_spawned_process_died( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_START", 0) - _patch_procs(monkeypatch, [[]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._proc = _Popen(1) # type: ignore[assignment] - assert await launcher.wait_for_start(5) is False - - -@pytest.mark.asyncio -async def test_stale_game_process_from_before_launch_is_ignored( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - stale = _Proc("RimWorldLinux", pid=10) - fresh = _Proc("RimWorldLinux", pid=20) - _patch_procs(monkeypatch, [[stale], [stale, fresh], [stale, fresh], [stale]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._baseline = frozenset({10}) - assert await launcher.wait_for_start(5) is True - await launcher.wait_for_exit() - assert GameLauncher.is_running() is True diff --git a/legacy/tests/test_services/test_sort_service.py b/legacy/tests/test_services/test_sort_service.py deleted file mode 100644 index e9ec004..0000000 --- a/legacy/tests/test_services/test_sort_service.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.sort_service import SortService -from pxmodrim.core.sort.models import PackageId - - -def _mod(name: str, pid: str = "") -> AboutXmlMod: - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid or name.lower().replace(" ", ".")), - provider_id="stub", - valid=True, - ) - - -def _mod_with_deps(name: str, pid: str, deps: dict[str, str]) -> AboutXmlMod: - """Create an AboutXmlMod with the given dependencies. - - ``deps`` maps dependency package-id → dependency name. - """ - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - dependencies={ - CaseInsensitiveStr(dep_pid): DependencyMod( - name=dep_name, - package_id=CaseInsensitiveStr(dep_pid), - ) - for dep_pid, dep_name in deps.items() - } - ), - ) - - -def _mod_with_alt_deps(name: str, pid: str, deps: dict[str, set[str]]) -> AboutXmlMod: - """Create an AboutXmlMod with deps that have alternative package IDs. - - ``deps`` maps dependency package-id → set of alternative package IDs. - """ - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - dependencies={ - CaseInsensitiveStr(dep_pid): DependencyMod( - name=f"Dep of {name}", - package_id=CaseInsensitiveStr(dep_pid), - alternative_package_ids={CaseInsensitiveStr(a) for a in alts}, - ) - for dep_pid, alts in deps.items() - } - ), - ) - - -@pytest.fixture -def ctx() -> CoreContext: - return CoreContext(AppConfig()) - - -@pytest.fixture -def sort_service(ctx: CoreContext) -> SortService: - return SortService(ctx, MagicMock()) - - -class TestSortActiveMods: - async def test_duplicate_inactive_copy_does_not_replace_active_uuid( - self, ctx: CoreContext - ) -> None: - active_mod = _mod("Active Copy", "shared.package") - inactive_mod = _mod("Inactive Copy", "shared.package") - ctx.load( - { - "uuid-active": active_mod, - "uuid-inactive": inactive_mod, - }, - active_uuids=["uuid-active"], - ) - diagnostics = MagicMock() - diagnostics.active_mods_by_pid = {PackageId("shared.package"): active_mod} - service = SortService(ctx, diagnostics) - - with patch( - "pxmodrim.core.services.sort_service.topological_sort", - return_value=[PackageId("shared.package")], - ): - result = await service.sort_active_mods() - - assert result == ["uuid-active"] - - -class TestResolveMissingDependencies: - def test_no_dependencies(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] - - def test_missing_dep_exists_as_inactive(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a"}) - assert result == ["uuid-b"] - - def test_missing_dep_not_in_all_mods(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - }, - active_uuids=["uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a"}) - assert result == [] - - def test_missing_dep_already_active(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] - - def test_dep_satisfied_via_alternative_pid(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_alt_deps( - "Mod A", - "mod.a", - {"mod.b": {"mod.c"}}, - ), - "uuid-c": _mod("Mod C", "mod.c"), - }, - active_uuids=["uuid-a", "uuid-c"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-c"}) - assert result == [] - - def test_non_about_xml_mod_in_active_set_skipped( - self, sort_service: SortService - ) -> None: - folder_mod = ListedMod(name="Folder Mod", provider_id="stub") - sort_service._ctx.load( - { - "uuid-folder": folder_mod, - "uuid-a": _mod("Mod A", "mod.a"), - }, - active_uuids=["uuid-folder", "uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-folder", "uuid-a"}) - assert result == [] - - def test_no_duplicates_when_two_mods_share_missing_dep( - self, sort_service: SortService - ) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.c": "Mod C"}), - "uuid-b": _mod_with_deps("Mod B", "mod.b", {"mod.c": "Mod C"}), - "uuid-c": _mod("Mod C", "mod.c"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == ["uuid-c"] - - def test_empty_active_set_returns_empty(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=[], - ) - result = sort_service.resolve_missing_dependencies(set()) - assert result == [] - - def test_mod_with_no_deps_does_not_add_to_result( - self, sort_service: SortService - ) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod_with_deps("Mod B", "mod.b", {"mod.a": "Mod A"}), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] diff --git a/legacy/tests/test_services/test_update_service.py b/legacy/tests/test_services/test_update_service.py deleted file mode 100644 index 8bb18b7..0000000 --- a/legacy/tests/test_services/test_update_service.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from pxmodrim.core.services.update_service import ( - UpdateCheckError, - UpdateService, - is_newer, - parse_version, -) - - -@pytest.mark.parametrize( - ("tag", "current", "expected"), - [ - ("v0.2.0", "0.1.0", True), - ("v0.1.0", "0.1.0", False), - ("v0.1.0", "0.2.0", False), - ("v0.10.0", "0.9.0", True), - ("v1.0", "1.0.0", False), - ("v1.0.1", "1.0", True), - ("v0.2.0-beta.1", "0.1.0", True), - ("nightly", "0.1.0", False), - ("v0.2.0", "unknown", False), - ("v0.2.0", "0.2.0-rc1", True), - ("v0.2.0-rc1", "0.2.0", False), - ("v0.2.0-rc2", "0.2.0-rc1", True), - ("v0.2.0-rc1", "0.2.0-rc1", False), - ("v0.2.0-rc1", "0.1.9", True), - ("v0.1.9", "0.2.0-rc1", False), - ("v0.2.0+build5", "0.2.0", False), - ], -) -def test_is_newer(tag: str, current: str, expected: bool) -> None: - assert is_newer(tag, current) is expected - - -def test_parse_version_rejects_non_numeric() -> None: - assert parse_version("latest") is None - assert parse_version(" v1.2.3 ") == (1, 2, 3) - - -def _service(transport: httpx.MockTransport, current: str = "0.1.0") -> UpdateService: - return UpdateService(current, transport=transport) - - -def _json_transport(payload: object, status: int = 200) -> httpx.MockTransport: - return httpx.MockTransport(lambda request: httpx.Response(status, json=payload)) - - -_RELEASE = { - "tag_name": "v0.2.0", - "html_url": "https://example.test/r/v0.2.0", - "name": "Second", - "body": "notes", -} - - -async def test_check_returns_newer_release() -> None: - release = await _service(_json_transport(_RELEASE)).check() - - assert release is not None - assert release.tag == "v0.2.0" - assert release.name == "Second" - assert release.notes == "notes" - assert release.url == "https://example.test/r/v0.2.0" - - -async def test_check_returns_none_when_not_newer() -> None: - assert await _service(_json_transport(_RELEASE), "0.2.0").check() is None - - -async def test_check_offers_release_over_its_rc() -> None: - service = _service(_json_transport(_RELEASE), "0.2.0-rc1") - - release = await service.check() - - assert release is not None - assert release.tag == "v0.2.0" - - -async def test_check_wraps_http_status_error() -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport({}, status=500)).check() - - -async def test_check_wraps_transport_error() -> None: - def fail(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("down", request=request) - - with pytest.raises(UpdateCheckError): - await _service(httpx.MockTransport(fail)).check() - - -async def test_check_wraps_non_json_body() -> None: - transport = httpx.MockTransport(lambda request: httpx.Response(200, text="")) - - with pytest.raises(UpdateCheckError): - await _service(transport).check() - - -@pytest.mark.parametrize("payload", [[], None, "tag", 3]) -async def test_check_rejects_non_object_json(payload: object) -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport(payload)).check() - - -@pytest.mark.parametrize( - "payload", - [ - {}, - {"tag_name": "v0.2.0"}, - {"html_url": "https://example.test/r"}, - {"tag_name": "", "html_url": "https://example.test/r"}, - ], -) -async def test_check_rejects_missing_fields(payload: dict[str, str]) -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport(payload)).check() diff --git a/legacy/tests/test_sort/test_tier_order.py b/legacy/tests/test_sort/test_tier_order.py deleted file mode 100644 index 1967dfb..0000000 --- a/legacy/tests/test_sort/test_tier_order.py +++ /dev/null @@ -1,254 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.checker.graph import ConstraintGraph -from pxmodrim.core.checker.sort import topological_sort -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, -) -from pxmodrim.core.sort.config import SortSettings, Tier, TierConfig -from pxmodrim.core.sort.tiers import ( - find_cycle, - get_deps_recursive, - get_reverse_deps_recursive, -) - -PackageId = CaseInsensitiveStr - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _graph( - mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId], - settings: SortSettings | None = None, -) -> ConstraintGraph: - graph = ConstraintGraph() - graph.build(mods, ordered_pids, settings or SortSettings()) - return graph - - -def _active_dict(*mods: AboutXmlMod) -> dict[PackageId, AboutXmlMod]: - return {PackageId(m.package_id): m for m in mods} - - -def _sort( - mods: dict[PackageId, AboutXmlMod], - tier_config: TierConfig | None = None, -) -> list[PackageId]: - ordered_pids = list(mods.keys()) - settings = SortSettings( - use_community_rules=False, - use_alternative_package_ids=False, - tier_config=tier_config or TierConfig.default(), - ) - graph = _graph(mods, ordered_pids, settings) - return topological_sort(mods, graph, settings) - - -def _assert_order( - result: list[PackageId], - tier: Tier, - expected: list[str], -): - pids_in_tier = [pid for pid in result if pid in expected] - assert [str(p) for p in pids_in_tier] == expected, ( - f"TIER_{tier.value} order mismatch\n" - f" expected: {expected}\n" - f" got: {[str(p) for p in pids_in_tier]}" - ) - - -class TestTier0Order: - def test_follows_config_order(self): - mods = _active_dict( - _make_mod("brrainz.harmony"), - _make_mod("zetrith.prepatcher"), - _make_mod("ludeon.rimworld"), - _make_mod("ludeon.rimworld.royalty"), - _make_mod("ludeon.rimworld.ideology"), - _make_mod("ludeon.rimworld.biotech"), - _make_mod("ludeon.rimworld.anomaly"), - _make_mod("ludeon.rimworld.odyssey"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "brrainz.harmony", - "ludeon.rimworld", - "ludeon.rimworld.royalty", - "ludeon.rimworld.ideology", - "ludeon.rimworld.biotech", - "ludeon.rimworld.anomaly", - "ludeon.rimworld.odyssey", - ], - ) - - def test_partial_active(self): - mods = _active_dict( - _make_mod("ludeon.rimworld.biotech"), - _make_mod("zetrith.prepatcher"), - _make_mod("ludeon.rimworld.royalty"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "ludeon.rimworld.royalty", - "ludeon.rimworld.biotech", - ], - ) - - def test_with_deps_within_tier(self): - mods = _active_dict( - _make_mod("brrainz.harmony"), - _make_mod("zetrith.prepatcher"), - ) - mods[PackageId("brrainz.harmony")].about_rules.load_after = CaseInsensitiveSet( - ["zetrith.prepatcher"] - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "brrainz.harmony", - ], - ) - - -class TestTier1Order: - def test_follows_config_order(self): - mods = _active_dict( - _make_mod("unlimitedhugs.hugslib"), - _make_mod("adaptive.storage.framework"), - _make_mod("aoba.framework"), - _make_mod("smashphil.vehicleframework"), - _make_mod("vanillaexpanded.backgrounds"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "adaptive.storage.framework", - "aoba.framework", - "smashphil.vehicleframework", - "unlimitedhugs.hugslib", - "vanillaexpanded.backgrounds", - ], - ) - - def test_partial_active(self): - mods = _active_dict( - _make_mod("smashphil.vehicleframework"), - _make_mod("adaptive.storage.framework"), - _make_mod("vanillaexpanded.backgrounds"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "adaptive.storage.framework", - "smashphil.vehicleframework", - "vanillaexpanded.backgrounds", - ], - ) - - def test_with_deps_within_tier(self): - mods = _active_dict( - _make_mod("aoba.exosuit.framework"), - _make_mod("aoba.framework"), - ) - mods[ - PackageId("aoba.exosuit.framework") - ].about_rules.load_after = CaseInsensitiveSet(["aoba.framework"]) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "aoba.framework", - "aoba.exosuit.framework", - ], - ) - - -class TestTierBoundaries: - def test_tiers_are_ordered(self): - mods = _active_dict( - _make_mod("some.two.mod"), - _make_mod("brrainz.harmony"), - _make_mod("adaptive.storage.framework"), - _make_mod("some.three.mod"), - ) - tc = TierConfig( - tier_0=(PackageId("brrainz.harmony"),), - tier_1=(PackageId("adaptive.storage.framework"),), - tier_3=(PackageId("some.three.mod"),), - ) - result = _sort(mods, tier_config=tc) - order = [str(p) for p in result] - assert order.index("brrainz.harmony") < order.index( - "adaptive.storage.framework" - ), "TIER_0 before TIER_1" - assert order.index("adaptive.storage.framework") < order.index( - "some.two.mod" - ), "TIER_1 before TIER_2" - assert order.index("some.two.mod") < order.index("some.three.mod"), ( - "TIER_2 before TIER_3" - ) - - -class TestNonConfigMods: - def test_unknown_mods_after_config_mods(self): - mods = _active_dict( - _make_mod("unknown.first"), - _make_mod("zetrith.prepatcher"), - _make_mod("unknown.second"), - _make_mod("brrainz.harmony"), - ) - tc = TierConfig( - tier_0=(PackageId("zetrith.prepatcher"), PackageId("brrainz.harmony")), - tier_1=(), - tier_3=(), - ) - result = _sort(mods, tier_config=tc) - order = [str(p) for p in result] - tier0_pids = [ - p for p in order if p in ("zetrith.prepatcher", "brrainz.harmony") - ] - assert tier0_pids == ["zetrith.prepatcher", "brrainz.harmony"], ( - f"config mods in wrong order: {tier0_pids}" - ) - - -def test_tier_graph_algorithms_handle_long_chains(): - nodes = [PackageId(f"mod.{index}") for index in range(5000)] - deps = {nodes[index]: {nodes[index + 1]} for index in range(len(nodes) - 1)} - reverse_deps = {nodes[index + 1]: {nodes[index]} for index in range(len(nodes) - 1)} - - assert get_deps_recursive(nodes[0], deps) == set(nodes[1:]) - assert get_reverse_deps_recursive(nodes[-1], reverse_deps) == set(nodes[:-1]) - assert find_cycle(set(nodes), deps) is None - - deps[nodes[-1]] = {nodes[0]} - cycle = find_cycle(set(nodes), deps) - assert cycle is not None - assert len(cycle) == len(nodes) + 1 - assert cycle[0] == cycle[-1] - assert set(cycle[:-1]) == set(nodes) diff --git a/legacy/tests/test_support/test_report.py b/legacy/tests/test_support/test_report.py deleted file mode 100644 index 1d340c1..0000000 --- a/legacy/tests/test_support/test_report.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import io -import urllib.error -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest - -from pxmodrim.core.config import AppConfig, PathConfig -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, - get_app_version, - read_log_tail, - redact, -) - - -class TestRedact: - def test_redacts_home_dir_and_username_in_user_paths(self) -> None: - raw = "User alice at /home/alice/.config/log.txt and /mnt/home/alice/x" - result = redact(raw, home_dir="/home/alice", username="alice") - assert result == "User alice at ~/.config/log.txt and /mnt/home//x" - - def test_redacts_windows_style_paths(self) -> None: - raw = r"Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log" - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert result == r"Loaded ~\AppData\Local\pxmodrim\test.log" - - def test_redacts_forward_slash_windows_path(self) -> None: - raw = "Loaded C:/Users/Bob/AppData/test.log" - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert result == "Loaded ~/AppData/test.log" - - def test_home_dir_respects_path_boundary(self) -> None: - raw = "/home/bob/a and /home/bobby/b and /home/bob" - result = redact(raw, home_dir="/home/bob", username="bob") - assert result == "~/a and /home/bobby/b and ~" - - def test_redacts_username_on_removable_media_mounts(self) -> None: - raw = "/run/media/alice/SD/mods and /media/alice/x and /media/alicia/y" - result = redact(raw, home_dir="/home/alice", username="alice") - assert ( - result - == "/run/media//SD/mods and /media//x and /media/alicia/y" - ) - - def test_redacts_doubled_backslashes_from_repr(self) -> None: - raw = repr(r"C:\Users\Bob\AppData") + " and " + repr(r"D:\Users\Bob\mods") - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert "Bob" not in result - assert result.startswith("'~") - - def test_short_username_redacted_in_other_user_paths(self) -> None: - raw = r"ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar" - result = redact(raw, home_dir="/home/ed", username="ed") - expected = ( - r"ed saw D:\Users\\mods and /Users//x but not /Users/edgar" - ) - assert result == expected - - def test_common_word_username_does_not_corrupt_package_ids(self) -> None: - raw = "Core (ludeon.rimworld) from /opt/rimworld; rimworld started" - result = redact(raw, home_dir="/home/rimworld", username="rimworld") - assert result == raw - - -class TestReadLogTail: - def test_missing_file_returns_notice(self, tmp_path: Path) -> None: - missing = tmp_path / "does_not_exist.log" - content = read_log_tail(missing) - assert "" in content - - def test_empty_file_returns_notice(self, tmp_path: Path) -> None: - empty = tmp_path / "empty.log" - empty.touch() - content = read_log_tail(empty) - assert "" in content - - def test_reads_small_file_completely(self, tmp_path: Path) -> None: - log_file = tmp_path / "app.log" - text = "Line 1\nLine 2\nLine 3\n" - log_file.write_text(text, encoding="utf-8") - content = read_log_tail(log_file) - assert content == text - - def test_parses_loguru_serialized_json_lines(self, tmp_path: Path) -> None: - log_file = tmp_path / "json.log" - raw_json = ( - '{"text": "2026-09-28 | INFO | Loaded mods\\n", "record": {"id": 1}}\n' - "Plain log line\n" - '{"text": "2026-09-28 | INFO | Ready\\n", "record": {"id": 2}}\n' - ) - log_file.write_text(raw_json, encoding="utf-8") - content = read_log_tail(log_file) - expected = ( - "2026-09-28 | INFO | Loaded mods\n" - "Plain log line\n" - "2026-09-28 | INFO | Ready\n" - ) - assert content == expected - assert '{"text"' not in content - - def test_truncates_large_file_and_adds_banner(self, tmp_path: Path) -> None: - log_file = tmp_path / "large.log" - lines = [ - f"Log record number {i:04d} with some extra padding" for i in range(200) - ] - log_file.write_text("\n".join(lines), encoding="utf-8") - - # Cap to 200 bytes - content = read_log_tail(log_file, max_bytes=200) - assert "[... truncated, showing last " in content - assert " of " in content - assert "Log record number 0199" in content - assert "Log record number 0001" not in content - - -def test_get_app_version_fallback_unknown() -> None: - from importlib.metadata import PackageNotFoundError - - with patch( - "pxmodrim.core.support.report.version", side_effect=PackageNotFoundError - ): - assert get_app_version() == "unknown" - - -class MockContext: - def __init__( - self, - game_version: str = "1.5.4243", - paths: PathConfig | None = None, - mods: dict[str, Any] | None = None, - active_uuids: list[str] | None = None, - ) -> None: - self.game_version = game_version - self.config = AppConfig(paths=paths or PathConfig()) - self.all_mods = mods or {} - self.active_uuids = active_uuids or [] - - -class TestBuildReport: - def test_report_contains_all_sections_and_active_mods_in_order( - self, tmp_path: Path - ) -> None: - mod1 = AboutXmlMod() - mod1.name = "Harmony" - mod1.package_id = "brrainz.harmony" # type: ignore[assignment] - - mod2 = AboutXmlMod() - mod2.name = "Core" - mod2.package_id = "ludeon.rimworld" # type: ignore[assignment] - - mod3 = AboutXmlMod() - mod3.name = "Royalty" - mod3.package_id = "ludeon.rimworld.royalty" # type: ignore[assignment] - - mods = {"u1": mod1, "u2": mod2, "u3": mod3} - # Specify load order: u2 (Core), then u1 (Harmony), then u3 (Royalty) - active = ["u2", "u1", "u3"] - - home = tmp_path / "home" / "testuser" - home.mkdir(parents=True, exist_ok=True) - paths = PathConfig( - game=str(home / "Games" / "RimWorld"), - config_folder=str(home / ".config" / "unity3d"), - local=str(home / "Games" / "RimWorld" / "Mods"), - ) - ctx = MockContext( - game_version="1.5.4243", - paths=paths, - mods=mods, - active_uuids=active, - ) - log_path = tmp_path / "app.log" - - log_path.write_text( - "INFO | Loaded /home/testuser/mods for testuser\n", encoding="utf-8" - ) - - report = build_report( - ctx, - log_path=log_path, - home_dir=str(home), - username="testuser", - ) - - # Verify sections - assert "=== PxModRim System Report ===" in report - assert "=== RimWorld Configuration ===" in report - assert "=== Mod Collection ===" in report - assert "=== Active Mods (Load Order) ===" in report - assert "=== Application Log ===" in report - - # Verify system info - assert f"App Version: {get_app_version()}" in report - assert "Python Version:" in report - assert "OS:" in report - - # Verify game version and paths are redacted - assert "Game Version: 1.5.4243" in report - assert "~/Games/RimWorld" in report.replace("\\", "/") - assert "~/.config/unity3d" in report.replace("\\", "/") - assert str(home) not in report - - # Verify mod counts - assert "Total Mods: 3" in report - assert "Active Mods: 3" in report - - # Verify active mods in load order - expected_order = ( - " 1. Core (ludeon.rimworld)\n" - " 2. Harmony (brrainz.harmony)\n" - " 3. Royalty (ludeon.rimworld.royalty)" - ) - assert expected_order in report - - # Username is redacted only in user-directory paths - assert "Loaded /home//mods for testuser" in report - - -class TestPasteUploader: - def test_upload_success(self) -> None: - uploader = PasteUploader(endpoint="https://paste.rs/test") - mock_response = MagicMock() - mock_response.status = 201 - mock_response.read.return_value = b"https://paste.rs/abc123\n" - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with patch( - "urllib.request.urlopen", return_value=mock_response - ) as mock_urlopen: - url = uploader.upload("Report text payload") - assert url == "https://paste.rs/abc123" - - mock_urlopen.assert_called_once() - req = mock_urlopen.call_args[0][0] - assert req.get_header("User-agent").startswith("PxModRim/") - assert req.get_header("Content-type") == "text/plain; charset=utf-8" - assert req.data == b"Report text payload" - - def test_upload_empty_text_raises_error(self) -> None: - uploader = PasteUploader() - with pytest.raises(ReportUploadError, match="Report text is empty"): - uploader.upload(" ") - - def test_upload_non_2xx_status_raises_error(self) -> None: - uploader = PasteUploader() - mock_response = MagicMock() - mock_response.status = 500 - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(ReportUploadError, match="HTTP status 500"), - ): - uploader.upload("Report content") - - def test_upload_http_error_raises_report_upload_error(self) -> None: - uploader = PasteUploader() - http_error = urllib.error.HTTPError( - url="https://paste.rs/", - code=403, - msg="Forbidden", - hdrs={}, # type: ignore[arg-type] - fp=io.BytesIO(b""), - ) - with ( - patch("urllib.request.urlopen", side_effect=http_error), - pytest.raises(ReportUploadError, match="HTTP 403 Forbidden"), - ): - uploader.upload("Report content") - - def test_upload_non_url_response_raises_error(self) -> None: - uploader = PasteUploader() - mock_response = MagicMock() - mock_response.status = 200 - mock_response.read.return_value = b"Something unexpected went wrong" - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(ReportUploadError, match="valid URL"), - ): - uploader.upload("Report content") - - def test_upload_network_error_raises_error(self) -> None: - uploader = PasteUploader() - url_error = urllib.error.URLError("Connection refused") - with ( - patch("urllib.request.urlopen", side_effect=url_error), - pytest.raises(ReportUploadError, match="network error"), - ): - uploader.upload("Report content") - - def test_upload_timeout_raises_error(self) -> None: - uploader = PasteUploader() - with ( - patch("urllib.request.urlopen", side_effect=TimeoutError()), - pytest.raises(ReportUploadError, match="timed out"), - ): - uploader.upload("Report content") diff --git a/legacy/tests/test_ui/test_dialog_chrome.py b/legacy/tests/test_ui/test_dialog_chrome.py deleted file mode 100644 index 2539c4b..0000000 --- a/legacy/tests/test_ui/test_dialog_chrome.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QPushButton - -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome - -_SB = QMessageBox.StandardButton - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def _click_close(dialog: QDialog) -> None: - install_dialog_chrome(dialog) - button = dialog.findChild(QPushButton, "dialogCloseBtn") - assert button is not None - button.click() - - -@pytest.mark.parametrize( - "buttons", [_SB.Yes | _SB.No | _SB.Cancel, _SB.Save | _SB.Discard | _SB.Cancel] -) -def test_message_box_close_yields_cancel(qapp: QApplication, buttons) -> None: - box = QMessageBox() - box.setStandardButtons(buttons) - _click_close(box) - assert box.result() == int(_SB.Cancel) - - -def test_plain_dialog_close_rejects(qapp: QApplication) -> None: - dialog = QDialog() - _click_close(dialog) - assert dialog.result() == QDialog.DialogCode.Rejected diff --git a/legacy/tests/test_ui/test_downloads_model.py b/legacy/tests/test_ui/test_downloads_model.py deleted file mode 100644 index 2291936..0000000 --- a/legacy/tests/test_ui/test_downloads_model.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult -from pxmodrim.ui.plugins.downloads.model import DownloadsModel - - -def _status(pid: str, status: str, done: int = 0, total: int = 0, error: str = ""): - return DownloadItemStatus(pid, status, done, total, error) - - -def _states(model: DownloadsModel) -> dict[str, str]: - return {r.pid: r.state for r in model._rows} - - -def test_states_follow_item_events_and_cancel_leftovers() -> None: - model = DownloadsModel() - model.begin(["1", "2", "3", "4", "5"], {"1": "One"}) - model.apply(_status("1", "downloading", 0, 0)) - model.apply(_status("2", "downloading", 5, 10)) - model.apply(_status("3", "downloading", 10, 10)) - model.apply(_status("4", "error", error="boom")) - model._flush() - assert _states(model) == { - "1": "unchanged", - "2": "downloading", - "3": "updated", - "4": "failed", - "5": "queued", - } - model.finish(DownloadResult(succeeded=["1", "3"], failed=["4"], changed=["3"])) - assert _states(model)["2"] == "cancelled" - assert _states(model)["5"] == "cancelled" - assert (model.updated, model.unchanged, model.failed, model.active) == (1, 1, 3, 0) - assert model.failed_ids() == ["2", "4", "5"] - - -def test_filter_limits_visible_rows() -> None: - model = DownloadsModel() - model.begin(["1", "2"], {"1": "One"}) - model.apply(_status("2", "error", error="x")) - model._flush() - model.setFilter("failed") - assert model.rowCount() == 1 - assert model.data(model.index(0), DownloadsModel._TitleRole) == "2" - model.setFilter("all") - assert model.rowCount() == 2 diff --git a/legacy/tests/test_ui/test_main_window.py b/legacy/tests/test_ui/test_main_window.py deleted file mode 100644 index 6c28f50..0000000 --- a/legacy/tests/test_ui/test_main_window.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import asyncio -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtGui import QCloseEvent -from PySide6.QtWidgets import QMessageBox - -from pxmodrim.ui.window import main_window as main_window_module -from pxmodrim.ui.window.main_window import MainWindow, UnsavedChangesDialog - - -def test_view_shortcuts_wrap_and_ignore_invalid_indexes() -> None: - selections: list[int] = [] - - class Stack: - index = 0 - - def count(self) -> int: - return 3 - - def currentIndex(self) -> int: - return self.index - - stack = Stack() - - class Rail: - def set_current(self, index: int) -> None: - selections.append(index) - - views = [ - SimpleNamespace(view_id=v, open_route=lambda path: None) - for v in ("a", "b", "c") - ] - - class Window: - _stack = stack - _rail = Rail() - _views = views - - def __init__(self) -> None: - self._app_ctx = SimpleNamespace( - navigate=lambda url: MainWindow._open_route(cast(MainWindow, self), url) - ) - - def _select_view(self, index: int) -> None: - MainWindow._select_view(cast(MainWindow, self), index) - - def _show_view(self, index: int) -> None: - MainWindow._show_view(cast(MainWindow, self), index) - - def _on_rail_tab_changed(self, index: int) -> None: - stack.index = index - - window = cast(MainWindow, Window()) - MainWindow._cycle_view(window, -1) - assert selections == [2] - assert stack.index == 2 - MainWindow._cycle_view(window, 1) - assert selections == [2, 0] - assert stack.index == 0 - MainWindow._select_view(window, 3) - assert selections == [2, 0] - - -def test_route_selects_the_view_then_hands_it_the_path() -> None: - opened: list[tuple[str, ...]] = [] - shown: list[int] = [] - - def view(view_id: str) -> SimpleNamespace: - return SimpleNamespace( - view_id=view_id, - open_route=lambda path, v=view_id: opened.append((v, *path)), - ) - - window = cast( - MainWindow, - SimpleNamespace( - _views=[view("mods"), view("workshop")], _show_view=shown.append - ), - ) - MainWindow._open_route(window, "modrim://workshop/mod/42") - MainWindow._open_route(window, "modrim://missing/x") - MainWindow._open_route(window, "workshop") - assert shown == [1] - assert opened == [("workshop", "mod", "42")] - - -def test_settings_route_opens_settings_without_switching_views() -> None: - shown: list[int] = [] - opened: list[bool] = [] - window = cast( - MainWindow, - SimpleNamespace( - _views=[], - _show_view=shown.append, - _start_settings=lambda: opened.append(True), - ), - ) - MainWindow._open_route(window, "modrim://settings") - assert opened == [True] - assert shown == [] - - -def test_dirty_state_tracks_active_list_order_and_updates_header() -> None: - class Header: - unsaved_changes = False - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - header = Header() - context = SimpleNamespace(active_uuids=["uuid-a", "uuid-b"]) - window = cast( - MainWindow, - SimpleNamespace( - _ctx=context, - _saved_active_uuids=["uuid-a", "uuid-b"], - _unsaved_changes=False, - _header_controller=header, - setWindowModified=lambda _modified: None, - ), - ) - - context.active_uuids.reverse() - MainWindow._refresh_unsaved_state(window) - assert header.unsaved_changes is True - - context.active_uuids.reverse() - MainWindow._refresh_unsaved_state(window) - assert header.unsaved_changes is False - - -def test_reload_baseline_is_list_loaded_from_disk_not_later_edits() -> None: - class Header: - unsaved_changes = False - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - class Window(SimpleNamespace): - def _refresh_unsaved_state(self) -> None: - MainWindow._refresh_unsaved_state(cast(MainWindow, self)) - - header = Header() - # The user deactivated uuid-b after ctx.load() but before mods_changed fired. - context = SimpleNamespace( - active_uuids=["uuid-a"], loaded_active_uuids=["uuid-a", "uuid-b"] - ) - window = cast( - MainWindow, - Window( - _ctx=context, - _saved_active_uuids=[], - _unsaved_changes=False, - _header_controller=header, - setWindowModified=lambda _modified: None, - ), - ) - - MainWindow._on_mods_reloaded(window) - - assert header.unsaved_changes is True - - -@pytest.mark.asyncio -async def test_successful_save_clears_unsaved_state() -> None: - class ModService: - async def save_active_layout(self, _active_uuids: list[str]) -> bool: - return True - - class Header: - unsaved_changes = True - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - class Window(SimpleNamespace): - def _refresh_unsaved_state(self) -> None: - MainWindow._refresh_unsaved_state(cast(MainWindow, self)) - - async def _write_active_layout(self) -> list[str] | None: - return await MainWindow._write_active_layout(cast(MainWindow, self)) - - header = Header() - context = SimpleNamespace( - active_uuids=["uuid-b", "uuid-a"], mod_service=ModService() - ) - window = cast( - MainWindow, - Window( - _ctx=context, - _saved_active_uuids=["uuid-a", "uuid-b"], - _unsaved_changes=True, - _header_controller=header, - setWindowModified=lambda _modified: None, - _toast_manager=SimpleNamespace( - success=lambda *_args: None, warning=lambda *_args: None - ), - mod_list=SimpleNamespace(active_uuids=lambda: ["uuid-b", "uuid-a"]), - ), - ) - - assert await MainWindow._save_active_mods(window) is True - assert header.unsaved_changes is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("choice", "save_succeeds", "expected_closed"), - [ - (QMessageBox.StandardButton.Save, True, True), - (QMessageBox.StandardButton.Save, False, False), - (QMessageBox.StandardButton.Discard, True, True), - (QMessageBox.StandardButton.Cancel, True, False), - ], -) -async def test_async_close_choice( - monkeypatch: pytest.MonkeyPatch, - choice: QMessageBox.StandardButton, - save_succeeds: bool, - expected_closed: bool, -) -> None: - async def choose( - dialog: type[QMessageBox], _parent: object - ) -> tuple[QMessageBox.StandardButton, None]: - assert dialog is UnsavedChangesDialog - return choice, None - - monkeypatch.setattr(main_window_module, "await_dialog", choose) - - class Window: - _close_prompt_open = True - _close_confirmed = False - closed = False - save_calls = 0 - - async def _save_active_mods(self) -> bool: - self.save_calls += 1 - return save_succeeds - - def close(self) -> None: - self.closed = True - - window = Window() - await MainWindow._confirm_close(cast(MainWindow, window)) - - assert window.closed is expected_closed - assert window._close_confirmed is expected_closed - assert window.save_calls == (choice == QMessageBox.StandardButton.Save) - - -class _CloseEvent: - ignored = False - accepted = False - - def ignore(self) -> None: - self.ignored = True - - def accept(self) -> None: - self.accepted = True - - -@pytest.mark.asyncio -async def test_dirty_close_event_is_ignored_and_prompts_once() -> None: - prompts = 0 - - class Window: - _unsaved_changes = True - _close_confirmed = False - _close_prompt_open = False - _close_task: asyncio.Task[None] | None = None - - async def _confirm_close(self) -> None: - nonlocal prompts - prompts += 1 - - window = Window() - first, second = _CloseEvent(), _CloseEvent() - - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, first)) - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, second)) - assert window._close_task is not None - await window._close_task - - assert first.ignored and second.ignored - assert not first.accepted - assert prompts == 1 - - -def test_confirmed_dirty_close_event_is_accepted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - main_window_module, "QApplication", SimpleNamespace(instance=lambda: None) - ) - quit_calls: list[None] = [] - window = SimpleNamespace( - _unsaved_changes=True, - _close_confirmed=True, - _views=[], - _update_task=None, - _launch_task=None, - _app_quit_callback=lambda: quit_calls.append(None), - deleteLater=lambda: None, - ) - event = _CloseEvent() - - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, event)) - - assert event.accepted and not event.ignored - assert quit_calls == [None] diff --git a/legacy/tests/test_ui/test_menu_bar.py b/legacy/tests/test_ui/test_menu_bar.py deleted file mode 100644 index 7deadbc..0000000 --- a/legacy/tests/test_ui/test_menu_bar.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtGui import QKeySequence -from PySide6.QtWidgets import QApplication, QMainWindow, QMenu - -from pxmodrim.ui.window.actions import ( - ACTION_SPECS, - ActionId, - create_actions, - shortcut_rows, -) -from pxmodrim.ui.window.menu_bar import MENU_LAYOUT, MenuBar - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_every_action_appears_in_exactly_one_menu() -> None: - placed = [a for _, ids in MENU_LAYOUT for a in ids if a is not None] - - assert sorted(placed) == sorted(ActionId) - - -def test_shortcuts_are_unique() -> None: - shortcuts = [spec.shortcut for spec in ACTION_SPECS if spec.shortcut] - - assert len(shortcuts) == len(set(shortcuts)) - - -def test_shortcut_dialog_rows_have_no_duplicates_and_collapse_view_switching() -> None: - rows = shortcut_rows() - - assert len(rows) == len(set(rows)) - assert sum(1 for _, label in rows if label == "Switch to view") == 1 - assert {label for _, label in rows} >= {"Save mod list", "Refresh mods"} - - -def test_menu_actions_carry_shortcuts_and_fire_while_menu_bar_hidden( - qapp: QApplication, -) -> None: - window = QMainWindow() - actions = create_actions(window) - menu_bar = MenuBar(actions, window) - menu_bar.hide() - triggered: list[bool] = [] - actions[ActionId.SAVE].triggered.connect(lambda: triggered.append(True)) - - file_menu = next( - menu for menu in menu_bar.findChildren(QMenu) if menu.title() == "&File" - ) - save = next(a for a in file_menu.actions() if a.text() == "&Save Mod List") - - assert save.shortcut() == QKeySequence("Ctrl+S") - assert save in window.actions() - save.trigger() - assert triggered == [True] diff --git a/legacy/tests/test_ui/test_mod_info_data.py b/legacy/tests/test_ui/test_mod_info_data.py deleted file mode 100644 index 023662d..0000000 --- a/legacy/tests/test_ui/test_mod_info_data.py +++ /dev/null @@ -1,311 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any, cast - -import pytest - -from pxmodrim.core.downloads import DownloadManager -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.models.view.diagnostics import ModIssueView -from pxmodrim.core.plugin import PluginRegistry -from pxmodrim.ui.panels.mod_info_data import ( - IMG_WIDTH_TOKEN, - _conflicts, - _needed_by, - _needs, - _pid_index, - autolink, - bbcode_images, - bbcode_markup, - build_mod_info, - description_to_html, - restore_entities, -) - - -def make_mod( - name: str, - pid: str, - deps: list[tuple[str, list[str]]] | None = None, - incompatible: list[str] | None = None, -) -> AboutXmlMod: - rules = BaseRules() - for dep_pid, alts in deps or []: - rules.dependencies[CaseInsensitiveStr(dep_pid)] = DependencyMod( - name=dep_pid, - package_id=CaseInsensitiveStr(dep_pid), - alternative_package_ids={CaseInsensitiveStr(a) for a in alts}, - ) - for i in incompatible or []: - rules.incompatible_with.add(CaseInsensitiveStr(i)) - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - about_rules=rules, - description="", - ) - - -def make_ctx(mods: list[ListedMod], active: list[str]) -> Any: - registry = PluginRegistry() - registry.register(DownloadManager()) - return cast( - Any, - SimpleNamespace( - all_mods={m.uuid: m for m in mods}, - active_uuids=active, - plugins=registry, - target_version="1.5", - ), - ) - - -class TestAutolink: - def test_bare_url_becomes_anchor(self) -> None: - assert autolink("see https://a.com/x?y=1 now") == ( - 'see https://a.com/x?y=1 now' - ) - - def test_trailing_punctuation_stays_outside(self) -> None: - assert autolink("go to https://a.com.") == ( - 'go to https://a.com.' - ) - - def test_existing_anchor_is_untouched(self) -> None: - html = 'https://b.com' - assert autolink(html) == html - - -class TestBbcodeImages: - def test_img_tag_becomes_width_constrained_image(self) -> None: - assert bbcode_images("[img]https://i.imgur.com/a.png[/img]\nhi") == ( - f'\nhi' - ) - - def test_image_url_is_not_autolinked_afterwards(self) -> None: - html = autolink(bbcode_images("[img]https://i.imgur.com/a.png[/img]")) - assert " None: - assert bbcode_markup("[url=https://a.com/w]the wiki[/url]") == ( - 'the wiki' - ) - - def test_image_inside_url_stays_inside_anchor(self) -> None: - html = bbcode_markup( - bbcode_images( - "[url=https://discord.gg/x][img]https://i.imgur.com/a.png[/img][/url]" - ) - ) - assert html.startswith('https://a.com/x" ok' - ) - - def test_angle_bracketed_url_excludes_entity(self) -> None: - assert description_to_html("") == ( - '<https://a.com/x>' - ) - - def test_ampersand_in_query_is_kept(self) -> None: - assert autolink("https://a.com/?a=1&b=2") == ( - 'https://a.com/?a=1&b=2' - ) - - -class TestDescription: - def test_rich_description_preserves_formatting_entities_and_images(self) -> None: - assert description_to_html( - "[h2]Title[/h2]\nBold •\n" - "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki" - ) == ( - "

    Title

    Bold •
    " - f'
    ' - 'https://a.com/wiki' - ) - - def test_plaintext_is_literal_and_preserves_line_breaks(self) -> None: - assert description_to_html( - "Literal & text\n[img]https://a.com/image.png[/img]\n" - "[url=https://a.com]wiki[/url]", - "plaintext", - ) == ( - "<b>Literal & text</b>
    " - "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]" - ) - - -class TestSecurity: - def test_file_img_is_not_an_image(self) -> None: - assert " None: - html = description_to_html("[img]http://a.com/i.png[/img]") - assert "' in html - - def test_javascript_url_tag_has_no_anchor(self) -> None: - assert " None: - html = description_to_html('x y') - assert " None: - html = description_to_html( - f"[url={url}]link[/url] [url]{url}[/url] [img]{url}[/img] " - f"Unity link" - ) - assert " None: - html = description_to_html('') - assert html == "<img src="file:///etc/passwd">" - - -class TestNeeds: - def test_states_and_alternatives(self) -> None: - dep_active = make_mod("Active", "a.active") - dep_inactive = make_mod("Inactive", "a.inactive") - alt = make_mod("Alt", "a.alt") - main = make_mod( - "Main", - "a.main", - deps=[ - ("a.active", []), - ("A.Inactive", []), - ("a.missing", ["a.alt"]), - ("a.gone", []), - ], - ) - ctx = make_ctx([main, dep_active, dep_inactive, alt], [dep_active.uuid]) - result = _needs(main, _pid_index(ctx), {dep_active.uuid}) - assert {r["name"]: (r["state"], r["uuid"]) for r in result} == { - "a.active": ("active", dep_active.uuid), - "A.Inactive": ("inactive", dep_inactive.uuid), - "a.missing": ("inactive", alt.uuid), - "a.gone": ("missing", ""), - } - - def test_conflicts_only_lists_active_sorted(self) -> None: - b = make_mod("beta", "x.b") - a = make_mod("Alpha", "x.a") - off = make_mod("Off", "x.off") - main = make_mod("Main", "x.main", incompatible=["X.B", "x.a", "x.off", "x.no"]) - ctx = make_ctx([main, a, b, off], [a.uuid, b.uuid]) - assert _conflicts(main, _pid_index(ctx), {a.uuid, b.uuid}) == ["Alpha", "beta"] - - def test_needed_by_matches_alternatives_and_sorts(self) -> None: - target = make_mod("Target", "t.target") - z = make_mod("zed", "t.z", deps=[("t.target", [])]) - a = make_mod("Apple", "t.a", deps=[("t.other", ["T.Target"])]) - unrelated = make_mod("Unrelated", "t.u", deps=[("t.other", [])]) - ctx = make_ctx([target, z, a, unrelated], []) - assert _needed_by(target, ctx) == [ - {"name": "Apple", "uuid": a.uuid}, - {"name": "zed", "uuid": z.uuid}, - ] - - -class TestBuildModInfo: - def test_active_mod_with_issue(self) -> None: - dep = make_mod("Dep", "d.dep") - main = make_mod("Main", "m.main", deps=[("d.dep", [])]) - main.description = "hello x https://a.com/x" - main.authors = ["Ann"] - other = make_mod("Other", "o.other") - ctx = make_ctx([dep, main, other], [other.uuid, main.uuid]) - issue = ModIssueView( - category="c", category_display_name="Cat", detail=None, is_error=False - ) - info = build_mod_info(ctx, main, [issue]) - assert info["name"] == "Main" - assert info["author"] == "Ann" - assert info["status"]["level"] == "warning" - assert info["status"]["title"] == "1 warning" - assert info["status"]["active"] is True - assert info["status"]["position"] == 2 - assert info["status"]["total"] == 2 - assert info["status"]["issues"] == [ - {"title": "Cat", "detail": "", "isError": False} - ] - assert info["needs"] == [ - {"name": "d.dep", "state": "inactive", "uuid": dep.uuid} - ] - assert '' in info["description"] - assert info["version"] == {"known": False, "ok": False, "target": "1.5"} - - def test_inactive_mod_without_description(self) -> None: - mod = make_mod("Solo", "s.solo") - info = build_mod_info(make_ctx([mod], []), mod, []) - assert info["status"]["level"] == "ok" - assert info["status"]["active"] is False - assert info["status"]["position"] == 0 - assert info["description"] == "" - assert info["neededBy"] == [] diff --git a/legacy/tests/test_ui/test_mod_list_model.py b/legacy/tests/test_ui/test_mod_list_model.py deleted file mode 100644 index 1cc916b..0000000 --- a/legacy/tests/test_ui/test_mod_list_model.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtCore import QCoreApplication, QPersistentModelIndex, Qt -from PySide6.QtWidgets import QApplication - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.ui.models.mod_list_model import ModListModel - - -def _provider_colors() -> dict[str, str]: - return {"stub": "#abcdef"} - - -def _mod(name: str) -> AboutXmlMod: - return AboutXmlMod(name=name, provider_id="stub", valid=True) - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -def model(qapp: QApplication) -> ModListModel: - colors = _provider_colors() - m = ModListModel(colors) - mods: dict[str, ListedMod] = {f"uuid-{i}": _mod(f"Mod {i}") for i in range(5)} - active = ["uuid-0", "uuid-2"] - m.load_mods(mods, active) - return m - - -class TestLoadMods: - def test_active_uuid_missing_from_mods_is_skipped(self, qapp: QApplication) -> None: - m = ModListModel(_provider_colors()) - m.load_mods({"a": _mod("A")}, ["ghost", "a"]) - assert m.active_uuids() == ["a"] - - -class TestSetData: - def test_toggle_check_emits_dataChanged_not_reset( - self, model: ModListModel - ) -> None: - resets: list[None] = [] - model.modelReset.connect(lambda: resets.append(None)) - data_changes: list[None] = [] - model.dataChanged.connect(lambda *_: data_changes.append(None)) - - inactive_rows = [i for i, it in enumerate(model._items) if not it.checked] - idx = model.index(inactive_rows[0], 0) - ok = model.setData(idx, Qt.CheckState.Checked, ModListModel.CheckStateRole) - assert ok is True - item = model.get_item(inactive_rows[0]) - assert item is not None - assert item.checked is True - assert data_changes - assert resets == [] - - def test_toggle_unchanged_is_noop(self, model: ModListModel) -> None: - active_rows = [i for i, it in enumerate(model._items) if it.checked] - idx = model.index(active_rows[0], 0) - data_changes: list[None] = [] - model.dataChanged.connect(lambda *_: data_changes.append(None)) - ok = model.setData(idx, Qt.CheckState.Checked, ModListModel.CheckStateRole) - assert ok is True - assert data_changes == [] - - -class TestCommitOrder: - def test_reorder_subset_preserves_positions(self, model: ModListModel) -> None: - before = [it.uuid for it in model._items] - # _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - # reorder active mods uuid-0 and uuid-2 within their positions - model.commitOrder(["uuid-2", "uuid-0"]) - after = [it.uuid for it in model._items] - # uuid-2 swaps into uuid-0's position (0), uuid-0 goes to uuid-2's position (1) - assert after == ["uuid-2", "uuid-0", "uuid-1", "uuid-3", "uuid-4"] - assert set(after) == set(before) - - def test_reorder_full_list(self, model: ModListModel) -> None: - before = [it.uuid for it in model._items] - target = list(reversed(before)) - model.commitOrder(target) - assert [it.uuid for it in model._items] == target - - def test_noop_on_same_order(self, model: ModListModel) -> None: - layout_changes: list[None] = [] - model.layoutChanged.connect(lambda *_: layout_changes.append(None)) - model.commitOrder([it.uuid for it in model._items]) - assert layout_changes == [] - - def test_layout_signals_wrap_reorder_and_preserve_persistent_index( - self, model: ModListModel - ) -> None: - before = [item.uuid for item in model._items] - target = list(reversed(before)) - persistent_index = QPersistentModelIndex(model.index(0, 0)) - uuid = persistent_index.data(ModListModel.UuidRole) - events: list[tuple[str, list[str]]] = [] - - model.layoutAboutToBeChanged.connect( - lambda *_: events.append( - ("about-to-change", [item.uuid for item in model._items]) - ) - ) - model.layoutChanged.connect( - lambda *_: events.append(("changed", [item.uuid for item in model._items])) - ) - - model.commitOrder(target) - - assert events == [("about-to-change", before), ("changed", target)] - assert persistent_index.data(ModListModel.UuidRole) == uuid - - -class TestActiveUuids: - def test_returns_only_checked_in_order(self, model: ModListModel) -> None: - active = model.active_uuids() - assert set(active) == {"uuid-0", "uuid-2"} - assert active == ["uuid-0", "uuid-2"] - - -class TestLoadIndex: - def test_initial_load_indices(self, model: ModListModel) -> None: - indices = [ - model.data(model.index(r, 0), ModListModel.LoadIndexRole) - for r in range(model.rowCount()) - ] - assert indices == [1, 2, -1, -1, -1] - - def test_load_index_after_reorder(self, model: ModListModel) -> None: - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _t, _b, roles: changed_roles.append(roles)) - - ok = model.move_row(0, 1) - assert ok is True - - indices = [ - model.data(model.index(r, 0), ModListModel.LoadIndexRole) - for r in range(model.rowCount()) - ] - assert indices == [1, 2, -1, -1, -1] - assert model.data(model.index(0, 0), ModListModel.UuidRole) == "uuid-2" - assert model.data(model.index(1, 0), ModListModel.UuidRole) == "uuid-0" - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - def test_load_index_after_toggle(self, model: ModListModel) -> None: - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _t, _b, roles: changed_roles.append(roles)) - - idx2 = model.index(2, 0) - assert model.data(idx2, ModListModel.LoadIndexRole) == -1 - model.setData(idx2, Qt.CheckState.Checked, ModListModel.CheckStateRole) - - assert model.data(idx2, ModListModel.LoadIndexRole) == 3 - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - changed_roles.clear() - idx0 = model.index(0, 0) - model.setData(idx0, Qt.CheckState.Unchecked, ModListModel.CheckStateRole) - - assert model.data(idx0, ModListModel.LoadIndexRole) == -1 - assert model.data(model.index(1, 0), ModListModel.LoadIndexRole) == 1 - assert model.data(model.index(2, 0), ModListModel.LoadIndexRole) == 2 - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - -class TestProviderLabel: - def test_provider_label_mapping(self, qapp: QApplication) -> None: - m = ModListModel({}) - mods: dict[str, ListedMod] = { - "uuid-steam": AboutXmlMod( - name="Workshop Mod", provider_id="steam", valid=True - ), - "uuid-downloaded": AboutXmlMod( - name="Downloaded Mod", provider_id="downloaded", valid=True - ), - "uuid-local": AboutXmlMod( - name="Local Mod", provider_id="local", valid=True - ), - "uuid-core": AboutXmlMod( - name="Core", - package_id=CaseInsensitiveStr("ludeon.rimworld"), - provider_id="core", - valid=True, - ), - "uuid-dlc": AboutXmlMod( - name="Royalty", - package_id=CaseInsensitiveStr("ludeon.rimworld.royalty"), - provider_id="core", - valid=True, - ), - } - m.load_mods(mods, list(mods.keys())) - - labels = { - m.data(m.index(r, 0), ModListModel.UuidRole): m.data( - m.index(r, 0), ModListModel.ProviderLabelRole - ) - for r in range(m.rowCount()) - } - assert labels["uuid-steam"] == "Workshop" - assert labels["uuid-downloaded"] == "Workshop" - assert labels["uuid-local"] == "Local" - assert labels["uuid-core"] == "Core" - assert labels["uuid-dlc"] == "DLC" - - -def test_setting_startup_impact_on_empty_model_emits_no_data_changed( - qapp: QApplication, -) -> None: - model = ModListModel(_provider_colors()) - changes: list[None] = [] - model.dataChanged.connect(lambda *_: changes.append(None)) - - model.set_startup_impact({"example.mod": 1.0}) - - assert changes == [] diff --git a/legacy/tests/test_ui/test_mod_list_panel.py b/legacy/tests/test_ui/test_mod_list_panel.py deleted file mode 100644 index d69fce3..0000000 --- a/legacy/tests/test_ui/test_mod_list_panel.py +++ /dev/null @@ -1,456 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtCore import QCoreApplication, QObject, QPoint, Qt, Signal -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication -from pytestqt.qtbot import QtBot - -from pxmodrim.core.checker.graph import ConstraintGraph, PackageId -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.activation_service import ActivationService -from pxmodrim.core.sort.config import SortSettings -from pxmodrim.ui.components import mod_activation -from pxmodrim.ui.components.svg_provider import create_qml_engine -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel -from pxmodrim.ui.panels.mod_list_panel import ModListPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture(scope="module") -def qml_engine(qapp: QApplication) -> Iterator[QQmlEngine]: - engine = create_qml_engine() - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - yield engine - - -class _Diagnostics(QObject): - diagnostics_summary_changed = Signal(dict) - - def summary_for(self, uuid: str) -> None: - return None - - -class _ModService(QObject): - mods_changed = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self.provider_colors: dict[str, str] = {} - - -class _WidgetContext(QObject): - active_state_changed = Signal(object) - - def __init__(self, mods: dict[str, ListedMod]) -> None: - super().__init__() - self.all_mods = mods - self.active_uuids: list[str] = [] - self.diagnostics_service = _Diagnostics(self) - self.mod_service = _ModService(self) - - def set_active(self, uuids: list[str]) -> None: - self.active_uuids = list(uuids) - - -def _widget_panel( - qml_engine: QQmlEngine, active_uuids: list[str] | None = None -) -> ModListPanel: - mods = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b"), - "uuid-c": _mod("Mod C", "mod.c"), - } - ctx = _WidgetContext(cast(dict[str, ListedMod], mods)) - panel = ModListPanel(cast(CoreContext, ctx), qml_engine) - panel.load_mods(cast(dict[str, ListedMod], mods), active_uuids or []) - return panel - - -def _qml_list_property(list_view: QObject, name: str) -> object: - value = list_view.property(name) - return value.toVariant() if hasattr(value, "toVariant") else value - - -def _mod(name: str, package_id: str, dependency: str = "") -> AboutXmlMod: - dependencies = {} - if dependency: - dependencies[CaseInsensitiveStr(dependency)] = DependencyMod( - name=dependency, - package_id=CaseInsensitiveStr(dependency), - ) - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(package_id), - provider_id="stub", - about_rules=BaseRules(dependencies=dependencies), - ) - - -def _panel(qapp: QApplication) -> ModListPanel: - mods = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b", "mod.a"), - "uuid-c": _mod("Mod C", "mod.c", "mod.b"), - } - graph = ConstraintGraph() - graph.build( - {PackageId(str(mod.package_id)): mod for mod in mods.values()}, - [PackageId("mod.a"), PackageId("mod.b"), PackageId("mod.c")], - SortSettings(use_community_rules=False, use_alternative_package_ids=False), - ) - ctx = SimpleNamespace( - all_mods=mods, - diagnostics_service=SimpleNamespace( - constraint_graph=graph, community_rules=None - ), - active_uuids=["uuid-a", "uuid-b", "uuid-c"], - config=AppConfig(), - ) - - panel = ModListPanel.__new__(ModListPanel) - panel._highlighted = frozenset() - panel._highlight_revision = 0 - panel._highlight_generation = 0 - panel._qml = cast(QQuickWidget, SimpleNamespace(rootObject=lambda: None)) - panel._ctx = cast(CoreContext, ctx) - panel._model = ModListModel({}) - panel._proxy = ModListProxyModel(panel._model) - panel._model.load_mods(cast(dict[str, ListedMod], mods), ctx.active_uuids) - - def set_active(uuids: list[str]) -> None: - ctx.active_uuids = list(uuids) - panel._on_core_state_changed(tuple(uuids)) - - ctx.set_active = set_active - ctx.activation = ActivationService(cast(CoreContext, ctx)) - return panel - - -@pytest.mark.asyncio -async def test_disabling_dependency_can_leave_dependents_active( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - calls: list[list[str]] = [] - - async def choose(*args: object) -> tuple[object, object]: - calls.append(list(args[1])) # type: ignore[arg-type] - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0]) - - assert panel.active_uuids() == ["uuid-b", "uuid-c"] - assert calls == [["Mod B (mod.b)", "Mod C (mod.c)"]] - - -@pytest.mark.asyncio -async def test_batch_disable_all_removes_transitive_dependents( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.Yes, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0, 1]) - - assert panel.active_uuids() == [] - - -@pytest.mark.asyncio -async def test_cancel_keeps_selected_mod_and_dependents_active( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.Cancel, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0]) - - assert panel.active_uuids() == ["uuid-a", "uuid-b", "uuid-c"] - - -@pytest.mark.asyncio -async def test_reenabled_mod_is_placed_between_dependency_and_dependent( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([1]) - assert panel.active_uuids() == ["uuid-a", "uuid-c"] - - await panel._toggle_rows([panel.rowForUuid("uuid-b")]) - - assert panel.active_uuids() == ["uuid-a", "uuid-b", "uuid-c"] - assert panel._ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c"] - - -@pytest.mark.asyncio -async def test_disabled_mod_leaves_active_block( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def keep_dependents(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", keep_dependents) - await panel._toggle_rows([panel.rowForUuid("uuid-b")]) - - rows = [(item.uuid, item.checked, item.load_index) for item in panel._model._items] - assert rows == [ - ("uuid-a", True, 1), - ("uuid-c", True, 2), - ("uuid-b", False, -1), - ] - - -def test_hovering_drag_handle_does_not_show_drag_proxy( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - drag_proxy = root.findChild(QObject, "dragProxy") - assert drag_proxy is not None - - QTest.mouseMove(panel._qml, QPoint(4, 26)) - QTest.mouseMove(panel._qml, QPoint(14, 26)) - - assert drag_proxy.property("visible") is False - - -def test_small_jitter_on_drag_handle_does_not_start_drag( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - drag_proxy = root.findChild(QObject, "dragProxy") - assert drag_proxy is not None - - QTest.mousePress( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(20, 26), - ) - QTest.mouseMove(panel._qml, QPoint(21, 26)) - visible = drag_proxy.property("visible") - QTest.mouseRelease( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(21, 26), - ) - - assert visible is False - - -@pytest.mark.asyncio -async def test_highlighting_enabled_mod_keeps_user_selection( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - - panel._highlight_rows(["uuid-c"]) - panel.set_search_filter("Mod") - panel.set_search_filter("") - - assert panel.isHighlighted("uuid-c") is True - assert panel.isHighlighted("uuid-a") is False - assert _qml_list_property(list_view, "selectedUuids") == ["uuid-a"] - assert list_view.property("currentUuid") == "uuid-a" - - -def test_highlight_tints_selected_row(qml_engine: QQmlEngine) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - - def colors() -> set[str]: - items = list_view.property("contentItem").childItems() - return {c.property("color").name() for c in items if c.property("color")} - - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - QTest.qWait(0) - before = colors() - - panel._set_highlighted(["uuid-a"]) - QTest.qWait(0) - after = colors() - - assert before != after - - -def test_qml_selection_clears_when_filter_hides_current_row( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - empty_state = root.findChild(QObject, "emptyStateText") - assert empty_state is not None - assert empty_state.property("visible") is False - root.selectRow(2, "uuid-c", 0) # type: ignore[attr-defined] - panel.set_search_filter("Mod A") - assert list_view.property("count") == 1 - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - - panel.set_search_filter("not present") - assert list_view.property("count") == 0 - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - assert empty_state.property("visible") is True - - -def test_qml_selection_follows_uuid_when_selected_row_moves( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - - root.selectRow(2, "uuid-c", 0) # type: ignore[attr-defined] - panel.moveRow(2, 0) - - assert panel.uuidAt(0) == "uuid-c" - assert list_view.property("currentIndex") == 0 - assert _qml_list_property(list_view, "selectedIndices") == [0] - assert _qml_list_property(list_view, "selectedUuids") == ["uuid-c"] - - -def test_search_focus_disables_list_keyboard_actions( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - - QTest.mouseClick( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(100, 26), - ) - qtbot.waitUntil(lambda: root.property("keyboardActive") is True) - assert root.property("keyboardActive") is True - panel.search_input.setFocus() - qtbot.waitUntil(lambda: root.property("keyboardActive") is False) - QTest.keyClick(panel.search_input, Qt.Key.Key_Return) - assert panel.active_uuids() == [] - - -def test_space_in_search_inserts_text_without_toggling_mods( - qml_engine: QQmlEngine, qtbot: QtBot -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - - QTest.mouseClick( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(100, 26), - ) - qtbot.waitUntil(lambda: root.property("keyboardActive") is True) - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - active_before = panel.active_uuids() - panel.search_input.setFocus() - qtbot.waitUntil(lambda: root.property("keyboardActive") is False) - - assert root.property("keyboardActive") is False - QTest.keyClick(panel.search_input, Qt.Key.Key_Space) - - assert panel.search_input.text() == " " - assert panel.active_uuids() == active_before - - -@pytest.mark.asyncio -async def test_mod_selection_presenter_clears_on_empty_uuid() -> None: - from unittest.mock import MagicMock - - from pxmodrim.ui.mod_selection import ModSelectionPresenter - - panel_mock = MagicMock() - ctx = _WidgetContext({}) - presenter = ModSelectionPresenter(cast(CoreContext, ctx), panel_mock) - await presenter.show("") - panel_mock.clear.assert_called_once() diff --git a/legacy/tests/test_ui/test_mod_list_proxy_model.py b/legacy/tests/test_ui/test_mod_list_proxy_model.py deleted file mode 100644 index 5c3d08b..0000000 --- a/legacy/tests/test_ui/test_mod_list_proxy_model.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtCore import QCoreApplication, QPersistentModelIndex -from PySide6.QtWidgets import QApplication - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel - - -def _provider_colors() -> dict[str, str]: - return {"stub": "#abcdef"} - - -def _mod(name: str, pid: str = "") -> AboutXmlMod: - package_id = CaseInsensitiveStr(pid or name.lower()) - return AboutXmlMod(name=name, package_id=package_id, provider_id="stub", valid=True) - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -def source(qapp: QApplication) -> ModListModel: - colors = _provider_colors() - m = ModListModel(colors) - mods: dict[str, ListedMod] = { - f"uuid-{i}": _mod(f"Mod {i}", f"mod.{i}") for i in range(5) - } - active = ["uuid-0", "uuid-2"] - m.load_mods(mods, active) - return m - - -@pytest.fixture -def proxy(source: ModListModel) -> ModListProxyModel: - return ModListProxyModel(source) - - -class TestSidebarFilter: - def test_no_filter_shows_all(self, proxy: ModListProxyModel) -> None: - assert proxy.rowCount() == 5 - - def test_filter_to_subset(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-2", "uuid-4"}) - assert proxy.rowCount() == 3 - - rows = [ - proxy.mapToSource(proxy.index(i, 0)).row() for i in range(proxy.rowCount()) - ] - # source _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert rows == [0, 1, 4] - - def test_filter_to_empty(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter(set()) - assert proxy.rowCount() == 0 - - def test_filter_reset_to_none(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0"}) - assert proxy.rowCount() == 1 - proxy.set_sidebar_filter(None) - assert proxy.rowCount() == 5 - - -class TestSearchFilter: - def test_search_by_name(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("Mod 1") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - # source _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert src_row == 2 - - def test_search_by_package_id(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("mod.3") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - assert src_row == 3 - - def test_search_empty_shows_all(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("") - assert proxy.rowCount() == 5 - - def test_search_no_match(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("zzzzzz") - assert proxy.rowCount() == 0 - - def test_search_case_insensitive(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("MOD 2") - assert proxy.rowCount() == 1 - - def test_search_whitespace_only_shows_all(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter(" \t \n ") - assert proxy.rowCount() == 5 - - def test_search_normalized_unicode_and_whitespace(self, qapp: QApplication) -> None: - colors = _provider_colors() - source = ModListModel(colors) - mods: dict[str, ListedMod] = { - "uuid-u": _mod("Große Mod", "grosse.mod"), - "uuid-p": _mod(" Padded Mod ", " padded.pkg "), - } - source.load_mods(mods, ["uuid-u", "uuid-p"]) - proxy = ModListProxyModel(source) - - proxy.set_search_filter(" GROßE ") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 0 - - proxy.set_search_filter(" padded.pkg ") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 1 - - -class TestCombinedFilter: - def test_sidebar_and_search(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-2", "uuid-3"}) - proxy.set_search_filter("Mod 2") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - # uuid-2 is at source row 1 in [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert src_row == 1 - - def test_sidebar_excludes_search_match(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-1"}) - proxy.set_search_filter("Mod 3") - assert proxy.rowCount() == 0 - - def test_clearing_search_returns_to_sidebar(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-2"}) - proxy.set_search_filter("Mod 2") - assert proxy.rowCount() == 1 - proxy.set_search_filter("") - assert proxy.rowCount() == 2 - - -class TestMapToSource: - def test_round_trip(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-2", "uuid-4"}) - for proxy_row in range(proxy.rowCount()): - proxy_index = proxy.index(proxy_row, 0) - src_index = proxy.mapToSource(proxy_index) - assert src_index.isValid() - src_row = src_index.row() - mapped_back = proxy.mapFromSource(src_index) - assert mapped_back == proxy_index - - def test_mapping_after_move_row(self, proxy: ModListProxyModel) -> None: - assert proxy.move_row(0, 2) - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert src_idx.isValid() - assert proxy.mapFromSource(src_idx).row() == r - - def test_mapping_after_filter_rebuild( - self, proxy: ModListProxyModel, source: ModListModel - ) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-3"}) - assert proxy.rowCount() == 2 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 2 - assert proxy.mapToSource(proxy.index(1, 0)).row() == 3 - assert proxy.mapFromSource(source.index(2, 0)).row() == 0 - assert proxy.mapFromSource(source.index(3, 0)).row() == 1 - assert not proxy.mapFromSource(source.index(0, 0)).isValid() - - proxy.set_sidebar_filter(None) - proxy.set_search_filter("Mod 4") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 4 - assert proxy.mapFromSource(source.index(4, 0)).row() == 0 - assert not proxy.mapFromSource(source.index(2, 0)).isValid() - - proxy.set_search_filter("") - assert proxy.rowCount() == 5 - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert proxy.mapFromSource(src_idx).row() == r - - def test_mapping_after_move_with_active_filter( - self, proxy: ModListProxyModel, source: ModListModel - ) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-1", "uuid-4"}) - assert proxy.rowCount() == 3 - assert proxy.move_row(0, 1) - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert src_idx.isValid() - assert proxy.mapFromSource(src_idx).row() == r - assert not proxy.mapFromSource(source.index(0, 0)).isValid() - - def test_persistent_indexes_follow_uuid_across_filter_rebuild( - self, proxy: ModListProxyModel - ) -> None: - kept = QPersistentModelIndex(proxy.index(2, 0)) - filtered = QPersistentModelIndex(proxy.index(1, 0)) - assert kept.data(ModListModel.UuidRole) == "uuid-1" - - proxy.set_sidebar_filter({"uuid-1", "uuid-3"}) - - assert kept.isValid() - assert kept.row() == 0 - assert kept.data(ModListModel.UuidRole) == "uuid-1" - assert not filtered.isValid() - - -class TestIsFiltered: - def test_transitions_on_search_filter(self, proxy: ModListProxyModel) -> None: - transitions: list[bool] = [] - proxy.is_filtered_changed.connect( - lambda: transitions.append(bool(proxy.property("isFiltered"))) - ) - - assert proxy.property("isFiltered") is False - assert proxy.is_filtered is False - - proxy.set_search_filter("mod") - assert proxy.property("isFiltered") is True - assert proxy.is_filtered is True - assert transitions == [True] - - proxy.set_search_filter("") - assert proxy.property("isFiltered") is False - assert proxy.is_filtered is False - assert transitions == [True, False] - - def test_transitions_on_sidebar_filter(self, proxy: ModListProxyModel) -> None: - transitions: list[bool] = [] - proxy.is_filtered_changed.connect( - lambda: transitions.append(bool(proxy.property("isFiltered"))) - ) - - assert proxy.property("isFiltered") is False - - proxy.set_sidebar_filter({"uuid-0"}) - assert proxy.property("isFiltered") is True - assert transitions == [True] - - proxy.set_sidebar_filter(None) - assert proxy.property("isFiltered") is False - assert transitions == [True, False] - - -class TestClampedMove: - def test_clamped_move_clamps_target_to_active_block( - self, proxy: ModListProxyModel - ) -> None: - assert proxy.activeCount == 2 - ok = proxy.move_row(0, 4, clamp_to_active=True) - assert ok is True - assert proxy.data(proxy.index(0, 0), ModListModel.UuidRole) == "uuid-2" - assert proxy.data(proxy.index(1, 0), ModListModel.UuidRole) == "uuid-0" - assert proxy.data(proxy.index(2, 0), ModListModel.UuidRole) == "uuid-1" - assert proxy.mapToSource(proxy.index(0, 0)).row() == 0 - assert proxy.mapToSource(proxy.index(1, 0)).row() == 1 - assert proxy.mapToSource(proxy.index(2, 0)).row() == 2 - - def test_clamped_move_rejects_inactive_source( - self, proxy: ModListProxyModel - ) -> None: - ok = proxy.move_row(2, 0, clamp_to_active=True) - assert ok is False - - def test_clamped_move_rejects_when_filtered(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("mod") - assert proxy.isFiltered is True - ok = proxy.move_row(0, 1, clamp_to_active=True) - assert ok is False - - -def test_move_refreshes_load_indices_only_after_move_completes( - qapp: QApplication, -) -> None: - source = ModListModel(_provider_colors()) - mods: dict[str, ListedMod] = {f"u{i}": _mod(f"Mod {i}") for i in range(3)} - source.load_mods(mods, ["u0", "u1", "u2"]) - proxy = ModListProxyModel(source) - events: list[tuple[str, int, int]] = [] - proxy.rowsAboutToBeMoved.connect(lambda *_: events.append(("begin", -1, -1))) - proxy.rowsMoved.connect(lambda *_: events.append(("end", -1, -1))) - proxy.dataChanged.connect( - lambda top, bottom, _roles: events.append(("data", top.row(), bottom.row())) - ) - - assert proxy.move_row(0, 2) is True - - assert events == [("begin", -1, -1), ("end", -1, -1), ("data", 0, 2)] - rows = [ - ( - proxy.data(proxy.index(row, 0), ModListModel.UuidRole), - proxy.data(proxy.index(row, 0), ModListModel.LoadIndexRole), - ) - for row in range(3) - ] - assert rows == [("u1", 1), ("u2", 2), ("u0", 3)] diff --git a/legacy/tests/test_ui/test_mod_tree_model.py b/legacy/tests/test_ui/test_mod_tree_model.py deleted file mode 100644 index 5299f53..0000000 --- a/legacy/tests/test_ui/test_mod_tree_model.py +++ /dev/null @@ -1,508 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator, Callable, Iterator -from pathlib import Path -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtCore import QCoreApplication, QMetaObject, QObject, QPoint, QPointF, Qt -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget -from pytestqt.qtbot import QtBot - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView -from pxmodrim.core.organizer import ROOT_ID, OrganizerDb, OrganizerService, TreeFilter -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.components.icon_button import IconButton -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.plugins.organizer.filter_model import OrganizerFilterModel -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel -from pxmodrim.ui.theme.qml_theme import Theme -from pxmodrim.ui.views.mods_view import ModsViewPanel - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -async def organizer( - tmp_path: Path, -) -> AsyncIterator[tuple[OrganizerService, SimpleNamespace]]: - mods: dict[str, ListedMod] = { - "u1": AboutXmlMod( - name="One", - _uuid="u1", - package_id=CaseInsensitiveStr("a.one"), - provider_id="local", - ), - "u2": AboutXmlMod( - name="Two", - _uuid="u2", - package_id=CaseInsensitiveStr("a.two"), - provider_id="steam", - ), - "u3": AboutXmlMod( - name="Loose", - _uuid="u3", - package_id=CaseInsensitiveStr("loose.mod"), - provider_id="local", - ), - } - ctx = SimpleNamespace( - all_mods=mods, - active_uuids=["u1"], - diagnostics_service=SimpleNamespace(summary_for=lambda _uuid: None), - ) - svc = OrganizerService( - cast(CoreContext, ctx), OrganizerDb(tmp_path / "organizer.db") - ) - await svc.init(cast(CoreContext, ctx)) - yield svc, ctx - await svc.shutdown() - - -async def test_nested_folder_rows_and_ungrouped( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - top = await svc.create_folder("Top") - sub = await svc.create_folder("Sub", top.id) - await svc.place(["a.one"], top.id) - await svc.place(["a.two"], sub.id) - model = ModTreeModel(lambda _uuid: None) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - - assert model.rowCount() == 2 - root_folder = model.index(0, 0) - ungrouped = model.index(1, 0) - assert model.data(root_folder) == "Top" - assert model.data(ungrouped) == "Ungrouped" - assert model.data(model.index(0, 0, root_folder)) == "Sub" - assert model.data(model.index(1, 0, root_folder)) == "One" - assert model.data(model.index(0, 0, ungrouped)) == "Loose" - assert model.parent(model.index(0, 0, ungrouped)) == ungrouped - assert model.parent(model.index(0, 0, root_folder)) == root_folder - assert model.parent(root_folder).isValid() is False - assert model.data(ungrouped, model.TotalCountRole) == 1 - assert model.data(root_folder, model.TotalCountRole) == 2 - - -async def test_check_states_and_filtered_counts( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - folder = await svc.create_folder_from("Group", ["a.one", "a.two"]) - model = ModTreeModel(lambda _uuid: None) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - idx = model.index(0, 0) - assert model.data(idx, model.CheckStateRole) == Qt.CheckState.PartiallyChecked - assert ( - model.data(model.index(0, 0, idx), model.CheckStateRole) - == Qt.CheckState.Checked - ) - assert ( - model.data(model.index(1, 0, idx), model.CheckStateRole) - == Qt.CheckState.Unchecked - ) - assert ( - model.data(model.index(1, 0), model.CheckStateRole) == Qt.CheckState.Unchecked - ) - - from pxmodrim.core.organizer import TreeQuery - - model.set_tree(svc.tree(TreeQuery(text="One")), ctx.all_mods, {}, True) - idx = model.index(0, 0) - assert model.data(idx, model.VisibleCountRole) == 1 - assert model.data(idx, model.TotalCountRole) == 2 - assert model.data(idx, model.CheckStateRole) == Qt.CheckState.PartiallyChecked - assert model.index_for_key(f"f:{folder.id}").isValid() - - -async def test_folder_toggle_applies_once_for_descendants( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - top = await svc.create_folder("Top") - sub = await svc.create_folder("Sub", top.id) - await svc.place(["a.one"], top.id) - await svc.place(["a.two"], sub.id) - calls: list[tuple[list[str], list[str]]] = [] - - class Activation: - def dependents_of(self, uuids: list[str], *, enable: list[str]) -> list[str]: - return [] - - def apply(self, enable: list[str], disable: list[str]) -> bool: - calls.append((enable, disable)) - return True - - ctx.activation = Activation() - view = OrganizerViewPanel.__new__(OrganizerViewPanel) - view._ctx = cast(CoreContext, ctx) - view._service = svc - view.model = ModTreeModel(lambda _uuid: None) - view.model.set_tree(svc.tree(), ctx.all_mods, {}, False) - node = view.model.node_at(view.model.index_for_key(f"f:{top.id}")) - assert node is not None - await view._toggle_folder(node) - assert calls == [(["u1", "u2"], [])] - assert svc.folder_mod_uuids(ROOT_ID, recursive=False) == ["u3"] - - -async def test_issue_markers_only_show_on_enabled_mods( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - summary = ModDiagnosticsView( - has_errors=True, - has_warnings=True, - error_tooltip="Error", - warning_tooltip="Incompatible", - ) - model = ModTreeModel(lambda _uuid: summary) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - ungrouped = model.index(0, 0) - enabled = model.index_for_key("m:u1") - inactive = model.index_for_key("m:u2") - assert model.parent(enabled) == ungrouped - assert model.data(enabled, model.HasErrorRole) is True - assert model.data(enabled, model.HasWarningRole) is True - assert model.data(inactive, model.HasErrorRole) is False - assert model.data(inactive, model.HasWarningRole) is False - - ctx.active_uuids.append("u2") - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - assert model.data(model.index_for_key("m:u2"), model.HasWarningRole) is True - - -@pytest.mark.asyncio -async def test_qml_tree_click_passes_model_index_to_panel( - tmp_path: Path, qapp: QApplication, qtbot: QtBot -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - mod = AboutXmlMod( - name="Example", - _uuid="example", - package_id=CaseInsensitiveStr("example.mod"), - provider_id="local", - ) - ctx.load({"example": mod}, []) - service = OrganizerService(ctx, OrganizerDb(tmp_path / "organizer.db")) - ctx.register_plugin(service) - await service.init(ctx) - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(900, 600) - view.resize(900, 600) - parent.show() - view.show() - view._rebuild() - try: - tree = view._qml.rootObject().findChild(QObject, "organizerTreeView") - assert tree is not None - assert view.model.rowCount() == 1 - qtbot.waitUntil(lambda: tree.property("contentHeight") > 0) - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=QPoint(200, 20)) - assert [node.kind for node in view.model.selected_nodes()] == ["ungrouped"] - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -def _click_quick(widget: QWidget, item: QQuickItem) -> None: - center = item.mapToScene(QPointF(item.width() / 2, item.height() / 2)) - QTest.mouseClick( - widget, - Qt.MouseButton.LeftButton, - pos=QPoint(round(center.x()), round(center.y())), - ) - - -def _find_quick_item(root_item: QQuickItem, name: str) -> QQuickItem | None: - if root_item.objectName() == name: - return root_item - for child in root_item.childItems(): - found = _find_quick_item(child, name) - if found is not None: - return found - return None - - -async def _until(predicate: Callable[[], object], timeout: float = 10.0) -> None: - deadline = asyncio.get_running_loop().time() + timeout - while not predicate() and asyncio.get_running_loop().time() < deadline: - await asyncio.sleep(0.02) - QTest.qWait(5) - - -@pytest.mark.asyncio -async def test_qml_tag_editor_persists_assignments_and_edits( - tmp_path: Path, qapp: QApplication, qtbot: QtBot -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - mods: dict[str, ListedMod] = { - uuid: AboutXmlMod( - name=name, - _uuid=uuid, - package_id=CaseInsensitiveStr(package_id), - provider_id="local", - ) - for uuid, name, package_id in ( - ("example", "Example", "example.mod"), - ("second", "Second", "second.mod"), - ) - } - ctx.load(mods, []) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(1400, 850) - view.setGeometry(parent.rect()) - parent.show() - view.show() - view._rebuild() - try: - root = view._qml.rootObject() - assert root is not None - tags = root.findChild(QObject, "organizerTagEditor") - assert tags is not None - selected = view.model.index_for_key("m:example") - assert selected.isValid() - view.model.select((selected,)) - view._sync_info() - await view.selectionAction("tags") - qtbot.waitUntil(lambda: tags.property("visible")) - name = tags.findChild(QQuickItem, "organizerNewTagName") - color = tags.findChild(QQuickItem, "organizerNewTagColor") - add_tag = tags.findChild(QQuickItem, "organizerAddTag") - error = tags.findChild(QQuickItem, "organizerTagError") - assert name is not None and color is not None and add_tag is not None - assert error is not None - name.setProperty("text", "Favorites") - color.setProperty("text", "not-a-color") - _click_quick(view._qml, add_tag) - await _until(lambda: "valid color" in str(error.property("text"))) - assert "valid color" in error.property("text") - assert name.property("text") == "Favorites" - assert color.property("text") == "not-a-color" - assert not service.state.tags - - color.setProperty("text", "#3399aa") - _click_quick(view._qml, add_tag) - await _until(lambda: bool(service.state.tags)) - assert [(tag.name, tag.color) for tag in service.state.tags.values()] == [ - ("Favorites", "#3399aa") - ] - assert error.property("text") == "" - - first = view.model.index_for_key("m:example") - second = view.model.index_for_key("m:second") - assert first.isValid() and second.isValid() - view.model.select((first,)) - view._sync_info() - await asyncio.sleep(0.03) - QTest.qWait(10) - repeater = tags.findChild(QQuickItem, "organizerTagRepeater") - assert repeater is not None - repeater_parent = repeater.parentItem() - assert repeater_parent is not None - assignment = _find_quick_item(repeater_parent, "organizerTagAssignment") - assert assignment is not None and assignment.isVisible() - assert assignment.property("checkState") == Qt.CheckState.Unchecked - _click_quick(view._qml, assignment) - await _until(lambda: "example.mod" in service.state.mod_tags) - tag_id = next(iter(service.state.tags)) - assert service.state.mod_tags["example.mod"] == frozenset({tag_id}) - assert assignment.property("checkState") == Qt.CheckState.Checked - - view.model.select((first, second)) - view._sync_info() - await asyncio.sleep(0.03) - QTest.qWait(10) - assert assignment.property("checkState") == Qt.CheckState.PartiallyChecked - _click_quick(view._qml, assignment) - await _until(lambda: "second.mod" in service.state.mod_tags) - assert service.state.mod_tags["second.mod"] == frozenset({tag_id}) - assert assignment.property("checkState") == Qt.CheckState.Checked - _click_quick(view._qml, assignment) - await _until(lambda: not service.state.mod_tags.get("example.mod")) - assert not service.state.mod_tags.get("example.mod") - assert not service.state.mod_tags.get("second.mod") - assert assignment.property("checkState") == Qt.CheckState.Unchecked - - edit = _find_quick_item(repeater_parent, "organizerEditTag") - tag_name = _find_quick_item(repeater_parent, "organizerTagName") - tag_color = _find_quick_item(repeater_parent, "organizerTagColor") - assert edit is not None and tag_name is not None and tag_color is not None - _click_quick(view._qml, edit) - assert not tag_name.property("readOnly") - tag_name.setProperty("text", "Best") - tag_color.setProperty("text", "bad") - _click_quick(view._qml, edit) - await _until(lambda: "valid color" in str(error.property("text"))) - assert "valid color" in error.property("text") - assert tag_name.property("text") == "Best" - assert tag_color.property("text") == "bad" - assert not tag_name.property("readOnly") - assert service.state.tags[tag_id].name == "Favorites" - - tag_color.setProperty("text", "#123456") - _click_quick(view._qml, edit) - await _until(lambda: service.state.tags[tag_id].name == "Best") - assert (service.state.tags[tag_id].name, service.state.tags[tag_id].color) == ( - "Best", - "#123456", - ) - assert tag_name.property("readOnly") - assert error.property("text") == "" - persisted = await db.load() - assert (persisted.tags[tag_id].name, persisted.tags[tag_id].color) == ( - "Best", - "#123456", - ) - assert not persisted.mod_tags.get("example.mod") - assert not persisted.mod_tags.get("second.mod") - - delete_btn = _find_quick_item(repeater_parent, "organizerDeleteTag") - assert delete_btn is not None - _click_quick(view._qml, delete_btn) - delete_confirm = tags.findChild(QObject, "organizerDeleteTagConfirm") - assert delete_confirm is not None - qtbot.waitUntil(lambda: delete_confirm.property("visible")) - QMetaObject.invokeMethod(delete_confirm, "accept") - await _until(lambda: not service.state.tags) - assert not service.state.tags - persisted = await db.load() - assert not persisted.tags - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -def test_organizer_filter_model_sections_icons_and_fallbacks( - qapp: QApplication, -) -> None: - model = OrganizerFilterModel() - assert model.rowCount() == 0 - assert model.for_key("missing").key == "all" - - entries = [ - TreeFilter("all", "All", 10), - TreeFilter("active", "Active", 6, status="active"), - TreeFilter("inactive", "Inactive", 4, status="inactive"), - TreeFilter("provider:steam", "Steam", 8, provider_id="steam"), - TreeFilter("tag:1", "Medieval", 5, tag_id=1), - TreeFilter("tag:2", "Empty", 0, tag_id=2), - ] - model.update(entries) - assert model.rowCount() == 6 - - # SectionRole checks - assert [ - model.data(model.index(i), OrganizerFilterModel.SectionRole) for i in range(6) - ] == ["Status", "Status", "Status", "Providers", "Tags", "Tags"] - - # IconRole checks - assert [ - model.data(model.index(i), OrganizerFilterModel.IconRole) for i in range(6) - ] == ["grid", "check-circle", "ban", "steam", "tag", "tag"] - - # Key and Display role checks - assert model.data(model.index(4), OrganizerFilterModel.KeyRole) == "tag:1" - assert model.data(model.index(4), Qt.ItemDataRole.DisplayRole) == "Medieval" - assert model.data(model.index(4), OrganizerFilterModel.CountRole) == 5 - assert model.data(model.index(5), OrganizerFilterModel.CountRole) == 0 - - # for_key lookup and fallback for deleted/unknown tag - assert model.for_key("tag:1").label == "Medieval" - assert model.for_key("tag:999").key == "all" - - # In-place update (label rename without key change) - renamed_entries = [ - TreeFilter("all", "All", 10), - TreeFilter("active", "Active", 6, status="active"), - TreeFilter("inactive", "Inactive", 4, status="inactive"), - TreeFilter("provider:steam", "Steam", 8, provider_id="steam"), - TreeFilter("tag:1", "Medieval Overhaul", 5, tag_id=1), - TreeFilter("tag:2", "Empty", 0, tag_id=2), - ] - model.update(renamed_entries) - label = model.data(model.index(4), Qt.ItemDataRole.DisplayRole) - assert label == "Medieval Overhaul" - - -@pytest.mark.asyncio -async def test_organizer_sidebar_geometry_matches_mods_after_dismissing_hint( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load({}, []) - service = OrganizerService(ctx, OrganizerDb(tmp_path / "organizer.db")) - ctx.register_plugin(service) - await service.init(ctx) - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - parent.resize(1300, 780) - organizer = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - mods = ModsViewPanel(ctx, engine, parent, AppContext(ctx)) - organizer.setGeometry(parent.rect()) - mods.setGeometry(parent.rect()) - parent.show() - organizer.show() - mods.show() - try: - QTest.qWait(10) - for _ in range(100): - if organizer.mod_info.width() == mods.mod_info.width(): - break - QTest.qWait(10) - assert organizer._sidebar.width() == mods.sidebar.width() - assert ( - organizer._sidebar.rootObject().childItems()[0].width() - == mods.sidebar.rootObject().childItems()[0].width() - ) - assert organizer.mod_info.width() == mods.mod_info.width() - - mods.hide() - hint = organizer.findChild(QWidget, "organizerHint") - assert hint is not None - dismiss = hint.findChild(IconButton) - assert dismiss is not None - width = organizer.mod_info.width() - dismiss.click() - QTest.qWait(10) - assert not hint.isVisible() - assert organizer.mod_info.width() == width - finally: - organizer.close() - mods.close() - parent.deleteLater() - await service.shutdown() diff --git a/legacy/tests/test_ui/test_mod_updates.py b/legacy/tests/test_ui/test_mod_updates.py deleted file mode 100644 index 0908ba3..0000000 --- a/legacy/tests/test_ui/test_mod_updates.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget - -from pxmodrim.ui.components import mod_updates - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() or QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.mark.asyncio -async def test_small_update_skips_confirmation( - qapp: QApplication, - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fail(*_args: object) -> tuple[int, object]: - raise AssertionError("dialog must not open") - - monkeypatch.setattr(mod_updates, "await_dialog", fail) - parent = QWidget() - assert await mod_updates.confirm_update(mod_updates.CONFIRM_THRESHOLD - 1, parent) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("answer", "expected"), - [ - (QMessageBox.StandardButton.Yes, True), - (QMessageBox.StandardButton.Cancel, False), - ], -) -async def test_large_update_follows_dialog_answer( - qapp: QApplication, - monkeypatch: pytest.MonkeyPatch, - answer: QMessageBox.StandardButton, - expected: bool, -) -> None: - async def fake(*_args: object) -> tuple[int, object]: - return answer, object() - - monkeypatch.setattr(mod_updates, "await_dialog", fake) - parent = QWidget() - result = await mod_updates.confirm_update(mod_updates.CONFIRM_THRESHOLD, parent) - assert result is expected diff --git a/legacy/tests/test_ui/test_navigation.py b/legacy/tests/test_ui/test_navigation.py deleted file mode 100644 index be675b4..0000000 --- a/legacy/tests/test_ui/test_navigation.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.ui.navigation import Route, build_route, parse_route - - -def test_route_round_trips_ids_with_reserved_characters() -> None: - url = build_route("workshop", "collection", "picked:a/b") - assert url == "modrim://workshop/collection/picked%3Aa%2Fb" - assert parse_route(url) == Route("workshop", ("collection", "picked:a/b")) - - -def test_view_only_route_has_empty_path() -> None: - assert parse_route("modrim://downloads") == Route("downloads") - assert parse_route("modrim://downloads/") == Route("downloads") - - -@pytest.mark.parametrize("url", ["downloads", "https://downloads", "modrim://", ""]) -def test_non_routes_are_rejected(url: str) -> None: - assert parse_route(url) is None diff --git a/legacy/tests/test_ui/test_organizer_dialogs.py b/legacy/tests/test_ui/test_organizer_dialogs.py deleted file mode 100644 index 225a4e9..0000000 --- a/legacy/tests/test_ui/test_organizer_dialogs.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget - -from pxmodrim.ui.plugins.organizer.dialogs import DeleteFolderDialog - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() or QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_delete_folder_dialog_confirms_with_yes_button(qapp: QApplication) -> None: - parent = QWidget() - dialog = DeleteFolderDialog("Fixes", parent) - yes = dialog.button(QMessageBox.StandardButton.Yes) - assert yes is not None - assert yes.text() == "Delete" - yes.click() - assert dialog.result() == QMessageBox.StandardButton.Yes diff --git a/legacy/tests/test_ui/test_organizer_rule_editor.py b/legacy/tests/test_ui/test_organizer_rule_editor.py deleted file mode 100644 index 9540b68..0000000 --- a/legacy/tests/test_ui/test_organizer_rule_editor.py +++ /dev/null @@ -1,327 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Iterator -from itertools import pairwise -from pathlib import Path - -import pytest -from PySide6.QtCore import ( - Q_ARG, - QCoreApplication, - QMetaObject, - QObject, - QPoint, - QPointF, - Qt, -) -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod, CaseInsensitiveStr -from pxmodrim.core.organizer import STANDARD_RULES, OrganizerDb, OrganizerService -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -async def _until(condition: Callable[[], bool], timeout: float = 10.0) -> None: - """Pump both Qt and asyncio (asyncSlot tasks) until ``condition`` holds.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while not condition(): - assert loop.time() < deadline, "condition not met before timeout" - QTest.qWait(10) - await asyncio.sleep(0) - - -def _click(widget: QWidget, item: QQuickItem) -> None: - center = item.mapToScene(QPointF(item.width() / 2, item.height() / 2)) - QTest.mouseClick( - widget, - Qt.MouseButton.LeftButton, - pos=QPoint(round(center.x()), round(center.y())), - ) - - -def _set_pattern(editor: QObject, index: int, value: str) -> None: - assert QMetaObject.invokeMethod( - editor, - "setRulePattern", - Qt.ConnectionType.DirectConnection, - Q_ARG("QVariant", index), - Q_ARG("QVariant", value), - ) - - -def _find_quick_item(root_item: QQuickItem, name: str) -> QQuickItem | None: - if root_item.objectName() == name: - return root_item - for child in root_item.childItems(): - found = _find_quick_item(child, name) - if found is not None: - return found - return None - - -def _control(row: QQuickItem, name: str) -> QQuickItem: - control = _find_quick_item(row, name) - assert control is not None, f"Control {name} not found in row" - return control - - -def _rows(dialog: QObject) -> list[QQuickItem]: - repeater = dialog.findChild(QQuickItem, "organizerRuleRepeater") - assert repeater is not None - column = repeater.parentItem() - assert column is not None - rows = [i for i in column.childItems() if i.objectName() == "organizerRuleRow"] - return sorted(rows, key=lambda item: item.y()) - - -async def _laid_out_rows(dialog: QObject, count: int) -> list[QQuickItem]: - """Wait until ``count`` rows exist and the layout has positioned them.""" - - def settled() -> bool: - rows = _rows(dialog) - ys = [row.y() for row in rows] - return len(rows) == count and all(a < b for a, b in pairwise(ys)) - - await _until(settled) - return _rows(dialog) - - -def _patterns(dialog: QObject) -> list[str]: - return [ - str(_control(row, "organizerRulePattern").property("text")) - for row in _rows(dialog) - ] - - -def _hits(dialog: QObject, index: int) -> str: - rows = _rows(dialog) - if len(rows) <= index: - return "" - return str(_control(rows[index], "organizerRuleHits").property("text")) - - -@pytest.mark.asyncio -async def test_rule_editor_persists_ordered_rules_and_keeps_invalid_draft( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load( - { - "example": AboutXmlMod( - name="Example", - _uuid="example", - package_id=CaseInsensitiveStr("example.mod"), - provider_id="local", - ) - }, - [], - ) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - first = await service.create_folder("First") - second = await service.create_folder("Second") - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(920, 700) - view.setGeometry(parent.rect()) - view.show() - parent.show() - # Earlier tests may leave another top-level active; key input needs ours. - parent.activateWindow() - assert QTest.qWaitForWindowActive(parent) - view._rebuild() - try: - qml = view._rules_qml - dialog = qml.rootObject() - assert dialog is not None - rules_button = view.rules_button - - async def open_editor(count: int) -> list[QQuickItem]: - QTest.mouseClick(rules_button, Qt.MouseButton.LeftButton) - await _until(lambda: bool(dialog.property("opened"))) - # Opening the QML root does not wait for native window activation. - assert QTest.qWaitForWindowActive(view._rules_dialog) - return await _laid_out_rows(dialog, count) - - async def save_and_close() -> None: - assert save is not None - _click(qml, save) - await _until(lambda: not dialog.property("visible")) - - await open_editor(0) - assert view._rules_dialog.isVisible() - assert dialog.property("width") == qml.width() - assert dialog.property("height") == qml.height() - initial_size = view._rules_dialog.size() - view._rules_dialog.resize(initial_size.width() + 24, initial_size.height() + 24) - await _until( - lambda: ( - dialog.property("width") == qml.width() - and dialog.property("height") == qml.height() - ) - ) - assert qml.width() > initial_size.width() - add = dialog.findChild(QQuickItem, "organizerAddRule") - save = dialog.findChild(QQuickItem, "organizerSaveRules") - assert add is not None and save is not None - assert dialog.property("width") <= qml.width() - - # Add a rule, type its pattern, save. - _click(qml, add) - await _laid_out_rows(dialog, 1) - _set_pattern(dialog, 0, "example") - assignable = dialog.findChild(QQuickItem, "organizerRuleAssignable") - assert assignable is not None - await _until(lambda: "1 mod matches first here" in _hits(dialog, 0)) - assert "1 currently ungrouped mod would" in str(assignable.property("text")) - await save_and_close() - - persisted = await db.load() - assert [(r.field, r.op, r.pattern, r.folder_id) for r in persisted.rules] == [ - ("package_id", "prefix", "example", first.id) - ] - assert service.folder_mod_uuids(first.id) == ["example"] - - # Add a second rule targeting the second folder, then move it up. - await open_editor(1) - _click(qml, add) - _, new_row = await _laid_out_rows(dialog, 2) - _set_pattern(dialog, 1, "example.mod") - new_row = (await _laid_out_rows(dialog, 2))[1] - operator_box = _control(new_row, "organizerRuleOperator") - _click(qml, operator_box) - await _until(lambda: bool(operator_box.property("down"))) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Return) - await _until(lambda: operator_box.property("currentValue") == "equals") - new_row = (await _laid_out_rows(dialog, 2))[1] - folder_box = _control(new_row, "organizerRuleFolder") - _click(qml, folder_box) - await _until(lambda: bool(folder_box.property("down"))) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Return) - await _until(lambda: folder_box.property("currentValue") == second.id) - - _click(qml, _control(new_row, "organizerRuleUp")) - await _until(lambda: _patterns(dialog) == ["example.mod", "example"]) - await _until(lambda: "1 mod matches first here" in _hits(dialog, 0)) - await _laid_out_rows(dialog, 2) - await save_and_close() - - persisted = await db.load() - assert [(r.op, r.pattern, r.folder_id) for r in persisted.rules] == [ - ("equals", "example.mod", second.id), - ("prefix", "example", first.id), - ] - assert service.folder_mod_uuids(second.id) == ["example"] - - # An invalid pattern keeps the dialog and the unsaved draft visible. - top, _ = await open_editor(2) - _set_pattern(dialog, 0, " ") - _click(qml, save) - await _until( - lambda: "pattern cannot be empty" in str(dialog.property("errorMessage")) - ) - assert dialog.property("visible") - assert _patterns(dialog) == [" ", "example"] - assert len((await db.load()).rules) == 2 - - # Remove the invalid rule from the kept draft and save the remainder. - _click(qml, _control(top, "organizerRuleRemove")) - await _laid_out_rows(dialog, 1) - assert _patterns(dialog) == ["example"] - await save_and_close() - - persisted = await db.load() - assert [(r.pattern, r.folder_id) for r in persisted.rules] == [ - ("example", first.id) - ] - assert service.folder_mod_uuids(first.id) == ["example"] - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -@pytest.mark.asyncio -async def test_rule_editor_adds_standard_rules_only_from_clean_draft( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load({}, []) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - await service.create_folder("Mine") - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(920, 700) - view.setGeometry(parent.rect()) - view.show() - parent.show() - parent.activateWindow() - assert QTest.qWaitForWindowActive(parent) - view._rebuild() - try: - qml = view._rules_qml - dialog = qml.rootObject() - assert dialog is not None - QTest.mouseClick(view.rules_button, Qt.MouseButton.LeftButton) - await _until(lambda: bool(dialog.property("opened"))) - assert QTest.qWaitForWindowActive(view._rules_dialog) - await _laid_out_rows(dialog, 0) - standard = dialog.findChild(QQuickItem, "organizerAddStandardRules") - assert standard is not None - assert standard.property("enabled") - - _click(qml, standard) - rows = await _laid_out_rows(dialog, len(STANDARD_RULES)) - assert len(rows) == len(STANDARD_RULES) - scroll_area = dialog.findChild(QQuickItem, "organizerRuleScrollArea") - assert scroll_area is not None - assert scroll_area.property("contentHeight") > scroll_area.height() - assert _patterns(dialog) == [rule.pattern for rule in STANDARD_RULES] - assert dialog.property("visible") - assert dialog.property("errorMessage") == "" - - persisted = await db.load() - folders = {f.id: f.name for f in persisted.folders.values()} - assert [ - (folders[r.folder_id], r.field, r.op, r.pattern) for r in persisted.rules - ] == [(s.folder, s.field, s.op, s.pattern) for s in STANDARD_RULES] - - # A dirty draft must be saved or discarded before adding more rules. - assert standard.property("enabled") - _set_pattern(dialog, 0, "edited") - await _until(lambda: not standard.property("enabled")) - finally: - view.close() - parent.deleteLater() - await service.shutdown() diff --git a/legacy/tests/test_ui/test_procedural_preview.py b/legacy/tests/test_ui/test_procedural_preview.py deleted file mode 100644 index f6d0dda..0000000 --- a/legacy/tests/test_ui/test_procedural_preview.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import pytest -from PySide6.QtGui import QGuiApplication, QImage - -from pxmodrim.ui.components.procedural_preview import generate_preview, initials - - -@pytest.mark.parametrize( - ("title", "expected"), - [ - ("[AV] Framework - Russian Translation", "FR"), - ("SF Grim Reality", "SG"), - ("Vanilla Expanded Framework", "VE"), - ("RimworldTweaks", "RT"), - ("Combat", "CO"), - ("[AV]", "AV"), - ("[AV] Framework", "AF"), - ("The Lord of Rings", "LR"), - ("Русский перевод", "РП"), - ("---", "--"), - ], -) -def test_initials(title: str, expected: str) -> None: - assert initials(title) == expected - - -def test_generate_preview_is_thread_safe_image() -> None: - app = QGuiApplication.instance() or QGuiApplication([]) - assert app is not None - image = generate_preview("[AV] Framework", 120, 60) - assert isinstance(image, QImage) - assert (image.width(), image.height()) == (120, 60) - assert not image.isNull() - assert generate_preview("[AV] Framework", 120, 60) is image diff --git a/legacy/tests/test_ui/test_restore_snapshot_dialog.py b/legacy/tests/test_ui/test_restore_snapshot_dialog.py deleted file mode 100644 index 7aa8dd0..0000000 --- a/legacy/tests/test_ui/test_restore_snapshot_dialog.py +++ /dev/null @@ -1,324 +0,0 @@ -from __future__ import annotations - -import asyncio -import threading -import time -from collections.abc import Iterator -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock - -import pytest -from PySide6.QtWidgets import ( - QAbstractButton, - QApplication, - QDialog, - QListWidget, - QMessageBox, - QWidget, -) - -from pxmodrim.core import mod_service as mod_service_module -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.mods_config import parse_mods_config, write_mods_config -from pxmodrim.ui.panels.restore_snapshot_dialog import ( - ConfirmRestoreDialog, - RestoreSnapshotDialog, -) - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def _write_snapshot(path: Path, *package_ids: str) -> None: - write_mods_config( - path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(value) for value in package_ids], - knownExpansions=[], - ), - path.parent, - ) - - -def test_restore_dialog_offscreen_selection_and_accept_reject( - qapp: QApplication, tmp_path: Path -) -> None: - valid_snap = tmp_path / "ModsConfig_20260922_120000.xml" - _write_snapshot(valid_snap, "mod.foo", "mod.bar") - corrupt_snap = tmp_path / "ModsConfig_20260922_130000.xml" - corrupt_snap.write_text("<<>>", encoding="utf-8") - - dialog = RestoreSnapshotDialog([valid_snap, corrupt_snap]) - list_widget = dialog.findChild(QListWidget) - assert list_widget is not None - assert list_widget.count() == 2 - - # Initial selection is valid_snap - assert dialog.selected_snapshot == valid_snap - restore_btn = next( - b for b in dialog.findChildren(QAbstractButton) if b.text() == "Restore" - ) - cancel_btn = next( - b for b in dialog.findChildren(QAbstractButton) if b.text() == "Cancel" - ) - assert restore_btn.isEnabled() is True - - # Change selection to corrupt item - list_widget.setCurrentRow(1) - assert dialog.selected_snapshot is None - assert restore_btn.isEnabled() is False - - # Switch back to valid item - list_widget.setCurrentRow(0) - assert dialog.selected_snapshot == valid_snap - assert restore_btn.isEnabled() is True - - # Cancel button rejects dialog - cancel_btn.click() - assert dialog.result() == QDialog.DialogCode.Rejected - - # Re-instantiate and test double click accept - dialog2 = RestoreSnapshotDialog([valid_snap]) - list_widget2 = dialog2.findChild(QListWidget) - assert list_widget2 is not None - dialog2._on_item_double_clicked(list_widget2.item(0)) - assert dialog2.result() == QDialog.DialogCode.Accepted - - -def test_confirm_restore_dialog_paths(qapp: QApplication) -> None: - confirm = ConfirmRestoreDialog("ModsConfig_20260922_120000.xml") - assert confirm.windowTitle() == "Restore Mod List" - assert "ModsConfig_20260922_120000.xml" in confirm.text() - assert confirm.defaultButton() == confirm.button(QMessageBox.StandardButton.Cancel) - - restore_btn = confirm.button(QMessageBox.StandardButton.Yes) - assert restore_btn is not None - assert restore_btn.text() == "Restore" - cancel_btn = confirm.button(QMessageBox.StandardButton.Cancel) - assert cancel_btn is not None - assert cancel_btn.text() == "Cancel" - - -@pytest.mark.asyncio -async def test_mod_service_restore_snapshot( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - mock_reload = AsyncMock() - monkeypatch.setattr(ModService, "reload", mock_reload) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("current.mod")], - knownExpansions=[], - ), - config_dir / "snapshots", - ) - - snapshots_dir = config_dir / "snapshots" - snapshots_dir.mkdir(parents=True, exist_ok=True) - snapshot_path = snapshots_dir / "ModsConfig_20260922_100000.xml" - _write_snapshot(snapshot_path, "restored.mod") - - assert await service.restore_snapshot(snapshot_path) is True - mock_reload.assert_awaited_once() - - restored = parse_mods_config(current_cfg_path) - assert restored is not None - assert restored.activeMods == [CaseInsensitiveStr("restored.mod")] - - snapshots = service.get_snapshots() - assert len(snapshots) == 2 - current_backup = next(path for path in snapshots if path != snapshot_path) - backup = parse_mods_config(current_backup) - assert backup is not None - assert backup.activeMods == [CaseInsensitiveStr("current.mod")] - - -@pytest.mark.asyncio -async def test_mod_service_restore_invalid_snapshot( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - mock_reload = AsyncMock() - monkeypatch.setattr(ModService, "reload", mock_reload) - current_cfg_path = game_config_dir / "ModsConfig.xml" - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("safe.mod")], - knownExpansions=[], - ), - config_dir / "snapshots", - ) - original = current_cfg_path.read_bytes() - - corrupt = config_dir / "snapshots" / "ModsConfig_corrupt.xml" - corrupt.parent.mkdir(parents=True, exist_ok=True) - corrupt.write_text("NOT XML", encoding="utf-8") - - assert await service.restore_snapshot(corrupt) is False - mock_reload.assert_not_called() - assert current_cfg_path.read_bytes() == original - - -@pytest.mark.asyncio -async def test_mod_service_initialize_snapshots_existing_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - mock_refresh = AsyncMock() - monkeypatch.setattr(ModService, "_run_refresh_pipeline", mock_refresh) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - initial_mods = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("current.mod")], - knownExpansions=[], - ) - write_mods_config(current_cfg_path, initial_mods, config_dir / "snapshots") - original_bytes = current_cfg_path.read_bytes() - - for snap in (config_dir / "snapshots").glob("*.xml"): - snap.unlink() - assert service.get_snapshots() == [] - - await service.initialize() - mock_refresh.assert_awaited_once_with("initialize") - - snapshots = service.get_snapshots() - assert len(snapshots) == 1 - snap_data = parse_mods_config(snapshots[0]) - assert snap_data is not None - assert snap_data.activeMods == [CaseInsensitiveStr("current.mod")] - assert current_cfg_path.read_bytes() == original_bytes - - saved = await service.save_active_layout([]) - assert saved is True - assert len(service.get_snapshots()) == 1 - - -@pytest.mark.asyncio -async def test_mod_service_configurable_retention_honored( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig(max_snapshots=3) - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - for i in range(5): - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(f"mod.{i}")], - knownExpansions=[], - ), - config_dir / "snapshots", - max_snapshots=ctx.config.max_snapshots, - ) - - assert len(service.get_snapshots()) == 3 - - -def test_settings_panel_save_preserves_max_snapshots(qapp: QApplication) -> None: - from pxmodrim.ui.components.svg_provider import create_qml_engine - from pxmodrim.ui.panels.settings_panel import SettingsPanel - from pxmodrim.ui.theme.qml_theme import Theme - - cfg = AppConfig(max_snapshots=7) - ctx = CoreContext(cfg) - host = QWidget() - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - panel = SettingsPanel(ctx, engine, host) - panel._save(panel._backend._initial) - assert panel.get_config().max_snapshots == 7 - - -@pytest.mark.asyncio -async def test_concurrent_layout_saves_do_not_overlap( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - service = ModService(CoreContext(app_cfg, ConfigService(config_dir)), []) - - running = 0 - peak = 0 - lock = threading.Lock() - real_write = mod_service_module.write_mods_config - - def slow_write(*args: Any, **kwargs: Any) -> None: - nonlocal running, peak - with lock: - running += 1 - peak = max(peak, running) - time.sleep(0.05) - real_write(*args, **kwargs) - with lock: - running -= 1 - - monkeypatch.setattr(mod_service_module, "write_mods_config", slow_write) - await asyncio.gather(*(service.save_active_layout([]) for _ in range(4))) - assert peak == 1 diff --git a/legacy/tests/test_ui/test_settings_panel.py b/legacy/tests/test_ui/test_settings_panel.py deleted file mode 100644 index 00fa86e..0000000 --- a/legacy/tests/test_ui/test_settings_panel.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from PySide6.QtCore import Qt -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.components.svg_provider import create_qml_engine -from pxmodrim.ui.panels.settings_panel import SettingsPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -def _button(root: QQuickItem, text: str) -> QQuickItem | None: - for child in root.findChildren(QQuickItem): - if child.metaObject().className().startswith("PxButton") and ( - child.property("text") == text - ): - return child - return None - - -async def test_cancel_click_closes_settings_and_unloads_without_errors( - qapp: QApplication, -) -> None: - host = QWidget() - engine = create_qml_engine(host) - engine.rootContext().setContextProperty("Theme", Theme(engine)) - warnings: list[Any] = [] - engine.warnings.connect(warnings.extend) - panel = SettingsPanel(CoreContext(AppConfig()), engine, host) - panel.resize(760, 640) - panel.show() - await asyncio.sleep(0.3) - root = panel._qml.rootObject() - assert isinstance(root, QQuickItem) - cancel = _button(root, "Cancel") - assert cancel is not None - center = cancel.mapToScene(cancel.boundingRect().center()).toPoint() - - QTest.mouseClick(panel._qml, Qt.MouseButton.LeftButton, pos=center) - QTest.qWait(20) - assert panel._qml.rootObject() is None - assert not panel.isVisible() - panel.deleteLater() - await asyncio.sleep(0.05) - assert [w.toString() for w in warnings] == [] - host.deleteLater() diff --git a/legacy/tests/test_ui/test_sidebar_model.py b/legacy/tests/test_ui/test_sidebar_model.py deleted file mode 100644 index fc2c18b..0000000 --- a/legacy/tests/test_ui/test_sidebar_model.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QCoreApplication -from PySide6.QtWidgets import QApplication - -from pxmodrim.ui.models.sidebar_model import SidebarModel -from pxmodrim.ui.theme.palette import PALETTE - - -class ProviderEntry: - def __init__(self, label: str) -> None: - self.label = label - self.count = 2 - - -def test_update_entries_refreshes_provider_icon() -> None: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - model = SidebarModel() - model.set_entries([ProviderEntry("Steam")]) - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _, __, roles: changed_roles.append(roles)) - - model.update_entries([ProviderEntry("Local")]) - - assert model.data(model.index(0, 0), SidebarModel.IconRole) == "folder" - assert ( - model.data(model.index(0, 0), SidebarModel.IconColorRole) == PALETTE["TEXT_DIM"] - ) - assert SidebarModel.IconRole in changed_roles[0] diff --git a/legacy/tests/test_ui/test_startup_impact_data.py b/legacy/tests/test_ui/test_startup_impact_data.py deleted file mode 100644 index 7b2822b..0000000 --- a/legacy/tests/test_ui/test_startup_impact_data.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) -from pxmodrim.ui.models.impact import format_duration -from pxmodrim.ui.panels.startup_impact_data import ( - BASE_GAME_LABEL, - build_startup_impact, -) - -_P = "LoadingProgress.StartupImpact." - - -def _report() -> StartupImpactReport: - return StartupImpactReport( - path="", - loading_time_s=0.0, - timestamp="", - mods=( - StartupImpactMod("Small", "a.small", 1.0, {f"{_P}ApplyPatches": 1.0}), - StartupImpactMod( - "Big", - "b.big", - 4.0, - { - f"{_P}ModContentPackReloadContentInt.Textures": 3.0, - f"{_P}ApplyPatches": 1.0, - }, - off_thread_total_impact_s=0.5, - ), - StartupImpactMod( - BASE_GAME_PID, - BASE_GAME_PID, - 10.0, - {f"{_P}GlobalTextureAtlasManagerBakeStaticAtlases": 10.0}, - ), - ), - ) - - -def _build(**kwargs: object) -> dict: - args: dict = {"active_pids": [], "selected_pid": None, "estimated_s": 0.0} - args.update(kwargs) - return build_startup_impact(_report(), **args) - - -def test_mods_exclude_base_game_and_rank_by_time() -> None: - data = _build() - assert [m["name"] for m in data["mods"]] == ["Big", "Small"] - assert [m["rank"] for m in data["mods"]] == [1, 2] - - -def test_segments_scale_to_slowest_mod() -> None: - big, small = _build()["mods"] - assert sum(s["fraction"] for s in big["segments"]) == pytest.approx(1.0) - assert sum(s["fraction"] for s in small["segments"]) == pytest.approx(0.25) - assert big["dominant"] == "Textures & audio" - assert big["off_thread"] == "500 ms" - - -def test_active_and_selected_match_normalized_package_ids() -> None: - data = _build(active_pids=["B.Big_steam"], selected_pid="A.SMALL") - flags = {m["name"]: (m["active"], m["selected"]) for m in data["mods"]} - assert flags == {"Big": (True, False), "Small": (False, True)} - assert data["active_count"] == 1 - assert data["selected_key"] == "a.small" - - -def test_estimate_hidden_without_active_list() -> None: - assert _build(estimated_s=12.0)["estimated"] == "" - assert _build(active_pids=["a.small"], estimated_s=12.0)["estimated"] == "12.00 s" - - -def test_last_launch_falls_back_to_measured_sum() -> None: - assert _build()["last_launch"] == "15.00 s" - - -def test_summary_splits_base_mods_off_thread() -> None: - summary = _build()["summary"] - assert [s["parallel"] for s in summary] == [False, False, True] - assert sum(s["fraction"] for s in summary) == pytest.approx(1.0) - assert summary[0]["fraction"] == pytest.approx(10.0 / 15.5) - - -def test_phases_toggle_base_game_contribution() -> None: - data = _build(selected_pid="b.big") - with_base = data["phases_with_base"] - assert [p["label"] for p in with_base] == ["Textures & audio", "Patches"] - content = with_base[0] - assert content["value"] == "13.00 s" - assert content["share"] == "87%" - assert [(m["name"], m["base"]) for m in content["mods"]] == [ - (BASE_GAME_LABEL, True), - ("Big", False), - ] - assert content["mods"][1]["selected"] - - mods_only = data["phases_mods_only"] - assert [(p["label"], p["value"]) for p in mods_only] == [ - ("Textures & audio", "3.00 s"), - ("Patches", "2.00 s"), - ] - assert all(not m["base"] for p in mods_only for m in p["mods"]) - - -@pytest.mark.parametrize( - ("seconds", "text"), - [ - (0.0004, "0 ms"), - (0.25, "250 ms"), - (59.5, "59.50 s"), - (60.0, "1m 00s"), - (268.7, "4m 29s"), - ], -) -def test_format_duration(seconds: float, text: str) -> None: - assert format_duration(seconds) == text - - -def test_tooltip_sums_metrics_sharing_a_label() -> None: - from pxmodrim.core.services.startup_impact_service.phases import Phase - from pxmodrim.ui.panels.startup_impact_data import _metrics_by_phase - - grouped = _metrics_by_phase( - {"ApplyPatches|Foo -> a": 1.0, "ApplyPatches|Foo -> b": 2.0} - ) - assert list(grouped[Phase.PATCHES].values()) == [3.0] diff --git a/legacy/tests/test_ui/test_toast_and_progress.py b/legacy/tests/test_ui/test_toast_and_progress.py deleted file mode 100644 index 1e5ea3b..0000000 --- a/legacy/tests/test_ui/test_toast_and_progress.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QPushButton, QWidget - -from pxmodrim.core.loading import LoadingState -from pxmodrim.ui.components.progress_dialog import ProgressDialog -from pxmodrim.ui.components.toast import ToastManager - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_close_button_removes_persistent_toast(qapp: QApplication) -> None: - parent = QWidget() - manager = ToastManager(parent) - manager.show_toast("stays", "info", duration_ms=0) - toast = manager._active_toasts[0] - - close_button = toast.findChild(QPushButton, "toastClose") - assert close_button is not None - close_button.click() - QTest.qWait(300) - - assert manager._active_toasts == [] - assert manager._layout.count() == 0 - - -def test_progress_dialog_stays_visible_when_new_task_starts_before_hide( - qapp: QApplication, -) -> None: - parent = QWidget() - loading = LoadingState(parent) - dialog = ProgressDialog(loading, parent) - - with loading.task("first"): - pass - with loading.task("second"): - QTest.qWait(500) - assert dialog.isVisible() - assert dialog.windowTitle() == "second" - - QTest.qWait(800) - assert not dialog.isVisible() diff --git a/legacy/tests/test_ui/test_unity_rich_text.py b/legacy/tests/test_ui/test_unity_rich_text.py deleted file mode 100644 index bd97e97..0000000 --- a/legacy/tests/test_ui/test_unity_rich_text.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.components.unity_rich_text import unity_rich_text_to_html - - -class TestUnityRichTextConverter: - def test_basic_text(self) -> None: - assert unity_rich_text_to_html("Hello world") == "Hello world" - - def test_bold(self) -> None: - assert unity_rich_text_to_html("bold") == "bold" - assert unity_rich_text_to_html("bold") == "bold" - - def test_italic(self) -> None: - assert unity_rich_text_to_html("italic") == "italic" - assert unity_rich_text_to_html("italic") == "italic" - - def test_underline(self) -> None: - assert ( - unity_rich_text_to_html("underline") - == 'underline' - ) - - def test_strikethrough(self) -> None: - assert unity_rich_text_to_html("strike") == "strike" - - def test_color_named(self) -> None: - assert ( - unity_rich_text_to_html("red") - == 'red' - ) - - def test_color_hex(self) -> None: - assert ( - unity_rich_text_to_html("red") - == 'red' - ) - - def test_size_px(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_percent(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_em(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_relative(self) -> None: - assert ( - unity_rich_text_to_html("relative") - == 'relative' - ) - - def test_size_negative(self) -> None: - assert ( - unity_rich_text_to_html("small") - == 'small' - ) - - def test_align(self) -> None: - assert ( - unity_rich_text_to_html('center') - == '
    center
    ' - ) - - def test_indent(self) -> None: - assert ( - unity_rich_text_to_html("indented") - == '
    indented
    ' - ) - - def test_mark(self) -> None: - assert ( - unity_rich_text_to_html("marked") - == 'marked' - ) - - def test_sub_sup(self) -> None: - assert unity_rich_text_to_html("sub") == "sub" - assert unity_rich_text_to_html("sup") == "sup" - - def test_link(self) -> None: - assert ( - unity_rich_text_to_html('link') - == '
    link' - ) - - def test_link_rejects_unsafe_urls(self) -> None: - for url in ( - "javascript:alert(1)", - "steam://rungameid/294100", - "https:example.com", - ): - assert unity_rich_text_to_html(f'link') == "link" - - def test_non_finite_size_uses_default(self) -> None: - html = unity_rich_text_to_html(f"large") - assert html == 'large' - - def test_non_finite_indent_uses_default(self) -> None: - html = unity_rich_text_to_html(f"wide") - assert html == '
    wide
    ' - - def test_nested(self) -> None: - assert ( - unity_rich_text_to_html("bold italic") - == "bold italic" - ) - - def test_unknown_tag_ignored(self) -> None: - # Unknown tags should be stripped - assert unity_rich_text_to_html("bar") == "bar" - - def test_mixed_formatting(self) -> None: - assert ( - unity_rich_text_to_html("green") - == 'green' - ) diff --git a/legacy/tests/test_ui/test_views.py b/legacy/tests/test_ui/test_views.py deleted file mode 100644 index f4a98eb..0000000 --- a/legacy/tests/test_ui/test_views.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from typing import TYPE_CHECKING - -import pytest -from PySide6.QtCore import QCoreApplication -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.context import AppContext - -pytestmark = pytest.mark.usefixtures("qapp") - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture(scope="module") -def qml_engine(qapp: QApplication) -> Iterator[QQmlEngine]: - engine = create_qml_engine() - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - yield engine - - -@pytest.fixture -def parent_widget(qapp: QApplication) -> Iterator[QWidget]: - """A container the panel is embedded in, mirroring MainWindow's stack. - - Mirrors production wiring (AGENTS.md: never orphan Qt objects) and keeps - the panel's C++ objects alive for the duration of the test instead of - being torn down by Python GC while the shared QML engine still holds - bindings into them. - """ - widget = QWidget() - yield widget - widget.deleteLater() - - -def _ctx_stub(cfg_svc: ConfigService) -> CoreContext: - from pxmodrim.core.context import CoreContext - - return CoreContext.create(cfg_svc.load("config.json", AppConfig), cfg_svc) - - -def _app_ctx_stub(cfg_svc: ConfigService) -> AppContext: - from pxmodrim.ui.context import AppContext - - return AppContext(_ctx_stub(cfg_svc)) - - -class TestSteamWorkshopView: - def test_preload_runs_without_error( - self, - qml_engine: QQmlEngine, - config_service: ConfigService, - parent_widget: QWidget, - ) -> None: - view = SteamWorkshopViewPanel( - ctx=_ctx_stub(config_service), - qml_engine=qml_engine, - parent=parent_widget, - app_ctx=_app_ctx_stub(config_service), - ) - - view.preload() - - def test_web_is_none_before_init( - self, - qml_engine: QQmlEngine, - config_service: ConfigService, - parent_widget: QWidget, - ) -> None: - view = SteamWorkshopViewPanel( - ctx=_ctx_stub(config_service), - qml_engine=qml_engine, - parent=parent_widget, - app_ctx=_app_ctx_stub(config_service), - ) - - assert view._web() is None diff --git a/legacy/tests/test_ui/test_workshop_images.py b/legacy/tests/test_ui/test_workshop_images.py deleted file mode 100644 index 765a398..0000000 --- a/legacy/tests/test_ui/test_workshop_images.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from PySide6.QtCore import QUrl -from PySide6.QtGui import QImage -from PySide6.QtQml import QJSEngine, QJSValue, QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QApplication, QWidget -from pytestqt.qtbot import QtBot - -from pxmodrim.ui.plugins.workshop import view - - -@pytest.fixture -def thumbnails(qapp: QApplication) -> QJSValue: - path = Path(view.__file__).with_name("thumbs.js") - engine = QJSEngine(qapp) - engine.evaluate(path.read_text().removeprefix(".pragma library"), str(path)) - return engine.globalObject().property("sized") - - -@pytest.mark.parametrize( - "url", - [ - "https://example.com/image.png", - "https://notsteamstatic.com/image.png", - "https://steamusercontent.com.example.com/image.png", - "https://user@steamstatic.com/image.png", - "", - ], -) -def test_thumbnail_leaves_other_urls_unchanged(thumbnails: QJSValue, url: str) -> None: - assert ( - thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() == url - ) - - -@pytest.mark.parametrize( - "host", ["images.steamusercontent.com", "steamstatic.com", "cdn.steamstatic.com"] -) -def test_thumbnail_replaces_resize_query_before_fragment( - thumbnails: QJSValue, host: str -) -> None: - url = f"https://{host}/image.png?token=abc&imw=720&imh=360&ima=fit&impolicy=Letterbox#preview" - expected = f"https://{host}/image.png?token=abc&imw=256&imh=128&ima=fit&impolicy=Letterbox#preview" - resized = thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() - assert resized == expected - assert ( - thumbnails.call([QJSValue(resized), QJSValue(256), QJSValue(128)]).toString() - == expected - ) - - -def test_hidden_thumbnail_unloads_and_can_load_again( - qapp: QApplication, tmp_path: Path, qtbot: QtBot -) -> None: - image = QImage(64, 32, QImage.Format.Format_RGB32) - image.fill(0xFF00FF00) - image_path = tmp_path / "preview.png" - assert image.save(str(image_path)) - directory = QUrl.fromLocalFile(str(Path(view.__file__).parent)).toString() - source = tmp_path / "Image.qml" - source.write_text( - f'import QtQuick\nimport "{directory}" as Workshop\n' - f"Workshop.CatalogImage {{ previewUrl: {json.dumps(image_path.as_uri())} }}", - ) - owner = QWidget() - engine = QQmlEngine(owner) - warnings: list[str] = [] - engine.warnings.connect( - lambda errors: warnings.extend(error.toString() for error in errors) - ) - widget = QQuickWidget(engine, owner) - widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - widget.resize(200, 110) - widget.setSource(QUrl.fromLocalFile(str(source))) - owner.show() - widget.show() - root = widget.rootObject() - assert root is not None - handle = engine.newQObject(root) - - def wait_for_status(expected: int) -> None: - qtbot.waitUntil(lambda: handle.property("status").toInt() == expected) - - try: - wait_for_status(1) - root.setVisible(False) - wait_for_status(0) - root.setVisible(True) - widget.resize(300, 150) - wait_for_status(1) - assert widget.grabFramebuffer().pixelColor(150, 75).name() == "#00ff00" - assert not warnings, warnings - finally: - widget.setSource(QUrl()) - owner.close() - owner.deleteLater() diff --git a/legacy/tests/test_ui/test_workshop_installed.py b/legacy/tests/test_ui/test_workshop_installed.py deleted file mode 100644 index f047cab..0000000 --- a/legacy/tests/test_ui/test_workshop_installed.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.workshop import Author, CatalogMod -from pxmodrim.ui.plugins.workshop.installed import PAGE, InstalledList - - -def _mod( - id: str, title: str, author: str = "Someone", tags: list[str] | None = None -) -> CatalogMod: - return CatalogMod( - id=id, - source="steam", - title=title, - author=Author(None, author, None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url="", - tags=tags or [], - supported_versions=[], - created_at=None, - updated_at=None, - file_size=None, - subscriptions=None, - votes=None, - dependencies=[], - package_id=None, - incompatible=False, - ) - - -def test_search_matches_title_author_id_and_tags_case_insensitively() -> None: - installed = InstalledList() - installed.replace( - [ - _mod("11", "Harmony", "Brrainz"), - _mod("12", "HugsLib", tags=["Library"]), - _mod("999", "Other"), - ] - ) - for query, expected in [ - ("harm", ["11"]), - ("BRRAINZ", ["11"]), - ("library", ["12"]), - ("999", ["999"]), - ("", ["11", "12", "999"]), - ("nothing", []), - ]: - installed.search(query) - assert [m.id for m in installed.visible()] == expected, query - - -def test_paging_reveals_one_page_at_a_time_and_search_restarts_it() -> None: - installed = InstalledList() - base = _mod("0", "Mod") - installed.replace( - [msgspec.structs.replace(base, id=str(i)) for i in range(PAGE * 2 + 5)] - ) - assert len(installed.visible()) == PAGE and installed.has_more - second = installed.next_page() - assert len(second) == PAGE and second[0].id == str(PAGE) - assert len(installed.next_page()) == 5 and not installed.has_more - assert installed.next_page() == [] - installed.search("") - assert len(installed.visible()) == PAGE and installed.total == PAGE * 2 + 5 diff --git a/legacy/tests/test_ui/test_workshop_models.py b/legacy/tests/test_ui/test_workshop_models.py deleted file mode 100644 index 1a6592d..0000000 --- a/legacy/tests/test_ui/test_workshop_models.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -import msgspec -import pytest -from PySide6.QtCore import QModelIndex, QObject, Qt - -from pxmodrim.core.workshop import Author, CatalogCollection, CatalogMod, Preview, Votes -from pxmodrim.ui.panels.mod_info_data import description_to_html -from pxmodrim.ui.plugins.workshop.models import ( - CatalogListModel, - item_row, - safe_url, - summarize, -) - - -@pytest.mark.parametrize( - "url", - [ - "javascript:alert(1)", - "file:///tmp/test", - "data:text/plain,hi", - "//example.com", - "https:///path", - "https://example.com/\nsecret", - "https://[bad", - ], -) -def test_link_scheme_filter_rejects_unsafe_urls(url: str) -> None: - assert safe_url(url) == "" - - -@pytest.mark.parametrize( - "url", ["https://example.com/path", "http://example.com", "HTTPS://example.com"] -) -def test_link_scheme_filter_accepts_web_urls(url: str) -> None: - assert safe_url(url) == url - - -def _mod(pid: str) -> CatalogMod: - return CatalogMod( - id=pid, - source="steam", - title=f"Mod {pid}", - author=Author("1", "Author", None), - description="[b]Description[/b]", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=f"https://steamcommunity.com/sharedfiles/filedetails/?id={pid}", - tags=["Utility"], - supported_versions=["1.5"], - created_at=None, - updated_at=100, - file_size="1048576", - subscriptions=None, - votes=None, - dependencies=[], - package_id=None, - incompatible=False, - ) - - -def test_roles_paging_append_and_reset(qapp: object) -> None: - owner = QObject() - model = CatalogListModel(owner) - rows = [item_row(_mod(pid), lambda _: "outdated", "1.6") for pid in ["1", "2", "3"]] - model.set_page(rows[:2], 10, "page2") - assert model.hasMore and model.count == 2 and model.total == 10 - names = {bytes(name.data()).decode() for name in model.roleNames().values()} - assert names == set(rows[0]) - role = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"itemId" - ) - assert model.data(model.index(0), role) == "1" - model.set_page(rows[1:], 10, None, append=True) - assert model.count == 3 and not model.hasMore - assert model.data(model.index(2), role) == "3" - assert model.data(QModelIndex(), role) is None - assert model.data(model.index(0), Qt.ItemDataRole.DisplayRole) is None - assert model.rowCount(model.index(0)) == 0 - model.set_page([], 0, None) - assert model.count == 0 and model.total == 0 - - -def test_refreshing_the_same_page_updates_changed_rows_without_reset( - qapp: object, -) -> None: - owner = QObject() - model = CatalogListModel(owner) - mods = [_mod(pid) for pid in ["1", "2", "3"]] - - def show(items: list[CatalogMod]) -> None: - rows = [item_row(m, lambda _: "missing", "1.5") for m in items] - model.set_page(rows, 3, None) - - show(mods) - resets: list[bool] = [] - changed: list[int] = [] - model.modelReset.connect(lambda: resets.append(True)) - model.dataChanged.connect(lambda first, _last: changed.append(first.row())) - - mods[0] = msgspec.structs.replace(mods[0], subscriptions=500) - mods[1] = msgspec.structs.replace(mods[1], title="Renamed") - show(mods) - assert resets == [] and changed == [1] - title = next(r for r, n in model.roleNames().items() if bytes(n.data()) == b"title") - assert model.data(model.index(1), title) == "Renamed" - - model.set_page( - [item_row(m, lambda _: "missing", "1.5") for m in mods[::-1]], 3, None - ) - assert resets == [True] - - -def test_row_shows_update_and_game_incompatibility() -> None: - row = item_row(_mod("1"), lambda _: "outdated", "1.6") - assert row["actionLabel"] == "Update" and row["incompatible"] - assert row["fileSize"] == "1.0 MB" - - -@pytest.mark.parametrize( - ("votes", "approval", "up", "down"), - [ - (None, "Unrated", "0", "0"), - (Votes(0, 0, 100), "Unrated", "0", "0"), - (Votes(0, 1, None), "0%", "0", "1"), - (Votes(1, 0, None), "100%", "1", "0"), - (Votes(1, 2, None), "33%", "1", "2"), - (Votes(2, 1, None), "67%", "2", "1"), - (Votes(1_000, 3_000, None), "25%", "1,000", "3,000"), - ], -) -def test_vote_approval_and_counts( - votes: Votes | None, approval: str, up: str, down: str -) -> None: - mod = msgspec.structs.replace(_mod("1"), votes=votes) - row = item_row(mod, lambda _: "installed", "1.5") - assert (row["votes"], row["votesUp"], row["votesDown"]) == (approval, up, down) - - -@pytest.mark.parametrize("description_format", ["bbcode", "text"]) -def test_descriptions_use_shared_mod_info_renderer(description_format: str) -> None: - mod = msgspec.structs.replace(_mod("1"), description_format=description_format) - row = item_row(mod, lambda _: "missing", "1.5", detail=True) - assert row["description"] == description_to_html( - mod.description, description_format - ) - - -def test_versions_are_unique_numeric_sorted_and_not_repeated_in_tags() -> None: - mod = _mod("1") - mod = msgspec.structs.replace( - mod, - supported_versions=["1.5", "1.10", "1.2", "1.5", "1.2.1"], - tags=["Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"], - ) - row = item_row(mod, lambda _: "missing", "1.5") - assert row["versions"] == "1.2, 1.2.1, 1.5, 1.10" - assert row["tags"] == "Mod · Utility" - - -def test_collection_image_prefers_own_then_first_image_then_member_collage() -> None: - bare = CatalogCollection( - id="steam:1", - source="steam", - steam_id="1", - title="C", - author=Author("1", "A", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[ - Preview("video", "https://youtube.test/v"), - Preview("image", "https://images.test/own.png"), - ], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["10"], - member_count=1, - member_previews=["https://images.test/m.png", "javascript:alert(1)"], - ) - row = item_row(bare, lambda _: "missing", "1.6") - assert row["previewUrl"] == "https://images.test/own.png" - assert row["collage"] == ["https://images.test/m.png"] - assert (row["votes"], row["votesUp"], row["votesDown"]) == ("Unrated", "0", "0") - assert row["active"] is False - - -def test_summary_is_plain_text_without_bbcode_or_media() -> None: - text = ( - "[h1]Title[/h1] [img]https://x.test/a.png[/img] " - "Adds [url=https://x.test]a thing[/url]" - ) - assert summarize(text) == "Title Adds a thing" - assert summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept" - - -def test_summary_truncates_on_a_word_boundary() -> None: - summary = summarize("word " * 100) - assert summary.endswith("word…") - assert len(summary) <= 141 - - -@pytest.mark.parametrize( - ("supported", "game", "incompatible", "label"), - [ - (["1.5"], "1.5", False, "Works with 1.5"), - (["1.4"], "1.5", True, "Not for 1.5"), - ([], "1.5", False, "Version not stated"), - (["1.5"], "", False, ""), - ], -) -def test_compat_label( - supported: list[str], game: str, incompatible: bool, label: str -) -> None: - mod = msgspec.structs.replace( - _mod("1"), supported_versions=supported, incompatible=incompatible - ) - assert item_row(mod, lambda _: "missing", game)["compatLabel"] == label - - -def test_collection_size_comes_from_the_server_and_is_hidden_when_unknown() -> None: - collection = CatalogCollection( - id="picked:x", - source="picked", - steam_id=None, - title="C", - author=Author(None, "Curator", None), - description="", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["1"], - member_count=1, - ) - unknown = item_row(collection, lambda _: "missing", "1.6") - sized = item_row( - msgspec.structs.replace(collection, total_size=str(3 * 1048576)), - lambda _: "missing", - "1.6", - ) - assert unknown["fileSize"] == "Unknown" - assert sized["fileSize"] == "3.0 MB" - - -def test_collection_without_a_common_version_is_incompatible_not_unstated() -> None: - collection = CatalogCollection( - id="steam:1", - source="steam", - steam_id="1", - title="C", - author=Author(None, "Curator", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["1", "2"], - member_count=2, - no_common_version=True, - ) - row = item_row(collection, lambda _: "missing", "1.6") - assert row["compatLabel"] == "No common version" - assert row["incompatible"] is True - unknown = item_row( - msgspec.structs.replace(collection, no_common_version=False), - lambda _: "missing", - "1.6", - ) - assert unknown["compatLabel"] == "Version not stated" - assert unknown["incompatible"] is False - - -@pytest.mark.parametrize( - ("supported_versions", "tags"), - [ - ([], ["Mod", "1.6"]), - (["1.5"], ["Mod", "1.6"]), - (["1.6"], ["Mod"]), - ], -) -def test_collection_declarations_override_conflicting_member_summary( - supported_versions: list[str], tags: list[str] -) -> None: - collection = CatalogCollection( - id="steam:3814118422", - source="steam", - steam_id="3814118422", - title="12", - author=Author(None, "Milka", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=None, - tags=tags, - supported_versions=supported_versions, - created_at=None, - updated_at=None, - member_ids=["1", "2"], - member_count=2, - no_common_version=True, - ) - row = item_row(collection, lambda _: "missing", "1.6") - assert row["incompatible"] is False - assert row["compatLabel"] == "Works with 1.6" - assert "1.6" in row["versions"] - assert row["tags"] == "Mod" diff --git a/legacy/tests/test_ui/test_workshop_view.py b/legacy/tests/test_ui/test_workshop_view.py deleted file mode 100644 index bdbed93..0000000 --- a/legacy/tests/test_ui/test_workshop_view.py +++ /dev/null @@ -1,1443 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from pathlib import Path -from types import SimpleNamespace -from typing import Any, cast - -import msgspec -import pytest -from PySide6.QtCore import QObject, QPoint, Qt, QUrl -from PySide6.QtGui import QAccessible, QKeySequence -from PySide6.QtQuick import QQuickItem -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QWidget -from pytestqt.qtbot import QtBot -from shiboken6 import delete, isValid - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import DownloadManager -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.workshop import ( - Author, - CatalogCollection, - CatalogError, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, - Preview, - WorkshopCatalog, -) -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.navigation import parse_route -from pxmodrim.ui.panels.mod_info_data import description_to_html -from pxmodrim.ui.plugins.workshop.plugin import CatalogSettingsSection -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -def _mod(pid: str, dependencies: list[str] | None = None) -> CatalogMod: - return CatalogMod( - id=pid, - source="steam", - title="Untrusted title", - author=Author("1", "Author", None), - description="[b]Safe description[/b]", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url="https://steamcommunity.com/sharedfiles/filedetails/?id=" + pid, - tags=["Utility"], - supported_versions=["1.5"], - created_at=None, - updated_at=100, - file_size="1024", - subscriptions=None, - votes=None, - dependencies=dependencies or [], - package_id=None, - incompatible=False, - ) - - -class FakeCatalog(WorkshopCatalog): - def __init__(self, ctx: CoreContext) -> None: - super().__init__() - self.mod_item = _mod("1", ["2"]) - self.dependency = _mod("2") - self.collection_item = CatalogCollection( - id="picked:test", - source="picked", - steam_id=None, - title="Test collection", - author=Author(None, "Curator", None), - description="Plain text", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=["1.6"], - created_at=None, - updated_at=None, - member_ids=["1", "missing"], - member_count=2, - ) - self.failure = False - self.collection_complete = False - self.downloaded: list[str] = [] - self.queries: list[CatalogQuery] = [] - - async def discover(self) -> Discover: - return Discover( - CatalogPage([self.mod_item], 1, None), - CatalogPage([self.collection_item], 1, None), - ) - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - self.queries.append(q) - if self.failure: - raise CatalogError("Catalog unavailable", 503) - return CatalogPage( - [self.dependency] if q.cursor else [self.mod_item], - 2, - None if q.cursor else "second", - ) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return CatalogPage([self.collection_item], 1, None) - - async def mod(self, id: str) -> CatalogMod | None: - return {"1": self.mod_item, "2": self.dependency}.get(id) - - async def collection(self, id: str) -> CollectionDetail | None: - if self.collection_complete: - collection = msgspec.structs.replace( - self.collection_item, member_ids=["1"], member_count=1 - ) - return CollectionDetail(collection, [self.mod_item], [], True) - return CollectionDetail( - self.collection_item, [self.mod_item], ["missing"], False - ) - - async def installed_with_updates(self) -> list[CatalogMod]: - return [self.mod_item] - - def install_state(self, mod: CatalogMod) -> InstallState: - return "outdated" if mod.id == "1" else "missing" - - async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - incomplete = bool(collection_ids) and not self.collection_complete - return DownloadPlan( - ["2", "1"], - [], - ["missing"] if incomplete else [], - collection_ids if incomplete else [], - {"1": "Mod", "2": "Dependency"}, - not incomplete, - ) - - def enqueue(self, plan: DownloadPlan) -> None: - self.downloaded.extend(plan.to_download) - self._queued.update(plan.to_download) - self.queue_changed.emit(None) - - -@pytest.fixture -async def panel( - qapp: object, config_service: ConfigService -) -> AsyncIterator[tuple[WorkshopViewPanel, FakeCatalog, list[Any]]]: - owner = QWidget() - ctx = CoreContext.create(AppConfig(), config_service) - manager = DownloadManager() - ctx.plugins.register(manager) - catalog = FakeCatalog(ctx) - engine = create_qml_engine() - engine.setParent(owner) - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - warnings: list[Any] = [] - - def _on_warning(qml_warnings: list[Any]) -> None: - for w in qml_warnings: - msg = w.toString() if hasattr(w, "toString") else str(w) - if "The current style does not support customization" in msg: - continue - warnings.append(w) - - engine.warnings.connect(_on_warning) - view = WorkshopViewPanel(ctx, engine, owner, catalog=catalog) - view.resize(1100, 800) - yield view, catalog, warnings - if isValid(view): - view.teardown() - view._qml.setSource(QUrl()) - owner.deleteLater() - - -@pytest.fixture -def installed_mods(tmp_path: Path) -> dict[str, ListedMod]: - mods: dict[str, ListedMod] = {} - for provider, pid in [("steamcmd", "1"), ("steam", "1"), ("steam", "2")]: - mod = AboutXmlMod( - name=f"Installed {pid}", - package_id=CaseInsensitiveStr(f"test.mod{pid}"), - provider_id=provider, - _mod_path=tmp_path / provider / pid, - ) - mods[mod.uuid] = mod - return mods - - -async def test_view_loads_qml_and_discovery_without_errors( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, warnings = panel - await asyncio.sleep(0) - assert view._qml.status() == QQuickWidget.Status.Ready - assert view.mods_model.count == 1 and view.collections_model.count == 1 - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_destroyed_view_does_not_receive_core_shutdown_events( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.load() - model = view.mods_model - assert model.count == 1 - notifications: list[None] = [] - catalog.queue_changed.connect(notifications.append) - downloads = view._downloads - ctx = view._ctx - view._qml.setSource(QUrl()) - delete(view) - await catalog.shutdown() - downloads.busy_changed.emit(False) - catalog.installed_changed.emit(None) - catalog.catalog_url_changed.emit("") - ctx.active_state_changed.emit(()) - assert not isValid(model) - assert notifications == [None] - - -async def test_teardown_discards_load_finishing_after_view_destruction( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.load() - started, release = asyncio.Event(), asyncio.Event() - discover = catalog.discover - - async def delayed_discover() -> Discover: - started.set() - await release.wait() - return await discover() - - monkeypatch.setattr(catalog, "discover", delayed_discover) - catalog._responses.clear() - task = asyncio.ensure_future(view.refresh()) - async with asyncio.timeout(3): - await started.wait() - view.teardown() - view.teardown() - model = view.mods_model - view._qml.setSource(QUrl()) - delete(view) - release.set() - await task - assert not isValid(model) - - -async def test_view_paging_filters_and_catalog_errors( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - await view.select_tab("Mods") - await view.filter("", "1.6", "all", "popular", "") - await view.loadMore() - assert view.mods_model.count == 2 and not view.mods_model.hasMore - assert ( - catalog.queries[-1].cursor == "second" and catalog.queries[-1].version == "1.6" - ) - catalog.failure = True - await view.filter("uncached", "1.6", "all", "popular", "") - assert view.error == "Catalog unavailable" - catalog.failure = False - await view.retry() - assert view.error == "" and view.mods_model.count == 1 - - -async def test_mod_detail_safe_description_dependencies_and_download( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, warnings = panel - await asyncio.sleep(0) - await view.open_item("1", "mod") - detail = cast("dict[str, Any]", view.detail) - assert detail["description"] == description_to_html( - catalog.mod_item.description, catalog.mod_item.description_format - ) - assert detail["actionLabel"] == "Update" - assert view.members_model.count == 1 - await view.downloadItem("1", "mod") - assert catalog.downloaded == ["2", "1"] - assert view._ctx.active_uuids == [] - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_incomplete_collection_requires_explicit_partial_download( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, warnings = panel - await asyncio.sleep(0) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert not detail["complete"] and "missing" in detail["warning"] - assert detail["description"] == description_to_html( - catalog.collection_item.description, catalog.collection_item.description_format - ) - await view.downloadItem("picked:test", "collection") - assert view.pendingPlan and not catalog.downloaded - assert view.canDownloadAvailable - await view.downloadAvailable() - assert catalog.downloaded == ["2", "1"] and not view.pendingPlan - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_collection_detail_uses_member_images_when_preview_metadata_is_absent( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, - preview_url=None, - previews=[Preview("image", "https://images.test/member.png")], - ) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["collage"] == ["https://images.test/member.png"] - - -@pytest.mark.parametrize("tab", ["Discover", "Collections", "Favourites"]) -@pytest.mark.parametrize( - ("installed_count", "state", "action_label"), - [ - (0, "missing", "Download all"), - (1, "missing", "Complete download"), - (2, "installed", "Installed"), - ], -) -async def test_collection_listing_detects_installed_members_without_opening_detail( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tab: str, - installed_count: int, - state: str, - action_label: str, -) -> None: - view, catalog, _ = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - installed_ids = catalog.collection_item.member_ids[:installed_count] - view._ctx.load( - { - uuid: mod - for uuid, mod in installed_mods.items() - if mod.published_file_id in installed_ids - }, - [], - ) - await view.load() - if tab == "Favourites": - view.toggleFavourite(catalog.collection_item.id) - await view.select_tab(tab) - - row = view.collections_model._rows[0] - assert row["state"] == state - assert row["actionLabel"] == action_label - assert not view.hasDetail - - -async def test_collection_listing_refreshes_when_installed_members_change( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - await view.select_tab("Collections") - assert view.collections_model._rows[0]["actionLabel"] == "Download all" - - view._ctx.load(installed_mods, []) - await view._on_installed_changed(None) - row = view.collections_model._rows[0] - assert row["state"] == "installed" - assert row["actionLabel"] == "Installed" - - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - await view._on_installed_changed(None) - row = view.collections_model._rows[0] - assert row["state"] == "missing" - assert row["actionLabel"] == "Complete download" - - view._ctx.load({}, []) - await view._on_installed_changed(None) - assert view.collections_model._rows[0]["actionLabel"] == "Download all" - assert not view.hasDetail - - -@pytest.mark.parametrize("fully_installed", [False, True]) -async def test_collection_listing_progress_overrides_installed_member_labels( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, - fully_installed: bool, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - steamcmd, _, _ = installed_mods - view._ctx.load( - installed_mods if fully_installed else {steamcmd: installed_mods[steamcmd]}, - [], - ) - await view.load() - release = asyncio.Event() - plan = catalog.plan - - async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - await release.wait() - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", gated_plan) - planning = asyncio.ensure_future(view._download([], [catalog.collection_item.id])) - try: - await asyncio.sleep(0) - row = view.collections_model._rows[0] - assert row["queued"] and row["actionLabel"] == "Preparing…" - finally: - release.set() - await planning - row = view.collections_model._rows[0] - assert row["queued"] and row["actionLabel"] == "Queued" - catalog.cancel_queue() - assert view.collections_model._rows[0]["actionLabel"] == ( - "Installed" if fully_installed else "Complete download" - ) - - -async def test_installed_collection_listing_and_resolved_detail_agree( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1"], member_count=1 - ) - catalog._responses.clear() - view._ctx.load(installed_mods, []) - await view.load() - listing = view.collections_model._rows[0] - assert listing["state"] == "installed" - assert listing["actionLabel"] == "Installed" - - await view.open_item(catalog.collection_item.id, "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["state"] == listing["state"] - assert detail["actionLabel"] == listing["actionLabel"] - - -async def test_collection_download_button_tracks_its_queued_mods( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - await view.load() - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert not detail["queued"] - release = asyncio.Event() - plan = catalog.plan - - async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - await release.wait() - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", gated_plan) - planning = asyncio.ensure_future(view._download([], ["picked:test"])) - await asyncio.sleep(0) - detail = cast("dict[str, Any]", view.detail) - assert detail["queued"] and detail["actionLabel"] == "Preparing…" - release.set() - await planning - detail = cast("dict[str, Any]", view.detail) - assert detail["queued"] and detail["actionLabel"] == "Queued" - model = view.collections_model - queued = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"queued" - ) - assert model.data(model.index(0), queued) is True - catalog.cancel_queue() - detail = cast("dict[str, Any]", view.detail) - assert not detail["queued"] and detail["actionLabel"] == "Complete download" - - -async def test_unset_catalog_never_fetches( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - catalog.set_base_url("") - await view.load() - assert not view.configured and not catalog.queries - - -async def test_settings_saves_and_applies_catalog_url( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - from pxmodrim.ui.panels.settings_panel import SettingsPanel - - view, catalog, warnings = panel - await asyncio.sleep(0) - settings = SettingsPanel( - view._ctx, - view._qml.engine(), - view, - [lambda parent: CatalogSettingsSection(catalog.settings, parent)], - ) - settings.show() - await asyncio.sleep(0.2) - root = settings._qml.rootObject() - assert root is not None - field = _find_item(cast("QQuickItem", root), "catalogUrlField") - assert field is not None - assert field.property("text") == "https://api.modrim.pyxiion.dev" - field.forceActiveFocus() - QTest.keySequence(settings._qml, QKeySequence.StandardKey.SelectAll) - QTest.keyClicks(settings._qml, " https://catalog.example.test/ ") - assert catalog.configured and catalog._base_url != "https://catalog.example.test" - - settings._save(dict(cast("dict[str, Any]", settings._backend.initial))) - assert catalog.settings.value.url == "https://catalog.example.test/" - assert catalog._base_url == "https://catalog.example.test" - QTest.qWait(10) - delete(settings) - assert warnings == [] - - -async def test_installed_search_filters_loaded_mods_without_refetching( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - fetches = 0 - base = catalog.mod_item - mods = [ - msgspec.structs.replace(base, id=str(i), title=title) - for i, title in enumerate(["Harmony", "HugsLib", "Rim HUD"]) - ] - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - return mods - - catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.select_tab("Installed") - assert view.mods_model.count == 3 and fetches == 1 - - await view.filter("hugs", "", "all", "popular", "") - assert view.mods_model.count == 1 and view.tab == "Installed" and fetches == 1 - await view.filter("", "", "all", "popular", "") - assert view.mods_model.count == 3 and fetches == 1 - assert view.searchQuery == "" - - -async def test_installed_set_changes_refresh_the_cached_list( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tmp_path: Path, -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - catalog._cache_dir = tmp_path / "cache" - steamcmd, _, dependency = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - fetches = 0 - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in catalog._installed()] - - catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.select_tab("Installed") - await view.select_tab("Mods") - await view.select_tab("Installed") - assert fetches == 1 and view.mods_model.count == 1 - - catalog.installed_changed.disconnect(view._on_installed_changed) - view._ctx.load(installed_mods, []) - catalog._on_installed_changed(None) - catalog.installed_changed.connect(view._on_installed_changed) - await view._on_installed_changed(None) - assert fetches == 2 and view.mods_model.count == 2 - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - catalog.installed_changed.disconnect(view._on_installed_changed) - view._ctx.load({dependency: installed_mods[dependency]}, []) - catalog._on_installed_changed(None) - catalog.installed_changed.connect(view._on_installed_changed) - await view._on_installed_changed(None) - assert fetches == 3 and view.mods_model.count == 1 - assert view.mods_model._rows[0]["itemId"] == "2" - - catalog._responses.clear() - await view.select_tab("Mods") - await view.select_tab("Installed") - assert fetches == 3 and view.mods_model._rows[0]["itemId"] == "2" - - -@pytest.mark.parametrize("state", ["installed", "outdated"]) -@pytest.mark.parametrize("duplicate_active", [False, True]) -async def test_activation_toggles_one_installed_copy_and_prefers_active_steam_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, - state: InstallState, - duplicate_active: bool, -) -> None: - view, catalog, _ = panel - steamcmd, steam, unrelated = installed_mods - view._ctx.load(installed_mods, []) - view._ctx.set_active( - [steamcmd, steam, unrelated] if duplicate_active else [steam, unrelated] - ) - monkeypatch.setattr(catalog, "install_state", lambda _: state) - await view.load() - await view.open_item("1", "mod") - role = next( - role - for role, name in view.mods_model.roleNames().items() - if bytes(name.data()) == b"active" - ) - assert view.mods_model.data(view.mods_model.index(0), role) is True - assert cast("dict[str, Any]", view.detail)["active"] is True - expected_action = "Update" if state == "outdated" else "Installed" - assert cast("dict[str, Any]", view.detail)["actionLabel"] == expected_action - - await view.toggleActivation("1") - assert view._ctx.active_uuids == [unrelated] - assert view.mods_model.data(view.mods_model.index(0), role) is False - assert cast("dict[str, Any]", view.detail)["active"] is False - - await view.toggleActivation("1") - assert set(view._ctx.active_uuids) == {steamcmd, unrelated} - assert steam not in view._ctx.active_uuids - assert view.mods_model.data(view.mods_model.index(0), role) is True - assert cast("dict[str, Any]", view.detail)["active"] is True - - -async def test_external_activation_refreshes_listing_detail_and_members( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, _, _ = panel - _, steam, dependency = installed_mods - view._ctx.load(installed_mods, []) - await view.load() - await view.open_item("1", "mod") - role = next( - role - for role, name in view.mods_model.roleNames().items() - if bytes(name.data()) == b"active" - ) - - for active, expected in [([steam, dependency], True), ([], False)]: - view._ctx.set_active(active) - assert view.mods_model.data(view.mods_model.index(0), role) is expected - assert view.members_model.data(view.members_model.index(0), role) is expected - assert cast("dict[str, Any]", view.detail)["active"] is expected - - -async def test_download_never_activates_installed_copies( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - view._ctx.load(installed_mods, []) - await view.downloadItem("1", "mod") - assert catalog.downloaded == ["2", "1"] - assert view._ctx.active_uuids == [] - - -def _find_item(item: QQuickItem, name: str) -> QQuickItem | None: - if item.objectName() == name: - return item - for child in item.childItems(): - found = _find_item(child, name) - if found is not None: - return found - return None - - -async def test_installed_checkbox_click_toggles_activation_without_opening_details( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - view.window().resize(1100, 800) - view.window().show() - await view.select_tab("Installed") - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - - root = view._qml.rootObject() - assert root is not None - box = _find_item(cast("QQuickItem", root), "activeToggle") - assert box is not None - center = box.mapToScene(box.boundingRect().center()).toPoint() - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) - async with asyncio.timeout(3): - while not view._ctx.active_uuids: - await asyncio.sleep(0.02) - assert view._ctx.active_uuids == [steamcmd] - assert not view.hasDetail - - -async def test_clicking_an_installed_row_navigates_by_link( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - links: list[str] = [] - - def navigate(url: str) -> None: - links.append(url) - route = parse_route(url) - assert route is not None - view.open_route(route.path) - - view._app_ctx = cast("AppContext", SimpleNamespace(navigate=navigate)) - view.window().resize(1100, 800) - view.window().show() - await view.select_tab("Installed") - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - - root = view._qml.rootObject() - assert root is not None - box = _find_item(cast("QQuickItem", root), "activeToggle") - assert box is not None - beside = box.mapToScene(box.boundingRect().center()).toPoint() - beside.setX(beside.x() + 150) - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=beside) - async with asyncio.timeout(3): - while not view.hasDetail: - await asyncio.sleep(0.02) - assert links == ["modrim://workshop/mod/1"] - assert view.url == "modrim://workshop/mod/1" - assert not view._ctx.active_uuids - - -async def test_activation_does_nothing_without_an_installed_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.toggleActivation("1") - assert view._ctx.active_uuids == [] - assert catalog.downloaded == [] - - -async def test_teardown_disconnects_activation_and_blocks_toggles( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, _, _ = panel - _, steam, _ = installed_mods - view._ctx.load(installed_mods, []) - await view.load() - await view.open_item("1", "mod") - notifications: list[bool] = [] - view.changed.connect(lambda: notifications.append(True)) - view.teardown() - view.teardown() - view._ctx.set_active([steam]) - await view.toggleActivation("1") - assert view._ctx.active_uuids == [steam] - assert not notifications - assert cast("dict[str, Any]", view.detail)["active"] is False - - -async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, tags=["Utility", "1.5"] - ) - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, tags=["Curated", "1.6"] - ) - catalog.dependency = msgspec.structs.replace( - catalog.dependency, tags=["Dependency", "1.5"] - ) - catalog._responses.clear() - await view.load() - assert view.tagOptions == ["All tags", "Curated", "Utility"] - await view.filter("utility", "1.5", "all", "popular", "Utility") - assert view.searchQuery == "utility" - assert view.tagOptions == ["All tags", "Curated", "Utility"] - await view.open_item("1", "mod") - assert view.tagOptions == ["All tags", "Curated", "Dependency", "Utility"] - await view.select_tab("Installed") - assert view.searchQuery == "utility" - - -async def test_back_walks_detail_history_before_returning_to_listing( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.select_tab("Mods") - await view.open_item("1", "mod") - await view.open_item("2", "mod") - assert view.url == "modrim://workshop/mod/2" - assert view.backLabel == f"Back to {catalog.mod_item.title}" - - await view.back() - assert cast("dict[str, Any]", view.detail)["itemId"] == "1" - assert view.url == "modrim://workshop/mod/1" - assert view.backLabel == "Back to Mods" - - await view.back() - assert not view.hasDetail - assert view.url == "modrim://workshop/mods" - - -async def test_failed_back_keeps_the_previous_page_for_retry( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.open_item("1", "mod") - await view.open_item("2", "mod") - catalog._responses.clear() - working = catalog.mod - - async def unavailable(id: str) -> CatalogMod | None: - raise CatalogError("Catalog unavailable", 503) - - monkeypatch.setattr(catalog, "mod", unavailable) - await view.back() - assert view.url == "modrim://workshop/mod/2" - assert view.backLabel == f"Back to {catalog.mod_item.title}" - - monkeypatch.setattr(catalog, "mod", working) - await view.back() - assert view.url == "modrim://workshop/mod/1" - - -async def test_routes_open_tabs_and_items_like_links( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, _ = panel - await view.open_item("1", "mod") - await view.open_item("2", "mod") - - await view.follow_route(("collection", "picked:test")) - assert view.url == "modrim://workshop/collection/picked%3Atest" - await view.back() - assert view.url == "modrim://workshop/mod/2" - - await view.follow_route(("installed",)) - assert view.tab == "Installed" - assert view.url == "modrim://workshop/installed" - - await view.follow_route(()) - assert view.url == "modrim://workshop/installed" - - -async def test_starred_collection_is_listed_in_favourites_until_unstarred( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, warnings = panel - view.window().resize(1100, 800) - view.window().show() - await view.follow_route(("collections",)) - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - root = view._qml.rootObject() - assert root is not None - star = _find_item(cast("QQuickItem", root), "favouriteToggle") - assert star is not None - center = star.mapToScene(star.boundingRect().center()).toPoint() - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) - assert not view.hasDetail - - await view.follow_route(("favourites",)) - model = view.collections_model - assert view.url == "modrim://workshop/favourites" - assert model.count == 1 - favourite = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"favourite" - ) - assert model.data(model.index(0), favourite) is True - - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["favourite"] is True - view.toggleFavourite("picked:test") - assert cast("dict[str, Any]", view.detail)["favourite"] is False - await view.back() - assert view.tab == "Favourites" and model.count == 0 - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_back_preserves_scroll_position_and_active_filters( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - many = [ - msgspec.structs.replace(catalog.mod_item, id=str(number)) - for number in range(40) - ] - - async def long_listing(query: CatalogQuery) -> CatalogPage[CatalogMod]: - return CatalogPage(many, len(many), None) - - catalog.mods = long_listing # type: ignore[method-assign] - view.window().resize(800, 600) - view.window().show() - await view.load() - await asyncio.sleep(0.2) - view._qml.grab() - await view.filter("test search", "1.5", "all", "popular", "Utility") - await asyncio.sleep(0.2) - view._qml.grab() - assert view.searchQuery == "test search" - - root = view._qml.rootObject() - assert root is not None - listing_scroll = root.findChild(QObject, "listingScroll") - assert listing_scroll is not None - listing_scroll.setProperty("width", 800) - listing_scroll.setProperty("height", 600) - listing_scroll.setProperty("contentY", 184) - view._qml.grab() - assert listing_scroll.property("contentY") == 184 - - await view.open_item("1", "mod") - assert view.hasDetail - - await view.back() - await asyncio.sleep(0.2) - view._qml.grab() - assert not view.hasDetail - assert view.tab == "Mods" - assert view.searchQuery == "test search" - assert listing_scroll.property("contentY") == 184 - - -async def test_downloaded_collection_activates_all_or_only_its_pack( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tmp_path: Path, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, _, dependency = installed_mods - outside = AboutXmlMod( - name="Outside", - package_id=CaseInsensitiveStr("test.outside"), - provider_id="steam", - _mod_path=tmp_path / "steam" / "9", - ) - local = AboutXmlMod( - name="Local", - package_id=CaseInsensitiveStr("test.local"), - provider_id="local", - _mod_path=tmp_path / "local" / "Mine", - ) - view._ctx.load({**installed_mods, outside.uuid: outside, local.uuid: local}, []) - view._ctx.set_active([outside.uuid, local.uuid]) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["state"] == "installed" and detail["active"] is False - - await view.toggleCollection() - assert set(view._ctx.active_uuids) == { - steamcmd, - dependency, - outside.uuid, - local.uuid, - } - assert cast("dict[str, Any]", view.detail)["active"] is True - - await view.toggleCollection() - assert set(view._ctx.active_uuids) == {outside.uuid, local.uuid} - - await view.activateOnlyCollection() - assert set(view._ctx.active_uuids) == {steamcmd, dependency, local.uuid} - - -async def test_collection_deactivation_removes_every_active_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - view._ctx.load(installed_mods, list(installed_mods)) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["active"] is True - - await view.toggleCollection() - - assert view._ctx.active_uuids == [] - assert cast("dict[str, Any]", view.detail)["active"] is False - - -async def test_activate_only_collection_keeps_dependents_of_retained_duplicate( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - from PySide6.QtWidgets import QMessageBox - - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, steam, dependent = installed_mods - mod = cast("AboutXmlMod", installed_mods[dependent]) - mod.about_rules = BaseRules( - dependencies={ - CaseInsensitiveStr("test.mod1"): DependencyMod( - name="Dependency", package_id=CaseInsensitiveStr("test.mod1") - ) - } - ) - view._ctx.load(installed_mods, [steamcmd, steam, dependent]) - view._ctx.diagnostics_service.rebuild() - prompts: list[object] = [] - - async def accept_dependents(*args: object) -> tuple[int, None]: - prompts.append(args) - return QMessageBox.StandardButton.Yes, None - - monkeypatch.setattr( - "pxmodrim.ui.components.mod_activation.await_dialog", accept_dependents - ) - await view.open_item("picked:test", "collection") - await view.activateOnlyCollection() - - assert not prompts - assert set(view._ctx.active_uuids) == {steamcmd, dependent} - assert cast("dict[str, Any]", view.detail)["active"] is True - - -async def test_partly_downloaded_collection_has_no_activation( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["state"] == "missing" - await view.toggleCollection() - await view.activateOnlyCollection() - assert view._ctx.active_uuids == [] - - -async def test_incomplete_collection_cannot_activate_only_its_available_members( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - view._ctx.load(installed_mods, list(installed_mods)) - active_before = view._ctx.active_uuids - - async def incomplete_plan( - mod_ids: list[str], collection_ids: list[str] - ) -> DownloadPlan: - return DownloadPlan([], ["1"], ["2"], [], {}, False) - - monkeypatch.setattr(catalog, "plan", incomplete_plan) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["state"] == "missing" - await view.toggleCollection() - await view.activateOnlyCollection() - assert view._ctx.active_uuids == active_before - - -async def test_installed_changes_during_fetch_are_loaded_after_it_finishes( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.load() - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - started, release = asyncio.Event(), asyncio.Event() - requested: list[list[str]] = [] - - async def installed() -> list[CatalogMod]: - ids = list(catalog._installed()) - requested.append(ids) - if len(requested) == 1: - started.set() - await release.wait() - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in ids] - - monkeypatch.setattr(catalog, "installed_with_updates", installed) - view._tab = "Installed" - task = asyncio.create_task(view.load()) - async with asyncio.timeout(3): - await started.wait() - view._ctx.load(installed_mods, []) - catalog._installed_index = None - await view._on_installed_changed(None) - release.set() - await task - assert requested == [["1"], ["1", "2"]] - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - -async def test_installed_changes_preserve_details_and_reload_when_going_back( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - - async def installed() -> list[CatalogMod]: - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in catalog._installed()] - - monkeypatch.setattr(catalog, "installed_with_updates", installed) - await view.select_tab("Installed") - await view.open_item("1", "mod") - view._ctx.load(installed_mods, []) - catalog._installed_index = None - await view._on_installed_changed(None) - assert view.hasDetail and cast("dict[str, Any]", view.detail)["itemId"] == "1" - await view.back() - assert not view.hasDetail - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - -@pytest.mark.parametrize("failure", [False, True]) -async def test_download_plan_finishing_after_destruction_is_discarded( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, - failure: bool, -) -> None: - view, catalog, _ = panel - await view.load() - started, release = asyncio.Event(), asyncio.Event() - plan = catalog.plan - - async def delayed_plan( - mod_ids: list[str], collection_ids: list[str] - ) -> DownloadPlan: - started.set() - await release.wait() - if failure: - raise CatalogError("Catalog unavailable", 503) - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", delayed_plan) - task = asyncio.create_task(view._download(["1"], [])) - async with asyncio.timeout(3): - await started.wait() - view.teardown() - view._qml.setSource(QUrl()) - delete(view) - release.set() - await task - assert not catalog.downloaded - - -async def test_revisiting_installed_within_freshness_does_not_refetch( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - fetches = 0 - mods = [catalog.mod_item] - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - return mods - - catalog.installed_with_updates = installed # type: ignore[method-assign] - for tab in ("Installed", "Mods", "Installed"): - await view.select_tab(tab) - assert fetches == 1 and view.mods_model.count == 1 - - -@pytest.mark.parametrize( - ("control", "tooltip_binding"), - [("PxButton", "ToolTip.text"), ("PxBadge", "tooltip")], -) -def test_card_controls_show_tooltips_lazily_on_hover( - qapp: object, - qtbot: QtBot, - tmp_path: Path, - control: str, - tooltip_binding: str, -) -> None: - owner = QWidget() - owner.resize(300, 160) - engine = create_qml_engine(owner) - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - controls = QUrl.fromLocalFile( - str(Path(__file__).parents[2] / "src/pxmodrim/ui/components/controls") - ).toString() - source = tmp_path / "Tooltips.qml" - source.write_text( - f'import QtQuick\nimport QtQuick.Controls\nimport "{controls}"\n' - "Rectangle {\n" - ' property string tipText: ""\n' - f" {control} {{\n" - ' objectName: "target"; x: 20; y: 20; text: "Test action"\n' - f" {tooltip_binding}: parent.tipText\n" - " }\n" - "}\n" - ) - widget = QQuickWidget(engine, owner) - widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - widget.resize(300, 160) - widget.setSource(QUrl.fromLocalFile(str(source))) - owner.show() - widget.show() - root = widget.rootObject() - assert root is not None - target = root.findChild(QObject, "target") - assert target is not None - - def tooltip() -> QObject | None: - return next( - ( - obj - for obj in root.findChildren(QObject) - if obj.inherits("QQuickToolTip") - ), - None, - ) - - try: - qtbot.mouseMove(widget, QPoint(280, 140)) - assert tooltip() is None - root.setProperty("tipText", "Help for this action") - assert tooltip() is None - qtbot.mouseMove(widget, QPoint(30, 25)) - qtbot.waitUntil( - lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), - timeout=3000, - ) - tip = tooltip() - assert tip is not None - assert tip.property("text") == "Help for this action" - assert tip.property("delay") == theme.tooltipDelay - assert tip.property("parent") == target - accessible = QAccessible.queryAccessibleInterface(target) - assert accessible is not None - assert accessible.text(QAccessible.Text.Name) == "Test action" - qtbot.mouseMove(widget, QPoint(280, 140)) - qtbot.waitUntil(lambda: tooltip() is None) - qtbot.mouseMove(widget, QPoint(30, 25)) - qtbot.waitUntil( - lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), - timeout=3000, - ) - root.setProperty("tipText", "") - qtbot.waitUntil(lambda: tooltip() is None) - finally: - widget.setSource(QUrl()) - owner.close() - owner.deleteLater() - - -@pytest.mark.parametrize("kind", ["collection", "mod"]) -@pytest.mark.parametrize("long_description", [False, True]) -async def test_detail_section_layout_and_jump_navigation( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, - kind: str, - long_description: bool, -) -> None: - view, catalog, warnings = panel - members: list[CatalogMod | CatalogCollection] = [ - _mod(str(index)) for index in range(2, 14) - ] - description = "\n\n".join( - f"Paragraph {index}: a detailed explanation of this Workshop item." - for index in range(70 if long_description else 1) - ) - collection = msgspec.structs.replace( - catalog.collection_item, - description=description, - member_ids=[member.id for member in members], - member_count=len(members), - ) - - async def collection_detail(_: str) -> CollectionDetail: - return CollectionDetail(collection, members, [], True) - - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, - description=description, - description_format="text", - dependencies=[member.id for member in members], - ) - - async def mod_detail(item_id: str) -> CatalogMod | None: - return next( - (mod for mod in [catalog.mod_item, *members] if mod.id == item_id), None - ) - - monkeypatch.setattr(catalog, "collection", collection_detail) - monkeypatch.setattr(catalog, "mod", mod_detail) - view.setParent(None) - view.resize(1300, 1000) - view.show() - try: - await asyncio.sleep(0) - await view.load() - await view.open_item(collection.id if kind == "collection" else "1", kind) - await asyncio.sleep(0.05) - view._qml.grabFramebuffer() - root = cast("QQuickItem", view._qml.rootObject()) - desc = _find_item(root, "detailDescription") - members_section = _find_item(root, "detailMembers") - heading = _find_item(root, "membersHeading") - grid = _find_item(root, "membersGrid") - scroll = _find_item(root, "detailScroll") - jump = _find_item(root, "jumpToMembers") - back = _find_item(root, "detailBackToTop") - assert all( - item is not None - for item in (desc, members_section, heading, grid, scroll, jump, back) - ) - assert desc is not None and members_section is not None - assert heading is not None and scroll is not None and grid is not None - assert jump is not None and back is not None - if kind == "collection": - assert members_section.y() >= desc.y() + desc.height() - assert grid.property("columns") == 3 - else: - assert members_section.y() == pytest.approx(desc.y()) - assert members_section.x() >= desc.x() + desc.width() - assert grid.property("columns") == 1 - assert jump.isVisible() is long_description - assert not back.isVisible() - flickable = cast("QQuickItem", scroll.property("contentItem")) - if long_description: - content = cast("QQuickItem", flickable.property("contentItem")) - target = heading.mapToItem(content, heading.boundingRect().topLeft()).y() - maximum = max(0, flickable.property("contentHeight") - flickable.height()) - QTest.mouseClick( - view._qml, - Qt.MouseButton.LeftButton, - pos=jump.mapToScene(jump.boundingRect().center()).toPoint(), - ) - await asyncio.sleep(0.05) - view._qml.grabFramebuffer() - assert flickable.property("contentY") == pytest.approx( - max(0, min(target, maximum)) - ) - assert flickable.property("contentY") > 0 - assert back.isVisible() - QTest.mouseClick( - view._qml, - Qt.MouseButton.LeftButton, - pos=back.mapToScene(back.boundingRect().center()).toPoint(), - ) - await asyncio.sleep(0.05) - assert flickable.property("contentY") == 0 - assert not back.isVisible() - assert not warnings, [warning.toString() for warning in warnings] - finally: - view.close() - - -@pytest.mark.parametrize( - ("tags", "versions", "visible"), - [ - (["Mod"], ["1.6"], True), - ([], ["1.6"], True), - (["Mod"], [], True), - ([], [], False), - ], -) -async def test_detail_metadata_preserves_tags_and_versions( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - tags: list[str], - versions: list[str], - visible: bool, -) -> None: - view, catalog, warnings = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, tags=tags, supported_versions=versions - ) - await asyncio.sleep(0) - await view.load() - await view.open_item(catalog.collection_item.id, "collection") - root = cast("QQuickItem", view._qml.rootObject()) - metadata = _find_item(root, "detailTags") - assert metadata is not None - assert metadata.property("visible") is visible - displayed = metadata.property("text") - for value in tags + versions: - assert value in displayed - assert not warnings, [warning.toString() for warning in warnings] diff --git a/legacy/tests/test_workshop/conftest.py b/legacy/tests/test_workshop/conftest.py deleted file mode 100644 index 525171e..0000000 --- a/legacy/tests/test_workshop/conftest.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest - - -@pytest.fixture -def mod_payload() -> dict[str, Any]: - return { - "id": "2009463077", - "kind": "mod", - "source": "steam", - "title": "Harmony", - "author": { - "id": "76561198000000000", - "name": "Author", - "profile_url": "https://steamcommunity.com/profiles/76561198000000000", - }, - "description": "[b]Untrusted[/b] ", - "description_format": "bbcode", - "preview_url": "https://images.example/harmony.jpg", - "previews": [ - {"type": "image", "url": "https://images.example/harmony.jpg"}, - {"type": "video", "url": "https://www.youtube.com/watch?v=example"}, - ], - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2009463077", - "tags": ["1.6", "Libraries"], - "supported_versions": ["1.5", "1.6"], - "created_at": 1600000000, - "updated_at": 1700000000, - "file_size": "18446744073709551615", - "subscriptions": 12345, - "votes": {"up": 9, "down": 1, "positive_percent": 90}, - "dependencies": ["818773962"], - "package_id": "brrainz.harmony", - "incompatible": False, - } - - -@pytest.fixture -def collection_payload() -> dict[str, Any]: - return { - "id": "picked:starter", - "kind": "collection", - "source": "picked", - "steam_id": None, - "title": "Starter pack", - "author": {"id": None, "name": "PxModRim", "profile_url": None}, - "description": "Plain text", - "description_format": "text", - "preview_url": None, - "previews": [], - "workshop_url": None, - "tags": ["Quality of Life"], - "supported_versions": ["1.6"], - "created_at": 1600000000, - "updated_at": 1700000000, - "member_ids": ["2009463077"], - "member_count": 1, - "member_previews": [], - "total_size": None, - "no_common_version": False, - } diff --git a/legacy/tests/test_workshop/test_catalog.py b/legacy/tests/test_workshop/test_catalog.py deleted file mode 100644 index e28d4f6..0000000 --- a/legacy/tests/test_workshop/test_catalog.py +++ /dev/null @@ -1,405 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -import httpx -import msgspec -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads.manager import download_manager -from pxmodrim.core.downloads.steam import SteamDownloader, WorkshopSyncState -from pxmodrim.core.downloads.types import DownloadResult -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.workshop import ( - CatalogClient, - CatalogError, - CatalogMod, - CatalogSettings, - DownloadPlan, - WorkshopCatalog, -) - - -def make_context(tmp_path: Path) -> tuple[CoreContext, SteamDownloader]: - config = AppConfig() - config.paths.local = str(tmp_path / "Mods") - config.paths.workshop = str(tmp_path / "Workshop") - ctx = CoreContext(config, ConfigService(tmp_path)) - ctx._mod_service = ModService(ctx, []) - downloader = SteamDownloader() - downloader.setup(ctx) - downloader._sync = WorkshopSyncState(synced={"1": 200, "2": 50}) - mods = [ - ListedMod(_mod_path=tmp_path / "Mods" / "1"), - ListedMod(_mod_path=tmp_path / "Mods" / "2"), - ListedMod(_mod_path=tmp_path / "Workshop" / "3", provider_id="workshop"), - ListedMod(_mod_path=tmp_path / "Mods" / "4"), - ListedMod(_mod_path=tmp_path / "Mods" / "NotWorkshop"), - ] - ctx.load({mod.uuid: mod for mod in mods}, [mods[0].uuid]) - return ctx, downloader - - -def decode_mod( - payload: dict[str, Any], id: str, updated: int | None = 100 -) -> CatalogMod: - return msgspec.json.decode( - msgspec.json.encode({**payload, "id": id, "updated_at": updated}), - type=CatalogMod, - ) - - -async def test_install_state_uses_sync_not_directory_mtime( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - assert catalog.install_state(decode_mod(mod_payload, "1")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "2")) == "outdated" - assert catalog.install_state(decode_mod(mod_payload, "3")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "4")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "5")) == "missing" - assert catalog.install_state(decode_mod(mod_payload, "2", None)) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "2", 50)) == "installed" - await catalog.shutdown() - - -async def test_plan_keeps_dependency_order_and_incomplete_flags( - tmp_path: Path, mod_payload: dict[str, Any], collection_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - active_before = ctx.active_uuids - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - json={ - "roots": ["5", "picked:starter"], - "items": { - **{ - id: { - **mod_payload, - "id": id, - "updated_at": 100, - "title": f"Title {id}", - } - for id in ["1", "2", "3", "4", "5"] - }, - "picked:starter": collection_payload, - }, - "mod_ids": ["1", "2", "3", "4", "5"], - "unavailable_ids": ["99"], - "incomplete_collection_ids": ["picked:starter"], - "is_complete": False, - }, - ) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)) - ) - catalog.setup(ctx) - try: - plan = await catalog.plan(["5"], ["picked:starter"]) - assert plan.to_download == ["2", "5"] - assert plan.already_current == ["1", "3", "4"] - assert plan.unavailable_ids == ["99"] - assert plan.incomplete_collection_ids == ["picked:starter"] - assert not plan.is_complete - assert plan.titles["2"] == "Title 2" - assert ctx.active_uuids == active_before - assert msgspec.json.decode(requests[0].content) == { - "ids": ["5"], - "collection_ids": ["picked:starter"], - } - finally: - await catalog.shutdown() - - -async def test_installed_metadata_uses_batch( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - requested: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requested.extend( - msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] - ) - return httpx.Response( - 200, - json={ - "items": [{**mod_payload, "id": "2", "updated_at": 100}], - "unavailable_ids": ["1", "3", "4"], - }, - ) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)) - ) - catalog.setup(ctx) - try: - installed = await catalog.installed_with_updates() - assert requested == ["1", "2", "3", "4"] - assert catalog.install_state(installed[0]) == "outdated" - finally: - await catalog.shutdown() - - -async def test_download_delegates_without_activation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - calls: list[list[str]] = [] - result = DownloadResult(succeeded=["2"], failed=[]) - - async def download(ids: list[str]) -> DownloadResult: - calls.append(ids) - return result - - monkeypatch.setattr(download_manager(ctx), "download_mods", download) - active_before = ctx.active_uuids - try: - plan = DownloadPlan(["2"], ["1"], [], [], {"2": "Mod"}, True) - assert await catalog.download(plan) is result - assert calls == [["2"]] - assert ctx.active_uuids == active_before - finally: - await catalog.shutdown() - - -async def test_empty_url_disabled_without_constructing_client(tmp_path: Path) -> None: - ctx, _ = make_context(tmp_path) - clients: list[str] = [] - - def factory(url: str) -> CatalogClient: - clients.append(url) - raise AssertionError("Disabled catalog must not create a client") - - catalog = WorkshopCatalog(factory) - catalog.setup(ctx) - catalog.set_base_url("") - try: - assert not catalog.configured - for operation in [ - catalog.discover(), - catalog.installed_with_updates(), - catalog.plan(["1"], []), - ]: - with pytest.raises(CatalogError, match="disabled"): - await operation - assert clients == [] - finally: - await catalog.shutdown() - - -async def test_configuration_and_installed_events( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - transports: list[httpx.AsyncClient] = [] - urls: list[str] = [] - - def factory(url: str) -> CatalogClient: - urls.append(url) - client = CatalogClient( - url, httpx.MockTransport(lambda _: httpx.Response(200, json=mod_payload)) - ) - transports.append(client._http) - return client - - catalog = WorkshopCatalog(factory) - catalog.setup(ctx) - changed: list[str] = [] - installed: list[None] = [] - catalog.catalog_url_changed.connect(changed.append) - catalog.installed_changed.connect(installed.append) - await catalog.mod("2009463077") - catalog.set_base_url(" https://other.example/ ") - assert catalog.settings.value.url == "https://other.example" - stored = ConfigService(tmp_path).load_existing( - "plugins/workshop_catalog.json", CatalogSettings - ) - assert stored == CatalogSettings("https://other.example") - await catalog.mod("2009463077") - catalog.set_base_url("") - assert not catalog.configured - with pytest.raises(CatalogError, match="disabled"): - await catalog.discover() - ctx.mod_service.mods_changed.emit(None) - download_manager(ctx).download_finished.emit(DownloadResult([], [])) - assert installed == [None, None] - assert changed == ["https://other.example", ""] - assert urls == ["https://api.modrim.pyxiion.dev", "https://other.example"] - await catalog.shutdown() - assert all(transport.is_closed for transport in transports) - ctx.mod_service.mods_changed.emit(None) - assert installed == [None, None] - - -async def test_cached_serves_remembered_value_before_refreshing( - tmp_path: Path, -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - values = iter(["first", "second", "second"]) - calls = 0 - - async def produce() -> str: - nonlocal calls - calls += 1 - return next(values) - - async def seen(fresh_for: float) -> list[str]: - return [value async for value in catalog.cached("k", produce, fresh_for)] - - try: - assert await seen(300) == ["first"] - assert await seen(300) == ["first"] and calls == 1 - assert await seen(0) == ["first", "second"] and calls == 2 - assert await seen(0) == ["second"] and calls == 3 - finally: - await catalog.shutdown() - - -async def test_enqueue_runs_batches_in_order_after_the_running_download( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - ctx, downloader = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - manager = download_manager(ctx) - manager.register(downloader) - calls: list[list[str]] = [] - busy = True - - async def download(ids: list[str]) -> DownloadResult: - calls.append(ids) - return DownloadResult(succeeded=ids, failed=[]) - - monkeypatch.setattr(manager, "download_mods", download) - monkeypatch.setattr(type(manager), "is_downloading", property(lambda _self: busy)) - try: - catalog.enqueue(DownloadPlan(["5", "6"], [], [], [], {}, True)) - catalog.enqueue(DownloadPlan(["6", "7"], [], [], [], {}, True)) - assert catalog.queued_ids == {"5", "6", "7"} - await asyncio.sleep(0.05) - assert calls == [] - busy = False - manager.busy_changed.emit(False) - await asyncio.wait_for(catalog._runner, 2) # type: ignore[arg-type] - assert calls == [["5", "6"], ["7"]] and not catalog.queued_ids - finally: - await catalog.shutdown() - - -async def test_cached_values_survive_a_restart_and_corrupt_files_are_ignored( - tmp_path: Path, -) -> None: - ctx, _ = make_context(tmp_path) - - async def produce() -> list[int]: - return [1, 2] - - async def must_not_run() -> list[int]: - raise AssertionError("A fresh value on disk must not hit the catalog") - - first = WorkshopCatalog() - first.setup(ctx) - assert [v async for v in first.cached("k", produce, value_type=list[int])] == [ - [1, 2] - ] - - second = WorkshopCatalog() - second.setup(ctx) - assert [ - v async for v in second.cached("k", must_not_run, value_type=list[int]) - ] == [[1, 2]] - - for file in (tmp_path / "workshop-cache").glob("*.json"): - file.write_text("{not json") - third = WorkshopCatalog() - third.setup(ctx) - assert [v async for v in third.cached("k", produce, value_type=list[int])] == [ - [1, 2] - ] - - -async def test_equal_cache_refresh_keeps_the_displayed_mods_and_refreshes_age( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - calls = 0 - - async def produce() -> list[CatalogMod]: - nonlocal calls - calls += 1 - return [decode_mod(mod_payload, "1")] - - try: - initial = await anext(catalog.cached("installed", produce)) - refreshed = [ - value async for value in catalog.cached("installed", produce, fresh_for=0) - ] - assert len(refreshed) == 1 and refreshed[0] is initial - current = await anext(catalog.cached("installed", produce)) - assert current is initial and calls == 2 - finally: - await catalog.shutdown() - - -async def test_disk_cache_is_reused_in_memory_after_first_paint( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - - async def produce() -> list[CatalogMod]: - return [decode_mod(mod_payload, "1")] - - async def must_not_run() -> list[CatalogMod]: - raise AssertionError("A fresh cached response must not hit the catalog") - - first = WorkshopCatalog() - first.setup(ctx) - second = WorkshopCatalog() - second.setup(ctx) - try: - await anext(first.cached("installed", produce, value_type=list[CatalogMod])) - displayed = await anext( - second.cached("installed", must_not_run, value_type=list[CatalogMod]) - ) - for file in (tmp_path / "workshop-cache").glob("*.json"): - file.write_text("{not json") - current = await anext( - second.cached("installed", must_not_run, value_type=list[CatalogMod]) - ) - assert current is displayed - finally: - await first.shutdown() - await second.shutdown() - - -def test_catalog_url_moves_out_of_the_old_shared_config(tmp_path: Path) -> None: - ctx, _ = make_context(tmp_path) - (tmp_path / "config.json").write_bytes( - b'{"schema_version":1,"workshop_catalog_url":"http://localhost:8787"}' - ) - catalog = WorkshopCatalog() - catalog.setup(ctx) - assert catalog.settings.value.url == "http://localhost:8787" - stored = ConfigService(tmp_path).load_existing( - "plugins/workshop_catalog.json", CatalogSettings - ) - assert stored == CatalogSettings("http://localhost:8787") diff --git a/legacy/tests/test_workshop/test_client.py b/legacy/tests/test_workshop/test_client.py deleted file mode 100644 index dca6154..0000000 --- a/legacy/tests/test_workshop/test_client.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx -import msgspec -import pytest - -from pxmodrim.core.workshop import ( - CatalogClient, - CatalogCollection, - CatalogError, - CatalogMod, - CatalogQuery, - WorkshopCatalog, -) -from pxmodrim.ui.plugins.workshop.details import fetch_detail - - -async def test_decodes_every_endpoint( - mod_payload: dict[str, Any], collection_payload: dict[str, Any] -) -> None: - mod_page = {"items": [mod_payload], "total": 1, "next_cursor": "next-page"} - collection_page = {"items": [collection_payload], "total": 1, "next_cursor": None} - detail = { - "collection": collection_payload, - "members": [mod_payload], - "unavailable_ids": ["99"], - "is_complete": False, - } - steam_collection = { - **collection_payload, - "id": "steam:123", - "source": "steam", - "steam_id": "123", - "description_format": "bbcode", - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", - "member_ids": ["2009463077", "456", "99"], - "member_count": 3, - "member_previews": ["https://images.example.test/a.png"], - } - nested_collection = { - **steam_collection, - "id": "steam:456", - "steam_id": "456", - "member_ids": ["2009463077"], - "member_count": 1, - "member_previews": [], - } - steam_detail = { - **detail, - "collection": steam_collection, - "members": [mod_payload, nested_collection], - } - resolution = { - "roots": ["picked:starter"], - "items": {"2009463077": mod_payload, "picked:starter": collection_payload}, - "mod_ids": ["2009463077"], - "unavailable_ids": ["99"], - "incomplete_collection_ids": ["picked:starter"], - "is_complete": False, - } - responses = { - "/catalog/discover": {"mods": mod_page, "collections": collection_page}, - "/catalog/mods": mod_page, - "/catalog/collections": collection_page, - "/catalog/mods/2009463077": mod_payload, - "/catalog/collections/picked/starter": detail, - "/catalog/collections/steam/123": steam_detail, - "/catalog/resolve": resolution, - } - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(200, json=responses[request.url.path]) - - client = CatalogClient("https://catalog.example/", httpx.MockTransport(handler)) - try: - discover = await client.discover() - assert discover.mods.items[0].file_size == "18446744073709551615" - assert discover.mods.items[0].votes is not None - assert discover.mods.items[0].votes.positive_percent == 90 - assert discover.mods.items[0].previews[1].type == "video" - assert discover.collections.items[0].description_format == "text" - q = CatalogQuery("quality", "Libraries", "1.6", "updated", 50, "next", "picked") - page = await client.mods(q) - assert page.next_cursor == "next-page" - assert dict(requests[-1].url.params) == { - "q": "quality", - "tag": "Libraries", - "version": "1.6", - "sort": "updated", - "limit": "50", - "cursor": "next", - "source": "picked", - } - assert (await client.collections(q)).items[0].id == "picked:starter" - mod = await client.mod("2009463077") - assert mod is not None and mod.description == mod_payload["description"] - assert mod.kind == "mod" - for id in ["picked:starter", "steam:123"]: - collection = await client.collection(id) - assert collection is not None - assert isinstance(collection.members[0], CatalogMod) - if id == "steam:123": - assert isinstance(collection.members[1], CatalogCollection) - assert collection.unavailable_ids == ["99"] - assert not collection.is_complete - resolved = await client.resolve([], ["picked:starter"]) - assert resolved.roots == ["picked:starter"] - assert resolved.mod_ids == ["2009463077"] - assert isinstance(resolved.items["picked:starter"], CatalogCollection) - assert resolved.incomplete_collection_ids == ["picked:starter"] - assert not resolved.is_complete - assert msgspec.json.decode(requests[-1].content) == { - "ids": [], - "collection_ids": ["picked:starter"], - } - finally: - await client.shutdown() - - -@pytest.mark.parametrize("status", [400, 401, 500, 502, 503]) -@pytest.mark.parametrize("json_body", [True, False]) -async def test_error_statuses_are_never_empty_results( - status: int, json_body: bool -) -> None: - def handler(_: httpx.Request) -> httpx.Response: - if json_body: - return httpx.Response( - status, json={"error": {"message": "Catalog unavailable"}} - ) - return httpx.Response(status, text="Upstream failure") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError) as exc: - await client.mod("123") - assert exc.value.status == status - assert exc.value.message == str(exc.value) - if json_body: - assert exc.value.message == "Catalog unavailable" - else: - assert f"HTTP {status}" in exc.value.message - assert "" not in exc.value.message - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -@pytest.mark.parametrize("json_body", [True, False]) -async def test_optional_details_return_none_only_for_404( - kind: str, json_body: bool -) -> None: - def handler(_: httpx.Request) -> httpx.Response: - if json_body: - return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) - return httpx.Response(404, text="Not found") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - if kind == "mod": - assert await client.mod("123") is None - else: - assert await client.collection("steam:123") is None - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -async def test_optional_details_keep_server_errors(kind: str) -> None: - client = CatalogClient( - "https://catalog.example", - httpx.MockTransport(lambda _: httpx.Response(500, text="Server error")), - ) - try: - with pytest.raises(CatalogError) as exc: - if kind == "mod": - await client.mod("123") - else: - await client.collection("steam:123") - assert exc.value.status == 500 - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -@pytest.mark.parametrize("failure", ["network", "decoding"]) -async def test_optional_details_keep_network_and_decoding_errors( - kind: str, failure: str -) -> None: - def handler(request: httpx.Request) -> httpx.Response: - if failure == "network": - raise httpx.ConnectError("Disconnected", request=request) - return httpx.Response(200, content=b"not-json") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError) as exc: - if kind == "mod": - await client.mod("123") - else: - await client.collection("steam:123") - assert exc.value.status == (None if failure == "network" else 200) - finally: - await client.shutdown() - - -@pytest.mark.parametrize("endpoint", ["mods", "collections", "batch", "resolve"]) -async def test_non_optional_endpoints_keep_404_errors(endpoint: str) -> None: - client = CatalogClient( - "https://catalog.example", - httpx.MockTransport(lambda _: httpx.Response(404)), - ) - try: - with pytest.raises(CatalogError) as exc: - if endpoint == "mods": - await client.mods(CatalogQuery()) - elif endpoint == "collections": - await client.collections(CatalogQuery()) - elif endpoint == "batch": - await client.mods_batch(["123"]) - else: - await client.resolve(["123"], []) - assert exc.value.status == 404 - finally: - await client.shutdown() - - -async def test_fetch_detail_preserves_parent_with_unavailable_dependency( - mod_payload: dict[str, Any], -) -> None: - requested: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requested.append(request.url.path) - if request.url.path == f"/catalog/mods/{mod_payload['id']}": - return httpx.Response(200, json=mod_payload) - assert request.url.path == "/catalog/mods/818773962" - return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)), - ) - try: - detail = await fetch_detail(catalog, mod_payload["id"], "mod") - assert detail.item.id == mod_payload["id"] - assert detail.item.title == mod_payload["title"] - assert detail.members == [] - assert detail.warning == "Unavailable dependencies: 818773962" - assert not detail.complete - assert requested == [ - f"/catalog/mods/{mod_payload['id']}", - "/catalog/mods/818773962", - ] - finally: - await catalog.shutdown() - - -@pytest.mark.parametrize( - "invalid", ["numeric_id", "bad_kind", "missing_field", "not_json"] -) -async def test_strict_wire_validation( - mod_payload: dict[str, Any], invalid: str -) -> None: - if invalid == "numeric_id": - mod_payload["id"] = 123 - elif invalid == "bad_kind": - mod_payload["kind"] = "collection" - elif invalid == "missing_field": - del mod_payload["title"] - - def handler(_: httpx.Request) -> httpx.Response: - return ( - httpx.Response(200, content=b"not-json") - if invalid == "not_json" - else httpx.Response(200, json=mod_payload) - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="invalid response"): - await client.mod("123") - finally: - await client.shutdown() - - -async def test_chunks_large_batch(mod_payload: dict[str, Any]) -> None: - batches: list[list[str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - ids = msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] - batches.append(ids) - return httpx.Response( - 200, - json={ - "items": [{**mod_payload, "id": id} for id in ids if id != "105"], - "unavailable_ids": ["105"] if "105" in ids else [], - }, - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - ids = [str(id) for id in range(1, 206)] - try: - items, unavailable = await client.mods_batch(ids) - assert [len(batch) for batch in batches] == [100, 100, 5] - assert [item.id for item in items] == [id for id in ids if id != "105"] - assert unavailable == ["105"] - assert await client.mods_batch([]) == ([], []) - finally: - await client.shutdown() - - -async def test_transport_failure_is_catalog_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("offline", request=request) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="Cannot reach") as exc: - await client.discover() - assert exc.value.status is None - finally: - await client.shutdown() - - -async def test_empty_url_does_not_use_transport() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(200) - - client = CatalogClient("", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="disabled"): - await client.discover() - assert requests == [] - finally: - await client.shutdown() - - -async def test_resolve_chunks_update_all_and_deduplicates_dependencies( - mod_payload: dict[str, Any], -) -> None: - requests: list[dict[str, list[str]]] = [] - ids = [str(id) for id in range(1, 104)] - collections = [f"picked:pack-{index}" for index in range(11)] - - def handler(request: httpx.Request) -> httpx.Response: - payload = msgspec.json.decode(request.content, type=dict[str, list[str]]) - requests.append(payload) - mod_ids = ["999", *payload["ids"]] - return httpx.Response( - 200, - json={ - "roots": [*payload["ids"], *payload["collection_ids"]], - "items": { - id: { - **mod_payload, - "id": id, - "dependencies": [] if id == "999" else ["999"], - } - for id in mod_ids - }, - "mod_ids": mod_ids, - "unavailable_ids": ["404"] if len(requests) == 2 else [], - "incomplete_collection_ids": ( - ["picked:pack-10"] if len(requests) == 2 else [] - ), - "is_complete": len(requests) != 2, - }, - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - resolved = await client.resolve(ids, collections) - assert [len(request["ids"]) for request in requests] == [50, 50, 3] - assert [len(request["collection_ids"]) for request in requests] == [10, 1, 0] - assert set(resolved.roots) == set(ids + collections) - assert resolved.mod_ids == ["999", *ids] - assert resolved.unavailable_ids == ["404"] - assert resolved.incomplete_collection_ids == ["picked:pack-10"] - assert not resolved.is_complete - finally: - await client.shutdown() diff --git a/legacy/tests/test_workshop/test_favourites.py b/legacy/tests/test_workshop/test_favourites.py deleted file mode 100644 index 2e290c6..0000000 --- a/legacy/tests/test_workshop/test_favourites.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.config import ConfigService -from pxmodrim.core.workshop import Author, CatalogCollection -from pxmodrim.core.workshop.favourites import FavouriteCollections - - -def _collection(cid: str, title: str = "Pack") -> CatalogCollection: - return CatalogCollection( - id=cid, - source="steam", - steam_id=cid.partition(":")[2], - title=title, - author=Author(None, "Curator", None), - description="", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=["1.6"], - created_at=None, - updated_at=None, - member_ids=["1"], - member_count=1, - ) - - -def test_favourites_persist_newest_first_and_survive_restart( - config_service: ConfigService, -) -> None: - favourites = FavouriteCollections(config_service) - first, second = _collection("steam:1"), _collection("steam:2") - favourites.set(first, True) - favourites.set(second, True) - favourites.set(second, True) - - reloaded = FavouriteCollections(config_service) - assert [c.id for c in reloaded.items] == ["steam:2", "steam:1"] - assert "steam:1" in reloaded - - reloaded.set(first, False) - assert [c.id for c in FavouriteCollections(config_service).items] == ["steam:2"] - assert "steam:1" not in reloaded - - -def test_remember_refreshes_only_starred_snapshots( - config_service: ConfigService, -) -> None: - favourites = FavouriteCollections(config_service) - favourites.set(_collection("steam:1", "Old title"), True) - saves: list[object] = [] - favourites.changed.connect(saves.append) - - favourites.remember(_collection("steam:1", "Old title")) - favourites.remember(_collection("steam:2", "Not starred")) - assert saves == [] - - favourites.remember(_collection("steam:1", "New title")) - reloaded = FavouriteCollections(config_service) - assert [c.title for c in reloaded.items] == ["New title"] - assert ( - msgspec.json.decode( - ( - config_service.config_dir / "plugins/workshop_favourites.json" - ).read_bytes() - )["collections"][0]["id"] - == "steam:1" - ) diff --git a/legacy/tests/test_workshop/test_types.py b/legacy/tests/test_workshop/test_types.py deleted file mode 100644 index 5104d39..0000000 --- a/legacy/tests/test_workshop/test_types.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any, cast - -import msgspec -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.workshop import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, -) - - -def test_mod_null_fields_are_not_invented(mod_payload: dict[str, Any]) -> None: - for key in [ - "preview_url", - "created_at", - "updated_at", - "file_size", - "subscriptions", - "votes", - "package_id", - ]: - mod_payload[key] = None - mod_payload["author"] = { - "id": "76561198000000000", - "name": None, - "profile_url": None, - } - mod = msgspec.json.decode(msgspec.json.encode(mod_payload), type=CatalogMod) - assert mod.author.name is None - assert mod.votes is None - assert mod.updated_at is None - assert mod.file_size is None - assert msgspec.json.decode(msgspec.json.encode(mod)) == mod_payload - with pytest.raises(AttributeError): - cast(Any, mod).title = "Changed" - - -def test_steam_collection_and_empty_page(collection_payload: dict[str, Any]) -> None: - collection_payload.update( - { - "id": "steam:123", - "source": "steam", - "steam_id": "123", - "description_format": "bbcode", - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", - "member_ids": ["2009463077", "steam:456"], - "member_count": 2, - "member_previews": ["https://images.example.test/a.png"], - } - ) - collection = msgspec.json.decode( - msgspec.json.encode(collection_payload), type=CatalogCollection - ) - assert collection.kind == "collection" - assert collection.member_ids == ["2009463077", "steam:456"] - assert msgspec.json.decode(msgspec.json.encode(collection)) == collection_payload - page = msgspec.json.decode( - b'{"items":[],"total":0,"next_cursor":null}', type=CatalogPage[CatalogMod] - ) - assert page.items == [] and page.total == 0 and page.next_cursor is None - - -def test_config_default_loads_existing_files_and_persists_override( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - (tmp_path / "config.json").write_bytes(b'{"schema_version":1,"max_snapshots":3}') - config = service.load("config.json", AppConfig) - assert config.log_upload_endpoint == "https://paste.rs/" - assert config.max_snapshots == 3 - config.log_upload_endpoint = "" - service.save("config.json", config) - assert service.load("config.json", AppConfig).log_upload_endpoint == "" - - -def test_query_defaults_and_frozen_contract() -> None: - query = CatalogQuery() - assert ( - query.query == "" - and query.sort == "popular" - and query.limit == 24 - and query.source == "all" - ) - assert query.tag is None and query.version is None and query.cursor is None - with pytest.raises(AttributeError): - cast(Any, query).limit = 50 diff --git a/packaging/build.py b/packaging/build.py deleted file mode 100644 index 4b573bf..0000000 --- a/packaging/build.py +++ /dev/null @@ -1,678 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import platform -import shutil -import subprocess -import sys -import tomllib -from pathlib import Path -from tempfile import TemporaryDirectory - - -def get_version(project_root: Path) -> str: - tag = os.environ.get("GITHUB_REF_NAME", "") - if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit(): - return tag[1:] - pyproject = project_root / "pyproject.toml" - return tomllib.loads(pyproject.read_text("utf-8"))["project"]["version"] - - -def debian_architecture(machine: str | None = None) -> str: - if machine is None: - machine = platform.machine() - architectures = {"x86_64": "amd64", "aarch64": "arm64"} - try: - return architectures[machine.lower()] - except KeyError: - raise RuntimeError( - f"Unsupported Debian package architecture: {machine}" - ) from None - - -_WITHOUT_WEBVIEW_BROWSER = [ - ( - "--nofollow-import-to=pxmodrim.ui.plugins.steam_workshop," - "PySide6.QtWebEngine*,PySide6.QtWebChannel" - ), - "--noinclude-data-files=pxmodrim/ui/plugins/steam_workshop", - "--noinclude-data-files=PySide6/qml/QtWeb*/*", - "--noinclude-data-files=*qtwebengine*", - "--noinclude-dlls=PySide6/qml/QtWeb*/*", - "--noinclude-dlls=*WebEngine*", - "--noinclude-dlls=*WebChannel*", -] -_WITHOUT_NATIVE_BROWSER = [ - "--nofollow-import-to=pxmodrim.ui.plugins.workshop", - "--noinclude-data-files=pxmodrim/ui/plugins/workshop", -] -_WITHOUT_DOWNLOADER = [ - ( - "--nofollow-import-to=pxsteamdl,pxmodrim.core.downloads.steam," - "pxmodrim.ui.plugins.downloads" - ), - "--noinclude-data-files=pxmodrim/ui/plugins/downloads", -] - - -def _variant_exclusions(workshop: str) -> list[str]: - """Nuitka options that leave out what the Workshop variant does not ship.""" - match workshop: - case "SteamWorkshop": - return _WITHOUT_NATIVE_BROWSER - case "NativeWorkshop": - return _WITHOUT_WEBVIEW_BROWSER - case "NoWorkshop": - return [ - *_WITHOUT_WEBVIEW_BROWSER, - *_WITHOUT_NATIVE_BROWSER, - *_WITHOUT_DOWNLOADER, - ] - raise ValueError(f"Unknown Workshop variant: {workshop}") - - -def get_standalone_args( - release: bool = False, workshop: str = "NativeWorkshop" -) -> list[str]: - project_root = Path(__file__).parent.parent - - args = [ - sys.executable, - "-m", - "nuitka", - "--standalone", - "--enable-plugin=pyside6", - "--nofollow-import-to=pytest,pygments", - "--include-package-data=pxmodrim", - f"--output-dir={project_root / 'dist'}", - "--assume-yes-for-downloads", - str(project_root / "packaging" / "entrypoint.py"), - ] - - args.append("--include-qt-plugins=qml") - - args.extend(_variant_exclusions(workshop)) - - if release: - args.extend( - [ - "--lto=yes", - "--python-flag=no_asserts", - "--python-flag=no_docstrings", - ] - ) - - args.extend( - [ - "--noinclude-setuptools-mode=nofollow", - "--noinclude-pytest-mode=nofollow", - "--noinclude-unittest-mode=nofollow", - "--noinclude-default-mode=nofollow", - "--nowarn-mnemonic=unwanted-module", - ] - ) - - system = platform.system() - version = get_version(project_root) - if system == "Windows": - args.extend( - [ - "--windows-icon-from-ico=packaging/logo.ico", - "--windows-company-name=PxModRim", - "--windows-product-name=PxModRim", - f"--windows-product-version={version}", - ] - ) - elif system == "Darwin": - logo_icns = project_root / "packaging" / "logo.icns" - args.extend( - [ - "--macos-create-app-bundle", - "--macos-app-name=PxModRim", - f"--macos-app-version={version}", - "--macos-signed-app-name=com.github.PyXiion.PxModRim", - f"--macos-app-icon={logo_icns}", - ] - ) - - return args - - -def _clean_debug_artifacts(base_dir: Path) -> None: - for obj_dir in base_dir.rglob("objects-RelWithDebInfo"): - shutil.rmtree(obj_dir) - print(f"Removed {obj_dir}") - - -def clean_qml_debug_artifacts() -> None: - for qml_dir in Path(".venv").rglob("**/PySide6/Qt/qml"): - _clean_debug_artifacts(qml_dir) - - -def copy_missing_libs(project_root: Path) -> None: - dist_dir = project_root / "dist" / "entrypoint.dist" - - qml_dir = dist_dir / "PySide6" / "qml" - if qml_dir.exists(): - _clean_debug_artifacts(qml_dir) - - venv_qml = None - for pattern in [ - ".venv/**/PySide6/Qt/qml", - "venv/**/PySide6/Qt/qml", - ]: - matches = list(project_root.glob(pattern)) - if matches: - venv_qml = matches[0] - break - - if not venv_qml: - print("Warning: PySide6 QML not found in venv, skipping") - return - - venv_lib = venv_qml.parent / "lib" - - required_libs = [ - "libQt6QmlModels.so.6", - "libQt6QuickTemplates2.so.6", - "libQt6QuickControls2.so.6", - "libQt6QuickControls2Impl.so.6", - "libQt6QuickLayouts.so.6", - ] - - for lib_name in required_libs: - src = venv_lib / lib_name - dst = dist_dir / lib_name - if src.exists() and not dst.exists(): - shutil.copy2(src, dst) - print(f"Copied {lib_name}") - - -def normalize_executable(dist_dir: Path, system: str | None = None) -> Path: - """Normalize the Nuitka entrypoint executable for its platform.""" - if system is None: - system = platform.system() - - if system == "Windows": - output_name = "PxModRim.exe" - elif system == "Darwin": - output_name = "entrypoint" - else: - output_name = "PxModRim" - - final_binary = dist_dir / output_name - - if final_binary.is_file(): - return final_binary - - candidates = ( - ["entrypoint.exe", "entrypoint.bin", "entrypoint"] - if system == "Windows" - else ["entrypoint", "entrypoint.bin", "entrypoint.exe"] - ) - - for candidate_name in candidates: - candidate_path = dist_dir / candidate_name - if candidate_path.is_file(): - candidate_path.rename(final_binary) - print(f"Renamed {candidate_path.name} to {final_binary.name}") - return final_binary - - raise FileNotFoundError( - f"Executable not found in {dist_dir}; looked for '{output_name}' " - f"and candidates {candidates}" - ) - - -def create_appimage(project_root: Path, release: bool = False) -> None: - dist_dir = project_root / "dist" / "entrypoint.dist" - app_dir = project_root / "dist" / "PxModRim.AppDir" - - if app_dir.exists(): - shutil.rmtree(app_dir) - - shutil.copytree(dist_dir, app_dir) - - normalize_executable(app_dir, system="Linux") - - desktop_content = """[Desktop Entry] -Type=Application -Name=PxModRim -Comment=Mod manager for RimWorld -Icon=pxmodrim -Exec=PxModRim -Categories=Game; -StartupWMClass=PxModRim -Terminal=false -""" - (app_dir / "pxmodrim.desktop").write_text(desktop_content) - - icon_src = project_root / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - shutil.copy(icon_src, app_dir / "pxmodrim.svg") - - run_script = """#!/bin/bash -HERE="$(dirname "$(readlink -f "$0")")" -exec "$HERE/PxModRim" "$@" -""" - run_file = app_dir / "AppRun" - run_file.write_text(run_script) - run_file.chmod(0o755) - - appimagetool = shutil.which("appimagetool") - - if not appimagetool: - if release: - raise RuntimeError("appimagetool is required to create the AppImage") - print("Warning: appimagetool not found, skipping AppImage creation") - print("Install from: https://github.com/AppImage/AppImageKit/releases") - return - - output = project_root / "dist" / "PxModRim-x86_64.AppImage" - subprocess.run([appimagetool, str(app_dir), str(output)], check=True) - print(f"Created {output}") - - -def create_deb(project_root: Path) -> Path | None: - dist_dir = project_root / "dist" / "entrypoint.dist" - if not dist_dir.exists(): - print("Warning: entrypoint.dist not found, skipping .deb creation") - return None - - dpkg_deb = shutil.which("dpkg-deb") - if not dpkg_deb: - raise RuntimeError("dpkg-deb is required to create the Debian package") - - version = get_version(project_root) - architecture = debian_architecture() - output = project_root / "dist" / f"pxmodrim_{version}_{architecture}.deb" - staging_dir = project_root / "dist" / "deb_staging" - if staging_dir.exists(): - shutil.rmtree(staging_dir) - - debian_dir = staging_dir / "DEBIAN" - opt_dir = staging_dir / "opt" / "pxmodrim" - bin_dir = staging_dir / "usr" / "bin" - apps_dir = staging_dir / "usr" / "share" / "applications" - icons_dir = ( - staging_dir / "usr" / "share" / "icons" / "hicolor" / "scalable" / "apps" - ) - - debian_dir.mkdir(parents=True, exist_ok=True) - bin_dir.mkdir(parents=True, exist_ok=True) - apps_dir.mkdir(parents=True, exist_ok=True) - icons_dir.mkdir(parents=True, exist_ok=True) - - shutil.copytree(dist_dir, opt_dir) - (bin_dir / "pxmodrim").symlink_to("/opt/pxmodrim/PxModRim") - shutil.copy2( - project_root / "packaging" / "linux" / "pxmodrim.desktop", - apps_dir / "pxmodrim.desktop", - ) - shutil.copy2( - project_root / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg", - icons_dir / "pxmodrim.svg", - ) - - exe_file = opt_dir / "PxModRim" - if exe_file.exists(): - exe_file.chmod(0o755) - - installed_size = ( - sum(f.stat().st_size for f in staging_dir.rglob("*") if f.is_file()) // 1024 - ) - control_content = f"""Package: pxmodrim -Version: {version} -Section: games -Priority: optional -Architecture: {architecture} -Installed-Size: {installed_size} -Maintainer: PyXiion -Homepage: https://github.com/PyXiion/PxModRim -Description: Mod manager for RimWorld - PxModRim is a fast and modern mod manager for RimWorld. -""" - (debian_dir / "control").write_text(control_content, encoding="utf-8") - - subprocess.run( - [dpkg_deb, "--build", "--root-owner-group", str(staging_dir), str(output)], - check=True, - ) - shutil.rmtree(staging_dir) - print(f"Created {output}") - return output - - -def create_rpm(project_root: Path, release: bool = False) -> Path | None: - rpmbuild = shutil.which("rpmbuild") - if not rpmbuild: - if release: - raise RuntimeError("rpmbuild is required to create the RPM") - print("Warning: rpmbuild not found, skipping RPM creation") - return None - - version = get_version(project_root) - dist_dir = project_root / "dist" / "entrypoint.dist" - if not dist_dir.exists(): - print("Warning: entrypoint.dist not found, skipping RPM creation") - return None - - top_dir = project_root / "dist" / "rpmbuild" - if top_dir.exists(): - shutil.rmtree(top_dir) - for sub in ("BUILD", "RPMS", "SOURCES", "SPECS", "SRPMS"): - (top_dir / sub).mkdir(parents=True, exist_ok=True) - - spec_file = project_root / "packaging" / "linux" / "pxmodrim.spec" - cmd = [ - rpmbuild, - "-bb", - f"--define=_topdir {top_dir.resolve()}", - f"--define=project_root {project_root.resolve()}", - f"--define=version {version}", - str(spec_file.resolve()), - ] - subprocess.run(cmd, check=True) - - rpm_files = list((top_dir / "RPMS").rglob("*.rpm")) - if not rpm_files: - print("Warning: No RPM file was generated") - return None - - output = project_root / "dist" / rpm_files[0].name - shutil.copy2(rpm_files[0], output) - shutil.rmtree(top_dir) - print(f"Created {output}") - return output - - -def create_nsis_installer(project_root: Path) -> Path | None: - makensis = shutil.which("makensis") - nsis_dir = os.environ.get("NSIS_DIR") - if nsis_dir: - configured_nsis = Path(nsis_dir) / "makensis.exe" - if configured_nsis.exists(): - makensis = str(configured_nsis) - if not makensis and platform.system() == "Windows": - default_nsis = Path("C:/Program Files (x86)/NSIS/makensis.exe") - if default_nsis.exists(): - makensis = str(default_nsis) - - if not makensis: - print("Warning: makensis not found, skipping NSIS installer creation") - return None - - version = get_version(project_root) - dist_dir = project_root / "dist" / "entrypoint.dist" - output = project_root / "dist" / "PxModRim-Setup.exe" - nsi_file = project_root / "packaging" / "windows" / "installer.nsi" - - cmd = [ - makensis, - f"/DVERSION={version}", - f"/DDIST_DIR={dist_dir.resolve()}", - f"/DOUTPUT_FILE={output.resolve()}", - str(nsi_file.resolve()), - ] - subprocess.run(cmd, check=True) - print(f"Created {output}") - return output - - -def create_macos_bundle(project_root: Path, release: bool = False) -> Path | None: - version = get_version(project_root) - dist_path = project_root / "dist" - - app_dir = dist_path / "PxModRim.app" - entry_app = dist_path / "entrypoint.app" - if not app_dir.exists() and entry_app.exists(): - entry_app.rename(app_dir) - - if not app_dir.exists(): - print("Warning: PxModRim.app not found, skipping macOS bundle packaging") - return None - macos_dir = app_dir / "Contents" / "MacOS" - if not macos_dir.exists(): - raise FileNotFoundError(f"Missing Contents/MacOS directory in {app_dir}") - - final_binary = normalize_executable(macos_dir, system="Darwin") - - plist_file = app_dir / "Contents" / "Info.plist" - if plist_file.exists(): - import plistlib - - with open(plist_file, "rb") as f: - pl = plistlib.load(f) - pl["CFBundleIdentifier"] = "com.github.PyXiion.PxModRim" - pl["CFBundleName"] = "PxModRim" - pl["CFBundleDisplayName"] = "PxModRim" - pl["CFBundleVersion"] = version - pl["CFBundleShortVersionString"] = version - pl["CFBundleExecutable"] = final_binary.name - pl["CFBundlePackageType"] = "APPL" - pl["NSHighResolutionCapable"] = True - pl["LSMinimumSystemVersion"] = "11.0" - with open(plist_file, "wb") as f: - plistlib.dump(pl, f) - print(f"Updated Info.plist bundle metadata (executable={final_binary.name})") - - subprocess.run( - ["codesign", "--force", "--deep", "--sign", "-", str(app_dir)], - check=True, - ) - - zip_output = dist_path / "PxModRim-macOS" - shutil.make_archive( - str(zip_output), "zip", root_dir=str(dist_path), base_dir="PxModRim.app" - ) - print(f"Created {zip_output}.zip") - - hdiutil = shutil.which("hdiutil") - if not hdiutil: - if release: - raise RuntimeError("hdiutil is required to create the macOS DMG") - print("Warning: hdiutil not found, skipping DMG creation") - return app_dir - - dmg_output = dist_path / "PxModRim-macOS.dmg" - if dmg_output.exists(): - dmg_output.unlink() - subprocess.run( - [ - hdiutil, - "create", - "-volname", - "PxModRim", - "-srcfolder", - str(app_dir), - "-ov", - "-format", - "UDZO", - str(dmg_output), - ], - check=True, - ) - print(f"Created {dmg_output}") - - return app_dir - - -def strip_unused_qml_modules(dist_dir: Path, workshop: str = "NativeWorkshop") -> None: - """Remove QML modules unused by the selected Workshop browser.""" - qml_root = dist_dir / "PySide6" / "qml" - if not qml_root.is_dir(): - return - keep = {"Qt", "QtQml", "QtQuick"} - if workshop == "SteamWorkshop": - keep.update({"QtWebChannel", "QtWebEngine"}) - removed = 0 - for entry in sorted(qml_root.iterdir()): - if entry.is_dir() and entry.name not in keep: - size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file()) - shutil.rmtree(entry) - removed += size - if size > 1024 * 1024: - print(f" Removed {entry.name} ({size // (1024 * 1024)} MB)") - else: - print(f" Removed {entry.name} ({size // 1024} KB)") - if removed: - print(f" Freed {removed // (1024 * 1024)} MB by removing unused QML modules") - - -def strip_qt_translations(dist_dir: Path) -> None: - """Remove all Qt .qm files except English ones.""" - removed = 0 - for f in dist_dir.glob("*.qm"): - base = f.stem.lower() - if any(base.endswith(suf) for suf in ("_en", "_en_us", "_en_gb")): - continue - removed += f.stat().st_size - f.unlink() - if removed: - print(f" Removed non-English .qm files ({removed // 1024} KB)") - - -def strip_qt_devtools(dist_dir: Path) -> None: - """Remove QtWebEngine DevTools resources (~12 MB).""" - pak = dist_dir / "qtwebengine_devtools_resources.pak" - if pak.exists(): - sz = pak.stat().st_size - pak.unlink() - print( - f" Removed qtwebengine_devtools_resources.pak ({sz // (1024 * 1024)} MB)" - ) - - -def strip_qt_locales(dist_dir: Path) -> None: - """Keep only en-US WebEngine locale, remove the other 50+.""" - locale_dir = dist_dir / "qtwebengine_locales" - if not locale_dir.is_dir(): - return - kept = en_count = 0 - for f in locale_dir.iterdir(): - if f.suffix != ".pak": - continue - if f.stem in ("en-US", "en-GB", "en"): - en_count += 1 - continue - f.unlink() - kept += 1 - print(f" Kept {en_count} English locale(s), removed {kept} other locales") - - -def strip_bundled_qt(dist_dir: Path) -> None: - """Remove bundled Qt shared libs so the app uses system Qt at runtime.""" - patterns = [ - "libQt6*.so*", - "libicu*.so*", - "icudtl.dat", - ] - total = 0 - for pattern in patterns: - for f in dist_dir.glob(pattern): - if f.is_file(): - size = f.stat().st_size - f.unlink() - total += size - print(f" Removed {f.name} ({size // 1024 // 1024}MB)") - print(f" Freed {total // 1024 // 1024}MB by removing bundled Qt libs") - - -def _get_dir_size(path: Path) -> int: - total = 0 - for entry in path.rglob("*"): - if entry.is_file(): - total += entry.stat().st_size - return total - - -def main() -> None: - parser = argparse.ArgumentParser(description="Build a standalone PxModRim package.") - parser.add_argument("--release", action="store_true") - parser.add_argument("--bundle-qt", action="store_true") - parser.add_argument( - "--workshop", - choices=("NoWorkshop", "SteamWorkshop", "NativeWorkshop"), - default="NativeWorkshop", - ) - options = parser.parse_args() - release = options.release - bundle_qt = options.bundle_qt - workshop = options.workshop - project_root = Path(__file__).parent.parent - system = platform.system() - - print("Step 0: Cleaning QML debug artifacts...") - clean_qml_debug_artifacts() - - print( - f"Step 1: Building standalone " - f"(release={release}, bundle_qt={bundle_qt}, workshop={workshop})..." - ) - with TemporaryDirectory(prefix="pxmodrim-workshop-") as profile_dir: - manifest = Path(profile_dir) / "workshop-variant.txt" - manifest.write_text(workshop, encoding="utf-8") - args = get_standalone_args(release=release, workshop=workshop) - args.append(f"--include-data-files={manifest}=pxmodrim/workshop-variant.txt") - print(f"Running: {' '.join(args)}") - - result = subprocess.run(args, check=False) - if result.returncode != 0: - sys.exit(result.returncode) - - dist_dir = project_root / "dist" / "entrypoint.dist" - if system == "Darwin": - for candidate in [ - project_root / "dist" / "PxModRim.app" / "Contents" / "MacOS", - project_root / "dist" / "entrypoint.app" / "Contents" / "MacOS", - ]: - if candidate.exists(): - dist_dir = candidate - break - - print("\nStep 2a: Stripping non-English WebEngine locales...") - strip_qt_locales(dist_dir) - - print("\nStep 2b: Stripping unused QML modules...") - strip_unused_qml_modules(dist_dir, workshop=workshop) - - print("\nStep 2c: Stripping Qt translations...") - strip_qt_translations(dist_dir) - - print("\nStep 2d: Stripping QtWebEngine DevTools...") - strip_qt_devtools(dist_dir) - - if bundle_qt and system == "Linux": - print("\nStep 2e: Copying missing Qt libraries...") - copy_missing_libs(project_root) - elif not bundle_qt and system == "Linux": - print("\nStep 2e: Stripping bundled Qt libs (using system Qt)...") - strip_bundled_qt(dist_dir) - - final_binary = normalize_executable(dist_dir, system=system) - if system == "Linux": - if bundle_qt: - print("\nStep 3: Creating Linux packages (AppImage, deb, rpm)...") - create_appimage(project_root, release=release) - create_deb(project_root) - create_rpm(project_root, release=release) - else: - print( - "\nStep 3: Skipping Linux packages (system Qt mode, not self-contained)" - ) - elif system == "Windows": - print("\nStep 3: Creating Windows NSIS installer...") - create_nsis_installer(project_root) - elif system == "Darwin": - print("\nStep 3: Packaging macOS .app bundle...") - create_macos_bundle(project_root, release=release) - - print(f"\nBuild complete! Output: {dist_dir}") - print(f"Run with: {final_binary}") - - size = _get_dir_size(dist_dir) - print(f"Build size: {size // (1024 * 1024)} MB ({size:,} bytes)") - - -if __name__ == "__main__": - main() diff --git a/packaging/entrypoint.py b/packaging/entrypoint.py deleted file mode 100644 index 1563f11..0000000 --- a/packaging/entrypoint.py +++ /dev/null @@ -1,4 +0,0 @@ -from pxmodrim.__main__ import main - -if __name__ == "__main__": - main() diff --git a/packaging/flatpak/com.github.PyXiion.PxModRim.yaml b/packaging/flatpak/com.github.PyXiion.PxModRim.yaml index 282e8a7..3901439 100644 --- a/packaging/flatpak/com.github.PyXiion.PxModRim.yaml +++ b/packaging/flatpak/com.github.PyXiion.PxModRim.yaml @@ -24,20 +24,20 @@ modules: buildsystem: simple build-commands: - install -d /app/lib/pxmodrim - - cp -a entrypoint.dist/* /app/lib/pxmodrim/ + - cp -a usr/. /app/lib/pxmodrim/ - install -d /app/bin - - ln -s /app/lib/pxmodrim/PxModRim /app/bin/PxModRim + - ln -s /app/lib/pxmodrim/bin/pxmodrim /app/bin/PxModRim - install -Dm644 com.github.PyXiion.PxModRim.desktop /app/share/applications/com.github.PyXiion.PxModRim.desktop - install -Dm644 com.github.PyXiion.PxModRim.svg /app/share/icons/hicolor/scalable/apps/com.github.PyXiion.PxModRim.svg - install -Dm644 com.github.PyXiion.PxModRim.metainfo.xml /app/share/metainfo/com.github.PyXiion.PxModRim.metainfo.xml sources: - type: dir - path: ../../dist/entrypoint.dist - dest: entrypoint.dist + path: ../../dist/usr + dest: usr - type: file path: com.github.PyXiion.PxModRim.desktop - type: file - path: ../../src/pxmodrim/ui/assets/logo.svg + path: ../../src/ui/assets/logo.svg dest-filename: com.github.PyXiion.PxModRim.svg - type: file path: com.github.PyXiion.PxModRim.metainfo.xml diff --git a/packaging/linux/pxmodrim.spec b/packaging/linux/pxmodrim.spec index c09b32a..d9e4e99 100644 --- a/packaging/linux/pxmodrim.spec +++ b/packaging/linux/pxmodrim.spec @@ -17,10 +17,10 @@ mkdir -p %{buildroot}/usr/bin mkdir -p %{buildroot}/usr/share/applications mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps -cp -a %{project_root}/dist/entrypoint.dist/* %{buildroot}/opt/pxmodrim/ -ln -s /opt/pxmodrim/PxModRim %{buildroot}/usr/bin/pxmodrim +cp -a %{project_root}/dist/AppDir/usr/. %{buildroot}/opt/pxmodrim/ +ln -s /opt/pxmodrim/bin/pxmodrim %{buildroot}/usr/bin/pxmodrim install -m 644 %{project_root}/packaging/linux/pxmodrim.desktop %{buildroot}/usr/share/applications/pxmodrim.desktop -install -m 644 %{project_root}/src/pxmodrim/ui/assets/logo.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg +install -m 644 %{project_root}/src/ui/assets/logo.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg %files /opt/pxmodrim diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 2100a0d..ae97850 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -13,7 +13,7 @@ !endif !ifndef DIST_DIR - !define DIST_DIR "..\..\dist\entrypoint.dist" + !define DIST_DIR "..\..\dist\PxModRim\bin" !endif !ifndef OUTPUT_FILE diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index cebddd7..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,101 +0,0 @@ -[project] -name = "pxmodrim" -description = "A mod manager for RimWorld." -license = "GPL-3.0-or-later" -license-files = ["LICENSE", "NOTICE"] -version = "0.3.1" -requires-python = "==3.12.*" -dependencies = [ - "aiosqlite>=0.22.1", - "httpx>=0.28.1", - "loguru>=0.7.3", - "lxml>=6.1.1", - "msgspec>=0.21.1", - "psutil>=7.2.2", - "pyside6>=6.11.1", - "qasync>=0.28.0", - "toposort>=1.10", - "ttimer>=0.0.2", -] - -[project.optional-dependencies] -# Steam Workshop downloads; without it the app runs as a plain mod sorter. -steam = ["pxsteamdl>=0.3.0"] - -[project.gui-scripts] -pxmodrim = "pxmodrim.__main__:main" - -[dependency-groups] -dev = [ - "pxsteamdl>=0.3.0", - "nuitka>=2.6.7", - "pydeps>=3.0.7", - "pyright>=1.1.411", - "pytest>=9.1.1", - "pytest-asyncio>=1.4.0", - "pytest-qt>=4.5.0", - "pytest-xvfb>=3.1.1", - "ruff>=0.16.0", -] - -[tool.uv] -package = true -environments = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] - -[tool.uv.sources] -pxsteamdl = { git = "https://github.com/PyXiion/PxSteamDL" } - -[tool.ruff] -line-length = 88 -indent-width = 4 -exclude = ["rimsort-original", "companion-mods"] - -[tool.ruff.format] -quote-style = "double" - -[tool.ruff.lint] -extend-select = [ - "I", # isort - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "RET", # flake8-return -] - -[tool.pytest.ini_options] -addopts = [ - "--import-mode=importlib", - "--no-qt-log", -] -asyncio_mode = "auto" -pythonpath = 'src' -testpaths = ['tests'] - -[tool.pyright] -venvPath = "." -venv = ".venv" -pythonPlatform = "All" -typeCheckingMode = "standard" -useLibraryCodeForTypes = true -exclude = ['rimsort-original'] - -[tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = [ - "S101", # allowing assert - "D", # no docs - "F841", # unused local vars -] -[tool.bandit] -targets = ["src"] -exclude_dirs = ['tests'] - -[tool.bandit.assert_used] -skips = ['tests/**/*.py'] diff --git a/scripts/check-deps.py b/scripts/check-deps.py deleted file mode 100644 index 45ec865..0000000 --- a/scripts/check-deps.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Check layer imports and circular dependencies using pydeps. - -Usage: uv run python scripts/check-deps.py - -Layer rules (bottom→top): - core/ — domain logic; never imports ui/ - ui/ — presentation layer; may import core/ - -Sub-layer rules enforce dependency ordering within each top-level package. -""" - -from __future__ import annotations - -import sys - -from pydeps import py2depgraph, target -from pydeps.configs import Config - -SRC = "src/pxmodrim" - -ENTRYPOINTS = {"__main__", "_app"} - -GROUPS: dict[str, set[str]] = { - "core.foundation": { - "core.constants", - "core.events", - "core.structures", - "core.utils", - "core.xml", - "core.loading", - "core.profiler", - "core.plugin", - "core.migrator", - }, - "core.models": {"core.models"}, - "core.msgspec_hooks": {"core.msgspec_hooks"}, - "core.sort": {"core.sort"}, - "core.checker": {"core.checker"}, - "core.config": {"core.config"}, - "core.plugin_config": {"core.plugin_config"}, - "core.context": {"core.context"}, - "core.mods_config": {"core.mods_config"}, - "core.providers": {"core.providers"}, - "core.services": {"core.services"}, - "core.mod_service": {"core.mod_service"}, - "core.downloads": {"core.downloads"}, - "core.workshop": {"core.workshop"}, - "core.organizer": {"core.organizer"}, - "core.support": {"core.support"}, - "ui.progress": {"ui.progress"}, - "ui.context": {"ui.context"}, - "ui.navigation": {"ui.navigation"}, - "ui.settings_section": {"ui.settings_section"}, - "ui.theme": {"ui.theme"}, - "ui.components": {"ui.components"}, - "ui.models": {"ui.models"}, - "ui.ui_prefs": {"ui.ui_prefs"}, - "ui.config": {"ui.config"}, - "ui.mod_selection": {"ui.mod_selection"}, - "ui.panels": {"ui.panels"}, - "ui.plugins": {"ui.plugins"}, - "ui.views": {"ui.views"}, - "ui.window": {"ui.window"}, -} - -ALLOWED: dict[str, set[str]] = { - "core": set(), - "ui": {"core"}, - "core.foundation": set(), - "core.models": {"core.foundation"}, - "core.msgspec_hooks": {"core.foundation", "core.models"}, - "core.sort": { - "core.foundation", - "core.models", - "core.msgspec_hooks", - "core.config", - }, - "core.checker": {"core.foundation", "core.models", "core.sort", "core.config"}, - "core.config": {"core.foundation", "core.msgspec_hooks", "core.sort"}, - "core.plugin_config": {"core.foundation", "core.config"}, - "core.context": {"core"}, - "core.mods_config": {"core.foundation", "core.models"}, - "core.providers": { - "core.foundation", - "core.models", - "core.config", - "core.services", - }, - "core.services": { - "core.foundation", - "core.models", - "core.checker", - "core.sort", - "core.context", - "core.config", - "core.mods_config", - }, - "core.mod_service": { - "core.foundation", - "core.models", - "core.context", - "core.providers", - "core.services", - "core.mods_config", - }, - "core.downloads": { - "core.plugin_config", - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "core.workshop": { - "core.plugin_config", - "core.foundation", - "core.models", - "core.config", - "core.context", - "core.downloads", - "core.mod_service", - }, - "core.organizer": { - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "core.support": { - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "ui.progress": set(), - "ui.context": {"ui.ui_prefs", "ui.settings_section"}, - "ui.settings_section": {"core"}, - "ui.theme": {"ui.components"}, - "ui.components": {"ui.theme"}, - "ui.models": {"ui.theme"}, - "ui.ui_prefs": set(), - "ui.navigation": set(), - "ui.config": {"core", "ui.ui_prefs"}, - "ui.mod_selection": {"ui.panels"}, - "ui.plugins": { - "core", - "ui.theme", - "ui.models", - "ui.context", - "ui.components", - "ui.views", - "ui.panels", - "ui.mod_selection", - "ui.navigation", - "ui.settings_section", - }, - "ui.panels": { - "ui.settings_section", - "ui.theme", - "ui.components", - "ui.models", - "ui.config", - "ui.ui_prefs", - "ui.plugins", - }, - "ui.views": { - "ui.theme", - "ui.context", - "ui.components", - "ui.models", - "ui.panels", - "ui.config", - "ui.ui_prefs", - "ui.plugins", - }, - "ui.window": { - "ui.theme", - "ui.context", - "ui.components", - "ui.models", - "ui.panels", - "ui.views", - "ui.config", - "ui.ui_prefs", - "ui.mod_selection", - "ui.navigation", - }, -} - -_PREFIX_TO_GROUP: dict[str, str] = {} -for _group, _members in GROUPS.items(): - for _member in _members: - _PREFIX_TO_GROUP[f"pxmodrim.{_member}"] = _group - - -def _layer(modname: str) -> str | None: - if not modname.startswith("pxmodrim."): - return None - parts = modname.split(".") - if len(parts) < 3: - return None - top = parts[1] - if top in ENTRYPOINTS: - return None - if parts[2] == "__init__": - return None - prefix = f"pxmodrim.{top}.{parts[2]}" - return _PREFIX_TO_GROUP.get(prefix, top) - - -def _is_allowed(src_group: str, tgt_group: str) -> bool: - own = ALLOWED.get(src_group, set()) - parent = ALLOWED.get(src_group.split(".")[0], set()) - effective = own | parent - if tgt_group in effective: - return True - return tgt_group.split(".")[0] in effective - - -def main() -> int: - cfg = Config() - cfg.fname = SRC - cfg.no_show = True - cfg.no_output = True - cfg.max_bacon = sys.maxsize - cfg.no_dot = True - cfg.max_module_depth = 3 - - trgt = target.Target(cfg.fname) - dg = py2depgraph.py2dep(trgt, **cfg.__dict__) - - has_error = False - - cycles = dg.find_import_cycles() - if cycles: - print(f"Found {len(cycles)} cyclic import(s):") - for cycle in cycles: - parts = [s.name for s in cycle] - print(" ", " → ".join(parts)) - has_error = True - - violations: list[tuple[str, str, str]] = [] - for modname, src in dg.sources.items(): - src_group = _layer(modname) - if src_group is None: - continue - for imp in src.imports: - tgt_group = _layer(imp) - if tgt_group is None or tgt_group == src_group: - continue - if _is_allowed(src_group, tgt_group): - continue - violations.append( - ( - modname.removeprefix("pxmodrim."), - imp.removeprefix("pxmodrim."), - tgt_group, - ) - ) - - if violations: - print("\nLayer dependency violations:") - for src_mod, tgt_mod, tgt_group in sorted(violations): - print(f" {src_mod} → {tgt_mod} (forbidden: {tgt_group})") - has_error = True - - if has_error: - print( - "\nFix: move imports to a higher layer, or update\n" - "GROUPS / ALLOWED in scripts/check-deps.py" - ) - return 1 - - print("No circular imports") - print("All layer dependencies OK") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index d0bbdfa..a294257 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -1,3 +1,44 @@ file(GLOB PX_APP_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) qt_add_executable(pxmodrim ${PX_APP_SOURCES}) target_link_libraries(pxmodrim PRIVATE pxui pxuiplugin) + +include(GNUInstallDirs) + +# The app reads the Workshop variant from workshop-variant.txt beside its executable +# (px::app::parseDisabledPlugins), so the build tree and the install get the file too. +file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" + CONTENT "${PX_WORKSHOP_VARIANT}\n") +add_custom_command(TARGET pxmodrim POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" "$") + +if(APPLE) + set_target_properties(pxmodrim PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME PxModRim + MACOSX_BUNDLE_GUI_IDENTIFIER com.github.PyXiion.PxModRim + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} + MACOSX_BUNDLE_ICON_FILE logo.icns) + target_sources(pxmodrim PRIVATE ${CMAKE_SOURCE_DIR}/packaging/logo.icns) + set_source_files_properties(${CMAKE_SOURCE_DIR}/packaging/logo.icns + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + set(PX_APP_INSTALL_DIR pxmodrim.app/Contents/MacOS) + install(TARGETS pxmodrim BUNDLE DESTINATION .) +else() + set(PX_APP_INSTALL_DIR ${CMAKE_INSTALL_BINDIR}) + install(TARGETS pxmodrim RUNTIME DESTINATION ${PX_APP_INSTALL_DIR}) +endif() + +if(WIN32) + file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/pxmodrim.rc" + CONTENT "IDI_ICON1 ICON \"${CMAKE_SOURCE_DIR}/packaging/logo.ico\"\n") + target_sources(pxmodrim PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/pxmodrim.rc") +endif() + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" DESTINATION ${PX_APP_INSTALL_DIR}) + +if(UNIX AND NOT APPLE) + install(FILES ${CMAKE_SOURCE_DIR}/packaging/linux/pxmodrim.desktop DESTINATION share/applications) + install(FILES ${CMAKE_SOURCE_DIR}/src/ui/assets/logo.svg RENAME pxmodrim.svg + DESTINATION share/icons/hicolor/scalable/apps) +endif() diff --git a/src/ui/components/dialogs.h b/src/ui/components/dialogs.h index 0125c69..20bc6e1 100644 --- a/src/ui/components/dialogs.h +++ b/src/ui/components/dialogs.h @@ -6,16 +6,42 @@ // synchronously from `finished`, so that holds for any code after the co_await). #include +#include #include -#include #include #include +#include #include "ui/components/dialog_chrome.h" namespace px::ui { +// Resumes from inside `finished` itself. A queued resume could run after the dialog, and the +// window owning it, are gone; the coroutine must not outlive the emission. +class DialogFinished { +public: + explicit DialogFinished(QDialog* dialog) + : connection_(QObject::connect(dialog, &QDialog::finished, dialog, [this](int code) { + code_ = code; + finished_ = true; + if (waiter_) std::exchange(waiter_, nullptr).resume(); + })) {} + DialogFinished(const DialogFinished&) = delete; + DialogFinished& operator=(const DialogFinished&) = delete; + ~DialogFinished() { QObject::disconnect(connection_); } + + bool await_ready() const noexcept { return finished_; } + void await_suspend(std::coroutine_handle<> waiting) noexcept { waiter_ = waiting; } + int await_resume() const noexcept { return code_; } + +private: + QMetaObject::Connection connection_; + std::coroutine_handle<> waiter_; + int code_ = QDialog::Rejected; + bool finished_ = false; +}; + template T, class... Args> QCoro::Task> awaitDialog(Args&&... args) { auto* dialog = new T(std::forward(args)...); @@ -23,9 +49,9 @@ QCoro::Task> awaitDialog(Args&&... args) { dialog->setModal(true); // Subscribe before show(), so a finish that happens immediately is not missed. - auto finished = qCoro(dialog, &QDialog::finished); + DialogFinished finished(dialog); dialog->show(); - const int code = co_await std::move(finished); + const int code = co_await finished; dialog->deleteLater(); co_return std::pair{code, dialog}; diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index 2c18589..a646ad9 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -43,14 +43,14 @@ WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, catalog_(catalog), downloads_(ctx.downloads()), favourites_(catalog.favourites()) { if (!gameVersion().isEmpty()) criteria_.version = gameVersion(); refresh_mod_ids(); - auto* qml = new QQuickWidget(engine, this); - qml->setObjectName(QStringLiteral("workshopView")); - qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml->setClearColor(px::ui::paletteColor(u"ELEVATE_0")); - qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); - qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), mods_model); - qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collections_model); - qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), members_model); + qml_ = new QQuickWidget(engine, this); + qml_->setObjectName(QStringLiteral("workshopView")); + qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml_->setClearColor(px::ui::paletteColor(u"ELEVATE_0")); + qml_->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); + qml_->rootContext()->setContextProperty(QStringLiteral("catalogMods"), mods_model); + qml_->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collections_model); + qml_->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), members_model); busy_id_ = downloads_.busy_changed.connect([this](bool) { refresh_rows(); emit changed(); }); queue_id_ = catalog_.queue_changed.connect([this](const std::monostate&) { refresh_rows(); emit changed(); }); url_id_ = catalog_.catalog_url_changed.connect([this](const QString&) { url_changed(); }); @@ -62,11 +62,15 @@ WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, active_id_ = ctx_.activeStateChanged().connect([this](const QStringList&) { refresh_mod_ids(); refresh_rows(); emit changed(); }); - qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); - root_->addWidget(qml, 1); + qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + root_->addWidget(qml_, 1); } -WorkshopViewPanel::~WorkshopViewPanel() { teardown(); } +WorkshopViewPanel::~WorkshopViewPanel() { + teardown(); + // Workshop.qml bindings read workshopPanel; the view must die while this panel is still alive. + delete qml_; +} void WorkshopViewPanel::teardown() { if (torn_down_) return; torn_down_ = true; diff --git a/src/ui/plugins/workshop/view.h b/src/ui/plugins/workshop/view.h index 2fcf6ae..d2c74d7 100644 --- a/src/ui/plugins/workshop/view.h +++ b/src/ui/plugins/workshop/view.h @@ -14,6 +14,8 @@ #include "ui/plugins/workshop/models.h" #include "ui/views/base.h" +class QQuickWidget; + namespace px::ui::workshop { class WorkshopViewPanel : public BaseViewPanel { @@ -133,6 +135,7 @@ class WorkshopViewPanel : public BaseViewPanel { core::workshop::WorkshopCatalog& catalog_; core::DownloadManager& downloads_; core::workshop::FavouriteCollections& favourites_; + QQuickWidget* qml_ = nullptr; QString tab_ = QStringLiteral("Discover"); core::workshop::CatalogQuery criteria_; bool busy_ = false; diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index a10350f..ace2fca 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -167,7 +167,7 @@ TEST_CASE("UpdateService wraps non-JSON bodies") { } TEST_CASE("UpdateService rejects non-object JSON payloads") { - for (const QByteArray& payload : {"[]", "null", "\"tag\"", "3"}) { + for (const QByteArray payload : {QByteArray("[]"), QByteArray("null"), QByteArray("\"tag\""), QByteArray("3")}) { CAPTURE(payload); const auto result = check_payload(payload); CHECK(!result.has_value()); diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp index 7fc2ac5..b3a51bf 100644 --- a/tests/ui/test_mod_list_panel.cpp +++ b/tests/ui/test_mod_list_panel.cpp @@ -163,6 +163,7 @@ TEST_CASE("QML selection follows UUID during a row move and reveal respects filt } TEST_CASE("search focus disables list keyboard actions and Space remains input text") { PanelFixture f(false); f.show(); + f.parent.show(); REQUIRE(QTest::qWaitForWindowActive(&f.parent)); QTest::mouseClick(f.panel->qml_widget(),Qt::LeftButton,Qt::NoModifier,QPoint(100,26)); QTest::qWait(1); CHECK(f.root()->property("keyboardActive").toBool()); f.select(0,"uuid-a"); f.panel->search_input->setFocus(); QTest::qWait(1); diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index 5a943fc..290c612 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -206,12 +206,13 @@ TEST_CASE("workshop collection detail falls back to member preview collage") { TEST_CASE("workshop collection listing detects partial and fully installed members") { PanelFixture f; + f.collection.member_ids = {"1", "2"}; f.collection.member_count = 2; f.install(QStringLiteral("1")); run(f.view->select_tab(QStringLiteral("Collections"))); auto row = f.view->collections_model->rows().front(); CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Complete download")); CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); - f.install(QStringLiteral("missing")); + f.install(QStringLiteral("2")); row = f.view->collections_model->rows().front(); CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Installed")); CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); diff --git a/uv.lock b/uv.lock deleted file mode 100644 index d6b3f40..0000000 --- a/uv.lock +++ /dev/null @@ -1,543 +0,0 @@ -version = 1 -revision = 3 -requires-python = "==3.12.*" -resolution-markers = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] -supported-markers = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] - -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, -] - -[[package]] -name = "anytree" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/a8/eb55fab589c56f9b6be2b3fd6997aa04bb6f3da93b01154ce6fc8e799db2/anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714", size = 48389, upload-time = "2025-04-08T21:06:30.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/98/f6aa7fe0783e42be3093d8ef1b0ecdc22c34c0d69640dfb37f56925cb141/anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", size = 45077, upload-time = "2025-04-08T21:06:29.494Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, -] - -[[package]] -name = "lxml" -version = "6.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, - { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, - { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, -] - -[[package]] -name = "msgspec" -version = "0.22.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/e6/6dcf9306ff3c5e486578f3bf29ed11dfbdbbc2a8bf0caf7e07d392887fda/msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38", size = 343188, upload-time = "2026-09-29T14:14:11.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/87/3e017dca361d09ed1cd09dc981a6df21b32e830fbec3470f7486d38b6be5/msgspec-0.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab1e9e7531e353653b906cdd12a0220cc288a1e8e3436aabc65f4508d91b14d9", size = 201301, upload-time = "2026-09-29T14:12:38.048Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/109165edaafb895668d87177972a32ade9126a54f3736123d8e44be9096d/msgspec-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b60b43425a47eb9cfe987f6874e354ca7c760e58e295b4e2273ff03574df28a1", size = 193044, upload-time = "2026-09-29T14:12:39.46Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/65de05f8804492f76ea121b21a125cdf1d97ec461c677bfa0ba354d6fbdd/msgspec-0.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5a169b5b03f0f2c7a296c002647db1dab75d2cd501bca34e32b71cab0261b56", size = 224035, upload-time = "2026-09-29T14:12:40.876Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cc/aa1a47f8c92280d37498a5ea56a2a36606d034383e3e6472d64cbb56cf85/msgspec-0.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c401861c5bb3a57f7d6423ea7ed4352cd57aa3f04f4fbe9f3e3e4564a10f08", size = 230377, upload-time = "2026-09-29T14:12:42.796Z" }, - { url = "https://files.pythonhosted.org/packages/61/50/f8bcdb3d613a4a4b92704297a12eba5c985cf572a64ee1a004d265759c69/msgspec-0.22.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08826f5e5b0fa2f7a88592c396a243cfcc63d37e19f9d4fbe3b3f1be2fbdc404", size = 237390, upload-time = "2026-09-29T14:12:44.282Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8a/473fa423f8fdd1b810b8652594323d7301df6920b62844d860daa0feff34/msgspec-0.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21460f54cee9208239b1a8421fdf25bffc77293e1daba88f585711ad839b9758", size = 227733, upload-time = "2026-09-29T14:12:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/03/1d/272ce23adae6c71b3f763aed3ee6e115cccc56124ed8ee0e3e3d2681e2c8/msgspec-0.22.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cfc3d9557de9c806318725b702f3e664db33167bb42892079b693c69893fd33b", size = 236783, upload-time = "2026-09-29T14:12:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/f6/26/29e0b9a8605c8819a3c718158e345a616ac42c092dd7d7ab248c2f2b0a72/msgspec-0.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b25dcbc108783cb72503ed705b9fbb8c3cb02ee5801923f44b5f038c91cc365", size = 232728, upload-time = "2026-09-29T14:12:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a6/99597c281d716da6c662b48dcc3f734669f716b41d5df2af367dac9e7c21/msgspec-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:6ad64f5c260866b0d543f89f50cee43628989c1433c5de7ce820281fa28a2611", size = 192885, upload-time = "2026-09-29T14:12:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/46/80/85fff923d448b886ec3a85900c578d9367f08dad54fe48879495b4c6d055/msgspec-0.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:0922714feff5300aacd8ecd65fa828317ce4bf5212b3139258c0bfc0253cd80e", size = 191223, upload-time = "2026-09-29T14:12:51.699Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "nuitka" -version = "4.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/27/9fef9381e967c333c808d8b087ca2cca713d608647a638d962f34ea22a45/nuitka-4.2.2.tar.gz", hash = "sha256:29c1bfb6f53154e620b38cf6167cbb03f54043f6e08ef7d3f2d5080a95df7e0d", size = 4635975, upload-time = "2026-09-22T14:25:53.659Z" } - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[package]] -name = "pxmodrim" -version = "0.3.1" -source = { editable = "." } -dependencies = [ - { name = "aiosqlite" }, - { name = "httpx" }, - { name = "loguru" }, - { name = "lxml" }, - { name = "msgspec" }, - { name = "psutil" }, - { name = "pyside6" }, - { name = "qasync" }, - { name = "toposort" }, - { name = "ttimer" }, -] - -[package.optional-dependencies] -steam = [ - { name = "pxsteamdl" }, -] - -[package.dev-dependencies] -dev = [ - { name = "nuitka" }, - { name = "pxsteamdl" }, - { name = "pydeps" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-qt" }, - { name = "pytest-xvfb" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiosqlite", specifier = ">=0.22.1" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "loguru", specifier = ">=0.7.3" }, - { name = "lxml", specifier = ">=6.1.1" }, - { name = "msgspec", specifier = ">=0.21.1" }, - { name = "psutil", specifier = ">=7.2.2" }, - { name = "pxsteamdl", marker = "extra == 'steam'", git = "https://github.com/PyXiion/PxSteamDL" }, - { name = "pyside6", specifier = ">=6.11.1" }, - { name = "qasync", specifier = ">=0.28.0" }, - { name = "toposort", specifier = ">=1.10" }, - { name = "ttimer", specifier = ">=0.0.2" }, -] -provides-extras = ["steam"] - -[package.metadata.requires-dev] -dev = [ - { name = "nuitka", specifier = ">=2.6.7" }, - { name = "pxsteamdl", git = "https://github.com/PyXiion/PxSteamDL" }, - { name = "pydeps", specifier = ">=3.0.7" }, - { name = "pyright", specifier = ">=1.1.411" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-asyncio", specifier = ">=1.4.0" }, - { name = "pytest-qt", specifier = ">=4.5.0" }, - { name = "pytest-xvfb", specifier = ">=3.1.1" }, - { name = "ruff", specifier = ">=0.16.0" }, -] - -[[package]] -name = "pxsteamdl" -version = "0.3.0" -source = { git = "https://github.com/PyXiion/PxSteamDL#d486d6b2125b8354cf4382020afd7c0774b678cc" } - -[[package]] -name = "pydeps" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "stdlib-list" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/7b/4c40de7ca4c27395adc772bec625674d874f6983657fd8763fb36bee9444/pydeps-3.0.8.tar.gz", hash = "sha256:e54b19d22c8f94b4787bc0bbb3bbe4afc074e32ff9e7868d7750340faec9f8ec", size = 59676, upload-time = "2026-09-07T13:58:56.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/5b/84b74df24b6c958dce0b8d99dc3f5b510f04828ade387ef4ae4a846f2b0a/pydeps-3.0.8-py3-none-any.whl", hash = "sha256:e7d8479292d427c87f7a05e96601c1495ca4147f5e160c48254197f4fc68601f", size = 50215, upload-time = "2026-09-07T13:58:55.576Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.414" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, -] - -[[package]] -name = "pyside6" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a", size = 571875, upload-time = "2026-08-18T06:37:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66", size = 572120, upload-time = "2026-08-18T06:37:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729", size = 572121, upload-time = "2026-08-18T06:37:32.383Z" }, - { url = "https://files.pythonhosted.org/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f", size = 578382, upload-time = "2026-08-18T06:37:34.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697", size = 561768, upload-time = "2026-08-18T06:37:36.437Z" }, -] - -[[package]] -name = "pyside6-addons" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac", size = 332047075, upload-time = "2026-08-18T06:35:33.037Z" }, - { url = "https://files.pythonhosted.org/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4", size = 175062576, upload-time = "2026-08-18T06:35:45.583Z" }, - { url = "https://files.pythonhosted.org/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a", size = 170827254, upload-time = "2026-08-18T06:35:56.675Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407", size = 168208836, upload-time = "2026-08-18T06:36:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988", size = 35807245, upload-time = "2026-08-18T06:36:12.485Z" }, -] - -[[package]] -name = "pyside6-essentials" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9", size = 110800753, upload-time = "2026-08-18T06:36:33.674Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778", size = 80108947, upload-time = "2026-08-18T06:36:40.637Z" }, - { url = "https://files.pythonhosted.org/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567", size = 79575497, upload-time = "2026-08-18T06:36:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1", size = 76913043, upload-time = "2026-08-18T06:36:54.038Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "pytest-qt" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pluggy" }, - { name = "pytest" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, -] - -[[package]] -name = "pytest-xvfb" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "pyvirtualdisplay" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/ce/b3444c98eab123b7348d55d4705c2f1095a6bdf8dae85bc6a34ab5222845/pytest_xvfb-3.1.1.tar.gz", hash = "sha256:90593634427d974b272e845793e4533f5b827c9d84c051879013624504175e44", size = 9022, upload-time = "2025-03-12T12:35:07.533Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/6e/859b1e496671fb236153c2caed519301c53f7cf3dfcaac809caae695b5a4/pytest_xvfb-3.1.1-py3-none-any.whl", hash = "sha256:3270f91417cfa0652b7c5a90e93a676e3a80bec12ccbd20cae3b42511e4aba98", size = 5861, upload-time = "2025-03-12T12:35:06.028Z" }, -] - -[[package]] -name = "pyvirtualdisplay" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, -] - -[[package]] -name = "qasync" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/b2/5be08597dbbf331edb69478eae2f8dd511834cebf56a183b442e7437f8e0/qasync-0.28.0.tar.gz", hash = "sha256:6f7f1f18971f59cb259b107218269ba56e3ad475ec456e54714b426a6e30b71d", size = 14010, upload-time = "2025-08-28T01:31:36.785Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/84/0ce4cd946f6e958428c87d5accac35df70f81607e45ba4919947d0762d63/qasync-0.28.0-py3-none-any.whl", hash = "sha256:21faba8d047c717008378f5ac29ea58c32a8128528629e4afd57c59b768dba0f", size = 16188, upload-time = "2025-08-28T01:31:35.591Z" }, -] - -[[package]] -name = "ruff" -version = "0.16.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, - { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, - { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, - { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, -] - -[[package]] -name = "shiboken6" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d", size = 476674, upload-time = "2026-08-18T06:37:11.526Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2", size = 272435, upload-time = "2026-08-18T06:37:13.397Z" }, - { url = "https://files.pythonhosted.org/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9", size = 270668, upload-time = "2026-08-18T06:37:15.025Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, -] - -[[package]] -name = "stdlib-list" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", size = 60923, upload-time = "2025-10-24T19:21:22.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/3d/2970b27a11ae17fb2d353e7a179763a2fe6f37d6d2a9f4d40104a2f132e9/stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2", size = 87615, upload-time = "2025-10-24T19:21:20.619Z" }, -] - -[[package]] -name = "tabulate" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, -] - -[[package]] -name = "toposort" -version = "1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/19/8e955d90985ecbd3b9adb2a759753a6840da2dff3c569d412b2c9217678b/toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd", size = 11132, upload-time = "2023-02-27T13:59:51.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500, upload-time = "2023-02-25T20:07:06.538Z" }, -] - -[[package]] -name = "ttimer" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anytree" }, - { name = "tabulate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/39/5ddf98e1fe3ceb46a6c252c1372e137464623ef6515b5a98161a87d5690b/ttimer-0.0.2.tar.gz", hash = "sha256:eb2b68279b7b616b513cdf1dde8c9949c8dc7c322b105262133fdff45410adca", size = 9584, upload-time = "2022-03-22T11:54:56.562Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/4d/b294f1ea65174550e67d49cb37111dd06af0e390cebb8ed0af1694924c5c/ttimer-0.0.2-py3-none-any.whl", hash = "sha256:de02458529a407ea04304ecb232586dedd2f7d894713ba53b9e00a5397cc3587", size = 8098, upload-time = "2022-03-22T11:54:55.487Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -] From ffc073fc144ceb5490606d3d555a965d0f05d93f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 02:16:30 +0300 Subject: [PATCH 06/50] test: avoid range-loop copy warning --- tests/core/services/test_update_service.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index ace2fca..5adae00 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -167,7 +167,8 @@ TEST_CASE("UpdateService wraps non-JSON bodies") { } TEST_CASE("UpdateService rejects non-object JSON payloads") { - for (const QByteArray payload : {QByteArray("[]"), QByteArray("null"), QByteArray("\"tag\""), QByteArray("3")}) { + const std::initializer_list payloads{"[]", "null", "\"tag\"", "3"}; + for (const QByteArray& payload : payloads) { CAPTURE(payload); const auto result = check_payload(payload); CHECK(!result.has_value()); From d3450341210052cc33ebf75558f5f610f0305d86 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 03:24:49 +0300 Subject: [PATCH 07/50] refactor: put all code in px::modrim --- .clang-format | 36 ++++++++++++- src/app/main.cpp | 36 ++++++------- src/app/plugins.cpp | 36 ++++++------- src/app/plugins.h | 18 +++---- src/core/checker/checker.cpp | 4 +- src/core/checker/checker.h | 4 +- src/core/checker/databases.cpp | 4 +- src/core/checker/databases.h | 4 +- src/core/checker/graph.cpp | 4 +- src/core/checker/graph.h | 4 +- src/core/checker/issues.cpp | 4 +- src/core/checker/issues.h | 4 +- src/core/checker/models.cpp | 4 +- src/core/checker/models.h | 4 +- src/core/checker/sort.cpp | 4 +- src/core/checker/sort.h | 4 +- src/core/config.cpp | 4 +- src/core/config.h | 4 +- src/core/constants.h | 4 +- src/core/context.cpp | 4 +- src/core/context.h | 4 +- src/core/context_fwd.h | 4 +- src/core/downloads/downloader.cpp | 4 +- src/core/downloads/downloader.h | 4 +- src/core/downloads/manager.cpp | 4 +- src/core/downloads/manager.h | 4 +- src/core/downloads/steam.cpp | 4 +- src/core/downloads/steam.h | 4 +- src/core/downloads/types.h | 4 +- src/core/error.h | 4 +- src/core/events.h | 4 +- src/core/file_io.cpp | 4 +- src/core/file_io.h | 4 +- src/core/loading.cpp | 4 +- src/core/loading.h | 4 +- src/core/migrator.cpp | 4 +- src/core/migrator.h | 4 +- src/core/mod_service.cpp | 8 +-- src/core/mod_service.h | 4 +- src/core/models/metadata/parsing.cpp | 4 +- src/core/models/metadata/parsing.h | 4 +- src/core/models/metadata/structures.cpp | 4 +- src/core/models/metadata/structures.h | 14 ++--- src/core/models/view/diagnostics.h | 4 +- src/core/models/view/sidebar.cpp | 4 +- src/core/models/view/sidebar.h | 4 +- src/core/mods_config.cpp | 4 +- src/core/mods_config.h | 4 +- src/core/organizer/db.cpp | 4 +- src/core/organizer/db.h | 4 +- src/core/organizer/defaults.cpp | 4 +- src/core/organizer/defaults.h | 4 +- src/core/organizer/models.h | 4 +- src/core/organizer/resolve.cpp | 4 +- src/core/organizer/resolve.h | 4 +- src/core/organizer/service.cpp | 4 +- src/core/organizer/service.h | 6 +-- src/core/plugin.h | 4 +- src/core/plugin_config.h | 4 +- src/core/profiler.cpp | 4 +- src/core/profiler.h | 4 +- src/core/providers/base.cpp | 4 +- src/core/providers/base.h | 4 +- src/core/providers/core.cpp | 4 +- src/core/providers/core.h | 4 +- src/core/providers/factory.cpp | 4 +- src/core/providers/factory.h | 4 +- src/core/providers/labels.h | 4 +- src/core/providers/local.cpp | 4 +- src/core/providers/local.h | 4 +- src/core/services/activation_service.cpp | 4 +- src/core/services/activation_service.h | 4 +- src/core/services/diagnostics_service.cpp | 4 +- src/core/services/diagnostics_service.h | 4 +- src/core/services/game_launcher.cpp | 4 +- src/core/services/game_launcher.h | 6 +-- src/core/services/game_process.cpp | 4 +- src/core/services/game_process.h | 4 +- src/core/services/metadata_cache.cpp | 4 +- src/core/services/metadata_cache.h | 4 +- src/core/services/mod_discovery.cpp | 4 +- src/core/services/mod_discovery.h | 4 +- src/core/services/sort_service.cpp | 4 +- src/core/services/sort_service.h | 4 +- .../services/startup_impact_service/db.cpp | 4 +- src/core/services/startup_impact_service/db.h | 4 +- .../startup_impact_service/labels.cpp | 4 +- .../services/startup_impact_service/labels.h | 4 +- .../startup_impact_service/models.cpp | 4 +- .../services/startup_impact_service/models.h | 4 +- .../startup_impact_service/parser.cpp | 4 +- .../services/startup_impact_service/parser.h | 4 +- .../startup_impact_service/phases.cpp | 4 +- .../services/startup_impact_service/phases.h | 4 +- .../startup_impact_service/service.cpp | 4 +- .../services/startup_impact_service/service.h | 6 +-- src/core/services/update_service.cpp | 4 +- src/core/services/update_service.h | 4 +- src/core/sort/community.cpp | 4 +- src/core/sort/community.h | 4 +- src/core/sort/community_service.cpp | 4 +- src/core/sort/community_service.h | 4 +- src/core/sort/config.cpp | 4 +- src/core/sort/config.h | 4 +- src/core/sort/models.h | 4 +- src/core/sort/tiers.cpp | 4 +- src/core/sort/tiers.h | 4 +- src/core/structures.h | 4 +- src/core/support/report.cpp | 4 +- src/core/support/report.h | 4 +- src/core/toposort.h | 4 +- src/core/utils.cpp | 4 +- src/core/utils.h | 4 +- src/core/version.cpp | 4 +- src/core/version.h | 4 +- src/core/workshop/catalog.cpp | 4 +- src/core/workshop/catalog.h | 4 +- src/core/workshop/client.cpp | 4 +- src/core/workshop/client.h | 4 +- src/core/workshop/favourites.cpp | 4 +- src/core/workshop/favourites.h | 4 +- src/core/workshop/types.h | 32 ++++++------ src/core/xml.cpp | 4 +- src/core/xml.h | 4 +- src/ui/components/banner_widget.cpp | 4 +- src/ui/components/banner_widget.h | 4 +- src/ui/components/button.cpp | 4 +- src/ui/components/button.h | 4 +- src/ui/components/dialog_chrome.cpp | 4 +- src/ui/components/dialog_chrome.h | 4 +- src/ui/components/dialogs.h | 4 +- src/ui/components/filter_sidebar.cpp | 2 +- src/ui/components/filter_sidebar.h | 2 +- src/ui/components/header_controller.cpp | 12 ++--- src/ui/components/header_controller.h | 4 +- src/ui/components/header_panel.cpp | 4 +- src/ui/components/header_panel.h | 4 +- src/ui/components/icon_button.cpp | 4 +- src/ui/components/icon_button.h | 4 +- src/ui/components/icons.cpp | 4 +- src/ui/components/icons.h | 4 +- src/ui/components/mod_activation.cpp | 8 +-- src/ui/components/mod_activation.h | 8 +-- src/ui/components/mod_updates.cpp | 12 ++--- src/ui/components/mod_updates.h | 12 ++--- src/ui/components/popup_parent.cpp | 4 +- src/ui/components/popup_parent.h | 4 +- src/ui/components/procedural_preview.cpp | 2 +- src/ui/components/procedural_preview.h | 2 +- src/ui/components/progress_dialog.cpp | 10 ++-- src/ui/components/progress_dialog.h | 10 ++-- src/ui/components/svg_provider.cpp | 4 +- src/ui/components/svg_provider.h | 4 +- src/ui/components/toast.cpp | 4 +- src/ui/components/toast.h | 4 +- src/ui/components/unity_rich_text.cpp | 2 +- src/ui/components/unity_rich_text.h | 2 +- src/ui/components/view_rail_panel.cpp | 4 +- src/ui/components/view_rail_panel.h | 4 +- src/ui/config.cpp | 12 ++--- src/ui/config.h | 14 ++--- src/ui/context.cpp | 16 +++--- src/ui/context.h | 30 +++++------ src/ui/mod_selection.cpp | 14 ++--- src/ui/mod_selection.h | 10 ++-- src/ui/models/impact.cpp | 4 +- src/ui/models/impact.h | 2 +- src/ui/models/mod_list_model.cpp | 16 +++--- src/ui/models/mod_list_model.h | 18 +++---- src/ui/models/mod_list_proxy_model.cpp | 4 +- src/ui/models/mod_list_proxy_model.h | 2 +- src/ui/models/sidebar_model.cpp | 12 ++--- src/ui/models/sidebar_model.h | 12 ++--- src/ui/navigation.cpp | 4 +- src/ui/navigation.h | 4 +- src/ui/panels/about_panel.cpp | 10 ++-- src/ui/panels/about_panel.h | 10 ++-- src/ui/panels/keyboard_shortcuts_dialog.cpp | 4 +- src/ui/panels/keyboard_shortcuts_dialog.h | 4 +- src/ui/panels/mod_info_data.cpp | 14 ++--- src/ui/panels/mod_info_data.h | 4 +- src/ui/panels/mod_info_panel.cpp | 18 +++---- src/ui/panels/mod_info_panel.h | 14 ++--- src/ui/panels/mod_list_panel.cpp | 6 +-- src/ui/panels/mod_list_panel.h | 10 ++-- src/ui/panels/restore_snapshot_dialog.cpp | 6 +-- src/ui/panels/restore_snapshot_dialog.h | 6 +-- src/ui/panels/settings_panel.cpp | 20 +++---- src/ui/panels/settings_panel.h | 14 ++--- src/ui/panels/sidebar_panel.cpp | 10 ++-- src/ui/panels/sidebar_panel.h | 16 +++--- src/ui/panels/startup_impact_data.cpp | 6 +-- src/ui/panels/startup_impact_data.h | 6 +-- src/ui/panels/time_analytics_panel.cpp | 8 +-- src/ui/panels/time_analytics_panel.h | 10 ++-- src/ui/panels/update_dialog.cpp | 6 +-- src/ui/panels/update_dialog.h | 6 +-- src/ui/panels/upload_report_dialog.cpp | 18 +++---- src/ui/panels/upload_report_dialog.h | 8 +-- src/ui/plugins/downloads/model.cpp | 10 ++-- src/ui/plugins/downloads/model.h | 10 ++-- src/ui/plugins/downloads/plugin.cpp | 4 +- src/ui/plugins/downloads/plugin.h | 4 +- src/ui/plugins/downloads/view.cpp | 12 ++--- src/ui/plugins/downloads/view.h | 20 +++---- src/ui/plugins/organizer/dialogs.cpp | 8 +-- src/ui/plugins/organizer/dialogs.h | 8 +-- src/ui/plugins/organizer/filter_model.cpp | 8 +-- src/ui/plugins/organizer/filter_model.h | 10 ++-- src/ui/plugins/organizer/plugin.h | 4 +- src/ui/plugins/organizer/tree_model.cpp | 20 +++---- src/ui/plugins/organizer/tree_model.h | 22 ++++---- src/ui/plugins/organizer/view.cpp | 24 ++++----- src/ui/plugins/organizer/view.h | 12 ++--- src/ui/plugins/steam_downloader/plugin.cpp | 8 +-- src/ui/plugins/steam_downloader/plugin.h | 8 +-- src/ui/plugins/steam_workshop/bridge_obj.cpp | 4 +- src/ui/plugins/steam_workshop/bridge_obj.h | 4 +- .../steam_workshop/download_queue_model.cpp | 4 +- .../steam_workshop/download_queue_model.h | 4 +- .../steam_workshop/download_sidebar.cpp | 4 +- .../plugins/steam_workshop/download_sidebar.h | 4 +- src/ui/plugins/steam_workshop/plugin.cpp | 24 ++++----- src/ui/plugins/steam_workshop/plugin.h | 38 +++++++------- src/ui/plugins/steam_workshop/view.cpp | 8 +-- src/ui/plugins/steam_workshop/view.h | 20 +++---- src/ui/plugins/workshop/details.cpp | 12 ++--- src/ui/plugins/workshop/details.h | 14 ++--- src/ui/plugins/workshop/installed.cpp | 14 ++--- src/ui/plugins/workshop/installed.h | 16 +++--- src/ui/plugins/workshop/models.cpp | 8 +-- src/ui/plugins/workshop/models.h | 8 +-- src/ui/plugins/workshop/plugin.cpp | 10 ++-- src/ui/plugins/workshop/plugin.h | 10 ++-- src/ui/plugins/workshop/view.cpp | 14 ++--- src/ui/plugins/workshop/view.h | 44 ++++++++-------- src/ui/settings_section.cpp | 6 +-- src/ui/settings_section.h | 20 +++---- src/ui/theme/constants.h | 4 +- src/ui/theme/palette.cpp | 4 +- src/ui/theme/palette.h | 4 +- src/ui/theme/qml_theme.cpp | 4 +- src/ui/theme/qml_theme.h | 4 +- src/ui/ui_prefs.h | 6 +-- src/ui/views/base.cpp | 6 +-- src/ui/views/base.h | 6 +-- src/ui/views/mods_view.cpp | 6 +-- src/ui/views/mods_view.h | 8 +-- src/ui/window/actions.cpp | 4 +- src/ui/window/actions.h | 4 +- src/ui/window/main_window.cpp | 32 ++++++------ src/ui/window/main_window.h | 12 ++--- src/ui/window/menu_bar.cpp | 4 +- src/ui/window/menu_bar.h | 4 +- tests/core/checker/test_checker.cpp | 4 +- tests/core/checker/test_databases.cpp | 4 +- tests/core/checker/test_graph.cpp | 4 +- tests/core/checker/test_issues.cpp | 4 +- tests/core/downloads/test_manager.cpp | 12 ++--- tests/core/downloads/test_steam.cpp | 36 ++++++------- tests/core/models/metadata/test_parsing.cpp | 10 ++-- tests/core/models/metadata/test_schemas.cpp | 2 +- .../core/models/metadata/test_structures.cpp | 10 ++-- tests/core/organizer/test_db.cpp | 2 +- tests/core/organizer/test_resolve.cpp | 4 +- tests/core/organizer/test_service.cpp | 4 +- tests/core/organizer/test_util.h | 6 +-- tests/core/providers/test_factory.cpp | 6 +-- tests/core/providers/test_providers.cpp | 14 ++--- .../startup_impact_service/test_db.cpp | 4 +- .../startup_impact_service/test_phases.cpp | 2 +- .../test_startup_impact.cpp | 2 +- .../core/services/test_activation_service.cpp | 2 +- tests/core/services/test_game_launcher.cpp | 34 ++++++------ tests/core/services/test_metadata_cache.cpp | 6 +-- tests/core/services/test_sort_service.cpp | 14 ++--- tests/core/services/test_update_service.cpp | 4 +- tests/core/sort/test_community.cpp | 4 +- tests/core/sort/test_tier_order.cpp | 18 +++---- tests/core/sort/test_tiers.cpp | 16 +++--- tests/core/support/test_report.cpp | 6 +-- tests/core/test_config.cpp | 2 +- tests/core/test_context.cpp | 14 ++--- tests/core/test_loading.cpp | 18 +++---- tests/core/test_migrator.cpp | 6 +-- tests/core/test_mods_config.cpp | 2 +- tests/core/test_plugin.cpp | 4 +- tests/core/test_plugin_config.cpp | 2 +- tests/core/test_utils.cpp | 16 +++--- tests/core/test_version.cpp | 4 +- tests/core/test_xml.cpp | 2 +- tests/core/workshop/support.h | 14 ++--- tests/core/workshop/test_catalog.cpp | 4 +- tests/core/workshop/test_client.cpp | 2 +- tests/core/workshop/test_favourites.cpp | 4 +- tests/core/workshop/test_types.cpp | 2 +- tests/ui/test_dialog_chrome.cpp | 2 +- tests/ui/test_downloads_model.cpp | 12 ++--- tests/ui/test_main_window.cpp | 52 +++++++++---------- tests/ui/test_menu_bar.cpp | 20 +++---- tests/ui/test_mod_info_data.cpp | 30 +++++------ tests/ui/test_mod_list_model.cpp | 8 +-- tests/ui/test_mod_list_panel.cpp | 18 +++---- tests/ui/test_mod_list_proxy_model.cpp | 22 ++++---- tests/ui/test_mod_tree_model.cpp | 8 +-- tests/ui/test_mod_updates.cpp | 4 +- tests/ui/test_navigation.cpp | 6 +-- tests/ui/test_organizer_dialogs.cpp | 2 +- tests/ui/test_organizer_rule_editor.cpp | 6 +-- tests/ui/test_procedural_preview.cpp | 8 +-- tests/ui/test_restore_snapshot_dialog.cpp | 10 ++-- tests/ui/test_settings_panel.cpp | 6 +-- tests/ui/test_sidebar_model.cpp | 18 +++---- tests/ui/test_startup_impact_data.cpp | 4 +- tests/ui/test_support.h | 6 +-- tests/ui/test_toast_and_progress.cpp | 10 ++-- tests/ui/test_unity_rich_text.cpp | 8 +-- tests/ui/test_views.cpp | 18 +++---- tests/ui/test_workshop_installed.cpp | 6 +-- tests/ui/test_workshop_models.cpp | 8 +-- tests/ui/test_workshop_view.cpp | 10 ++-- 321 files changed, 1246 insertions(+), 1214 deletions(-) diff --git a/.clang-format b/.clang-format index 4ca6bcb..2f8df33 100644 --- a/.clang-format +++ b/.clang-format @@ -1,3 +1,35 @@ +--- +Language: Cpp BasedOnStyle: Google -IndentWidth: 4 -ColumnLimit: 100 +Standard: c++20 + +# Google puts everything in <> that has no dot into the "C++ standard library" group, which +# would swallow every Qt header. Regroup explicitly: std, Qt, other libraries, then ours. +# The header a .cpp implements stays first (priority 0 is automatic). +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^<[a-z_0-9]+(\.h)?>$' + Priority: 1 + - Regex: '^ teardown(px::ui::AppContext& appContext, px::core::CoreContext& ctx) { +QCoro::Task teardown(px::modrim::AppContext& appContext, px::modrim::CoreContext& ctx) { if (auto shut = co_await appContext.shutdownAll(); !shut) { spdlog::error("Shutdown failed: {}", shut.error().message.toStdString()); } @@ -107,9 +107,9 @@ QCoro::Task teardown(px::ui::AppContext& appContext, px::core::CoreContext QCoro::Task runApp(QApplication& qtApp, QSet disabled) { spdlog::info("Starting PxModRim"); - px::core::ConfigService configService(px::core::config_dir()); - px::core::AppConfigService appConfigService(configService); - px::ui::UiPrefsService uiPrefsService(configService); + px::modrim::ConfigService configService(px::modrim::config_dir()); + px::modrim::AppConfigService appConfigService(configService); + px::modrim::UiPrefsService uiPrefsService(configService); if (auto setup = co_await appConfigService.setup(); !setup) { spdlog::error("Failed to initialize configuration: {}", setup.error().message.toStdString()); @@ -120,13 +120,13 @@ QCoro::Task runApp(QApplication& qtApp, QSet disabled) { co_return 1; } - px::core::AppConfig cfg = appConfigService.cfg(); + px::modrim::AppConfig cfg = appConfigService.cfg(); spdlog::info("Configuration loaded: game={}, local={}, workshop={}", cfg.paths.game, cfg.paths.local, cfg.paths.workshop); if (cfg.paths.game.empty()) { spdlog::info("No game path in config, attempting auto-detect"); - if (auto detected = px::core::detect_game_paths(); !detected.game.empty()) { + if (auto detected = px::modrim::detect_game_paths(); !detected.game.empty()) { cfg.paths = detected; if (auto saved = configService.save(QStringLiteral("config.json"), cfg); !saved) { spdlog::warn("Cannot save detected game paths: {}", saved.error().message.toStdString()); @@ -135,13 +135,13 @@ QCoro::Task runApp(QApplication& qtApp, QSet disabled) { } // Declared before the context, so it outlives the organizer service that holds it. - px::core::organizer::OrganizerDb organizerDb(px::core::organizer::db_path(configService.directory())); - auto ctx = px::core::CoreContext::create(cfg, &configService); - const QStringList downloaders = px::app::registerCorePlugins(*ctx, configService, organizerDb, disabled); + px::modrim::organizer::OrganizerDb organizerDb(px::modrim::organizer::db_path(configService.directory())); + auto ctx = px::modrim::CoreContext::create(cfg, &configService); + const QStringList downloaders = px::modrim::registerCorePlugins(*ctx, configService, organizerDb, disabled); - px::ui::AppContext appContext(*ctx, uiPrefsService.prefs()); - px::app::PluginSelection selection{std::move(disabled), downloaders}; - px::app::registerUiPlugins(appContext, selection); + px::modrim::AppContext appContext(*ctx, uiPrefsService.prefs()); + px::modrim::PluginSelection selection{std::move(disabled), downloaders}; + px::modrim::registerUiPlugins(appContext, selection); if (auto setup = appContext.setupAll(); !setup) { spdlog::error("Plugin setup failed: {}", setup.error().message.toStdString()); @@ -151,7 +151,7 @@ QCoro::Task runApp(QApplication& qtApp, QSet disabled) { spdlog::info("Services initialized for game version {}", ctx->gameVersion().toStdString()); // The window opens before mods load, as in the legacy app. - auto window = std::make_unique(appContext); + auto window = std::make_unique(appContext); window->setAppQuitCallback([] { QCoreApplication::quit(); }); window->show(); @@ -187,7 +187,7 @@ int main(int argc, char* argv[]) { QApplication qtApp(argc, argv); qtApp.setApplicationName(QStringLiteral("PxModRim")); - qtApp.setApplicationVersion(px::core::appVersion()); + qtApp.setApplicationVersion(px::modrim::appVersion()); qtApp.setOrganizationName(QStringLiteral("PxModRim")); qtApp.setApplicationDisplayName(QStringLiteral("PxModRim")); qtApp.setDesktopFileName(QStringLiteral("pxmodrim")); @@ -195,10 +195,10 @@ int main(int argc, char* argv[]) { // The app quits when its run finishes, not when the last window closes. qtApp.setQuitOnLastWindowClosed(false); qtApp.styleHints()->setWheelScrollLines(kWheelScrollLines); - px::ui::applyAppTheme(qtApp); - qtApp.installEventFilter(new px::ui::QuickPopupParenting(&qtApp)); + px::modrim::applyAppTheme(qtApp); + qtApp.installEventFilter(new px::modrim::QuickPopupParenting(&qtApp)); - configureLogging(QDir(px::core::config_dir()).filePath(QStringLiteral("logs"))); + configureLogging(QDir(px::modrim::config_dir()).filePath(QStringLiteral("logs"))); if (const int smokeMs = smokeExitMs(); smokeMs > 0) { QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); @@ -206,7 +206,7 @@ int main(int argc, char* argv[]) { const QString manifest = QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("workshop-variant.txt")); - auto disabled = px::app::parseDisabledPlugins(qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); + auto disabled = px::modrim::parseDisabledPlugins(qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); if (!disabled) { spdlog::critical("{}", disabled.error().message.toStdString()); return 1; diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp index e8ee5fe..b6c0403 100644 --- a/src/app/plugins.cpp +++ b/src/app/plugins.cpp @@ -23,9 +23,9 @@ #include "core/downloads/steam.h" #endif -namespace px::app { +namespace px::modrim { -std::expected, px::core::Error> parseDisabledPlugins(const QString& envValue, +std::expected, px::modrim::Error> parseDisabledPlugins(const QString& envValue, const QString& manifestPath) { QSet disabled; for (const QString& part : envValue.split(QLatin1Char(','))) { @@ -40,7 +40,7 @@ std::expected, px::core::Error> parseDisabledPlugins(const QString } QFile manifest(manifestPath); if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { - return std::unexpected(px::core::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); + return std::unexpected(px::modrim::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); } const QString variant = QString::fromUtf8(manifest.readAll()).trimmed(); @@ -53,19 +53,19 @@ std::expected, px::core::Error> parseDisabledPlugins(const QString } else if (variant == QLatin1StringView("SteamWorkshop")) { disabled.unite(QSet{QStringLiteral("workshop_catalog"), QStringLiteral("workshop_ui")}); } else { - return std::unexpected(px::core::Error{QStringLiteral("Unknown workshop variant: %1").arg(variant)}); + return std::unexpected(px::modrim::Error{QStringLiteral("Unknown workshop variant: %1").arg(variant)}); } return disabled; } -QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigService& config, - px::core::organizer::OrganizerDb& organizerDb, const QSet& disabled) { - ctx.registerPlugin(std::make_unique(ctx, organizerDb)); +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, const QSet& disabled) { + ctx.registerPlugin(std::make_unique(ctx, organizerDb)); QStringList downloaders; if (!disabled.contains(QStringLiteral("steam_downloader"))) { #ifdef PX_HAS_STEAM - ctx.registerPlugin(std::make_unique(px::core::SteamDownloaderDeps{ + ctx.registerPlugin(std::make_unique(px::modrim::SteamDownloaderDeps{ ctx.downloads(), config, [&ctx] { return QString::fromStdString(ctx.config().paths.local); }, @@ -79,7 +79,7 @@ QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigServ // The catalog is only useful with a downloader to fetch items. if (!downloaders.isEmpty() && !disabled.contains(QStringLiteral("workshop_catalog"))) { - ctx.registerPlugin(std::make_unique()); + ctx.registerPlugin(std::make_unique()); } return downloaders; } @@ -87,19 +87,19 @@ QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigServ // How to add a UI plugin (mirrors the legacy _app.py `_setup`): // - Guard it exactly as _app.py did, e.g. // if (!selection.disabled.contains(QStringLiteral("workshop_ui")) && selection.downloaders.contains("steam")) -// app.registerPlugin(std::make_unique()); +// app.registerPlugin(std::make_unique()); // - Rail views go through app.addRailView(), and must come before the plugins // that read the rail. // Each port adds one line here; no other file changes. -void registerUiPlugins(px::ui::AppContext& ctx, const PluginSelection& selection) { - ctx.addRailView(); - ctx.registerPlugin(std::make_unique()); +void registerUiPlugins(px::modrim::AppContext& ctx, const PluginSelection& selection) { + ctx.addRailView(); + ctx.registerPlugin(std::make_unique()); #ifdef PX_HAS_STEAM - if (selection.downloaders.contains(QStringLiteral("steam"))) ctx.registerPlugin(std::make_unique()); - if (selection.downloaders.contains(QStringLiteral("steam")) && !selection.disabled.contains(QStringLiteral("steamworkshop"))) ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam"))) ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam")) && !selection.disabled.contains(QStringLiteral("steamworkshop"))) ctx.registerPlugin(std::make_unique()); #endif - if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("downloads_ui"))) ctx.registerPlugin(std::make_unique()); - if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("workshop_catalog")) && !selection.disabled.contains(QStringLiteral("workshop_ui"))) ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("downloads_ui"))) ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("workshop_catalog")) && !selection.disabled.contains(QStringLiteral("workshop_ui"))) ctx.registerPlugin(std::make_unique()); } -} // namespace px::app +} // namespace px::modrim diff --git a/src/app/plugins.h b/src/app/plugins.h index b708bf5..2321e85 100644 --- a/src/app/plugins.h +++ b/src/app/plugins.h @@ -8,19 +8,19 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { class ConfigService; class CoreContext; namespace organizer { class OrganizerDb; } -} // namespace px::core +} // namespace px::modrim -namespace px::ui { +namespace px::modrim { class AppContext; } -namespace px::app { +namespace px::modrim { // Which plugins this run keeps, and which downloaders came up. Core registration // produces `downloaders`; UI registration reads both. @@ -33,16 +33,16 @@ struct PluginSelection { // Names from PX_DISABLED_PLUGINS (comma-separated) plus the plugins the baked // workshop variant omits. `manifestPath` is the workshop-variant.txt file; a missing // file bakes nothing. Fails on an unknown variant name. -std::expected, px::core::Error> parseDisabledPlugins(const QString& envValue, +std::expected, px::modrim::Error> parseDisabledPlugins(const QString& envValue, const QString& manifestPath); // Registers core plugins in legacy _app.py order and returns the downloaders that came // up. `organizerDb` is not owned and must outlive `ctx`. -QStringList registerCorePlugins(px::core::CoreContext& ctx, px::core::ConfigService& config, - px::core::organizer::OrganizerDb& organizerDb, const QSet& disabled); +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, const QSet& disabled); // Registers the UI plugins and rail views. Add one line per plugin here, guarded the // same way the legacy _app.py guarded it; see plugins.cpp for the pattern. -void registerUiPlugins(px::ui::AppContext& app, const PluginSelection& selection); +void registerUiPlugins(px::modrim::AppContext& app, const PluginSelection& selection); -} // namespace px::app +} // namespace px::modrim diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp index c3626e4..eb6afa8 100644 --- a/src/core/checker/checker.cpp +++ b/src/core/checker/checker.cpp @@ -5,7 +5,7 @@ #include -namespace px::core::checker { +namespace px::modrim::checker { namespace { ModDiagnostics compose(const std::vector>& per_checker) { @@ -287,4 +287,4 @@ void ModChecker::emit_diagnostics() const { on_diagnostics_changed_(by_uuid); } -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/checker.h b/src/core/checker/checker.h index dd4a4a7..6345e54 100644 --- a/src/core/checker/checker.h +++ b/src/core/checker/checker.h @@ -18,7 +18,7 @@ #include "core/sort/config.h" #include "core/sort/models.h" -namespace px::core::checker { +namespace px::modrim::checker { // Diagnostics with at least one issue, keyed by mod uuid. using DiagnosticsByUuid = QHash; @@ -101,4 +101,4 @@ class ModChecker { std::optional checker_results_; }; -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp index 8b77451..86f29f7 100644 --- a/src/core/checker/databases.cpp +++ b/src/core/checker/databases.cpp @@ -29,7 +29,7 @@ #include "core/file_io.h" #include "core/xml.h" -namespace px::core::checker { +namespace px::modrim::checker { namespace { const QString kNoVersionWarningUrl = @@ -421,4 +421,4 @@ QCoro::Task> UseThisInsteadService::download() { co_return entries_; } -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/databases.h b/src/core/checker/databases.h index 1254bea..189436e 100644 --- a/src/core/checker/databases.h +++ b/src/core/checker/databases.h @@ -12,7 +12,7 @@ class QNetworkAccessManager; -namespace px::core { +namespace px::modrim { class ConfigService; @@ -79,4 +79,4 @@ class UseThisInsteadService { }; } // namespace checker -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index 06fc738..c752d1b 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -4,7 +4,7 @@ #include #include -namespace px::core::checker { +namespace px::modrim::checker { namespace { constexpr std::size_t index_of_type(EdgeType type) { @@ -327,4 +327,4 @@ void ConstraintGraph::add_load_rules(const PackageId& pid, } } -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/graph.h b/src/core/checker/graph.h index dcd44f6..77ab81e 100644 --- a/src/core/checker/graph.h +++ b/src/core/checker/graph.h @@ -13,7 +13,7 @@ #include "core/sort/config.h" #include "core/sort/models.h" -namespace px::core::checker { +namespace px::modrim::checker { enum class EdgeType : std::uint8_t { Dependency, @@ -108,4 +108,4 @@ class ConstraintGraph { std::optional>> cycle_components_; }; -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp index eafd4d4..af0f3ae 100644 --- a/src/core/checker/issues.cpp +++ b/src/core/checker/issues.cpp @@ -4,7 +4,7 @@ #include "core/checker/graph.h" -namespace px::core::checker { +namespace px::modrim::checker { bool DependencyIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext&) const { return !mod.about_rules.dependencies.isEmpty(); @@ -266,4 +266,4 @@ QList ReplacementIssueChecker::check(const AboutXmlMod& mod, }}; } -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/issues.h b/src/core/checker/issues.h index ec4115f..efbaf6e 100644 --- a/src/core/checker/issues.h +++ b/src/core/checker/issues.h @@ -9,7 +9,7 @@ #include "core/checker/models.h" -namespace px::core::checker { +namespace px::modrim::checker { class ModIssueChecker { public: @@ -94,4 +94,4 @@ class ReplacementIssueChecker final : public ModIssueChecker { QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; }; -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp index 51fd85b..fe372ac 100644 --- a/src/core/checker/models.cpp +++ b/src/core/checker/models.cpp @@ -1,6 +1,6 @@ #include "core/checker/models.h" -namespace px::core::checker { +namespace px::modrim::checker { QString ModDiagnostics::error_tooltip() const { if (errors.isEmpty()) { @@ -22,4 +22,4 @@ QString ModDiagnostics::warning_tooltip() const { return QStringLiteral("%1 warnings").arg(warnings.size()); } -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/models.h b/src/core/checker/models.h index b32ac57..f5b017f 100644 --- a/src/core/checker/models.h +++ b/src/core/checker/models.h @@ -11,7 +11,7 @@ #include "core/models/metadata/structures.h" #include "core/sort/config.h" -namespace px::core::checker { +namespace px::modrim::checker { using PackageId = CaseInsensitiveStr; using AboutXmlModPtr = std::shared_ptr; @@ -58,4 +58,4 @@ struct CheckContext { const QList>& cycles; }; -} // namespace px::core::checker +} // namespace px::modrim::checker diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp index 0b3535c..2bffe8e 100644 --- a/src/core/checker/sort.cpp +++ b/src/core/checker/sort.cpp @@ -8,7 +8,7 @@ #include "core/sort/tiers.h" -namespace px::core::checker { +namespace px::modrim::checker { namespace { using sort::PackageGraph; @@ -245,4 +245,4 @@ QList topological_sort(const QHash topological_sort(const QHash AppConfigService::save_cfg() const { return svc_.save(kConfigFile, *cfg_); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/config.h b/src/core/config.h index f26b9b6..3f12323 100644 --- a/src/core/config.h +++ b/src/core/config.h @@ -18,7 +18,7 @@ #include "core/file_io.h" #include "core/sort/config.h" -namespace px::core { +namespace px::modrim { inline constexpr int kCurrentConfigSchemaVersion = 1; inline constexpr std::string_view kSchemaVersionKey = "schema_version"; @@ -189,4 +189,4 @@ class AppConfigService { std::optional cfg_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/constants.h b/src/core/constants.h index 00c8047..24ed41e 100644 --- a/src/core/constants.h +++ b/src/core/constants.h @@ -7,7 +7,7 @@ #include #include -namespace px::core { +namespace px::modrim { inline constexpr QLatin1StringView RIMWORLD_STEAM_APP_ID{"294100"}; @@ -79,4 +79,4 @@ inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { return nullptr; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/context.cpp b/src/core/context.cpp index 4b0f343..c1f7a8e 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -16,7 +16,7 @@ #include "core/services/game_launcher.h" #include "core/services/sort_service.h" -namespace px::core { +namespace px::modrim { namespace { @@ -176,4 +176,4 @@ void CoreContext::registerPlugin(std::unique_ptr plugin) { plugins_.register_plugin(std::move(plugin)); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/context.h b/src/core/context.h index 62c7ade..b94181b 100644 --- a/src/core/context.h +++ b/src/core/context.h @@ -17,7 +17,7 @@ #include "core/plugin.h" #include "core/structures.h" -namespace px::core { +namespace px::modrim { class ActivationService; class ConfigService; @@ -114,4 +114,4 @@ class CoreContext { PluginRegistry plugins_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/context_fwd.h b/src/core/context_fwd.h index 41eee2c..4be04c8 100644 --- a/src/core/context_fwd.h +++ b/src/core/context_fwd.h @@ -1,7 +1,7 @@ #pragma once -namespace px::core { +namespace px::modrim { class CoreContext; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp index a45c75d..b977bed 100644 --- a/src/core/downloads/downloader.cpp +++ b/src/core/downloads/downloader.cpp @@ -4,7 +4,7 @@ #include "core/downloads/manager.h" -namespace px::core { +namespace px::modrim { Downloader::Downloader(QString name, QString label, DownloadManager& manager) : Plugin(std::move(name), QStringList{kDownloadsManagerName}), @@ -28,4 +28,4 @@ QStringList Downloader::updatable_ids(const QList& mods) const { return ids; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/downloads/downloader.h b/src/core/downloads/downloader.h index b6916d0..a7ede7b 100644 --- a/src/core/downloads/downloader.h +++ b/src/core/downloads/downloader.h @@ -14,7 +14,7 @@ #include "core/models/metadata/structures.h" #include "core/plugin.h" -namespace px::core { +namespace px::modrim { inline const QString kDownloadsManagerName = QStringLiteral("downloads"); @@ -65,4 +65,4 @@ class Downloader : public Plugin { DownloadManager& manager_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp index 355185c..92ee89d 100644 --- a/src/core/downloads/manager.cpp +++ b/src/core/downloads/manager.cpp @@ -4,7 +4,7 @@ #include #include -namespace px::core { +namespace px::modrim { DownloadManager::DownloadManager() : Plugin(kDownloadsManagerName) {} @@ -149,4 +149,4 @@ void DownloadManager::cancel() { } } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/downloads/manager.h b/src/core/downloads/manager.h index cac8e0d..93db6b2 100644 --- a/src/core/downloads/manager.h +++ b/src/core/downloads/manager.h @@ -17,7 +17,7 @@ #include "core/models/metadata/structures.h" #include "core/plugin.h" -namespace px::core { +namespace px::modrim { // The one download entry point for the UI; routes to registered downloaders. // With no downloader registered nothing is updatable and `available()` is false, @@ -65,4 +65,4 @@ class DownloadManager : public Plugin { bool cancelled_ = false; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index 42f85b6..f2df128 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -20,7 +20,7 @@ #include "core/utils.h" -namespace px::core { +namespace px::modrim { namespace { constexpr auto kProgressLogInterval = std::chrono::seconds(15); @@ -565,6 +565,6 @@ QCoro::Task SteamDownloader::run_scheduled_auto_update() { arm_auto_update(kAutoUpdateCheckInterval); } -} // namespace px::core +} // namespace px::modrim #endif // PX_HAS_STEAM diff --git a/src/core/downloads/steam.h b/src/core/downloads/steam.h index f226db5..10ba828 100644 --- a/src/core/downloads/steam.h +++ b/src/core/downloads/steam.h @@ -34,7 +34,7 @@ #include "core/models/metadata/structures.h" #include "core/plugin_config.h" -namespace px::core { +namespace px::modrim { // PxSteamDL runs one shared pool of parallel_items * threads_per_item workers. inline constexpr int kSteamMaxParallelItems = 8; @@ -192,6 +192,6 @@ class SteamDownloader : public Downloader { bool stopping_ = false; }; -} // namespace px::core +} // namespace px::modrim #endif // PX_HAS_STEAM diff --git a/src/core/downloads/types.h b/src/core/downloads/types.h index 4931ef5..58ad325 100644 --- a/src/core/downloads/types.h +++ b/src/core/downloads/types.h @@ -3,7 +3,7 @@ #include #include -namespace px::core { +namespace px::modrim { struct DownloadProgress { int total = 0; @@ -35,4 +35,4 @@ struct DownloadResult { bool operator==(const DownloadResult&) const = default; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/error.h b/src/core/error.h index 56047d9..40231c8 100644 --- a/src/core/error.h +++ b/src/core/error.h @@ -4,7 +4,7 @@ #include -namespace px::core { +namespace px::modrim { struct Error { QString message; @@ -13,4 +13,4 @@ struct Error { template using Result = std::expected; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/events.h b/src/core/events.h index ac6f762..c5877bc 100644 --- a/src/core/events.h +++ b/src/core/events.h @@ -5,7 +5,7 @@ #include #include -namespace px::core { +namespace px::modrim { // Synchronous, single-threaded callback list. `connect` returns an id that // `disconnect` takes back: std::function is not comparable, so the legacy @@ -47,4 +47,4 @@ class Event { Id next_id_ = 1; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp index 5d09e65..62ff1f1 100644 --- a/src/core/file_io.cpp +++ b/src/core/file_io.cpp @@ -5,7 +5,7 @@ #include #include -namespace px::core { +namespace px::modrim { Result read_file_bytes(const QString& path) { QFile file(path); @@ -43,4 +43,4 @@ Result copy_file_replacing(const QString& source, const QString& target) { return {}; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/file_io.h b/src/core/file_io.h index cdb97d4..076bf90 100644 --- a/src/core/file_io.h +++ b/src/core/file_io.h @@ -7,7 +7,7 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { Result read_file_bytes(const QString& path); @@ -19,4 +19,4 @@ Result write_file_atomically(const QString& path, std::string_view data); // Copies `source` to `target`, replacing `target` if it exists. Result copy_file_replacing(const QString& source, const QString& target); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/loading.cpp b/src/core/loading.cpp index 62622b7..14f9e76 100644 --- a/src/core/loading.cpp +++ b/src/core/loading.cpp @@ -2,7 +2,7 @@ #include -namespace px::core { +namespace px::modrim { LoadingState::LoadingState(QObject* parent) : QObject(parent) {} @@ -77,4 +77,4 @@ void LoadingState::emit_changed() { Q_EMIT changed(frame.status, pct, frame.current_step, frame.total_steps); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/loading.h b/src/core/loading.h index 3acd20f..0bf5ef5 100644 --- a/src/core/loading.h +++ b/src/core/loading.h @@ -7,7 +7,7 @@ #include #include -namespace px::core { +namespace px::modrim { // Stack of progress frames for one async operation. Each frame is a subtask; // signals describe the innermost (top) frame. @@ -61,4 +61,4 @@ class LoadingState : public QObject { std::vector stack_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp index 356267f..ca8744c 100644 --- a/src/core/migrator.cpp +++ b/src/core/migrator.cpp @@ -9,7 +9,7 @@ #include "core/file_io.h" -namespace px::core { +namespace px::modrim { namespace { @@ -157,4 +157,4 @@ Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int sche return {}; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/migrator.h b/src/core/migrator.h index f5d8edf..0a1fcff 100644 --- a/src/core/migrator.h +++ b/src/core/migrator.h @@ -9,7 +9,7 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { // Applies one schema step, leaving the database at the step's version. using MigrationStep = std::function()>; @@ -25,4 +25,4 @@ Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int sche const MigrationSteps& steps, const std::optional& backup_path = std::nullopt, const QString& label = QStringLiteral("schema")); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp index af16577..63051aa 100644 --- a/src/core/mod_service.cpp +++ b/src/core/mod_service.cpp @@ -14,7 +14,7 @@ #include "core/services/diagnostics_service.h" #include "core/services/mod_discovery.h" -namespace px::core { +namespace px::modrim { namespace { @@ -192,7 +192,7 @@ QCoro::Task> ModService::reload(bool full) { QStringList ModService::get_snapshots() const { const auto snapshots = snapshots_dir(); - return snapshots ? px::core::get_snapshots(*snapshots) : QStringList{}; + return snapshots ? px::modrim::get_snapshots(*snapshots) : QStringList{}; } QCoro::Task ModService::restore_snapshot(QString snapshot_path) { @@ -204,7 +204,7 @@ QCoro::Task ModService::restore_snapshot(QString snapshot_path) { } const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); - auto restored = px::core::restore_snapshot(snapshot_path, config_path, *snapshots, + auto restored = px::modrim::restore_snapshot(snapshot_path, config_path, *snapshots, ctx_.config().max_snapshots); if (!restored) { spdlog::warn("Failed to restore snapshot: {}", restored.error().message.toStdString()); @@ -267,4 +267,4 @@ void ModService::close_sync() { metadata_cache_.reset(); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/mod_service.h b/src/core/mod_service.h index 859c838..7a6d8a2 100644 --- a/src/core/mod_service.h +++ b/src/core/mod_service.h @@ -17,7 +17,7 @@ #include "core/services/metadata_cache.h" #include "core/services/startup_impact_service/service.h" -namespace px::core { +namespace px::modrim { class CoreContext; @@ -74,4 +74,4 @@ class ModService { mutable QMutex save_mutex_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 5f3341e..045e7db 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -10,7 +10,7 @@ #include "core/utils.h" #include "core/xml.h" -namespace px::core { +namespace px::modrim { namespace { @@ -407,4 +407,4 @@ Result createListedModFromPath(const QString& path, const QString& return invalidListedMod(path); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/models/metadata/parsing.h b/src/core/models/metadata/parsing.h index a7b7562..eb4b6db 100644 --- a/src/core/models/metadata/parsing.h +++ b/src/core/models/metadata/parsing.h @@ -9,7 +9,7 @@ #include "core/error.h" #include "core/models/metadata/structures.h" -namespace px::core { +namespace px::modrim { // Builds a mod for the directory `path`, parsing its About.xml (found automatically, or the // explicit `aboutXmlPath`). Returns Err only when `path` is not a directory. A mod whose metadata @@ -27,4 +27,4 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, const QString& targetVersion, bool preferVersioned = true); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp index a4f36d7..a9af353 100644 --- a/src/core/models/metadata/structures.cpp +++ b/src/core/models/metadata/structures.cpp @@ -8,7 +8,7 @@ #include "core/constants.h" #include "core/utils.h" -namespace px::core { +namespace px::modrim { namespace { @@ -218,4 +218,4 @@ std::optional AboutXmlMod::get_dlc_name() const { return std::nullopt; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/models/metadata/structures.h b/src/core/models/metadata/structures.h index ec919ea..a1b0924 100644 --- a/src/core/models/metadata/structures.h +++ b/src/core/models/metadata/structures.h @@ -18,7 +18,7 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { // Package id that compares and hashes without regard to case. Stored lowercased. class CaseInsensitiveStr { @@ -266,23 +266,23 @@ struct WorkshopUpdateResult { QStringList errors; }; -} // namespace px::core +} // namespace px::modrim // Package ids are JSON strings on the wire. template <> -struct glz::to { +struct glz::to { template - static void op(const px::core::CaseInsensitiveStr& value, auto&& ctx, auto&& b, auto&& ix) { + static void op(const px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& b, auto&& ix) { glz::to::template op(value.str().toStdString(), ctx, b, ix); } }; template <> -struct glz::from { +struct glz::from { template - static void op(px::core::CaseInsensitiveStr& value, auto&& ctx, auto&& it, auto&& end) { + static void op(px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& it, auto&& end) { std::string text; glz::from::template op(text, ctx, it, end); - value = px::core::CaseInsensitiveStr(QString::fromStdString(text)); + value = px::modrim::CaseInsensitiveStr(QString::fromStdString(text)); } }; diff --git a/src/core/models/view/diagnostics.h b/src/core/models/view/diagnostics.h index ef29b9f..37d8c9b 100644 --- a/src/core/models/view/diagnostics.h +++ b/src/core/models/view/diagnostics.h @@ -4,7 +4,7 @@ #include -namespace px::core::view { +namespace px::modrim::view { // UI-agnostic diagnostics summary for one mod. struct ModDiagnosticsView { @@ -25,4 +25,4 @@ struct ModIssueView { bool operator==(const ModIssueView&) const = default; }; -} // namespace px::core::view +} // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp index a9c06b2..a1616ab 100644 --- a/src/core/models/view/sidebar.cpp +++ b/src/core/models/view/sidebar.cpp @@ -2,7 +2,7 @@ #include -namespace px::core::view { +namespace px::modrim::view { QString provider_label(const QString& provider_id) { static const QHash labels{ @@ -14,4 +14,4 @@ QString provider_label(const QString& provider_id) { return labels.value(provider_id, provider_id); } -} // namespace px::core::view +} // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.h b/src/core/models/view/sidebar.h index bf34381..96920c0 100644 --- a/src/core/models/view/sidebar.h +++ b/src/core/models/view/sidebar.h @@ -5,7 +5,7 @@ #include #include -namespace px::core::view { +namespace px::modrim::view { // Display name of a mod provider; an unknown provider is shown by its id. QString provider_label(const QString& provider_id); @@ -23,4 +23,4 @@ struct SidebarEntry { int count() const noexcept { return static_cast(visible_uuids.size()); } }; -} // namespace px::core::view +} // namespace px::modrim::view diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp index 01be8b8..57a8bf2 100644 --- a/src/core/mods_config.cpp +++ b/src/core/mods_config.cpp @@ -22,7 +22,7 @@ #include "core/file_io.h" #include "core/xml.h" -namespace px::core { +namespace px::modrim { namespace { @@ -288,4 +288,4 @@ Result write_mods_config(const QString& path, const ModsConfig& data, cons return {}; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/mods_config.h b/src/core/mods_config.h index 26b57bd..dea5015 100644 --- a/src/core/mods_config.h +++ b/src/core/mods_config.h @@ -8,7 +8,7 @@ #include "core/error.h" #include "core/models/metadata/structures.h" -namespace px::core { +namespace px::modrim { // Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not a ModsConfig document. std::optional parse_mods_config(const QString& path); @@ -31,4 +31,4 @@ Result restore_snapshot(const QString& snapshot_path, const QString& targe Result write_mods_config(const QString& path, const ModsConfig& data, const QString& snapshots_dir, int max_snapshots = 10); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp index b978d5a..976a1cc 100644 --- a/src/core/organizer/db.cpp +++ b/src/core/organizer/db.cpp @@ -15,7 +15,7 @@ #include "core/migrator.h" -namespace px::core::organizer { +namespace px::modrim::organizer { namespace { @@ -624,4 +624,4 @@ QCoro::Task OrganizerDb::close() { co_await QtConcurrent::run(&pool_, [name = connection_name_] { discard_connection(name); }); } -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/db.h b/src/core/organizer/db.h index 28698ba..7fe6b09 100644 --- a/src/core/organizer/db.h +++ b/src/core/organizer/db.h @@ -10,7 +10,7 @@ #include "core/organizer/defaults.h" #include "core/organizer/models.h" -namespace px::core::organizer { +namespace px::modrim::organizer { // The organizer database file inside the config directory. QString db_path(const QString& config_dir); @@ -65,4 +65,4 @@ class OrganizerDb { QThreadPool pool_; }; -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp index 1bc2940..89bf5c3 100644 --- a/src/core/organizer/defaults.cpp +++ b/src/core/organizer/defaults.cpp @@ -2,7 +2,7 @@ #include -namespace px::core::organizer { +namespace px::modrim::organizer { namespace { @@ -83,4 +83,4 @@ const QList& standard_rules() { return rules; } -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.h b/src/core/organizer/defaults.h index c07d90e..cbfacad 100644 --- a/src/core/organizer/defaults.h +++ b/src/core/organizer/defaults.h @@ -5,7 +5,7 @@ #include "core/organizer/models.h" -namespace px::core::organizer { +namespace px::modrim::organizer { // A built-in auto-folder rule targeting a top-level folder by name. struct StandardRule { @@ -18,4 +18,4 @@ struct StandardRule { // Built-in rules in priority order: the first match wins. const QList& standard_rules(); -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/models.h b/src/core/organizer/models.h index 53029c4..82b2092 100644 --- a/src/core/organizer/models.h +++ b/src/core/organizer/models.h @@ -10,7 +10,7 @@ #include #include -namespace px::core::organizer { +namespace px::modrim::organizer { inline constexpr qint64 ROOT_ID = 1; inline constexpr int MAX_DEPTH = 3; @@ -114,4 +114,4 @@ inline QStringList clean_package_ids(const QStringList& package_ids) { return cleaned; } -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp index 0ba1cbd..39d7f7c 100644 --- a/src/core/organizer/resolve.cpp +++ b/src/core/organizer/resolve.cpp @@ -5,7 +5,7 @@ #include "core/providers/labels.h" -namespace px::core::organizer { +namespace px::modrim::organizer { namespace { @@ -381,4 +381,4 @@ FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const return builder.build(state.folders.value(ROOT_ID), 0); } -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.h b/src/core/organizer/resolve.h index cd5e570..8d04263 100644 --- a/src/core/organizer/resolve.h +++ b/src/core/organizer/resolve.h @@ -11,7 +11,7 @@ #include "core/models/metadata/structures.h" #include "core/organizer/models.h" -namespace px::core::organizer { +namespace px::modrim::organizer { // Discovered mods keyed by uuid. using ModsByUuid = QHash; @@ -101,4 +101,4 @@ QList tree_filters(const ModsByUuid& mods, const QSet& acti FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, const std::optional& query = std::nullopt); -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index 7fb088b..19f94b3 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -11,7 +11,7 @@ #include "core/context.h" #include "core/organizer/defaults.h" -namespace px::core::organizer { +namespace px::modrim::organizer { namespace { const QHash kEmptyMods; @@ -586,4 +586,4 @@ QList OrganizerService::tree_filters() const { return organizer::tree_filters(all_mods(), active_set(), &state()); } -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/organizer/service.h b/src/core/organizer/service.h index 542ff79..3648cec 100644 --- a/src/core/organizer/service.h +++ b/src/core/organizer/service.h @@ -17,11 +17,11 @@ #include "core/organizer/resolve.h" #include "core/plugin.h" -namespace px::core { +namespace px::modrim { class CoreContext; } -namespace px::core::organizer { +namespace px::modrim::organizer { struct FolderToggle { QStringList enable; @@ -115,4 +115,4 @@ class OrganizerService : public Plugin { std::optional state_; }; -} // namespace px::core::organizer +} // namespace px::modrim::organizer diff --git a/src/core/plugin.h b/src/core/plugin.h index 60c0c7e..01357ba 100644 --- a/src/core/plugin.h +++ b/src/core/plugin.h @@ -15,7 +15,7 @@ #include "core/error.h" #include "core/toposort.h" -namespace px::core { +namespace px::modrim { // `setup` runs synchronously in dependency order; `init` is awaited right after // setup, for I/O or async start-up. `shutdown` runs in reverse order. Templated over @@ -153,4 +153,4 @@ class BasicPluginRegistry { using Plugin = BasicPlugin; using PluginRegistry = BasicPluginRegistry; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/plugin_config.h b/src/core/plugin_config.h index 20d9ba9..00ac739 100644 --- a/src/core/plugin_config.h +++ b/src/core/plugin_config.h @@ -13,7 +13,7 @@ #include "core/config.h" #include "core/events.h" -namespace px::core { +namespace px::modrim { // A plugin's own settings, kept in plugins/.json. `legacy` maps keys of // the old shared config.json to fields of T; they seed the file the first time @@ -107,4 +107,4 @@ void PluginConfig::store(const T& value) const { } } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp index 80eddb9..06878f7 100644 --- a/src/core/profiler.cpp +++ b/src/core/profiler.cpp @@ -2,7 +2,7 @@ #include -namespace px::core { +namespace px::modrim { namespace { @@ -55,4 +55,4 @@ ProfileSession::~ProfileSession() { spdlog::debug("Profile report:\n{}", timer_.render().toStdString()); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/profiler.h b/src/core/profiler.h index d8141d5..49d8df2 100644 --- a/src/core/profiler.h +++ b/src/core/profiler.h @@ -7,7 +7,7 @@ #include #include -namespace px::core { +namespace px::modrim { // Hierarchical wall-clock timer. Scopes nest according to their lifetimes; `render()` prints // the resulting tree. @@ -67,4 +67,4 @@ class ProfileSession { std::unique_ptr scope_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp index 38fe125..7d56d23 100644 --- a/src/core/providers/base.cpp +++ b/src/core/providers/base.cpp @@ -7,7 +7,7 @@ #include "core/models/metadata/parsing.h" #include "core/utils.h" -namespace px::core::providers { +namespace px::modrim::providers { namespace { @@ -159,4 +159,4 @@ QCoro::Task>> BaseModProvider::parse_candidates( co_return parsed; } -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/base.h b/src/core/providers/base.h index a5a804e..257e12b 100644 --- a/src/core/providers/base.h +++ b/src/core/providers/base.h @@ -17,7 +17,7 @@ #include "core/models/metadata/structures.h" #include "core/profiler.h" -namespace px::core::providers { +namespace px::modrim::providers { // Runs `fn` on `pool`, or on the global pool when `pool` is null, and returns a future for its // result. Awaiting the future from a coroutine resumes it on the calling thread. @@ -127,4 +127,4 @@ class BaseModProvider { MetadataCacheStore* metadata_cache_; }; -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp index 3943774..05b8405 100644 --- a/src/core/providers/core.cpp +++ b/src/core/providers/core.cpp @@ -4,7 +4,7 @@ #include #include -namespace px::core::providers { +namespace px::modrim::providers { CoreModProvider::CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache) : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), std::move(gamePath), @@ -37,4 +37,4 @@ QCoro::Task> CoreModProvider::discover(QString target_version, Pr co_return discovered; } -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/core.h b/src/core/providers/core.h index 0fc08d1..3b807c0 100644 --- a/src/core/providers/core.h +++ b/src/core/providers/core.h @@ -2,7 +2,7 @@ #include "core/providers/base.h" -namespace px::core::providers { +namespace px::modrim::providers { // Base-game and DLC mods under the game's `Data/` directory. class CoreModProvider final : public BaseModProvider { @@ -15,4 +15,4 @@ class CoreModProvider final : public BaseModProvider { bool force_reparse = false) override; }; -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp index 1229f3c..1ab48f9 100644 --- a/src/core/providers/factory.cpp +++ b/src/core/providers/factory.cpp @@ -3,7 +3,7 @@ #include "core/providers/core.h" #include "core/providers/local.h" -namespace px::core::providers { +namespace px::modrim::providers { ProviderList create_providers(const PathConfig& paths, QThreadPool* pool) { ProviderList providers; @@ -22,4 +22,4 @@ ProviderList create_providers(const PathConfig& paths, QThreadPool* pool) { return providers; } -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/factory.h b/src/core/providers/factory.h index 85b280c..da71e5c 100644 --- a/src/core/providers/factory.h +++ b/src/core/providers/factory.h @@ -8,7 +8,7 @@ #include "core/config.h" #include "core/providers/base.h" -namespace px::core::providers { +namespace px::modrim::providers { using ProviderList = std::vector>; @@ -17,4 +17,4 @@ using ProviderList = std::vector>; // null uses the global pool. ProviderList create_providers(const PathConfig& paths, QThreadPool* pool = nullptr); -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/labels.h b/src/core/providers/labels.h index b948f4c..c72daeb 100644 --- a/src/core/providers/labels.h +++ b/src/core/providers/labels.h @@ -4,7 +4,7 @@ #include #include -namespace px::core { +namespace px::modrim { // Display name for a mod provider, mirroring the legacy PROVIDER_LABELS table. Unknown ids // fall back to the id with underscores as spaces and each word title-cased, as Python's @@ -34,4 +34,4 @@ inline QString providerLabel(const QString& providerId) { return label; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp index ed4cb40..1db2567 100644 --- a/src/core/providers/local.cpp +++ b/src/core/providers/local.cpp @@ -4,7 +4,7 @@ #include #include -namespace px::core::providers { +namespace px::modrim::providers { namespace { @@ -113,4 +113,4 @@ SteamWorkshopModProvider::SteamWorkshopModProvider(QString workshopPath, QThread : DownloadedModProvider(QStringLiteral("steam"), std::move(workshopPath), pool, metadataCache) {} -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/providers/local.h b/src/core/providers/local.h index 5b1c1cf..7b508dd 100644 --- a/src/core/providers/local.h +++ b/src/core/providers/local.h @@ -2,7 +2,7 @@ #include "core/providers/base.h" -namespace px::core::providers { +namespace px::modrim::providers { // Mods the user keeps in the local mods folder: directories without a published file id. class LocalModProvider final : public BaseModProvider { @@ -37,4 +37,4 @@ class SteamWorkshopModProvider final : public DownloadedModProvider { MetadataCacheStore* metadataCache = nullptr); }; -} // namespace px::core::providers +} // namespace px::modrim::providers diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp index ef604f6..7098cd0 100644 --- a/src/core/services/activation_service.cpp +++ b/src/core/services/activation_service.cpp @@ -18,7 +18,7 @@ #include "core/sort/config.h" #include "core/sort/models.h" -namespace px::core { +namespace px::modrim { namespace { using checker::ConstraintEdge; @@ -498,4 +498,4 @@ bool ActivationService::apply(const QStringList& enable, const QStringList& disa return true; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/services/activation_service.h b/src/core/services/activation_service.h index 9837b79..1081e83 100644 --- a/src/core/services/activation_service.h +++ b/src/core/services/activation_service.h @@ -2,7 +2,7 @@ #include -namespace px::core { +namespace px::modrim { class CoreContext; @@ -24,4 +24,4 @@ class ActivationService { CoreContext& ctx_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp index 05f9d8a..ac641e3 100644 --- a/src/core/services/diagnostics_service.cpp +++ b/src/core/services/diagnostics_service.cpp @@ -14,7 +14,7 @@ #include "core/context.h" #include "core/sort/community.h" -namespace px::core { +namespace px::modrim { namespace { // Cached databases older than this are refreshed in the background. @@ -348,4 +348,4 @@ bool DiagnosticsService::SidebarBase::matches(const QHash return true; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/services/diagnostics_service.h b/src/core/services/diagnostics_service.h index ba50dee..6662fcf 100644 --- a/src/core/services/diagnostics_service.h +++ b/src/core/services/diagnostics_service.h @@ -23,7 +23,7 @@ class QNetworkAccessManager; -namespace px::core { +namespace px::modrim { class CoreContext; @@ -100,4 +100,4 @@ class DiagnosticsService { std::optional sidebar_base_; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp index 3312258..d09f58b 100644 --- a/src/core/services/game_launcher.cpp +++ b/src/core/services/game_launcher.cpp @@ -15,7 +15,7 @@ #include #endif -namespace px::core::services { +namespace px::modrim::services { namespace { @@ -127,4 +127,4 @@ QCoro::Task> GameLauncher::launch_steam() { co_return QStringLiteral("Launching via Steam..."); } -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/game_launcher.h b/src/core/services/game_launcher.h index f9d805d..d019811 100644 --- a/src/core/services/game_launcher.h +++ b/src/core/services/game_launcher.h @@ -10,11 +10,11 @@ #include "core/error.h" #include "core/services/game_process.h" -namespace px::core { +namespace px::modrim { class CoreContext; } -namespace px::core::services { +namespace px::modrim::services { // Starts RimWorld directly or through Steam, and tracks the game until it exits. class GameLauncher { @@ -44,4 +44,4 @@ class GameLauncher { GameProcessMonitor monitor_; }; -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 35f4375..97803b5 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -36,7 +36,7 @@ #include #endif -namespace px::core::services { +namespace px::modrim::services { namespace { @@ -450,4 +450,4 @@ QCoro::Task> GameProcessMonitor::wait_for_exit() { co_return code; } -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/game_process.h b/src/core/services/game_process.h index 095a2b2..a5339ba 100644 --- a/src/core/services/game_process.h +++ b/src/core/services/game_process.h @@ -15,7 +15,7 @@ #include "core/error.h" -namespace px::core::services { +namespace px::modrim::services { enum class Platform { Linux, @@ -119,4 +119,4 @@ class GameProcessMonitor { std::unique_ptr proc_; }; -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp index 31c3340..efb6f56 100644 --- a/src/core/services/metadata_cache.cpp +++ b/src/core/services/metadata_cache.cpp @@ -20,7 +20,7 @@ #include "core/migrator.h" #include "core/models/metadata/structures.h" -namespace px::core::services { +namespace px::modrim::services { using providers::CacheLookup; using providers::CachedMod; @@ -621,4 +621,4 @@ void MetadataCache::prune(const QSet& live_mod_paths) { } } -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.h b/src/core/services/metadata_cache.h index f3a1ee9..f26f5b5 100644 --- a/src/core/services/metadata_cache.h +++ b/src/core/services/metadata_cache.h @@ -11,7 +11,7 @@ #include "core/providers/base.h" -namespace px::core::services { +namespace px::modrim::services { // The cache database inside the config directory. QString metadata_cache_path(const QString& config_dir); @@ -61,4 +61,4 @@ class MetadataCache final : public providers::MetadataCacheStore { bool disabled_ = false; // worker thread only }; -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp index a449ca2..faeaee4 100644 --- a/src/core/services/mod_discovery.cpp +++ b/src/core/services/mod_discovery.cpp @@ -8,7 +8,7 @@ #include "core/mods_config.h" -namespace px::core::services { +namespace px::modrim::services { namespace { @@ -58,4 +58,4 @@ QStringList resolve_active_uuids(const QHash& all_mods, return active; } -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.h b/src/core/services/mod_discovery.h index edc1d19..1885239 100644 --- a/src/core/services/mod_discovery.h +++ b/src/core/services/mod_discovery.h @@ -6,7 +6,7 @@ #include "core/models/metadata/structures.h" -namespace px::core::services { +namespace px::modrim::services { // Parses ModsConfig.xml inside `config_folder` and returns the ordered list of UUIDs matching // active mods. If multiple discovered mods share a package id, the first match (in sorted UUID @@ -14,4 +14,4 @@ namespace px::core::services { QStringList resolve_active_uuids(const QHash& all_mods, const QString& config_folder); -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp index 088c361..9dffd66 100644 --- a/src/core/services/sort_service.cpp +++ b/src/core/services/sort_service.cpp @@ -11,7 +11,7 @@ #include "core/profiler.h" #include "core/services/diagnostics_service.h" -namespace px::core { +namespace px::modrim { namespace { @@ -116,4 +116,4 @@ QStringList SortService::missing_dependency_uuids(const QHash #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { namespace { @@ -118,4 +118,4 @@ QString metric_label(const QString& key) { return QStringLiteral("%1 (%2)").arg(label, shown); } -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.h b/src/core/services/startup_impact_service/labels.h index 48ef93b..00f876c 100644 --- a/src/core/services/startup_impact_service/labels.h +++ b/src/core/services/startup_impact_service/labels.h @@ -2,7 +2,7 @@ #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { // Drops the "LoadingProgress.StartupImpact." prefix the game writes before every metric key. QString strip_metric_prefix(const QString& key); @@ -11,4 +11,4 @@ QString strip_metric_prefix(const QString& key); // is shown in parentheses after the label. QString metric_label(const QString& key); -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp index 62159d1..1a6f399 100644 --- a/src/core/services/startup_impact_service/models.cpp +++ b/src/core/services/startup_impact_service/models.cpp @@ -2,7 +2,7 @@ #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { namespace { @@ -76,4 +76,4 @@ QString format_impact(double seconds) { return QStringLiteral("%1ms").arg(ms); } -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.h b/src/core/services/startup_impact_service/models.h index da5cf1c..8c4a0bd 100644 --- a/src/core/services/startup_impact_service/models.h +++ b/src/core/services/startup_impact_service/models.h @@ -8,7 +8,7 @@ #include #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { inline constexpr double IMPACT_WARN_THRESHOLD_S = 1.0; inline constexpr double IMPACT_HIGH_THRESHOLD_S = 5.0; @@ -53,4 +53,4 @@ QString normalize_package_id(QStringView package_id); // Rounds half to even, as Python's round() does, so displayed values match the legacy app. QString format_impact(double seconds); -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp index dbe7a5a..b2aa2d6 100644 --- a/src/core/services/startup_impact_service/parser.cpp +++ b/src/core/services/startup_impact_service/parser.cpp @@ -10,7 +10,7 @@ #include #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { namespace { @@ -141,4 +141,4 @@ std::optional parse_startup_impact(const QString& path) { return report; } -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.h b/src/core/services/startup_impact_service/parser.h index 2235af8..bc618ff 100644 --- a/src/core/services/startup_impact_service/parser.h +++ b/src/core/services/startup_impact_service/parser.h @@ -6,7 +6,7 @@ #include "core/services/startup_impact_service/models.h" -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { // The report lives next to the game's config folder, not inside it. QString get_startup_impact_path(const QString& config_folder); @@ -15,4 +15,4 @@ QString get_startup_impact_path(const QString& config_folder); // or not a JSON object. Malformed entries are skipped rather than failing the whole report. std::optional parse_startup_impact(const QString& path); -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp index 38be7a1..e533931 100644 --- a/src/core/services/startup_impact_service/phases.cpp +++ b/src/core/services/startup_impact_service/phases.cpp @@ -8,7 +8,7 @@ #include "core/services/startup_impact_service/labels.h" -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { namespace { @@ -90,4 +90,4 @@ PhaseTotals phase_totals(const QMap& metrics) { return totals; } -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.h b/src/core/services/startup_impact_service/phases.h index 4f12a61..d9e047d 100644 --- a/src/core/services/startup_impact_service/phases.h +++ b/src/core/services/startup_impact_service/phases.h @@ -7,7 +7,7 @@ #include #include -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { enum class Phase : std::uint8_t { Content, @@ -34,4 +34,4 @@ Phase metric_phase(const QString& key); // Sums metric seconds per phase. Every phase is present, even when it has no metrics. PhaseTotals phase_totals(const QMap& metrics); -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp index 18ad336..cfaa521 100644 --- a/src/core/services/startup_impact_service/service.cpp +++ b/src/core/services/startup_impact_service/service.cpp @@ -9,7 +9,7 @@ #include "core/context.h" #include "core/services/startup_impact_service/parser.h" -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { namespace { @@ -106,4 +106,4 @@ QCoro::Task StartupImpactService::close_connection() { co_await db_.close( void StartupImpactService::close_connection_sync() { db_.close_sync(); } -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.h b/src/core/services/startup_impact_service/service.h index 6df1ea3..383ac6e 100644 --- a/src/core/services/startup_impact_service/service.h +++ b/src/core/services/startup_impact_service/service.h @@ -11,11 +11,11 @@ #include "core/services/startup_impact_service/db.h" #include "core/services/startup_impact_service/models.h" -namespace px::core { +namespace px::modrim { class CoreContext; } -namespace px::core::startup_impact { +namespace px::modrim::startup_impact { // Loads the game's startup impact report into the history database and answers queries about // historical averages and per-session breakdowns. @@ -46,4 +46,4 @@ class StartupImpactService { StartupImpactDb db_; }; -} // namespace px::core::startup_impact +} // namespace px::modrim::startup_impact diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp index 34b1000..41bce42 100644 --- a/src/core/services/update_service.cpp +++ b/src/core/services/update_service.cpp @@ -14,7 +14,7 @@ #include #include -namespace px::core::services { +namespace px::modrim::services { namespace { @@ -163,4 +163,4 @@ QCoro::Task>> UpdateService::check() { ReleaseInfo{tag, name, string_field(data, "body"), url}); } -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/services/update_service.h b/src/core/services/update_service.h index bfd12d5..bcd6bce 100644 --- a/src/core/services/update_service.h +++ b/src/core/services/update_service.h @@ -11,7 +11,7 @@ class QNetworkAccessManager; -namespace px::core::services { +namespace px::modrim::services { inline constexpr QLatin1StringView LATEST_RELEASE_URL{ "https://api.github.com/repos/PyXiion/PxModRim/releases/latest"}; @@ -52,4 +52,4 @@ class UpdateService { QString release_url_; }; -} // namespace px::core::services +} // namespace px::modrim::services diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp index 5cfcf35..ac3d7e3 100644 --- a/src/core/sort/community.cpp +++ b/src/core/sort/community.cpp @@ -7,7 +7,7 @@ #include "core/config.h" #include "core/file_io.h" -namespace px::core::sort { +namespace px::modrim::sort { namespace { @@ -54,4 +54,4 @@ Result load_community_rules(const QString& json_path) { return to_community_rules(schema); } -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/community.h b/src/core/sort/community.h index 8e30167..751d728 100644 --- a/src/core/sort/community.h +++ b/src/core/sort/community.h @@ -6,7 +6,7 @@ #include "core/models/metadata/structures.h" #include "core/sort/models.h" -namespace px::core::sort { +namespace px::modrim::sort { // Converts the upstream schema into rules keyed by package id. Only the keys of loadAfter and // loadBefore matter; their values are ignored. @@ -15,4 +15,4 @@ CommunityRules to_community_rules(const ExternalRulesSchema& schema); // Reads the community rules file. A missing file means no rules; a malformed one is an error. Result load_community_rules(const QString& json_path); -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp index 5d7a59a..01718b9 100644 --- a/src/core/sort/community_service.cpp +++ b/src/core/sort/community_service.cpp @@ -19,7 +19,7 @@ #include "core/file_io.h" -namespace px::core::sort { +namespace px::modrim::sort { namespace { @@ -211,4 +211,4 @@ Result CommunityRulesService::load_rules() const { return load_community_rules(json_path_); } -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/community_service.h b/src/core/sort/community_service.h index 4a9a3f4..47537d7 100644 --- a/src/core/sort/community_service.h +++ b/src/core/sort/community_service.h @@ -12,7 +12,7 @@ class QNetworkAccessManager; -namespace px::core::sort { +namespace px::modrim::sort { // Keeps the community rules file in the config directory, downloading the Community-Rules-Database // archive when the file is missing. @@ -36,4 +36,4 @@ class CommunityRulesService { QNetworkAccessManager& network_; }; -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/config.cpp b/src/core/sort/config.cpp index ebaa99e..d5e6495 100644 --- a/src/core/sort/config.cpp +++ b/src/core/sort/config.cpp @@ -2,7 +2,7 @@ #include -namespace px::core::sort { +namespace px::modrim::sort { TierConfig TierConfig::defaults() { return TierConfig{ @@ -38,4 +38,4 @@ TierConfig TierConfig::defaults() { }; } -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/config.h b/src/core/sort/config.h index e5a8dd4..eefe225 100644 --- a/src/core/sort/config.h +++ b/src/core/sort/config.h @@ -4,7 +4,7 @@ #include "core/models/metadata/structures.h" -namespace px::core::sort { +namespace px::modrim::sort { using PackageId = CaseInsensitiveStr; @@ -30,4 +30,4 @@ struct SortSettings { bool operator==(const SortSettings&) const = default; }; -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/models.h b/src/core/sort/models.h index 4dc84a6..925aabf 100644 --- a/src/core/sort/models.h +++ b/src/core/sort/models.h @@ -6,7 +6,7 @@ #include "core/models/metadata/structures.h" #include "core/sort/config.h" -namespace px::core::sort { +namespace px::modrim::sort { // Load-order rule from the community database for one package id. struct CommunityRule { @@ -25,4 +25,4 @@ using CommunityRules = QHash; // Adjacency map over package ids: a node's dependencies or reverse dependencies. using PackageGraph = QHash>; -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp index 54cbaab..c289226 100644 --- a/src/core/sort/tiers.cpp +++ b/src/core/sort/tiers.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::core::sort { +namespace px::modrim::sort { namespace { @@ -161,4 +161,4 @@ std::optional> find_cycle(const QSet& nodes, const P return std::nullopt; } -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/sort/tiers.h b/src/core/sort/tiers.h index d7688dc..71f4f20 100644 --- a/src/core/sort/tiers.h +++ b/src/core/sort/tiers.h @@ -8,7 +8,7 @@ #include "core/sort/config.h" #include "core/sort/models.h" -namespace px::core::sort { +namespace px::modrim::sort { // Assigns each canonical id to a Tier from load order and dependency constraints. Ids outside // `canonical_ids` are never returned. @@ -29,4 +29,4 @@ QSet get_reverse_deps_recursive(const PackageId& start, const Package // form a DAG. Edges leaving `nodes` are ignored. std::optional> find_cycle(const QSet& nodes, const PackageGraph& deps); -} // namespace px::core::sort +} // namespace px::modrim::sort diff --git a/src/core/structures.h b/src/core/structures.h index 4e79a40..cf52abf 100644 --- a/src/core/structures.h +++ b/src/core/structures.h @@ -2,7 +2,7 @@ #include -namespace px::core { +namespace px::modrim { struct CollectionStats { std::int64_t total = 0; @@ -11,4 +11,4 @@ struct CollectionStats { std::int64_t errors = 0; }; -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp index 87f0b3e..4d19fc5 100644 --- a/src/core/support/report.cpp +++ b/src/core/support/report.cpp @@ -27,7 +27,7 @@ #include #endif -namespace px::core::support { +namespace px::modrim::support { namespace { @@ -365,4 +365,4 @@ QCoro::Task> PasteUploader::upload(QString text) { co_return body; } -} // namespace px::core::support +} // namespace px::modrim::support diff --git a/src/core/support/report.h b/src/core/support/report.h index 0db2ae7..eca32ed 100644 --- a/src/core/support/report.h +++ b/src/core/support/report.h @@ -15,7 +15,7 @@ class QNetworkAccessManager; -namespace px::core::support { +namespace px::modrim::support { inline constexpr qint64 MAX_LOG_BYTES = 2 * 1024 * 1024; inline constexpr QLatin1StringView DEFAULT_UPLOAD_ENDPOINT{"https://paste.rs/"}; @@ -63,4 +63,4 @@ class PasteUploader { std::chrono::milliseconds timeout_; }; -} // namespace px::core::support +} // namespace px::modrim::support diff --git a/src/core/toposort.h b/src/core/toposort.h index d700dc6..9fb5f29 100644 --- a/src/core/toposort.h +++ b/src/core/toposort.h @@ -11,7 +11,7 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { // Kahn's algorithm, layered the way the `toposort` package lays levels: level 0 // holds nodes without dependencies, and each later level holds nodes whose @@ -60,4 +60,4 @@ inline Result> toposort_levels( return levels; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/utils.cpp b/src/core/utils.cpp index 1da6340..9317951 100644 --- a/src/core/utils.cpp +++ b/src/core/utils.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::core { +namespace px::modrim { std::optional findAboutXml(const QString& modPath) { if (!QFileInfo(modPath).isDir()) { @@ -48,4 +48,4 @@ bool isAsciiDigits(QStringView text) { return true; } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/utils.h b/src/core/utils.h index cef4375..acd2e6b 100644 --- a/src/core/utils.h +++ b/src/core/utils.h @@ -5,7 +5,7 @@ #include #include -namespace px::core { +namespace px::modrim { // Case-insensitive search for About/About.xml inside a mod directory. std::optional findAboutXml(const QString& modPath); @@ -13,4 +13,4 @@ std::optional findAboutXml(const QString& modPath); // True for a non-empty string made only of ASCII digits (Python's str.isdigit for ASCII input). bool isAsciiDigits(QStringView text); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/version.cpp b/src/core/version.cpp index dd27030..1c4f6fe 100644 --- a/src/core/version.cpp +++ b/src/core/version.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::core { +namespace px::modrim { QString appVersion() { return QStringLiteral("0.3.1"); } @@ -15,4 +15,4 @@ QCoro::Task delayedVersion(int ms) { co_return appVersion(); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/version.h b/src/core/version.h index 261d228..620e95d 100644 --- a/src/core/version.h +++ b/src/core/version.h @@ -3,11 +3,11 @@ #include #include -namespace px::core { +namespace px::modrim { QString appVersion(); // Smoke coroutine: proves QCoro runs on the Qt event loop. QCoro::Task delayedVersion(int ms); -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index 1a37a55..60653e4 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -12,7 +12,7 @@ #include "core/context.h" #include "core/mod_service.h" -namespace px::core::workshop { +namespace px::modrim::workshop { namespace { @@ -338,4 +338,4 @@ void WorkshopCatalog::on_busy_changed(bool busy) { } } -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/catalog.h b/src/core/workshop/catalog.h index 6b8a78d..a35f99a 100644 --- a/src/core/workshop/catalog.h +++ b/src/core/workshop/catalog.h @@ -34,7 +34,7 @@ #include "core/workshop/favourites.h" #include "core/workshop/types.h" -namespace px::core::workshop { +namespace px::modrim::workshop { inline constexpr std::string_view kDefaultCatalogUrl = "https://api.modrim.pyxiion.dev"; @@ -263,4 +263,4 @@ QCoro::Task> WorkshopCatalog::cached(QString key, co_return {}; } -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index 51f253d..42ddc44 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -13,7 +13,7 @@ #include #include -namespace px::core::workshop { +namespace px::modrim::workshop { namespace { @@ -285,4 +285,4 @@ QCoro::Task> CatalogClient::resolve(QStringList co_return merged; } -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/client.h b/src/core/workshop/client.h index e8dcdf2..1f22de1 100644 --- a/src/core/workshop/client.h +++ b/src/core/workshop/client.h @@ -14,7 +14,7 @@ #include "core/workshop/types.h" -namespace px::core::workshop { +namespace px::modrim::workshop { // Trims whitespace and trailing slashes, so "https://x/" and "https://x" name the same catalog. QString normalize_base_url(QString url); @@ -63,4 +63,4 @@ class CatalogClient { QSet pending_; }; -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp index b3a5935..d87eb26 100644 --- a/src/core/workshop/favourites.cpp +++ b/src/core/workshop/favourites.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::core::workshop { +namespace px::modrim::workshop { FavouriteCollections::FavouriteCollections(ConfigService* service) : config_(service, QStringLiteral("workshop_favourites")) { @@ -57,4 +57,4 @@ void FavouriteCollections::save(std::vector collections) { config_.update(FavouritesState{std::move(collections)}); } -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.h b/src/core/workshop/favourites.h index c7b0c84..3b49d72 100644 --- a/src/core/workshop/favourites.h +++ b/src/core/workshop/favourites.h @@ -9,7 +9,7 @@ #include "core/plugin_config.h" #include "core/workshop/types.h" -namespace px::core::workshop { +namespace px::modrim::workshop { // The persisted form, plugins/workshop_favourites.json. struct FavouritesState { @@ -40,4 +40,4 @@ class FavouriteCollections { std::unordered_set ids_; }; -} // namespace px::core::workshop +} // namespace px::modrim::workshop diff --git a/src/core/workshop/types.h b/src/core/workshop/types.h index 7326668..0acbc1a 100644 --- a/src/core/workshop/types.h +++ b/src/core/workshop/types.h @@ -13,7 +13,7 @@ #include #include -namespace px::core::workshop { +namespace px::modrim::workshop { // Catalog responses are strict: every key is present (nullable ones may be null), unknown keys are // ignored and a value of the wrong type fails. Writing emits nulls so payloads round-trip exactly. @@ -178,46 +178,46 @@ struct CatalogError { template using CatalogResult = std::expected; -} // namespace px::core::workshop +} // namespace px::modrim::workshop template <> -struct glz::meta { - using enum px::core::workshop::PreviewType; +struct glz::meta { + using enum px::modrim::workshop::PreviewType; static constexpr auto value = glz::enumerate(image, video); }; template <> -struct glz::meta { - using enum px::core::workshop::ModSource; +struct glz::meta { + using enum px::modrim::workshop::ModSource; static constexpr auto value = glz::enumerate(steam); }; template <> -struct glz::meta { - using enum px::core::workshop::ModDescriptionFormat; +struct glz::meta { + using enum px::modrim::workshop::ModDescriptionFormat; static constexpr auto value = glz::enumerate(bbcode); }; template <> -struct glz::meta { - using enum px::core::workshop::ModKind; +struct glz::meta { + using enum px::modrim::workshop::ModKind; static constexpr auto value = glz::enumerate(mod); }; template <> -struct glz::meta { - using enum px::core::workshop::CollectionSource; +struct glz::meta { + using enum px::modrim::workshop::CollectionSource; static constexpr auto value = glz::enumerate(steam, picked); }; template <> -struct glz::meta { - using enum px::core::workshop::CollectionDescriptionFormat; +struct glz::meta { + using enum px::modrim::workshop::CollectionDescriptionFormat; static constexpr auto value = glz::enumerate(bbcode, text); }; template <> -struct glz::meta { - using enum px::core::workshop::CollectionKind; +struct glz::meta { + using enum px::modrim::workshop::CollectionKind; static constexpr auto value = glz::enumerate(collection); }; diff --git a/src/core/xml.cpp b/src/core/xml.cpp index 4fed441..7ab072d 100644 --- a/src/core/xml.cpp +++ b/src/core/xml.cpp @@ -7,7 +7,7 @@ #include #include -namespace px::core { +namespace px::modrim { namespace { @@ -203,4 +203,4 @@ Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data) { return fillElement(root, rootValue); } -} // namespace px::core +} // namespace px::modrim diff --git a/src/core/xml.h b/src/core/xml.h index b7527db..4393b43 100644 --- a/src/core/xml.h +++ b/src/core/xml.h @@ -8,7 +8,7 @@ #include "core/error.h" -namespace px::core { +namespace px::modrim { // Loads and parses an XML file into `doc`. Fails if the file is missing, unreadable, malformed, // or has no root element. @@ -31,4 +31,4 @@ Result xmlFileToJson(const QString& path); // elementToJson). Fails unless `data` is an object with exactly one key. Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data); -} // namespace px::core +} // namespace px::modrim diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp index 511a0cf..b01d00f 100644 --- a/src/ui/components/banner_widget.cpp +++ b/src/ui/components/banner_widget.cpp @@ -12,7 +12,7 @@ #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -132,4 +132,4 @@ void AspectRatioBanner::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/banner_widget.h b/src/ui/components/banner_widget.h index 834387a..0570265 100644 --- a/src/ui/components/banner_widget.h +++ b/src/ui/components/banner_widget.h @@ -9,7 +9,7 @@ class QPaintEvent; class QResizeEvent; -namespace px::ui { +namespace px::modrim { // Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it, with an optional // title and subtitle over a gradient at the bottom. @@ -43,4 +43,4 @@ class AspectRatioBanner : public QWidget { bool show_overlay_ = true; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/button.cpp b/src/ui/components/button.cpp index 7bc5180..4b8e835 100644 --- a/src/ui/components/button.cpp +++ b/src/ui/components/button.cpp @@ -2,11 +2,11 @@ #include -namespace px::ui { +namespace px::modrim { AppButton::AppButton(const QString& text, QWidget* parent) : QPushButton(text, parent) { setObjectName(QStringLiteral("appBtn")); setCursor(Qt::PointingHandCursor); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/button.h b/src/ui/components/button.h index 6c4e88c..68e7f0c 100644 --- a/src/ui/components/button.h +++ b/src/ui/components/button.h @@ -5,7 +5,7 @@ class QWidget; -namespace px::ui { +namespace px::modrim { // Push button in the application's standard look (QSS `appBtn`) with a pointing-hand cursor. class AppButton : public QPushButton { @@ -13,4 +13,4 @@ class AppButton : public QPushButton { explicit AppButton(const QString& text = {}, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp index c139d3c..9c07a8c 100644 --- a/src/ui/components/dialog_chrome.cpp +++ b/src/ui/components/dialog_chrome.cpp @@ -20,7 +20,7 @@ #include "ui/components/icons.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -176,4 +176,4 @@ void installDialogChrome(QDialog* dialog, bool closable, std::optional res new Chrome(dialog, closable, grip); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.h b/src/ui/components/dialog_chrome.h index 43c9f7d..ceda2d9 100644 --- a/src/ui/components/dialog_chrome.h +++ b/src/ui/components/dialog_chrome.h @@ -4,7 +4,7 @@ class QDialog; -namespace px::ui { +namespace px::modrim { // Gives `dialog` the frameless title-bar look of the main window. Call after the // dialog's layout is built and before it is shown. Native file pickers keep the @@ -13,4 +13,4 @@ namespace px::ui { void installDialogChrome(QDialog* dialog, bool closable = true, std::optional resizable = std::nullopt); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/dialogs.h b/src/ui/components/dialogs.h index 20bc6e1..51659b7 100644 --- a/src/ui/components/dialogs.h +++ b/src/ui/components/dialogs.h @@ -15,7 +15,7 @@ #include "ui/components/dialog_chrome.h" -namespace px::ui { +namespace px::modrim { // Resumes from inside `finished` itself. A queued resume could run after the dialog, and the // window owning it, are gone; the coroutine must not outlive the emission. @@ -57,4 +57,4 @@ QCoro::Task> awaitDialog(Args&&... args) { co_return std::pair{code, dialog}; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp index 433e676..6a11dc0 100644 --- a/src/ui/components/filter_sidebar.cpp +++ b/src/ui/components/filter_sidebar.cpp @@ -3,7 +3,7 @@ #include #include #include -namespace px::ui { +namespace px::modrim { FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent) : QQuickWidget(engine, parent) { setResizeMode(QQuickWidget::SizeRootObjectToView); diff --git a/src/ui/components/filter_sidebar.h b/src/ui/components/filter_sidebar.h index 70d74e2..da2bc75 100644 --- a/src/ui/components/filter_sidebar.h +++ b/src/ui/components/filter_sidebar.h @@ -1,7 +1,7 @@ #pragma once #include class QAbstractItemModel; -namespace px::ui { +namespace px::modrim { class FilterSidebar : public QQuickWidget { Q_OBJECT public: diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp index 9739877..71fded2 100644 --- a/src/ui/components/header_controller.cpp +++ b/src/ui/components/header_controller.cpp @@ -5,15 +5,15 @@ #include "core/constants.h" -namespace px::ui { +namespace px::modrim { namespace { -QString strategyLabel(core::LaunchStrategy strategy) { +QString strategyLabel(LaunchStrategy strategy) { switch (strategy) { - case core::LaunchStrategy::Direct: + case LaunchStrategy::Direct: return QStringLiteral("Direct executable"); - case core::LaunchStrategy::Steam: + case LaunchStrategy::Steam: return QStringLiteral("Steam"); } return {}; @@ -32,7 +32,7 @@ HeaderController::HeaderController(bool isFrameless, int initialStrategy, QStrin QVariantList HeaderController::strategies() const { QVariantList list; - for (const auto strategy : {core::LaunchStrategy::Direct, core::LaunchStrategy::Steam}) { + for (const auto strategy : {LaunchStrategy::Direct, LaunchStrategy::Steam}) { list.append(QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, {QStringLiteral("label"), strategyLabel(strategy)}}); } @@ -115,4 +115,4 @@ void HeaderController::setStrategy(int index) { void HeaderController::dragStarted() { emit dragStartRequested(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/header_controller.h b/src/ui/components/header_controller.h index c13bde6..17a5f44 100644 --- a/src/ui/components/header_controller.h +++ b/src/ui/components/header_controller.h @@ -5,7 +5,7 @@ #include #include -namespace px::ui { +namespace px::modrim { // State and commands behind Header.qml, exposed to it as `headerController`. The // QML-facing names (properties and invokables) match the legacy HeaderController; @@ -112,4 +112,4 @@ class HeaderController : public QObject { int downloads_total_ = 0; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/header_panel.cpp b/src/ui/components/header_panel.cpp index e2c4c38..d356abf 100644 --- a/src/ui/components/header_panel.cpp +++ b/src/ui/components/header_panel.cpp @@ -8,7 +8,7 @@ #include "ui/components/header_controller.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent) : QQuickWidget(engine, parent) { @@ -21,4 +21,4 @@ HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidg } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/header_panel.h b/src/ui/components/header_panel.h index 4a4ef23..8b812c1 100644 --- a/src/ui/components/header_panel.h +++ b/src/ui/components/header_panel.h @@ -5,7 +5,7 @@ class QQmlEngine; class QWidget; -namespace px::ui { +namespace px::modrim { class HeaderController; @@ -16,4 +16,4 @@ class HeaderPanel : public QQuickWidget { HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp index 9bf475f..5bf9681 100644 --- a/src/ui/components/icon_button.cpp +++ b/src/ui/components/icon_button.cpp @@ -7,7 +7,7 @@ #include "ui/components/icons.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -43,4 +43,4 @@ void IconButton::setIconColor(const QString& color) { setIcon(requireIcon(icon_name_, icon_px_, color)); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/icon_button.h b/src/ui/components/icon_button.h index 25cacb4..10d0be2 100644 --- a/src/ui/components/icon_button.h +++ b/src/ui/components/icon_button.h @@ -5,7 +5,7 @@ class QWidget; -namespace px::ui { +namespace px::modrim { // Square icon-only button (QSS `iconBtn`). `primary` paints it in the accent color. class IconButton : public QPushButton { @@ -20,4 +20,4 @@ class IconButton : public QPushButton { int icon_px_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/icons.cpp b/src/ui/components/icons.cpp index 3a43090..b38e85a 100644 --- a/src/ui/components/icons.cpp +++ b/src/ui/components/icons.cpp @@ -9,7 +9,7 @@ #include #include -namespace px::ui { +namespace px::modrim { namespace { @@ -81,4 +81,4 @@ std::optional icon(QStringView name, int size, QStringView color) { return QIcon(*pixmap); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/icons.h b/src/ui/components/icons.h index b7f7e0d..8caebb1 100644 --- a/src/ui/components/icons.h +++ b/src/ui/components/icons.h @@ -7,7 +7,7 @@ #include #include -namespace px::ui { +namespace px::modrim { // The SVG markup of a named icon with `currentColor` replaced by `color`, or // nullopt when no icon has that name. @@ -19,4 +19,4 @@ std::optional iconPixmap(QStringView name, int size, QStringView color) std::optional icon(QStringView name, int size, QStringView color); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp index 866a388..120424d 100644 --- a/src/ui/components/mod_activation.cpp +++ b/src/ui/components/mod_activation.cpp @@ -3,7 +3,7 @@ #include "core/context.h" #include "core/services/activation_service.h" #include -namespace px::ui { +namespace px::modrim { DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* parent) : QMessageBox(parent) { setIcon(QMessageBox::Warning); setWindowTitle(QStringLiteral("Disable Dependent Mods")); @@ -17,7 +17,7 @@ DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); } -QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { +QCoro::Task apply_activation(CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { // Copy before suspension: callers often pass temporary batches. const QStringList enabling = enable; QStringList disabling = disable; @@ -26,7 +26,7 @@ QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, cons QStringList details; for (const auto& uuid : dependents) { const auto& mod = ctx.allMods().value(uuid); - const auto* about = dynamic_cast(mod.get()); + const auto* about = dynamic_cast(mod.get()); details.append(about ? QStringLiteral("%1 (%2)").arg(mod->name, about->package_id.str()) : mod->name); } const auto [result, dialog] = co_await awaitDialog(details, parent); @@ -35,7 +35,7 @@ QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, cons } co_return ctx.activation().apply(enabling, disabling); } -QCoro::Task toggle_mods(core::CoreContext& ctx, QWidget* parent, const QStringList& uuids) { +QCoro::Task toggle_mods(CoreContext& ctx, QWidget* parent, const QStringList& uuids) { QStringList enable, disable; for (const auto& uuid : uuids) (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); co_return co_await apply_activation(ctx, parent, enable, disable); diff --git a/src/ui/components/mod_activation.h b/src/ui/components/mod_activation.h index e3c7d98..03e07a6 100644 --- a/src/ui/components/mod_activation.h +++ b/src/ui/components/mod_activation.h @@ -2,12 +2,12 @@ #include #include #include -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { class DependentModsDialog : public QMessageBox { public: DependentModsDialog(const QStringList& dependents, QWidget* parent); }; -QCoro::Task apply_activation(core::CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); -QCoro::Task toggle_mods(core::CoreContext& ctx, QWidget* parent, const QStringList& uuids); +QCoro::Task apply_activation(CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); +QCoro::Task toggle_mods(CoreContext& ctx, QWidget* parent, const QStringList& uuids); } diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp index 5f41422..a545d8b 100644 --- a/src/ui/components/mod_updates.cpp +++ b/src/ui/components/mod_updates.cpp @@ -6,9 +6,9 @@ #include #include #include -namespace px::ui { -QStringList updatable_ids(core::CoreContext& ctx, const QStringList& uuids) { - QList mods; +namespace px::modrim { +QStringList updatable_ids(CoreContext& ctx, const QStringList& uuids) { + QList mods; mods.reserve(uuids.size()); for (const auto& uuid : uuids) { const auto it = ctx.allMods().constFind(uuid); @@ -16,7 +16,7 @@ QStringList updatable_ids(core::CoreContext& ctx, const QStringList& uuids) { } return ctx.downloads().updatable_ids(mods); } -UpdateState update_state(core::CoreContext& ctx, const QStringList& uuids) { +UpdateState update_state(CoreContext& ctx, const QStringList& uuids) { const auto ids = updatable_ids(ctx, uuids); QString label = QStringLiteral("Update"); if (uuids.size() > 1) label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); @@ -26,7 +26,7 @@ UpdateState update_state(core::CoreContext& ctx, const QStringList& uuids) { if (ids.isEmpty()) return {label, false, QStringLiteral("None of these mods were downloaded by PxModRim")}; return {label, true, {}}; } -QAction* add_update_action(QMenu* menu, core::CoreContext& ctx, const QStringList& uuids, std::function handler) { +QAction* add_update_action(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler) { if (!ctx.downloads().available()) return nullptr; const auto state = update_state(ctx, uuids); auto* action = menu->addAction(state.label); @@ -48,7 +48,7 @@ QCoro::Task confirm_update(int count, QWidget* parent) { const auto [result, dialog] = co_await awaitDialog(count, parent); co_return result == QMessageBox::Yes; } -QCoro::Task update_mods(core::CoreContext& ctx, const QStringList& uuids, QWidget* parent) { +QCoro::Task update_mods(CoreContext& ctx, const QStringList& uuids, QWidget* parent) { const auto ids = updatable_ids(ctx, uuids); if (ids.isEmpty() || !(co_await confirm_update(static_cast(ids.size()), parent))) co_return; const auto result = co_await ctx.downloads().download_mods(ids); diff --git a/src/ui/components/mod_updates.h b/src/ui/components/mod_updates.h index 1f31e92..929f4a4 100644 --- a/src/ui/components/mod_updates.h +++ b/src/ui/components/mod_updates.h @@ -5,17 +5,17 @@ #include class QMenu; class QAction; -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { inline constexpr int CONFIRM_THRESHOLD = 25; struct UpdateState { QString label; bool enabled; QString tooltip; }; -QStringList updatable_ids(core::CoreContext& ctx, const QStringList& uuids); -UpdateState update_state(core::CoreContext& ctx, const QStringList& uuids); -QAction* add_update_action(QMenu* menu, core::CoreContext& ctx, const QStringList& uuids, std::function handler); +QStringList updatable_ids(CoreContext& ctx, const QStringList& uuids); +UpdateState update_state(CoreContext& ctx, const QStringList& uuids); +QAction* add_update_action(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler); class ConfirmUpdateDialog : public QMessageBox { public: ConfirmUpdateDialog(int count, QWidget* parent); }; QCoro::Task confirm_update(int count, QWidget* parent); -QCoro::Task update_mods(core::CoreContext& ctx, const QStringList& uuids, QWidget* parent); +QCoro::Task update_mods(CoreContext& ctx, const QStringList& uuids, QWidget* parent); } diff --git a/src/ui/components/popup_parent.cpp b/src/ui/components/popup_parent.cpp index 5454de4..c2117ed 100644 --- a/src/ui/components/popup_parent.cpp +++ b/src/ui/components/popup_parent.cpp @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QuickPopupParenting::QuickPopupParenting(QObject* parent) : QObject(parent) {} @@ -21,4 +21,4 @@ bool QuickPopupParenting::eventFilter(QObject* watched, QEvent* event) { return false; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/popup_parent.h b/src/ui/components/popup_parent.h index 7de618c..4f33e37 100644 --- a/src/ui/components/popup_parent.h +++ b/src/ui/components/popup_parent.h @@ -4,7 +4,7 @@ class QEvent; -namespace px::ui { +namespace px::modrim { // Gives every QQuickWidget's offscreen window its top-level window as transient // parent when shown. Without it Wayland refuses Popup.Window popups (menus, @@ -20,4 +20,4 @@ class QuickPopupParenting : public QObject { bool eventFilter(QObject* watched, QEvent* event) override; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp index 7e81ec8..c2b3faa 100644 --- a/src/ui/components/procedural_preview.cpp +++ b/src/ui/components/procedural_preview.cpp @@ -12,7 +12,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QString initials(const QString& title) { static const QRegularExpression tags(R"([\[(][^\])]*[\])])"); static const QRegularExpression words(R"([^\W_]+)", QRegularExpression::UseUnicodePropertiesOption); diff --git a/src/ui/components/procedural_preview.h b/src/ui/components/procedural_preview.h index 3176fe4..1aeb57a 100644 --- a/src/ui/components/procedural_preview.h +++ b/src/ui/components/procedural_preview.h @@ -1,7 +1,7 @@ #pragma once #include #include -namespace px::ui { +namespace px::modrim { QString initials(const QString& title); QImage generate_preview(const QString& title, int width, int height); } diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp index 014ddcd..0e37bd2 100644 --- a/src/ui/components/progress_dialog.cpp +++ b/src/ui/components/progress_dialog.cpp @@ -14,7 +14,7 @@ #include "ui/components/icons.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -31,7 +31,7 @@ QIcon requireIcon(QStringView name, int size, QStringView color) { } // namespace -ProgressDialog::ProgressDialog(core::LoadingState& loading, QWidget* parent) +ProgressDialog::ProgressDialog(LoadingState& loading, QWidget* parent) : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | Qt::WindowTitleHint), loading_(loading) { setWindowTitle(QStringLiteral("Loading")); @@ -63,10 +63,10 @@ ProgressDialog::ProgressDialog(core::LoadingState& loading, QWidget* parent) connect(fade_, &QPropertyAnimation::finished, this, &ProgressDialog::onFadeFinished); connections_.push_back(QObject::connect( - &loading_, &core::LoadingState::changed, this, + &loading_, &LoadingState::changed, this, [this](const QString&, int pct, int, int) { onChanged(pct); })); connections_.push_back( - QObject::connect(&loading_, &core::LoadingState::finished, this, &ProgressDialog::onFinished)); + QObject::connect(&loading_, &LoadingState::finished, this, &ProgressDialog::onFinished)); } ProgressDialog::~ProgressDialog() { @@ -145,4 +145,4 @@ void ProgressDialog::onFadeFinished() { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/progress_dialog.h b/src/ui/components/progress_dialog.h index 4213b24..b6ed608 100644 --- a/src/ui/components/progress_dialog.h +++ b/src/ui/components/progress_dialog.h @@ -14,7 +14,7 @@ class QPropertyAnimation; class QTimer; class QWidget; -namespace px::ui { +namespace px::modrim { // Modal progress dialog showing the stacked loading tasks as breadcrumbs. Shows when the first // task reports, and fades out shortly after the last one finishes unless a new task starts. @@ -22,10 +22,10 @@ class ProgressDialog : public QDialog { Q_OBJECT public: - ProgressDialog(core::LoadingState& loading, QWidget* parent); + ProgressDialog(LoadingState& loading, QWidget* parent); ~ProgressDialog() override; - core::LoadingState& loading() const noexcept { return loading_; } + LoadingState& loading() const noexcept { return loading_; } private: void onChanged(int pct); @@ -35,7 +35,7 @@ class ProgressDialog : public QDialog { void fadeTo(qreal end, int durationMs); void onFadeFinished(); - core::LoadingState& loading_; + LoadingState& loading_; bool shown_ = false; QWidget* crumbs_ = nullptr; @@ -47,4 +47,4 @@ class ProgressDialog : public QDialog { std::vector connections_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/svg_provider.cpp b/src/ui/components/svg_provider.cpp index 65c1fa5..4dd2156 100644 --- a/src/ui/components/svg_provider.cpp +++ b/src/ui/components/svg_provider.cpp @@ -9,7 +9,7 @@ #include "ui/components/icons.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -63,4 +63,4 @@ QQmlEngine* createQmlEngine(QObject* parent) { return engine; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/svg_provider.h b/src/ui/components/svg_provider.h index 4795804..c3a3026 100644 --- a/src/ui/components/svg_provider.h +++ b/src/ui/components/svg_provider.h @@ -8,7 +8,7 @@ class QObject; class QQmlEngine; -namespace px::ui { +namespace px::modrim { // Serves named SVG icons to QML via image://icons/?color=. // An unknown name renders an empty SVG, so the image is transparent. @@ -23,4 +23,4 @@ class SvgIconProvider : public QQuickImageProvider { // carries the `icons` provider, so image://icons/… URLs resolve. QQmlEngine* createQmlEngine(QObject* parent); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp index 00d3f71..0533323 100644 --- a/src/ui/components/toast.cpp +++ b/src/ui/components/toast.cpp @@ -15,7 +15,7 @@ #include "ui/components/icons.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -152,4 +152,4 @@ void ToastManager::remove(Toast* toast) { toast->deleteLater(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/toast.h b/src/ui/components/toast.h index d8cec47..085a546 100644 --- a/src/ui/components/toast.h +++ b/src/ui/components/toast.h @@ -9,7 +9,7 @@ class QGraphicsOpacityEffect; class QPropertyAnimation; class QVBoxLayout; -namespace px::ui { +namespace px::modrim { enum class ToastLevel { Info, Success, Warning, Error }; @@ -57,4 +57,4 @@ class ToastManager : public QWidget, public Notifier { QVBoxLayout* layout_ = nullptr; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp index 0c61701..c66cb5e 100644 --- a/src/ui/components/unity_rich_text.cpp +++ b/src/ui/components/unity_rich_text.cpp @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QString escape_html(const QString& text) { QString out = text.toHtmlEscaped(); return out.replace('\'', QStringLiteral("'")); diff --git a/src/ui/components/unity_rich_text.h b/src/ui/components/unity_rich_text.h index a4dcab6..9c75d88 100644 --- a/src/ui/components/unity_rich_text.h +++ b/src/ui/components/unity_rich_text.h @@ -1,6 +1,6 @@ #pragma once #include -namespace px::ui { +namespace px::modrim { QString escape_html(const QString& text); QString unity_rich_text_to_html(const QString& text); } diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp index 203338a..2a1f79e 100644 --- a/src/ui/components/view_rail_panel.cpp +++ b/src/ui/components/view_rail_panel.cpp @@ -8,7 +8,7 @@ #include "ui/theme/constants.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { ViewRailPanel::ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, QQmlEngine* engine, QWidget* parent) @@ -39,4 +39,4 @@ void ViewRailPanel::setCurrent(int index) { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/components/view_rail_panel.h b/src/ui/components/view_rail_panel.h index c26a747..423e956 100644 --- a/src/ui/components/view_rail_panel.h +++ b/src/ui/components/view_rail_panel.h @@ -7,7 +7,7 @@ class QQmlEngine; class QWidget; -namespace px::ui { +namespace px::modrim { // Hosts IconRail.qml, the left navigation rail. `tabs` are maps with `icon` and `label` // keys; `helpEntries` are maps with `id` and `label` keys for the Help menu. @@ -28,4 +28,4 @@ class ViewRailPanel : public QQuickWidget { void helpActionRequested(const QString& actionId); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/config.cpp b/src/ui/config.cpp index cc18df0..0773cc0 100644 --- a/src/ui/config.cpp +++ b/src/ui/config.cpp @@ -2,7 +2,7 @@ #include -namespace px::ui { +namespace px::modrim { namespace { @@ -10,13 +10,13 @@ const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); } // namespace -core::Result saveUiPrefs(const UiPrefs& prefs, const core::ConfigService& svc) { +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc) { return svc.save(kUiPrefsFile, prefs); } -UiPrefsService::UiPrefsService(core::ConfigService& svc) : svc_(svc) {} +UiPrefsService::UiPrefsService(ConfigService& svc) : svc_(svc) {} -QCoro::Task> UiPrefsService::setup() { +QCoro::Task> UiPrefsService::setup() { auto loaded = svc_.load(kUiPrefsFile); if (!loaded) { co_return std::unexpected(loaded.error()); @@ -30,11 +30,11 @@ const UiPrefs& UiPrefsService::prefs() const { return *prefs_; } -core::Result UiPrefsService::savePrefs() const { +Result UiPrefsService::savePrefs() const { if (!prefs_) { return {}; } return saveUiPrefs(*prefs_, svc_); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/config.h b/src/ui/config.h index f03d1e4..b5d0854 100644 --- a/src/ui/config.h +++ b/src/ui/config.h @@ -8,27 +8,27 @@ #include "core/error.h" #include "ui/ui_prefs.h" -namespace px::ui { +namespace px::modrim { // Writes ui_prefs.json. Other UI code calls this after changing prefs. -core::Result saveUiPrefs(const UiPrefs& prefs, const core::ConfigService& svc); +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc); // Owns the UiPrefs lifecycle: loaded once in setup(), saved on request. The // ConfigService must outlive this object. class UiPrefsService { public: - explicit UiPrefsService(core::ConfigService& svc); + explicit UiPrefsService(ConfigService& svc); - QCoro::Task> setup(); + QCoro::Task> setup(); // Valid only after a successful setup(). const UiPrefs& prefs() const; - core::Result savePrefs() const; + Result savePrefs() const; private: - core::ConfigService& svc_; + ConfigService& svc_; std::optional prefs_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/context.cpp b/src/ui/context.cpp index 31998f5..ef7d99a 100644 --- a/src/ui/context.cpp +++ b/src/ui/context.cpp @@ -5,9 +5,9 @@ #include "core/mod_service.h" -namespace px::ui { +namespace px::modrim { -RailView::RailView(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +RailView::RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : QWidget(parent), ctx_(ctx), engine_(engine), app_(app) {} void RailView::openRoute(const QStringList&) {} @@ -16,7 +16,7 @@ void RailView::preload() {} void RailView::teardown() {} -AppContext::AppContext(core::CoreContext& core, UiPrefs prefs) +AppContext::AppContext(CoreContext& core, UiPrefs prefs) : core_(core), ui_prefs_(std::move(prefs)) {} AppContext::~AppContext() = default; @@ -39,32 +39,32 @@ void AppContext::setNavigator(std::function navigate) { navigate_ = std::move(navigate); } -core::Result AppContext::setupAll() { +Result AppContext::setupAll() { if (auto core = core_.plugins().setup_all(core_); !core) { return std::unexpected(core.error()); } return plugins_.setup_all(*this, core_.plugins().names()); } -QCoro::Task> AppContext::initAll() { +QCoro::Task> AppContext::initAll() { if (auto core = co_await core_.plugins().init_all(core_); !core) { co_return std::unexpected(core.error()); } co_return co_await plugins_.init_all(*this); } -QCoro::Task> AppContext::refreshMods(bool full) { +QCoro::Task> AppContext::refreshMods(bool full) { if (auto reloaded = co_await core_.modService().reload(full); !reloaded) { co_return std::unexpected(reloaded.error()); } co_return static_cast(core_.allMods().size()); } -QCoro::Task> AppContext::shutdownAll() { +QCoro::Task> AppContext::shutdownAll() { if (auto ui = co_await plugins_.shutdown_all(); !ui) { co_return std::unexpected(ui.error()); } co_return co_await core_.plugins().shutdown_all(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/context.h b/src/ui/context.h index c8ba182..a8a3dcd 100644 --- a/src/ui/context.h +++ b/src/ui/context.h @@ -21,13 +21,13 @@ class QQmlEngine; -namespace px::ui { +namespace px::modrim { class AppContext; // UI-layer plugins: same lifecycle as core plugins, but their context is the AppContext. -using UiPlugin = core::BasicPlugin; -using UiPluginRegistry = core::BasicPluginRegistry; +using UiPlugin = BasicPlugin; +using UiPluginRegistry = BasicPluginRegistry; // Toast sink. Durations are milliseconds; the defaults match the legacy toast manager. class Notifier { @@ -56,7 +56,7 @@ class RailView : public QWidget { Q_OBJECT public: - RailView(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); // Handles the part of a `modrim:///…` link after the view id. virtual void openRoute(const QStringList& path); @@ -67,12 +67,12 @@ class RailView : public QWidget { virtual void teardown(); protected: - core::CoreContext& ctx_; + CoreContext& ctx_; QQmlEngine* engine_; AppContext& app_; }; -using RailViewFactory = std::function; +using RailViewFactory = std::function; struct RailViewSpec { RailViewMeta meta; @@ -89,7 +89,7 @@ concept RailViewType = std::derived_from && requires { // coreContext(), which must outlive this object. class AppContext { public: - AppContext(core::CoreContext& core, UiPrefs prefs = {}); + AppContext(CoreContext& core, UiPrefs prefs = {}); AppContext(const AppContext&) = delete; AppContext& operator=(const AppContext&) = delete; AppContext(AppContext&&) = delete; @@ -126,19 +126,19 @@ class AppContext { const UiPrefs& uiPrefs() const noexcept { return ui_prefs_; } // ── Core access ─────────────────────────────────── - core::CoreContext& coreContext() const noexcept { return core_; } + CoreContext& coreContext() const noexcept { return core_; } // ── Lifecycle ───────────────────────────────────── // Core plugins first, then UI plugins; UI plugins may depend on core plugin names. - core::Result setupAll(); - QCoro::Task> initAll(); + Result setupAll(); + QCoro::Task> initAll(); // Reloads mods and resolves to how many are now loaded. - QCoro::Task> refreshMods(bool full = false); + QCoro::Task> refreshMods(bool full = false); // UI plugins shut down before core plugins. - QCoro::Task> shutdownAll(); + QCoro::Task> shutdownAll(); private: - core::CoreContext& core_; + CoreContext& core_; UiPluginRegistry plugins_; std::vector rail_views_; std::vector settings_sections_; @@ -151,7 +151,7 @@ template void AppContext::addRailView(std::optional position) { RailViewSpec spec{ V::kRailMeta, - [](core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) -> RailView* { + [](CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) -> RailView* { return new V(ctx, engine, parent, app); }, }; @@ -162,4 +162,4 @@ void AppContext::addRailView(std::optional position) { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp index a646ab8..37a9a6d 100644 --- a/src/ui/mod_selection.cpp +++ b/src/ui/mod_selection.cpp @@ -4,9 +4,9 @@ #include "core/services/diagnostics_service.h" -namespace px::ui { +namespace px::modrim { -ModSelectionPresenter::ModSelectionPresenter(core::CoreContext& ctx, ModInfoSink& sink) +ModSelectionPresenter::ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink) : ctx_(ctx), sink_(sink) {} QCoro::Task ModSelectionPresenter::show(QString uuid) { @@ -24,11 +24,11 @@ QCoro::Task ModSelectionPresenter::show(QString uuid) { co_return; } - const core::ListedMod& mod = *found.value(); + const ListedMod& mod = *found.value(); sink_.showMod(mod, ctx_.diagnosticsService().issues_for(uuid)); std::optional packageId; - if (const auto* about = dynamic_cast(&mod)) { + if (const auto* about = dynamic_cast(&mod)) { packageId = about->package_id.str(); } const QStringList activePackageIds = resolveActivePackageIds(); @@ -47,8 +47,8 @@ void ModSelectionPresenter::clear() { QStringList ModSelectionPresenter::resolveActivePackageIds() const { const QSet active(ctx_.activeUuids().cbegin(), ctx_.activeUuids().cend()); QStringList packageIds; - for (const core::ListedModPtr& mod : ctx_.allMods()) { - const auto* about = dynamic_cast(mod.get()); + for (const ListedModPtr& mod : ctx_.allMods()) { + const auto* about = dynamic_cast(mod.get()); if (about && active.contains(about->uuid())) { packageIds.append(about->package_id.str()); } @@ -56,4 +56,4 @@ QStringList ModSelectionPresenter::resolveActivePackageIds() const { return packageIds; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/mod_selection.h b/src/ui/mod_selection.h index 1e6f039..8643866 100644 --- a/src/ui/mod_selection.h +++ b/src/ui/mod_selection.h @@ -12,7 +12,7 @@ #include "core/models/metadata/structures.h" #include "core/models/view/diagnostics.h" -namespace px::ui { +namespace px::modrim { // The mod-details surface the presenter drives. The mod info panel implements it. class ModInfoSink { @@ -20,7 +20,7 @@ class ModInfoSink { virtual ~ModInfoSink() = default; virtual void clear() = 0; - virtual void showMod(const core::ListedMod& mod, const QList& issues) = 0; + virtual void showMod(const ListedMod& mod, const QList& issues) = 0; // `packageId` is nullopt for mods that have no package id (non-About.xml mods). virtual QCoro::Task setTimeAnalytics(const std::optional& packageId, const QStringList& activePackageIds) = 0; @@ -29,7 +29,7 @@ class ModInfoSink { // Loads a selected mod's details into a ModInfoSink for any view. class ModSelectionPresenter { public: - ModSelectionPresenter(core::CoreContext& ctx, ModInfoSink& sink); + ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink); // A stale show() (superseded by a later one or clear()) stops before its analytics await. QCoro::Task show(QString uuid); @@ -38,9 +38,9 @@ class ModSelectionPresenter { private: QStringList resolveActivePackageIds() const; - core::CoreContext& ctx_; + CoreContext& ctx_; ModInfoSink& sink_; std::uint64_t generation_ = 0; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp index 7a718f5..cf83904 100644 --- a/src/ui/models/impact.cpp +++ b/src/ui/models/impact.cpp @@ -2,7 +2,7 @@ #include "core/services/startup_impact_service/models.h" #include "ui/theme/palette.h" #include -namespace px::ui { +namespace px::modrim { QString format_duration(double seconds) { if (seconds < 0.001) return QStringLiteral("0 ms"); if (seconds < 1) return QStringLiteral("%1 ms").arg(static_cast(std::nearbyint(seconds * 1000))); @@ -11,7 +11,7 @@ QString format_duration(double seconds) { return QStringLiteral("%1m %2s").arg(rounded / 60).arg(rounded % 60, 2, 10, QLatin1Char('0')); } QString impact_color(double seconds) { - using namespace core::startup_impact; + using namespace startup_impact; if (seconds < IMPACT_WARN_THRESHOLD_S) return paletteValue(u"SUCCESS"); if (seconds < IMPACT_HIGH_THRESHOLD_S) return paletteValue(u"WARNING"); return paletteValue(u"DANGER"); diff --git a/src/ui/models/impact.h b/src/ui/models/impact.h index ddefd82..7858807 100644 --- a/src/ui/models/impact.h +++ b/src/ui/models/impact.h @@ -1,6 +1,6 @@ #pragma once #include -namespace px::ui { +namespace px::modrim { QString format_duration(double seconds); QString impact_color(double seconds); } diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp index b4a7377..5985ea1 100644 --- a/src/ui/models/mod_list_model.cpp +++ b/src/ui/models/mod_list_model.cpp @@ -5,7 +5,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QString provider_label(const QString& provider_id, const QString& package_id) { const auto pid = provider_id.trimmed().toLower(); const auto pkg = package_id.trimmed().toLower(); @@ -19,7 +19,7 @@ QString provider_label(const QString& provider_id, const QString& package_id) { QString section_name(bool checked, int active_count, int inactive_count) { return checked || active_count == 0 || inactive_count == 0 ? QString{} : QStringLiteral("Inactive (%1)").arg(inactive_count); } -ModListModel::ModListModel(QHash colors, core::DiagnosticsService* diagnostics, QObject* parent) +ModListModel::ModListModel(QHash colors, DiagnosticsService* diagnostics, QObject* parent) : QAbstractListModel(parent), provider_colors_(std::move(colors)), diagnostics_(diagnostics) { if (diagnostics_) diagnostics_connection_ = diagnostics_->diagnostics_summary_changed.connect( [this](const auto& summaries) { diagnostics_changed(summaries); }); @@ -36,7 +36,7 @@ const ModItem* ModListModel::get_item_by_uuid(const QString& uuid) const { QVariant ModListModel::data(const QModelIndex& index, int role) const { const auto* item = index.isValid() ? get_item(index.row()) : nullptr; if (!item) return {}; - const auto* about = dynamic_cast(item->mod.get()); + const auto* about = dynamic_cast(item->mod.get()); switch (role) { case Qt::DisplayRole: return item->mod->name; case Qt::ToolTipRole: return item->mod->description; @@ -80,13 +80,13 @@ QHash ModListModel::roleNames() const { {StartupImpactColorRole,"startupImpactColor"},{StartupImpactTextRole,"startupImpactText"},{Qt::ToolTipRole,"toolTip"}}; } void ModListModel::update_provider_colors(QHash colors) { provider_colors_ = std::move(colors); } -ModItem ModListModel::make_item(const QString& uuid, core::ListedModPtr mod, bool checked) const { - const auto* about = dynamic_cast(mod.get()); +ModItem ModListModel::make_item(const QString& uuid, ListedModPtr mod, bool checked) const { + const auto* about = dynamic_cast(mod.get()); auto label = provider_label(mod->provider_id, about ? about->package_id.str() : QString{}); auto color = provider_colors_.value(mod->provider_id, paletteValue(u"TEXT_DIM")); return {std::move(mod), uuid, checked, std::move(color), 0, -1, std::move(label)}; } -void ModListModel::load_mods(const QHash& mods, const QStringList& active_uuids) { +void ModListModel::load_mods(const QHash& mods, const QStringList& active_uuids) { beginResetModel(); items_.clear(); items_.reserve(mods.size()); @@ -180,12 +180,12 @@ void ModListModel::set_check_states(const QList& rows, bool checked) { void ModListModel::set_startup_impact(const QHash& impacts) { if (items_.isEmpty()) return; for (auto& item : items_) { - const auto* about = dynamic_cast(item.mod.get()); + const auto* about = dynamic_cast(item.mod.get()); item.startup_impact_s = about ? impacts.value(about->package_id.str()) : 0; } emit dataChanged(index(0), index(rowCount()-1), {StartupImpactRole,StartupImpactColorRole,StartupImpactTextRole}); } -void ModListModel::diagnostics_changed(const QHash& summaries) { +void ModListModel::diagnostics_changed(const QHash& summaries) { if (items_.isEmpty()) return; int first = rowCount(), last = -1; for (int row = 0; row < items_.size(); ++row) { diff --git a/src/ui/models/mod_list_model.h b/src/ui/models/mod_list_model.h index 6d67f01..519a4c8 100644 --- a/src/ui/models/mod_list_model.h +++ b/src/ui/models/mod_list_model.h @@ -8,13 +8,13 @@ #include "core/models/metadata/structures.h" #include "core/models/view/diagnostics.h" -namespace px::core { class DiagnosticsService; } -namespace px::ui { +namespace px::modrim { class DiagnosticsService; } +namespace px::modrim { QString provider_label(const QString& provider_id, const QString& package_id = {}); QString section_name(bool checked, int active_count, int inactive_count); struct ModItem { - core::ListedModPtr mod; + ListedModPtr mod; QString uuid; bool checked = false; QString provider_color; @@ -35,7 +35,7 @@ class ModListModel : public QAbstractListModel { }; Q_ENUM(Role) explicit ModListModel(QHash provider_colors = {}, - core::DiagnosticsService* diagnostics = nullptr, QObject* parent = nullptr); + DiagnosticsService* diagnostics = nullptr, QObject* parent = nullptr); ~ModListModel() override; int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; @@ -43,7 +43,7 @@ class ModListModel : public QAbstractListModel { Qt::ItemFlags flags(const QModelIndex& index) const override; QHash roleNames() const override; void update_provider_colors(QHash colors); - void load_mods(const QHash& mods, const QStringList& active_uuids); + void load_mods(const QHash& mods, const QStringList& active_uuids); QStringList active_uuids() const; int active_count() const { return active_count_; } const ModItem* get_item(int row) const; @@ -58,13 +58,13 @@ class ModListModel : public QAbstractListModel { void active_count_changed(); private: QList update_load_indices(); - ModItem make_item(const QString& uuid, core::ListedModPtr mod, bool checked) const; - void diagnostics_changed(const QHash& summaries); + ModItem make_item(const QString& uuid, ListedModPtr mod, bool checked) const; + void diagnostics_changed(const QHash& summaries); QHash provider_colors_; QList items_; int active_count_ = 0; - core::DiagnosticsService* diagnostics_; + DiagnosticsService* diagnostics_; std::uint64_t diagnostics_connection_ = 0; - QHash last_diagnostics_; + QHash last_diagnostics_; }; } diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp index 9523796..1ad0fc6 100644 --- a/src/ui/models/mod_list_proxy_model.cpp +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -1,7 +1,7 @@ #include "ui/models/mod_list_proxy_model.h" #include #include -namespace px::ui { +namespace px::modrim { ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), source_(source) { connect(source_, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::source_data_changed); connect(source_, &QAbstractItemModel::layoutChanged, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); @@ -19,7 +19,7 @@ void ModListProxyModel::rebuild_source_mapping() { bool ModListProxyModel::accept_item(const ModItem& item) const { if (sidebar_uuids_ && !sidebar_uuids_->contains(item.uuid)) return false; if (search_text_.isEmpty() || item.mod->name.trimmed().toCaseFolded().contains(search_text_)) return true; - const auto* about = dynamic_cast(item.mod.get()); + const auto* about = dynamic_cast(item.mod.get()); return about && about->package_id.str().trimmed().toCaseFolded().contains(search_text_); } void ModListProxyModel::rebuild() { diff --git a/src/ui/models/mod_list_proxy_model.h b/src/ui/models/mod_list_proxy_model.h index 9c58827..b32b2bc 100644 --- a/src/ui/models/mod_list_proxy_model.h +++ b/src/ui/models/mod_list_proxy_model.h @@ -2,7 +2,7 @@ #include #include #include "ui/models/mod_list_model.h" -namespace px::ui { +namespace px::modrim { class ModListProxyModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(bool isFiltered READ is_filtered NOTIFY is_filtered_changed) diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp index 7bf32c5..6553397 100644 --- a/src/ui/models/sidebar_model.cpp +++ b/src/ui/models/sidebar_model.cpp @@ -1,6 +1,6 @@ #include "ui/models/sidebar_model.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } QVariant SidebarModel::data(const QModelIndex& index, int role) const { @@ -21,8 +21,8 @@ QHash SidebarModel::roleNames() const { return {{LabelRole,"filterLabel"},{CountRole,"count"},{BadgeBgRole,"badgeBg"},{BadgeFgRole,"badgeFg"}, {IconRole,"iconName"},{IconColorRole,"iconColor"},{SectionRole,"sectionName"}}; } -SidebarItem SidebarModel::make_item(const core::view::SidebarEntry& entry) { - using Kind = core::view::SidebarEntry::Kind; +SidebarItem SidebarModel::make_item(const view::SidebarEntry& entry) { + using Kind = view::SidebarEntry::Kind; SidebarItem item{entry,paletteValue(u"ELEVATE_4"),paletteValue(u"TEXT_MUTED"),QStringLiteral("grid"),paletteValue(u"TEXT_DIM"),QStringLiteral("Status")}; switch (entry.kind) { case Kind::All: break; @@ -45,12 +45,12 @@ SidebarItem SidebarModel::make_item(const core::view::SidebarEntry& entry) { } return item; } -void SidebarModel::set_entries(const QList& entries) { +void SidebarModel::set_entries(const QList& entries) { beginResetModel(); items_.clear(); items_.reserve(entries.size()); for (const auto& entry : entries) items_.append(make_item(entry)); endResetModel(); } -void SidebarModel::update_entries(const QList& entries) { +void SidebarModel::update_entries(const QList& entries) { if (entries.size() != items_.size()) { set_entries(entries); return; } QSet changed; for (int row = 0; row < entries.size(); ++row) { @@ -67,7 +67,7 @@ void SidebarModel::update_entries(const QList& entries } if (!changed.isEmpty() && !items_.isEmpty()) emit dataChanged(index(0), index(rowCount()-1), changed.values()); } -std::optional SidebarModel::get_entry(int row) const { +std::optional SidebarModel::get_entry(int row) const { if (row < 0 || row >= items_.size()) return std::nullopt; return items_[row].entry; } diff --git a/src/ui/models/sidebar_model.h b/src/ui/models/sidebar_model.h index dbbff8e..e050834 100644 --- a/src/ui/models/sidebar_model.h +++ b/src/ui/models/sidebar_model.h @@ -2,9 +2,9 @@ #include #include #include "core/models/view/sidebar.h" -namespace px::ui { +namespace px::modrim { struct SidebarItem { - core::view::SidebarEntry entry; + view::SidebarEntry entry; QString badge_bg, badge_fg, icon_name, icon_color, section_name; }; class SidebarModel : public QAbstractListModel { @@ -16,11 +16,11 @@ class SidebarModel : public QAbstractListModel { int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void set_entries(const QList& entries); - void update_entries(const QList& entries); - std::optional get_entry(int row) const; + void set_entries(const QList& entries); + void update_entries(const QList& entries); + std::optional get_entry(int row) const; private: - static SidebarItem make_item(const core::view::SidebarEntry& entry); + static SidebarItem make_item(const view::SidebarEntry& entry); QList items_; }; } diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp index 3a8d23e..64b2108 100644 --- a/src/ui/navigation.cpp +++ b/src/ui/navigation.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QString Route::url() const { return buildRoute(view_id, path); } @@ -31,4 +31,4 @@ std::optional parseRoute(const QString& url) { return route; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/navigation.h b/src/ui/navigation.h index 7740494..4f39538 100644 --- a/src/ui/navigation.h +++ b/src/ui/navigation.h @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { // modrim://[/…] links that open a view and optionally a sub-route inside it. inline constexpr QLatin1StringView kRouteScheme{"modrim"}; @@ -28,4 +28,4 @@ QString buildRoute(const QString& viewId, const QStringList& path = {}); // Parses `modrim://[/…]`; anything else is not a route. std::optional parseRoute(const QString& url); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp index e76d160..72ca5ba 100644 --- a/src/ui/panels/about_panel.cpp +++ b/src/ui/panels/about_panel.cpp @@ -25,7 +25,7 @@ #include "ui/context.h" #include "ui/panels/upload_report_dialog.h" -namespace px::ui { +namespace px::modrim { QStringList AboutPanel::environment() { #if defined(__clang__) const QString compiler = QStringLiteral("Clang " __clang_version__); @@ -42,7 +42,7 @@ QStringList AboutPanel::environment() { } QString AboutPanel::systemInformation() const { - return (QStringList{QStringLiteral("PxModRim %1").arg(core::appVersion())} + environment()).join(u'\n'); + return (QStringList{QStringLiteral("PxModRim %1").arg(appVersion())} + environment()).join(u'\n'); } QString AboutPanel::creditsHtml() { @@ -65,7 +65,7 @@ QString AboutPanel::creditsHtml() { .arg(SPDLOG_VER_MAJOR).arg(SPDLOG_VER_MINOR).arg(SPDLOG_VER_PATCH); } -AboutPanel::AboutPanel(QWidget* parent, core::CoreContext& ctx, Notifier& toast) +AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) : QDialog(parent), ctx_(ctx), toast_(toast) { setObjectName(QStringLiteral("aboutPanel")); setWindowTitle(tr("About PxModRim")); @@ -91,7 +91,7 @@ AboutPanel::AboutPanel(QWidget* parent, core::CoreContext& ctx, Notifier& toast) title->setObjectName(QStringLiteral("aboutTitle")); titleRow->addWidget(title); titleRow->addStretch(); - auto* version = new QLabel(QStringLiteral("v%1").arg(core::appVersion()), hero); + auto* version = new QLabel(QStringLiteral("v%1").arg(appVersion()), hero); version->setObjectName(QStringLiteral("aboutVersionBadge")); titleRow->addWidget(version, 0, Qt::AlignVCenter); identity->addLayout(titleRow); @@ -230,4 +230,4 @@ QCoro::Task AboutPanel::uploadReport() { co_await handle_upload_report(this, ctx_, toast_); if (button) button->setEnabled(true); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/about_panel.h b/src/ui/panels/about_panel.h index 653c365..981797f 100644 --- a/src/ui/panels/about_panel.h +++ b/src/ui/panels/about_panel.h @@ -5,20 +5,20 @@ #include class QPushButton; -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { class Notifier; class AboutPanel : public QDialog { public: - AboutPanel(QWidget* parent, core::CoreContext& ctx, Notifier& toast); + AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast); static QStringList environment(); QString systemInformation() const; static QString creditsHtml(); private: QCoro::Task uploadReport(); - core::CoreContext& ctx_; + CoreContext& ctx_; Notifier& toast_; QPushButton* upload_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.cpp b/src/ui/panels/keyboard_shortcuts_dialog.cpp index 6d226f5..758547a 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.cpp +++ b/src/ui/panels/keyboard_shortcuts_dialog.cpp @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { KeyboardShortcutsDialog::KeyboardShortcutsDialog(const QList>& rows, QWidget* parent) : QDialog(parent) { setWindowTitle(tr("Keyboard Shortcuts")); @@ -33,4 +33,4 @@ KeyboardShortcutsDialog::KeyboardShortcutsDialog(const QListaddWidget(close); layout->addLayout(buttons); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.h b/src/ui/panels/keyboard_shortcuts_dialog.h index 4444d44..23077a4 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.h +++ b/src/ui/panels/keyboard_shortcuts_dialog.h @@ -5,7 +5,7 @@ #include #include -namespace px::ui { +namespace px::modrim { inline const QList> QML_SHORTCUTS{ {QStringLiteral("Up / Down"), QStringLiteral("Navigate mod list")}, {QStringLiteral("Shift+Up / Shift+Down"), QStringLiteral("Extend mod selection")}, @@ -18,4 +18,4 @@ class KeyboardShortcutsDialog : public QDialog { explicit KeyboardShortcutsDialog(const QList>& rows, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp index c38a4a5..18b410a 100644 --- a/src/ui/panels/mod_info_data.cpp +++ b/src/ui/panels/mod_info_data.cpp @@ -8,7 +8,7 @@ #include #include -namespace px::ui { +namespace px::modrim { namespace { template QString substitute(const QString& text, const QRegularExpression& re, F replacement) { QString out; @@ -105,8 +105,8 @@ QString format_age(double mtime, std::optional now) { } return "just now"; } -QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, const QList& issues) { - const auto* about = dynamic_cast(&mod); +QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QList& issues) { + const auto* about = dynamic_cast(&mod); const auto& active = ctx.activeUuids(); const auto position = active.indexOf(mod.uuid()); QVariantList issue_rows, needs, needed_by, details; @@ -121,8 +121,8 @@ QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, c const auto count = errors ? errors : warnings; const QString title = count ? QString::number(count) + (errors ? " error" : " warning") + (count == 1 ? "" : "s") : QStringLiteral("No issues"); if (about) { - QHash index; - for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get())) + QHash index; + for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get())) if (!index.contains(other->package_id.str())) index.insert(other->package_id.str(), other); for (const auto& dep : about->about_rules.dependencies) { auto* found = index.value(dep.package_id.str(), nullptr); @@ -138,8 +138,8 @@ QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, c if (other && active.contains(other->uuid())) conflicts.append(other->name); } std::sort(conflicts.begin(), conflicts.end(), [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); - QList dependents; - for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get()); other && other->uuid() != mod.uuid()) { + QList dependents; + for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get()); other && other->uuid() != mod.uuid()) { for (const auto& dep : other->about_rules.dependencies) if (dep.package_id == about->package_id || dep.alternative_package_ids.contains(about->package_id)) { dependents.append(other); break; } } diff --git a/src/ui/panels/mod_info_data.h b/src/ui/panels/mod_info_data.h index c1b80de..6f69fdd 100644 --- a/src/ui/panels/mod_info_data.h +++ b/src/ui/panels/mod_info_data.h @@ -3,7 +3,7 @@ #include #include "core/context.h" #include "core/models/view/diagnostics.h" -namespace px::ui { +namespace px::modrim { inline const QString IMG_WIDTH_TOKEN = QStringLiteral("__IMG_WIDTH__"); QString bbcode_images(QString text); QString restore_entities(QString text); @@ -11,5 +11,5 @@ QString bbcode_markup(QString text); QString autolink(const QString& text); QString description_to_html(const QString& text, const QString& description_format = QStringLiteral("bbcode")); QString format_age(double mtime, std::optional now = std::nullopt); -QVariantMap build_mod_info(core::CoreContext& ctx, const core::ListedMod& mod, const QList& issues); +QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QList& issues); } diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp index 57dd43b..230457d 100644 --- a/src/ui/panels/mod_info_panel.cpp +++ b/src/ui/panels/mod_info_panel.cpp @@ -27,7 +27,7 @@ #include #include -namespace px::ui { +namespace px::modrim { class ModInfoBanner final : public QWidget { public: explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } @@ -84,7 +84,7 @@ QString first_sentence(QString text) { return text; } } -ModInfoPanel::ModInfoPanel(core::CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent) +ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent) : QWidget(parent), ctx_(ctx), engine_(engine), prefs_(prefs), banner_(new ModInfoBanner(this)), placeholder_(new QLabel("Select a mod to view details", this)), qml_(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { auto* layout = new QVBoxLayout(this); @@ -111,22 +111,22 @@ ModInfoPanel::ModInfoPanel(core::CoreContext& ctx, QQmlEngine* engine, UiPrefs& } ModInfoPanel::~ModInfoPanel() { ctx_.downloads().busy_changed.disconnect(busyConnection_); } void ModInfoPanel::setQml(const char* name, const QVariant& value) { if (auto* root = qml_->rootObject()) root->setProperty(name, value); } -void ModInfoPanel::showMod(const core::ListedMod& mod, const QList& issues) { +void ModInfoPanel::showMod(const ListedMod& mod, const QList& issues) { const auto token = ++previewToken_; mod_ = ctx_.allMods().value(mod.uuid()); if (!mod_) { - if (const auto* about = dynamic_cast(&mod)) mod_ = std::make_shared(*about); - else mod_ = std::make_shared(mod); + if (const auto* about = dynamic_cast(&mod)) mod_ = std::make_shared(*about); + else mod_ = std::make_shared(mod); } placeholder_->hide(); banner_->show(); qml_->show(); - const auto* about = dynamic_cast(&mod); + const auto* about = dynamic_cast(&mod); banner_->setTitle(mod.name, !mod.description.isEmpty() ? first_sentence(mod.description) : about ? about->package_id.str() : QString{}); ++startupToken_; setQml("startup", QVariant{}); setIssues(issues); syncUpdating(); if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, "resetScroll"); loadPreview(mod.mod_path(), mod.name, token); } -void ModInfoPanel::setIssues(const QList& issues) { +void ModInfoPanel::setIssues(const QList& issues) { if (!mod_) return; auto data = build_mod_info(ctx_, *mod_, issues); const QString description = data.take("description").toString(); @@ -145,7 +145,7 @@ void ModInfoPanel::syncUpdating() { } void ModInfoPanel::openFolder() { if (mod_ && mod_->mod_path()) QDesktopServices::openUrl(QUrl::fromLocalFile(*mod_->mod_path())); } void ModInfoPanel::openUrl() { - const auto* about = dynamic_cast(mod_.get()); + const auto* about = dynamic_cast(mod_.get()); if (!about) return; const QUrl url(about->url); const auto scheme = url.scheme().toLower(); @@ -199,7 +199,7 @@ QCoro::Task ModInfoPanel::setTimeAnalytics(const std::optional& p std::stable_sort(metrics.begin(), metrics.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); QVariantList rows; for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) if (metrics[i].second >= .001) - rows.append(QVariantMap{{"label", core::startup_impact::metric_label(metrics[i].first)}, {"value", format_duration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); + rows.append(QVariantMap{{"label", startup_impact::metric_label(metrics[i].first)}, {"value", format_duration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); guard->setQml("startup", QVariantMap{{"available", true}, {"own", format_duration(own)}, {"rank", QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, {"color", impact_color(own)}, {"rows", rows}}); } diff --git a/src/ui/panels/mod_info_panel.h b/src/ui/panels/mod_info_panel.h index 537f9e0..023d860 100644 --- a/src/ui/panels/mod_info_panel.h +++ b/src/ui/panels/mod_info_panel.h @@ -7,16 +7,16 @@ class QQmlEngine; class QQuickWidget; class QLabel; -namespace px::ui { +namespace px::modrim { class ModInfoBanner; class StartupImpactDialog; class ModInfoPanel : public QWidget, public ModInfoSink { Q_OBJECT public: - ModInfoPanel(core::CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent = nullptr); + ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent = nullptr); ~ModInfoPanel() override; - void showMod(const core::ListedMod& mod, const QList& issues) override; - void setIssues(const QList& issues); + void showMod(const ListedMod& mod, const QList& issues) override; + void setIssues(const QList& issues); void clear() override; QCoro::Task setTimeAnalytics(const std::optional& pid, const QStringList& activePids) override; signals: @@ -34,10 +34,10 @@ private slots: QCoro::Task loadPreview(std::optional path, QString title, quint64 token); QCoro::Task performUpdate(QString id); QCoro::Task showStartupDetails(); - core::CoreContext& ctx_; + CoreContext& ctx_; QQmlEngine* engine_; UiPrefs& prefs_; - core::ListedModPtr mod_; + ListedModPtr mod_; ModInfoBanner* banner_; QLabel* placeholder_; QQuickWidget* qml_; @@ -47,6 +47,6 @@ private slots: std::optional startupPid_; QStringList startupActivePids_; QPointer startupDialog_; - core::Event::Id busyConnection_; + Event::Id busyConnection_; }; } diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp index efafacd..08b73e5 100644 --- a/src/ui/panels/mod_list_panel.cpp +++ b/src/ui/panels/mod_list_panel.cpp @@ -18,8 +18,8 @@ #include #include #include -namespace px::ui { -ModListPanel::ModListPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent) +namespace px::modrim { +ModListPanel::ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) : QWidget(parent), ctx_(ctx), model_(new ModListModel({}, &ctx.diagnosticsService(), this)), proxy_(new ModListProxyModel(model_, this)), qml_(new QQuickWidget(engine, this)), compact_mode_(ctx.config().compact_mod_list), highlight_timer_(new QTimer(this)) { @@ -56,7 +56,7 @@ ModListPanel::~ModListPanel() { // QML bindings refer to the models and panel during destruction. delete qml_; } -void ModListPanel::load_mods(const QHash& mods, const QStringList& active) { model_->load_mods(mods,active); } +void ModListPanel::load_mods(const QHash& mods, const QStringList& active) { model_->load_mods(mods,active); } QStringList ModListPanel::active_uuids() const { return model_->active_uuids(); } void ModListPanel::set_sidebar_filter(std::optional> uuids) { proxy_->set_sidebar_filter(std::move(uuids)); } void ModListPanel::set_search_filter(const QString& text) { proxy_->set_search_filter(text); } diff --git a/src/ui/panels/mod_list_panel.h b/src/ui/panels/mod_list_panel.h index c6affdd..8e81105 100644 --- a/src/ui/panels/mod_list_panel.h +++ b/src/ui/panels/mod_list_panel.h @@ -8,16 +8,16 @@ class QQmlEngine; class QQuickWidget; class QLineEdit; -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { class ModListPanel : public QWidget { Q_OBJECT Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY compact_mode_changed) Q_PROPERTY(int highlightRevision READ highlightRevision NOTIFY highlighted_changed) public: - ModListPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); ~ModListPanel() override; - void load_mods(const QHash& mods, const QStringList& active); + void load_mods(const QHash& mods, const QStringList& active); QStringList active_uuids() const; ModListModel* model() const { return model_; } ModListProxyModel* proxy_model() const { return proxy_; } @@ -58,7 +58,7 @@ class ModListPanel : public QWidget { int proxy_to_source_row(int row) const; void core_state_changed(const QStringList& uuids); void set_highlighted(const QStringList& uuids); - core::CoreContext& ctx_; + CoreContext& ctx_; ModListModel* model_; ModListProxyModel* proxy_; QQuickWidget* qml_; diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp index 7c3871a..6be967a 100644 --- a/src/ui/panels/restore_snapshot_dialog.cpp +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -17,7 +17,7 @@ #include "core/mods_config.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -79,7 +79,7 @@ RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, QWidg snapshots_.reserve(static_cast(snapshots.size())); for (const QString& path : snapshots) { - snapshots_.push_back(Snapshot{path, core::parse_mods_config(path)}); + snapshots_.push_back(Snapshot{path, parse_mods_config(path)}); } auto* layout = new QVBoxLayout(this); @@ -194,4 +194,4 @@ ConfirmRestoreDialog::ConfirmRestoreDialog(const QString& snapshotName, QWidget* } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/restore_snapshot_dialog.h b/src/ui/panels/restore_snapshot_dialog.h index 8a4ea71..e068a6c 100644 --- a/src/ui/panels/restore_snapshot_dialog.h +++ b/src/ui/panels/restore_snapshot_dialog.h @@ -15,7 +15,7 @@ class QListWidget; class QListWidgetItem; class QPushButton; -namespace px::ui { +namespace px::modrim { // Lists saved mod lists (ModsConfig snapshots, in the order given, newest first) and // lets the user pick one to restore. Snapshots that cannot be parsed are listed but @@ -36,7 +36,7 @@ private slots: private: struct Snapshot { QString path; - std::optional parsed; + std::optional parsed; }; void populateList(); @@ -54,4 +54,4 @@ class ConfirmRestoreDialog : public QMessageBox { explicit ConfirmRestoreDialog(const QString& snapshotName, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp index 35f9e86..f0be7cb 100644 --- a/src/ui/panels/settings_panel.cpp +++ b/src/ui/panels/settings_panel.cpp @@ -14,7 +14,7 @@ #include "ui/components/dialogs.h" #include "ui/context.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { class FolderDialog : public QFileDialog { public: @@ -24,7 +24,7 @@ class FolderDialog : public QFileDialog { } }; } -SettingsBackend::SettingsBackend(core::CoreContext& ctx, QDialog* dialog, const std::vector& sections) +SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections) : QObject(dialog), ctx_(ctx), dialog_(dialog) { for (auto* section : sections) sections_.append(QVariantMap{{"title", section->title()}, {"source", section->source().toString()}, @@ -38,7 +38,7 @@ SettingsBackend::SettingsBackend(core::CoreContext& ctx, QDialog* dialog, const {"confirmUnsaved", cfg.launch_confirm_unsaved}, {"confirmRunning", cfg.launch_confirm_running}, {"useAltIds", cfg.sort.use_alternative_package_ids}, {"checkMissing", cfg.sort.check_missing_dependencies}, {"useCommunity", cfg.sort.use_community_rules}}; - const auto path = core::community_rules_file(); + const auto path = community_rules_file(); communityStatus_ = QFileInfo::exists(path) ? "Found: " + path : QStringLiteral("Not downloaded"); cacheAvailable_ = !cfg.paths.config_folder.empty(); cacheStatus_ = cacheAvailable_ ? QStringLiteral("Cache ready") : QStringLiteral("No config folder — not available"); @@ -57,7 +57,7 @@ QString SettingsBackend::localCandidate(const QString& gamePath) const { return QFileInfo(path).isDir() ? path : QString{}; } void SettingsBackend::autoDetect() { - const auto paths = core::detect_game_paths(); + const auto paths = detect_game_paths(); const std::pair values[]{{"game", &paths.game}, {"local", &paths.local}, {"workshop", &paths.workshop}, {"config", &paths.config_folder}}; for (const auto& [key, value] : values) @@ -73,15 +73,15 @@ QCoro::Task SettingsBackend::downloadCommunityRules() { emit communityChanged(); co_return; } - core::LoadingState loading; + LoadingState loading; auto* progress = new QProgressDialog(QStringLiteral("Downloading community rules…"), QString{}, 0, 100, dialog_); progress->setWindowModality(Qt::WindowModal); progress->setCancelButton(nullptr); progress->setMinimumDuration(0); - connect(&loading, &core::LoadingState::changed, progress, + connect(&loading, &LoadingState::changed, progress, [progress](const QString& status, int percent, int, int) { progress->setLabelText(status); progress->setValue(percent); }); progress->show(); - core::sort::CommunityRulesService service(ctx_.configService(), ctx_.network()); + sort::CommunityRulesService service(ctx_.configService(), ctx_.network()); QPointer guard(this); QPointer progressGuard(progress); const auto path = co_await service.ensure_rules(loading, true); @@ -107,7 +107,7 @@ void SettingsBackend::reportSaveError(const QString& message) { box->setAttribute(Qt::WA_DeleteOnClose); box->open(); } -SettingsPanel::SettingsPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, +SettingsPanel::SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, const std::vector& factories) : QDialog(parent), config_(ctx.config()), backend_(nullptr), qml_(new QQuickWidget(engine, this)) { setObjectName(QStringLiteral("settingsPanel")); @@ -145,7 +145,7 @@ void SettingsPanel::save(const QVariantMap& values) { updated.compact_mod_list = values.value("compact").toBool(); updated.launch_args = text("launchArgs"); updated.launch_wrapper = text("launchWrapper"); - updated.after_launch = static_cast(values.value("afterLaunch").toInt()); + updated.after_launch = static_cast(values.value("afterLaunch").toInt()); updated.launch_confirm_errors = values.value("confirmErrors").toBool(); updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); updated.launch_confirm_running = values.value("confirmRunning").toBool(); @@ -156,4 +156,4 @@ void SettingsPanel::save(const QVariantMap& values) { config_ = std::move(updated); accept(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/settings_panel.h b/src/ui/panels/settings_panel.h index f66b834..570796a 100644 --- a/src/ui/panels/settings_panel.h +++ b/src/ui/panels/settings_panel.h @@ -7,7 +7,7 @@ #include "ui/settings_section.h" class QQmlEngine; class QQuickWidget; -namespace px::ui { +namespace px::modrim { class AppContext; class SettingsBackend : public QObject { Q_OBJECT @@ -19,7 +19,7 @@ class SettingsBackend : public QObject { Q_PROPERTY(bool cacheAvailable READ cacheAvailable CONSTANT) Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) public: - SettingsBackend(core::CoreContext& ctx, QDialog* dialog, const std::vector& sections); + SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections); QVariantMap initial() const { return initial_; } QVariantList sections() const { return sections_; } QString communityStatus() const { return communityStatus_; } @@ -42,7 +42,7 @@ class SettingsBackend : public QObject { void communityChanged(); void cacheChanged(); private: - core::CoreContext& ctx_; + CoreContext& ctx_; QDialog* dialog_; QVariantMap initial_; QVariantList sections_; @@ -52,16 +52,16 @@ class SettingsBackend : public QObject { class SettingsPanel : public QDialog { Q_OBJECT public: - SettingsPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, const std::vector& sections = {}); SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); ~SettingsPanel() override; - const core::AppConfig& getConfig() const { return config_; } + const AppConfig& getConfig() const { return config_; } private: void save(const QVariantMap& values); - core::AppConfig config_; + AppConfig config_; std::vector sections_; SettingsBackend* backend_; QQuickWidget* qml_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp index c2736c9..a5be878 100644 --- a/src/ui/panels/sidebar_panel.cpp +++ b/src/ui/panels/sidebar_panel.cpp @@ -2,21 +2,21 @@ #include "core/context.h" #include "core/services/diagnostics_service.h" #include -namespace px::ui { -SidebarPanel::SidebarPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent) +namespace px::modrim { +SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) : FilterSidebar(engine, nullptr, parent), ctx_(ctx), model_(new SidebarModel(this)) { if (rootObject()) rootObject()->setProperty("model", QVariant::fromValue(model_)); connect(this, &FilterSidebar::activated, this, &SidebarPanel::on_activated); sidebar_connection_ = ctx_.diagnosticsService().sidebar_entries_changed.connect([this](const auto& entries) { set_entries(entries); }); } SidebarPanel::~SidebarPanel() { ctx_.diagnosticsService().sidebar_entries_changed.disconnect(sidebar_connection_); } -void SidebarPanel::set_entries(const QList& entries) { +void SidebarPanel::set_entries(const QList& entries) { const bool reset = entries.size() != entries_.size(); entries_ = entries; model_->update_entries(entries); if (reset) if (const auto current = current_entry()) emit entry_selected(*current); } -std::optional SidebarPanel::current_entry() const { +std::optional SidebarPanel::current_entry() const { const int selected = current_index(); if (selected >= 0 && selected < entries_.size()) return entries_[selected]; return std::nullopt; @@ -25,7 +25,7 @@ void SidebarPanel::select_all_entry() { auto* root = rootObject(); auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; if (!view) return; - for (int row = 0; row < entries_.size(); ++row) if (entries_[row].kind == core::view::SidebarEntry::Kind::All) { + for (int row = 0; row < entries_.size(); ++row) if (entries_[row].kind == view::SidebarEntry::Kind::All) { view->setProperty("currentIndex", row); on_activated(row); return; } } diff --git a/src/ui/panels/sidebar_panel.h b/src/ui/panels/sidebar_panel.h index 9077825..ce77c3d 100644 --- a/src/ui/panels/sidebar_panel.h +++ b/src/ui/panels/sidebar_panel.h @@ -2,24 +2,24 @@ #include "ui/components/filter_sidebar.h" #include "ui/models/sidebar_model.h" #include -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { class SidebarPanel : public FilterSidebar { Q_OBJECT public: - SidebarPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); ~SidebarPanel() override; - void set_entries(const QList& entries); - std::optional current_entry() const; + void set_entries(const QList& entries); + std::optional current_entry() const; void select_all_entry(); SidebarModel* model() const { return model_; } signals: - void entry_selected(const px::core::view::SidebarEntry& entry); + void entry_selected(const px::modrim::view::SidebarEntry& entry); private: void on_activated(int row); - core::CoreContext& ctx_; + CoreContext& ctx_; SidebarModel* model_; - QList entries_; + QList entries_; std::uint64_t sidebar_connection_; }; } diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp index 5539b3c..e0b3d5f 100644 --- a/src/ui/panels/startup_impact_data.cpp +++ b/src/ui/panels/startup_impact_data.cpp @@ -11,8 +11,8 @@ #include "core/services/startup_impact_service/phases.h" #include "ui/theme/palette.h" -namespace px::ui { -using namespace core::startup_impact; +namespace px::modrim { +using namespace startup_impact; namespace { constexpr double minSeconds = 0.001; QString phaseColor(std::size_t i) { @@ -139,4 +139,4 @@ QVariantMap build_startup_impact(const StartupImpactReport& report, const QStrin {"legend", legend}, {"mods", rows}, {"active_count", activeCount}, {"selected_key", selectedKey}, {"phases_with_base", phaseRows(entries, selected)}, {"phases_mods_only", modsPhases}}; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/startup_impact_data.h b/src/ui/panels/startup_impact_data.h index 92d8aa8..c43c568 100644 --- a/src/ui/panels/startup_impact_data.h +++ b/src/ui/panels/startup_impact_data.h @@ -6,8 +6,8 @@ #include "core/services/startup_impact_service/models.h" #include "ui/models/impact.h" -namespace px::ui { -QVariantMap build_startup_impact(const core::startup_impact::StartupImpactReport& report, +namespace px::modrim { +QVariantMap build_startup_impact(const startup_impact::StartupImpactReport& report, const QStringList& active_pids, const std::optional& selected_pid, double estimated_s); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp index 9d15143..018da68 100644 --- a/src/ui/panels/time_analytics_panel.cpp +++ b/src/ui/panels/time_analytics_panel.cpp @@ -7,8 +7,8 @@ #include "ui/components/dialog_chrome.h" #include "ui/panels/startup_impact_data.h" #include "ui/theme/palette.h" -namespace px::ui { -TimeAnalyticsPanel::TimeAnalyticsPanel(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) +namespace px::modrim { +TimeAnalyticsPanel::TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) : QWidget(parent), service_(service), qml_(new QQuickWidget(engine, this)) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -35,7 +35,7 @@ void TimeAnalyticsPanel::clear() { ++requestToken_; if (auto* root = qml_->rootObject()) root->setProperty("sourceData", QVariant{}); } -StartupImpactDialog::StartupImpactDialog(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) +StartupImpactDialog::StartupImpactDialog(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) : QDialog(parent), panel(new TimeAnalyticsPanel(service, engine, this)) { setWindowTitle(QStringLiteral("Startup impact")); setAttribute(Qt::WA_DeleteOnClose); @@ -45,4 +45,4 @@ StartupImpactDialog::StartupImpactDialog(core::startup_impact::StartupImpactServ layout->addWidget(panel); installDialogChrome(this); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.h b/src/ui/panels/time_analytics_panel.h index 53ae29c..984a38d 100644 --- a/src/ui/panels/time_analytics_panel.h +++ b/src/ui/panels/time_analytics_panel.h @@ -7,22 +7,22 @@ #include "core/services/startup_impact_service/service.h" class QQmlEngine; class QQuickWidget; -namespace px::ui { +namespace px::modrim { class TimeAnalyticsPanel : public QWidget { Q_OBJECT public: - TimeAnalyticsPanel(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); + TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); QCoro::Task setData(std::optional pid, QStringList activePids); void clear(); private: - core::startup_impact::StartupImpactService* service_; + startup_impact::StartupImpactService* service_; QQuickWidget* qml_; quint64 requestToken_ = 0; }; class StartupImpactDialog : public QDialog { Q_OBJECT public: - StartupImpactDialog(core::startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); + StartupImpactDialog(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); TimeAnalyticsPanel* panel; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp index 209b299..cf1fdf8 100644 --- a/src/ui/panels/update_dialog.cpp +++ b/src/ui/panels/update_dialog.cpp @@ -8,8 +8,8 @@ #include #include -namespace px::ui { -UpdateDialog::UpdateDialog(const core::services::ReleaseInfo& release, +namespace px::modrim { +UpdateDialog::UpdateDialog(const services::ReleaseInfo& release, const QString& currentVersion, QWidget* parent) : QDialog(parent) { setWindowTitle(tr("Update Available")); resize(560, 460); @@ -49,4 +49,4 @@ UpdateDialog::UpdateDialog(const core::services::ReleaseInfo& release, buttons->addWidget(download); layout->addLayout(buttons); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/update_dialog.h b/src/ui/panels/update_dialog.h index 967b51c..4f280bf 100644 --- a/src/ui/panels/update_dialog.h +++ b/src/ui/panels/update_dialog.h @@ -3,12 +3,12 @@ #include #include "core/services/update_service.h" -namespace px::ui { +namespace px::modrim { inline constexpr int SKIP_RESULT = 2; class UpdateDialog : public QDialog { public: - UpdateDialog(const core::services::ReleaseInfo& release, const QString& currentVersion, + UpdateDialog(const services::ReleaseInfo& release, const QString& currentVersion, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp index 390f78c..1b91733 100644 --- a/src/ui/panels/upload_report_dialog.cpp +++ b/src/ui/panels/upload_report_dialog.cpp @@ -16,7 +16,7 @@ #include "ui/components/dialogs.h" #include "ui/context.h" -namespace px::ui { +namespace px::modrim { UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { setObjectName(QStringLiteral("uploadConfirmDialog")); setWindowTitle(tr("Upload Log & System Info")); @@ -102,7 +102,7 @@ QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { const QStringList files = dialog->selectedFiles(); if (result != QDialog::Accepted || files.isEmpty()) co_return; const QString path = files.first(); - const QString error = co_await core::providers::runOnPool(nullptr, + const QString error = co_await providers::runOnPool(nullptr, [path, report = std::move(report)] { QSaveFile file(path); if (!file.open(QIODevice::WriteOnly)) return file.errorString(); @@ -115,16 +115,16 @@ QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { } } // namespace -QCoro::Task> handle_upload_report(QWidget* parent, core::CoreContext& ctx, +QCoro::Task> handle_upload_report(QWidget* parent, CoreContext& ctx, Notifier& toast) { QPointer owner(parent); auto [choice, dialog] = co_await awaitDialog(parent); Q_UNUSED(dialog); if (choice == static_cast(UploadChoice::CANCEL)) co_return std::nullopt; - core::support::ReportSource source{ctx.gameVersion(), ctx.config().paths, + support::ReportSource source{ctx.gameVersion(), ctx.config().paths, ctx.allMods(), ctx.activeUuids()}; - const QString report = co_await core::providers::runOnPool(nullptr, - [source = std::move(source)] { return core::support::build_report(source); }); + const QString report = co_await providers::runOnPool(nullptr, + [source = std::move(source)] { return support::build_report(source); }); if (parent && !owner) co_return std::nullopt; if (choice == static_cast(UploadChoice::SAVE_TO_FILE)) { co_await saveReport(owner, report, toast); @@ -132,8 +132,8 @@ QCoro::Task> handle_upload_report(QWidget* parent, core:: } toast.info(QObject::tr("Uploading log & system info…"), 2000); QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); - if (endpoint.isEmpty()) endpoint = QString(core::support::DEFAULT_UPLOAD_ENDPOINT); - core::support::PasteUploader uploader(ctx.network(), std::move(endpoint)); + if (endpoint.isEmpty()) endpoint = QString(support::DEFAULT_UPLOAD_ENDPOINT); + support::PasteUploader uploader(ctx.network(), std::move(endpoint)); auto uploaded = co_await uploader.upload(report); if (parent && !owner) co_return std::nullopt; if (!uploaded) { @@ -146,4 +146,4 @@ QCoro::Task> handle_upload_report(QWidget* parent, core:: toast.success(QObject::tr("Link copied: %1").arg(*uploaded), 5000); co_return *uploaded; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.h b/src/ui/panels/upload_report_dialog.h index e99c079..fbb943f 100644 --- a/src/ui/panels/upload_report_dialog.h +++ b/src/ui/panels/upload_report_dialog.h @@ -4,8 +4,8 @@ #include #include -namespace px::core { class CoreContext; } -namespace px::ui { +namespace px::modrim { class CoreContext; } +namespace px::modrim { class Notifier; enum class UploadChoice { CANCEL = QDialog::Rejected, UPLOAD = QDialog::Accepted, SAVE_TO_FILE = 2 }; class UploadConfirmDialog : public QDialog { @@ -16,6 +16,6 @@ class UploadFailedDialog : public QDialog { public: explicit UploadFailedDialog(const QString& errorMessage, QWidget* parent = nullptr); }; -QCoro::Task> handle_upload_report(QWidget* parent, core::CoreContext& ctx, +QCoro::Task> handle_upload_report(QWidget* parent, CoreContext& ctx, Notifier& toast); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp index 2b99fab..c9bb63a 100644 --- a/src/ui/plugins/downloads/model.cpp +++ b/src/ui/plugins/downloads/model.cpp @@ -1,6 +1,6 @@ #include "ui/plugins/downloads/model.h" -namespace px::ui { +namespace px::modrim { namespace { bool is_active(const QString& state) { return state == QLatin1String("queued") || state == QLatin1String("checking") || state == QLatin1String("downloading"); @@ -41,7 +41,7 @@ void DownloadsModel::begin(const QStringList& ids, const QHash emit summary_changed(); } -void DownloadsModel::apply(const core::DownloadItemStatus& status) { +void DownloadsModel::apply(const DownloadItemStatus& status) { const auto found = by_id_.constFind(status.mod_id); if (found == by_id_.cend()) return; auto& row = rows_[*found]; @@ -65,7 +65,7 @@ void DownloadsModel::apply(const core::DownloadItemStatus& status) { mark(row); } -void DownloadsModel::set_title(const core::DownloadItemTitle& item) { +void DownloadsModel::set_title(const DownloadItemTitle& item) { const auto found = by_id_.constFind(item.mod_id); if (found == by_id_.cend()) return; auto& row = rows_[*found]; @@ -80,7 +80,7 @@ void DownloadsModel::set_phase(const QString& phase) { emit phase_changed(); } -void DownloadsModel::finish(const core::DownloadResult&) { +void DownloadsModel::finish(const DownloadResult&) { for (auto& row : rows_) { if (is_active(row.state)) { row.state = QStringLiteral("cancelled"); @@ -199,4 +199,4 @@ QHash DownloadsModel::roleNames() const { return {{IdRole, "id"}, {TitleRole, "title"}, {StateRole, "state"}, {ProgressRole, "progress"}, {ErrorRole, "error"}}; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.h b/src/ui/plugins/downloads/model.h index dbf5fa8..b4e1209 100644 --- a/src/ui/plugins/downloads/model.h +++ b/src/ui/plugins/downloads/model.h @@ -8,7 +8,7 @@ #include "core/downloads/types.h" -namespace px::ui { +namespace px::modrim { class DownloadsModel final : public QAbstractListModel { Q_OBJECT @@ -36,10 +36,10 @@ class DownloadsModel final : public QAbstractListModel { QHash roleNames() const override; void begin(const QStringList& ids, const QHash& titles); - void apply(const core::DownloadItemStatus& status); - void set_title(const core::DownloadItemTitle& item); + void apply(const DownloadItemStatus& status); + void set_title(const DownloadItemTitle& item); void set_phase(const QString& phase); - void finish(const core::DownloadResult& result); + void finish(const DownloadResult& result); void set_busy(bool busy); QStringList failed_ids() const; void flush(); @@ -95,4 +95,4 @@ class DownloadsModel final : public QAbstractListModel { QVariantList history_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.cpp b/src/ui/plugins/downloads/plugin.cpp index c641857..a5d0b52 100644 --- a/src/ui/plugins/downloads/plugin.cpp +++ b/src/ui/plugins/downloads/plugin.cpp @@ -2,9 +2,9 @@ #include "ui/plugins/downloads/view.h" -namespace px::ui { +namespace px::modrim { DownloadsUiPlugin::DownloadsUiPlugin() : UiPlugin(QStringLiteral("downloads_ui"), {QStringLiteral("downloads")}) {} void DownloadsUiPlugin::setup(AppContext& ctx) { ctx.addRailView(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.h b/src/ui/plugins/downloads/plugin.h index 347b738..6ce6016 100644 --- a/src/ui/plugins/downloads/plugin.h +++ b/src/ui/plugins/downloads/plugin.h @@ -2,7 +2,7 @@ #include "ui/context.h" -namespace px::ui { +namespace px::modrim { class DownloadsUiPlugin final : public UiPlugin { public: @@ -10,4 +10,4 @@ class DownloadsUiPlugin final : public UiPlugin { void setup(AppContext& ctx) override; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp index c890b03..e589abe 100644 --- a/src/ui/plugins/downloads/view.cpp +++ b/src/ui/plugins/downloads/view.cpp @@ -6,9 +6,9 @@ #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { -DownloadsViewPanel::DownloadsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +DownloadsViewPanel::DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : BaseViewPanel(ctx, engine, parent, app), downloads_(ctx.downloads()), model_(new DownloadsModel(this)) { auto* qml = new QQuickWidget(engine, this); qml->setObjectName(QStringLiteral("downloadsView")); @@ -22,10 +22,10 @@ DownloadsViewPanel::DownloadsViewPanel(core::CoreContext& ctx, QQmlEngine* engin batch_id_ = downloads_.batch_started.connect([this](const QStringList& ids) { model_->begin(ids, downloads_.names_by_id(ctx_.allMods().values())); }); - status_id_ = downloads_.download_item_status_changed.connect([this](const core::DownloadItemStatus& status) { model_->apply(status); }); - title_id_ = downloads_.download_item_titled.connect([this](const core::DownloadItemTitle& item) { model_->set_title(item); }); + status_id_ = downloads_.download_item_status_changed.connect([this](const DownloadItemStatus& status) { model_->apply(status); }); + title_id_ = downloads_.download_item_titled.connect([this](const DownloadItemTitle& item) { model_->set_title(item); }); phase_id_ = downloads_.download_phase_changed.connect([this](const QString& phase) { model_->set_phase(phase); }); - finished_id_ = downloads_.download_finished.connect([this](const core::DownloadResult& result) { model_->finish(result); }); + finished_id_ = downloads_.download_finished.connect([this](const DownloadResult& result) { model_->finish(result); }); busy_id_ = downloads_.busy_changed.connect([this](bool busy) { model_->set_busy(busy); }); } @@ -49,4 +49,4 @@ QCoro::Task DownloadsViewPanel::retry_failed() { if (!result) spdlog::warn("downloads retry not started: {}", result.error().message.toStdString()); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.h b/src/ui/plugins/downloads/view.h index 87f944a..e650652 100644 --- a/src/ui/plugins/downloads/view.h +++ b/src/ui/plugins/downloads/view.h @@ -7,27 +7,27 @@ #include "ui/plugins/downloads/model.h" #include "ui/views/base.h" -namespace px::ui { +namespace px::modrim { class DownloadsViewPanel final : public BaseViewPanel { Q_OBJECT public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("downloads"), QLatin1StringView("download"), QLatin1StringView("Downloads"), true}; - DownloadsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); ~DownloadsViewPanel() override; Q_INVOKABLE void stop(); Q_INVOKABLE QCoro::QmlTask retryFailed(); private: QCoro::Task retry_failed(); - core::DownloadManager& downloads_; + DownloadManager& downloads_; DownloadsModel* model_; - core::Event::Id batch_id_; - core::Event::Id status_id_; - core::Event::Id title_id_; - core::Event::Id phase_id_; - core::Event::Id finished_id_; - core::Event::Id busy_id_; + Event::Id batch_id_; + Event::Id status_id_; + Event::Id title_id_; + Event::Id phase_id_; + Event::Id finished_id_; + Event::Id busy_id_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp index 100df25..81f81d5 100644 --- a/src/ui/plugins/organizer/dialogs.cpp +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -12,12 +12,12 @@ #include "core/organizer/models.h" #include "core/organizer/resolve.h" -namespace px::ui { +namespace px::modrim { namespace { -using px::core::organizer::FolderNode; -using px::core::organizer::ROOT_ID; +using px::modrim::organizer::FolderNode; +using px::modrim::organizer::ROOT_ID; QPushButton* makeButton(const QString& text, QWidget* parent, const QString& object_name) { auto* button = new QPushButton(text, parent); @@ -141,4 +141,4 @@ qint64 FolderPickerDialog::folder_id() const { return item->data(0, Qt::UserRole).toLongLong(); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.h b/src/ui/plugins/organizer/dialogs.h index 76218ce..28c1d9b 100644 --- a/src/ui/plugins/organizer/dialogs.h +++ b/src/ui/plugins/organizer/dialogs.h @@ -9,11 +9,11 @@ class QLineEdit; class QTreeWidget; -namespace px::core::organizer { +namespace px::modrim::organizer { struct FolderNode; } -namespace px::ui { +namespace px::modrim { // Asks for a folder name. Enter confirms; the confirm button is disabled while the // name is blank. Without `verb`, the first word of `title` names the confirm button. @@ -40,7 +40,7 @@ class DeleteFolderDialog : public QMessageBox { // entry for ROOT_ID. Folders not in `allowed` are shown but cannot be chosen. class FolderPickerDialog : public QDialog { public: - FolderPickerDialog(const px::core::organizer::FolderNode& tree, const QString& title, + FolderPickerDialog(const px::modrim::organizer::FolderNode& tree, const QString& title, const QSet& allowed, QWidget* parent, const QString& verb = QStringLiteral("Move")); @@ -51,4 +51,4 @@ class FolderPickerDialog : public QDialog { QTreeWidget* tree_ = nullptr; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp index f85af53..e9ef5bf 100644 --- a/src/ui/plugins/organizer/filter_model.cpp +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -1,6 +1,6 @@ #include "ui/plugins/organizer/filter_model.h" -namespace px::ui { +namespace px::modrim { OrganizerFilterModel::OrganizerFilterModel(QObject* parent) : QAbstractListModel(parent) {} @@ -39,7 +39,7 @@ QHash OrganizerFilterModel::roleNames() const { {IconRole, "iconName"}, {SectionRole, "sectionName"}, {KeyRole, "key"}}; } -void OrganizerFilterModel::update(const QList& entries) { +void OrganizerFilterModel::update(const QList& entries) { bool same_keys = entries.size() == items_.size(); for (qsizetype i = 0; same_keys && i < entries.size(); ++i) same_keys = entries[i].key == items_[i].key; @@ -60,10 +60,10 @@ std::optional OrganizerFilterModel::key_at(int row) const { return items_[row].key; } -core::organizer::TreeFilter OrganizerFilterModel::for_key(const QString& key) const { +organizer::TreeFilter OrganizerFilterModel::for_key(const QString& key) const { for (const auto& entry : items_) if (entry.key == key) return entry; if (!items_.isEmpty()) return items_.first(); return {QStringLiteral("all"), QStringLiteral("All"), 0}; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.h b/src/ui/plugins/organizer/filter_model.h index 54f9924..aef30a2 100644 --- a/src/ui/plugins/organizer/filter_model.h +++ b/src/ui/plugins/organizer/filter_model.h @@ -4,7 +4,7 @@ #include "core/organizer/resolve.h" -namespace px::ui { +namespace px::modrim { class OrganizerFilterModel : public QAbstractListModel { Q_OBJECT @@ -15,12 +15,12 @@ class OrganizerFilterModel : public QAbstractListModel { int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void update(const QList& entries); + void update(const QList& entries); std::optional key_at(int row) const; - core::organizer::TreeFilter for_key(const QString& key) const; + organizer::TreeFilter for_key(const QString& key) const; private: - QList items_; + QList items_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/plugin.h b/src/ui/plugins/organizer/plugin.h index 9570efe..c519de3 100644 --- a/src/ui/plugins/organizer/plugin.h +++ b/src/ui/plugins/organizer/plugin.h @@ -3,7 +3,7 @@ #include "ui/context.h" #include "ui/plugins/organizer/view.h" -namespace px::ui { +namespace px::modrim { class OrganizerUiPlugin final : public UiPlugin { public: @@ -11,4 +11,4 @@ class OrganizerUiPlugin final : public UiPlugin { void setup(AppContext& ctx) override { ctx.addRailView(0); } }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index 19e3380..801ea8c 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -5,9 +5,9 @@ #include "ui/models/mod_list_model.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { -using namespace core::organizer; +using namespace organizer; QString provider_icon(const QString& id) { if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); @@ -39,7 +39,7 @@ std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, const auto& mod = **it; item->provider_id = mod.provider_id; item->provider_label = provider_label(mod.provider_id, mod_package_id(mod).value_or(QString{})); - if (const auto* about = dynamic_cast(&mod)) + if (const auto* about = dynamic_cast(&mod)) item->mod_version = about->mod_version; } parent->add(std::move(item)); @@ -106,7 +106,7 @@ QList TreeNode::walk() const { return result; } TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, - const core::organizer::FolderNode* folder, const core::organizer::ModLeaf* leaf, + const organizer::FolderNode* folder, const organizer::ModLeaf* leaf, QString provider_id) : kind(std::move(kind)), key(std::move(key)), parent(parent), folder(folder), leaf(leaf), provider_id(std::move(provider_id)) {} @@ -115,7 +115,7 @@ TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, qint64 TreeNode::folder_id() const { if (kind == "folder" && folder) return folder->folder.id; if (kind == "mod" && parent) return parent->folder_id(); - return core::organizer::ROOT_ID; + return organizer::ROOT_ID; } bool TreeNode::is_container() const { return kind == "folder" || kind == "ungrouped"; } @@ -212,10 +212,10 @@ QHash ModTreeModel::roleNames() const { bool ModTreeModel::filtering() const { return filtering_; } -void ModTreeModel::set_tree(const core::organizer::FolderNode& root, - const core::organizer::ModsByUuid& mods, const QHash& provider_colors, - bool filtering, const QHash& tags) { - auto payload = std::make_unique(root); +void ModTreeModel::set_tree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, const QHash& provider_colors, + bool filtering, const QHash& tags) { + auto payload = std::make_unique(root); auto fresh = build(*payload, mods, filtering); provider_colors_ = provider_colors; tag_rows_.clear(); @@ -312,4 +312,4 @@ void ModTreeModel::notify_selection(const QSet& changed) { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.h b/src/ui/plugins/organizer/tree_model.h index 8663733..9e0faaa 100644 --- a/src/ui/plugins/organizer/tree_model.h +++ b/src/ui/plugins/organizer/tree_model.h @@ -10,7 +10,7 @@ #include "core/models/view/diagnostics.h" #include "core/organizer/resolve.h" -namespace px::ui { +namespace px::modrim { inline const QString UNGROUPED_KEY = QStringLiteral("ungrouped"); @@ -18,8 +18,8 @@ struct TreeNode { QString kind; QString key; TreeNode* parent = nullptr; - const core::organizer::FolderNode* folder = nullptr; - const core::organizer::ModLeaf* leaf = nullptr; + const organizer::FolderNode* folder = nullptr; + const organizer::ModLeaf* leaf = nullptr; QString provider_id; QString provider_label; QString mod_version; @@ -30,8 +30,8 @@ struct TreeNode { QList walk() const; qint64 folder_id() const; TreeNode(QString kind = QStringLiteral("root"), QString key = {}, TreeNode* parent = nullptr, - const core::organizer::FolderNode* folder = nullptr, - const core::organizer::ModLeaf* leaf = nullptr, QString provider_id = {}); + const organizer::FolderNode* folder = nullptr, + const organizer::ModLeaf* leaf = nullptr, QString provider_id = {}); bool is_container() const; }; @@ -46,7 +46,7 @@ class ModTreeModel : public QAbstractItemModel { HasWarningRole, WarningTooltipRole, TagsRole, ProviderLabelRole, ModVersionRole }; Q_ENUM(Role) - using SummaryFor = std::function(const QString&)>; + using SummaryFor = std::function(const QString&)>; explicit ModTreeModel(SummaryFor summary_for, QObject* parent = nullptr); QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; QModelIndex parent(const QModelIndex& child) const override; @@ -56,10 +56,10 @@ class ModTreeModel : public QAbstractItemModel { QHash roleNames() const override; bool filtering() const; - void set_tree(const core::organizer::FolderNode& root, - const core::organizer::ModsByUuid& mods, + void set_tree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, const QHash& provider_colors, bool filtering, - const QHash& tags = {}); + const QHash& tags = {}); void refresh_diagnostics(); TreeNode* node_at(const QModelIndex& index) const; QModelIndex index_for_key(const QString& key) const; @@ -77,11 +77,11 @@ class ModTreeModel : public QAbstractItemModel { void notify_selection(const QSet& changed); SummaryFor summary_for_; std::unique_ptr root_; - std::unique_ptr payload_; + std::unique_ptr payload_; bool filtering_ = false; QHash provider_colors_; QHash tag_rows_; QSet selected_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp index e47d812..6d262bd 100644 --- a/src/ui/plugins/organizer/view.cpp +++ b/src/ui/plugins/organizer/view.cpp @@ -35,13 +35,13 @@ #include "ui/theme/constants.h" #include "ui/theme/palette.h" -namespace px::ui { -using namespace core::organizer; +namespace px::modrim { +using namespace organizer; namespace { QString plural(qsizetype count, const QString& word) { return QStringLiteral("%1 %2%3").arg(count).arg(word, count == 1 ? QString{} : QStringLiteral("s")); } -OrganizerService& organizer(core::CoreContext& ctx) { +OrganizerService& organizerService(CoreContext& ctx) { auto* service = dynamic_cast(ctx.plugins().get(QStringLiteral("organizer"))); Q_ASSERT(service); return *service; @@ -55,8 +55,8 @@ QPushButton* button(const QString& text, QWidget* parent, QHBoxLayout* layout) { } } -OrganizerViewPanel::OrganizerViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx, engine, parent, app), service_(organizer(ctx)), +OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), service_(organizerService(ctx)), model_(new ModTreeModel([&ctx](const QString& uuid) { return ctx.diagnosticsService().summary_for(uuid); }, this)), filters_(new OrganizerFilterModel(this)) { auto* content = new QWidget(this); @@ -278,14 +278,14 @@ QVariantList OrganizerViewPanel::ruleFolders() const { result.append(QVariantMap{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); return result; } -core::Result> OrganizerViewPanel::ruleSpecs(const QVariantList& rows) const { +Result> OrganizerViewPanel::ruleSpecs(const QVariantList& rows) const { QList specs; specs.reserve(rows.size()); for (const auto& value : rows) { const auto row = value.toMap(); const auto field = parse_rule_field(row.value(QStringLiteral("field")).toString()); const auto op = parse_rule_op(row.value(QStringLiteral("op")).toString()); - if (!field || !op) return std::unexpected(core::Error{QStringLiteral("Invalid rule field or operator")}); + if (!field || !op) return std::unexpected(Error{QStringLiteral("Invalid rule field or operator")}); specs.append(RuleSpec{*field, *op, row.value(QStringLiteral("pattern")).toString(), row.value(QStringLiteral("folder_id")).toLongLong()}); } return specs; @@ -310,7 +310,7 @@ QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, in void OrganizerViewPanel::setEditorError(QString message) { if (message != editor_error_) { editor_error_ = std::move(message); Q_EMIT editorChanged(); } } -void OrganizerViewPanel::reportError(const core::Error& error) { +void OrganizerViewPanel::reportError(const Error& error) { if (auto* toasts = app_.toasts()) toasts->error(error.message); else spdlog::warn("organizer: {}", error.message.toStdString()); } @@ -345,7 +345,7 @@ QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, QS if (!result) { setEditorError(result.error().message); co_return; } Q_EMIT tagCreated(); } else { - core::Result result; + Result result; if (operation == QStringLiteral("update")) result = co_await service_.update_tag(id, std::move(name), std::move(color)); else if (operation == QStringLiteral("delete")) result = co_await service_.delete_tag(id); else result = co_await service_.set_mod_tags(selectedPackageIds(), add ? QSet{id} : QSet{}, add ? QSet{} : QSet{id}); @@ -436,7 +436,7 @@ QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { QCoro::QmlTask OrganizerViewPanel::newFolder() { return editFolder(QStringLiteral("new")); } QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStringList pids) { const QPointer alive(this); - core::Result result; + Result result; if (action == QStringLiteral("new") || action == QStringLiteral("create") || action == QStringLiteral("rename")) { const bool rename = action == QStringLiteral("rename"); const QString initial = rename ? service_.state().folders.value(id).name : QString{}; @@ -452,7 +452,7 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr const auto name = dialog->textValue(); if (rename) result = co_await service_.rename_folder(id, name); else { - core::Result created; + Result created; if (action == QStringLiteral("create")) created = co_await service_.create_folder_from(name, pids); else created = co_await service_.create_folder(name, id); if (!alive) co_return; @@ -488,7 +488,7 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr } QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, qint64 destination) { const QPointer alive(this); - core::Result result; + Result result; if (destination == ROOT_ID) result = co_await service_.ungroup(pids); else result = co_await service_.place(pids, destination); if (!alive) co_return; diff --git a/src/ui/plugins/organizer/view.h b/src/ui/plugins/organizer/view.h index 10039c9..55dd476 100644 --- a/src/ui/plugins/organizer/view.h +++ b/src/ui/plugins/organizer/view.h @@ -19,7 +19,7 @@ class QQuickWidget; class QTimer; class QShowEvent; -namespace px::ui { +namespace px::modrim { class ModTreeModel; class OrganizerFilterModel; class ModInfoPanel; @@ -36,7 +36,7 @@ class OrganizerViewPanel final : public BaseViewPanel { Q_PROPERTY(QString editorError READ editorError NOTIFY editorChanged) public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("organizer"), QLatin1StringView("folder"), QLatin1StringView("Organizer")}; - OrganizerViewPanel(core::CoreContext&, QQmlEngine*, QWidget*, AppContext&); + OrganizerViewPanel(CoreContext&, QQmlEngine*, QWidget*, AppContext&); ~OrganizerViewPanel() override; bool ready() const; int selectedTagCount() const; @@ -80,10 +80,10 @@ class OrganizerViewPanel final : public BaseViewPanel { void syncInfo(bool force = false); void treeCommand(const char*); void setEditorError(QString message = {}); - void reportError(const core::Error&); + void reportError(const Error&); QStringList selectedPackageIds() const; QStringList selectedUuids() const; - core::Result> ruleSpecs(const QVariantList&) const; + Result> ruleSpecs(const QVariantList&) const; QCoro::Task calculateRulePreview(QVariantList, int); QCoro::Task mutateTag(QString operation, qint64 id, QString name = {}, QString color = {}, bool add = false); QCoro::Task persistRules(QVariantList); @@ -92,14 +92,14 @@ class OrganizerViewPanel final : public BaseViewPanel { QCoro::Task persistAllCollapse(bool); QCoro::Task toggle(QModelIndex); QCoro::Task runSelectionAction(QString); - QCoro::Task editFolder(QString action, qint64 id = core::organizer::ROOT_ID, QStringList pids = {}); + QCoro::Task editFolder(QString action, qint64 id = organizer::ROOT_ID, QStringList pids = {}); QCoro::Task placeMods(QStringList, qint64); QCoro::Task resetRules(QStringList); QCoro::Task performDrop(QModelIndex, QModelIndex); void modMenu(); void folderMenu(const TreeNode&); - core::organizer::OrganizerService& service_; + organizer::OrganizerService& service_; ModTreeModel* model_; OrganizerFilterModel* filters_; ModInfoPanel* info_; diff --git a/src/ui/plugins/steam_downloader/plugin.cpp b/src/ui/plugins/steam_downloader/plugin.cpp index 0cfb802..2c29c7f 100644 --- a/src/ui/plugins/steam_downloader/plugin.cpp +++ b/src/ui/plugins/steam_downloader/plugin.cpp @@ -1,9 +1,9 @@ #include "ui/plugins/steam_downloader/plugin.h" #ifdef PX_HAS_STEAM -namespace px::ui { +namespace px::modrim { -SteamSettingsSection::SteamSettingsSection(core::PluginConfig& settings, QObject* parent) +SteamSettingsSection::SteamSettingsSection(PluginConfig& settings, QObject* parent) : PluginConfigSection(settings, parent) {} QString SteamSettingsSection::title() const { return QStringLiteral("Steam Workshop"); } QUrl SteamSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamSettings.qml")); } @@ -12,9 +12,9 @@ SteamDownloaderUiPlugin::SteamDownloaderUiPlugin() : UiPlugin(QStringLiteral("steam_downloader_ui"), {QStringLiteral("steam_downloader")}) {} void SteamDownloaderUiPlugin::setup(AppContext& ctx) { - auto& downloader = *static_cast(ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); + auto& downloader = *static_cast(ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); ctx.addSettingsSection([&downloader](QObject* parent) { return new SteamSettingsSection(downloader.settings(), parent); }); } -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_downloader/plugin.h b/src/ui/plugins/steam_downloader/plugin.h index 42f9c6a..bed3f65 100644 --- a/src/ui/plugins/steam_downloader/plugin.h +++ b/src/ui/plugins/steam_downloader/plugin.h @@ -6,11 +6,11 @@ #include "core/downloads/steam.h" #include "ui/settings_section.h" -namespace px::ui { +namespace px::modrim { -class SteamSettingsSection final : public PluginConfigSection { +class SteamSettingsSection final : public PluginConfigSection { public: - SteamSettingsSection(core::PluginConfig& settings, QObject* parent); + SteamSettingsSection(PluginConfig& settings, QObject* parent); QString title() const override; QUrl source() const override; }; @@ -21,5 +21,5 @@ class SteamDownloaderUiPlugin final : public UiPlugin { void setup(AppContext& ctx) override; }; -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp index 7d73017..5321e92 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.cpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -12,7 +12,7 @@ #include "ui/plugins/steam_workshop/plugin.h" -namespace px::ui { +namespace px::modrim { namespace { bool is_published_file_id(const QJsonValue& value) { if (!value.isString()) return false; @@ -95,5 +95,5 @@ QCoro::Task PxModRimBridge::handle(QString request_id, QString method, QSt respond(request_id, true); } -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.h b/src/ui/plugins/steam_workshop/bridge_obj.h index 5f9a796..02f8882 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.h +++ b/src/ui/plugins/steam_workshop/bridge_obj.h @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { class SteamWorkshopUiPlugin; @@ -23,5 +23,5 @@ class PxModRimBridge final : public QObject { SteamWorkshopUiPlugin& plugin_; }; -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp index 5ec2513..98ab9f9 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.cpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -1,6 +1,6 @@ #include "ui/plugins/steam_workshop/download_queue_model.h" -namespace px::ui { +namespace px::modrim { DownloadQueueModel::DownloadQueueModel(QObject* parent) : QAbstractListModel(parent) {} @@ -48,4 +48,4 @@ void DownloadQueueModel::update_status(const QString& id, const QString& status, } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_queue_model.h b/src/ui/plugins/steam_workshop/download_queue_model.h index de12d01..43809a5 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.h +++ b/src/ui/plugins/steam_workshop/download_queue_model.h @@ -6,7 +6,7 @@ #include #include -namespace px::ui { +namespace px::modrim { using CheckedDownloads = QList>; @@ -32,4 +32,4 @@ class DownloadQueueModel final : public QAbstractListModel { QList items_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp index 9b317bd..b9451df 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.cpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -8,7 +8,7 @@ #include "ui/theme/constants.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { DownloadSidebar::DownloadSidebar(QQmlEngine* engine, QWidget* parent) : QWidget(parent), model_(new DownloadQueueModel(this)), qml_(new QQuickWidget(engine, this)) { @@ -39,4 +39,4 @@ void DownloadSidebar::update_status(const QString& id, const QString& status, qi model_->update_status(id, status, done, total); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.h b/src/ui/plugins/steam_workshop/download_sidebar.h index 9f41e1a..5a1f3b3 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.h +++ b/src/ui/plugins/steam_workshop/download_sidebar.h @@ -7,7 +7,7 @@ class QQmlEngine; class QQuickWidget; -namespace px::ui { +namespace px::modrim { class DownloadSidebar final : public QWidget { Q_OBJECT @@ -30,4 +30,4 @@ class DownloadSidebar final : public QWidget { QQuickWidget* qml_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp index 00fe1e9..765f1b4 100644 --- a/src/ui/plugins/steam_workshop/plugin.cpp +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -17,7 +17,7 @@ #include "ui/navigation.h" #include "ui/plugins/steam_workshop/view.h" -namespace px::ui { +namespace px::modrim { SteamWorkshopUiPlugin::SteamWorkshopUiPlugin() : UiPlugin(QStringLiteral("steamworkshop"), {QStringLiteral("steam_downloader")}) {} @@ -26,7 +26,7 @@ SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnect_events(); } void SteamWorkshopUiPlugin::setup(AppContext& ctx) { core_ = &ctx.coreContext(); app_ = &ctx; - service_ = static_cast(core_->plugins().get(QStringLiteral("steam_downloader"))); + service_ = static_cast(core_->plugins().get(QStringLiteral("steam_downloader"))); ctx.addRailView(); mods_id_ = core_->modService().mods_changed.connect([this](const std::monostate&) { refresh_cached_ids(); @@ -38,8 +38,8 @@ void SteamWorkshopUiPlugin::setup(AppContext& ctx) { badges_refresh_requested.notify(installed_ids_.values()); active_refresh_requested.notify(active_ids_.values()); }); - status_id_ = service_->download_item_status_changed.connect([this](const core::DownloadItemStatus& status) { on_item_status(status); }); - finished_id_ = service_->download_finished.connect([this](const core::DownloadResult& result) { on_download_finished(result); }); + status_id_ = service_->download_item_status_changed.connect([this](const DownloadItemStatus& status) { on_item_status(status); }); + finished_id_ = service_->download_finished.connect([this](const DownloadResult& result) { on_download_finished(result); }); busy_id_ = service_->busy_changed.connect([this](bool busy) { download_busy_changed.notify(busy); }); } @@ -172,13 +172,13 @@ void SteamWorkshopUiPlugin::sync_all() { notify_sidebar(); } -void SteamWorkshopUiPlugin::on_item_status(const core::DownloadItemStatus& status) { +void SteamWorkshopUiPlugin::on_item_status(const DownloadItemStatus& status) { if (!own_download_) return; statuses_.insert(status.mod_id, status.status); item_status_changed.notify(status); } -void SteamWorkshopUiPlugin::on_download_finished(const core::DownloadResult& result) { +void SteamWorkshopUiPlugin::on_download_finished(const DownloadResult& result) { if (!own_download_) return; for (const auto& id : result.succeeded) { checked_ids_.removeIf([&id](const auto& item) { return item.first == id; }); @@ -204,7 +204,7 @@ void SteamWorkshopUiPlugin::refresh_cached_ids() { } } -QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id) { +QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id) { QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); QUrlQuery query; query.addQueryItem(QStringLiteral("id"), id); @@ -216,22 +216,22 @@ QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QStr if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { reply->abort(); reply->deleteLater(); - co_return std::unexpected(core::Error{QStringLiteral("Timeout")}); + co_return std::unexpected(Error{QStringLiteral("Timeout")}); } const auto error = reply->error(); const auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const auto bytes = reply->readAll(); const auto message = reply->errorString(); reply->deleteLater(); - if (error == QNetworkReply::TimeoutError) co_return std::unexpected(core::Error{QStringLiteral("Timeout")}); + if (error == QNetworkReply::TimeoutError) co_return std::unexpected(Error{QStringLiteral("Timeout")}); if (status && status != 200) co_return QJsonValue(QJsonValue::Null); - if (error != QNetworkReply::NoError) co_return std::unexpected(core::Error{message}); + if (error != QNetworkReply::NoError) co_return std::unexpected(Error{message}); if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); QJsonParseError parse_error; const auto document = QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parse_error); - if (parse_error.error != QJsonParseError::NoError) co_return std::unexpected(core::Error{parse_error.errorString()}); + if (parse_error.error != QJsonParseError::NoError) co_return std::unexpected(Error{parse_error.errorString()}); co_return document.array().at(0); } -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/plugin.h b/src/ui/plugins/steam_workshop/plugin.h index 9109d68..89b917c 100644 --- a/src/ui/plugins/steam_workshop/plugin.h +++ b/src/ui/plugins/steam_workshop/plugin.h @@ -11,7 +11,7 @@ #include "ui/context.h" #include "ui/plugins/steam_workshop/download_queue_model.h" -namespace px::ui { +namespace px::modrim { struct SidebarSync { CheckedDownloads checked_ids; @@ -36,36 +36,36 @@ class SteamWorkshopUiPlugin final : public UiPlugin { void clear_queue(); void sync_all(); bool is_downloading() const; - QCoro::Task> fetch_mod_deps(QString id); + QCoro::Task> fetch_mod_deps(QString id); - core::Event badges_refresh_requested; - core::Event active_refresh_requested; - core::Event sidebar_sync_requested; - core::Event item_status_changed; - core::Event download_busy_changed; - core::Event uncheck_mod_requested; - core::Event clear_checked_requested; + Event badges_refresh_requested; + Event active_refresh_requested; + Event sidebar_sync_requested; + Event item_status_changed; + Event download_busy_changed; + Event uncheck_mod_requested; + Event clear_checked_requested; private: void disconnect_events(); void refresh_cached_ids(); void notify_sidebar(); - void on_item_status(const core::DownloadItemStatus& status); - void on_download_finished(const core::DownloadResult& result); - core::CoreContext* core_ = nullptr; + void on_item_status(const DownloadItemStatus& status); + void on_download_finished(const DownloadResult& result); + CoreContext* core_ = nullptr; AppContext* app_ = nullptr; - core::SteamDownloader* service_ = nullptr; + SteamDownloader* service_ = nullptr; QSet installed_ids_; QSet active_ids_; CheckedDownloads checked_ids_; QHash statuses_; bool own_download_ = false; - core::Event::Id mods_id_ = 0; - core::Event::Id active_id_ = 0; - core::Event::Id status_id_ = 0; - core::Event::Id finished_id_ = 0; - core::Event::Id busy_id_ = 0; + Event::Id mods_id_ = 0; + Event::Id active_id_ = 0; + Event::Id status_id_ = 0; + Event::Id finished_id_ = 0; + Event::Id busy_id_ = 0; }; -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index 85ee2cf..4bf2a63 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -17,7 +17,7 @@ #include "ui/plugins/steam_workshop/download_sidebar.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { const QUrl workshop_url(QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); @@ -39,7 +39,7 @@ QString injection() { } } -SteamWorkshopViewPanel::SteamWorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : BaseViewPanel(ctx, engine, parent, app) { auto* content = new QWidget(this); auto* layout = new QHBoxLayout(content); @@ -81,7 +81,7 @@ void SteamWorkshopViewPanel::ensure_initialized() { run_js(QStringLiteral("if (window.__pxmSetActive) window.__pxmSetActive(%1)").arg(json_argument(QJsonArray::fromStringList(ids)))); }); sidebar_id_ = plugin_->sidebar_sync_requested.connect([this](const SidebarSync& sync) { sidebar_->sync_from(sync.checked_ids, sync.statuses); }); - status_id_ = plugin_->item_status_changed.connect([this](const core::DownloadItemStatus& status) { + status_id_ = plugin_->item_status_changed.connect([this](const DownloadItemStatus& status) { sidebar_->update_status(status.mod_id, status.status, status.bytes_done, status.bytes_total); }); busy_id_ = plugin_->download_busy_changed.connect([this](bool busy) { sidebar_->set_download_enabled(!busy); }); @@ -160,5 +160,5 @@ void SteamWorkshopViewPanel::teardown() { initialized_ = false; } -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/steam_workshop/view.h b/src/ui/plugins/steam_workshop/view.h index 541bbe5..d3e511d 100644 --- a/src/ui/plugins/steam_workshop/view.h +++ b/src/ui/plugins/steam_workshop/view.h @@ -8,7 +8,7 @@ class QQuickWidget; class QShowEvent; -namespace px::ui { +namespace px::modrim { class DownloadSidebar; @@ -16,7 +16,7 @@ class SteamWorkshopViewPanel final : public BaseViewPanel { Q_OBJECT public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("steam_workshop"), QLatin1StringView("steam"), QLatin1StringView("Steam Workshop")}; - SteamWorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); ~SteamWorkshopViewPanel() override; void preload(); void teardown(); @@ -32,14 +32,14 @@ class SteamWorkshopViewPanel final : public BaseViewPanel { QQuickWidget* qml_; SteamWorkshopUiPlugin* plugin_ = nullptr; bool initialized_ = false; - core::Event::Id badges_id_ = 0; - core::Event::Id active_id_ = 0; - core::Event::Id sidebar_id_ = 0; - core::Event::Id status_id_ = 0; - core::Event::Id busy_id_ = 0; - core::Event::Id uncheck_id_ = 0; - core::Event::Id clear_id_ = 0; + Event::Id badges_id_ = 0; + Event::Id active_id_ = 0; + Event::Id sidebar_id_ = 0; + Event::Id status_id_ = 0; + Event::Id busy_id_ = 0; + Event::Id uncheck_id_ = 0; + Event::Id clear_id_ = 0; }; -} // namespace px::ui +} // namespace px::modrim #endif diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp index e707adf..556cbdc 100644 --- a/src/ui/plugins/workshop/details.cpp +++ b/src/ui/plugins/workshop/details.cpp @@ -1,6 +1,6 @@ #include "ui/plugins/workshop/details.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { namespace { QString joined(const std::vector& ids) { QStringList values; @@ -10,9 +10,9 @@ QString joined(const std::vector& ids) { } } -QCoro::Task> fetch_detail( - core::workshop::WorkshopCatalog& catalog, QString item_id, QString kind) { - using namespace core::workshop; +QCoro::Task> fetch_detail( + workshop::WorkshopCatalog& catalog, QString item_id, QString kind) { + using namespace workshop; if (kind == QStringLiteral("collection")) { auto result = co_await catalog.collection(item_id); if (!result) co_return std::unexpected(result.error()); @@ -44,7 +44,7 @@ QCoro::Task> fetch_detail( co_return Detail{std::move(mod), std::move(members), warning.toStdString(), missing.isEmpty()}; } -QString incomplete_plan_notice(const core::workshop::DownloadPlan& plan) { +QString incomplete_plan_notice(const workshop::DownloadPlan& plan) { QStringList parts{QStringLiteral("The download plan is incomplete.")}; if (!plan.unavailable_ids.isEmpty()) parts.append(QStringLiteral("Unavailable: ") + plan.unavailable_ids.join(QStringLiteral(", "))); if (!plan.incomplete_collection_ids.isEmpty()) parts.append(QStringLiteral("Incomplete collections: ") + plan.incomplete_collection_ids.join(QStringLiteral(", "))); @@ -56,4 +56,4 @@ QString queued_notice(qsizetype count) { .arg(count).arg(count == 1 ? QString{} : QStringLiteral("s")); } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/details.h b/src/ui/plugins/workshop/details.h index af9fb8e..6c36f63 100644 --- a/src/ui/plugins/workshop/details.h +++ b/src/ui/plugins/workshop/details.h @@ -2,20 +2,20 @@ #include "core/workshop/catalog.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { struct Detail { - core::workshop::CatalogEntry item; - std::vector members; + workshop::CatalogEntry item; + std::vector members; std::string warning; bool complete = false; bool operator==(const Detail&) const = default; }; -QCoro::Task> fetch_detail( - core::workshop::WorkshopCatalog& catalog, QString item_id, QString kind); -QString incomplete_plan_notice(const core::workshop::DownloadPlan& plan); +QCoro::Task> fetch_detail( + workshop::WorkshopCatalog& catalog, QString item_id, QString kind); +QString incomplete_plan_notice(const workshop::DownloadPlan& plan); QString queued_notice(qsizetype count); -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp index 4216fa8..d47e966 100644 --- a/src/ui/plugins/workshop/installed.cpp +++ b/src/ui/plugins/workshop/installed.cpp @@ -5,9 +5,9 @@ #include -namespace px::ui::workshop { +namespace px::modrim::workshop { -void InstalledList::replace(std::vector mods) { +void InstalledList::replace(std::vector mods) { mods_ = std::move(mods); rematch(); } @@ -22,18 +22,18 @@ void InstalledList::reset_paging() { shown_ = PAGE; } -std::vector InstalledList::visible() const { +std::vector InstalledList::visible() const { return slice(0, std::min(shown_, matches_.size())); } -std::vector InstalledList::next_page() { +std::vector InstalledList::next_page() { const auto start = shown_; shown_ = std::min(start + PAGE, matches_.size()); return slice(start, shown_); } -std::vector InstalledList::slice(std::size_t start, std::size_t end) const { - std::vector result; +std::vector InstalledList::slice(std::size_t start, std::size_t end) const { + std::vector result; if (start >= end) return result; result.reserve(end - start); for (auto i = start; i < end; ++i) result.push_back(mods_[matches_[i]]); @@ -57,4 +57,4 @@ void InstalledList::rematch() { } } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.h b/src/ui/plugins/workshop/installed.h index 454d34c..5606ded 100644 --- a/src/ui/plugins/workshop/installed.h +++ b/src/ui/plugins/workshop/installed.h @@ -7,31 +7,31 @@ #include "core/workshop/types.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { inline constexpr int PAGE = 48; class InstalledList { public: - const std::vector& mods() const { return mods_; } + const std::vector& mods() const { return mods_; } bool loaded() const { return !mods_.empty(); } int total() const { return static_cast(matches_.size()); } bool has_more() const { return shown_ < matches_.size(); } - void replace(std::vector mods); + void replace(std::vector mods); void search(QString query); void reset_paging(); - std::vector visible() const; - std::vector next_page(); + std::vector visible() const; + std::vector next_page(); private: void rematch(); - std::vector slice(std::size_t start, std::size_t end) const; + std::vector slice(std::size_t start, std::size_t end) const; - std::vector mods_; + std::vector mods_; std::vector matches_; QString query_; std::size_t shown_ = PAGE; }; -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp index 5f964a0..5850784 100644 --- a/src/ui/plugins/workshop/models.cpp +++ b/src/ui/plugins/workshop/models.cpp @@ -12,9 +12,9 @@ #include "ui/panels/mod_info_data.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { namespace { -using namespace core::workshop; +using namespace workshop; constexpr std::array roles = { "itemId", "kind", "title", "author", "previewUrl", "collage", "sourceLabel", "versions", "tags", @@ -157,7 +157,7 @@ QVariantMap item_row(const CatalogEntry& entry, {"tags", tag_labels.join(QStringLiteral(" · "))}, {"state", state_name}, {"active", false}, {"queued", false}, {"favourite", false}, {"stateLabel", state_label}, {"actionLabel", action}, {"incompatible", incompatible}, {"memberCount", member_count}, - {"description", detail ? px::ui::description_to_html(description, format) : QString{}}, + {"description", detail ? px::modrim::description_to_html(description, format) : QString{}}, {"workshopUrl", safe_url(workshop_url)}, {"fileSize", size}, {"votes", votes}, {"votesUp", up}, {"votesDown", down}, {"summary", summarize(description, format)}, {"compatLabel", compat_label(versions, game_version, incompatible, no_common)}}; @@ -222,4 +222,4 @@ QHash CatalogListModel::roleNames() const { return names; } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.h b/src/ui/plugins/workshop/models.h index 18d6bae..5604989 100644 --- a/src/ui/plugins/workshop/models.h +++ b/src/ui/plugins/workshop/models.h @@ -8,14 +8,14 @@ #include "core/workshop/types.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { -using core::workshop::CatalogEntry; +using workshop::CatalogEntry; QString safe_url(const QString& value); QString summarize(const QString& text, const QString& format = QStringLiteral("bbcode")); QVariantMap item_row(const CatalogEntry& item, - const std::function& install_state, + const std::function& install_state, const QString& game_version, bool detail = false); class CatalogListModel : public QAbstractListModel { @@ -48,4 +48,4 @@ class CatalogListModel : public QAbstractListModel { int total_ = 0; }; -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp index ee69e5a..5091ed2 100644 --- a/src/ui/plugins/workshop/plugin.cpp +++ b/src/ui/plugins/workshop/plugin.cpp @@ -2,13 +2,13 @@ #include "ui/plugins/workshop/view.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { -CatalogSettingsSection::CatalogSettingsSection(core::workshop::WorkshopCatalog& catalog, QObject* parent) +CatalogSettingsSection::CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent) : PluginConfigSectionBase({{QStringLiteral("url"), QString::fromStdString(catalog.settings().url)}}, parent), catalog_(catalog) {} QString CatalogSettingsSection::title() const { return QStringLiteral("Workshop catalog"); } QUrl CatalogSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); } -core::Result CatalogSettingsSection::commit(const QJsonObject& merged) { +Result CatalogSettingsSection::commit(const QJsonObject& merged) { catalog_.set_base_url(merged.value(QStringLiteral("url")).toString()); return {}; } @@ -17,8 +17,8 @@ WorkshopUiPlugin::WorkshopUiPlugin() : UiPlugin(QStringLiteral("workshop_ui"), {QStringLiteral("workshop_catalog"), QStringLiteral("downloads")}) {} void WorkshopUiPlugin::setup(AppContext& ctx) { ctx.addRailView(); - auto* catalog = static_cast(ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); + auto* catalog = static_cast(ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); ctx.addSettingsSection([catalog](QObject* parent) { return new CatalogSettingsSection(*catalog, parent); }); } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.h b/src/ui/plugins/workshop/plugin.h index 3453e00..9977838 100644 --- a/src/ui/plugins/workshop/plugin.h +++ b/src/ui/plugins/workshop/plugin.h @@ -4,19 +4,19 @@ #include "ui/context.h" #include "ui/settings_section.h" -namespace px::ui::workshop { +namespace px::modrim::workshop { class CatalogSettingsSection : public PluginConfigSectionBase { public: - CatalogSettingsSection(core::workshop::WorkshopCatalog& catalog, QObject* parent); + CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent); QString title() const override; QUrl source() const override; protected: - core::Result commit(const QJsonObject& merged) override; + Result commit(const QJsonObject& merged) override; private: - core::workshop::WorkshopCatalog& catalog_; + workshop::WorkshopCatalog& catalog_; }; class WorkshopUiPlugin : public UiPlugin { @@ -25,4 +25,4 @@ class WorkshopUiPlugin : public UiPlugin { void setup(AppContext& ctx) override; }; -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index a646ad9..f4d5efb 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -14,8 +14,8 @@ #include "ui/navigation.h" #include "ui/theme/palette.h" -namespace px::ui::workshop { -using namespace core::workshop; +namespace px::modrim::workshop { +using namespace workshop; namespace { QString id_of(const CatalogEntry& item) { return std::visit([](const auto& value) { return QString::fromStdString(value.id); }, item); @@ -28,15 +28,15 @@ QSet ids_of(const std::vector& ids) { std::optional cursor_of(const std::optional& cursor) { return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; } -WorkshopCatalog& catalog_of(core::CoreContext& ctx) { +WorkshopCatalog& catalog_of(CoreContext& ctx) { return *static_cast(ctx.plugins().get(QStringLiteral("workshop_catalog"))); } } -WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : WorkshopViewPanel(ctx, engine, parent, app, catalog_of(ctx)) {} -WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, WorkshopCatalog& catalog) : BaseViewPanel(ctx, engine, parent, app), mods_model(new CatalogListModel(this)), collections_model(new CatalogListModel(this)), members_model(new CatalogListModel(this)), @@ -46,7 +46,7 @@ WorkshopViewPanel::WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, qml_ = new QQuickWidget(engine, this); qml_->setObjectName(QStringLiteral("workshopView")); qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(px::ui::paletteColor(u"ELEVATE_0")); + qml_->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); qml_->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); qml_->rootContext()->setContextProperty(QStringLiteral("catalogMods"), mods_model); qml_->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collections_model); @@ -453,4 +453,4 @@ QStringList WorkshopViewPanel::versionOptions() const { return versions; } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.h b/src/ui/plugins/workshop/view.h index d2c74d7..94afc0e 100644 --- a/src/ui/plugins/workshop/view.h +++ b/src/ui/plugins/workshop/view.h @@ -16,7 +16,7 @@ class QQuickWidget; -namespace px::ui::workshop { +namespace px::modrim::workshop { class WorkshopViewPanel : public BaseViewPanel { Q_OBJECT @@ -40,9 +40,9 @@ class WorkshopViewPanel : public BaseViewPanel { public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("workshop"), QLatin1StringView("steam"), QLatin1StringView("Workshop")}; - WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - WorkshopViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, - core::workshop::WorkshopCatalog& catalog); + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, + workshop::WorkshopCatalog& catalog); ~WorkshopViewPanel() override; void openRoute(const QStringList& path) override; Q_INVOKABLE void teardown() override; @@ -106,14 +106,14 @@ class WorkshopViewPanel : public BaseViewPanel { private: template QCoro::Task run_cached(QString key, - std::function>()> produce, + std::function>()> produce, std::function apply, bool cached = true); QCoro::Task load_pack(); QCoro::Task download(QStringList ids, QStringList collections); QCoro::Task installed_changed(); QCoro::Task url_changed(); void refresh_mod_ids(); - QVariantMap row(const core::workshop::CatalogEntry& item, bool detail = false); + QVariantMap row(const workshop::CatalogEntry& item, bool detail = false); QVariantMap detail_row(const Detail& detail); template QList rows(const std::vector& items) { refresh_mod_ids(); @@ -129,15 +129,15 @@ class WorkshopViewPanel : public BaseViewPanel { void show_installed(); void recount(); std::optional installed_cursor() const; - void queue_plan(const core::workshop::DownloadPlan& plan); + void queue_plan(const workshop::DownloadPlan& plan); void go(const QStringList& path); - core::workshop::WorkshopCatalog& catalog_; - core::DownloadManager& downloads_; - core::workshop::FavouriteCollections& favourites_; + workshop::WorkshopCatalog& catalog_; + DownloadManager& downloads_; + workshop::FavouriteCollections& favourites_; QQuickWidget* qml_ = nullptr; QString tab_ = QStringLiteral("Discover"); - core::workshop::CatalogQuery criteria_; + workshop::CatalogQuery criteria_; bool busy_ = false; bool torn_down_ = false; bool installed_dirty_ = false; @@ -151,20 +151,20 @@ class WorkshopViewPanel : public BaseViewPanel { InstalledList installed_; std::optional> current_; std::vector> trail_; - std::optional pending_; + std::optional pending_; QSet preparing_, pack_ids_, installed_ids_, active_ids_, known_tags_; QHash> collection_mods_; - QHash items_; - core::Event::Id busy_id_ = 0; - core::Event::Id queue_id_ = 0, installed_id_ = 0; - core::Event::Id url_id_ = 0; - core::Event::Id favourites_id_ = 0; - core::Event::Id active_id_ = 0; + QHash items_; + Event::Id busy_id_ = 0; + Event::Id queue_id_ = 0, installed_id_ = 0; + Event::Id url_id_ = 0; + Event::Id favourites_id_ = 0; + Event::Id active_id_ = 0; }; template QCoro::Task WorkshopViewPanel::run_cached(QString key, - std::function>()> produce, + std::function>()> produce, std::function apply, bool cached) { if (torn_down_) co_return; QPointer self(this); @@ -183,8 +183,8 @@ QCoro::Task WorkshopViewPanel::run_cached(QString key, self->busy_ = false; emit self->changed(); }; - core::workshop::CatalogResult result; - if (cached) result = co_await catalog_.cached(key, std::move(produce), show, core::workshop::kFreshSeconds, true); + workshop::CatalogResult result; + if (cached) result = co_await catalog_.cached(key, std::move(produce), show, workshop::kFreshSeconds, true); else { auto fetched = co_await produce(); if (fetched) show(*fetched); @@ -199,4 +199,4 @@ QCoro::Task WorkshopViewPanel::run_cached(QString key, co_await self->load(); } -} // namespace px::ui::workshop +} // namespace px::modrim::workshop diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp index fd673f0..3c19234 100644 --- a/src/ui/settings_section.cpp +++ b/src/ui/settings_section.cpp @@ -3,7 +3,7 @@ #include #include -namespace px::ui { +namespace px::modrim { PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, QObject* parent) : SettingsSection(parent), initial_(std::move(initial)) {} @@ -18,7 +18,7 @@ bool PluginConfigSectionBase::set(const QString& key, const QVariant& value) { return true; } -core::Result PluginConfigSectionBase::apply() { +Result PluginConfigSectionBase::apply() { if (draft_.isEmpty()) { return {}; } @@ -29,4 +29,4 @@ core::Result PluginConfigSectionBase::apply() { return commit(QJsonObject::fromVariantMap(merged)); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/settings_section.h b/src/ui/settings_section.h index 06ab748..3442b6c 100644 --- a/src/ui/settings_section.h +++ b/src/ui/settings_section.h @@ -20,7 +20,7 @@ #include "core/error.h" #include "core/plugin_config.h" -namespace px::ui { +namespace px::modrim { // A plugin's group on the Settings dialog. `source()` is loaded into a group titled // `title()` with this object as its required `section` property. Edits stay on the @@ -35,7 +35,7 @@ class SettingsSection : public QObject { virtual QString title() const = 0; virtual QUrl source() const = 0; - virtual core::Result apply() = 0; + virtual Result apply() = 0; }; // Builds a plugin's section for one dialog, parented to that dialog. @@ -55,13 +55,13 @@ class PluginConfigSectionBase : public SettingsSection { // Returns false when `key` is not a field of the settings, so QML can tell the edit was dropped. Q_INVOKABLE bool set(const QString& key, const QVariant& value); - core::Result apply() final; + Result apply() final; protected: PluginConfigSectionBase(QVariantMap initial, QObject* parent); // Commits the merged settings; an error leaves the stored config unchanged. - virtual core::Result commit(const QJsonObject& merged) = 0; + virtual Result commit(const QJsonObject& merged) = 0; private: QVariantMap initial_; @@ -72,15 +72,15 @@ class PluginConfigSectionBase : public SettingsSection { template class PluginConfigSection : public PluginConfigSectionBase { public: - PluginConfigSection(core::PluginConfig& config, QObject* parent) + PluginConfigSection(PluginConfig& config, QObject* parent) : PluginConfigSectionBase(toVariantMap(config.value()), parent), config_(config) {} protected: - core::Result commit(const QJsonObject& merged) override { + Result commit(const QJsonObject& merged) override { const std::string text = QJsonDocument(merged).toJson(QJsonDocument::Compact).toStdString(); T value{}; - if (auto error = glz::read(value, text)) { - return std::unexpected(core::Error{QString::fromStdString(glz::format_error(error, text))}); + if (auto error = glz::read(value, text)) { + return std::unexpected(Error{QString::fromStdString(glz::format_error(error, text))}); } config_.update(std::move(value)); return {}; @@ -96,7 +96,7 @@ class PluginConfigSection : public PluginConfigSectionBase { return QJsonDocument::fromJson(QByteArray::fromStdString(*text)).object().toVariantMap(); } - core::PluginConfig& config_; + PluginConfig& config_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/theme/constants.h b/src/ui/theme/constants.h index 6a5900c..c4c2ccb 100644 --- a/src/ui/theme/constants.h +++ b/src/ui/theme/constants.h @@ -2,7 +2,7 @@ #include -namespace px::ui { +namespace px::modrim { // Animation inline constexpr int ACCORDION_ANIMATION_DURATION_MS = 200; @@ -23,4 +23,4 @@ inline constexpr int SIDEBAR_WIDTH = 240; // Interaction inline constexpr int TOOLTIP_DELAY_MS = 400; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/theme/palette.cpp b/src/ui/theme/palette.cpp index ef3b05b..2893a17 100644 --- a/src/ui/theme/palette.cpp +++ b/src/ui/theme/palette.cpp @@ -8,7 +8,7 @@ #include #include -namespace px::ui { +namespace px::modrim { namespace { @@ -104,4 +104,4 @@ void applyAppTheme(QApplication& app) { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/theme/palette.h b/src/ui/theme/palette.h index 7f2fed9..5f755b2 100644 --- a/src/ui/theme/palette.h +++ b/src/ui/theme/palette.h @@ -6,7 +6,7 @@ class QApplication; -namespace px::ui { +namespace px::modrim { // Look up a color token by its uppercase name, e.g. "ELEVATE_0", "PRIMARY". // Returns an empty string if the name is unknown. @@ -27,4 +27,4 @@ QString getStylesheet(); // matching legacy pxmodrim/_app.py `_apply_theme`. void applyAppTheme(QApplication& app); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp index 7ac3593..5dd7fde 100644 --- a/src/ui/theme/qml_theme.cpp +++ b/src/ui/theme/qml_theme.cpp @@ -8,7 +8,7 @@ #include "ui/theme/constants.h" #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { namespace { @@ -88,4 +88,4 @@ int Theme::sidebarWidth() const { return SIDEBAR_WIDTH; } QStringList Theme::logoVariants() const { return logo_variants_; } QString Theme::logoFileDataUri() const { return logo_data_uri_; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/theme/qml_theme.h b/src/ui/theme/qml_theme.h index 7c09241..e5ddfe5 100644 --- a/src/ui/theme/qml_theme.h +++ b/src/ui/theme/qml_theme.h @@ -4,7 +4,7 @@ #include #include -namespace px::ui { +namespace px::modrim { // Design tokens for QML, exposed as the `Theme` root context property (see // createQmlEngine). Color values come from the same PALETTE as the stylesheet; @@ -119,4 +119,4 @@ class Theme : public QObject { QString logo_data_uri_; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/ui_prefs.h b/src/ui/ui_prefs.h index 890f6e4..a632856 100644 --- a/src/ui/ui_prefs.h +++ b/src/ui/ui_prefs.h @@ -4,16 +4,16 @@ #include "core/constants.h" -namespace px::ui { +namespace px::modrim { // Persisted UI state (ui_prefs.json). Field names are the JSON keys. struct UiPrefs { bool desc_expanded = false; - core::LaunchStrategy launch_strategy = core::LaunchStrategy::Direct; + LaunchStrategy launch_strategy = LaunchStrategy::Direct; std::string skipped_update_tag; bool rail_collapsed = false; bool operator==(const UiPrefs&) const = default; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp index daa20d2..0bc9445 100644 --- a/src/ui/views/base.cpp +++ b/src/ui/views/base.cpp @@ -5,9 +5,9 @@ #include "ui/theme/palette.h" -namespace px::ui { +namespace px::modrim { -BaseViewPanel::BaseViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +BaseViewPanel::BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : RailView(ctx, engine, parent, app) { setAutoFillBackground(true); root_ = new QVBoxLayout(this); @@ -22,4 +22,4 @@ void BaseViewPanel::setClearColor(const QString& color) { setPalette(palette); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/views/base.h b/src/ui/views/base.h index a5e855e..5153fa0 100644 --- a/src/ui/views/base.h +++ b/src/ui/views/base.h @@ -5,7 +5,7 @@ #include "ui/context.h" -namespace px::ui { +namespace px::modrim { // Whole-window view contributed to the left icon rail. Core never includes this: // views are discovered entirely within ui/. Subclasses declare `kRailMeta`. @@ -13,7 +13,7 @@ class BaseViewPanel : public RailView { Q_OBJECT public: - BaseViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); protected: void setClearColor(const QString& color); @@ -21,4 +21,4 @@ class BaseViewPanel : public RailView { QVBoxLayout* root_ = nullptr; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp index b206426..d912413 100644 --- a/src/ui/views/mods_view.cpp +++ b/src/ui/views/mods_view.cpp @@ -1,8 +1,8 @@ #include "ui/views/mods_view.h" #include "ui/theme/constants.h" #include -namespace px::ui { -ModsViewPanel::ModsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +namespace px::modrim { +ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : BaseViewPanel(ctx,engine,parent,app) { auto* content = new QWidget(this); content->setObjectName(QStringLiteral("contentArea")); auto* layout = new QHBoxLayout(content); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); @@ -16,7 +16,7 @@ ModsViewPanel::ModsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget mod_info = new ModInfoPanel(ctx,engine,app.uiPrefs(),content); mod_info->setObjectName(QStringLiteral("modInfoPanel")); mod_info->setMinimumWidth(300); layout->addWidget(mod_info,2); root_->addWidget(content,1); } -void ModsViewPanel::apply_sidebar_entry(const core::view::SidebarEntry& entry) { +void ModsViewPanel::apply_sidebar_entry(const view::SidebarEntry& entry) { mod_list->clearSelection(); mod_list->set_sidebar_filter(entry.visible_uuids); } diff --git a/src/ui/views/mods_view.h b/src/ui/views/mods_view.h index 94ef1c6..57af096 100644 --- a/src/ui/views/mods_view.h +++ b/src/ui/views/mods_view.h @@ -3,19 +3,19 @@ #include "ui/panels/sidebar_panel.h" #include "ui/panels/mod_list_panel.h" #include "ui/panels/mod_info_panel.h" -namespace px::ui { +namespace px::modrim { class ModsViewPanel : public BaseViewPanel { Q_OBJECT public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), QLatin1StringView("mods"), QLatin1StringView("Mods")}; - ModsViewPanel(core::CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - void apply_sidebar_entry(const core::view::SidebarEntry& entry); + ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + void apply_sidebar_entry(const view::SidebarEntry& entry); void apply_current_sidebar_filter(); SidebarPanel* sidebar; ModListPanel* mod_list; ModInfoPanel* mod_info; signals: - void entry_selected(const px::core::view::SidebarEntry& entry); + void entry_selected(const px::modrim::view::SidebarEntry& entry); void mod_selected(const QString& uuid); void active_mods_changed(); void order_changed(); diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp index 1f320e1..6ba436f 100644 --- a/src/ui/window/actions.cpp +++ b/src/ui/window/actions.cpp @@ -4,7 +4,7 @@ #include #include -namespace px::ui { +namespace px::modrim { QString ActionSpec::tooltip() const { return shortcut.isEmpty() ? label @@ -63,4 +63,4 @@ QList> shortcutRows() { return rows; } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/window/actions.h b/src/ui/window/actions.h index b97d606..aacb7dd 100644 --- a/src/ui/window/actions.h +++ b/src/ui/window/actions.h @@ -12,7 +12,7 @@ class QAction; class QWidget; -namespace px::ui { +namespace px::modrim { enum class ActionId { Save, Restore, Settings, Quit, Refresh, FullRescan, AutoSort, UpdateMods, @@ -35,4 +35,4 @@ const ActionSpec& actionSpec(ActionId id); ActionMap createActions(QWidget& window); QList> shortcutRows(); -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index 0b186ac..8250583 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -53,7 +53,7 @@ #include "ui/views/mods_view.h" #include "ui/window/menu_bar.h" -namespace px::ui { +namespace px::modrim { UnsavedChangesDialog::UnsavedChangesDialog(QWidget* parent) : QMessageBox(parent) { setIcon(Question); @@ -81,7 +81,7 @@ bool LaunchConfirmDialog::dontAskAgain() const { return checkBox()->isChecked(); MainWindow::MainWindow(AppContext& app, QWidget* parent) : QMainWindow(parent), app_(app), ctx_(app.coreContext()), engine_(createQmlEngine(this)), saved_active_(ctx_.loadedActiveUuids()), - updates_(core::appVersion(), ctx_.network()) { + updates_(appVersion(), ctx_.network()) { setWindowTitle(QStringLiteral("PxModRim[*]")); setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); resize(1400, 850); @@ -97,7 +97,7 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) {"update_mods", actionSpec(ActionId::UpdateMods).tooltip()}, }; header_controller_ = new HeaderController(windowFlags().testFlag(Qt::FramelessWindowHint), - static_cast(app_.uiPrefs().launch_strategy), core::appVersion(), tooltips, + static_cast(app_.uiPrefs().launch_strategy), appVersion(), tooltips, ctx_.downloads().available(), this); header_ = new HeaderPanel(*header_controller_, engine_, this); setupActions(); @@ -118,7 +118,7 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) if (windowHandle()) windowHandle()->startSystemMove(); }); connect(header_controller_, &HeaderController::strategyChanged, this, [this](int index) { - const auto strategy = static_cast(index); + const auto strategy = static_cast(index); if (app_.uiPrefs().launch_strategy != strategy) { app_.uiPrefs().launch_strategy = strategy; savePrefs(); @@ -175,7 +175,7 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) } if (mods_view_) { selection_ = std::make_unique(ctx_, *mods_view_->mod_info); - connect(mods_view_, &ModsViewPanel::entry_selected, this, [this](const core::view::SidebarEntry& entry) { + connect(mods_view_, &ModsViewPanel::entry_selected, this, [this](const view::SidebarEntry& entry) { mods_view_->apply_sidebar_entry(entry); }); connect(mods_view_, &ModsViewPanel::mod_selected, this, [this](const QString& uuid) { selectMod(uuid); }); @@ -204,7 +204,7 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) actions_.at(ActionId::UpdateMods)->setEnabled(!busy); if (busy) header_controller_->setDownloadsProgress(QStringLiteral("Updating mods…"), 0, 0); }); - download_progress_connection_ = downloads.download_progress.connect([this](const core::DownloadProgress& progress) { + download_progress_connection_ = downloads.download_progress.connect([this](const DownloadProgress& progress) { header_controller_->setDownloadsProgress( QStringLiteral("Updating mods: %1 / %2 · %3 MB").arg(progress.completed).arg(progress.total) .arg(progress.bytes_done / 1048576.0, 0, 'f', 0), progress.completed, progress.total); @@ -255,7 +255,7 @@ void MainWindow::setupActions() { bind(ActionId::PrevView, [this] { cycleView(-1); }); bind(ActionId::Fullscreen, [this] { isFullScreen() ? showNormal() : showFullScreen(); }); bind(ActionId::ReportIssue, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); - bind(ActionId::OpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(core::config_dir()).filePath(QStringLiteral("logs")))); }); + bind(ActionId::OpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(config_dir()).filePath(QStringLiteral("logs")))); }); bind(ActionId::UploadLogs, [this] { uploadReport(); }); bind(ActionId::Shortcuts, [this] { showShortcuts(); }); bind(ActionId::CheckUpdates, [this] { checkUpdates(true); }); @@ -413,7 +413,7 @@ QCoro::Task MainWindow::updateMods() { if (!result) toasts_->error(result.error().message); } -void MainWindow::onDownloadsFinished(const core::DownloadResult& result) { +void MainWindow::onDownloadsFinished(const DownloadResult& result) { const auto ok = result.succeeded.size(), failed = result.failed.size(), changed = result.changed.size(); if (!ok && !failed) { toasts_->info(QStringLiteral("Download cancelled")); return; } const QString summary = QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); @@ -501,7 +501,7 @@ QCoro::Task MainWindow::saveActiveMods() { co_return true; } -QCoro::Task MainWindow::confirmLaunch(bool core::AppConfig::* setting, const QString& title, +QCoro::Task MainWindow::confirmLaunch(bool AppConfig::* setting, const QString& title, const QString& text, const QString& info, const QString& accept) { const auto [result, dialog] = co_await awaitDialog(this, title, text, info, accept); if (result != QMessageBox::Ok) co_return false; @@ -525,13 +525,13 @@ QCoro::Task MainWindow::launchGame() { if (!guard || closing_) co_return; const int errors = ctx_.diagnosticsService().active_error_count(); const auto abort = [this] { header_controller_->setLaunchState(QStringLiteral("idle")); }; - if (running && !co_await confirmLaunch(&core::AppConfig::launch_confirm_running, + if (running && !co_await confirmLaunch(&AppConfig::launch_confirm_running, QStringLiteral("Game Already Running"), QStringLiteral("RimWorld is already running."), QStringLiteral("A second copy can overwrite the first one's saves and settings."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } - if (config.launch_confirm_errors && errors > 0 && !co_await confirmLaunch(&core::AppConfig::launch_confirm_errors, + if (config.launch_confirm_errors && errors > 0 && !co_await confirmLaunch(&AppConfig::launch_confirm_errors, QStringLiteral("Mod List Has Errors"), QStringLiteral("%1 active mod(s) have errors.").arg(errors), QStringLiteral("Missing dependencies or wrong load order can crash the game."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } - if (config.launch_confirm_unsaved && unsaved_ && !co_await confirmLaunch(&core::AppConfig::launch_confirm_unsaved, + if (config.launch_confirm_unsaved && unsaved_ && !co_await confirmLaunch(&AppConfig::launch_confirm_unsaved, QStringLiteral("Unsaved Changes"), QStringLiteral("The active mod list has unsaved changes."), QStringLiteral("The list is saved to ModsConfig.xml before the game starts."), QStringLiteral("Save and Launch"))) { abort(); co_return; } if (!co_await writeActiveLayout()) toasts_->warning(QStringLiteral("Config folder not set — mod list won't be saved")); @@ -546,13 +546,13 @@ QCoro::Task MainWindow::launchGame() { } if (!guard || closing_) co_return; header_controller_->setLaunchState(QStringLiteral("running")); - if (after == core::AfterLaunch::Close && !unsaved_) { close_confirmed_ = true; close(); co_return; } - if (after == core::AfterLaunch::Minimize) showMinimized(); + if (after == AfterLaunch::Close && !unsaved_) { close_confirmed_ = true; close(); co_return; } + if (after == AfterLaunch::Minimize) showMinimized(); const auto code = co_await launcher.wait_for_exit(); if (!guard || closing_) co_return; if (code && *code) toasts_->warning(QStringLiteral("Game exited with code %1").arg(*code)); else toasts_->info(QStringLiteral("Game closed")); - if (after == core::AfterLaunch::Minimize) { showNormal(); activateWindow(); } + if (after == AfterLaunch::Minimize) { showNormal(); activateWindow(); } abort(); } @@ -571,4 +571,4 @@ QCoro::Task MainWindow::requestMod(QString uuid) { if (!mods_view_->mod_list->select_uuid(uuid)) co_await selectMod(std::move(uuid)); } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/window/main_window.h b/src/ui/window/main_window.h index 4b257b3..72be03c 100644 --- a/src/ui/window/main_window.h +++ b/src/ui/window/main_window.h @@ -20,7 +20,7 @@ class QQmlEngine; class QSplitter; class QStackedWidget; -namespace px::ui { +namespace px::modrim { class HeaderController; class HeaderPanel; @@ -73,7 +73,7 @@ class MainWindow : public QMainWindow { void snapRail(); void savePrefs(); void onModsReloaded(); - void onDownloadsFinished(const core::DownloadResult& result); + void onDownloadsFinished(const DownloadResult& result); QCoro::Task confirmClose(); QCoro::Task reloadMods(bool full = false); QCoro::Task autoSort(); @@ -85,14 +85,14 @@ class MainWindow : public QMainWindow { QCoro::Task restoreSnapshot(); QCoro::Task checkUpdates(bool manual); QCoro::Task> writeActiveLayout(); - QCoro::Task confirmLaunch(bool core::AppConfig::* setting, const QString& title, + QCoro::Task confirmLaunch(bool AppConfig::* setting, const QString& title, const QString& text, const QString& info, const QString& accept); QCoro::Task launchGame(); QCoro::Task selectMod(QString uuid); QCoro::Task requestMod(QString uuid); AppContext& app_; - core::CoreContext& ctx_; + CoreContext& ctx_; QQmlEngine* engine_; HeaderController* header_controller_ = nullptr; HeaderPanel* header_ = nullptr; @@ -108,7 +108,7 @@ class MainWindow : public QMainWindow { QStringList saved_active_; ActionMap actions_; std::function quit_; - core::services::UpdateService updates_; + services::UpdateService updates_; bool unsaved_ = false; bool close_prompt_open_ = false; bool close_confirmed_ = false; @@ -126,4 +126,4 @@ class MainWindow : public QMainWindow { std::uint64_t download_status_connection_ = 0; }; -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp index 4cd0da9..8ddcde5 100644 --- a/src/ui/window/menu_bar.cpp +++ b/src/ui/window/menu_bar.cpp @@ -2,7 +2,7 @@ #include -namespace px::ui { +namespace px::modrim { const std::array& menuLayout() { static const std::array layout{{ @@ -25,4 +25,4 @@ MenuBar::MenuBar(const ActionMap& actions, QWidget* parent) : QMenuBar(parent) { } } -} // namespace px::ui +} // namespace px::modrim diff --git a/src/ui/window/menu_bar.h b/src/ui/window/menu_bar.h index 3b025c3..9c62363 100644 --- a/src/ui/window/menu_bar.h +++ b/src/ui/window/menu_bar.h @@ -4,7 +4,7 @@ #include "ui/window/actions.h" -namespace px::ui { +namespace px::modrim { struct MenuSpec { QString title; @@ -19,4 +19,4 @@ class MenuBar : public QMenuBar { explicit MenuBar(const ActionMap& actions, QWidget* parent = nullptr); }; -} // namespace px::ui +} // namespace px::modrim diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp index b7d583d..ea7f948 100644 --- a/tests/core/checker/test_checker.cpp +++ b/tests/core/checker/test_checker.cpp @@ -17,8 +17,8 @@ #include "core/models/metadata/structures.h" #include "core/sort/config.h" -using namespace px::core; -using namespace px::core::checker; +using namespace px::modrim; +using namespace px::modrim::checker; namespace { diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp index 81183d7..c318f52 100644 --- a/tests/core/checker/test_databases.cpp +++ b/tests/core/checker/test_databases.cpp @@ -15,8 +15,8 @@ #include "core/checker/databases.h" #include "core/config.h" -using namespace px::core; -using namespace px::core::checker; +using namespace px::modrim; +using namespace px::modrim::checker; namespace { diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp index bd0edc8..f039d76 100644 --- a/tests/core/checker/test_graph.cpp +++ b/tests/core/checker/test_graph.cpp @@ -11,8 +11,8 @@ #include "core/models/metadata/structures.h" #include "core/sort/config.h" -using namespace px::core; -using namespace px::core::checker; +using namespace px::modrim; +using namespace px::modrim::checker; namespace { diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp index fe94b7a..67a6c9f 100644 --- a/tests/core/checker/test_issues.cpp +++ b/tests/core/checker/test_issues.cpp @@ -14,8 +14,8 @@ #include "core/sort/config.h" #include "core/sort/models.h" -using namespace px::core; -using namespace px::core::checker; +using namespace px::modrim; +using namespace px::modrim::checker; namespace { diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp index 50f145c..0f2afe1 100644 --- a/tests/core/downloads/test_manager.cpp +++ b/tests/core/downloads/test_manager.cpp @@ -20,12 +20,12 @@ namespace { -using px::core::Downloader; -using px::core::DownloadManager; -using px::core::DownloadResult; -using px::core::ListedMod; -using px::core::ListedModPtr; -using px::core::Result; +using px::modrim::Downloader; +using px::modrim::DownloadManager; +using px::modrim::DownloadResult; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::Result; using test_support::run_sync; // Claims ids that start with `prefix`, and records each batch it was asked to download. diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp index a79f96c..21788d1 100644 --- a/tests/core/downloads/test_steam.cpp +++ b/tests/core/downloads/test_steam.cpp @@ -38,21 +38,21 @@ namespace { using namespace std::chrono_literals; -using px::core::ConfigService; -using px::core::DownloadItemStatus; -using px::core::DownloadManager; -using px::core::DownloadProgress; -using px::core::DownloadResult; -using px::core::ListedMod; -using px::core::ListedModPtr; -using px::core::LoginOptions; -using px::core::Result; -using px::core::SteamBatch; -using px::core::SteamDownloader; -using px::core::SteamDownloaderDeps; -using px::core::SteamSettings; -using px::core::WorkshopClient; -using px::core::WorkshopSyncState; +using px::modrim::ConfigService; +using px::modrim::DownloadItemStatus; +using px::modrim::DownloadManager; +using px::modrim::DownloadProgress; +using px::modrim::DownloadResult; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::LoginOptions; +using px::modrim::Result; +using px::modrim::SteamBatch; +using px::modrim::SteamDownloader; +using px::modrim::SteamDownloaderDeps; +using px::modrim::SteamSettings; +using px::modrim::WorkshopClient; +using px::modrim::WorkshopSyncState; using test_support::TestApplication; // Thread-safe flag with a timeout-aware wait. @@ -235,7 +235,7 @@ struct Service { QCoro::Task>> login(LoginOptions opts) { logins.push_back(opts); if (clients_.empty()) { - co_return std::unexpected(px::core::Error{QStringLiteral("logon denied")}); + co_return std::unexpected(px::modrim::Error{QStringLiteral("logon denied")}); } auto client = clients_.front(); clients_.pop_front(); @@ -590,7 +590,7 @@ TEST_CASE("success records sync time and persists", "[downloads][steam]") { REQUIRE(synced_of(svc, "111").has_value()); REQUIRE_FALSE(synced_of(svc, "222").has_value()); - auto reloaded = svc.config.load(px::core::kWorkshopSyncFile); + auto reloaded = svc.config.load(px::modrim::kWorkshopSyncFile); REQUIRE(reloaded.has_value()); std::vector keys; for (const auto& [k, _] : reloaded->synced) { @@ -607,7 +607,7 @@ TEST_CASE("stale ids never synced first then oldest", "[downloads][steam]") { } WorkshopSyncState seed; seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; - REQUIRE(svc.config.save(px::core::kWorkshopSyncFile, seed).has_value()); + REQUIRE(svc.config.save(px::modrim::kWorkshopSyncFile, seed).has_value()); run_loop_void(svc.steam->load_sync_state()); REQUIRE(svc.steam->stale_ids(3600.0, 10000.0) == QStringList{"4", "3", "1"}); diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp index 4b00a9e..0d7a5b9 100644 --- a/tests/core/models/metadata/test_parsing.cpp +++ b/tests/core/models/metadata/test_parsing.cpp @@ -8,11 +8,11 @@ #include "core/models/metadata/parsing.h" #include "core/models/metadata/structures.h" -using px::core::AboutXmlMod; -using px::core::CaseInsensitiveStr; -using px::core::createAboutModFromElement; -using px::core::createListedModFromPath; -using px::core::ListedMod; +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveStr; +using px::modrim::createAboutModFromElement; +using px::modrim::createListedModFromPath; +using px::modrim::ListedMod; namespace { diff --git a/tests/core/models/metadata/test_schemas.cpp b/tests/core/models/metadata/test_schemas.cpp index 1a0c78b..4dc0d75 100644 --- a/tests/core/models/metadata/test_schemas.cpp +++ b/tests/core/models/metadata/test_schemas.cpp @@ -6,7 +6,7 @@ #include "core/models/metadata/structures.h" -using namespace px::core; +using namespace px::modrim; TEST_CASE("CaseInsensitiveStr serializes as a lowercased JSON string") { const CaseInsensitiveStr pid(QStringLiteral("Ludeon.RimWorld")); diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp index 4df4b5c..1f77336 100644 --- a/tests/core/models/metadata/test_structures.cpp +++ b/tests/core/models/metadata/test_structures.cpp @@ -5,11 +5,11 @@ #include "core/models/metadata/structures.h" -using px::core::AboutXmlMod; -using px::core::CaseInsensitiveSet; -using px::core::CaseInsensitiveStr; -using px::core::ListedMod; -using px::core::ModsConfig; +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveSet; +using px::modrim::CaseInsensitiveStr; +using px::modrim::ListedMod; +using px::modrim::ModsConfig; TEST_CASE("CaseInsensitiveStr stores the lowercased package id") { const CaseInsensitiveStr pid(QStringLiteral("Test.Mod.PackageId")); diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp index 7f3291b..2ed4469 100644 --- a/tests/core/organizer/test_db.cpp +++ b/tests/core/organizer/test_db.cpp @@ -9,7 +9,7 @@ #include "core/organizer/models.h" #include "test_util.h" -using namespace px::core::organizer; +using namespace px::modrim::organizer; using organizer_test::expect; using organizer_test::expect_error; using organizer_test::wait; diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp index f1dd834..f61c9df 100644 --- a/tests/core/organizer/test_resolve.cpp +++ b/tests/core/organizer/test_resolve.cpp @@ -7,8 +7,8 @@ #include "core/organizer/models.h" #include "core/organizer/resolve.h" -using namespace px::core; -using namespace px::core::organizer; +using namespace px::modrim; +using namespace px::modrim::organizer; namespace { diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp index 2ce35cb..3c0eabf 100644 --- a/tests/core/organizer/test_service.cpp +++ b/tests/core/organizer/test_service.cpp @@ -14,8 +14,8 @@ #include "core/organizer/service.h" #include "test_util.h" -using namespace px::core; -using namespace px::core::organizer; +using namespace px::modrim; +using namespace px::modrim::organizer; using organizer_test::expect; using organizer_test::expect_error; using organizer_test::wait; diff --git a/tests/core/organizer/test_util.h b/tests/core/organizer/test_util.h index d499140..49ea999 100644 --- a/tests/core/organizer/test_util.h +++ b/tests/core/organizer/test_util.h @@ -18,17 +18,17 @@ T wait(QCoro::Task task) { } template -T expect(px::core::Result result) { +T expect(px::modrim::Result result) { REQUIRE(result.has_value()); return std::move(*result); } -inline void expect(const px::core::Result& result) { +inline void expect(const px::modrim::Result& result) { REQUIRE(result.has_value()); } template -void expect_error(const px::core::Result& result, const QString& fragment = {}) { +void expect_error(const px::modrim::Result& result, const QString& fragment = {}) { REQUIRE_FALSE(result.has_value()); if (!fragment.isEmpty()) { CHECK(result.error().message.contains(fragment)); diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp index ffc84ba..599b261 100644 --- a/tests/core/providers/test_factory.cpp +++ b/tests/core/providers/test_factory.cpp @@ -7,9 +7,9 @@ #include "core/providers/factory.h" #include "core/providers/local.h" -using px::core::PathConfig; -using px::core::providers::create_providers; -using px::core::providers::SteamWorkshopModProvider; +using px::modrim::PathConfig; +using px::modrim::providers::create_providers; +using px::modrim::providers::SteamWorkshopModProvider; TEST_CASE("no configured paths produce no providers") { CHECK(create_providers(PathConfig{}).empty()); diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp index 8745a65..41f96fd 100644 --- a/tests/core/providers/test_providers.cpp +++ b/tests/core/providers/test_providers.cpp @@ -12,9 +12,9 @@ #include "core/providers/core.h" #include "core/providers/local.h" -using namespace px::core::providers; -using px::core::ListedModPtr; -using px::core::Result; +using namespace px::modrim::providers; +using px::modrim::ListedModPtr; +using px::modrim::Result; namespace { @@ -72,10 +72,10 @@ class FakeCache final : public MetadataCacheStore { int getCalls = 0; QList stored; - QCoro::Task get_mods( + QCoro::Task get_mods( QList candidates, QString /*target_version*/) override { ++getCalls; - px::core::providers::CacheLookup lookup; + px::modrim::providers::CacheLookup lookup; if (serveCached) { lookup.cached.append(cachedMod); } else { @@ -182,7 +182,7 @@ TEST_CASE("cached metadata is returned without parsing") { FakeCache cache; cache.serveCached = true; - cache.cachedMod = std::make_shared(); + cache.cachedMod = std::make_shared(); REQUIRE(cache.cachedMod->set_mod_path(plain)); LocalModProvider provider(root.path(), nullptr, &cache); @@ -218,7 +218,7 @@ TEST_CASE("force_reparse bypasses the cache lookup") { FakeCache cache; cache.serveCached = true; - cache.cachedMod = std::make_shared(); + cache.cachedMod = std::make_shared(); LocalModProvider provider(root.path(), nullptr, &cache); const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"), nullptr, diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp index facc6ae..d7e7730 100644 --- a/tests/core/services/startup_impact_service/test_db.cpp +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -14,9 +14,9 @@ #include "../../test_support.h" #include "core/services/startup_impact_service/db.h" -using namespace px::core::startup_impact; +using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; -using px::core::Result; +using px::modrim::Result; using test_support::TestApplication; namespace { diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp index cce23a5..a4e8065 100644 --- a/tests/core/services/startup_impact_service/test_phases.cpp +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -7,7 +7,7 @@ #include "core/services/startup_impact_service/labels.h" #include "core/services/startup_impact_service/phases.h" -using namespace px::core::startup_impact; +using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; namespace { diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp index 23a3286..cf9d8bd 100644 --- a/tests/core/services/startup_impact_service/test_startup_impact.cpp +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -10,7 +10,7 @@ #include "core/services/startup_impact_service/parser.h" #include "../../test_support.h" -using namespace px::core::startup_impact; +using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; using test_support::write_bytes; diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp index eb84258..49d541f 100644 --- a/tests/core/services/test_activation_service.cpp +++ b/tests/core/services/test_activation_service.cpp @@ -19,7 +19,7 @@ #include "core/services/diagnostics_service.h" #include "services_support.h" -using namespace px::core; +using namespace px::modrim; namespace { diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp index 4628308..0145f14 100644 --- a/tests/core/services/test_game_launcher.cpp +++ b/tests/core/services/test_game_launcher.cpp @@ -21,22 +21,22 @@ #include "core/services/game_process.h" #include "services_support.h" -using px::core::AppConfig; -using px::core::CoreContext; -using px::core::LaunchStrategy; -using px::core::PathConfig; -using px::core::Result; -using px::core::services::build_direct_command; -using px::core::services::find_executable; -using px::core::services::GameLauncher; -using px::core::services::GameProcessMonitor; -using px::core::services::is_game_running; -using px::core::services::MonitorTiming; -using px::core::services::Platform; -using px::core::services::ProcessInfo; -using px::core::services::ProcessLister; -using px::core::services::split_arguments; -using px::core::services::steam_run_url; +using px::modrim::AppConfig; +using px::modrim::CoreContext; +using px::modrim::LaunchStrategy; +using px::modrim::PathConfig; +using px::modrim::Result; +using px::modrim::services::build_direct_command; +using px::modrim::services::find_executable; +using px::modrim::services::GameLauncher; +using px::modrim::services::GameProcessMonitor; +using px::modrim::services::is_game_running; +using px::modrim::services::MonitorTiming; +using px::modrim::services::Platform; +using px::modrim::services::ProcessInfo; +using px::modrim::services::ProcessLister; +using px::modrim::services::split_arguments; +using px::modrim::services::steam_run_url; using services_test::EventLoop; using services_test::run_async; @@ -84,7 +84,7 @@ void make_executable(const QString& path) { std::unique_ptr context_for(const QString& game_path) { static QTemporaryDir configDir; - static px::core::ConfigService configService(configDir.path()); + static px::modrim::ConfigService configService(configDir.path()); AppConfig cfg; cfg.paths.game = game_path.toStdString(); return CoreContext::create(cfg, &configService); diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp index 445b15c..3589e4e 100644 --- a/tests/core/services/test_metadata_cache.cpp +++ b/tests/core/services/test_metadata_cache.cpp @@ -14,9 +14,9 @@ #include "core/services/metadata_cache.h" #include "services_support.h" -using namespace px::core; -using namespace px::core::providers; -using namespace px::core::services; +using namespace px::modrim; +using namespace px::modrim::providers; +using namespace px::modrim::services; using services_test::EventLoop; using services_test::run_async; using services_test::write_text; diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp index 14d7564..6302ef1 100644 --- a/tests/core/services/test_sort_service.cpp +++ b/tests/core/services/test_sort_service.cpp @@ -9,13 +9,13 @@ #include "core/services/sort_service.h" -using px::core::AboutXmlMod; -using px::core::CaseInsensitiveStr; -using px::core::DependencyMod; -using px::core::ListedMod; -using px::core::ListedModPtr; -using px::core::SortService; -using px::core::sort::PackageId; +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveStr; +using px::modrim::DependencyMod; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::SortService; +using px::modrim::sort::PackageId; namespace { diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index 5adae00..4408398 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -13,8 +13,8 @@ #include "core/services/update_service.h" #include "services_support.h" -using namespace px::core; -using namespace px::core::services; +using namespace px::modrim; +using namespace px::modrim::services; using services_test::EventLoop; using services_test::run_async; diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp index 8df3017..cf3f847 100644 --- a/tests/core/sort/test_community.cpp +++ b/tests/core/sort/test_community.cpp @@ -6,8 +6,8 @@ #include "core/sort/community.h" #include "../test_support.h" -using px::core::sort::load_community_rules; -using px::core::sort::PackageId; +using px::modrim::sort::load_community_rules; +using px::modrim::sort::PackageId; TEST_CASE("a missing community rules file means no rules") { QTemporaryDir dir; diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp index e3af9a8..a34a154 100644 --- a/tests/core/sort/test_tier_order.cpp +++ b/tests/core/sort/test_tier_order.cpp @@ -13,15 +13,15 @@ #include "core/models/metadata/structures.h" #include "core/sort/config.h" -using px::core::AboutXmlMod; -using px::core::CaseInsensitiveSet; -using px::core::CaseInsensitiveStr; -using px::core::checker::ConstraintGraph; -using px::core::checker::topological_sort; -using px::core::sort::PackageId; -using px::core::sort::SortSettings; -using px::core::sort::Tier; -using px::core::sort::TierConfig; +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveSet; +using px::modrim::CaseInsensitiveStr; +using px::modrim::checker::ConstraintGraph; +using px::modrim::checker::topological_sort; +using px::modrim::sort::PackageId; +using px::modrim::sort::SortSettings; +using px::modrim::sort::Tier; +using px::modrim::sort::TierConfig; namespace { diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp index 79c026c..d3f4d12 100644 --- a/tests/core/sort/test_tiers.cpp +++ b/tests/core/sort/test_tiers.cpp @@ -6,14 +6,14 @@ #include "core/sort/tiers.h" -using px::core::sort::assign_tiers; -using px::core::sort::find_cycle; -using px::core::sort::get_deps_recursive; -using px::core::sort::get_reverse_deps_recursive; -using px::core::sort::PackageGraph; -using px::core::sort::PackageId; -using px::core::sort::Tier; -using px::core::sort::TierConfig; +using px::modrim::sort::assign_tiers; +using px::modrim::sort::find_cycle; +using px::modrim::sort::get_deps_recursive; +using px::modrim::sort::get_reverse_deps_recursive; +using px::modrim::sort::PackageGraph; +using px::modrim::sort::PackageId; +using px::modrim::sort::Tier; +using px::modrim::sort::TierConfig; namespace { diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp index 27184b6..c91af8d 100644 --- a/tests/core/support/test_report.cpp +++ b/tests/core/support/test_report.cpp @@ -18,9 +18,9 @@ #include "core/support/report.h" #include "core/version.h" -using namespace px::core; -using namespace px::core::support; -using px::core::Result; +using namespace px::modrim; +using namespace px::modrim::support; +using px::modrim::Result; using test_support::TestApplication; using test_support::write_bytes; diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index 768310e..607c1c8 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -17,7 +17,7 @@ namespace { -using namespace px::core; +using namespace px::modrim; using test_support::read_bytes; using test_support::run_sync; using test_support::write_bytes; diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp index 538a5a1..84a3fa1 100644 --- a/tests/core/test_context.cpp +++ b/tests/core/test_context.cpp @@ -16,12 +16,12 @@ #include "core/models/metadata/structures.h" #include "services/services_support.h" -using px::core::AboutXmlMod; -using px::core::AppConfig; -using px::core::CaseInsensitiveStr; -using px::core::CoreContext; -using px::core::DependencyMod; -using px::core::ListedModPtr; +using px::modrim::AboutXmlMod; +using px::modrim::AppConfig; +using px::modrim::CaseInsensitiveStr; +using px::modrim::CoreContext; +using px::modrim::DependencyMod; +using px::modrim::ListedModPtr; using services_test::EventLoop; using services_test::run_async; @@ -38,7 +38,7 @@ std::shared_ptr about(const QString& package_id, bool valid = true) std::unique_ptr make_context(const QString& game_path = {}) { static QTemporaryDir configDir; - static px::core::ConfigService configService(configDir.path()); + static px::modrim::ConfigService configService(configDir.path()); AppConfig cfg; cfg.paths.game = game_path.toStdString(); return CoreContext::create(cfg, &configService); diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp index b3ad7bf..a4887ef 100644 --- a/tests/core/test_loading.cpp +++ b/tests/core/test_loading.cpp @@ -21,14 +21,14 @@ struct Emission { } // namespace TEST_CASE("loading state reports the innermost frame and pops back to its parent") { - px::core::LoadingState state; + px::modrim::LoadingState state; QList emissions; int finished = 0; - QObject::connect(&state, &px::core::LoadingState::changed, + QObject::connect(&state, &px::modrim::LoadingState::changed, [&](const QString& status, int pct, int current, int total) { emissions.append(Emission{status, pct, current, total}); }); - QObject::connect(&state, &px::core::LoadingState::finished, [&] { ++finished; }); + QObject::connect(&state, &px::modrim::LoadingState::finished, [&] { ++finished; }); { auto outer = state.task(QStringLiteral("outer"), 4); @@ -49,9 +49,9 @@ TEST_CASE("loading state reports the innermost frame and pops back to its parent } TEST_CASE("percent truncates the float quotient, as the legacy progress did") { - px::core::LoadingState state; + px::modrim::LoadingState state; QList emissions; - QObject::connect(&state, &px::core::LoadingState::changed, + QObject::connect(&state, &px::modrim::LoadingState::changed, [&](const QString& status, int pct, int current, int total) { emissions.append(Emission{status, pct, current, total}); }); @@ -61,9 +61,9 @@ TEST_CASE("percent truncates the float quotient, as the legacy progress did") { } TEST_CASE("set_total clamps to at least one step and progress stays within bounds") { - px::core::LoadingState state; + px::modrim::LoadingState state; QList emissions; - QObject::connect(&state, &px::core::LoadingState::changed, + QObject::connect(&state, &px::modrim::LoadingState::changed, [&](const QString& status, int pct, int current, int total) { emissions.append(Emission{status, pct, current, total}); }); @@ -77,9 +77,9 @@ TEST_CASE("set_total clamps to at least one step and progress stays within bound } TEST_CASE("popping an empty stack still reports completion") { - px::core::LoadingState state; + px::modrim::LoadingState state; int finished = 0; - QObject::connect(&state, &px::core::LoadingState::finished, [&] { ++finished; }); + QObject::connect(&state, &px::modrim::LoadingState::finished, [&] { ++finished; }); state.pop(); REQUIRE(finished == 1); } diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp index 555ae9e..7d9d190 100644 --- a/tests/core/test_migrator.cpp +++ b/tests/core/test_migrator.cpp @@ -16,9 +16,9 @@ namespace { -using px::core::ensure_schema; -using px::core::MigrationSteps; -using px::core::Result; +using px::modrim::ensure_schema; +using px::modrim::MigrationSteps; +using px::modrim::Result; using test_support::read_bytes; // Owns one named SQLite connection and closes it when it goes out of scope. diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp index 74a49b7..33c7598 100644 --- a/tests/core/test_mods_config.cpp +++ b/tests/core/test_mods_config.cpp @@ -15,7 +15,7 @@ namespace { -using namespace px::core; +using namespace px::modrim; using test_support::read_bytes; using test_support::write_bytes; diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp index ab67d4f..06c21ec 100644 --- a/tests/core/test_plugin.cpp +++ b/tests/core/test_plugin.cpp @@ -18,8 +18,8 @@ struct TestContext { QStringList log; }; -using Plugin = px::core::BasicPlugin; -using PluginRegistry = px::core::BasicPluginRegistry; +using Plugin = px::modrim::BasicPlugin; +using PluginRegistry = px::modrim::BasicPluginRegistry; using test_support::run_sync; class RecorderPlugin : public Plugin { diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp index b23f109..6aa38ac 100644 --- a/tests/core/test_plugin_config.cpp +++ b/tests/core/test_plugin_config.cpp @@ -13,7 +13,7 @@ namespace { -using namespace px::core; +using namespace px::modrim; using test_support::read_bytes; using test_support::write_bytes; diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp index 45ff75a..440d4e6 100644 --- a/tests/core/test_utils.cpp +++ b/tests/core/test_utils.cpp @@ -23,7 +23,7 @@ TEST_CASE("findAboutXml locates About/About.xml") { REQUIRE(mod.mkpath(QStringLiteral("About"))); writeFile(mod.filePath(QStringLiteral("About/About.xml")), ""); - const auto found = px::core::findAboutXml(root.path()); + const auto found = px::modrim::findAboutXml(root.path()); REQUIRE(found.has_value()); CHECK(found->endsWith(QStringLiteral("About/About.xml"))); } @@ -35,7 +35,7 @@ TEST_CASE("findAboutXml matches the About folder and file name case-insensitivel REQUIRE(mod.mkpath(QStringLiteral("ABOUT"))); writeFile(mod.filePath(QStringLiteral("ABOUT/about.XML")), ""); - const auto found = px::core::findAboutXml(root.path()); + const auto found = px::modrim::findAboutXml(root.path()); REQUIRE(found.has_value()); CHECK(found->endsWith(QStringLiteral("ABOUT/about.XML"))); } @@ -45,19 +45,19 @@ TEST_CASE("findAboutXml returns nullopt when About has no About.xml") { REQUIRE(root.isValid()); REQUIRE(QDir(root.path()).mkpath(QStringLiteral("About"))); - CHECK_FALSE(px::core::findAboutXml(root.path()).has_value()); + CHECK_FALSE(px::modrim::findAboutXml(root.path()).has_value()); } TEST_CASE("findAboutXml returns nullopt for a missing directory") { QTemporaryDir root; REQUIRE(root.isValid()); - CHECK_FALSE(px::core::findAboutXml(root.filePath(QStringLiteral("nonexistent"))).has_value()); + CHECK_FALSE(px::modrim::findAboutXml(root.filePath(QStringLiteral("nonexistent"))).has_value()); } TEST_CASE("isAsciiDigits accepts only non-empty ASCII digit strings") { - CHECK(px::core::isAsciiDigits(QStringLiteral("123"))); - CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral(""))); - CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral("12a"))); - CHECK_FALSE(px::core::isAsciiDigits(QStringLiteral(" 12"))); + CHECK(px::modrim::isAsciiDigits(QStringLiteral("123"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(""))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral("12a"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(" 12"))); } diff --git a/tests/core/test_version.cpp b/tests/core/test_version.cpp index 3b9f9b9..7f9cd90 100644 --- a/tests/core/test_version.cpp +++ b/tests/core/test_version.cpp @@ -11,11 +11,11 @@ TEST_CASE("coroutine resumes on the Qt event loop") { QString result; auto run = [&]() -> QCoro::Task<> { - result = co_await px::core::delayedVersion(5); + result = co_await px::modrim::delayedVersion(5); app.quit(); }; run(); app.exec(); - REQUIRE(result == px::core::appVersion()); + REQUIRE(result == px::modrim::appVersion()); } diff --git a/tests/core/test_xml.cpp b/tests/core/test_xml.cpp index 9d46c97..e376ebb 100644 --- a/tests/core/test_xml.cpp +++ b/tests/core/test_xml.cpp @@ -6,7 +6,7 @@ #include "core/xml.h" -using namespace px::core; +using namespace px::modrim; namespace { diff --git a/tests/core/workshop/support.h b/tests/core/workshop/support.h index f8783ae..24d8060 100644 --- a/tests/core/workshop/support.h +++ b/tests/core/workshop/support.h @@ -83,22 +83,22 @@ inline constexpr std::string_view kCollectionJson = R"JSON({ "no_common_version": false })JSON"; -inline px::core::workshop::CatalogMod decode_mod(std::string_view json) { - px::core::workshop::CatalogMod mod; - REQUIRE_FALSE(glz::read(mod, std::string(json))); +inline px::modrim::workshop::CatalogMod decode_mod(std::string_view json) { + px::modrim::workshop::CatalogMod mod; + REQUIRE_FALSE(glz::read(mod, std::string(json))); return mod; } -inline px::core::workshop::CatalogCollection decode_collection(std::string_view json) { - px::core::workshop::CatalogCollection collection; - REQUIRE_FALSE(glz::read(collection, std::string(json))); +inline px::modrim::workshop::CatalogCollection decode_collection(std::string_view json) { + px::modrim::workshop::CatalogCollection collection; + REQUIRE_FALSE(glz::read(collection, std::string(json))); return collection; } // The wire form of any catalog type, for building responses. template std::string wire(const T& value) { - auto text = glz::write(value); + auto text = glz::write(value); REQUIRE(text.has_value()); return *text; } diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index aa30adb..4d7117a 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -30,8 +30,8 @@ #include "core/workshop/types.h" #include "support.h" -using namespace px::core; -using namespace px::core::workshop; +using namespace px::modrim; +using namespace px::modrim::workshop; using test_support::TestApplication; using test_support::write_bytes; using workshop_test::FakeCatalog; diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp index a51e37c..d4aa1a4 100644 --- a/tests/core/workshop/test_client.cpp +++ b/tests/core/workshop/test_client.cpp @@ -13,7 +13,7 @@ #include "core/workshop/types.h" #include "support.h" -using namespace px::core::workshop; +using namespace px::modrim::workshop; using test_support::TestApplication; using workshop_test::FakeCatalog; using workshop_test::HttpRequest; diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp index 23f3f91..56397c8 100644 --- a/tests/core/workshop/test_favourites.cpp +++ b/tests/core/workshop/test_favourites.cpp @@ -10,8 +10,8 @@ #include "core/workshop/types.h" #include "support.h" -using namespace px::core; -using namespace px::core::workshop; +using namespace px::modrim; +using namespace px::modrim::workshop; using workshop_test::decode_collection; namespace { diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp index 5ee1a23..fdf17d1 100644 --- a/tests/core/workshop/test_types.cpp +++ b/tests/core/workshop/test_types.cpp @@ -7,7 +7,7 @@ #include "core/workshop/types.h" #include "support.h" -using namespace px::core::workshop; +using namespace px::modrim::workshop; using workshop_test::decode_collection; using workshop_test::decode_mod; using workshop_test::wire; diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp index 2f9bb89..77c7d96 100644 --- a/tests/ui/test_dialog_chrome.cpp +++ b/tests/ui/test_dialog_chrome.cpp @@ -13,7 +13,7 @@ namespace { QApplication& application() { return *static_cast(QApplication::instance()); } void clickClose(QDialog& dialog) { - px::ui::installDialogChrome(&dialog); + px::modrim::installDialogChrome(&dialog); auto* button = dialog.findChild(QStringLiteral("dialogCloseBtn")); REQUIRE(button != nullptr); button->click(); diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp index c04bcc9..ecb9b07 100644 --- a/tests/ui/test_downloads_model.cpp +++ b/tests/ui/test_downloads_model.cpp @@ -5,19 +5,19 @@ #include "ui/plugins/downloads/model.h" namespace { -QHash states(const px::ui::DownloadsModel& model) { +QHash states(const px::modrim::DownloadsModel& model) { QHash result; for (int row = 0; row < model.rowCount(); ++row) { const auto index = model.index(row); - result.insert(model.data(index, px::ui::DownloadsModel::IdRole).toString(), - model.data(index, px::ui::DownloadsModel::StateRole).toString()); + result.insert(model.data(index, px::modrim::DownloadsModel::IdRole).toString(), + model.data(index, px::modrim::DownloadsModel::StateRole).toString()); } return result; } } TEST_CASE("download item events resolve states and cancel leftover rows") { - px::ui::DownloadsModel model; + px::modrim::DownloadsModel model; model.begin({"1", "2", "3", "4", "5"}, {{"1", "One"}}); model.apply({"1", "downloading", 0, 0}); model.apply({"2", "downloading", 5, 10}); @@ -37,13 +37,13 @@ TEST_CASE("download item events resolve states and cancel leftover rows") { } TEST_CASE("download filters limit visible rows and keep fallback titles") { - px::ui::DownloadsModel model; + px::modrim::DownloadsModel model; model.begin({"1", "2"}, {{"1", "One"}}); model.apply({"2", "error", 0, 0, "x"}); model.flush(); model.setFilter(QStringLiteral("failed")); REQUIRE(model.rowCount() == 1); - CHECK(model.data(model.index(0), px::ui::DownloadsModel::TitleRole).toString() == "2"); + CHECK(model.data(model.index(0), px::modrim::DownloadsModel::TitleRole).toString() == "2"); model.setFilter(QStringLiteral("all")); CHECK(model.rowCount() == 2); } diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index 6afce3a..6727d0c 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -30,55 +30,55 @@ void ensureApplication() { if (!QApplication::instance()) app = std::make_unique(argc, argv); } -class FirstView : public px::ui::RailView { +class FirstView : public px::modrim::RailView { public: using RailView::RailView; - static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("first"), QLatin1StringView("mods"), QLatin1StringView("First")}; + static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("first"), QLatin1StringView("mods"), QLatin1StringView("First")}; }; -class SecondView : public px::ui::RailView { +class SecondView : public px::modrim::RailView { public: using RailView::RailView; - static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("second"), QLatin1StringView("mods"), QLatin1StringView("Second")}; + static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("second"), QLatin1StringView("mods"), QLatin1StringView("Second")}; void openRoute(const QStringList& path) override { opened = path; } QStringList opened; }; -class ThirdView : public px::ui::RailView { +class ThirdView : public px::modrim::RailView { public: using RailView::RailView; - static constexpr px::ui::RailViewMeta kRailMeta{QLatin1StringView("third"), QLatin1StringView("mods"), QLatin1StringView("Third")}; + static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("third"), QLatin1StringView("mods"), QLatin1StringView("Third")}; }; struct Fixture { QTemporaryDir directory; - px::core::ConfigService configService{directory.path()}; - std::unique_ptr core; - std::unique_ptr app; + px::modrim::ConfigService configService{directory.path()}; + std::unique_ptr core; + std::unique_ptr app; explicit Fixture(bool canSave = true) { ensureApplication(); - px::core::AppConfig config; + px::modrim::AppConfig config; if (canSave) { config.paths.config_folder = QDir(directory.path()).filePath(QStringLiteral("game-config")).toStdString(); QDir().mkpath(QString::fromStdString(config.paths.config_folder)); } - core = px::core::CoreContext::create(config, &configService); - core->registerPlugin(std::make_unique()); - auto a = std::make_shared(); - a->package_id = px::core::CaseInsensitiveStr(u"test.a"); - auto b = std::make_shared(); - b->package_id = px::core::CaseInsensitiveStr(u"test.b"); + core = px::modrim::CoreContext::create(config, &configService); + core->registerPlugin(std::make_unique()); + auto a = std::make_shared(); + a->package_id = px::modrim::CaseInsensitiveStr(u"test.a"); + auto b = std::make_shared(); + b->package_id = px::modrim::CaseInsensitiveStr(u"test.b"); core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, {QStringLiteral("a"), QStringLiteral("b")}); - app = std::make_unique(*core); + app = std::make_unique(*core); app->addRailView(); app->addRailView(); app->addRailView(); } }; -QStackedWidget* stackOf(px::ui::MainWindow& window) { +QStackedWidget* stackOf(px::modrim::MainWindow& window) { return window.findChild(QStringLiteral("viewStack")); } -QMessageBox* closePrompt(px::ui::MainWindow& window) { +QMessageBox* closePrompt(px::modrim::MainWindow& window) { for (auto* dialog : window.findChildren()) if (dialog->windowTitle() == QStringLiteral("Unsaved Changes") && dialog->isVisible()) return dialog; return nullptr; @@ -87,7 +87,7 @@ QMessageBox* closePrompt(px::ui::MainWindow& window) { TEST_CASE("View shortcuts wrap and ignore invalid indexes", "[ui][window]") { Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); auto* stack = stackOf(window); REQUIRE(stack != nullptr); window.cycleView(-1); @@ -101,7 +101,7 @@ TEST_CASE("View shortcuts wrap and ignore invalid indexes", "[ui][window]") { TEST_CASE("Routes select their view before handing it the path", "[ui][window]") { Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); auto* stack = stackOf(window); REQUIRE(stack != nullptr); window.openRoute(QStringLiteral("modrim://second/mod/42")); @@ -114,7 +114,7 @@ TEST_CASE("Routes select their view before handing it the path", "[ui][window]") TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); window.selectView(1); window.openRoute(QStringLiteral("modrim://settings")); CHECK(stackOf(window)->currentIndex() == 1); @@ -127,7 +127,7 @@ TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui][window]") { Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); CHECK_FALSE(window.hasUnsavedChanges()); fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); CHECK(window.hasUnsavedChanges()); @@ -141,7 +141,7 @@ TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui] TEST_CASE("Successful saves clear the unsaved state", "[ui][window]") { Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); REQUIRE(window.hasUnsavedChanges()); CHECK(QCoro::waitFor(window.saveActiveMods())); @@ -152,7 +152,7 @@ TEST_CASE("Successful saves clear the unsaved state", "[ui][window]") { TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", "[ui][window]") { const auto choice = GENERATE(QMessageBox::Save, QMessageBox::Discard, QMessageBox::Cancel); Fixture fixture; - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); fixture.core->setActive({QStringLiteral("a")}); int closed = 0; QEventLoop loop; @@ -175,7 +175,7 @@ TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", "[ui][w TEST_CASE("A failed close-time save leaves the window open", "[ui][window]") { Fixture fixture(false); - px::ui::MainWindow window(*fixture.app); + px::modrim::MainWindow window(*fixture.app); fixture.core->setActive({QStringLiteral("a")}); bool closed = false; window.setAppQuitCallback([&] { closed = true; }); diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp index 337f910..7526036 100644 --- a/tests/ui/test_menu_bar.cpp +++ b/tests/ui/test_menu_bar.cpp @@ -23,22 +23,22 @@ void ensureApplication() { } TEST_CASE("Every window action belongs to exactly one menu", "[ui][window]") { - std::multiset placed; - for (const auto& menu : px::ui::menuLayout()) + std::multiset placed; + for (const auto& menu : px::modrim::menuLayout()) for (const auto id : menu.actions) if (id) placed.insert(*id); - REQUIRE(placed.size() == px::ui::actionSpecs().size()); - for (const auto& spec : px::ui::actionSpecs()) CHECK(placed.count(spec.id) == 1); + REQUIRE(placed.size() == px::modrim::actionSpecs().size()); + for (const auto& spec : px::modrim::actionSpecs()) CHECK(placed.count(spec.id) == 1); } TEST_CASE("Window shortcuts are unique and view switching is collapsed", "[ui][window]") { ensureApplication(); std::set shortcuts; - for (const auto& spec : px::ui::actionSpecs()) + for (const auto& spec : px::modrim::actionSpecs()) if (!spec.shortcut.isEmpty()) CHECK(shortcuts.insert(spec.shortcut).second); std::set> unique; int viewRows = 0; bool save = false, refresh = false; - for (const auto& row : px::ui::shortcutRows()) { + for (const auto& row : px::modrim::shortcutRows()) { CHECK(unique.emplace(row.first, row.second).second); if (row.second == QStringLiteral("Switch to view")) ++viewRows; save |= row.second == QStringLiteral("Save mod list"); @@ -52,15 +52,15 @@ TEST_CASE("Window shortcuts are unique and view switching is collapsed", "[ui][w TEST_CASE("Hidden menu actions retain their window shortcuts", "[ui][window]") { ensureApplication(); QMainWindow window; - const auto actions = px::ui::createActions(window); - px::ui::MenuBar menu(actions, &window); + const auto actions = px::modrim::createActions(window); + px::modrim::MenuBar menu(actions, &window); menu.hide(); bool triggered = false; - QObject::connect(actions.at(px::ui::ActionId::Save), &QAction::triggered, &window, [&] { triggered = true; }); + QObject::connect(actions.at(px::modrim::ActionId::Save), &QAction::triggered, &window, [&] { triggered = true; }); QMenu* file = nullptr; for (auto* item : menu.findChildren()) if (item->title() == QStringLiteral("&File")) file = item; REQUIRE(file != nullptr); - auto* save = actions.at(px::ui::ActionId::Save); + auto* save = actions.at(px::modrim::ActionId::Save); CHECK(file->actions().contains(save)); CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); CHECK(window.actions().contains(save)); diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp index 1ab6759..8e3e0c6 100644 --- a/tests/ui/test_mod_info_data.cpp +++ b/tests/ui/test_mod_info_data.cpp @@ -12,31 +12,31 @@ #include "core/downloads/manager.h" #include "ui/panels/mod_info_data.h" -namespace px::ui { +namespace px::modrim { namespace { -std::shared_ptr make_mod(const QString& name, const QString& pid) { - auto mod = std::make_shared(); +std::shared_ptr make_mod(const QString& name, const QString& pid) { + auto mod = std::make_shared(); mod->name = name; - mod->package_id = core::CaseInsensitiveStr(pid); + mod->package_id = CaseInsensitiveStr(pid); mod->description.clear(); return mod; } -void add_dependency(core::AboutXmlMod& mod, const QString& pid, +void add_dependency(AboutXmlMod& mod, const QString& pid, const QStringList& alternatives = {}) { - core::DependencyMod dependency; + DependencyMod dependency; dependency.name = pid; - dependency.package_id = core::CaseInsensitiveStr(pid); - dependency.alternative_package_ids = core::CaseInsensitiveSet(alternatives); + dependency.package_id = CaseInsensitiveStr(pid); + dependency.alternative_package_ids = CaseInsensitiveSet(alternatives); mod.about_rules.dependencies.append(std::move(dependency)); } struct ModInfoContext { QTemporaryDir game; - std::unique_ptr ctx; + std::unique_ptr ctx; - ModInfoContext(const QList& mods, QStringList active) { + ModInfoContext(const QList& mods, QStringList active) { if (!QCoreApplication::instance()) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); static int argc = 1; @@ -49,12 +49,12 @@ struct ModInfoContext { REQUIRE(version.open(QIODevice::WriteOnly)); REQUIRE(version.write("1.5.4297") == 8); version.close(); - core::AppConfig config; + AppConfig config; config.paths.game = game.path().toStdString(); ctx = px::ui_test::makeCore(config); // CoreContext registers the real, empty DownloadManager during construction. REQUIRE_FALSE(ctx->downloads().available()); - QHash indexed; + QHash indexed; for (const auto& mod : mods) indexed.insert(mod->uuid(), mod); ctx->load(std::move(indexed), std::move(active)); } @@ -184,7 +184,7 @@ TEST_CASE("Mod info conflicts include only active mods sorted by name", "[ui][mo auto alpha = make_mod("Alpha", "x.a"); auto off = make_mod("Off", "x.off"); auto main = make_mod("Main", "x.main"); - main->about_rules.incompatible_with = core::CaseInsensitiveSet( + main->about_rules.incompatible_with = CaseInsensitiveSet( QStringList{"X.B", "x.a", "x.off", "x.no"}); ModInfoContext context({main, alpha, beta, off}, {alpha->uuid(), beta->uuid()}); CHECK(build_mod_info(*context.ctx, *main, {}).value("conflicts").toStringList() == @@ -214,7 +214,7 @@ TEST_CASE("Mod info builds active mod status issues dependencies and description main->authors = {"Ann"}; auto other = make_mod("Other", "o.other"); ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); - const core::view::ModIssueView issue{"c", "Cat", std::nullopt, false}; + const view::ModIssueView issue{"c", "Cat", std::nullopt, false}; const auto info = build_mod_info(*context.ctx, *main, {issue}); CHECK(info.value("name").toString() == "Main"); CHECK(info.value("author").toString() == "Ann"); @@ -268,4 +268,4 @@ TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", "[ } } -} // namespace px::ui +} // namespace px::modrim diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp index 6fdfab7..d4afdde 100644 --- a/tests/ui/test_mod_list_model.cpp +++ b/tests/ui/test_mod_list_model.cpp @@ -4,16 +4,16 @@ #include "ui/models/mod_list_model.h" #include "ui/models/impact.h" #include "ui/theme/palette.h" -using namespace px::ui; +using namespace px::modrim; namespace { struct ListFixture { - QHash mods; + QHash mods; ModListModel model{{{QStringLiteral("stub"),QStringLiteral("#abcdef")}}}; ListFixture() { for (int i = 0; i < 5; ++i) { - auto mod = std::make_shared(); + auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(i); - mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); mod->provider_id = QStringLiteral("stub"); mods.insert(QStringLiteral("uuid-%1").arg(i),mod); } diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp index b3a51bf..b1d77eb 100644 --- a/tests/ui/test_mod_list_panel.cpp +++ b/tests/ui/test_mod_list_panel.cpp @@ -19,33 +19,33 @@ #include "ui/panels/mod_list_panel.h" #include "ui/theme/qml_theme.h" #include "../core/services/services_support.h" -using px::ui::ModListPanel; +using px::modrim::ModListPanel; namespace { struct PanelFixture { - std::unique_ptr ctx; + std::unique_ptr ctx; QWidget parent; QQmlEngine* engine; std::unique_ptr panel; PanelFixture(bool active = true, bool dependencies = false) { ctx = px::ui_test::makeCore(); - QHash mods; + QHash mods; for (int i = 0; i < 3; ++i) { const auto letter = QChar('a'+i); - auto mod = std::make_shared(); + auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(QChar('A'+i)); - mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); + mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); mod->provider_id = QStringLiteral("stub"); if (dependencies && i > 0) { - px::core::DependencyMod dependency; - dependency.package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); + px::modrim::DependencyMod dependency; + dependency.package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); mod->about_rules.dependencies.append(dependency); } mods.insert(QStringLiteral("uuid-%1").arg(letter),mod); } ctx->load(mods,active ? QStringList{"uuid-a","uuid-b","uuid-c"} : QStringList{}); ctx->diagnosticsService().rebuild(); - engine = px::ui::createQmlEngine(&parent); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"),new px::ui::Theme(engine)); + engine = px::modrim::createQmlEngine(&parent); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"),new px::modrim::Theme(engine)); panel = std::make_unique(*ctx,engine,&parent); panel->load_mods(mods,ctx->activeUuids()); } diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp index 881e3dc..95d6e08 100644 --- a/tests/ui/test_mod_list_proxy_model.cpp +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -2,17 +2,17 @@ #include #include #include "ui/models/mod_list_proxy_model.h" -using namespace px::ui; +using namespace px::modrim; namespace { struct ProxyFixture { ModListModel source; ModListProxyModel proxy{&source}; ProxyFixture() { - QHash mods; + QHash mods; for (int i = 0; i < 5; ++i) { - auto mod = std::make_shared(); + auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(i); - mod->package_id = px::core::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); mods.insert(QStringLiteral("uuid-%1").arg(i),mod); } source.load_mods(mods,{"uuid-0","uuid-2"}); @@ -49,12 +49,12 @@ TEST_CASE("mod proxy search normalizes case whitespace package IDs and Unicode") f.proxy.set_search_filter("MOD 2"); CHECK(f.source_rows() == QList{1}); f.proxy.set_search_filter("zzzz"); CHECK(f.proxy.rowCount() == 0); f.proxy.set_search_filter(" \t \n "); CHECK(f.proxy.rowCount() == 5); - auto unicode = std::make_shared(); + auto unicode = std::make_shared(); unicode->name = QStringLiteral("Große Mod"); - unicode->package_id = px::core::CaseInsensitiveStr(QStringLiteral("grosse.mod")); - auto padded = std::make_shared(); + unicode->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); + auto padded = std::make_shared(); padded->name = QStringLiteral(" Padded Mod "); - padded->package_id = px::core::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); + padded->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); f.source.load_mods({{"u",unicode},{"p",padded}},{"u","p"}); f.proxy.set_search_filter(QStringLiteral(" GROßE ")); CHECK(f.source_rows() == QList{0}); f.proxy.set_search_filter(" padded.pkg "); CHECK(f.source_rows() == QList{1}); @@ -106,9 +106,9 @@ TEST_CASE("mod proxy clamped move stays inside the unfiltered active block") { } TEST_CASE("mod proxy emits data changes only after move completion") { ProxyFixture f; - f.source.load_mods({{"u0",std::make_shared()}, - {"u1",std::make_shared()}, - {"u2",std::make_shared()}},{"u0","u1","u2"}); + f.source.load_mods({{"u0",std::make_shared()}, + {"u1",std::make_shared()}, + {"u2",std::make_shared()}},{"u0","u1","u2"}); QStringList events; QObject::connect(&f.proxy,&QAbstractItemModel::rowsAboutToBeMoved,&f.proxy,[&] { events.append("begin"); }); QObject::connect(&f.proxy,&QAbstractItemModel::rowsMoved,&f.proxy,[&] { events.append("end"); }); diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp index 594d764..61c62b2 100644 --- a/tests/ui/test_mod_tree_model.cpp +++ b/tests/ui/test_mod_tree_model.cpp @@ -6,10 +6,10 @@ #include "ui/plugins/organizer/filter_model.h" #include "ui/plugins/organizer/tree_model.h" -using namespace px::core; -using namespace px::core::organizer; -using px::ui::ModTreeModel; -using px::ui::OrganizerFilterModel; +using namespace px::modrim; +using namespace px::modrim::organizer; +using px::modrim::ModTreeModel; +using px::modrim::OrganizerFilterModel; namespace { struct OrganizerFixture { diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp index d750237..9f0066d 100644 --- a/tests/ui/test_mod_updates.cpp +++ b/tests/ui/test_mod_updates.cpp @@ -8,7 +8,7 @@ TEST_CASE("small updates skip confirmation") { QWidget parent; - CHECK(services_test::run_async(px::ui::confirm_update(px::ui::CONFIRM_THRESHOLD-1,&parent))); + CHECK(services_test::run_async(px::modrim::confirm_update(px::modrim::CONFIRM_THRESHOLD-1,&parent))); CHECK(parent.findChildren().isEmpty()); } TEST_CASE("large updates follow the actual confirmation dialog answer") { @@ -22,6 +22,6 @@ TEST_CASE("large updates follow the actual confirmation dialog answer") { } }); click.start(); - CHECK(services_test::run_async(px::ui::confirm_update(px::ui::CONFIRM_THRESHOLD,&parent)) == (answer == QMessageBox::Yes)); + CHECK(services_test::run_async(px::modrim::confirm_update(px::modrim::CONFIRM_THRESHOLD,&parent)) == (answer == QMessageBox::Yes)); } } diff --git a/tests/ui/test_navigation.cpp b/tests/ui/test_navigation.cpp index cc016d4..d4a04b6 100644 --- a/tests/ui/test_navigation.cpp +++ b/tests/ui/test_navigation.cpp @@ -5,9 +5,9 @@ #include "ui/navigation.h" -using px::ui::Route; -using px::ui::buildRoute; -using px::ui::parseRoute; +using px::modrim::Route; +using px::modrim::buildRoute; +using px::modrim::parseRoute; TEST_CASE("route round-trips ids with reserved characters") { const QString url = buildRoute(QStringLiteral("workshop"), diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp index 71afad7..519bf2a 100644 --- a/tests/ui/test_organizer_dialogs.cpp +++ b/tests/ui/test_organizer_dialogs.cpp @@ -8,7 +8,7 @@ #include "ui/plugins/organizer/dialogs.h" -using px::ui::DeleteFolderDialog; +using px::modrim::DeleteFolderDialog; namespace { diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp index 95e0078..d66a22a 100644 --- a/tests/ui/test_organizer_rule_editor.cpp +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -23,9 +23,9 @@ #include "../core/organizer/test_util.h" namespace { -using namespace px::core; -using namespace px::core::organizer; -using namespace px::ui; +using namespace px::modrim; +using namespace px::modrim::organizer; +using namespace px::modrim; using organizer_test::expect; using organizer_test::wait; diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp index fec7482..2b1e768 100644 --- a/tests/ui/test_procedural_preview.cpp +++ b/tests/ui/test_procedural_preview.cpp @@ -46,13 +46,13 @@ TEST_CASE("initials abbreviates titles") { }; for (const Case& c : cases) { INFO("title: " << c.title); - CHECK(px::ui::initials(QString::fromUtf8(c.title)) == QString::fromUtf8(c.expected)); + CHECK(px::modrim::initials(QString::fromUtf8(c.title)) == QString::fromUtf8(c.expected)); } } TEST_CASE("generate preview returns a non-null image of the requested size") { application(); - const QImage image = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage image = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); REQUIRE(!image.isNull()); CHECK(image.width() == 120); CHECK(image.height() == 60); @@ -60,9 +60,9 @@ TEST_CASE("generate preview returns a non-null image of the requested size") { TEST_CASE("generate preview returns the cached image for the same request") { application(); - const QImage image = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage image = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); REQUIRE(!image.isNull()); // QImage shares its data implicitly, so a cache hit is identified by the shared data's cacheKey. - const QImage again = px::ui::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage again = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); CHECK(again.cacheKey() == image.cacheKey()); } diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp index 6595393..4989bb2 100644 --- a/tests/ui/test_restore_snapshot_dialog.cpp +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -21,10 +21,10 @@ namespace { -using px::core::CaseInsensitiveStr; -using px::core::ModsConfig; -using px::ui::ConfirmRestoreDialog; -using px::ui::RestoreSnapshotDialog; +using px::modrim::CaseInsensitiveStr; +using px::modrim::ModsConfig; +using px::modrim::ConfirmRestoreDialog; +using px::modrim::RestoreSnapshotDialog; // Widgets need a QApplication for the whole run; the first test that asks for it creates it. QApplication& ensure_app() { @@ -49,7 +49,7 @@ void write_snapshot(const QString& path, const QStringList& package_ids) { for (const QString& id : package_ids) { config.activeMods.append(CaseInsensitiveStr(id)); } - REQUIRE(px::core::write_mods_config(path, config, QFileInfo(path).absolutePath()).has_value()); + REQUIRE(px::modrim::write_mods_config(path, config, QFileInfo(path).absolutePath()).has_value()); } QListWidget* list_of(RestoreSnapshotDialog& dialog) { diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp index c94c30e..876a9a1 100644 --- a/tests/ui/test_settings_panel.cpp +++ b/tests/ui/test_settings_panel.cpp @@ -33,12 +33,12 @@ void settle(int milliseconds) { TEST_CASE("Cancel settings unloads QML without binding errors") { ensureApplication(); QWidget host; - auto* engine = px::ui::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); QList warnings; QObject::connect(engine, &QQmlEngine::warnings, &host, [&](const QList& values) { warnings.append(values); }); auto context = px::ui_test::makeCore(); - px::ui::SettingsPanel panel(*context, engine, &host); + px::modrim::SettingsPanel panel(*context, engine, &host); panel.resize(760, 640); panel.show(); settle(300); diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp index 45cee28..ac4ee1a 100644 --- a/tests/ui/test_sidebar_model.cpp +++ b/tests/ui/test_sidebar_model.cpp @@ -3,28 +3,28 @@ #include "ui/models/sidebar_model.h" #include "ui/theme/palette.h" TEST_CASE("sidebar entry updates refresh provider icons without resetting") { - using px::core::view::SidebarEntry; - px::ui::SidebarModel model; + using px::modrim::view::SidebarEntry; + px::modrim::SidebarModel model; model.set_entries({{SidebarEntry::Kind::Provider,"Steam",{"a","b"},"steam"}}); QSignalSpy changed(&model,&QAbstractItemModel::dataChanged); QSignalSpy reset(&model,&QAbstractItemModel::modelReset); model.update_entries({{SidebarEntry::Kind::Provider,"Local",{"a","b"},"local"}}); - CHECK(model.data(model.index(0),px::ui::SidebarModel::IconRole).toString() == "folder"); - CHECK(model.data(model.index(0),px::ui::SidebarModel::IconColorRole).toString() == px::ui::paletteValue(u"TEXT_DIM")); - CHECK(model.data(model.index(0),px::ui::SidebarModel::CountRole).toInt() == 2); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::IconRole).toString() == "folder"); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::IconColorRole).toString() == px::modrim::paletteValue(u"TEXT_DIM")); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::CountRole).toInt() == 2); CHECK(reset.isEmpty()); REQUIRE(changed.size() == 1); - CHECK(changed.first()[2].value>().contains(px::ui::SidebarModel::IconRole)); + CHECK(changed.first()[2].value>().contains(px::modrim::SidebarModel::IconRole)); CHECK(model.get_entry(0)->provider_id == "local"); } TEST_CASE("sidebar models status sections and resets when its entry shape changes") { - using px::core::view::SidebarEntry; - using px::ui::SidebarModel; + using px::modrim::view::SidebarEntry; + using px::modrim::SidebarModel; SidebarModel model; model.set_entries({{SidebarEntry::Kind::All,"All",{"a"},{}}, {SidebarEntry::Kind::WithErrors,"Errors",{"a"},{}}}); CHECK(model.data(model.index(0),SidebarModel::SectionRole).toString() == "Status"); - CHECK(model.data(model.index(1),SidebarModel::BadgeFgRole).toString() == px::ui::paletteValue(u"DANGER")); + CHECK(model.data(model.index(1),SidebarModel::BadgeFgRole).toString() == px::modrim::paletteValue(u"DANGER")); CHECK_FALSE(model.get_entry(-1).has_value()); QSignalSpy reset(&model,&QAbstractItemModel::modelReset); model.update_entries({}); diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp index 813835a..437ba73 100644 --- a/tests/ui/test_startup_impact_data.cpp +++ b/tests/ui/test_startup_impact_data.cpp @@ -1,8 +1,8 @@ #include #include #include "ui/panels/startup_impact_data.h" -using namespace px::ui; -using namespace px::core::startup_impact; +using namespace px::modrim; +using namespace px::modrim::startup_impact; namespace { StartupImpactReport report() { StartupImpactReport result; diff --git a/tests/ui/test_support.h b/tests/ui/test_support.h index 51f905c..4dd6f32 100644 --- a/tests/ui/test_support.h +++ b/tests/ui/test_support.h @@ -11,10 +11,10 @@ namespace px::ui_test { // CoreContext services read their cache/config files through a ConfigService, so tests // give every context one rooted in a directory that lives for the whole test run. -inline std::unique_ptr makeCore(px::core::AppConfig config = {}) { +inline std::unique_ptr makeCore(px::modrim::AppConfig config = {}) { static QTemporaryDir configDir; - static px::core::ConfigService configService(configDir.path()); - return px::core::CoreContext::create(std::move(config), &configService); + static px::modrim::ConfigService configService(configDir.path()); + return px::modrim::CoreContext::create(std::move(config), &configService); } } // namespace px::ui_test diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp index faa63f5..07a27cb 100644 --- a/tests/ui/test_toast_and_progress.cpp +++ b/tests/ui/test_toast_and_progress.cpp @@ -15,10 +15,10 @@ namespace { -using px::ui::ProgressDialog; -using px::ui::Toast; -using px::ui::ToastLevel; -using px::ui::ToastManager; +using px::modrim::ProgressDialog; +using px::modrim::Toast; +using px::modrim::ToastLevel; +using px::modrim::ToastManager; QApplication& application() { if (auto* app = qobject_cast(QCoreApplication::instance())) { @@ -72,7 +72,7 @@ TEST_CASE("timed toast dismisses itself after its duration") { TEST_CASE("progress dialog stays visible when a new task starts before it hides") { application(); QWidget parent; - px::core::LoadingState loading(&parent); + px::modrim::LoadingState loading(&parent); ProgressDialog dialog(loading, &parent); { auto first = loading.task(QStringLiteral("first")); } diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp index 73fae93..753111f 100644 --- a/tests/ui/test_unity_rich_text.cpp +++ b/tests/ui/test_unity_rich_text.cpp @@ -18,7 +18,7 @@ struct Case { void checkCases(std::initializer_list cases) { for (const Case& c : cases) { INFO("input: " << c.input); - CHECK(px::ui::unity_rich_text_to_html(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); + CHECK(px::modrim::unity_rich_text_to_html(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); } } @@ -98,21 +98,21 @@ TEST_CASE("unity rich text drops links with unsafe urls and keeps their text") { }) { INFO("url: " << url); const QString input = QStringLiteral("link").arg(QString::fromUtf8(url)); - CHECK(px::ui::unity_rich_text_to_html(input) == QStringLiteral("link")); + CHECK(px::modrim::unity_rich_text_to_html(input) == QStringLiteral("link")); } } TEST_CASE("unity rich text uses the default size for a non-finite size") { // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::ui::unity_rich_text_to_html(QStringLiteral("large").arg(huge)); + const QString html = px::modrim::unity_rich_text_to_html(QStringLiteral("large").arg(huge)); CHECK(html == QStringLiteral("large")); } TEST_CASE("unity rich text uses the default indent for a non-finite indent") { // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::ui::unity_rich_text_to_html(QStringLiteral("wide").arg(huge)); + const QString html = px::modrim::unity_rich_text_to_html(QStringLiteral("wide").arg(huge)); CHECK(html == QStringLiteral("
    wide
    ")); } diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp index 2c008fb..03a215d 100644 --- a/tests/ui/test_views.cpp +++ b/tests/ui/test_views.cpp @@ -19,7 +19,7 @@ namespace { // The embedded browser exists only in WebEngine builds; without it there is no "workshopWeb" object. -QObject* web_of(const px::ui::SteamWorkshopViewPanel& view) { +QObject* web_of(const px::modrim::SteamWorkshopViewPanel& view) { for (auto* quick : view.findChildren()) { if (auto* root = quick->rootObject()) { if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) return web; @@ -31,21 +31,21 @@ QObject* web_of(const px::ui::SteamWorkshopViewPanel& view) { TEST_CASE("steam workshop view preloads without error") { QWidget host; - auto* engine = px::ui::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); auto core = px::ui_test::makeCore(); - px::ui::AppContext app(*core); - px::ui::SteamWorkshopViewPanel view(*core, engine, &host, app); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); view.preload(); } TEST_CASE("steam workshop view has no web view before initialization") { QWidget host; - auto* engine = px::ui::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); auto core = px::ui_test::makeCore(); - px::ui::AppContext app(*core); - px::ui::SteamWorkshopViewPanel view(*core, engine, &host, app); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); CHECK(web_of(view) == nullptr); } #endif diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp index 9f14ec2..22400f0 100644 --- a/tests/ui/test_workshop_installed.cpp +++ b/tests/ui/test_workshop_installed.cpp @@ -6,9 +6,9 @@ #include "ui/plugins/workshop/installed.h" -using px::core::workshop::CatalogMod; -using px::ui::workshop::InstalledList; -using px::ui::workshop::PAGE; +using px::modrim::workshop::CatalogMod; +using px::modrim::workshop::InstalledList; +using px::modrim::workshop::PAGE; namespace { CatalogMod installed_mod(std::string id, std::string title, std::string author = "Someone", diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp index a96753e..23848ca 100644 --- a/tests/ui/test_workshop_models.cpp +++ b/tests/ui/test_workshop_models.cpp @@ -8,8 +8,8 @@ #include "ui/panels/mod_info_data.h" #include "ui/plugins/workshop/models.h" -using namespace px::core::workshop; -using namespace px::ui::workshop; +using namespace px::modrim::workshop; +using namespace px::modrim::workshop; namespace { CatalogMod mod(std::string id = "1") { @@ -121,7 +121,7 @@ TEST_CASE("workshop mod rows preserve state sizes versions descriptions and comp REQUIRE(row.value("fileSize").toString() == "1.0 MB"); REQUIRE(row.value("description").toString().isEmpty()); REQUIRE(item_row(value, missing, "1.5", true).value("description").toString() - == px::ui::description_to_html(QString::fromStdString(value.description), "bbcode")); + == px::modrim::description_to_html(QString::fromStdString(value.description), "bbcode")); value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; row = item_row(value, missing, "1.5"); @@ -193,7 +193,7 @@ TEST_CASE("workshop collection rows select previews filter collage and display s REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); REQUIRE(row.value("actionLabel").toString() == "Download all"); REQUIRE(row.value("memberCount").toInt() == 1); - REQUIRE(row.value("description").toString() == px::ui::description_to_html("[b]Description[/b]", "text")); + REQUIRE(row.value("description").toString() == px::modrim::description_to_html("[b]Description[/b]", "text")); value.total_size = "3145728"; value.preview_url = "https://images.test/explicit.png"; value.source = CollectionSource::steam; diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index 290c612..4bd7aeb 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -21,10 +21,10 @@ #include "ui/plugins/workshop/view.h" namespace { -using namespace px::core; -using namespace px::core::workshop; -using namespace px::ui; -using namespace px::ui::workshop; +using namespace px::modrim; +using namespace px::modrim::workshop; +using namespace px::modrim; +using namespace px::modrim::workshop; using workshop_test::HttpRequest; using workshop_test::HttpResponse; using workshop_test::run; @@ -87,7 +87,7 @@ struct PanelFixture { collection.supported_versions = {"1.6"}; collection.member_ids = {"1", "missing"}; collection.member_count = 2; catalog.set_base_url(server.url()); engine = createQmlEngine(&owner); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::ui::Theme(engine)); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); view = std::make_unique(*core, engine, &owner, app, catalog); view->resize(1100, 800); run(view->load()); From 8c74e0b576888a810bc45f4283169c5921937746 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 03:31:10 +0300 Subject: [PATCH 08/50] refactor: use .hpp for headers --- CMakeLists.txt | 2 - justfile | 4 +- src/app/main.cpp | 20 +++---- src/app/plugins.cpp | 26 ++++----- src/app/{plugins.h => plugins.hpp} | 2 +- src/core/checker/checker.cpp | 2 +- src/core/checker/{checker.h => checker.hpp} | 14 ++--- src/core/checker/databases.cpp | 10 ++-- .../checker/{databases.h => databases.hpp} | 4 +- src/core/checker/graph.cpp | 2 +- src/core/checker/{graph.h => graph.hpp} | 8 +-- src/core/checker/issues.cpp | 4 +- src/core/checker/{issues.h => issues.hpp} | 2 +- src/core/checker/models.cpp | 2 +- src/core/checker/{models.h => models.hpp} | 4 +- src/core/checker/sort.cpp | 4 +- src/core/checker/{sort.h => sort.hpp} | 10 ++-- src/core/config.cpp | 4 +- src/core/{config.h => config.hpp} | 8 +-- src/core/{constants.h => constants.hpp} | 0 src/core/context.cpp | 20 +++---- src/core/{context.h => context.hpp} | 12 ++-- src/core/{context_fwd.h => context_fwd.hpp} | 0 src/core/downloads/downloader.cpp | 4 +- .../{downloader.h => downloader.hpp} | 10 ++-- src/core/downloads/manager.cpp | 2 +- src/core/downloads/{manager.h => manager.hpp} | 12 ++-- src/core/downloads/steam.cpp | 4 +- src/core/downloads/{steam.h => steam.hpp} | 12 ++-- src/core/downloads/{types.h => types.hpp} | 0 src/core/{error.h => error.hpp} | 0 src/core/{events.h => events.hpp} | 0 src/core/file_io.cpp | 2 +- src/core/{file_io.h => file_io.hpp} | 2 +- src/core/loading.cpp | 2 +- src/core/{loading.h => loading.hpp} | 0 src/core/migrator.cpp | 4 +- src/core/{migrator.h => migrator.hpp} | 2 +- src/core/mod_service.cpp | 10 ++-- src/core/{mod_service.h => mod_service.hpp} | 12 ++-- src/core/models/metadata/parsing.cpp | 8 +-- .../metadata/{parsing.h => parsing.hpp} | 4 +- src/core/models/metadata/structures.cpp | 6 +- .../metadata/{structures.h => structures.hpp} | 2 +- .../view/{diagnostics.h => diagnostics.hpp} | 0 src/core/models/view/sidebar.cpp | 2 +- .../models/view/{sidebar.h => sidebar.hpp} | 0 src/core/mods_config.cpp | 8 +-- src/core/{mods_config.h => mods_config.hpp} | 4 +- src/core/organizer/db.cpp | 4 +- src/core/organizer/{db.h => db.hpp} | 6 +- src/core/organizer/defaults.cpp | 2 +- .../organizer/{defaults.h => defaults.hpp} | 2 +- src/core/organizer/{models.h => models.hpp} | 0 src/core/organizer/resolve.cpp | 4 +- src/core/organizer/{resolve.h => resolve.hpp} | 4 +- src/core/organizer/service.cpp | 6 +- src/core/organizer/{service.h => service.hpp} | 14 ++--- src/core/{plugin.h => plugin.hpp} | 6 +- .../{plugin_config.h => plugin_config.hpp} | 4 +- src/core/profiler.cpp | 2 +- src/core/{profiler.h => profiler.hpp} | 0 src/core/providers/base.cpp | 6 +- src/core/providers/{base.h => base.hpp} | 6 +- src/core/providers/core.cpp | 2 +- src/core/providers/{core.h => core.hpp} | 2 +- src/core/providers/factory.cpp | 6 +- src/core/providers/{factory.h => factory.hpp} | 4 +- src/core/providers/{labels.h => labels.hpp} | 0 src/core/providers/local.cpp | 2 +- src/core/providers/{local.h => local.hpp} | 2 +- src/core/services/activation_service.cpp | 14 ++--- ...ation_service.h => activation_service.hpp} | 0 src/core/services/diagnostics_service.cpp | 8 +-- ...tics_service.h => diagnostics_service.hpp} | 20 +++---- src/core/services/game_launcher.cpp | 6 +- .../{game_launcher.h => game_launcher.hpp} | 6 +- src/core/services/game_process.cpp | 8 +-- .../{game_process.h => game_process.hpp} | 2 +- src/core/services/metadata_cache.cpp | 6 +- .../{metadata_cache.h => metadata_cache.hpp} | 2 +- src/core/services/mod_discovery.cpp | 4 +- .../{mod_discovery.h => mod_discovery.hpp} | 2 +- src/core/services/sort_service.cpp | 10 ++-- .../{sort_service.h => sort_service.hpp} | 4 +- .../services/startup_impact_service/db.cpp | 4 +- .../startup_impact_service/{db.h => db.hpp} | 4 +- .../startup_impact_service/labels.cpp | 2 +- .../{labels.h => labels.hpp} | 0 .../startup_impact_service/models.cpp | 2 +- .../{models.h => models.hpp} | 0 .../startup_impact_service/parser.cpp | 2 +- .../{parser.h => parser.hpp} | 2 +- .../startup_impact_service/phases.cpp | 4 +- .../{phases.h => phases.hpp} | 0 .../startup_impact_service/service.cpp | 8 +-- .../{service.h => service.hpp} | 6 +- src/core/services/update_service.cpp | 2 +- .../{update_service.h => update_service.hpp} | 2 +- src/core/sort/community.cpp | 6 +- src/core/sort/{community.h => community.hpp} | 6 +- src/core/sort/community_service.cpp | 6 +- ...munity_service.h => community_service.hpp} | 8 +-- src/core/sort/config.cpp | 2 +- src/core/sort/{config.h => config.hpp} | 2 +- src/core/sort/{models.h => models.hpp} | 4 +- src/core/sort/tiers.cpp | 2 +- src/core/sort/{tiers.h => tiers.hpp} | 4 +- src/core/{structures.h => structures.hpp} | 0 src/core/support/report.cpp | 6 +- src/core/support/{report.h => report.hpp} | 6 +- src/core/{toposort.h => toposort.hpp} | 2 +- src/core/utils.cpp | 2 +- src/core/{utils.h => utils.hpp} | 0 src/core/version.cpp | 2 +- src/core/{version.h => version.hpp} | 0 src/core/workshop/catalog.cpp | 6 +- src/core/workshop/{catalog.h => catalog.hpp} | 22 ++++---- src/core/workshop/client.cpp | 2 +- src/core/workshop/{client.h => client.hpp} | 2 +- src/core/workshop/favourites.cpp | 2 +- .../workshop/{favourites.h => favourites.hpp} | 8 +-- src/core/workshop/{types.h => types.hpp} | 0 src/core/xml.cpp | 2 +- src/core/{xml.h => xml.hpp} | 2 +- src/ui/CMakeLists.txt | 2 +- src/ui/components/banner_widget.cpp | 4 +- .../{banner_widget.h => banner_widget.hpp} | 2 +- src/ui/components/button.cpp | 2 +- src/ui/components/{button.h => button.hpp} | 0 src/ui/components/components.h | 13 ----- src/ui/components/components.hpp | 13 +++++ src/ui/components/dialog_chrome.cpp | 6 +- .../{dialog_chrome.h => dialog_chrome.hpp} | 0 src/ui/components/{dialogs.h => dialogs.hpp} | 2 +- src/ui/components/filter_sidebar.cpp | 4 +- .../{filter_sidebar.h => filter_sidebar.hpp} | 0 src/ui/components/header_controller.cpp | 4 +- ...der_controller.h => header_controller.hpp} | 0 src/ui/components/header_panel.cpp | 6 +- .../{header_panel.h => header_panel.hpp} | 0 src/ui/components/icon_button.cpp | 6 +- .../{icon_button.h => icon_button.hpp} | 0 src/ui/components/icons.cpp | 2 +- src/ui/components/{icons.h => icons.hpp} | 0 src/ui/components/mod_activation.cpp | 8 +-- .../{mod_activation.h => mod_activation.hpp} | 0 src/ui/components/mod_updates.cpp | 8 +-- .../{mod_updates.h => mod_updates.hpp} | 0 src/ui/components/popup_parent.cpp | 2 +- .../{popup_parent.h => popup_parent.hpp} | 0 src/ui/components/procedural_preview.cpp | 4 +- ...dural_preview.h => procedural_preview.hpp} | 0 src/ui/components/progress_dialog.cpp | 8 +-- ...{progress_dialog.h => progress_dialog.hpp} | 2 +- src/ui/components/svg_provider.cpp | 6 +- .../{svg_provider.h => svg_provider.hpp} | 0 src/ui/components/toast.cpp | 6 +- src/ui/components/{toast.h => toast.hpp} | 2 +- src/ui/components/unity_rich_text.cpp | 2 +- ...{unity_rich_text.h => unity_rich_text.hpp} | 0 src/ui/components/view_rail_panel.cpp | 6 +- ...{view_rail_panel.h => view_rail_panel.hpp} | 0 src/ui/config.cpp | 2 +- src/ui/{config.h => config.hpp} | 6 +- src/ui/context.cpp | 4 +- src/ui/{context.h => context.hpp} | 10 ++-- src/ui/mod_selection.cpp | 4 +- src/ui/{mod_selection.h => mod_selection.hpp} | 6 +- src/ui/models/impact.cpp | 6 +- src/ui/models/{impact.h => impact.hpp} | 0 src/ui/models/mod_list_model.cpp | 8 +-- .../{mod_list_model.h => mod_list_model.hpp} | 4 +- src/ui/models/mod_list_proxy_model.cpp | 2 +- ...proxy_model.h => mod_list_proxy_model.hpp} | 2 +- src/ui/models/sidebar_model.cpp | 4 +- .../{sidebar_model.h => sidebar_model.hpp} | 2 +- src/ui/navigation.cpp | 2 +- src/ui/{navigation.h => navigation.hpp} | 0 src/ui/panels/about_panel.cpp | 10 ++-- .../panels/{about_panel.h => about_panel.hpp} | 0 src/ui/panels/keyboard_shortcuts_dialog.cpp | 2 +- ...dialog.h => keyboard_shortcuts_dialog.hpp} | 0 src/ui/panels/mod_info_data.cpp | 8 +-- .../{mod_info_data.h => mod_info_data.hpp} | 4 +- src/ui/panels/mod_info_panel.cpp | 22 ++++---- .../{mod_info_panel.h => mod_info_panel.hpp} | 4 +- src/ui/panels/mod_list_panel.cpp | 16 +++--- .../{mod_list_panel.h => mod_list_panel.hpp} | 2 +- src/ui/panels/restore_snapshot_dialog.cpp | 4 +- ...t_dialog.h => restore_snapshot_dialog.hpp} | 2 +- src/ui/panels/settings_panel.cpp | 12 ++-- .../{settings_panel.h => settings_panel.hpp} | 4 +- src/ui/panels/sidebar_panel.cpp | 6 +- .../{sidebar_panel.h => sidebar_panel.hpp} | 4 +- src/ui/panels/startup_impact_data.cpp | 8 +-- ..._impact_data.h => startup_impact_data.hpp} | 4 +- src/ui/panels/time_analytics_panel.cpp | 8 +-- ...ytics_panel.h => time_analytics_panel.hpp} | 2 +- src/ui/panels/update_dialog.cpp | 2 +- .../{update_dialog.h => update_dialog.hpp} | 2 +- src/ui/panels/upload_report_dialog.cpp | 10 ++-- ...port_dialog.h => upload_report_dialog.hpp} | 0 src/ui/plugins/downloads/model.cpp | 2 +- .../plugins/downloads/{model.h => model.hpp} | 2 +- src/ui/plugins/downloads/plugin.cpp | 4 +- .../downloads/{plugin.h => plugin.hpp} | 2 +- src/ui/plugins/downloads/view.cpp | 4 +- src/ui/plugins/downloads/{view.h => view.hpp} | 6 +- src/ui/plugins/organizer/dialogs.cpp | 6 +- .../organizer/{dialogs.h => dialogs.hpp} | 0 src/ui/plugins/organizer/filter_model.cpp | 2 +- .../{filter_model.h => filter_model.hpp} | 2 +- .../organizer/{plugin.h => plugin.hpp} | 4 +- src/ui/plugins/organizer/tree_model.cpp | 6 +- .../{tree_model.h => tree_model.hpp} | 4 +- src/ui/plugins/organizer/view.cpp | 32 +++++------ src/ui/plugins/organizer/{view.h => view.hpp} | 4 +- src/ui/plugins/steam_downloader/plugin.cpp | 2 +- .../steam_downloader/{plugin.h => plugin.hpp} | 6 +- src/ui/plugins/steam_workshop/bridge_obj.cpp | 4 +- .../{bridge_obj.h => bridge_obj.hpp} | 0 .../steam_workshop/download_queue_model.cpp | 2 +- ...queue_model.h => download_queue_model.hpp} | 0 .../steam_workshop/download_sidebar.cpp | 6 +- ...ownload_sidebar.h => download_sidebar.hpp} | 2 +- src/ui/plugins/steam_workshop/plugin.cpp | 8 +-- .../steam_workshop/{plugin.h => plugin.hpp} | 6 +- src/ui/plugins/steam_workshop/view.cpp | 8 +-- .../steam_workshop/{view.h => view.hpp} | 4 +- src/ui/plugins/workshop/details.cpp | 2 +- .../workshop/{details.h => details.hpp} | 2 +- src/ui/plugins/workshop/installed.cpp | 2 +- .../workshop/{installed.h => installed.hpp} | 2 +- src/ui/plugins/workshop/models.cpp | 4 +- .../plugins/workshop/{models.h => models.hpp} | 2 +- src/ui/plugins/workshop/plugin.cpp | 4 +- .../plugins/workshop/{plugin.h => plugin.hpp} | 6 +- src/ui/plugins/workshop/view.cpp | 8 +-- src/ui/plugins/workshop/{view.h => view.hpp} | 8 +-- src/ui/settings_section.cpp | 2 +- ...ettings_section.h => settings_section.hpp} | 6 +- src/ui/theme/{constants.h => constants.hpp} | 0 src/ui/theme/palette.cpp | 2 +- src/ui/theme/{palette.h => palette.hpp} | 0 src/ui/theme/qml_theme.cpp | 6 +- src/ui/theme/{qml_theme.h => qml_theme.hpp} | 0 src/ui/{ui_prefs.h => ui_prefs.hpp} | 2 +- src/ui/views/base.cpp | 4 +- src/ui/views/{base.h => base.hpp} | 2 +- src/ui/views/mods_view.cpp | 4 +- src/ui/views/{mods_view.h => mods_view.hpp} | 8 +-- src/ui/views/{views.h => views.hpp} | 2 +- src/ui/window/actions.cpp | 2 +- src/ui/window/{actions.h => actions.hpp} | 0 src/ui/window/main_window.cpp | 56 +++++++++---------- .../window/{main_window.h => main_window.hpp} | 8 +-- src/ui/window/menu_bar.cpp | 2 +- src/ui/window/{menu_bar.h => menu_bar.hpp} | 2 +- tests/core/checker/test_checker.cpp | 12 ++-- tests/core/checker/test_databases.cpp | 4 +- tests/core/checker/test_graph.cpp | 6 +- tests/core/checker/test_issues.cpp | 12 ++-- tests/core/downloads/test_manager.cpp | 10 ++-- tests/core/downloads/test_steam.cpp | 12 ++-- tests/core/models/metadata/test_parsing.cpp | 4 +- tests/core/models/metadata/test_schemas.cpp | 2 +- .../core/models/metadata/test_structures.cpp | 2 +- tests/core/organizer/test_db.cpp | 8 +-- tests/core/organizer/test_resolve.cpp | 6 +- tests/core/organizer/test_service.cpp | 14 ++--- .../organizer/{test_util.h => test_util.hpp} | 4 +- tests/core/providers/test_factory.cpp | 6 +- tests/core/providers/test_providers.cpp | 4 +- ...ervices_support.h => services_support.hpp} | 0 .../startup_impact_service/test_db.cpp | 4 +- .../startup_impact_service/test_phases.cpp | 4 +- .../test_startup_impact.cpp | 6 +- .../core/services/test_activation_service.cpp | 14 ++--- tests/core/services/test_game_launcher.cpp | 12 ++-- tests/core/services/test_metadata_cache.cpp | 8 +-- tests/core/services/test_sort_service.cpp | 2 +- tests/core/services/test_update_service.cpp | 4 +- tests/core/sort/test_community.cpp | 4 +- tests/core/sort/test_tier_order.cpp | 8 +-- tests/core/sort/test_tiers.cpp | 2 +- tests/core/support/test_report.cpp | 8 +-- tests/core/test_config.cpp | 8 +-- tests/core/test_context.cpp | 8 +-- tests/core/test_loading.cpp | 2 +- tests/core/test_migrator.cpp | 4 +- tests/core/test_mods_config.cpp | 8 +-- tests/core/test_plugin.cpp | 4 +- tests/core/test_plugin_config.cpp | 6 +- .../core/{test_support.h => test_support.hpp} | 0 tests/core/test_utils.cpp | 2 +- tests/core/test_version.cpp | 2 +- tests/core/test_xml.cpp | 2 +- .../core/workshop/{support.h => support.hpp} | 2 +- tests/core/workshop/test_catalog.cpp | 22 ++++---- tests/core/workshop/test_client.cpp | 8 +-- tests/core/workshop/test_favourites.cpp | 8 +-- tests/core/workshop/test_types.cpp | 4 +- tests/ui/test_dialog_chrome.cpp | 2 +- tests/ui/test_downloads_model.cpp | 2 +- tests/ui/test_main_window.cpp | 14 ++--- tests/ui/test_menu_bar.cpp | 4 +- tests/ui/test_mod_info_data.cpp | 6 +- tests/ui/test_mod_list_model.cpp | 6 +- tests/ui/test_mod_list_panel.cpp | 16 +++--- tests/ui/test_mod_list_proxy_model.cpp | 2 +- tests/ui/test_mod_tree_model.cpp | 4 +- tests/ui/test_mod_updates.cpp | 4 +- tests/ui/test_navigation.cpp | 2 +- tests/ui/test_organizer_dialogs.cpp | 2 +- tests/ui/test_organizer_rule_editor.cpp | 18 +++--- tests/ui/test_procedural_preview.cpp | 2 +- tests/ui/test_restore_snapshot_dialog.cpp | 6 +- tests/ui/test_settings_panel.cpp | 10 ++-- tests/ui/test_sidebar_model.cpp | 4 +- tests/ui/test_startup_impact_data.cpp | 2 +- tests/ui/{test_support.h => test_support.hpp} | 4 +- tests/ui/test_toast_and_progress.cpp | 6 +- tests/ui/test_unity_rich_text.cpp | 2 +- tests/ui/test_views.cpp | 12 ++-- tests/ui/test_workshop_installed.cpp | 2 +- tests/ui/test_workshop_models.cpp | 4 +- tests/ui/test_workshop_view.cpp | 22 ++++---- 328 files changed, 808 insertions(+), 810 deletions(-) rename src/app/{plugins.h => plugins.hpp} (98%) rename src/core/checker/{checker.h => checker.hpp} (94%) rename src/core/checker/{databases.h => databases.hpp} (96%) rename src/core/checker/{graph.h => graph.hpp} (96%) rename src/core/checker/{issues.h => issues.hpp} (99%) rename src/core/checker/{models.h => models.hpp} (95%) rename src/core/checker/{sort.h => sort.hpp} (82%) rename src/core/{config.h => config.hpp} (98%) rename src/core/{constants.h => constants.hpp} (100%) rename src/core/{context.h => context.hpp} (96%) rename src/core/{context_fwd.h => context_fwd.hpp} (100%) rename src/core/downloads/{downloader.h => downloader.hpp} (93%) rename src/core/downloads/{manager.h => manager.hpp} (91%) rename src/core/downloads/{steam.h => steam.hpp} (97%) rename src/core/downloads/{types.h => types.hpp} (100%) rename src/core/{error.h => error.hpp} (100%) rename src/core/{events.h => events.hpp} (100%) rename src/core/{file_io.h => file_io.hpp} (95%) rename src/core/{loading.h => loading.hpp} (100%) rename src/core/{migrator.h => migrator.hpp} (97%) rename src/core/{mod_service.h => mod_service.hpp} (92%) rename src/core/models/metadata/{parsing.h => parsing.hpp} (94%) rename src/core/models/metadata/{structures.h => structures.hpp} (99%) rename src/core/models/view/{diagnostics.h => diagnostics.hpp} (100%) rename src/core/models/view/{sidebar.h => sidebar.hpp} (100%) rename src/core/{mods_config.h => mods_config.hpp} (94%) rename src/core/organizer/{db.h => db.hpp} (96%) rename src/core/organizer/{defaults.h => defaults.hpp} (92%) rename src/core/organizer/{models.h => models.hpp} (100%) rename src/core/organizer/{resolve.h => resolve.hpp} (97%) rename src/core/organizer/{service.h => service.hpp} (96%) rename src/core/{plugin.h => plugin.hpp} (98%) rename src/core/{plugin_config.h => plugin_config.hpp} (98%) rename src/core/{profiler.h => profiler.hpp} (100%) rename src/core/providers/{base.h => base.hpp} (97%) rename src/core/providers/{core.h => core.hpp} (94%) rename src/core/providers/{factory.h => factory.hpp} (89%) rename src/core/providers/{labels.h => labels.hpp} (100%) rename src/core/providers/{local.h => local.hpp} (97%) rename src/core/services/{activation_service.h => activation_service.hpp} (100%) rename src/core/services/{diagnostics_service.h => diagnostics_service.hpp} (91%) rename src/core/services/{game_launcher.h => game_launcher.hpp} (92%) rename src/core/services/{game_process.h => game_process.hpp} (99%) rename src/core/services/{metadata_cache.h => metadata_cache.hpp} (98%) rename src/core/services/{mod_discovery.h => mod_discovery.hpp} (92%) rename src/core/services/{sort_service.h => sort_service.hpp} (95%) rename src/core/services/startup_impact_service/{db.h => db.hpp} (96%) rename src/core/services/startup_impact_service/{labels.h => labels.hpp} (100%) rename src/core/services/startup_impact_service/{models.h => models.hpp} (100%) rename src/core/services/startup_impact_service/{parser.h => parser.hpp} (90%) rename src/core/services/startup_impact_service/{phases.h => phases.hpp} (100%) rename src/core/services/startup_impact_service/{service.h => service.hpp} (91%) rename src/core/services/{update_service.h => update_service.hpp} (98%) rename src/core/sort/{community.h => community.hpp} (82%) rename src/core/sort/{community_service.h => community_service.hpp} (90%) rename src/core/sort/{config.h => config.hpp} (94%) rename src/core/sort/{models.h => models.hpp} (89%) rename src/core/sort/{tiers.h => tiers.hpp} (95%) rename src/core/{structures.h => structures.hpp} (100%) rename src/core/support/{report.h => report.hpp} (95%) rename src/core/{toposort.h => toposort.hpp} (98%) rename src/core/{utils.h => utils.hpp} (100%) rename src/core/{version.h => version.hpp} (100%) rename src/core/workshop/{catalog.h => catalog.hpp} (96%) rename src/core/workshop/{client.h => client.hpp} (98%) rename src/core/workshop/{favourites.h => favourites.hpp} (91%) rename src/core/workshop/{types.h => types.hpp} (100%) rename src/core/{xml.h => xml.hpp} (97%) rename src/ui/components/{banner_widget.h => banner_widget.hpp} (96%) rename src/ui/components/{button.h => button.hpp} (100%) delete mode 100644 src/ui/components/components.h create mode 100644 src/ui/components/components.hpp rename src/ui/components/{dialog_chrome.h => dialog_chrome.hpp} (100%) rename src/ui/components/{dialogs.h => dialogs.hpp} (97%) rename src/ui/components/{filter_sidebar.h => filter_sidebar.hpp} (100%) rename src/ui/components/{header_controller.h => header_controller.hpp} (100%) rename src/ui/components/{header_panel.h => header_panel.hpp} (100%) rename src/ui/components/{icon_button.h => icon_button.hpp} (100%) rename src/ui/components/{icons.h => icons.hpp} (100%) rename src/ui/components/{mod_activation.h => mod_activation.hpp} (100%) rename src/ui/components/{mod_updates.h => mod_updates.hpp} (100%) rename src/ui/components/{popup_parent.h => popup_parent.hpp} (100%) rename src/ui/components/{procedural_preview.h => procedural_preview.hpp} (100%) rename src/ui/components/{progress_dialog.h => progress_dialog.hpp} (97%) rename src/ui/components/{svg_provider.h => svg_provider.hpp} (100%) rename src/ui/components/{toast.h => toast.hpp} (98%) rename src/ui/components/{unity_rich_text.h => unity_rich_text.hpp} (100%) rename src/ui/components/{view_rail_panel.h => view_rail_panel.hpp} (100%) rename src/ui/{config.h => config.hpp} (89%) rename src/ui/{context.h => context.hpp} (97%) rename src/ui/{mod_selection.h => mod_selection.hpp} (90%) rename src/ui/models/{impact.h => impact.hpp} (100%) rename src/ui/models/{mod_list_model.h => mod_list_model.hpp} (96%) rename src/ui/models/{mod_list_proxy_model.h => mod_list_proxy_model.hpp} (97%) rename src/ui/models/{sidebar_model.h => sidebar_model.hpp} (96%) rename src/ui/{navigation.h => navigation.hpp} (100%) rename src/ui/panels/{about_panel.h => about_panel.hpp} (100%) rename src/ui/panels/{keyboard_shortcuts_dialog.h => keyboard_shortcuts_dialog.hpp} (100%) rename src/ui/panels/{mod_info_data.h => mod_info_data.hpp} (89%) rename src/ui/panels/{mod_info_panel.h => mod_info_panel.hpp} (96%) rename src/ui/panels/{mod_list_panel.h => mod_list_panel.hpp} (98%) rename src/ui/panels/{restore_snapshot_dialog.h => restore_snapshot_dialog.hpp} (96%) rename src/ui/panels/{settings_panel.h => settings_panel.hpp} (97%) rename src/ui/panels/{sidebar_panel.h => sidebar_panel.hpp} (89%) rename src/ui/panels/{startup_impact_data.h => startup_impact_data.hpp} (80%) rename src/ui/panels/{time_analytics_panel.h => time_analytics_panel.hpp} (93%) rename src/ui/panels/{update_dialog.h => update_dialog.hpp} (87%) rename src/ui/panels/{upload_report_dialog.h => upload_report_dialog.hpp} (100%) rename src/ui/plugins/downloads/{model.h => model.hpp} (99%) rename src/ui/plugins/downloads/{plugin.h => plugin.hpp} (88%) rename src/ui/plugins/downloads/{view.h => view.hpp} (89%) rename src/ui/plugins/organizer/{dialogs.h => dialogs.hpp} (100%) rename src/ui/plugins/organizer/{filter_model.h => filter_model.hpp} (95%) rename src/ui/plugins/organizer/{plugin.h => plugin.hpp} (82%) rename src/ui/plugins/organizer/{tree_model.h => tree_model.hpp} (97%) rename src/ui/plugins/organizer/{view.h => view.hpp} (98%) rename src/ui/plugins/steam_downloader/{plugin.h => plugin.hpp} (83%) rename src/ui/plugins/steam_workshop/{bridge_obj.h => bridge_obj.hpp} (100%) rename src/ui/plugins/steam_workshop/{download_queue_model.h => download_queue_model.hpp} (100%) rename src/ui/plugins/steam_workshop/{download_sidebar.h => download_sidebar.hpp} (93%) rename src/ui/plugins/steam_workshop/{plugin.h => plugin.hpp} (94%) rename src/ui/plugins/steam_workshop/{view.h => view.hpp} (94%) rename src/ui/plugins/workshop/{details.h => details.hpp} (93%) rename src/ui/plugins/workshop/{installed.h => installed.hpp} (96%) rename src/ui/plugins/workshop/{models.h => models.hpp} (97%) rename src/ui/plugins/workshop/{plugin.h => plugin.hpp} (85%) rename src/ui/plugins/workshop/{view.h => view.hpp} (98%) rename src/ui/{settings_section.h => settings_section.hpp} (97%) rename src/ui/theme/{constants.h => constants.hpp} (100%) rename src/ui/theme/{palette.h => palette.hpp} (100%) rename src/ui/theme/{qml_theme.h => qml_theme.hpp} (100%) rename src/ui/{ui_prefs.h => ui_prefs.hpp} (92%) rename src/ui/views/{base.h => base.hpp} (95%) rename src/ui/views/{mods_view.h => mods_view.hpp} (82%) rename src/ui/views/{views.h => views.hpp} (84%) rename src/ui/window/{actions.h => actions.hpp} (100%) rename src/ui/window/{main_window.h => main_window.hpp} (96%) rename src/ui/window/{menu_bar.h => menu_bar.hpp} (91%) rename tests/core/organizer/{test_util.h => test_util.hpp} (93%) rename tests/core/services/{services_support.h => services_support.hpp} (100%) rename tests/core/{test_support.h => test_support.hpp} (100%) rename tests/core/workshop/{support.h => support.hpp} (99%) rename tests/ui/{test_support.h => test_support.hpp} (91%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 68bd5cb..5972800 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,8 +26,6 @@ include(cmake/CPM.cmake) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets Test) qt_standard_project_setup(REQUIRES 6.8) -# No .ui files; AUTOUIC would misread ui/ui_prefs.h as a generated uic header. -set(CMAKE_AUTOUIC OFF) set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") if(NOT PX_WORKSHOP_VARIANT STREQUAL "NativeWorkshop" AND NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") diff --git a/justfile b/justfile index 203337f..3c978eb 100644 --- a/justfile +++ b/justfile @@ -26,10 +26,10 @@ build-js: {{python}} scripts/build-inject-js.py format: - find src tests \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format -i + find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format -i format-check: - find src tests \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format --dry-run --Werror + find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format --dry-run --Werror check: format-check build build-js diff --git a/src/app/main.cpp b/src/app/main.cpp index b4e8695..75bf9a5 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -17,16 +17,16 @@ #include #include -#include "app/plugins.h" -#include "core/config.h" -#include "core/context.h" -#include "core/organizer/db.h" -#include "core/version.h" -#include "ui/components/popup_parent.h" -#include "ui/config.h" -#include "ui/context.h" -#include "ui/theme/palette.h" -#include "ui/window/main_window.h" +#include "app/plugins.hpp" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/organizer/db.hpp" +#include "core/version.hpp" +#include "ui/components/popup_parent.hpp" +#include "ui/config.hpp" +#include "ui/context.hpp" +#include "ui/theme/palette.hpp" +#include "ui/window/main_window.hpp" Q_IMPORT_QML_PLUGIN(PxModRimPlugin) diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp index b6c0403..05eda45 100644 --- a/src/app/plugins.cpp +++ b/src/app/plugins.cpp @@ -1,4 +1,4 @@ -#include "app/plugins.h" +#include "app/plugins.hpp" #include @@ -7,20 +7,20 @@ #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/organizer/db.h" -#include "core/organizer/service.h" -#include "core/workshop/catalog.h" -#include "ui/plugins/downloads/plugin.h" -#include "ui/plugins/steam_downloader/plugin.h" -#include "ui/plugins/steam_workshop/plugin.h" -#include "ui/plugins/workshop/plugin.h" -#include "ui/plugins/organizer/plugin.h" -#include "ui/views/mods_view.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/service.hpp" +#include "core/workshop/catalog.hpp" +#include "ui/plugins/downloads/plugin.hpp" +#include "ui/plugins/steam_downloader/plugin.hpp" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/workshop/plugin.hpp" +#include "ui/plugins/organizer/plugin.hpp" +#include "ui/views/mods_view.hpp" #ifdef PX_HAS_STEAM -#include "core/downloads/steam.h" +#include "core/downloads/steam.hpp" #endif namespace px::modrim { diff --git a/src/app/plugins.h b/src/app/plugins.hpp similarity index 98% rename from src/app/plugins.h rename to src/app/plugins.hpp index 2321e85..26c5158 100644 --- a/src/app/plugins.h +++ b/src/app/plugins.hpp @@ -6,7 +6,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { class ConfigService; diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp index eb6afa8..7160fed 100644 --- a/src/core/checker/checker.cpp +++ b/src/core/checker/checker.cpp @@ -1,4 +1,4 @@ -#include "core/checker/checker.h" +#include "core/checker/checker.hpp" #include #include diff --git a/src/core/checker/checker.h b/src/core/checker/checker.hpp similarity index 94% rename from src/core/checker/checker.h rename to src/core/checker/checker.hpp index 6345e54..4e7a1e3 100644 --- a/src/core/checker/checker.h +++ b/src/core/checker/checker.hpp @@ -10,13 +10,13 @@ #include #include -#include "core/checker/graph.h" -#include "core/checker/issues.h" -#include "core/checker/models.h" -#include "core/models/metadata/structures.h" -#include "core/profiler.h" -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/profiler.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp index 86f29f7..1f4144b 100644 --- a/src/core/checker/databases.cpp +++ b/src/core/checker/databases.cpp @@ -1,4 +1,4 @@ -#include "core/checker/databases.h" +#include "core/checker/databases.hpp" #include #include @@ -24,10 +24,10 @@ #include #include -#include "core/config.h" -#include "core/error.h" -#include "core/file_io.h" -#include "core/xml.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/file_io.hpp" +#include "core/xml.hpp" namespace px::modrim::checker { namespace { diff --git a/src/core/checker/databases.h b/src/core/checker/databases.hpp similarity index 96% rename from src/core/checker/databases.h rename to src/core/checker/databases.hpp index 189436e..2e3b945 100644 --- a/src/core/checker/databases.h +++ b/src/core/checker/databases.hpp @@ -7,8 +7,8 @@ #include #include -#include "core/checker/models.h" -#include "core/models/metadata/structures.h" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" class QNetworkAccessManager; diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index c752d1b..c89c108 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -1,4 +1,4 @@ -#include "core/checker/graph.h" +#include "core/checker/graph.hpp" #include #include diff --git a/src/core/checker/graph.h b/src/core/checker/graph.hpp similarity index 96% rename from src/core/checker/graph.h rename to src/core/checker/graph.hpp index 77ab81e..b7811f3 100644 --- a/src/core/checker/graph.h +++ b/src/core/checker/graph.hpp @@ -8,10 +8,10 @@ #include #include -#include "core/checker/models.h" -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp index af0f3ae..bc1ada0 100644 --- a/src/core/checker/issues.cpp +++ b/src/core/checker/issues.cpp @@ -1,8 +1,8 @@ -#include "core/checker/issues.h" +#include "core/checker/issues.hpp" #include -#include "core/checker/graph.h" +#include "core/checker/graph.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/issues.h b/src/core/checker/issues.hpp similarity index 99% rename from src/core/checker/issues.h rename to src/core/checker/issues.hpp index efbaf6e..479f413 100644 --- a/src/core/checker/issues.h +++ b/src/core/checker/issues.hpp @@ -7,7 +7,7 @@ #include #include -#include "core/checker/models.h" +#include "core/checker/models.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp index fe372ac..cc4a977 100644 --- a/src/core/checker/models.cpp +++ b/src/core/checker/models.cpp @@ -1,4 +1,4 @@ -#include "core/checker/models.h" +#include "core/checker/models.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/models.h b/src/core/checker/models.hpp similarity index 95% rename from src/core/checker/models.h rename to src/core/checker/models.hpp index f5b017f..72bcb58 100644 --- a/src/core/checker/models.h +++ b/src/core/checker/models.hpp @@ -8,8 +8,8 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" namespace px::modrim::checker { diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp index 2bffe8e..c78b6a4 100644 --- a/src/core/checker/sort.cpp +++ b/src/core/checker/sort.cpp @@ -1,4 +1,4 @@ -#include "core/checker/sort.h" +#include "core/checker/sort.hpp" #include #include @@ -6,7 +6,7 @@ #include #include -#include "core/sort/tiers.h" +#include "core/sort/tiers.hpp" namespace px::modrim::checker { namespace { diff --git a/src/core/checker/sort.h b/src/core/checker/sort.hpp similarity index 82% rename from src/core/checker/sort.h rename to src/core/checker/sort.hpp index fc7894b..ddbddaa 100644 --- a/src/core/checker/sort.h +++ b/src/core/checker/sort.hpp @@ -5,11 +5,11 @@ #include #include -#include "core/checker/graph.h" -#include "core/checker/models.h" -#include "core/profiler.h" -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/checker/graph.hpp" +#include "core/checker/models.hpp" +#include "core/profiler.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" namespace px::modrim::checker { diff --git a/src/core/config.cpp b/src/core/config.cpp index 98d6b71..3e298c6 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -1,4 +1,4 @@ -#include "core/config.h" +#include "core/config.hpp" #include #include @@ -15,7 +15,7 @@ #include #include -#include "core/constants.h" +#include "core/constants.hpp" namespace px::modrim { diff --git a/src/core/config.h b/src/core/config.hpp similarity index 98% rename from src/core/config.h rename to src/core/config.hpp index 3f12323..cb35c71 100644 --- a/src/core/config.h +++ b/src/core/config.hpp @@ -13,10 +13,10 @@ #include #include -#include "core/constants.h" -#include "core/error.h" -#include "core/file_io.h" -#include "core/sort/config.h" +#include "core/constants.hpp" +#include "core/error.hpp" +#include "core/file_io.hpp" +#include "core/sort/config.hpp" namespace px::modrim { diff --git a/src/core/constants.h b/src/core/constants.hpp similarity index 100% rename from src/core/constants.h rename to src/core/constants.hpp diff --git a/src/core/context.cpp b/src/core/context.cpp index c1f7a8e..b780113 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -1,4 +1,4 @@ -#include "core/context.h" +#include "core/context.hpp" #include #include @@ -6,15 +6,15 @@ #include #include -#include "core/downloads/downloader.h" -#include "core/downloads/manager.h" -#include "core/mod_service.h" -#include "core/profiler.h" -#include "core/providers/factory.h" -#include "core/services/activation_service.h" -#include "core/services/diagnostics_service.h" -#include "core/services/game_launcher.h" -#include "core/services/sort_service.h" +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/profiler.hpp" +#include "core/providers/factory.hpp" +#include "core/services/activation_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/game_launcher.hpp" +#include "core/services/sort_service.hpp" namespace px::modrim { diff --git a/src/core/context.h b/src/core/context.hpp similarity index 96% rename from src/core/context.h rename to src/core/context.hpp index b94181b..db09989 100644 --- a/src/core/context.h +++ b/src/core/context.hpp @@ -10,12 +10,12 @@ #include #include -#include "core/config.h" -#include "core/error.h" -#include "core/events.h" -#include "core/models/metadata/structures.h" -#include "core/plugin.h" -#include "core/structures.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" +#include "core/structures.hpp" namespace px::modrim { diff --git a/src/core/context_fwd.h b/src/core/context_fwd.hpp similarity index 100% rename from src/core/context_fwd.h rename to src/core/context_fwd.hpp diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp index b977bed..13092b4 100644 --- a/src/core/downloads/downloader.cpp +++ b/src/core/downloads/downloader.cpp @@ -1,8 +1,8 @@ -#include "core/downloads/downloader.h" +#include "core/downloads/downloader.hpp" #include -#include "core/downloads/manager.h" +#include "core/downloads/manager.hpp" namespace px::modrim { diff --git a/src/core/downloads/downloader.h b/src/core/downloads/downloader.hpp similarity index 93% rename from src/core/downloads/downloader.h rename to src/core/downloads/downloader.hpp index a7ede7b..93b6716 100644 --- a/src/core/downloads/downloader.h +++ b/src/core/downloads/downloader.hpp @@ -8,11 +8,11 @@ #include #include -#include "core/downloads/types.h" -#include "core/error.h" -#include "core/events.h" -#include "core/models/metadata/structures.h" -#include "core/plugin.h" +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" namespace px::modrim { diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp index 92ee89d..065e313 100644 --- a/src/core/downloads/manager.cpp +++ b/src/core/downloads/manager.cpp @@ -1,4 +1,4 @@ -#include "core/downloads/manager.h" +#include "core/downloads/manager.hpp" #include #include diff --git a/src/core/downloads/manager.h b/src/core/downloads/manager.hpp similarity index 91% rename from src/core/downloads/manager.h rename to src/core/downloads/manager.hpp index 93db6b2..006a059 100644 --- a/src/core/downloads/manager.h +++ b/src/core/downloads/manager.hpp @@ -10,12 +10,12 @@ #include #include -#include "core/downloads/downloader.h" -#include "core/downloads/types.h" -#include "core/error.h" -#include "core/events.h" -#include "core/models/metadata/structures.h" -#include "core/plugin.h" +#include "core/downloads/downloader.hpp" +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" namespace px::modrim { diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index f2df128..32aba3c 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -1,6 +1,6 @@ #ifdef PX_HAS_STEAM -#include "core/downloads/steam.h" +#include "core/downloads/steam.hpp" #include #include @@ -18,7 +18,7 @@ #include #include -#include "core/utils.h" +#include "core/utils.hpp" namespace px::modrim { namespace { diff --git a/src/core/downloads/steam.h b/src/core/downloads/steam.hpp similarity index 97% rename from src/core/downloads/steam.h rename to src/core/downloads/steam.hpp index 10ba828..f3a2839 100644 --- a/src/core/downloads/steam.h +++ b/src/core/downloads/steam.hpp @@ -27,12 +27,12 @@ #include #include -#include "core/config.h" -#include "core/downloads/downloader.h" -#include "core/downloads/types.h" -#include "core/error.h" -#include "core/models/metadata/structures.h" -#include "core/plugin_config.h" +#include "core/config.hpp" +#include "core/downloads/downloader.hpp" +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin_config.hpp" namespace px::modrim { diff --git a/src/core/downloads/types.h b/src/core/downloads/types.hpp similarity index 100% rename from src/core/downloads/types.h rename to src/core/downloads/types.hpp diff --git a/src/core/error.h b/src/core/error.hpp similarity index 100% rename from src/core/error.h rename to src/core/error.hpp diff --git a/src/core/events.h b/src/core/events.hpp similarity index 100% rename from src/core/events.h rename to src/core/events.hpp diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp index 62ff1f1..415ba21 100644 --- a/src/core/file_io.cpp +++ b/src/core/file_io.cpp @@ -1,4 +1,4 @@ -#include "core/file_io.h" +#include "core/file_io.hpp" #include #include diff --git a/src/core/file_io.h b/src/core/file_io.hpp similarity index 95% rename from src/core/file_io.h rename to src/core/file_io.hpp index 076bf90..79ec9af 100644 --- a/src/core/file_io.h +++ b/src/core/file_io.hpp @@ -5,7 +5,7 @@ #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { diff --git a/src/core/loading.cpp b/src/core/loading.cpp index 14f9e76..0cf6be9 100644 --- a/src/core/loading.cpp +++ b/src/core/loading.cpp @@ -1,4 +1,4 @@ -#include "core/loading.h" +#include "core/loading.hpp" #include diff --git a/src/core/loading.h b/src/core/loading.hpp similarity index 100% rename from src/core/loading.h rename to src/core/loading.hpp diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp index ca8744c..3b30e1b 100644 --- a/src/core/migrator.cpp +++ b/src/core/migrator.cpp @@ -1,4 +1,4 @@ -#include "core/migrator.h" +#include "core/migrator.hpp" #include #include @@ -7,7 +7,7 @@ #include #include -#include "core/file_io.h" +#include "core/file_io.hpp" namespace px::modrim { diff --git a/src/core/migrator.h b/src/core/migrator.hpp similarity index 97% rename from src/core/migrator.h rename to src/core/migrator.hpp index 0a1fcff..60b9172 100644 --- a/src/core/migrator.h +++ b/src/core/migrator.hpp @@ -7,7 +7,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp index 63051aa..c18e1ad 100644 --- a/src/core/mod_service.cpp +++ b/src/core/mod_service.cpp @@ -1,4 +1,4 @@ -#include "core/mod_service.h" +#include "core/mod_service.hpp" #include #include @@ -9,10 +9,10 @@ #include #include -#include "core/context.h" -#include "core/mods_config.h" -#include "core/services/diagnostics_service.h" -#include "core/services/mod_discovery.h" +#include "core/context.hpp" +#include "core/mods_config.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/mod_discovery.hpp" namespace px::modrim { diff --git a/src/core/mod_service.h b/src/core/mod_service.hpp similarity index 92% rename from src/core/mod_service.h rename to src/core/mod_service.hpp index 7a6d8a2..e7b3f25 100644 --- a/src/core/mod_service.h +++ b/src/core/mod_service.hpp @@ -10,12 +10,12 @@ #include #include -#include "core/error.h" -#include "core/events.h" -#include "core/profiler.h" -#include "core/providers/factory.h" -#include "core/services/metadata_cache.h" -#include "core/services/startup_impact_service/service.h" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/profiler.hpp" +#include "core/providers/factory.hpp" +#include "core/services/metadata_cache.hpp" +#include "core/services/startup_impact_service/service.hpp" namespace px::modrim { diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 045e7db..3bd0e48 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -1,4 +1,4 @@ -#include "core/models/metadata/parsing.h" +#include "core/models/metadata/parsing.hpp" #include @@ -6,9 +6,9 @@ #include #include -#include "core/constants.h" -#include "core/utils.h" -#include "core/xml.h" +#include "core/constants.hpp" +#include "core/utils.hpp" +#include "core/xml.hpp" namespace px::modrim { diff --git a/src/core/models/metadata/parsing.h b/src/core/models/metadata/parsing.hpp similarity index 94% rename from src/core/models/metadata/parsing.h rename to src/core/models/metadata/parsing.hpp index eb4b6db..e16ab9e 100644 --- a/src/core/models/metadata/parsing.h +++ b/src/core/models/metadata/parsing.hpp @@ -6,8 +6,8 @@ #include #include -#include "core/error.h" -#include "core/models/metadata/structures.h" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" namespace px::modrim { diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp index a9af353..c9591b6 100644 --- a/src/core/models/metadata/structures.cpp +++ b/src/core/models/metadata/structures.cpp @@ -1,12 +1,12 @@ -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" #include #include #include #include -#include "core/constants.h" -#include "core/utils.h" +#include "core/constants.hpp" +#include "core/utils.hpp" namespace px::modrim { diff --git a/src/core/models/metadata/structures.h b/src/core/models/metadata/structures.hpp similarity index 99% rename from src/core/models/metadata/structures.h rename to src/core/models/metadata/structures.hpp index a1b0924..c0bd715 100644 --- a/src/core/models/metadata/structures.h +++ b/src/core/models/metadata/structures.hpp @@ -16,7 +16,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { diff --git a/src/core/models/view/diagnostics.h b/src/core/models/view/diagnostics.hpp similarity index 100% rename from src/core/models/view/diagnostics.h rename to src/core/models/view/diagnostics.hpp diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp index a1616ab..06a493b 100644 --- a/src/core/models/view/sidebar.cpp +++ b/src/core/models/view/sidebar.cpp @@ -1,4 +1,4 @@ -#include "core/models/view/sidebar.h" +#include "core/models/view/sidebar.hpp" #include diff --git a/src/core/models/view/sidebar.h b/src/core/models/view/sidebar.hpp similarity index 100% rename from src/core/models/view/sidebar.h rename to src/core/models/view/sidebar.hpp diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp index 57a8bf2..5164687 100644 --- a/src/core/mods_config.cpp +++ b/src/core/mods_config.cpp @@ -1,4 +1,4 @@ -#include "core/mods_config.h" +#include "core/mods_config.hpp" #include #include @@ -18,9 +18,9 @@ #include #include -#include "core/constants.h" -#include "core/file_io.h" -#include "core/xml.h" +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "core/xml.hpp" namespace px::modrim { diff --git a/src/core/mods_config.h b/src/core/mods_config.hpp similarity index 94% rename from src/core/mods_config.h rename to src/core/mods_config.hpp index dea5015..c49651e 100644 --- a/src/core/mods_config.h +++ b/src/core/mods_config.hpp @@ -5,8 +5,8 @@ #include #include -#include "core/error.h" -#include "core/models/metadata/structures.h" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" namespace px::modrim { diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp index 976a1cc..a7f7fd0 100644 --- a/src/core/organizer/db.cpp +++ b/src/core/organizer/db.cpp @@ -1,4 +1,4 @@ -#include "core/organizer/db.h" +#include "core/organizer/db.hpp" #include #include @@ -13,7 +13,7 @@ #include #include -#include "core/migrator.h" +#include "core/migrator.hpp" namespace px::modrim::organizer { diff --git a/src/core/organizer/db.h b/src/core/organizer/db.hpp similarity index 96% rename from src/core/organizer/db.h rename to src/core/organizer/db.hpp index 7fe6b09..aa49568 100644 --- a/src/core/organizer/db.h +++ b/src/core/organizer/db.hpp @@ -6,9 +6,9 @@ #include #include -#include "core/error.h" -#include "core/organizer/defaults.h" -#include "core/organizer/models.h" +#include "core/error.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" namespace px::modrim::organizer { diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp index 89bf5c3..9703cc3 100644 --- a/src/core/organizer/defaults.cpp +++ b/src/core/organizer/defaults.cpp @@ -1,4 +1,4 @@ -#include "core/organizer/defaults.h" +#include "core/organizer/defaults.hpp" #include diff --git a/src/core/organizer/defaults.h b/src/core/organizer/defaults.hpp similarity index 92% rename from src/core/organizer/defaults.h rename to src/core/organizer/defaults.hpp index cbfacad..13def2f 100644 --- a/src/core/organizer/defaults.h +++ b/src/core/organizer/defaults.hpp @@ -3,7 +3,7 @@ #include #include -#include "core/organizer/models.h" +#include "core/organizer/models.hpp" namespace px::modrim::organizer { diff --git a/src/core/organizer/models.h b/src/core/organizer/models.hpp similarity index 100% rename from src/core/organizer/models.h rename to src/core/organizer/models.hpp diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp index 39d7f7c..5348814 100644 --- a/src/core/organizer/resolve.cpp +++ b/src/core/organizer/resolve.cpp @@ -1,9 +1,9 @@ -#include "core/organizer/resolve.h" +#include "core/organizer/resolve.hpp" #include #include -#include "core/providers/labels.h" +#include "core/providers/labels.hpp" namespace px::modrim::organizer { diff --git a/src/core/organizer/resolve.h b/src/core/organizer/resolve.hpp similarity index 97% rename from src/core/organizer/resolve.h rename to src/core/organizer/resolve.hpp index 8d04263..29d5f5b 100644 --- a/src/core/organizer/resolve.h +++ b/src/core/organizer/resolve.hpp @@ -8,8 +8,8 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/organizer/models.h" +#include "core/models/metadata/structures.hpp" +#include "core/organizer/models.hpp" namespace px::modrim::organizer { diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index 19f94b3..fc6ff57 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -1,4 +1,4 @@ -#include "core/organizer/service.h" +#include "core/organizer/service.hpp" #include #include @@ -8,8 +8,8 @@ #include #include -#include "core/context.h" -#include "core/organizer/defaults.h" +#include "core/context.hpp" +#include "core/organizer/defaults.hpp" namespace px::modrim::organizer { namespace { diff --git a/src/core/organizer/service.h b/src/core/organizer/service.hpp similarity index 96% rename from src/core/organizer/service.h rename to src/core/organizer/service.hpp index 3648cec..745cdce 100644 --- a/src/core/organizer/service.h +++ b/src/core/organizer/service.hpp @@ -9,13 +9,13 @@ #include #include -#include "core/context_fwd.h" -#include "core/error.h" -#include "core/events.h" -#include "core/organizer/db.h" -#include "core/organizer/models.h" -#include "core/organizer/resolve.h" -#include "core/plugin.h" +#include "core/context_fwd.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" +#include "core/plugin.hpp" namespace px::modrim { class CoreContext; diff --git a/src/core/plugin.h b/src/core/plugin.hpp similarity index 98% rename from src/core/plugin.h rename to src/core/plugin.hpp index 01357ba..6d1d32f 100644 --- a/src/core/plugin.h +++ b/src/core/plugin.hpp @@ -11,9 +11,9 @@ #include #include -#include "core/context_fwd.h" -#include "core/error.h" -#include "core/toposort.h" +#include "core/context_fwd.hpp" +#include "core/error.hpp" +#include "core/toposort.hpp" namespace px::modrim { diff --git a/src/core/plugin_config.h b/src/core/plugin_config.hpp similarity index 98% rename from src/core/plugin_config.h rename to src/core/plugin_config.hpp index 00ac739..166e3c4 100644 --- a/src/core/plugin_config.h +++ b/src/core/plugin_config.hpp @@ -10,8 +10,8 @@ #include #include -#include "core/config.h" -#include "core/events.h" +#include "core/config.hpp" +#include "core/events.hpp" namespace px::modrim { diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp index 06878f7..f7e94c6 100644 --- a/src/core/profiler.cpp +++ b/src/core/profiler.cpp @@ -1,4 +1,4 @@ -#include "core/profiler.h" +#include "core/profiler.hpp" #include diff --git a/src/core/profiler.h b/src/core/profiler.hpp similarity index 100% rename from src/core/profiler.h rename to src/core/profiler.hpp diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp index 7d56d23..9532d31 100644 --- a/src/core/providers/base.cpp +++ b/src/core/providers/base.cpp @@ -1,11 +1,11 @@ -#include "core/providers/base.h" +#include "core/providers/base.hpp" #include #include #include -#include "core/models/metadata/parsing.h" -#include "core/utils.h" +#include "core/models/metadata/parsing.hpp" +#include "core/utils.hpp" namespace px::modrim::providers { diff --git a/src/core/providers/base.h b/src/core/providers/base.hpp similarity index 97% rename from src/core/providers/base.h rename to src/core/providers/base.hpp index 257e12b..f7bf69d 100644 --- a/src/core/providers/base.h +++ b/src/core/providers/base.hpp @@ -13,9 +13,9 @@ #include #include -#include "core/error.h" -#include "core/models/metadata/structures.h" -#include "core/profiler.h" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/profiler.hpp" namespace px::modrim::providers { diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp index 05b8405..9f7f758 100644 --- a/src/core/providers/core.cpp +++ b/src/core/providers/core.cpp @@ -1,4 +1,4 @@ -#include "core/providers/core.h" +#include "core/providers/core.hpp" #include #include diff --git a/src/core/providers/core.h b/src/core/providers/core.hpp similarity index 94% rename from src/core/providers/core.h rename to src/core/providers/core.hpp index 3b807c0..2161fad 100644 --- a/src/core/providers/core.h +++ b/src/core/providers/core.hpp @@ -1,6 +1,6 @@ #pragma once -#include "core/providers/base.h" +#include "core/providers/base.hpp" namespace px::modrim::providers { diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp index 1ab48f9..74ffc66 100644 --- a/src/core/providers/factory.cpp +++ b/src/core/providers/factory.cpp @@ -1,7 +1,7 @@ -#include "core/providers/factory.h" +#include "core/providers/factory.hpp" -#include "core/providers/core.h" -#include "core/providers/local.h" +#include "core/providers/core.hpp" +#include "core/providers/local.hpp" namespace px::modrim::providers { diff --git a/src/core/providers/factory.h b/src/core/providers/factory.hpp similarity index 89% rename from src/core/providers/factory.h rename to src/core/providers/factory.hpp index da71e5c..a5be4b8 100644 --- a/src/core/providers/factory.h +++ b/src/core/providers/factory.hpp @@ -5,8 +5,8 @@ #include -#include "core/config.h" -#include "core/providers/base.h" +#include "core/config.hpp" +#include "core/providers/base.hpp" namespace px::modrim::providers { diff --git a/src/core/providers/labels.h b/src/core/providers/labels.hpp similarity index 100% rename from src/core/providers/labels.h rename to src/core/providers/labels.hpp diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp index 1db2567..e74f770 100644 --- a/src/core/providers/local.cpp +++ b/src/core/providers/local.cpp @@ -1,4 +1,4 @@ -#include "core/providers/local.h" +#include "core/providers/local.hpp" #include #include diff --git a/src/core/providers/local.h b/src/core/providers/local.hpp similarity index 97% rename from src/core/providers/local.h rename to src/core/providers/local.hpp index 7b508dd..cb9110d 100644 --- a/src/core/providers/local.h +++ b/src/core/providers/local.hpp @@ -1,6 +1,6 @@ #pragma once -#include "core/providers/base.h" +#include "core/providers/base.hpp" namespace px::modrim::providers { diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp index 7098cd0..17c9302 100644 --- a/src/core/services/activation_service.cpp +++ b/src/core/services/activation_service.cpp @@ -1,4 +1,4 @@ -#include "core/services/activation_service.h" +#include "core/services/activation_service.hpp" #include #include @@ -11,12 +11,12 @@ #include #include -#include "core/checker/graph.h" -#include "core/context.h" -#include "core/models/metadata/structures.h" -#include "core/services/diagnostics_service.h" -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/checker/graph.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" namespace px::modrim { namespace { diff --git a/src/core/services/activation_service.h b/src/core/services/activation_service.hpp similarity index 100% rename from src/core/services/activation_service.h rename to src/core/services/activation_service.hpp diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp index ac641e3..cb8361e 100644 --- a/src/core/services/diagnostics_service.cpp +++ b/src/core/services/diagnostics_service.cpp @@ -1,4 +1,4 @@ -#include "core/services/diagnostics_service.h" +#include "core/services/diagnostics_service.hpp" #include #include @@ -10,9 +10,9 @@ #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/sort/community.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/sort/community.hpp" namespace px::modrim { namespace { diff --git a/src/core/services/diagnostics_service.h b/src/core/services/diagnostics_service.hpp similarity index 91% rename from src/core/services/diagnostics_service.h rename to src/core/services/diagnostics_service.hpp index 6662fcf..87428c6 100644 --- a/src/core/services/diagnostics_service.h +++ b/src/core/services/diagnostics_service.hpp @@ -10,16 +10,16 @@ #include #include -#include "core/checker/checker.h" -#include "core/checker/databases.h" -#include "core/checker/graph.h" -#include "core/checker/models.h" -#include "core/error.h" -#include "core/events.h" -#include "core/models/view/diagnostics.h" -#include "core/models/view/sidebar.h" -#include "core/profiler.h" -#include "core/sort/models.h" +#include "core/checker/checker.hpp" +#include "core/checker/databases.hpp" +#include "core/checker/graph.hpp" +#include "core/checker/models.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/view/diagnostics.hpp" +#include "core/models/view/sidebar.hpp" +#include "core/profiler.hpp" +#include "core/sort/models.hpp" class QNetworkAccessManager; diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp index d09f58b..882cd8b 100644 --- a/src/core/services/game_launcher.cpp +++ b/src/core/services/game_launcher.cpp @@ -1,4 +1,4 @@ -#include "core/services/game_launcher.h" +#include "core/services/game_launcher.hpp" #include #include @@ -8,8 +8,8 @@ #include #include -#include "core/context.h" -#include "core/providers/base.h" +#include "core/context.hpp" +#include "core/providers/base.hpp" #if defined(Q_OS_UNIX) #include diff --git a/src/core/services/game_launcher.h b/src/core/services/game_launcher.hpp similarity index 92% rename from src/core/services/game_launcher.h rename to src/core/services/game_launcher.hpp index d019811..4fe4f14 100644 --- a/src/core/services/game_launcher.h +++ b/src/core/services/game_launcher.hpp @@ -6,9 +6,9 @@ #include #include -#include "core/constants.h" -#include "core/error.h" -#include "core/services/game_process.h" +#include "core/constants.hpp" +#include "core/error.hpp" +#include "core/services/game_process.hpp" namespace px::modrim { class CoreContext; diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 97803b5..45cfc92 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -1,4 +1,4 @@ -#include "core/services/game_process.h" +#include "core/services/game_process.hpp" #include #include @@ -14,9 +14,9 @@ #include #include -#include "core/constants.h" -#include "core/file_io.h" -#include "core/providers/base.h" +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "core/providers/base.hpp" #if defined(Q_OS_LINUX) #include diff --git a/src/core/services/game_process.h b/src/core/services/game_process.hpp similarity index 99% rename from src/core/services/game_process.h rename to src/core/services/game_process.hpp index a5339ba..4825489 100644 --- a/src/core/services/game_process.h +++ b/src/core/services/game_process.hpp @@ -13,7 +13,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim::services { diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp index efb6f56..3a98d4f 100644 --- a/src/core/services/metadata_cache.cpp +++ b/src/core/services/metadata_cache.cpp @@ -1,4 +1,4 @@ -#include "core/services/metadata_cache.h" +#include "core/services/metadata_cache.hpp" #include #include @@ -17,8 +17,8 @@ #include #include -#include "core/migrator.h" -#include "core/models/metadata/structures.h" +#include "core/migrator.hpp" +#include "core/models/metadata/structures.hpp" namespace px::modrim::services { diff --git a/src/core/services/metadata_cache.h b/src/core/services/metadata_cache.hpp similarity index 98% rename from src/core/services/metadata_cache.h rename to src/core/services/metadata_cache.hpp index f26f5b5..37cb8a8 100644 --- a/src/core/services/metadata_cache.h +++ b/src/core/services/metadata_cache.hpp @@ -9,7 +9,7 @@ #include #include -#include "core/providers/base.h" +#include "core/providers/base.hpp" namespace px::modrim::services { diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp index faeaee4..befaf41 100644 --- a/src/core/services/mod_discovery.cpp +++ b/src/core/services/mod_discovery.cpp @@ -1,4 +1,4 @@ -#include "core/services/mod_discovery.h" +#include "core/services/mod_discovery.hpp" #include #include @@ -6,7 +6,7 @@ #include #include -#include "core/mods_config.h" +#include "core/mods_config.hpp" namespace px::modrim::services { diff --git a/src/core/services/mod_discovery.h b/src/core/services/mod_discovery.hpp similarity index 92% rename from src/core/services/mod_discovery.h rename to src/core/services/mod_discovery.hpp index 1885239..d28448e 100644 --- a/src/core/services/mod_discovery.h +++ b/src/core/services/mod_discovery.hpp @@ -4,7 +4,7 @@ #include #include -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" namespace px::modrim::services { diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp index 9dffd66..270bfee 100644 --- a/src/core/services/sort_service.cpp +++ b/src/core/services/sort_service.cpp @@ -1,4 +1,4 @@ -#include "core/services/sort_service.h" +#include "core/services/sort_service.hpp" #include #include @@ -6,10 +6,10 @@ #include #include -#include "core/checker/sort.h" -#include "core/context.h" -#include "core/profiler.h" -#include "core/services/diagnostics_service.h" +#include "core/checker/sort.hpp" +#include "core/context.hpp" +#include "core/profiler.hpp" +#include "core/services/diagnostics_service.hpp" namespace px::modrim { diff --git a/src/core/services/sort_service.h b/src/core/services/sort_service.hpp similarity index 95% rename from src/core/services/sort_service.h rename to src/core/services/sort_service.hpp index 9674bfe..c0aa651 100644 --- a/src/core/services/sort_service.h +++ b/src/core/services/sort_service.hpp @@ -6,8 +6,8 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" namespace px::modrim { diff --git a/src/core/services/startup_impact_service/db.cpp b/src/core/services/startup_impact_service/db.cpp index 8e35fba..f0fbfa1 100644 --- a/src/core/services/startup_impact_service/db.cpp +++ b/src/core/services/startup_impact_service/db.cpp @@ -1,4 +1,4 @@ -#include "core/services/startup_impact_service/db.h" +#include "core/services/startup_impact_service/db.hpp" #include @@ -16,7 +16,7 @@ #include #include -#include "core/migrator.h" +#include "core/migrator.hpp" namespace px::modrim::startup_impact { diff --git a/src/core/services/startup_impact_service/db.h b/src/core/services/startup_impact_service/db.hpp similarity index 96% rename from src/core/services/startup_impact_service/db.h rename to src/core/services/startup_impact_service/db.hpp index 8482842..001ba07 100644 --- a/src/core/services/startup_impact_service/db.h +++ b/src/core/services/startup_impact_service/db.hpp @@ -8,8 +8,8 @@ #include #include -#include "core/error.h" -#include "core/services/startup_impact_service/models.h" +#include "core/error.hpp" +#include "core/services/startup_impact_service/models.hpp" namespace px::modrim::startup_impact { diff --git a/src/core/services/startup_impact_service/labels.cpp b/src/core/services/startup_impact_service/labels.cpp index 671942b..b2c34db 100644 --- a/src/core/services/startup_impact_service/labels.cpp +++ b/src/core/services/startup_impact_service/labels.cpp @@ -1,4 +1,4 @@ -#include "core/services/startup_impact_service/labels.h" +#include "core/services/startup_impact_service/labels.hpp" #include #include diff --git a/src/core/services/startup_impact_service/labels.h b/src/core/services/startup_impact_service/labels.hpp similarity index 100% rename from src/core/services/startup_impact_service/labels.h rename to src/core/services/startup_impact_service/labels.hpp diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp index 1a6f399..0a811bf 100644 --- a/src/core/services/startup_impact_service/models.cpp +++ b/src/core/services/startup_impact_service/models.cpp @@ -1,4 +1,4 @@ -#include "core/services/startup_impact_service/models.h" +#include "core/services/startup_impact_service/models.hpp" #include diff --git a/src/core/services/startup_impact_service/models.h b/src/core/services/startup_impact_service/models.hpp similarity index 100% rename from src/core/services/startup_impact_service/models.h rename to src/core/services/startup_impact_service/models.hpp diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp index b2aa2d6..15acfac 100644 --- a/src/core/services/startup_impact_service/parser.cpp +++ b/src/core/services/startup_impact_service/parser.cpp @@ -1,4 +1,4 @@ -#include "core/services/startup_impact_service/parser.h" +#include "core/services/startup_impact_service/parser.hpp" #include #include diff --git a/src/core/services/startup_impact_service/parser.h b/src/core/services/startup_impact_service/parser.hpp similarity index 90% rename from src/core/services/startup_impact_service/parser.h rename to src/core/services/startup_impact_service/parser.hpp index bc618ff..41425b0 100644 --- a/src/core/services/startup_impact_service/parser.h +++ b/src/core/services/startup_impact_service/parser.hpp @@ -4,7 +4,7 @@ #include -#include "core/services/startup_impact_service/models.h" +#include "core/services/startup_impact_service/models.hpp" namespace px::modrim::startup_impact { diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp index e533931..c4408ef 100644 --- a/src/core/services/startup_impact_service/phases.cpp +++ b/src/core/services/startup_impact_service/phases.cpp @@ -1,4 +1,4 @@ -#include "core/services/startup_impact_service/phases.h" +#include "core/services/startup_impact_service/phases.hpp" #include #include @@ -6,7 +6,7 @@ #include #include -#include "core/services/startup_impact_service/labels.h" +#include "core/services/startup_impact_service/labels.hpp" namespace px::modrim::startup_impact { diff --git a/src/core/services/startup_impact_service/phases.h b/src/core/services/startup_impact_service/phases.hpp similarity index 100% rename from src/core/services/startup_impact_service/phases.h rename to src/core/services/startup_impact_service/phases.hpp diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp index cfaa521..2681bf7 100644 --- a/src/core/services/startup_impact_service/service.cpp +++ b/src/core/services/startup_impact_service/service.cpp @@ -1,13 +1,13 @@ -#include "core/services/startup_impact_service/service.h" +#include "core/services/startup_impact_service/service.hpp" #include #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/services/startup_impact_service/parser.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/services/startup_impact_service/parser.hpp" namespace px::modrim::startup_impact { diff --git a/src/core/services/startup_impact_service/service.h b/src/core/services/startup_impact_service/service.hpp similarity index 91% rename from src/core/services/startup_impact_service/service.h rename to src/core/services/startup_impact_service/service.hpp index 383ac6e..d3333ab 100644 --- a/src/core/services/startup_impact_service/service.h +++ b/src/core/services/startup_impact_service/service.hpp @@ -7,9 +7,9 @@ #include #include -#include "core/error.h" -#include "core/services/startup_impact_service/db.h" -#include "core/services/startup_impact_service/models.h" +#include "core/error.hpp" +#include "core/services/startup_impact_service/db.hpp" +#include "core/services/startup_impact_service/models.hpp" namespace px::modrim { class CoreContext; diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp index 41bce42..184f0f5 100644 --- a/src/core/services/update_service.cpp +++ b/src/core/services/update_service.cpp @@ -1,4 +1,4 @@ -#include "core/services/update_service.h" +#include "core/services/update_service.hpp" #include #include diff --git a/src/core/services/update_service.h b/src/core/services/update_service.hpp similarity index 98% rename from src/core/services/update_service.h rename to src/core/services/update_service.hpp index bcd6bce..2f24dd8 100644 --- a/src/core/services/update_service.h +++ b/src/core/services/update_service.hpp @@ -7,7 +7,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" class QNetworkAccessManager; diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp index ac3d7e3..506450b 100644 --- a/src/core/sort/community.cpp +++ b/src/core/sort/community.cpp @@ -1,11 +1,11 @@ -#include "core/sort/community.h" +#include "core/sort/community.hpp" #include #include -#include "core/config.h" -#include "core/file_io.h" +#include "core/config.hpp" +#include "core/file_io.hpp" namespace px::modrim::sort { diff --git a/src/core/sort/community.h b/src/core/sort/community.hpp similarity index 82% rename from src/core/sort/community.h rename to src/core/sort/community.hpp index 751d728..5784d48 100644 --- a/src/core/sort/community.h +++ b/src/core/sort/community.hpp @@ -2,9 +2,9 @@ #include -#include "core/error.h" -#include "core/models/metadata/structures.h" -#include "core/sort/models.h" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/models.hpp" namespace px::modrim::sort { diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp index 01718b9..ef9f147 100644 --- a/src/core/sort/community_service.cpp +++ b/src/core/sort/community_service.cpp @@ -1,6 +1,6 @@ -#include "core/sort/community_service.h" +#include "core/sort/community_service.hpp" -#include "core/sort/community.h" +#include "core/sort/community.hpp" #include #include @@ -17,7 +17,7 @@ #include #include -#include "core/file_io.h" +#include "core/file_io.hpp" namespace px::modrim::sort { diff --git a/src/core/sort/community_service.h b/src/core/sort/community_service.hpp similarity index 90% rename from src/core/sort/community_service.h rename to src/core/sort/community_service.hpp index 47537d7..81fa592 100644 --- a/src/core/sort/community_service.h +++ b/src/core/sort/community_service.hpp @@ -5,10 +5,10 @@ #include #include -#include "core/config.h" -#include "core/error.h" -#include "core/loading.h" -#include "core/sort/models.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/loading.hpp" +#include "core/sort/models.hpp" class QNetworkAccessManager; diff --git a/src/core/sort/config.cpp b/src/core/sort/config.cpp index d5e6495..508e0de 100644 --- a/src/core/sort/config.cpp +++ b/src/core/sort/config.cpp @@ -1,4 +1,4 @@ -#include "core/sort/config.h" +#include "core/sort/config.hpp" #include diff --git a/src/core/sort/config.h b/src/core/sort/config.hpp similarity index 94% rename from src/core/sort/config.h rename to src/core/sort/config.hpp index eefe225..3780e49 100644 --- a/src/core/sort/config.h +++ b/src/core/sort/config.hpp @@ -2,7 +2,7 @@ #include -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" namespace px::modrim::sort { diff --git a/src/core/sort/models.h b/src/core/sort/models.hpp similarity index 89% rename from src/core/sort/models.h rename to src/core/sort/models.hpp index 925aabf..807232a 100644 --- a/src/core/sort/models.h +++ b/src/core/sort/models.hpp @@ -3,8 +3,8 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" namespace px::modrim::sort { diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp index c289226..da3e255 100644 --- a/src/core/sort/tiers.cpp +++ b/src/core/sort/tiers.cpp @@ -1,4 +1,4 @@ -#include "core/sort/tiers.h" +#include "core/sort/tiers.hpp" #include #include diff --git a/src/core/sort/tiers.h b/src/core/sort/tiers.hpp similarity index 95% rename from src/core/sort/tiers.h rename to src/core/sort/tiers.hpp index 71f4f20..2fb1195 100644 --- a/src/core/sort/tiers.h +++ b/src/core/sort/tiers.hpp @@ -5,8 +5,8 @@ #include #include -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" namespace px::modrim::sort { diff --git a/src/core/structures.h b/src/core/structures.hpp similarity index 100% rename from src/core/structures.h rename to src/core/structures.hpp diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp index 4d19fc5..d1d68b7 100644 --- a/src/core/support/report.cpp +++ b/src/core/support/report.cpp @@ -1,4 +1,4 @@ -#include "core/support/report.h" +#include "core/support/report.hpp" #include #include @@ -19,8 +19,8 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/version.h" +#include "core/models/metadata/structures.hpp" +#include "core/version.hpp" #if defined(Q_OS_UNIX) #include diff --git a/src/core/support/report.h b/src/core/support/report.hpp similarity index 95% rename from src/core/support/report.h rename to src/core/support/report.hpp index eca32ed..74f137c 100644 --- a/src/core/support/report.h +++ b/src/core/support/report.hpp @@ -9,9 +9,9 @@ #include #include -#include "core/config.h" -#include "core/error.h" -#include "core/models/metadata/structures.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" class QNetworkAccessManager; diff --git a/src/core/toposort.h b/src/core/toposort.hpp similarity index 98% rename from src/core/toposort.h rename to src/core/toposort.hpp index 9fb5f29..fd91710 100644 --- a/src/core/toposort.h +++ b/src/core/toposort.hpp @@ -9,7 +9,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { diff --git a/src/core/utils.cpp b/src/core/utils.cpp index 9317951..c5ecc6d 100644 --- a/src/core/utils.cpp +++ b/src/core/utils.cpp @@ -1,4 +1,4 @@ -#include "core/utils.h" +#include "core/utils.hpp" #include #include diff --git a/src/core/utils.h b/src/core/utils.hpp similarity index 100% rename from src/core/utils.h rename to src/core/utils.hpp diff --git a/src/core/version.cpp b/src/core/version.cpp index 1c4f6fe..8695f51 100644 --- a/src/core/version.cpp +++ b/src/core/version.cpp @@ -1,4 +1,4 @@ -#include "core/version.h" +#include "core/version.hpp" #include #include diff --git a/src/core/version.h b/src/core/version.hpp similarity index 100% rename from src/core/version.h rename to src/core/version.hpp diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index 60653e4..dc2cdb6 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -1,4 +1,4 @@ -#include "core/workshop/catalog.h" +#include "core/workshop/catalog.hpp" #include #include @@ -9,8 +9,8 @@ #include #include -#include "core/context.h" -#include "core/mod_service.h" +#include "core/context.hpp" +#include "core/mod_service.hpp" namespace px::modrim::workshop { diff --git a/src/core/workshop/catalog.h b/src/core/workshop/catalog.hpp similarity index 96% rename from src/core/workshop/catalog.h rename to src/core/workshop/catalog.hpp index a35f99a..0346d2a 100644 --- a/src/core/workshop/catalog.h +++ b/src/core/workshop/catalog.hpp @@ -22,17 +22,17 @@ #include #include -#include "core/config.h" -#include "core/downloads/manager.h" -#include "core/error.h" -#include "core/events.h" -#include "core/file_io.h" -#include "core/models/metadata/structures.h" -#include "core/plugin.h" -#include "core/plugin_config.h" -#include "core/workshop/client.h" -#include "core/workshop/favourites.h" -#include "core/workshop/types.h" +#include "core/config.hpp" +#include "core/downloads/manager.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/file_io.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" +#include "core/plugin_config.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/favourites.hpp" +#include "core/workshop/types.hpp" namespace px::modrim::workshop { diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index 42ddc44..ca09f8c 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -1,4 +1,4 @@ -#include "core/workshop/client.h" +#include "core/workshop/client.hpp" #include #include diff --git a/src/core/workshop/client.h b/src/core/workshop/client.hpp similarity index 98% rename from src/core/workshop/client.h rename to src/core/workshop/client.hpp index 1f22de1..da08e83 100644 --- a/src/core/workshop/client.h +++ b/src/core/workshop/client.hpp @@ -12,7 +12,7 @@ #include #include -#include "core/workshop/types.h" +#include "core/workshop/types.hpp" namespace px::modrim::workshop { diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp index d87eb26..b5e19c4 100644 --- a/src/core/workshop/favourites.cpp +++ b/src/core/workshop/favourites.cpp @@ -1,4 +1,4 @@ -#include "core/workshop/favourites.h" +#include "core/workshop/favourites.hpp" #include #include diff --git a/src/core/workshop/favourites.h b/src/core/workshop/favourites.hpp similarity index 91% rename from src/core/workshop/favourites.h rename to src/core/workshop/favourites.hpp index 3b49d72..d0d8830 100644 --- a/src/core/workshop/favourites.h +++ b/src/core/workshop/favourites.hpp @@ -4,10 +4,10 @@ #include #include -#include "core/config.h" -#include "core/events.h" -#include "core/plugin_config.h" -#include "core/workshop/types.h" +#include "core/config.hpp" +#include "core/events.hpp" +#include "core/plugin_config.hpp" +#include "core/workshop/types.hpp" namespace px::modrim::workshop { diff --git a/src/core/workshop/types.h b/src/core/workshop/types.hpp similarity index 100% rename from src/core/workshop/types.h rename to src/core/workshop/types.hpp diff --git a/src/core/xml.cpp b/src/core/xml.cpp index 7ab072d..d69c066 100644 --- a/src/core/xml.cpp +++ b/src/core/xml.cpp @@ -1,4 +1,4 @@ -#include "core/xml.h" +#include "core/xml.hpp" #include #include diff --git a/src/core/xml.h b/src/core/xml.hpp similarity index 97% rename from src/core/xml.h rename to src/core/xml.hpp index 4393b43..f6ea6b3 100644 --- a/src/core/xml.h +++ b/src/core/xml.hpp @@ -6,7 +6,7 @@ #include #include -#include "core/error.h" +#include "core/error.hpp" namespace px::modrim { diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index acc5cc2..ca4c558 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -4,7 +4,7 @@ # type names only resolve within one flat module. file(GLOB_RECURSE PX_UI_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) -file(GLOB_RECURSE PX_UI_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.h) +file(GLOB_RECURSE PX_UI_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.hpp) file(GLOB PX_UI_QML CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.qml ${CMAKE_CURRENT_SOURCE_DIR}/*.js) # Resources are addressed relative to this directory: the stylesheet is diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp index b01d00f..d660811 100644 --- a/src/ui/components/banner_widget.cpp +++ b/src/ui/components/banner_widget.cpp @@ -1,4 +1,4 @@ -#include "ui/components/banner_widget.h" +#include "ui/components/banner_widget.hpp" #include @@ -10,7 +10,7 @@ #include #include -#include "ui/theme/palette.h" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/banner_widget.h b/src/ui/components/banner_widget.hpp similarity index 96% rename from src/ui/components/banner_widget.h rename to src/ui/components/banner_widget.hpp index 0570265..84f75fb 100644 --- a/src/ui/components/banner_widget.h +++ b/src/ui/components/banner_widget.hpp @@ -4,7 +4,7 @@ #include #include -#include "ui/theme/constants.h" +#include "ui/theme/constants.hpp" class QPaintEvent; class QResizeEvent; diff --git a/src/ui/components/button.cpp b/src/ui/components/button.cpp index 4b8e835..3d30f9b 100644 --- a/src/ui/components/button.cpp +++ b/src/ui/components/button.cpp @@ -1,4 +1,4 @@ -#include "ui/components/button.h" +#include "ui/components/button.hpp" #include diff --git a/src/ui/components/button.h b/src/ui/components/button.hpp similarity index 100% rename from src/ui/components/button.h rename to src/ui/components/button.hpp diff --git a/src/ui/components/components.h b/src/ui/components/components.h deleted file mode 100644 index 63b0087..0000000 --- a/src/ui/components/components.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "ui/components/banner_widget.h" -#include "ui/components/button.h" -#include "ui/components/header_controller.h" -#include "ui/components/header_panel.h" -#include "ui/components/icon_button.h" -#include "ui/components/icons.h" -#include "ui/components/procedural_preview.h" -#include "ui/components/progress_dialog.h" -#include "ui/components/svg_provider.h" -#include "ui/components/toast.h" -#include "ui/components/view_rail_panel.h" diff --git a/src/ui/components/components.hpp b/src/ui/components/components.hpp new file mode 100644 index 0000000..c3d130c --- /dev/null +++ b/src/ui/components/components.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "ui/components/banner_widget.hpp" +#include "ui/components/button.hpp" +#include "ui/components/header_controller.hpp" +#include "ui/components/header_panel.hpp" +#include "ui/components/icon_button.hpp" +#include "ui/components/icons.hpp" +#include "ui/components/procedural_preview.hpp" +#include "ui/components/progress_dialog.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/components/toast.hpp" +#include "ui/components/view_rail_panel.hpp" diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp index 9c07a8c..1907dda 100644 --- a/src/ui/components/dialog_chrome.cpp +++ b/src/ui/components/dialog_chrome.cpp @@ -1,4 +1,4 @@ -#include "ui/components/dialog_chrome.h" +#include "ui/components/dialog_chrome.hpp" #include #include @@ -17,8 +17,8 @@ #include #include -#include "ui/components/icons.h" -#include "ui/theme/palette.h" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/dialog_chrome.h b/src/ui/components/dialog_chrome.hpp similarity index 100% rename from src/ui/components/dialog_chrome.h rename to src/ui/components/dialog_chrome.hpp diff --git a/src/ui/components/dialogs.h b/src/ui/components/dialogs.hpp similarity index 97% rename from src/ui/components/dialogs.h rename to src/ui/components/dialogs.hpp index 51659b7..ace0d50 100644 --- a/src/ui/components/dialogs.h +++ b/src/ui/components/dialogs.hpp @@ -13,7 +13,7 @@ #include #include -#include "ui/components/dialog_chrome.h" +#include "ui/components/dialog_chrome.hpp" namespace px::modrim { diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp index 6a11dc0..423a0cd 100644 --- a/src/ui/components/filter_sidebar.cpp +++ b/src/ui/components/filter_sidebar.cpp @@ -1,5 +1,5 @@ -#include "ui/components/filter_sidebar.h" -#include "ui/theme/palette.h" +#include "ui/components/filter_sidebar.hpp" +#include "ui/theme/palette.hpp" #include #include #include diff --git a/src/ui/components/filter_sidebar.h b/src/ui/components/filter_sidebar.hpp similarity index 100% rename from src/ui/components/filter_sidebar.h rename to src/ui/components/filter_sidebar.hpp diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp index 71fded2..a7fbc93 100644 --- a/src/ui/components/header_controller.cpp +++ b/src/ui/components/header_controller.cpp @@ -1,9 +1,9 @@ -#include "ui/components/header_controller.h" +#include "ui/components/header_controller.hpp" #include #include -#include "core/constants.h" +#include "core/constants.hpp" namespace px::modrim { diff --git a/src/ui/components/header_controller.h b/src/ui/components/header_controller.hpp similarity index 100% rename from src/ui/components/header_controller.h rename to src/ui/components/header_controller.hpp diff --git a/src/ui/components/header_panel.cpp b/src/ui/components/header_panel.cpp index d356abf..a0eab48 100644 --- a/src/ui/components/header_panel.cpp +++ b/src/ui/components/header_panel.cpp @@ -1,12 +1,12 @@ -#include "ui/components/header_panel.h" +#include "ui/components/header_panel.hpp" #include #include #include #include -#include "ui/components/header_controller.h" -#include "ui/theme/palette.h" +#include "ui/components/header_controller.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/header_panel.h b/src/ui/components/header_panel.hpp similarity index 100% rename from src/ui/components/header_panel.h rename to src/ui/components/header_panel.hpp diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp index 5bf9681..ce6a193 100644 --- a/src/ui/components/icon_button.cpp +++ b/src/ui/components/icon_button.cpp @@ -1,11 +1,11 @@ -#include "ui/components/icon_button.h" +#include "ui/components/icon_button.hpp" #include #include -#include "ui/components/icons.h" -#include "ui/theme/palette.h" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/icon_button.h b/src/ui/components/icon_button.hpp similarity index 100% rename from src/ui/components/icon_button.h rename to src/ui/components/icon_button.hpp diff --git a/src/ui/components/icons.cpp b/src/ui/components/icons.cpp index b38e85a..de386fd 100644 --- a/src/ui/components/icons.cpp +++ b/src/ui/components/icons.cpp @@ -1,4 +1,4 @@ -#include "ui/components/icons.h" +#include "ui/components/icons.hpp" #include diff --git a/src/ui/components/icons.h b/src/ui/components/icons.hpp similarity index 100% rename from src/ui/components/icons.h rename to src/ui/components/icons.hpp diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp index 120424d..127424a 100644 --- a/src/ui/components/mod_activation.cpp +++ b/src/ui/components/mod_activation.cpp @@ -1,7 +1,7 @@ -#include "ui/components/mod_activation.h" -#include "ui/components/dialogs.h" -#include "core/context.h" -#include "core/services/activation_service.h" +#include "ui/components/mod_activation.hpp" +#include "ui/components/dialogs.hpp" +#include "core/context.hpp" +#include "core/services/activation_service.hpp" #include namespace px::modrim { DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* parent) : QMessageBox(parent) { diff --git a/src/ui/components/mod_activation.h b/src/ui/components/mod_activation.hpp similarity index 100% rename from src/ui/components/mod_activation.h rename to src/ui/components/mod_activation.hpp diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp index a545d8b..fa7124e 100644 --- a/src/ui/components/mod_updates.cpp +++ b/src/ui/components/mod_updates.cpp @@ -1,7 +1,7 @@ -#include "ui/components/mod_updates.h" -#include "ui/components/dialogs.h" -#include "core/context.h" -#include "core/downloads/manager.h" +#include "ui/components/mod_updates.hpp" +#include "ui/components/dialogs.hpp" +#include "core/context.hpp" +#include "core/downloads/manager.hpp" #include #include #include diff --git a/src/ui/components/mod_updates.h b/src/ui/components/mod_updates.hpp similarity index 100% rename from src/ui/components/mod_updates.h rename to src/ui/components/mod_updates.hpp diff --git a/src/ui/components/popup_parent.cpp b/src/ui/components/popup_parent.cpp index c2117ed..dac648a 100644 --- a/src/ui/components/popup_parent.cpp +++ b/src/ui/components/popup_parent.cpp @@ -1,4 +1,4 @@ -#include "ui/components/popup_parent.h" +#include "ui/components/popup_parent.hpp" #include #include diff --git a/src/ui/components/popup_parent.h b/src/ui/components/popup_parent.hpp similarity index 100% rename from src/ui/components/popup_parent.h rename to src/ui/components/popup_parent.hpp diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp index c2b3faa..a9d7c59 100644 --- a/src/ui/components/procedural_preview.cpp +++ b/src/ui/components/procedural_preview.cpp @@ -1,5 +1,5 @@ -#include "ui/components/procedural_preview.h" -#include "ui/theme/constants.h" +#include "ui/components/procedural_preview.hpp" +#include "ui/theme/constants.hpp" #include #include #include diff --git a/src/ui/components/procedural_preview.h b/src/ui/components/procedural_preview.hpp similarity index 100% rename from src/ui/components/procedural_preview.h rename to src/ui/components/procedural_preview.hpp diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp index 0e37bd2..c0b5c1a 100644 --- a/src/ui/components/progress_dialog.cpp +++ b/src/ui/components/progress_dialog.cpp @@ -1,4 +1,4 @@ -#include "ui/components/progress_dialog.h" +#include "ui/components/progress_dialog.hpp" #include #include @@ -10,9 +10,9 @@ #include #include -#include "ui/components/dialog_chrome.h" -#include "ui/components/icons.h" -#include "ui/theme/palette.h" +#include "ui/components/dialog_chrome.hpp" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/progress_dialog.h b/src/ui/components/progress_dialog.hpp similarity index 97% rename from src/ui/components/progress_dialog.h rename to src/ui/components/progress_dialog.hpp index b6ed608..3298dff 100644 --- a/src/ui/components/progress_dialog.h +++ b/src/ui/components/progress_dialog.hpp @@ -6,7 +6,7 @@ #include #include -#include "core/loading.h" +#include "core/loading.hpp" class QHBoxLayout; class QProgressBar; diff --git a/src/ui/components/svg_provider.cpp b/src/ui/components/svg_provider.cpp index 4dd2156..442faf9 100644 --- a/src/ui/components/svg_provider.cpp +++ b/src/ui/components/svg_provider.cpp @@ -1,4 +1,4 @@ -#include "ui/components/svg_provider.h" +#include "ui/components/svg_provider.hpp" #include #include @@ -6,8 +6,8 @@ #include #include -#include "ui/components/icons.h" -#include "ui/theme/palette.h" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/svg_provider.h b/src/ui/components/svg_provider.hpp similarity index 100% rename from src/ui/components/svg_provider.h rename to src/ui/components/svg_provider.hpp diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp index 0533323..103e999 100644 --- a/src/ui/components/toast.cpp +++ b/src/ui/components/toast.cpp @@ -1,4 +1,4 @@ -#include "ui/components/toast.h" +#include "ui/components/toast.hpp" #include @@ -12,8 +12,8 @@ #include #include -#include "ui/components/icons.h" -#include "ui/theme/palette.h" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/toast.h b/src/ui/components/toast.hpp similarity index 98% rename from src/ui/components/toast.h rename to src/ui/components/toast.hpp index 085a546..26a8f33 100644 --- a/src/ui/components/toast.h +++ b/src/ui/components/toast.hpp @@ -3,7 +3,7 @@ #include #include -#include "ui/context.h" +#include "ui/context.hpp" class QGraphicsOpacityEffect; class QPropertyAnimation; diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp index c66cb5e..48bcf8c 100644 --- a/src/ui/components/unity_rich_text.cpp +++ b/src/ui/components/unity_rich_text.cpp @@ -1,4 +1,4 @@ -#include "ui/components/unity_rich_text.h" +#include "ui/components/unity_rich_text.hpp" #include #include #include diff --git a/src/ui/components/unity_rich_text.h b/src/ui/components/unity_rich_text.hpp similarity index 100% rename from src/ui/components/unity_rich_text.h rename to src/ui/components/unity_rich_text.hpp diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp index 2a1f79e..ac0be8b 100644 --- a/src/ui/components/view_rail_panel.cpp +++ b/src/ui/components/view_rail_panel.cpp @@ -1,12 +1,12 @@ -#include "ui/components/view_rail_panel.h" +#include "ui/components/view_rail_panel.hpp" #include #include #include #include -#include "ui/theme/constants.h" -#include "ui/theme/palette.h" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/components/view_rail_panel.h b/src/ui/components/view_rail_panel.hpp similarity index 100% rename from src/ui/components/view_rail_panel.h rename to src/ui/components/view_rail_panel.hpp diff --git a/src/ui/config.cpp b/src/ui/config.cpp index 0773cc0..2d2ef73 100644 --- a/src/ui/config.cpp +++ b/src/ui/config.cpp @@ -1,4 +1,4 @@ -#include "ui/config.h" +#include "ui/config.hpp" #include diff --git a/src/ui/config.h b/src/ui/config.hpp similarity index 89% rename from src/ui/config.h rename to src/ui/config.hpp index b5d0854..d11554a 100644 --- a/src/ui/config.h +++ b/src/ui/config.hpp @@ -4,9 +4,9 @@ #include -#include "core/config.h" -#include "core/error.h" -#include "ui/ui_prefs.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "ui/ui_prefs.hpp" namespace px::modrim { diff --git a/src/ui/context.cpp b/src/ui/context.cpp index ef7d99a..fa123fe 100644 --- a/src/ui/context.cpp +++ b/src/ui/context.cpp @@ -1,9 +1,9 @@ -#include "ui/context.h" +#include "ui/context.hpp" #include #include -#include "core/mod_service.h" +#include "core/mod_service.hpp" namespace px::modrim { diff --git a/src/ui/context.h b/src/ui/context.hpp similarity index 97% rename from src/ui/context.h rename to src/ui/context.hpp index a8a3dcd..61495a8 100644 --- a/src/ui/context.h +++ b/src/ui/context.hpp @@ -13,11 +13,11 @@ #include #include -#include "core/context.h" -#include "core/error.h" -#include "core/plugin.h" -#include "ui/settings_section.h" -#include "ui/ui_prefs.h" +#include "core/context.hpp" +#include "core/error.hpp" +#include "core/plugin.hpp" +#include "ui/settings_section.hpp" +#include "ui/ui_prefs.hpp" class QQmlEngine; diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp index 37a9a6d..4fe7c31 100644 --- a/src/ui/mod_selection.cpp +++ b/src/ui/mod_selection.cpp @@ -1,8 +1,8 @@ -#include "ui/mod_selection.h" +#include "ui/mod_selection.hpp" #include -#include "core/services/diagnostics_service.h" +#include "core/services/diagnostics_service.hpp" namespace px::modrim { diff --git a/src/ui/mod_selection.h b/src/ui/mod_selection.hpp similarity index 90% rename from src/ui/mod_selection.h rename to src/ui/mod_selection.hpp index 8643866..68cd84c 100644 --- a/src/ui/mod_selection.h +++ b/src/ui/mod_selection.hpp @@ -8,9 +8,9 @@ #include #include -#include "core/context.h" -#include "core/models/metadata/structures.h" -#include "core/models/view/diagnostics.h" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/models/view/diagnostics.hpp" namespace px::modrim { diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp index cf83904..bb0f2d2 100644 --- a/src/ui/models/impact.cpp +++ b/src/ui/models/impact.cpp @@ -1,6 +1,6 @@ -#include "ui/models/impact.h" -#include "core/services/startup_impact_service/models.h" -#include "ui/theme/palette.h" +#include "ui/models/impact.hpp" +#include "core/services/startup_impact_service/models.hpp" +#include "ui/theme/palette.hpp" #include namespace px::modrim { QString format_duration(double seconds) { diff --git a/src/ui/models/impact.h b/src/ui/models/impact.hpp similarity index 100% rename from src/ui/models/impact.h rename to src/ui/models/impact.hpp diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp index 5985ea1..e2210b9 100644 --- a/src/ui/models/mod_list_model.cpp +++ b/src/ui/models/mod_list_model.cpp @@ -1,7 +1,7 @@ -#include "ui/models/mod_list_model.h" -#include "core/services/diagnostics_service.h" -#include "ui/models/impact.h" -#include "ui/theme/palette.h" +#include "ui/models/mod_list_model.hpp" +#include "core/services/diagnostics_service.hpp" +#include "ui/models/impact.hpp" +#include "ui/theme/palette.hpp" #include #include diff --git a/src/ui/models/mod_list_model.h b/src/ui/models/mod_list_model.hpp similarity index 96% rename from src/ui/models/mod_list_model.h rename to src/ui/models/mod_list_model.hpp index 519a4c8..08ae2fd 100644 --- a/src/ui/models/mod_list_model.h +++ b/src/ui/models/mod_list_model.hpp @@ -5,8 +5,8 @@ #include #include #include -#include "core/models/metadata/structures.h" -#include "core/models/view/diagnostics.h" +#include "core/models/metadata/structures.hpp" +#include "core/models/view/diagnostics.hpp" namespace px::modrim { class DiagnosticsService; } namespace px::modrim { diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp index 1ad0fc6..159812f 100644 --- a/src/ui/models/mod_list_proxy_model.cpp +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -1,4 +1,4 @@ -#include "ui/models/mod_list_proxy_model.h" +#include "ui/models/mod_list_proxy_model.hpp" #include #include namespace px::modrim { diff --git a/src/ui/models/mod_list_proxy_model.h b/src/ui/models/mod_list_proxy_model.hpp similarity index 97% rename from src/ui/models/mod_list_proxy_model.h rename to src/ui/models/mod_list_proxy_model.hpp index b32b2bc..4522cee 100644 --- a/src/ui/models/mod_list_proxy_model.h +++ b/src/ui/models/mod_list_proxy_model.hpp @@ -1,7 +1,7 @@ #pragma once #include #include -#include "ui/models/mod_list_model.h" +#include "ui/models/mod_list_model.hpp" namespace px::modrim { class ModListProxyModel : public QAbstractListModel { Q_OBJECT diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp index 6553397..184192f 100644 --- a/src/ui/models/sidebar_model.cpp +++ b/src/ui/models/sidebar_model.cpp @@ -1,5 +1,5 @@ -#include "ui/models/sidebar_model.h" -#include "ui/theme/palette.h" +#include "ui/models/sidebar_model.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } diff --git a/src/ui/models/sidebar_model.h b/src/ui/models/sidebar_model.hpp similarity index 96% rename from src/ui/models/sidebar_model.h rename to src/ui/models/sidebar_model.hpp index e050834..9e9c020 100644 --- a/src/ui/models/sidebar_model.h +++ b/src/ui/models/sidebar_model.hpp @@ -1,7 +1,7 @@ #pragma once #include #include -#include "core/models/view/sidebar.h" +#include "core/models/view/sidebar.hpp" namespace px::modrim { struct SidebarItem { view::SidebarEntry entry; diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp index 64b2108..3f482b8 100644 --- a/src/ui/navigation.cpp +++ b/src/ui/navigation.cpp @@ -1,4 +1,4 @@ -#include "ui/navigation.h" +#include "ui/navigation.hpp" #include #include diff --git a/src/ui/navigation.h b/src/ui/navigation.hpp similarity index 100% rename from src/ui/navigation.h rename to src/ui/navigation.hpp diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp index 72ca5ba..8dd6ac0 100644 --- a/src/ui/panels/about_panel.cpp +++ b/src/ui/panels/about_panel.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/about_panel.h" +#include "ui/panels/about_panel.hpp" #include #include @@ -20,10 +20,10 @@ #include #include -#include "core/version.h" -#include "ui/components/dialog_chrome.h" -#include "ui/context.h" -#include "ui/panels/upload_report_dialog.h" +#include "core/version.hpp" +#include "ui/components/dialog_chrome.hpp" +#include "ui/context.hpp" +#include "ui/panels/upload_report_dialog.hpp" namespace px::modrim { QStringList AboutPanel::environment() { diff --git a/src/ui/panels/about_panel.h b/src/ui/panels/about_panel.hpp similarity index 100% rename from src/ui/panels/about_panel.h rename to src/ui/panels/about_panel.hpp diff --git a/src/ui/panels/keyboard_shortcuts_dialog.cpp b/src/ui/panels/keyboard_shortcuts_dialog.cpp index 758547a..cd074f9 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.cpp +++ b/src/ui/panels/keyboard_shortcuts_dialog.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/keyboard_shortcuts_dialog.h" +#include "ui/panels/keyboard_shortcuts_dialog.hpp" #include #include diff --git a/src/ui/panels/keyboard_shortcuts_dialog.h b/src/ui/panels/keyboard_shortcuts_dialog.hpp similarity index 100% rename from src/ui/panels/keyboard_shortcuts_dialog.h rename to src/ui/panels/keyboard_shortcuts_dialog.hpp diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp index 18b410a..b63fd4a 100644 --- a/src/ui/panels/mod_info_data.cpp +++ b/src/ui/panels/mod_info_data.cpp @@ -1,7 +1,7 @@ -#include "ui/panels/mod_info_data.h" -#include "ui/components/unity_rich_text.h" -#include "ui/models/mod_list_model.h" -#include "core/downloads/manager.h" +#include "ui/panels/mod_info_data.hpp" +#include "ui/components/unity_rich_text.hpp" +#include "ui/models/mod_list_model.hpp" +#include "core/downloads/manager.hpp" #include #include #include diff --git a/src/ui/panels/mod_info_data.h b/src/ui/panels/mod_info_data.hpp similarity index 89% rename from src/ui/panels/mod_info_data.h rename to src/ui/panels/mod_info_data.hpp index 6f69fdd..f119e98 100644 --- a/src/ui/panels/mod_info_data.h +++ b/src/ui/panels/mod_info_data.hpp @@ -1,8 +1,8 @@ #pragma once #include #include -#include "core/context.h" -#include "core/models/view/diagnostics.h" +#include "core/context.hpp" +#include "core/models/view/diagnostics.hpp" namespace px::modrim { inline const QString IMG_WIDTH_TOKEN = QStringLiteral("__IMG_WIDTH__"); QString bbcode_images(QString text); diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp index 230457d..98d4f32 100644 --- a/src/ui/panels/mod_info_panel.cpp +++ b/src/ui/panels/mod_info_panel.cpp @@ -1,14 +1,14 @@ -#include "ui/panels/mod_info_panel.h" -#include "ui/panels/mod_info_data.h" -#include "ui/panels/time_analytics_panel.h" -#include "ui/components/procedural_preview.h" -#include "ui/config.h" -#include "ui/models/impact.h" -#include "ui/theme/constants.h" -#include "ui/theme/palette.h" -#include "core/downloads/manager.h" -#include "core/mod_service.h" -#include "core/services/startup_impact_service/labels.h" +#include "ui/panels/mod_info_panel.hpp" +#include "ui/panels/mod_info_data.hpp" +#include "ui/panels/time_analytics_panel.hpp" +#include "ui/components/procedural_preview.hpp" +#include "ui/config.hpp" +#include "ui/models/impact.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/startup_impact_service/labels.hpp" #include #include #include diff --git a/src/ui/panels/mod_info_panel.h b/src/ui/panels/mod_info_panel.hpp similarity index 96% rename from src/ui/panels/mod_info_panel.h rename to src/ui/panels/mod_info_panel.hpp index 023d860..2847ede 100644 --- a/src/ui/panels/mod_info_panel.h +++ b/src/ui/panels/mod_info_panel.hpp @@ -2,8 +2,8 @@ #include #include #include -#include "ui/mod_selection.h" -#include "ui/ui_prefs.h" +#include "ui/mod_selection.hpp" +#include "ui/ui_prefs.hpp" class QQmlEngine; class QQuickWidget; class QLabel; diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp index 08b73e5..f467772 100644 --- a/src/ui/panels/mod_list_panel.cpp +++ b/src/ui/panels/mod_list_panel.cpp @@ -1,11 +1,11 @@ -#include "ui/panels/mod_list_panel.h" -#include "core/context.h" -#include "core/mod_service.h" -#include "core/downloads/manager.h" -#include "ui/components/icons.h" -#include "ui/components/mod_activation.h" -#include "ui/components/mod_updates.h" -#include "ui/theme/palette.h" +#include "ui/panels/mod_list_panel.hpp" +#include "core/context.hpp" +#include "core/mod_service.hpp" +#include "core/downloads/manager.hpp" +#include "ui/components/icons.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/theme/palette.hpp" #include #include #include diff --git a/src/ui/panels/mod_list_panel.h b/src/ui/panels/mod_list_panel.hpp similarity index 98% rename from src/ui/panels/mod_list_panel.h rename to src/ui/panels/mod_list_panel.hpp index 8e81105..34d0975 100644 --- a/src/ui/panels/mod_list_panel.h +++ b/src/ui/panels/mod_list_panel.hpp @@ -4,7 +4,7 @@ #include #include #include -#include "ui/models/mod_list_proxy_model.h" +#include "ui/models/mod_list_proxy_model.hpp" class QQmlEngine; class QQuickWidget; class QLineEdit; diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp index 6be967a..cf288f2 100644 --- a/src/ui/panels/restore_snapshot_dialog.cpp +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/restore_snapshot_dialog.h" +#include "ui/panels/restore_snapshot_dialog.hpp" #include @@ -15,7 +15,7 @@ #include #include -#include "core/mods_config.h" +#include "core/mods_config.hpp" namespace px::modrim { diff --git a/src/ui/panels/restore_snapshot_dialog.h b/src/ui/panels/restore_snapshot_dialog.hpp similarity index 96% rename from src/ui/panels/restore_snapshot_dialog.h rename to src/ui/panels/restore_snapshot_dialog.hpp index e068a6c..6752203 100644 --- a/src/ui/panels/restore_snapshot_dialog.h +++ b/src/ui/panels/restore_snapshot_dialog.hpp @@ -8,7 +8,7 @@ #include #include -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" class QLabel; class QListWidget; diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp index f0be7cb..8fcaa36 100644 --- a/src/ui/panels/settings_panel.cpp +++ b/src/ui/panels/settings_panel.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/settings_panel.h" +#include "ui/panels/settings_panel.hpp" #include #include #include @@ -9,11 +9,11 @@ #include #include #include -#include "core/mod_service.h" -#include "core/sort/community_service.h" -#include "ui/components/dialogs.h" -#include "ui/context.h" -#include "ui/theme/palette.h" +#include "core/mod_service.hpp" +#include "core/sort/community_service.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/context.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { namespace { class FolderDialog : public QFileDialog { diff --git a/src/ui/panels/settings_panel.h b/src/ui/panels/settings_panel.hpp similarity index 97% rename from src/ui/panels/settings_panel.h rename to src/ui/panels/settings_panel.hpp index 570796a..4c40ad7 100644 --- a/src/ui/panels/settings_panel.h +++ b/src/ui/panels/settings_panel.hpp @@ -3,8 +3,8 @@ #include #include #include -#include "core/context.h" -#include "ui/settings_section.h" +#include "core/context.hpp" +#include "ui/settings_section.hpp" class QQmlEngine; class QQuickWidget; namespace px::modrim { diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp index a5be878..2ab8ac8 100644 --- a/src/ui/panels/sidebar_panel.cpp +++ b/src/ui/panels/sidebar_panel.cpp @@ -1,6 +1,6 @@ -#include "ui/panels/sidebar_panel.h" -#include "core/context.h" -#include "core/services/diagnostics_service.h" +#include "ui/panels/sidebar_panel.hpp" +#include "core/context.hpp" +#include "core/services/diagnostics_service.hpp" #include namespace px::modrim { SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) diff --git a/src/ui/panels/sidebar_panel.h b/src/ui/panels/sidebar_panel.hpp similarity index 89% rename from src/ui/panels/sidebar_panel.h rename to src/ui/panels/sidebar_panel.hpp index ce77c3d..4812f60 100644 --- a/src/ui/panels/sidebar_panel.h +++ b/src/ui/panels/sidebar_panel.hpp @@ -1,6 +1,6 @@ #pragma once -#include "ui/components/filter_sidebar.h" -#include "ui/models/sidebar_model.h" +#include "ui/components/filter_sidebar.hpp" +#include "ui/models/sidebar_model.hpp" #include namespace px::modrim { class CoreContext; } namespace px::modrim { diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp index e0b3d5f..8f710fe 100644 --- a/src/ui/panels/startup_impact_data.cpp +++ b/src/ui/panels/startup_impact_data.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/startup_impact_data.h" +#include "ui/panels/startup_impact_data.hpp" #include #include @@ -7,9 +7,9 @@ #include #include #include -#include "core/services/startup_impact_service/labels.h" -#include "core/services/startup_impact_service/phases.h" -#include "ui/theme/palette.h" +#include "core/services/startup_impact_service/labels.hpp" +#include "core/services/startup_impact_service/phases.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { using namespace startup_impact; diff --git a/src/ui/panels/startup_impact_data.h b/src/ui/panels/startup_impact_data.hpp similarity index 80% rename from src/ui/panels/startup_impact_data.h rename to src/ui/panels/startup_impact_data.hpp index c43c568..b752a02 100644 --- a/src/ui/panels/startup_impact_data.h +++ b/src/ui/panels/startup_impact_data.hpp @@ -3,8 +3,8 @@ #include #include #include -#include "core/services/startup_impact_service/models.h" -#include "ui/models/impact.h" +#include "core/services/startup_impact_service/models.hpp" +#include "ui/models/impact.hpp" namespace px::modrim { QVariantMap build_startup_impact(const startup_impact::StartupImpactReport& report, diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp index 018da68..092ba22 100644 --- a/src/ui/panels/time_analytics_panel.cpp +++ b/src/ui/panels/time_analytics_panel.cpp @@ -1,12 +1,12 @@ -#include "ui/panels/time_analytics_panel.h" +#include "ui/panels/time_analytics_panel.hpp" #include #include #include #include #include -#include "ui/components/dialog_chrome.h" -#include "ui/panels/startup_impact_data.h" -#include "ui/theme/palette.h" +#include "ui/components/dialog_chrome.hpp" +#include "ui/panels/startup_impact_data.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { TimeAnalyticsPanel::TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) : QWidget(parent), service_(service), qml_(new QQuickWidget(engine, this)) { diff --git a/src/ui/panels/time_analytics_panel.h b/src/ui/panels/time_analytics_panel.hpp similarity index 93% rename from src/ui/panels/time_analytics_panel.h rename to src/ui/panels/time_analytics_panel.hpp index 984a38d..254a713 100644 --- a/src/ui/panels/time_analytics_panel.h +++ b/src/ui/panels/time_analytics_panel.hpp @@ -4,7 +4,7 @@ #include #include #include -#include "core/services/startup_impact_service/service.h" +#include "core/services/startup_impact_service/service.hpp" class QQmlEngine; class QQuickWidget; namespace px::modrim { diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp index cf1fdf8..0dd82a1 100644 --- a/src/ui/panels/update_dialog.cpp +++ b/src/ui/panels/update_dialog.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/update_dialog.h" +#include "ui/panels/update_dialog.hpp" #include #include diff --git a/src/ui/panels/update_dialog.h b/src/ui/panels/update_dialog.hpp similarity index 87% rename from src/ui/panels/update_dialog.h rename to src/ui/panels/update_dialog.hpp index 4f280bf..8e76679 100644 --- a/src/ui/panels/update_dialog.h +++ b/src/ui/panels/update_dialog.hpp @@ -1,7 +1,7 @@ #pragma once #include -#include "core/services/update_service.h" +#include "core/services/update_service.hpp" namespace px::modrim { inline constexpr int SKIP_RESULT = 2; diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp index 1b91733..1a83bab 100644 --- a/src/ui/panels/upload_report_dialog.cpp +++ b/src/ui/panels/upload_report_dialog.cpp @@ -1,4 +1,4 @@ -#include "ui/panels/upload_report_dialog.h" +#include "ui/panels/upload_report_dialog.hpp" #include #include @@ -11,10 +11,10 @@ #include #include -#include "core/providers/base.h" -#include "core/support/report.h" -#include "ui/components/dialogs.h" -#include "ui/context.h" +#include "core/providers/base.hpp" +#include "core/support/report.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/context.hpp" namespace px::modrim { UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { diff --git a/src/ui/panels/upload_report_dialog.h b/src/ui/panels/upload_report_dialog.hpp similarity index 100% rename from src/ui/panels/upload_report_dialog.h rename to src/ui/panels/upload_report_dialog.hpp diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp index c9bb63a..c6b47b6 100644 --- a/src/ui/plugins/downloads/model.cpp +++ b/src/ui/plugins/downloads/model.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/downloads/model.h" +#include "ui/plugins/downloads/model.hpp" namespace px::modrim { namespace { diff --git a/src/ui/plugins/downloads/model.h b/src/ui/plugins/downloads/model.hpp similarity index 99% rename from src/ui/plugins/downloads/model.h rename to src/ui/plugins/downloads/model.hpp index b4e1209..0eed4a7 100644 --- a/src/ui/plugins/downloads/model.h +++ b/src/ui/plugins/downloads/model.hpp @@ -6,7 +6,7 @@ #include #include -#include "core/downloads/types.h" +#include "core/downloads/types.hpp" namespace px::modrim { diff --git a/src/ui/plugins/downloads/plugin.cpp b/src/ui/plugins/downloads/plugin.cpp index a5d0b52..aa44b3d 100644 --- a/src/ui/plugins/downloads/plugin.cpp +++ b/src/ui/plugins/downloads/plugin.cpp @@ -1,6 +1,6 @@ -#include "ui/plugins/downloads/plugin.h" +#include "ui/plugins/downloads/plugin.hpp" -#include "ui/plugins/downloads/view.h" +#include "ui/plugins/downloads/view.hpp" namespace px::modrim { diff --git a/src/ui/plugins/downloads/plugin.h b/src/ui/plugins/downloads/plugin.hpp similarity index 88% rename from src/ui/plugins/downloads/plugin.h rename to src/ui/plugins/downloads/plugin.hpp index 6ce6016..b2fd038 100644 --- a/src/ui/plugins/downloads/plugin.h +++ b/src/ui/plugins/downloads/plugin.hpp @@ -1,6 +1,6 @@ #pragma once -#include "ui/context.h" +#include "ui/context.hpp" namespace px::modrim { diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp index e589abe..256cd61 100644 --- a/src/ui/plugins/downloads/view.cpp +++ b/src/ui/plugins/downloads/view.cpp @@ -1,10 +1,10 @@ -#include "ui/plugins/downloads/view.h" +#include "ui/plugins/downloads/view.hpp" #include #include #include -#include "ui/theme/palette.h" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/plugins/downloads/view.h b/src/ui/plugins/downloads/view.hpp similarity index 89% rename from src/ui/plugins/downloads/view.h rename to src/ui/plugins/downloads/view.hpp index e650652..7abdf6e 100644 --- a/src/ui/plugins/downloads/view.h +++ b/src/ui/plugins/downloads/view.hpp @@ -3,9 +3,9 @@ #include #include -#include "core/downloads/manager.h" -#include "ui/plugins/downloads/model.h" -#include "ui/views/base.h" +#include "core/downloads/manager.hpp" +#include "ui/plugins/downloads/model.hpp" +#include "ui/views/base.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp index 81f81d5..01fd118 100644 --- a/src/ui/plugins/organizer/dialogs.cpp +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/organizer/dialogs.h" +#include "ui/plugins/organizer/dialogs.hpp" #include #include @@ -9,8 +9,8 @@ #include #include -#include "core/organizer/models.h" -#include "core/organizer/resolve.h" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/dialogs.h b/src/ui/plugins/organizer/dialogs.hpp similarity index 100% rename from src/ui/plugins/organizer/dialogs.h rename to src/ui/plugins/organizer/dialogs.hpp diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp index e9ef5bf..4849a9b 100644 --- a/src/ui/plugins/organizer/filter_model.cpp +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/organizer/filter_model.h" +#include "ui/plugins/organizer/filter_model.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/filter_model.h b/src/ui/plugins/organizer/filter_model.hpp similarity index 95% rename from src/ui/plugins/organizer/filter_model.h rename to src/ui/plugins/organizer/filter_model.hpp index aef30a2..ad01d68 100644 --- a/src/ui/plugins/organizer/filter_model.h +++ b/src/ui/plugins/organizer/filter_model.hpp @@ -2,7 +2,7 @@ #include -#include "core/organizer/resolve.h" +#include "core/organizer/resolve.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/plugin.h b/src/ui/plugins/organizer/plugin.hpp similarity index 82% rename from src/ui/plugins/organizer/plugin.h rename to src/ui/plugins/organizer/plugin.hpp index c519de3..1d1b1ba 100644 --- a/src/ui/plugins/organizer/plugin.h +++ b/src/ui/plugins/organizer/plugin.hpp @@ -1,7 +1,7 @@ #pragma once -#include "ui/context.h" -#include "ui/plugins/organizer/view.h" +#include "ui/context.hpp" +#include "ui/plugins/organizer/view.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index 801ea8c..c344930 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -1,9 +1,9 @@ -#include "ui/plugins/organizer/tree_model.h" +#include "ui/plugins/organizer/tree_model.hpp" #include -#include "ui/models/mod_list_model.h" -#include "ui/theme/palette.h" +#include "ui/models/mod_list_model.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { namespace { diff --git a/src/ui/plugins/organizer/tree_model.h b/src/ui/plugins/organizer/tree_model.hpp similarity index 97% rename from src/ui/plugins/organizer/tree_model.h rename to src/ui/plugins/organizer/tree_model.hpp index 9e0faaa..5d767fd 100644 --- a/src/ui/plugins/organizer/tree_model.h +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -7,8 +7,8 @@ #include #include -#include "core/models/view/diagnostics.h" -#include "core/organizer/resolve.h" +#include "core/models/view/diagnostics.hpp" +#include "core/organizer/resolve.hpp" namespace px::modrim { diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp index 6d262bd..2cba6e1 100644 --- a/src/ui/plugins/organizer/view.cpp +++ b/src/ui/plugins/organizer/view.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/organizer/view.h" +#include "ui/plugins/organizer/view.hpp" #include #include @@ -19,21 +19,21 @@ #include #include #include -#include "core/downloads/manager.h" -#include "core/mod_service.h" -#include "core/services/diagnostics_service.h" -#include "ui/components/dialog_chrome.h" -#include "ui/components/dialogs.h" -#include "ui/components/filter_sidebar.h" -#include "ui/components/mod_activation.h" -#include "ui/components/mod_updates.h" -#include "ui/mod_selection.h" -#include "ui/panels/mod_info_panel.h" -#include "ui/plugins/organizer/dialogs.h" -#include "ui/plugins/organizer/filter_model.h" -#include "ui/plugins/organizer/tree_model.h" -#include "ui/theme/constants.h" -#include "ui/theme/palette.h" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "ui/components/dialog_chrome.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/components/filter_sidebar.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/mod_selection.hpp" +#include "ui/panels/mod_info_panel.hpp" +#include "ui/plugins/organizer/dialogs.hpp" +#include "ui/plugins/organizer/filter_model.hpp" +#include "ui/plugins/organizer/tree_model.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { using namespace organizer; diff --git a/src/ui/plugins/organizer/view.h b/src/ui/plugins/organizer/view.hpp similarity index 98% rename from src/ui/plugins/organizer/view.h rename to src/ui/plugins/organizer/view.hpp index 55dd476..3d443de 100644 --- a/src/ui/plugins/organizer/view.h +++ b/src/ui/plugins/organizer/view.hpp @@ -7,8 +7,8 @@ #include #include #include -#include "core/organizer/service.h" -#include "ui/views/base.h" +#include "core/organizer/service.hpp" +#include "ui/views/base.hpp" class QDialog; class QLabel; diff --git a/src/ui/plugins/steam_downloader/plugin.cpp b/src/ui/plugins/steam_downloader/plugin.cpp index 2c29c7f..6f73674 100644 --- a/src/ui/plugins/steam_downloader/plugin.cpp +++ b/src/ui/plugins/steam_downloader/plugin.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/steam_downloader/plugin.h" +#include "ui/plugins/steam_downloader/plugin.hpp" #ifdef PX_HAS_STEAM namespace px::modrim { diff --git a/src/ui/plugins/steam_downloader/plugin.h b/src/ui/plugins/steam_downloader/plugin.hpp similarity index 83% rename from src/ui/plugins/steam_downloader/plugin.h rename to src/ui/plugins/steam_downloader/plugin.hpp index bed3f65..dd065cc 100644 --- a/src/ui/plugins/steam_downloader/plugin.h +++ b/src/ui/plugins/steam_downloader/plugin.hpp @@ -1,10 +1,10 @@ #pragma once -#include "ui/context.h" +#include "ui/context.hpp" #ifdef PX_HAS_STEAM -#include "core/downloads/steam.h" -#include "ui/settings_section.h" +#include "core/downloads/steam.hpp" +#include "ui/settings_section.hpp" namespace px::modrim { diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp index 5321e92..d812034 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.cpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/steam_workshop/bridge_obj.h" +#include "ui/plugins/steam_workshop/bridge_obj.hpp" #ifdef PX_HAS_STEAM @@ -10,7 +10,7 @@ #include #include -#include "ui/plugins/steam_workshop/plugin.h" +#include "ui/plugins/steam_workshop/plugin.hpp" namespace px::modrim { namespace { diff --git a/src/ui/plugins/steam_workshop/bridge_obj.h b/src/ui/plugins/steam_workshop/bridge_obj.hpp similarity index 100% rename from src/ui/plugins/steam_workshop/bridge_obj.h rename to src/ui/plugins/steam_workshop/bridge_obj.hpp diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp index 98ab9f9..6cbbe71 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.cpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/steam_workshop/download_queue_model.h" +#include "ui/plugins/steam_workshop/download_queue_model.hpp" namespace px::modrim { diff --git a/src/ui/plugins/steam_workshop/download_queue_model.h b/src/ui/plugins/steam_workshop/download_queue_model.hpp similarity index 100% rename from src/ui/plugins/steam_workshop/download_queue_model.h rename to src/ui/plugins/steam_workshop/download_queue_model.hpp diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp index b9451df..867fcd6 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.cpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -1,12 +1,12 @@ -#include "ui/plugins/steam_workshop/download_sidebar.h" +#include "ui/plugins/steam_workshop/download_sidebar.hpp" #include #include #include #include -#include "ui/theme/constants.h" -#include "ui/theme/palette.h" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/plugins/steam_workshop/download_sidebar.h b/src/ui/plugins/steam_workshop/download_sidebar.hpp similarity index 93% rename from src/ui/plugins/steam_workshop/download_sidebar.h rename to src/ui/plugins/steam_workshop/download_sidebar.hpp index 5a1f3b3..22e8830 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.h +++ b/src/ui/plugins/steam_workshop/download_sidebar.hpp @@ -2,7 +2,7 @@ #include -#include "ui/plugins/steam_workshop/download_queue_model.h" +#include "ui/plugins/steam_workshop/download_queue_model.hpp" class QQmlEngine; class QQuickWidget; diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp index 765f1b4..1175db9 100644 --- a/src/ui/plugins/steam_workshop/plugin.cpp +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/steam_workshop/plugin.h" +#include "ui/plugins/steam_workshop/plugin.hpp" #ifdef PX_HAS_STEAM @@ -13,9 +13,9 @@ #include #include -#include "core/mod_service.h" -#include "ui/navigation.h" -#include "ui/plugins/steam_workshop/view.h" +#include "core/mod_service.hpp" +#include "ui/navigation.hpp" +#include "ui/plugins/steam_workshop/view.hpp" namespace px::modrim { diff --git a/src/ui/plugins/steam_workshop/plugin.h b/src/ui/plugins/steam_workshop/plugin.hpp similarity index 94% rename from src/ui/plugins/steam_workshop/plugin.h rename to src/ui/plugins/steam_workshop/plugin.hpp index 89b917c..33d8cdf 100644 --- a/src/ui/plugins/steam_workshop/plugin.h +++ b/src/ui/plugins/steam_workshop/plugin.hpp @@ -7,9 +7,9 @@ #include #include -#include "core/downloads/steam.h" -#include "ui/context.h" -#include "ui/plugins/steam_workshop/download_queue_model.h" +#include "core/downloads/steam.hpp" +#include "ui/context.hpp" +#include "ui/plugins/steam_workshop/download_queue_model.hpp" namespace px::modrim { diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index 4bf2a63..cf1d74c 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/steam_workshop/view.h" +#include "ui/plugins/steam_workshop/view.hpp" #ifdef PX_HAS_STEAM @@ -13,9 +13,9 @@ #include #include -#include "ui/plugins/steam_workshop/bridge_obj.h" -#include "ui/plugins/steam_workshop/download_sidebar.h" -#include "ui/theme/palette.h" +#include "ui/plugins/steam_workshop/bridge_obj.hpp" +#include "ui/plugins/steam_workshop/download_sidebar.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { namespace { diff --git a/src/ui/plugins/steam_workshop/view.h b/src/ui/plugins/steam_workshop/view.hpp similarity index 94% rename from src/ui/plugins/steam_workshop/view.h rename to src/ui/plugins/steam_workshop/view.hpp index d3e511d..8075ffb 100644 --- a/src/ui/plugins/steam_workshop/view.h +++ b/src/ui/plugins/steam_workshop/view.hpp @@ -2,8 +2,8 @@ #ifdef PX_HAS_STEAM -#include "ui/plugins/steam_workshop/plugin.h" -#include "ui/views/base.h" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/views/base.hpp" class QQuickWidget; class QShowEvent; diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp index 556cbdc..8cf2235 100644 --- a/src/ui/plugins/workshop/details.cpp +++ b/src/ui/plugins/workshop/details.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/workshop/details.h" +#include "ui/plugins/workshop/details.hpp" namespace px::modrim::workshop { namespace { diff --git a/src/ui/plugins/workshop/details.h b/src/ui/plugins/workshop/details.hpp similarity index 93% rename from src/ui/plugins/workshop/details.h rename to src/ui/plugins/workshop/details.hpp index 6c36f63..32d302c 100644 --- a/src/ui/plugins/workshop/details.h +++ b/src/ui/plugins/workshop/details.hpp @@ -1,6 +1,6 @@ #pragma once -#include "core/workshop/catalog.h" +#include "core/workshop/catalog.hpp" namespace px::modrim::workshop { diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp index d47e966..2f0b803 100644 --- a/src/ui/plugins/workshop/installed.cpp +++ b/src/ui/plugins/workshop/installed.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/workshop/installed.h" +#include "ui/plugins/workshop/installed.hpp" #include #include diff --git a/src/ui/plugins/workshop/installed.h b/src/ui/plugins/workshop/installed.hpp similarity index 96% rename from src/ui/plugins/workshop/installed.h rename to src/ui/plugins/workshop/installed.hpp index 5606ded..bedf52f 100644 --- a/src/ui/plugins/workshop/installed.h +++ b/src/ui/plugins/workshop/installed.hpp @@ -5,7 +5,7 @@ #include -#include "core/workshop/types.h" +#include "core/workshop/types.hpp" namespace px::modrim::workshop { diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp index 5850784..92cbdb3 100644 --- a/src/ui/plugins/workshop/models.cpp +++ b/src/ui/plugins/workshop/models.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/workshop/models.h" +#include "ui/plugins/workshop/models.hpp" #include #include @@ -10,7 +10,7 @@ #include #include -#include "ui/panels/mod_info_data.h" +#include "ui/panels/mod_info_data.hpp" namespace px::modrim::workshop { namespace { diff --git a/src/ui/plugins/workshop/models.h b/src/ui/plugins/workshop/models.hpp similarity index 97% rename from src/ui/plugins/workshop/models.h rename to src/ui/plugins/workshop/models.hpp index 5604989..759af8c 100644 --- a/src/ui/plugins/workshop/models.h +++ b/src/ui/plugins/workshop/models.hpp @@ -6,7 +6,7 @@ #include #include -#include "core/workshop/types.h" +#include "core/workshop/types.hpp" namespace px::modrim::workshop { diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp index 5091ed2..8c7724f 100644 --- a/src/ui/plugins/workshop/plugin.cpp +++ b/src/ui/plugins/workshop/plugin.cpp @@ -1,6 +1,6 @@ -#include "ui/plugins/workshop/plugin.h" +#include "ui/plugins/workshop/plugin.hpp" -#include "ui/plugins/workshop/view.h" +#include "ui/plugins/workshop/view.hpp" namespace px::modrim::workshop { diff --git a/src/ui/plugins/workshop/plugin.h b/src/ui/plugins/workshop/plugin.hpp similarity index 85% rename from src/ui/plugins/workshop/plugin.h rename to src/ui/plugins/workshop/plugin.hpp index 9977838..8657928 100644 --- a/src/ui/plugins/workshop/plugin.h +++ b/src/ui/plugins/workshop/plugin.hpp @@ -1,8 +1,8 @@ #pragma once -#include "core/workshop/catalog.h" -#include "ui/context.h" -#include "ui/settings_section.h" +#include "core/workshop/catalog.hpp" +#include "ui/context.hpp" +#include "ui/settings_section.hpp" namespace px::modrim::workshop { diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index f4d5efb..5dd2016 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -1,4 +1,4 @@ -#include "ui/plugins/workshop/view.h" +#include "ui/plugins/workshop/view.hpp" #include #include @@ -10,9 +10,9 @@ #include #include -#include "ui/components/mod_activation.h" -#include "ui/navigation.h" -#include "ui/theme/palette.h" +#include "ui/components/mod_activation.hpp" +#include "ui/navigation.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim::workshop { using namespace workshop; diff --git a/src/ui/plugins/workshop/view.h b/src/ui/plugins/workshop/view.hpp similarity index 98% rename from src/ui/plugins/workshop/view.h rename to src/ui/plugins/workshop/view.hpp index 94afc0e..181692c 100644 --- a/src/ui/plugins/workshop/view.h +++ b/src/ui/plugins/workshop/view.hpp @@ -9,10 +9,10 @@ #include #include -#include "ui/plugins/workshop/details.h" -#include "ui/plugins/workshop/installed.h" -#include "ui/plugins/workshop/models.h" -#include "ui/views/base.h" +#include "ui/plugins/workshop/details.hpp" +#include "ui/plugins/workshop/installed.hpp" +#include "ui/plugins/workshop/models.hpp" +#include "ui/views/base.hpp" class QQuickWidget; diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp index 3c19234..6ac9527 100644 --- a/src/ui/settings_section.cpp +++ b/src/ui/settings_section.cpp @@ -1,4 +1,4 @@ -#include "ui/settings_section.h" +#include "ui/settings_section.hpp" #include #include diff --git a/src/ui/settings_section.h b/src/ui/settings_section.hpp similarity index 97% rename from src/ui/settings_section.h rename to src/ui/settings_section.hpp index 3442b6c..0114017 100644 --- a/src/ui/settings_section.h +++ b/src/ui/settings_section.hpp @@ -16,9 +16,9 @@ #include #include -#include "core/config.h" -#include "core/error.h" -#include "core/plugin_config.h" +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/plugin_config.hpp" namespace px::modrim { diff --git a/src/ui/theme/constants.h b/src/ui/theme/constants.hpp similarity index 100% rename from src/ui/theme/constants.h rename to src/ui/theme/constants.hpp diff --git a/src/ui/theme/palette.cpp b/src/ui/theme/palette.cpp index 2893a17..f6317d7 100644 --- a/src/ui/theme/palette.cpp +++ b/src/ui/theme/palette.cpp @@ -1,4 +1,4 @@ -#include "ui/theme/palette.h" +#include "ui/theme/palette.hpp" #include diff --git a/src/ui/theme/palette.h b/src/ui/theme/palette.hpp similarity index 100% rename from src/ui/theme/palette.h rename to src/ui/theme/palette.hpp diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp index 5dd7fde..e91f649 100644 --- a/src/ui/theme/qml_theme.cpp +++ b/src/ui/theme/qml_theme.cpp @@ -1,12 +1,12 @@ -#include "ui/theme/qml_theme.h" +#include "ui/theme/qml_theme.hpp" #include #include #include #include -#include "ui/theme/constants.h" -#include "ui/theme/palette.h" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/theme/qml_theme.h b/src/ui/theme/qml_theme.hpp similarity index 100% rename from src/ui/theme/qml_theme.h rename to src/ui/theme/qml_theme.hpp diff --git a/src/ui/ui_prefs.h b/src/ui/ui_prefs.hpp similarity index 92% rename from src/ui/ui_prefs.h rename to src/ui/ui_prefs.hpp index a632856..bcb7d5b 100644 --- a/src/ui/ui_prefs.h +++ b/src/ui/ui_prefs.hpp @@ -2,7 +2,7 @@ #include -#include "core/constants.h" +#include "core/constants.hpp" namespace px::modrim { diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp index 0bc9445..b0c958a 100644 --- a/src/ui/views/base.cpp +++ b/src/ui/views/base.cpp @@ -1,9 +1,9 @@ -#include "ui/views/base.h" +#include "ui/views/base.hpp" #include #include -#include "ui/theme/palette.h" +#include "ui/theme/palette.hpp" namespace px::modrim { diff --git a/src/ui/views/base.h b/src/ui/views/base.hpp similarity index 95% rename from src/ui/views/base.h rename to src/ui/views/base.hpp index 5153fa0..ca389dd 100644 --- a/src/ui/views/base.h +++ b/src/ui/views/base.hpp @@ -3,7 +3,7 @@ #include #include -#include "ui/context.h" +#include "ui/context.hpp" namespace px::modrim { diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp index d912413..5982593 100644 --- a/src/ui/views/mods_view.cpp +++ b/src/ui/views/mods_view.cpp @@ -1,5 +1,5 @@ -#include "ui/views/mods_view.h" -#include "ui/theme/constants.h" +#include "ui/views/mods_view.hpp" +#include "ui/theme/constants.hpp" #include namespace px::modrim { ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) diff --git a/src/ui/views/mods_view.h b/src/ui/views/mods_view.hpp similarity index 82% rename from src/ui/views/mods_view.h rename to src/ui/views/mods_view.hpp index 57af096..22c3d11 100644 --- a/src/ui/views/mods_view.h +++ b/src/ui/views/mods_view.hpp @@ -1,8 +1,8 @@ #pragma once -#include "ui/views/base.h" -#include "ui/panels/sidebar_panel.h" -#include "ui/panels/mod_list_panel.h" -#include "ui/panels/mod_info_panel.h" +#include "ui/views/base.hpp" +#include "ui/panels/sidebar_panel.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/panels/mod_info_panel.hpp" namespace px::modrim { class ModsViewPanel : public BaseViewPanel { Q_OBJECT diff --git a/src/ui/views/views.h b/src/ui/views/views.hpp similarity index 84% rename from src/ui/views/views.h rename to src/ui/views/views.hpp index 3ab0d6c..247bbb7 100644 --- a/src/ui/views/views.h +++ b/src/ui/views/views.hpp @@ -2,4 +2,4 @@ // Aggregate of the built-in whole-window views. Each view header is added here as its // port lands; registration happens in src/app/plugins.cpp. -#include "ui/views/base.h" +#include "ui/views/base.hpp" diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp index 6ba436f..4e8e6ca 100644 --- a/src/ui/window/actions.cpp +++ b/src/ui/window/actions.cpp @@ -1,4 +1,4 @@ -#include "ui/window/actions.h" +#include "ui/window/actions.hpp" #include #include diff --git a/src/ui/window/actions.h b/src/ui/window/actions.hpp similarity index 100% rename from src/ui/window/actions.h rename to src/ui/window/actions.hpp diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index 8250583..79c37b5 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -1,4 +1,4 @@ -#include "ui/window/main_window.h" +#include "ui/window/main_window.hpp" #include #include @@ -25,33 +25,33 @@ #include #include -#include "core/downloads/manager.h" -#include "core/mod_service.h" -#include "core/services/diagnostics_service.h" -#include "core/services/game_launcher.h" -#include "core/version.h" -#include "ui/components/dialogs.h" -#include "ui/components/header_controller.h" -#include "ui/components/header_panel.h" -#include "ui/components/mod_updates.h" -#include "ui/components/svg_provider.h" -#include "ui/components/toast.h" -#include "ui/components/view_rail_panel.h" -#include "ui/config.h" -#include "ui/mod_selection.h" -#include "ui/navigation.h" -#include "ui/panels/about_panel.h" -#include "ui/panels/keyboard_shortcuts_dialog.h" -#include "ui/panels/mod_info_panel.h" -#include "ui/panels/mod_list_panel.h" -#include "ui/panels/restore_snapshot_dialog.h" -#include "ui/panels/settings_panel.h" -#include "ui/panels/update_dialog.h" -#include "ui/panels/upload_report_dialog.h" -#include "ui/theme/constants.h" -#include "ui/theme/qml_theme.h" -#include "ui/views/mods_view.h" -#include "ui/window/menu_bar.h" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/game_launcher.hpp" +#include "core/version.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/components/header_controller.hpp" +#include "ui/components/header_panel.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/components/toast.hpp" +#include "ui/components/view_rail_panel.hpp" +#include "ui/config.hpp" +#include "ui/mod_selection.hpp" +#include "ui/navigation.hpp" +#include "ui/panels/about_panel.hpp" +#include "ui/panels/keyboard_shortcuts_dialog.hpp" +#include "ui/panels/mod_info_panel.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/panels/restore_snapshot_dialog.hpp" +#include "ui/panels/settings_panel.hpp" +#include "ui/panels/update_dialog.hpp" +#include "ui/panels/upload_report_dialog.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/qml_theme.hpp" +#include "ui/views/mods_view.hpp" +#include "ui/window/menu_bar.hpp" namespace px::modrim { diff --git a/src/ui/window/main_window.h b/src/ui/window/main_window.hpp similarity index 96% rename from src/ui/window/main_window.h rename to src/ui/window/main_window.hpp index 72be03c..30bcbaf 100644 --- a/src/ui/window/main_window.h +++ b/src/ui/window/main_window.hpp @@ -11,10 +11,10 @@ #include #include -#include "core/downloads/types.h" -#include "core/services/update_service.h" -#include "ui/context.h" -#include "ui/window/actions.h" +#include "core/downloads/types.hpp" +#include "core/services/update_service.hpp" +#include "ui/context.hpp" +#include "ui/window/actions.hpp" class QQmlEngine; class QSplitter; diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp index 8ddcde5..005dbb2 100644 --- a/src/ui/window/menu_bar.cpp +++ b/src/ui/window/menu_bar.cpp @@ -1,4 +1,4 @@ -#include "ui/window/menu_bar.h" +#include "ui/window/menu_bar.hpp" #include diff --git a/src/ui/window/menu_bar.h b/src/ui/window/menu_bar.hpp similarity index 91% rename from src/ui/window/menu_bar.h rename to src/ui/window/menu_bar.hpp index 9c62363..e26159b 100644 --- a/src/ui/window/menu_bar.h +++ b/src/ui/window/menu_bar.hpp @@ -2,7 +2,7 @@ #include -#include "ui/window/actions.h" +#include "ui/window/actions.hpp" namespace px::modrim { diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp index ea7f948..311269c 100644 --- a/tests/core/checker/test_checker.cpp +++ b/tests/core/checker/test_checker.cpp @@ -10,12 +10,12 @@ #include #include -#include "core/checker/checker.h" -#include "core/checker/graph.h" -#include "core/checker/issues.h" -#include "core/checker/models.h" -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/checker/checker.hpp" +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" using namespace px::modrim; using namespace px::modrim::checker; diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp index c318f52..d6cc154 100644 --- a/tests/core/checker/test_databases.cpp +++ b/tests/core/checker/test_databases.cpp @@ -12,8 +12,8 @@ #include #include -#include "core/checker/databases.h" -#include "core/config.h" +#include "core/checker/databases.hpp" +#include "core/config.hpp" using namespace px::modrim; using namespace px::modrim::checker; diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp index f039d76..8e77814 100644 --- a/tests/core/checker/test_graph.cpp +++ b/tests/core/checker/test_graph.cpp @@ -7,9 +7,9 @@ #include #include -#include "core/checker/graph.h" -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/checker/graph.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" using namespace px::modrim; using namespace px::modrim::checker; diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp index 67a6c9f..d68d5ee 100644 --- a/tests/core/checker/test_issues.cpp +++ b/tests/core/checker/test_issues.cpp @@ -7,12 +7,12 @@ #include #include -#include "core/checker/graph.h" -#include "core/checker/issues.h" -#include "core/checker/models.h" -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" -#include "core/sort/models.h" +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" using namespace px::modrim; using namespace px::modrim::checker; diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp index 0f2afe1..55660cb 100644 --- a/tests/core/downloads/test_manager.cpp +++ b/tests/core/downloads/test_manager.cpp @@ -12,11 +12,11 @@ #include #include -#include "core/downloads/downloader.h" -#include "core/downloads/manager.h" -#include "core/downloads/types.h" -#include "core/models/metadata/structures.h" -#include "../test_support.h" +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/types.hpp" +#include "core/models/metadata/structures.hpp" +#include "../test_support.hpp" namespace { diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp index 21788d1..aeb24ee 100644 --- a/tests/core/downloads/test_steam.cpp +++ b/tests/core/downloads/test_steam.cpp @@ -27,12 +27,12 @@ #include #include -#include "../test_support.h" -#include "core/config.h" -#include "core/downloads/manager.h" -#include "core/downloads/steam.h" -#include "core/downloads/types.h" -#include "core/models/metadata/structures.h" +#include "../test_support.hpp" +#include "core/config.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/steam.hpp" +#include "core/downloads/types.hpp" +#include "core/models/metadata/structures.hpp" namespace { diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp index 0d7a5b9..58f66d9 100644 --- a/tests/core/models/metadata/test_parsing.cpp +++ b/tests/core/models/metadata/test_parsing.cpp @@ -5,8 +5,8 @@ #include #include -#include "core/models/metadata/parsing.h" -#include "core/models/metadata/structures.h" +#include "core/models/metadata/parsing.hpp" +#include "core/models/metadata/structures.hpp" using px::modrim::AboutXmlMod; using px::modrim::CaseInsensitiveStr; diff --git a/tests/core/models/metadata/test_schemas.cpp b/tests/core/models/metadata/test_schemas.cpp index 4dc0d75..82c16d3 100644 --- a/tests/core/models/metadata/test_schemas.cpp +++ b/tests/core/models/metadata/test_schemas.cpp @@ -4,7 +4,7 @@ #include -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" using namespace px::modrim; diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp index 1f77336..5e546fb 100644 --- a/tests/core/models/metadata/test_structures.cpp +++ b/tests/core/models/metadata/test_structures.cpp @@ -3,7 +3,7 @@ #include #include -#include "core/models/metadata/structures.h" +#include "core/models/metadata/structures.hpp" using px::modrim::AboutXmlMod; using px::modrim::CaseInsensitiveSet; diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp index 2ed4469..845dad3 100644 --- a/tests/core/organizer/test_db.cpp +++ b/tests/core/organizer/test_db.cpp @@ -4,10 +4,10 @@ #include #include -#include "core/organizer/db.h" -#include "core/organizer/defaults.h" -#include "core/organizer/models.h" -#include "test_util.h" +#include "core/organizer/db.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" +#include "test_util.hpp" using namespace px::modrim::organizer; using organizer_test::expect; diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp index f61c9df..74611de 100644 --- a/tests/core/organizer/test_resolve.cpp +++ b/tests/core/organizer/test_resolve.cpp @@ -3,9 +3,9 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/organizer/models.h" -#include "core/organizer/resolve.h" +#include "core/models/metadata/structures.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" using namespace px::modrim; using namespace px::modrim::organizer; diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp index 3c0eabf..8ffedaf 100644 --- a/tests/core/organizer/test_service.cpp +++ b/tests/core/organizer/test_service.cpp @@ -6,13 +6,13 @@ #include #include -#include "core/models/metadata/structures.h" -#include "core/organizer/db.h" -#include "core/organizer/defaults.h" -#include "core/organizer/models.h" -#include "core/organizer/resolve.h" -#include "core/organizer/service.h" -#include "test_util.h" +#include "core/models/metadata/structures.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" +#include "core/organizer/service.hpp" +#include "test_util.hpp" using namespace px::modrim; using namespace px::modrim::organizer; diff --git a/tests/core/organizer/test_util.h b/tests/core/organizer/test_util.hpp similarity index 93% rename from tests/core/organizer/test_util.h rename to tests/core/organizer/test_util.hpp index 49ea999..64e2b8a 100644 --- a/tests/core/organizer/test_util.h +++ b/tests/core/organizer/test_util.hpp @@ -6,8 +6,8 @@ #include #include -#include "core/error.h" -#include "../test_support.h" +#include "core/error.hpp" +#include "../test_support.hpp" namespace organizer_test { diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp index 599b261..4788a91 100644 --- a/tests/core/providers/test_factory.cpp +++ b/tests/core/providers/test_factory.cpp @@ -3,9 +3,9 @@ #include #include -#include "core/config.h" -#include "core/providers/factory.h" -#include "core/providers/local.h" +#include "core/config.hpp" +#include "core/providers/factory.hpp" +#include "core/providers/local.hpp" using px::modrim::PathConfig; using px::modrim::providers::create_providers; diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp index 41f96fd..2231233 100644 --- a/tests/core/providers/test_providers.cpp +++ b/tests/core/providers/test_providers.cpp @@ -9,8 +9,8 @@ #include #include -#include "core/providers/core.h" -#include "core/providers/local.h" +#include "core/providers/core.hpp" +#include "core/providers/local.hpp" using namespace px::modrim::providers; using px::modrim::ListedModPtr; diff --git a/tests/core/services/services_support.h b/tests/core/services/services_support.hpp similarity index 100% rename from tests/core/services/services_support.h rename to tests/core/services/services_support.hpp diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp index d7e7730..64d01ed 100644 --- a/tests/core/services/startup_impact_service/test_db.cpp +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -11,8 +11,8 @@ #include #include -#include "../../test_support.h" -#include "core/services/startup_impact_service/db.h" +#include "../../test_support.hpp" +#include "core/services/startup_impact_service/db.hpp" using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp index a4e8065..edc02ed 100644 --- a/tests/core/services/startup_impact_service/test_phases.cpp +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -4,8 +4,8 @@ #include #include -#include "core/services/startup_impact_service/labels.h" -#include "core/services/startup_impact_service/phases.h" +#include "core/services/startup_impact_service/labels.hpp" +#include "core/services/startup_impact_service/phases.hpp" using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp index cf9d8bd..5cf9b9c 100644 --- a/tests/core/services/startup_impact_service/test_startup_impact.cpp +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -6,9 +6,9 @@ #include #include -#include "core/services/startup_impact_service/models.h" -#include "core/services/startup_impact_service/parser.h" -#include "../../test_support.h" +#include "core/services/startup_impact_service/models.hpp" +#include "core/services/startup_impact_service/parser.hpp" +#include "../../test_support.hpp" using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp index 49d541f..a25ae71 100644 --- a/tests/core/services/test_activation_service.cpp +++ b/tests/core/services/test_activation_service.cpp @@ -11,13 +11,13 @@ #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/models/metadata/structures.h" -#include "core/models/view/sidebar.h" -#include "core/services/activation_service.h" -#include "core/services/diagnostics_service.h" -#include "services_support.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/models/view/sidebar.hpp" +#include "core/services/activation_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "services_support.hpp" using namespace px::modrim; diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp index 0145f14..c6abaa9 100644 --- a/tests/core/services/test_game_launcher.cpp +++ b/tests/core/services/test_game_launcher.cpp @@ -14,12 +14,12 @@ #include #include -#include "core/config.h" -#include "core/constants.h" -#include "core/context.h" -#include "core/services/game_launcher.h" -#include "core/services/game_process.h" -#include "services_support.h" +#include "core/config.hpp" +#include "core/constants.hpp" +#include "core/context.hpp" +#include "core/services/game_launcher.hpp" +#include "core/services/game_process.hpp" +#include "services_support.hpp" using px::modrim::AppConfig; using px::modrim::CoreContext; diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp index 3589e4e..d09dcb9 100644 --- a/tests/core/services/test_metadata_cache.cpp +++ b/tests/core/services/test_metadata_cache.cpp @@ -9,10 +9,10 @@ #include #include -#include "core/models/metadata/parsing.h" -#include "core/providers/local.h" -#include "core/services/metadata_cache.h" -#include "services_support.h" +#include "core/models/metadata/parsing.hpp" +#include "core/providers/local.hpp" +#include "core/services/metadata_cache.hpp" +#include "services_support.hpp" using namespace px::modrim; using namespace px::modrim::providers; diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp index 6302ef1..7bf2787 100644 --- a/tests/core/services/test_sort_service.cpp +++ b/tests/core/services/test_sort_service.cpp @@ -7,7 +7,7 @@ #include #include -#include "core/services/sort_service.h" +#include "core/services/sort_service.hpp" using px::modrim::AboutXmlMod; using px::modrim::CaseInsensitiveStr; diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index 4408398..07053cd 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -10,8 +10,8 @@ #include #include -#include "core/services/update_service.h" -#include "services_support.h" +#include "core/services/update_service.hpp" +#include "services_support.hpp" using namespace px::modrim; using namespace px::modrim::services; diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp index cf3f847..7c9ff0f 100644 --- a/tests/core/sort/test_community.cpp +++ b/tests/core/sort/test_community.cpp @@ -3,8 +3,8 @@ #include #include -#include "core/sort/community.h" -#include "../test_support.h" +#include "core/sort/community.hpp" +#include "../test_support.hpp" using px::modrim::sort::load_community_rules; using px::modrim::sort::PackageId; diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp index a34a154..b662e0e 100644 --- a/tests/core/sort/test_tier_order.cpp +++ b/tests/core/sort/test_tier_order.cpp @@ -8,10 +8,10 @@ #include #include -#include "core/checker/graph.h" -#include "core/checker/sort.h" -#include "core/models/metadata/structures.h" -#include "core/sort/config.h" +#include "core/checker/graph.hpp" +#include "core/checker/sort.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" using px::modrim::AboutXmlMod; using px::modrim::CaseInsensitiveSet; diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp index d3f4d12..0185ba3 100644 --- a/tests/core/sort/test_tiers.cpp +++ b/tests/core/sort/test_tiers.cpp @@ -4,7 +4,7 @@ #include #include -#include "core/sort/tiers.h" +#include "core/sort/tiers.hpp" using px::modrim::sort::assign_tiers; using px::modrim::sort::find_cycle; diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp index c91af8d..990d70a 100644 --- a/tests/core/support/test_report.cpp +++ b/tests/core/support/test_report.cpp @@ -13,10 +13,10 @@ #include #include -#include "../test_support.h" -#include "core/models/metadata/structures.h" -#include "core/support/report.h" -#include "core/version.h" +#include "../test_support.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/support/report.hpp" +#include "core/version.hpp" using namespace px::modrim; using namespace px::modrim::support; diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index 607c1c8..f012fb9 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -10,10 +10,10 @@ #include #include -#include "core/config.h" -#include "core/constants.h" -#include "core/file_io.h" -#include "test_support.h" +#include "core/config.hpp" +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "test_support.hpp" namespace { diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp index 84a3fa1..6abe3c1 100644 --- a/tests/core/test_context.cpp +++ b/tests/core/test_context.cpp @@ -11,10 +11,10 @@ #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/models/metadata/structures.h" -#include "services/services_support.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "services/services_support.hpp" using px::modrim::AboutXmlMod; using px::modrim::AppConfig; diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp index a4887ef..8445b62 100644 --- a/tests/core/test_loading.cpp +++ b/tests/core/test_loading.cpp @@ -5,7 +5,7 @@ #include #include -#include "core/loading.h" +#include "core/loading.hpp" namespace { diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp index 7d9d190..d475645 100644 --- a/tests/core/test_migrator.cpp +++ b/tests/core/test_migrator.cpp @@ -11,8 +11,8 @@ #include #include -#include "core/migrator.h" -#include "test_support.h" +#include "core/migrator.hpp" +#include "test_support.hpp" namespace { diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp index 33c7598..acce815 100644 --- a/tests/core/test_mods_config.cpp +++ b/tests/core/test_mods_config.cpp @@ -8,10 +8,10 @@ #include #include -#include "core/file_io.h" -#include "core/mods_config.h" -#include "core/models/metadata/structures.h" -#include "test_support.h" +#include "core/file_io.hpp" +#include "core/mods_config.hpp" +#include "core/models/metadata/structures.hpp" +#include "test_support.hpp" namespace { diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp index 06c21ec..1638fd1 100644 --- a/tests/core/test_plugin.cpp +++ b/tests/core/test_plugin.cpp @@ -8,8 +8,8 @@ #include #include -#include "core/plugin.h" -#include "test_support.h" +#include "core/plugin.hpp" +#include "test_support.hpp" // Stand-in for a composition root's context: records what each plugin did. namespace { diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp index 6aa38ac..3cd3060 100644 --- a/tests/core/test_plugin_config.cpp +++ b/tests/core/test_plugin_config.cpp @@ -7,9 +7,9 @@ #include #include -#include "core/config.h" -#include "core/plugin_config.h" -#include "test_support.h" +#include "core/config.hpp" +#include "core/plugin_config.hpp" +#include "test_support.hpp" namespace { diff --git a/tests/core/test_support.h b/tests/core/test_support.hpp similarity index 100% rename from tests/core/test_support.h rename to tests/core/test_support.hpp diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp index 440d4e6..b65040c 100644 --- a/tests/core/test_utils.cpp +++ b/tests/core/test_utils.cpp @@ -4,7 +4,7 @@ #include #include -#include "core/utils.h" +#include "core/utils.hpp" namespace { diff --git a/tests/core/test_version.cpp b/tests/core/test_version.cpp index 7f9cd90..1e31b39 100644 --- a/tests/core/test_version.cpp +++ b/tests/core/test_version.cpp @@ -3,7 +3,7 @@ #include #include -#include "core/version.h" +#include "core/version.hpp" TEST_CASE("coroutine resumes on the Qt event loop") { int argc = 0; diff --git a/tests/core/test_xml.cpp b/tests/core/test_xml.cpp index e376ebb..d14db1a 100644 --- a/tests/core/test_xml.cpp +++ b/tests/core/test_xml.cpp @@ -4,7 +4,7 @@ #include #include -#include "core/xml.h" +#include "core/xml.hpp" using namespace px::modrim; diff --git a/tests/core/workshop/support.h b/tests/core/workshop/support.hpp similarity index 99% rename from tests/core/workshop/support.h rename to tests/core/workshop/support.hpp index 24d8060..8396505 100644 --- a/tests/core/workshop/support.h +++ b/tests/core/workshop/support.hpp @@ -19,7 +19,7 @@ #include #include -#include "core/workshop/types.h" +#include "core/workshop/types.hpp" namespace workshop_test { diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index 4d7117a..1ebb248 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -18,17 +18,17 @@ #include #include -#include "../test_support.h" -#include "core/config.h" -#include "core/downloads/downloader.h" -#include "core/downloads/manager.h" -#include "core/downloads/types.h" -#include "core/file_io.h" -#include "core/models/metadata/structures.h" -#include "core/workshop/catalog.h" -#include "core/workshop/client.h" -#include "core/workshop/types.h" -#include "support.h" +#include "../test_support.hpp" +#include "core/config.hpp" +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/types.hpp" +#include "core/file_io.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/workshop/catalog.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" using namespace px::modrim; using namespace px::modrim::workshop; diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp index d4aa1a4..a63bcc0 100644 --- a/tests/core/workshop/test_client.cpp +++ b/tests/core/workshop/test_client.cpp @@ -8,10 +8,10 @@ #include #include -#include "../test_support.h" -#include "core/workshop/client.h" -#include "core/workshop/types.h" -#include "support.h" +#include "../test_support.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" using namespace px::modrim::workshop; using test_support::TestApplication; diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp index 56397c8..bfa3938 100644 --- a/tests/core/workshop/test_favourites.cpp +++ b/tests/core/workshop/test_favourites.cpp @@ -5,10 +5,10 @@ #include -#include "core/config.h" -#include "core/workshop/favourites.h" -#include "core/workshop/types.h" -#include "support.h" +#include "core/config.hpp" +#include "core/workshop/favourites.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" using namespace px::modrim; using namespace px::modrim::workshop; diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp index fdf17d1..4496682 100644 --- a/tests/core/workshop/test_types.cpp +++ b/tests/core/workshop/test_types.cpp @@ -4,8 +4,8 @@ #include -#include "core/workshop/types.h" -#include "support.h" +#include "core/workshop/types.hpp" +#include "support.hpp" using namespace px::modrim::workshop; using workshop_test::decode_collection; diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp index 77c7d96..1a39c0b 100644 --- a/tests/ui/test_dialog_chrome.cpp +++ b/tests/ui/test_dialog_chrome.cpp @@ -5,7 +5,7 @@ #include #include -#include "ui/components/dialog_chrome.h" +#include "ui/components/dialog_chrome.hpp" namespace { diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp index ecb9b07..82b4a6c 100644 --- a/tests/ui/test_downloads_model.cpp +++ b/tests/ui/test_downloads_model.cpp @@ -2,7 +2,7 @@ #include -#include "ui/plugins/downloads/model.h" +#include "ui/plugins/downloads/model.hpp" namespace { QHash states(const px::modrim::DownloadsModel& model) { diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index 6727d0c..01d5db3 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -13,13 +13,13 @@ #include #include -#include "core/config.h" -#include "core/context.h" -#include "core/downloads/manager.h" -#include "core/mod_service.h" -#include "core/models/metadata/structures.h" -#include "ui/context.h" -#include "ui/window/main_window.h" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/models/metadata/structures.hpp" +#include "ui/context.hpp" +#include "ui/window/main_window.hpp" namespace { void ensureApplication() { diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp index 7526036..cac9480 100644 --- a/tests/ui/test_menu_bar.cpp +++ b/tests/ui/test_menu_bar.cpp @@ -9,8 +9,8 @@ #include #include -#include "ui/window/actions.h" -#include "ui/window/menu_bar.h" +#include "ui/window/actions.hpp" +#include "ui/window/menu_bar.hpp" namespace { void ensureApplication() { diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp index 8e3e0c6..1c731c4 100644 --- a/tests/ui/test_mod_info_data.cpp +++ b/tests/ui/test_mod_info_data.cpp @@ -8,9 +8,9 @@ #include #include -#include "test_support.h" -#include "core/downloads/manager.h" -#include "ui/panels/mod_info_data.h" +#include "test_support.hpp" +#include "core/downloads/manager.hpp" +#include "ui/panels/mod_info_data.hpp" namespace px::modrim { namespace { diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp index d4afdde..edef203 100644 --- a/tests/ui/test_mod_list_model.cpp +++ b/tests/ui/test_mod_list_model.cpp @@ -1,9 +1,9 @@ #include #include #include -#include "ui/models/mod_list_model.h" -#include "ui/models/impact.h" -#include "ui/theme/palette.h" +#include "ui/models/mod_list_model.hpp" +#include "ui/models/impact.hpp" +#include "ui/theme/palette.hpp" using namespace px::modrim; namespace { struct ListFixture { diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp index b1d77eb..9741141 100644 --- a/tests/ui/test_mod_list_panel.cpp +++ b/tests/ui/test_mod_list_panel.cpp @@ -11,14 +11,14 @@ #include #include #include -#include "test_support.h" -#include "core/context.h" -#include "core/services/diagnostics_service.h" -#include "ui/components/mod_activation.h" -#include "ui/components/svg_provider.h" -#include "ui/panels/mod_list_panel.h" -#include "ui/theme/qml_theme.h" -#include "../core/services/services_support.h" +#include "test_support.hpp" +#include "core/context.hpp" +#include "core/services/diagnostics_service.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/theme/qml_theme.hpp" +#include "../core/services/services_support.hpp" using px::modrim::ModListPanel; namespace { struct PanelFixture { diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp index 95d6e08..ce4e912 100644 --- a/tests/ui/test_mod_list_proxy_model.cpp +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -1,7 +1,7 @@ #include #include #include -#include "ui/models/mod_list_proxy_model.h" +#include "ui/models/mod_list_proxy_model.hpp" using namespace px::modrim; namespace { struct ProxyFixture { diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp index 61c62b2..1a3ecb7 100644 --- a/tests/ui/test_mod_tree_model.cpp +++ b/tests/ui/test_mod_tree_model.cpp @@ -3,8 +3,8 @@ #include #include -#include "ui/plugins/organizer/filter_model.h" -#include "ui/plugins/organizer/tree_model.h" +#include "ui/plugins/organizer/filter_model.hpp" +#include "ui/plugins/organizer/tree_model.hpp" using namespace px::modrim; using namespace px::modrim::organizer; diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp index 9f0066d..ba310a1 100644 --- a/tests/ui/test_mod_updates.cpp +++ b/tests/ui/test_mod_updates.cpp @@ -3,8 +3,8 @@ #include #include #include -#include "ui/components/mod_updates.h" -#include "../core/services/services_support.h" +#include "ui/components/mod_updates.hpp" +#include "../core/services/services_support.hpp" TEST_CASE("small updates skip confirmation") { QWidget parent; diff --git a/tests/ui/test_navigation.cpp b/tests/ui/test_navigation.cpp index d4a04b6..7611392 100644 --- a/tests/ui/test_navigation.cpp +++ b/tests/ui/test_navigation.cpp @@ -3,7 +3,7 @@ #include #include -#include "ui/navigation.h" +#include "ui/navigation.hpp" using px::modrim::Route; using px::modrim::buildRoute; diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp index 519bf2a..a382832 100644 --- a/tests/ui/test_organizer_dialogs.cpp +++ b/tests/ui/test_organizer_dialogs.cpp @@ -6,7 +6,7 @@ #include #include -#include "ui/plugins/organizer/dialogs.h" +#include "ui/plugins/organizer/dialogs.hpp" using px::modrim::DeleteFolderDialog; diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp index d66a22a..fcb0348 100644 --- a/tests/ui/test_organizer_rule_editor.cpp +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -12,15 +12,15 @@ #include #include -#include "test_support.h" -#include "core/context.h" -#include "core/organizer/defaults.h" -#include "ui/components/svg_provider.h" -#include "ui/context.h" -#include "ui/plugins/organizer/tree_model.h" -#include "ui/plugins/organizer/view.h" -#include "ui/theme/qml_theme.h" -#include "../core/organizer/test_util.h" +#include "test_support.hpp" +#include "core/context.hpp" +#include "core/organizer/defaults.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/plugins/organizer/tree_model.hpp" +#include "ui/plugins/organizer/view.hpp" +#include "ui/theme/qml_theme.hpp" +#include "../core/organizer/test_util.hpp" namespace { using namespace px::modrim; diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp index 2b1e768..1cfa36d 100644 --- a/tests/ui/test_procedural_preview.cpp +++ b/tests/ui/test_procedural_preview.cpp @@ -5,7 +5,7 @@ #include #include -#include "ui/components/procedural_preview.h" +#include "ui/components/procedural_preview.hpp" namespace { diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp index 4989bb2..a836354 100644 --- a/tests/ui/test_restore_snapshot_dialog.cpp +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -15,9 +15,9 @@ #include #include -#include "core/mods_config.h" -#include "core/models/metadata/structures.h" -#include "ui/panels/restore_snapshot_dialog.h" +#include "core/mods_config.hpp" +#include "core/models/metadata/structures.hpp" +#include "ui/panels/restore_snapshot_dialog.hpp" namespace { diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp index 876a9a1..da9b62d 100644 --- a/tests/ui/test_settings_panel.cpp +++ b/tests/ui/test_settings_panel.cpp @@ -7,11 +7,11 @@ #include #include #include -#include "test_support.h" -#include "ui/theme/qml_theme.h" -#include "core/context.h" -#include "ui/components/svg_provider.h" -#include "ui/panels/settings_panel.h" +#include "test_support.hpp" +#include "ui/theme/qml_theme.hpp" +#include "core/context.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/panels/settings_panel.hpp" namespace { void ensureApplication() { if (QCoreApplication::instance()) return; diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp index ac4ee1a..fa72a14 100644 --- a/tests/ui/test_sidebar_model.cpp +++ b/tests/ui/test_sidebar_model.cpp @@ -1,7 +1,7 @@ #include #include -#include "ui/models/sidebar_model.h" -#include "ui/theme/palette.h" +#include "ui/models/sidebar_model.hpp" +#include "ui/theme/palette.hpp" TEST_CASE("sidebar entry updates refresh provider icons without resetting") { using px::modrim::view::SidebarEntry; px::modrim::SidebarModel model; diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp index 437ba73..7b89ee9 100644 --- a/tests/ui/test_startup_impact_data.cpp +++ b/tests/ui/test_startup_impact_data.cpp @@ -1,6 +1,6 @@ #include #include -#include "ui/panels/startup_impact_data.h" +#include "ui/panels/startup_impact_data.hpp" using namespace px::modrim; using namespace px::modrim::startup_impact; namespace { diff --git a/tests/ui/test_support.h b/tests/ui/test_support.hpp similarity index 91% rename from tests/ui/test_support.h rename to tests/ui/test_support.hpp index 4dd6f32..40324a0 100644 --- a/tests/ui/test_support.h +++ b/tests/ui/test_support.hpp @@ -4,8 +4,8 @@ #include -#include "core/config.h" -#include "core/context.h" +#include "core/config.hpp" +#include "core/context.hpp" namespace px::ui_test { diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp index 07a27cb..4729902 100644 --- a/tests/ui/test_toast_and_progress.cpp +++ b/tests/ui/test_toast_and_progress.cpp @@ -9,9 +9,9 @@ #include #include -#include "core/loading.h" -#include "ui/components/progress_dialog.h" -#include "ui/components/toast.h" +#include "core/loading.hpp" +#include "ui/components/progress_dialog.hpp" +#include "ui/components/toast.hpp" namespace { diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp index 753111f..c857b69 100644 --- a/tests/ui/test_unity_rich_text.cpp +++ b/tests/ui/test_unity_rich_text.cpp @@ -5,7 +5,7 @@ #include #include -#include "ui/components/unity_rich_text.h" +#include "ui/components/unity_rich_text.hpp" namespace { diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp index 03a215d..6789bb5 100644 --- a/tests/ui/test_views.cpp +++ b/tests/ui/test_views.cpp @@ -8,14 +8,14 @@ #include #include -#include "test_support.h" -#include "core/context.h" -#include "ui/components/svg_provider.h" -#include "ui/context.h" -#include "ui/theme/qml_theme.h" +#include "test_support.hpp" +#include "core/context.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/theme/qml_theme.hpp" #ifdef PX_HAS_STEAM -#include "ui/plugins/steam_workshop/view.h" +#include "ui/plugins/steam_workshop/view.hpp" namespace { // The embedded browser exists only in WebEngine builds; without it there is no "workshopWeb" object. diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp index 22400f0..0683d2f 100644 --- a/tests/ui/test_workshop_installed.cpp +++ b/tests/ui/test_workshop_installed.cpp @@ -4,7 +4,7 @@ #include -#include "ui/plugins/workshop/installed.h" +#include "ui/plugins/workshop/installed.hpp" using px::modrim::workshop::CatalogMod; using px::modrim::workshop::InstalledList; diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp index 23848ca..ecdbbd8 100644 --- a/tests/ui/test_workshop_models.cpp +++ b/tests/ui/test_workshop_models.cpp @@ -5,8 +5,8 @@ #include -#include "ui/panels/mod_info_data.h" -#include "ui/plugins/workshop/models.h" +#include "ui/panels/mod_info_data.hpp" +#include "ui/plugins/workshop/models.hpp" using namespace px::modrim::workshop; using namespace px::modrim::workshop; diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index 4bd7aeb..3e3d2c3 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -8,17 +8,17 @@ #include #include -#include "test_support.h" -#include "ui/theme/qml_theme.h" -#include "../core/workshop/support.h" -#include "core/context.h" -#include "core/downloads/downloader.h" -#include "core/mod_service.h" -#include "ui/components/svg_provider.h" -#include "ui/context.h" -#include "ui/navigation.h" -#include "ui/plugins/workshop/plugin.h" -#include "ui/plugins/workshop/view.h" +#include "test_support.hpp" +#include "ui/theme/qml_theme.hpp" +#include "../core/workshop/support.hpp" +#include "core/context.hpp" +#include "core/downloads/downloader.hpp" +#include "core/mod_service.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/navigation.hpp" +#include "ui/plugins/workshop/plugin.hpp" +#include "ui/plugins/workshop/view.hpp" namespace { using namespace px::modrim; From 97d47c78c058c05ad8e2875362ff244a14966409 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 04:03:18 +0300 Subject: [PATCH 09/50] refactor: camelCase functions and variables, m_ member prefix, kConstants (clang-tidy readability-identifier-naming) --- .clang-tidy | 41 ++ src/app/main.cpp | 8 +- src/core/checker/checker.cpp | 246 ++++----- src/core/checker/checker.hpp | 78 +-- src/core/checker/databases.cpp | 142 ++--- src/core/checker/databases.hpp | 34 +- src/core/checker/graph.cpp | 258 ++++----- src/core/checker/graph.hpp | 68 +-- src/core/checker/issues.cpp | 202 +++---- src/core/checker/issues.hpp | 28 +- src/core/checker/models.cpp | 8 +- src/core/checker/models.hpp | 30 +- src/core/checker/sort.cpp | 142 ++--- src/core/checker/sort.hpp | 4 +- src/core/config.cpp | 198 +++---- src/core/config.hpp | 58 +- src/core/constants.hpp | 22 +- src/core/context.cpp | 124 ++--- src/core/context.hpp | 74 +-- src/core/downloads/downloader.cpp | 12 +- src/core/downloads/downloader.hpp | 34 +- src/core/downloads/manager.cpp | 102 ++-- src/core/downloads/manager.hpp | 40 +- src/core/downloads/steam.cpp | 372 ++++++------- src/core/downloads/steam.hpp | 98 ++-- src/core/downloads/types.hpp | 12 +- src/core/events.hpp | 14 +- src/core/file_io.cpp | 6 +- src/core/file_io.hpp | 6 +- src/core/loading.cpp | 62 +-- src/core/loading.hpp | 26 +- src/core/migrator.cpp | 60 +- src/core/migrator.hpp | 4 +- src/core/mod_service.cpp | 210 +++---- src/core/mod_service.hpp | 44 +- src/core/models/metadata/parsing.cpp | 50 +- src/core/models/metadata/structures.cpp | 104 ++-- src/core/models/metadata/structures.hpp | 134 +++-- src/core/models/view/diagnostics.hpp | 12 +- src/core/models/view/sidebar.cpp | 4 +- src/core/models/view/sidebar.hpp | 12 +- src/core/mods_config.cpp | 152 ++--- src/core/mods_config.hpp | 16 +- src/core/organizer/db.cpp | 320 +++++------ src/core/organizer/db.hpp | 40 +- src/core/organizer/defaults.cpp | 14 +- src/core/organizer/defaults.hpp | 6 +- src/core/organizer/models.hpp | 60 +- src/core/organizer/resolve.cpp | 256 ++++----- src/core/organizer/resolve.hpp | 56 +- src/core/organizer/service.cpp | 288 +++++----- src/core/organizer/service.hpp | 86 +-- src/core/plugin.hpp | 52 +- src/core/plugin_config.hpp | 38 +- src/core/profiler.cpp | 26 +- src/core/profiler.hpp | 18 +- src/core/providers/base.cpp | 52 +- src/core/providers/base.hpp | 44 +- src/core/providers/core.cpp | 10 +- src/core/providers/core.hpp | 6 +- src/core/providers/factory.cpp | 2 +- src/core/providers/factory.hpp | 2 +- src/core/providers/local.cpp | 22 +- src/core/providers/local.hpp | 12 +- src/core/services/activation_service.cpp | 268 ++++----- src/core/services/activation_service.hpp | 4 +- src/core/services/diagnostics_service.cpp | 228 ++++---- src/core/services/diagnostics_service.hpp | 68 +-- src/core/services/game_launcher.cpp | 52 +- src/core/services/game_launcher.hpp | 18 +- src/core/services/game_process.cpp | 148 ++--- src/core/services/game_process.hpp | 56 +- src/core/services/metadata_cache.cpp | 292 +++++----- src/core/services/metadata_cache.hpp | 40 +- src/core/services/mod_discovery.cpp | 28 +- src/core/services/mod_discovery.hpp | 4 +- src/core/services/sort_service.cpp | 88 +-- src/core/services/sort_service.hpp | 18 +- .../services/startup_impact_service/db.cpp | 246 ++++----- .../services/startup_impact_service/db.hpp | 32 +- .../startup_impact_service/labels.cpp | 14 +- .../startup_impact_service/labels.hpp | 4 +- .../startup_impact_service/models.cpp | 46 +- .../startup_impact_service/models.hpp | 40 +- .../startup_impact_service/parser.cpp | 68 +-- .../startup_impact_service/parser.hpp | 4 +- .../startup_impact_service/phases.cpp | 70 +-- .../startup_impact_service/phases.hpp | 26 +- .../startup_impact_service/service.cpp | 72 +-- .../startup_impact_service/service.hpp | 22 +- src/core/services/update_service.cpp | 60 +- src/core/services/update_service.hpp | 18 +- src/core/sort/community.cpp | 26 +- src/core/sort/community.hpp | 4 +- src/core/sort/community_service.cpp | 102 ++-- src/core/sort/community_service.hpp | 14 +- src/core/sort/config.hpp | 6 +- src/core/sort/models.hpp | 12 +- src/core/sort/tiers.cpp | 86 +-- src/core/sort/tiers.hpp | 16 +- src/core/support/report.cpp | 144 ++--- src/core/support/report.hpp | 32 +- src/core/toposort.hpp | 2 +- src/core/workshop/catalog.cpp | 258 ++++----- src/core/workshop/catalog.hpp | 138 ++--- src/core/workshop/client.cpp | 78 +-- src/core/workshop/client.hpp | 16 +- src/core/workshop/favourites.cpp | 20 +- src/core/workshop/favourites.hpp | 12 +- src/core/workshop/types.hpp | 74 ++- src/ui/Downloads.qml | 2 +- src/ui/components/banner_widget.cpp | 58 +- src/ui/components/banner_widget.hpp | 14 +- src/ui/components/dialog_chrome.cpp | 44 +- src/ui/components/dialogs.hpp | 26 +- src/ui/components/filter_sidebar.cpp | 2 +- src/ui/components/filter_sidebar.hpp | 2 +- src/ui/components/header_controller.cpp | 50 +- src/ui/components/header_controller.hpp | 56 +- src/ui/components/icon_button.cpp | 4 +- src/ui/components/icon_button.hpp | 4 +- src/ui/components/mod_activation.cpp | 10 +- src/ui/components/mod_activation.hpp | 4 +- src/ui/components/mod_updates.cpp | 26 +- src/ui/components/mod_updates.hpp | 12 +- src/ui/components/procedural_preview.cpp | 18 +- src/ui/components/procedural_preview.hpp | 2 +- src/ui/components/progress_dialog.cpp | 80 +-- src/ui/components/progress_dialog.hpp | 18 +- src/ui/components/toast.cpp | 72 +-- src/ui/components/toast.hpp | 14 +- src/ui/components/unity_rich_text.cpp | 20 +- src/ui/components/unity_rich_text.hpp | 4 +- src/ui/components/view_rail_panel.cpp | 4 +- src/ui/config.cpp | 14 +- src/ui/config.hpp | 4 +- src/ui/context.cpp | 30 +- src/ui/context.hpp | 40 +- src/ui/mod_selection.cpp | 28 +- src/ui/mod_selection.hpp | 6 +- src/ui/models/impact.cpp | 8 +- src/ui/models/impact.hpp | 4 +- src/ui/models/mod_list_model.cpp | 216 ++++---- src/ui/models/mod_list_model.hpp | 66 +-- src/ui/models/mod_list_proxy_model.cpp | 180 +++--- src/ui/models/mod_list_proxy_model.hpp | 46 +- src/ui/models/sidebar_model.cpp | 92 +-- src/ui/models/sidebar_model.hpp | 14 +- src/ui/navigation.cpp | 2 +- src/ui/navigation.hpp | 2 +- src/ui/panels/about_panel.cpp | 18 +- src/ui/panels/about_panel.hpp | 6 +- src/ui/panels/keyboard_shortcuts_dialog.hpp | 2 +- src/ui/panels/mod_info_data.cpp | 84 +-- src/ui/panels/mod_info_data.hpp | 14 +- src/ui/panels/mod_info_panel.cpp | 148 ++--- src/ui/panels/mod_info_panel.hpp | 28 +- src/ui/panels/mod_list_panel.cpp | 148 ++--- src/ui/panels/mod_list_panel.hpp | 68 +-- src/ui/panels/restore_snapshot_dialog.cpp | 68 +-- src/ui/panels/restore_snapshot_dialog.hpp | 10 +- src/ui/panels/settings_panel.cpp | 84 +-- src/ui/panels/settings_panel.hpp | 36 +- src/ui/panels/sidebar_panel.cpp | 34 +- src/ui/panels/sidebar_panel.hpp | 20 +- src/ui/panels/startup_impact_data.cpp | 94 ++-- src/ui/panels/startup_impact_data.hpp | 6 +- src/ui/panels/time_analytics_panel.cpp | 32 +- src/ui/panels/time_analytics_panel.hpp | 6 +- src/ui/panels/update_dialog.cpp | 2 +- src/ui/panels/update_dialog.hpp | 2 +- src/ui/panels/upload_report_dialog.cpp | 12 +- src/ui/panels/upload_report_dialog.hpp | 4 +- src/ui/plugins/downloads/model.cpp | 178 +++--- src/ui/plugins/downloads/model.hpp | 100 ++-- src/ui/plugins/downloads/view.cpp | 47 +- src/ui/plugins/downloads/view.hpp | 18 +- src/ui/plugins/organizer/dialogs.cpp | 52 +- src/ui/plugins/organizer/dialogs.hpp | 6 +- src/ui/plugins/organizer/filter_model.cpp | 60 +- src/ui/plugins/organizer/filter_model.hpp | 8 +- src/ui/plugins/organizer/tree_model.cpp | 240 ++++---- src/ui/plugins/organizer/tree_model.hpp | 66 +-- src/ui/plugins/organizer/view.cpp | 418 +++++++------- src/ui/plugins/organizer/view.hpp | 58 +- src/ui/plugins/steam_workshop/bridge_obj.cpp | 70 +-- src/ui/plugins/steam_workshop/bridge_obj.hpp | 10 +- .../steam_workshop/download_queue_model.cpp | 34 +- .../steam_workshop/download_queue_model.hpp | 8 +- .../steam_workshop/download_sidebar.cpp | 36 +- .../steam_workshop/download_sidebar.hpp | 18 +- src/ui/plugins/steam_workshop/plugin.cpp | 226 ++++---- src/ui/plugins/steam_workshop/plugin.hpp | 74 +-- src/ui/plugins/steam_workshop/view.cpp | 114 ++-- src/ui/plugins/steam_workshop/view.hpp | 26 +- src/ui/plugins/workshop/details.cpp | 12 +- src/ui/plugins/workshop/details.hpp | 8 +- src/ui/plugins/workshop/installed.cpp | 34 +- src/ui/plugins/workshop/installed.hpp | 22 +- src/ui/plugins/workshop/models.cpp | 128 ++--- src/ui/plugins/workshop/models.hpp | 28 +- src/ui/plugins/workshop/plugin.cpp | 4 +- src/ui/plugins/workshop/plugin.hpp | 2 +- src/ui/plugins/workshop/view.cpp | 448 +++++++-------- src/ui/plugins/workshop/view.hpp | 182 +++--- src/ui/settings_section.cpp | 14 +- src/ui/settings_section.hpp | 10 +- src/ui/theme/constants.hpp | 22 +- src/ui/theme/qml_theme.cpp | 22 +- src/ui/theme/qml_theme.hpp | 4 +- src/ui/ui_prefs.hpp | 4 +- src/ui/views/base.cpp | 6 +- src/ui/views/base.hpp | 2 +- src/ui/views/mods_view.cpp | 28 +- src/ui/views/mods_view.hpp | 16 +- src/ui/window/actions.cpp | 36 +- src/ui/window/actions.hpp | 6 +- src/ui/window/main_window.cpp | 522 +++++++++--------- src/ui/window/main_window.hpp | 70 +-- src/ui/window/menu_bar.cpp | 10 +- tests/core/checker/test_checker.cpp | 162 +++--- tests/core/checker/test_databases.cpp | 48 +- tests/core/checker/test_graph.cpp | 184 +++--- tests/core/checker/test_issues.cpp | 218 ++++---- tests/core/downloads/test_manager.cpp | 88 +-- tests/core/downloads/test_steam.cpp | 264 ++++----- tests/core/models/metadata/test_parsing.cpp | 56 +- .../core/models/metadata/test_structures.cpp | 36 +- tests/core/organizer/test_db.cpp | 234 ++++---- tests/core/organizer/test_resolve.cpp | 408 +++++++------- tests/core/organizer/test_service.cpp | 374 ++++++------- tests/core/organizer/test_util.hpp | 2 +- tests/core/providers/test_factory.cpp | 14 +- tests/core/providers/test_providers.cpp | 20 +- tests/core/services/services_support.hpp | 10 +- .../startup_impact_service/test_db.cpp | 160 +++--- .../startup_impact_service/test_phases.cpp | 52 +- .../test_startup_impact.cpp | 140 ++--- .../core/services/test_activation_service.cpp | 110 ++-- tests/core/services/test_game_launcher.cpp | 94 ++-- tests/core/services/test_metadata_cache.cpp | 136 ++--- tests/core/services/test_sort_service.cpp | 58 +- tests/core/services/test_update_service.cpp | 58 +- tests/core/sort/test_community.cpp | 24 +- tests/core/sort/test_tier_order.cpp | 136 ++--- tests/core/sort/test_tiers.cpp | 78 +-- tests/core/support/test_report.cpp | 130 ++--- tests/core/test_config.cpp | 138 ++--- tests/core/test_context.cpp | 44 +- tests/core/test_loading.cpp | 10 +- tests/core/test_migrator.cpp | 88 +-- tests/core/test_mods_config.cpp | 170 +++--- tests/core/test_plugin.cpp | 78 +-- tests/core/test_plugin_config.cpp | 12 +- tests/core/test_support.hpp | 16 +- tests/core/workshop/support.hpp | 40 +- tests/core/workshop/test_catalog.cpp | 184 +++--- tests/core/workshop/test_client.cpp | 168 +++--- tests/core/workshop/test_favourites.cpp | 26 +- tests/core/workshop/test_types.cpp | 38 +- tests/ui/test_downloads_model.cpp | 8 +- tests/ui/test_main_window.cpp | 6 +- tests/ui/test_menu_bar.cpp | 4 +- tests/ui/test_mod_info_data.cpp | 148 ++--- tests/ui/test_mod_list_model.cpp | 90 +-- tests/ui/test_mod_list_panel.cpp | 90 +-- tests/ui/test_mod_list_proxy_model.cpp | 110 ++-- tests/ui/test_mod_tree_model.cpp | 238 ++++---- tests/ui/test_mod_updates.cpp | 4 +- tests/ui/test_organizer_dialogs.cpp | 4 +- tests/ui/test_organizer_rule_editor.cpp | 80 +-- tests/ui/test_procedural_preview.cpp | 6 +- tests/ui/test_restore_snapshot_dialog.cpp | 168 +++--- tests/ui/test_sidebar_model.cpp | 26 +- tests/ui/test_startup_impact_data.cpp | 16 +- tests/ui/test_toast_and_progress.cpp | 2 +- tests/ui/test_unity_rich_text.cpp | 8 +- tests/ui/test_views.cpp | 4 +- tests/ui/test_workshop_images.cpp | 12 +- tests/ui/test_workshop_installed.cpp | 52 +- tests/ui/test_workshop_models.cpp | 78 +-- tests/ui/test_workshop_view.cpp | 178 +++--- 282 files changed, 9666 insertions(+), 9538 deletions(-) create mode 100644 .clang-tidy diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..d254cc5 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,41 @@ +--- +# Naming only. The formatter handles layout; this check enforces (and, with --fix, applies) +# the identifier style. +Checks: '-*,readability-identifier-naming' +HeaderFilterRegex: '.*/(src|tests)/.*' +CheckOptions: + readability-identifier-naming.NamespaceCase: lower_case + + readability-identifier-naming.ClassCase: CamelCase + readability-identifier-naming.StructCase: CamelCase + readability-identifier-naming.UnionCase: CamelCase + readability-identifier-naming.EnumCase: CamelCase + readability-identifier-naming.TypeAliasCase: CamelCase + readability-identifier-naming.TypedefCase: CamelCase + readability-identifier-naming.TemplateParameterCase: CamelCase + readability-identifier-naming.ConceptCase: CamelCase + + readability-identifier-naming.FunctionCase: camelBack + readability-identifier-naming.MethodCase: camelBack + + readability-identifier-naming.VariableCase: camelBack + readability-identifier-naming.ParameterCase: camelBack + readability-identifier-naming.LocalVariableCase: camelBack + + readability-identifier-naming.PrivateMemberCase: camelBack + readability-identifier-naming.PrivateMemberPrefix: m_ + readability-identifier-naming.ProtectedMemberCase: camelBack + readability-identifier-naming.ProtectedMemberPrefix: m_ + readability-identifier-naming.PublicMemberCase: camelBack + readability-identifier-naming.ClassMemberCase: camelBack + + readability-identifier-naming.GlobalConstantCase: CamelCase + readability-identifier-naming.GlobalConstantPrefix: k + readability-identifier-naming.ConstexprVariableCase: CamelCase + readability-identifier-naming.ConstexprVariablePrefix: k + readability-identifier-naming.ClassConstantCase: CamelCase + readability-identifier-naming.ClassConstantPrefix: k + readability-identifier-naming.EnumConstantCase: CamelCase + readability-identifier-naming.EnumConstantPrefix: k + + readability-identifier-naming.MacroDefinitionCase: UPPER_CASE diff --git a/src/app/main.cpp b/src/app/main.cpp index 75bf9a5..08486f1 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -107,7 +107,7 @@ QCoro::Task teardown(px::modrim::AppContext& appContext, px::modrim::CoreC QCoro::Task runApp(QApplication& qtApp, QSet disabled) { spdlog::info("Starting PxModRim"); - px::modrim::ConfigService configService(px::modrim::config_dir()); + px::modrim::ConfigService configService(px::modrim::configDir()); px::modrim::AppConfigService appConfigService(configService); px::modrim::UiPrefsService uiPrefsService(configService); @@ -126,7 +126,7 @@ QCoro::Task runApp(QApplication& qtApp, QSet disabled) { if (cfg.paths.game.empty()) { spdlog::info("No game path in config, attempting auto-detect"); - if (auto detected = px::modrim::detect_game_paths(); !detected.game.empty()) { + if (auto detected = px::modrim::detectGamePaths(); !detected.game.empty()) { cfg.paths = detected; if (auto saved = configService.save(QStringLiteral("config.json"), cfg); !saved) { spdlog::warn("Cannot save detected game paths: {}", saved.error().message.toStdString()); @@ -135,7 +135,7 @@ QCoro::Task runApp(QApplication& qtApp, QSet disabled) { } // Declared before the context, so it outlives the organizer service that holds it. - px::modrim::organizer::OrganizerDb organizerDb(px::modrim::organizer::db_path(configService.directory())); + px::modrim::organizer::OrganizerDb organizerDb(px::modrim::organizer::dbPath(configService.directory())); auto ctx = px::modrim::CoreContext::create(cfg, &configService); const QStringList downloaders = px::modrim::registerCorePlugins(*ctx, configService, organizerDb, disabled); @@ -198,7 +198,7 @@ int main(int argc, char* argv[]) { px::modrim::applyAppTheme(qtApp); qtApp.installEventFilter(new px::modrim::QuickPopupParenting(&qtApp)); - configureLogging(QDir(px::modrim::config_dir()).filePath(QStringLiteral("logs"))); + configureLogging(QDir(px::modrim::configDir()).filePath(QStringLiteral("logs"))); if (const int smokeMs = smokeExitMs(); smokeMs > 0) { QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp index 7160fed..0951375 100644 --- a/src/core/checker/checker.cpp +++ b/src/core/checker/checker.cpp @@ -8,11 +8,11 @@ namespace px::modrim::checker { namespace { -ModDiagnostics compose(const std::vector>& per_checker) { +ModDiagnostics compose(const std::vector>& perChecker) { ModDiagnostics diagnostics; - for (const auto& issues : per_checker) { + for (const auto& issues : perChecker) { for (const ModIssue& issue : issues) { - if (issue.severity == IssueSeverity::Error) { + if (issue.severity == IssueSeverity::kError) { diagnostics.errors.append(issue); } else { diagnostics.warnings.append(issue); @@ -23,7 +23,7 @@ ModDiagnostics compose(const std::vector>& per_checker) { } // Same key set, and every entry is the same mod object (identity, not equality). -bool same_mods(const QHash& a, +bool sameMods(const QHash& a, const QHash& b) { if (a.size() != b.size()) { return false; @@ -41,122 +41,122 @@ bool same_mods(const QHash& a, ModChecker::ModChecker(std::vector> checkers, sort::SortSettings settings, - QString target_version, - DiagnosticsCallback on_diagnostics_changed) - : checkers_(std::move(checkers)), - settings_(std::move(settings)), - target_version_(std::move(target_version)), - on_diagnostics_changed_(std::move(on_diagnostics_changed)) {} - -void ModChecker::set_no_version_warning(QSet pids) { - no_version_warning_ = std::move(pids); - checker_results_.reset(); + QString targetVersion, + DiagnosticsCallback onDiagnosticsChanged) + : m_checkers(std::move(checkers)), + m_settings(std::move(settings)), + m_targetVersion(std::move(targetVersion)), + m_onDiagnosticsChanged(std::move(onDiagnosticsChanged)) {} + +void ModChecker::setNoVersionWarning(QSet pids) { + m_noVersionWarning = std::move(pids); + m_checkerResults.reset(); } -void ModChecker::set_use_this_instead(QHash db) { - use_this_instead_ = std::move(db); - checker_results_.reset(); +void ModChecker::setUseThisInstead(QHash db) { + m_useThisInstead = std::move(db); + m_checkerResults.reset(); } -void ModChecker::set_community_rules(sort::CommunityRules rules) { - community_rules_ = std::move(rules); +void ModChecker::setCommunityRules(sort::CommunityRules rules) { + m_communityRules = std::move(rules); } void ModChecker::rebuild(const QHash& mods, - const QStringList& ordered_uuids, + const QStringList& orderedUuids, Profiler* timer) { Profiler local; Profiler& t = timer != nullptr ? *timer : local; { auto scope = t("collect_active"); - collect_active(mods, ordered_uuids); + collectActive(mods, orderedUuids); } { auto scope = t("graph.build"); - graph_.build(active_mods_, ordered_pids_, settings_, &community_rules_); + m_graph.build(m_activeMods, m_orderedPids, m_settings, &m_communityRules); } { auto scope = t("find_cycles"); - cached_cycles_ = graph_.find_cycles(); + m_cachedCycles = m_graph.findCycles(); } - const CheckContext ctx = build_context(cached_cycles_); + const CheckContext ctx = buildContext(m_cachedCycles); { auto scope = t("check_mod"); - check_all(ctx); + checkAll(ctx); } - emit_diagnostics(); + emitDiagnostics(); } -void ModChecker::toggle_mod(const ListedMod& mod, const QStringList& ordered_uuids) { +void ModChecker::toggleMod(const ListedMod& mod, const QStringList& orderedUuids) { if (dynamic_cast(&mod) == nullptr) { - emit_diagnostics(); + emitDiagnostics(); return; } - rebuild(all_mods_, ordered_uuids); + rebuild(m_allMods, orderedUuids); } -void ModChecker::move_mod(const QString& uuid, int old_index, int new_index) { - if (ordered_pids_.isEmpty()) { +void ModChecker::moveMod(const QString& uuid, int oldIndex, int newIndex) { + if (m_orderedPids.isEmpty()) { return; } - const auto uuid_it = uuid_to_pid_.constFind(uuid); - if (uuid_it == uuid_to_pid_.cend()) { + const auto uuidIt = m_uuidToPid.constFind(uuid); + if (uuidIt == m_uuidToPid.cend()) { return; } - const PackageId pid = uuid_it.value(); + const PackageId pid = uuidIt.value(); - ordered_pids_.insert(new_index, ordered_pids_.takeAt(old_index)); - graph_.update_order(ordered_pids_); + m_orderedPids.insert(newIndex, m_orderedPids.takeAt(oldIndex)); + m_graph.updateOrder(m_orderedPids); - const CheckContext ctx = build_context(cached_cycles_); + const CheckContext ctx = buildContext(m_cachedCycles); // Only neighbours are rechecked, so cached results no longer match the order. - checker_results_.reset(); + m_checkerResults.reset(); QSet affected{pid}; - affected.unite(graph_.neighbors(pid)); - for (const PackageId& current_pid : affected) { - if (const auto mod = active_mods_.constFind(current_pid); mod != active_mods_.cend()) { - diagnostics_.insert(current_pid, check_mod(*mod.value(), ctx)); + affected.unite(m_graph.neighbors(pid)); + for (const PackageId& currentPid : affected) { + if (const auto mod = m_activeMods.constFind(currentPid); mod != m_activeMods.cend()) { + m_diagnostics.insert(currentPid, checkMod(*mod.value(), ctx)); } } - emit_diagnostics(); + emitDiagnostics(); } -void ModChecker::reorder(const QStringList& ordered_uuids) { - const QHash previous = active_mods_; - const QHash previous_index = graph_.pid_to_index(); - collect_active(all_mods_, ordered_uuids); - graph_.update_order(ordered_pids_); +void ModChecker::reorder(const QStringList& orderedUuids) { + const QHash previous = m_activeMods; + const QHash previousIndex = m_graph.pidToIndex(); + collectActive(m_allMods, orderedUuids); + m_graph.updateOrder(m_orderedPids); - const QList> cycles = graph_.find_cycles(); - const CheckContext ctx = build_context(cycles); + const QList> cycles = m_graph.findCycles(); + const CheckContext ctx = buildContext(cycles); - if (!checker_results_.has_value() || !same_mods(previous, active_mods_)) { - check_all(ctx); + if (!m_checkerResults.has_value() || !sameMods(previous, m_activeMods)) { + checkAll(ctx); } else { QSet moved; - for (auto it = ctx.pid_to_index.cbegin(); it != ctx.pid_to_index.cend(); ++it) { - if (previous_index.value(it.key(), -1) != it.value()) { + for (auto it = ctx.pidToIndex.cbegin(); it != ctx.pidToIndex.cend(); ++it) { + if (previousIndex.value(it.key(), -1) != it.value()) { moved.insert(it.key()); } } - CheckerResults& results = *checker_results_; + CheckerResults& results = *m_checkerResults; QSet changed; - for (std::size_t i = 0; i < checkers_.size(); ++i) { - const ModIssueChecker& checker = *checkers_[i]; - if (!checker.order_dependent()) { + for (std::size_t i = 0; i < m_checkers.size(); ++i) { + const ModIssueChecker& checker = *m_checkers[i]; + if (!checker.orderDependent()) { continue; } - const auto affected = checker.affected_by_moves(ctx, moved); - for (auto it = active_mods_.cbegin(); it != active_mods_.cend(); ++it) { + const auto affected = checker.affectedByMoves(ctx, moved); + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { if (affected && !affected->contains(it.key())) { continue; } - QList issues = run_checker(checker, *it.value(), ctx); + QList issues = runChecker(checker, *it.value(), ctx); auto& stored = results[it.key()][i]; if (issues != stored) { stored = std::move(issues); @@ -165,100 +165,100 @@ void ModChecker::reorder(const QStringList& ordered_uuids) { } } for (const PackageId& pid : changed) { - diagnostics_.insert(pid, compose(results[pid])); + m_diagnostics.insert(pid, compose(results[pid])); } } - emit_diagnostics(); + emitDiagnostics(); } -const ModDiagnostics* ModChecker::diagnostics_for(const QString& uuid) const { - const auto pid = uuid_to_pid_.constFind(uuid); - if (pid == uuid_to_pid_.cend()) { +const ModDiagnostics* ModChecker::diagnosticsFor(const QString& uuid) const { + const auto pid = m_uuidToPid.constFind(uuid); + if (pid == m_uuidToPid.cend()) { return nullptr; } - const auto diagnostics = diagnostics_.constFind(pid.value()); - return diagnostics == diagnostics_.cend() ? nullptr : &diagnostics.value(); + const auto diagnostics = m_diagnostics.constFind(pid.value()); + return diagnostics == m_diagnostics.cend() ? nullptr : &diagnostics.value(); } -DiagnosticsByUuid ModChecker::active_mod_diagnostics() const { +DiagnosticsByUuid ModChecker::activeModDiagnostics() const { DiagnosticsByUuid result; - for (auto it = uuid_to_pid_.cbegin(); it != uuid_to_pid_.cend(); ++it) { - if (const auto diagnostics = diagnostics_.constFind(it.value()); - diagnostics != diagnostics_.cend()) { + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + if (const auto diagnostics = m_diagnostics.constFind(it.value()); + diagnostics != m_diagnostics.cend()) { result.insert(it.key(), diagnostics.value()); } } return result; } -void ModChecker::collect_active(const QHash& mods, - const QStringList& ordered_uuids) { +void ModChecker::collectActive(const QHash& mods, + const QStringList& orderedUuids) { // `mods` may alias all_mods_ (toggle_mod and reorder pass it back in), so copy first. - all_mods_ = mods; - active_mods_.clear(); - uuid_to_pid_.clear(); - ordered_pids_.clear(); - - for (const QString& uuid : ordered_uuids) { - const auto listed = all_mods_.constFind(uuid); - if (listed == all_mods_.cend()) { + m_allMods = mods; + m_activeMods.clear(); + m_uuidToPid.clear(); + m_orderedPids.clear(); + + for (const QString& uuid : orderedUuids) { + const auto listed = m_allMods.constFind(uuid); + if (listed == m_allMods.cend()) { continue; } auto about = std::dynamic_pointer_cast(listed.value()); if (!about) { continue; } - const PackageId pid = about->package_id; - active_mods_.insert(pid, about); - uuid_to_pid_.insert(uuid, pid); - ordered_pids_.append(pid); + const PackageId pid = about->packageId; + m_activeMods.insert(pid, about); + m_uuidToPid.insert(uuid, pid); + m_orderedPids.append(pid); } } -CheckContext ModChecker::build_context(const QList>& cycles) const { +CheckContext ModChecker::buildContext(const QList>& cycles) const { return CheckContext{ - .active_mods = active_mods_, - .ordered_pids = ordered_pids_, - .pid_to_index = graph_.pid_to_index(), - .graph = graph_, - .settings = settings_, - .target_version = target_version_, - .no_version_warning = no_version_warning_, - .use_this_instead = use_this_instead_, + .activeMods = m_activeMods, + .orderedPids = m_orderedPids, + .pidToIndex = m_graph.pidToIndex(), + .graph = m_graph, + .settings = m_settings, + .targetVersion = m_targetVersion, + .noVersionWarning = m_noVersionWarning, + .useThisInstead = m_useThisInstead, .cycles = cycles, }; } -void ModChecker::check_all(const CheckContext& ctx) { +void ModChecker::checkAll(const CheckContext& ctx) { CheckerResults results; QHash diagnostics; - for (auto it = active_mods_.cbegin(); it != active_mods_.cend(); ++it) { - std::vector> per_checker; - per_checker.reserve(checkers_.size()); - for (const auto& checker : checkers_) { - per_checker.push_back(run_checker(*checker, *it.value(), ctx)); + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { + std::vector> perChecker; + perChecker.reserve(m_checkers.size()); + for (const auto& checker : m_checkers) { + perChecker.push_back(runChecker(*checker, *it.value(), ctx)); } - diagnostics.insert(it.key(), compose(per_checker)); - results.insert(it.key(), std::move(per_checker)); + diagnostics.insert(it.key(), compose(perChecker)); + results.insert(it.key(), std::move(perChecker)); } - checker_results_ = std::move(results); - diagnostics_ = std::move(diagnostics); + m_checkerResults = std::move(results); + m_diagnostics = std::move(diagnostics); } -ModDiagnostics ModChecker::check_mod(const AboutXmlMod& mod, const CheckContext& ctx) const { - std::vector> per_checker; - per_checker.reserve(checkers_.size()); - for (const auto& checker : checkers_) { - per_checker.push_back(run_checker(*checker, mod, ctx)); +ModDiagnostics ModChecker::checkMod(const AboutXmlMod& mod, const CheckContext& ctx) const { + std::vector> perChecker; + perChecker.reserve(m_checkers.size()); + for (const auto& checker : m_checkers) { + perChecker.push_back(runChecker(*checker, mod, ctx)); } - return compose(per_checker); + return compose(perChecker); } -QList ModChecker::run_checker(const ModIssueChecker& checker, +QList ModChecker::runChecker(const ModIssueChecker& checker, const AboutXmlMod& mod, const CheckContext& ctx) const { - if (!checker.should_check(mod, ctx)) { + if (!checker.shouldCheck(mod, ctx)) { return {}; } // One checker must not abort diagnostics for every other mod and checker. @@ -266,25 +266,25 @@ QList ModChecker::run_checker(const ModIssueChecker& checker, return checker.check(mod, ctx); } catch (const std::exception& error) { spdlog::error("Checker failed on {}: {}", - mod.package_id.str().toStdString(), + mod.packageId.str().toStdString(), error.what()); return {}; } } -void ModChecker::emit_diagnostics() const { - if (!on_diagnostics_changed_) { +void ModChecker::emitDiagnostics() const { + if (!m_onDiagnosticsChanged) { return; } - DiagnosticsByUuid by_uuid; - for (auto it = uuid_to_pid_.cbegin(); it != uuid_to_pid_.cend(); ++it) { - const auto diagnostics = diagnostics_.constFind(it.value()); - if (diagnostics != diagnostics_.cend() && - (diagnostics->has_errors() || diagnostics->has_warnings())) { - by_uuid.insert(it.key(), diagnostics.value()); + DiagnosticsByUuid byUuid; + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + const auto diagnostics = m_diagnostics.constFind(it.value()); + if (diagnostics != m_diagnostics.cend() && + (diagnostics->hasErrors() || diagnostics->hasWarnings())) { + byUuid.insert(it.key(), diagnostics.value()); } } - on_diagnostics_changed_(by_uuid); + m_onDiagnosticsChanged(byUuid); } } // namespace px::modrim::checker diff --git a/src/core/checker/checker.hpp b/src/core/checker/checker.hpp index 4e7a1e3..09f2a60 100644 --- a/src/core/checker/checker.hpp +++ b/src/core/checker/checker.hpp @@ -30,75 +30,75 @@ class ModChecker { public: ModChecker(std::vector> checkers, sort::SortSettings settings, - QString target_version = QStringLiteral("1.5"), - DiagnosticsCallback on_diagnostics_changed = {}); + QString targetVersion = QStringLiteral("1.5"), + DiagnosticsCallback onDiagnosticsChanged = {}); // ── Database config ─────────────────────────────────────── - void set_no_version_warning(QSet pids); - void set_use_this_instead(QHash db); - void set_community_rules(sort::CommunityRules rules); + void setNoVersionWarning(QSet pids); + void setUseThisInstead(QHash db); + void setCommunityRules(sort::CommunityRules rules); // ── Rebuild ─────────────────────────────────────────────── // Re-scans all mods and regenerates diagnostics from scratch. void rebuild(const QHash& mods, - const QStringList& ordered_uuids, + const QStringList& orderedUuids, Profiler* timer = nullptr); // ── Incremental updates ─────────────────────────────────── // Rebuilds constraints and diagnostics after toggling `mod`. Non-About.xml mods only emit. - void toggle_mod(const ListedMod& mod, const QStringList& ordered_uuids); + void toggleMod(const ListedMod& mod, const QStringList& orderedUuids); // Moves a mod to a new position and rechecks it and its graph neighbours. - void move_mod(const QString& uuid, int old_index, int new_index); + void moveMod(const QString& uuid, int oldIndex, int newIndex); // Reapplies the full active-mod order and regenerates the affected diagnostics. - void reorder(const QStringList& ordered_uuids); + void reorder(const QStringList& orderedUuids); // ── Query ───────────────────────────────────────────────── - QHash active_mods() const { return active_mods_; } - const ConstraintGraph& graph() const noexcept { return graph_; } - QList ordered_pids() const { return ordered_pids_; } - const sort::CommunityRules& community_rules() const noexcept { return community_rules_; } + QHash activeMods() const { return m_activeMods; } + const ConstraintGraph& graph() const noexcept { return m_graph; } + QList orderedPids() const { return m_orderedPids; } + const sort::CommunityRules& communityRules() const noexcept { return m_communityRules; } // Null when `uuid` is not an active mod. The pointer is valid until the next mutation. - const ModDiagnostics* diagnostics_for(const QString& uuid) const; - DiagnosticsByUuid active_mod_diagnostics() const; + const ModDiagnostics* diagnosticsFor(const QString& uuid) const; + DiagnosticsByUuid activeModDiagnostics() const; private: using CheckerResults = QHash>>; - void collect_active(const QHash& mods, const QStringList& ordered_uuids); - CheckContext build_context(const QList>& cycles) const; - void check_all(const CheckContext& ctx); - ModDiagnostics check_mod(const AboutXmlMod& mod, const CheckContext& ctx) const; - QList run_checker(const ModIssueChecker& checker, + void collectActive(const QHash& mods, const QStringList& orderedUuids); + CheckContext buildContext(const QList>& cycles) const; + void checkAll(const CheckContext& ctx); + ModDiagnostics checkMod(const AboutXmlMod& mod, const CheckContext& ctx) const; + QList runChecker(const ModIssueChecker& checker, const AboutXmlMod& mod, const CheckContext& ctx) const; - void emit_diagnostics() const; - - std::vector> checkers_; - sort::SortSettings settings_; - QString target_version_; - DiagnosticsCallback on_diagnostics_changed_; - - ConstraintGraph graph_; - QHash all_mods_; - QHash diagnostics_; - QHash active_mods_; - QHash uuid_to_pid_; - QList ordered_pids_; - - QSet no_version_warning_; - QHash use_this_instead_; - sort::CommunityRules community_rules_; - QList> cached_cycles_; + void emitDiagnostics() const; + + std::vector> m_checkers; + sort::SortSettings m_settings; + QString m_targetVersion; + DiagnosticsCallback m_onDiagnosticsChanged; + + ConstraintGraph m_graph; + QHash m_allMods; + QHash m_diagnostics; + QHash m_activeMods; + QHash m_uuidToPid; + QList m_orderedPids; + + QSet m_noVersionWarning; + QHash m_useThisInstead; + sort::CommunityRules m_communityRules; + QList> m_cachedCycles; // Per-mod issues from each checker (checker order), reused by reorder() for // order-independent checkers; nullopt when stale. - std::optional checker_results_; + std::optional m_checkerResults; }; } // namespace px::modrim::checker diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp index 1f4144b..ba43e67 100644 --- a/src/core/checker/databases.cpp +++ b/src/core/checker/databases.cpp @@ -42,7 +42,7 @@ const QString kReplacementsGz = QStringLiteral("replacements.json.gz"); constexpr int kDownloadTimeoutMs = 30000; -std::optional file_stamp(const QString& path) { +std::optional fileStamp(const QString& path) { const QFileInfo info(path); if (!info.exists()) { return std::nullopt; @@ -50,17 +50,17 @@ std::optional file_stamp(const QString& path) { return FileStamp{info.lastModified().toMSecsSinceEpoch(), info.size()}; } -std::optional file_age_s(const QString& path) { +std::optional fileAgeS(const QString& path) { const QFileInfo info(path); if (!info.exists()) { return std::nullopt; } - const qint64 age_ms = info.lastModified().msecsTo(QDateTime::currentDateTime()); - return std::max(0.0, static_cast(age_ms) / 1000.0); + const qint64 ageMs = info.lastModified().msecsTo(QDateTime::currentDateTime()); + return std::max(0.0, static_cast(ageMs) / 1000.0); } // Downloads `url`, following redirects. Any transport or HTTP error fails the download. -QCoro::Task> fetch_archive(QNetworkAccessManager& network, const QString& url) { +QCoro::Task> fetchArchive(QNetworkAccessManager& network, const QString& url) { QNetworkRequest request{QUrl(url)}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); @@ -79,14 +79,14 @@ QCoro::Task> fetch_archive(QNetworkAccessManager& network, co co_return reply->readAll(); } -Error zip_failure(const char* message) { +Error zipFailure(const char* message) { return Error{QString::fromUtf8(message)}; } // Reads the first archive entry whose file name is `entry_name`, at any depth. Yields nullopt // when no entry has that name. -Result> read_zip_entry(const QByteArray& archive, - const QString& entry_name) { +Result> readZipEntry(const QByteArray& archive, + const QString& entryName) { zip_error_t error; zip_error_init(&error); zip_source_t* source = zip_source_buffer_create(archive.constData(), @@ -111,13 +111,13 @@ Result> read_zip_entry(const QByteArray& archive, const auto position = static_cast(index); const char* name = zip_get_name(zip, position, 0); if (name == nullptr || - QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entry_name) { + QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entryName) { continue; } zip_file_t* file = zip_fopen_index(zip, position, 0); if (file == nullptr) { - return std::unexpected(zip_failure(zip_strerror(zip))); + return std::unexpected(zipFailure(zip_strerror(zip))); } QByteArray bytes; QByteArray chunk(64 * 1024, Qt::Uninitialized); @@ -128,7 +128,7 @@ Result> read_zip_entry(const QByteArray& archive, const bool failed = read < 0; zip_fclose(file); if (failed) { - return std::unexpected(zip_failure(zip_strerror(zip))); + return std::unexpected(zipFailure(zip_strerror(zip))); } return std::optional(std::move(bytes)); } @@ -137,10 +137,10 @@ Result> read_zip_entry(const QByteArray& archive, // Blocking: writes the archive entry `entry_name` to `target`. Yields false when the archive has // no such entry, leaving `target` untouched. -Result store_archive_entry(const QByteArray& archive, - const QString& entry_name, +Result storeArchiveEntry(const QByteArray& archive, + const QString& entryName, const QString& target) { - auto entry = read_zip_entry(archive, entry_name); + auto entry = readZipEntry(archive, entryName); if (!entry) { return std::unexpected(entry.error()); } @@ -148,7 +148,7 @@ Result store_archive_entry(const QByteArray& archive, return false; } const QByteArray& bytes = **entry; - auto written = write_file_atomically( + auto written = writeFileAtomically( target, std::string_view(bytes.constData(), static_cast(bytes.size()))); if (!written) { return std::unexpected(written.error()); @@ -187,7 +187,7 @@ Result gunzip(const std::string& data) { // The ids under ModIdsToFix/li: a lone
  • is a string, several are an array. Nullopt when the // document has another shape. -std::optional> no_version_warning_ids(const glz::json_t& root) { +std::optional> noVersionWarningIds(const glz::json_t& root) { QSet ids; if (!root.contains("ModIdsToFix")) { return ids; @@ -217,13 +217,13 @@ std::optional> no_version_warning_ids(const glz::json_t& root) { } // Blocking: parses the cached NoVersionWarning XML. A broken file yields an empty set. -QSet parse_no_version_warning(const QString& xml_path) { - const auto data = xmlFileToJson(xml_path); +QSet parseNoVersionWarning(const QString& xmlPath) { + const auto data = xmlFileToJson(xmlPath); if (!data) { spdlog::error("Failed to load No Version Warning DB: {}", data.error().message.toStdString()); return {}; } - const auto ids = no_version_warning_ids(*data); + const auto ids = noVersionWarningIds(*data); if (!ids) { spdlog::error("Failed to load No Version Warning DB: unexpected ModIdsToFix layout"); return {}; @@ -233,7 +233,7 @@ QSet parse_no_version_warning(const QString& xml_path) { } // Text of a JSON scalar as Python's str() would show it. Other kinds yield an empty string. -QString json_text(const glz::json_t& object, const char* key) { +QString jsonText(const glz::json_t& object, const char* key) { if (!object.contains(key)) { return {}; } @@ -251,7 +251,7 @@ QString json_text(const glz::json_t& object, const char* key) { return {}; } -QStringList json_string_list(const glz::json_t& object, const char* key) { +QStringList jsonStringList(const glz::json_t& object, const char* key) { QStringList values; if (!object.contains(key) || !object[key].is_array()) { return values; @@ -265,8 +265,8 @@ QStringList json_string_list(const glz::json_t& object, const char* key) { } // Blocking: parses the cached gzipped UseThisInstead JSON. A broken file yields no entries. -QHash parse_replacements(const QString& json_path) { - const auto bytes = read_file_bytes(json_path); +QHash parseReplacements(const QString& jsonPath) { + const auto bytes = readFileBytes(jsonPath); if (!bytes) { spdlog::error("Failed to load Use This Instead DB: {}", bytes.error().message.toStdString()); return {}; @@ -295,12 +295,12 @@ QHash parse_replacements(const QString& json_path) { continue; } ReplacementInfo info; - info.name = json_text(rule, "newName"); - info.author = json_text(rule, "newAuthor"); - info.packageid = json_text(rule, "newPackageId"); - info.pfid = json_text(rule, "newWorkshopId"); - info.supportedversions = json_string_list(rule, "newVersions"); - entries.insert(json_text(rule, "oldWorkshopId"), std::move(info)); + info.name = jsonText(rule, "newName"); + info.author = jsonText(rule, "newAuthor"); + info.packageid = jsonText(rule, "newPackageId"); + info.pfid = jsonText(rule, "newWorkshopId"); + info.supportedversions = jsonStringList(rule, "newVersions"); + entries.insert(jsonText(rule, "oldWorkshopId"), std::move(info)); } } spdlog::info("Loaded {} Use This Instead entries", entries.size()); @@ -309,49 +309,49 @@ QHash parse_replacements(const QString& json_path) { } // namespace -NoVersionWarningService::NoVersionWarningService(const ConfigService& config_service, +NoVersionWarningService::NoVersionWarningService(const ConfigService& configService, QNetworkAccessManager& network) - : network_(network), - cache_dir_(config_service.directory()), - xml_path_(QDir(cache_dir_).filePath(kNoVersionWarningXml)) {} + : m_network(network), + m_cacheDir(configService.directory()), + m_xmlPath(QDir(m_cacheDir).filePath(kNoVersionWarningXml)) {} QCoro::Task> NoVersionWarningService::ensure(bool force) { if (!force) { - if (const auto stamp = file_stamp(xml_path_)) { - if (stamp != loaded_stamp_) { - pids_ = co_await QtConcurrent::run(parse_no_version_warning, xml_path_); - loaded_stamp_ = stamp; + if (const auto stamp = fileStamp(m_xmlPath)) { + if (stamp != m_loadedStamp) { + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + m_loadedStamp = stamp; } - co_return pids_; + co_return m_pids; } } co_return co_await download(); } -std::optional NoVersionWarningService::cache_age_s() const { - return file_age_s(xml_path_); +std::optional NoVersionWarningService::cacheAgeS() const { + return fileAgeS(m_xmlPath); } -QSet NoVersionWarningService::load_if_exists() { - if (QFileInfo::exists(xml_path_)) { - pids_ = parse_no_version_warning(xml_path_); +QSet NoVersionWarningService::loadIfExists() { + if (QFileInfo::exists(m_xmlPath)) { + m_pids = parseNoVersionWarning(m_xmlPath); } - return pids_; + return m_pids; } QCoro::Task> NoVersionWarningService::download() { - QDir().mkpath(cache_dir_); + QDir().mkpath(m_cacheDir); - auto archive = co_await fetch_archive(network_, kNoVersionWarningUrl); + auto archive = co_await fetchArchive(m_network, kNoVersionWarningUrl); if (!archive) { spdlog::warn("Failed to download No Version Warning DB: {}", archive.error().message.toStdString()); co_return QSet{}; } - auto stored = co_await QtConcurrent::run(store_archive_entry, + auto stored = co_await QtConcurrent::run(storeArchiveEntry, std::move(*archive), kNoVersionWarningXml, - xml_path_); + m_xmlPath); if (!stored) { spdlog::warn("Failed to download No Version Warning DB: {}", stored.error().message.toStdString()); @@ -361,53 +361,53 @@ QCoro::Task> NoVersionWarningService::download() { co_return QSet{}; } - pids_ = co_await QtConcurrent::run(parse_no_version_warning, xml_path_); - co_return pids_; + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + co_return m_pids; } -UseThisInsteadService::UseThisInsteadService(const ConfigService& config_service, +UseThisInsteadService::UseThisInsteadService(const ConfigService& configService, QNetworkAccessManager& network) - : network_(network), - cache_dir_(config_service.directory()), - json_path_(QDir(cache_dir_).filePath(kReplacementsGz)) {} + : m_network(network), + m_cacheDir(configService.directory()), + m_jsonPath(QDir(m_cacheDir).filePath(kReplacementsGz)) {} QCoro::Task> UseThisInsteadService::ensure(bool force) { if (!force) { - if (const auto stamp = file_stamp(json_path_)) { - if (stamp != loaded_stamp_) { - entries_ = co_await QtConcurrent::run(parse_replacements, json_path_); - loaded_stamp_ = stamp; + if (const auto stamp = fileStamp(m_jsonPath)) { + if (stamp != m_loadedStamp) { + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + m_loadedStamp = stamp; } - co_return entries_; + co_return m_entries; } } co_return co_await download(); } -std::optional UseThisInsteadService::cache_age_s() const { - return file_age_s(json_path_); +std::optional UseThisInsteadService::cacheAgeS() const { + return fileAgeS(m_jsonPath); } -QHash UseThisInsteadService::load_if_exists() { - if (QFileInfo::exists(json_path_)) { - entries_ = parse_replacements(json_path_); +QHash UseThisInsteadService::loadIfExists() { + if (QFileInfo::exists(m_jsonPath)) { + m_entries = parseReplacements(m_jsonPath); } - return entries_; + return m_entries; } QCoro::Task> UseThisInsteadService::download() { - QDir().mkpath(cache_dir_); + QDir().mkpath(m_cacheDir); - auto archive = co_await fetch_archive(network_, kUseThisInsteadUrl); + auto archive = co_await fetchArchive(m_network, kUseThisInsteadUrl); if (!archive) { spdlog::warn("Failed to download Use This Instead DB: {}", archive.error().message.toStdString()); co_return QHash{}; } - auto stored = co_await QtConcurrent::run(store_archive_entry, + auto stored = co_await QtConcurrent::run(storeArchiveEntry, std::move(*archive), kReplacementsGz, - json_path_); + m_jsonPath); if (!stored) { spdlog::warn("Failed to download Use This Instead DB: {}", stored.error().message.toStdString()); @@ -417,8 +417,8 @@ QCoro::Task> UseThisInsteadService::download() { co_return QHash{}; } - entries_ = co_await QtConcurrent::run(parse_replacements, json_path_); - co_return entries_; + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + co_return m_entries; } } // namespace px::modrim::checker diff --git a/src/core/checker/databases.hpp b/src/core/checker/databases.hpp index 2e3b945..b022b8b 100644 --- a/src/core/checker/databases.hpp +++ b/src/core/checker/databases.hpp @@ -20,7 +20,7 @@ namespace checker { // Identity of a cache file: changes whenever the file is rewritten. struct FileStamp { - qint64 modified_ms = 0; + qint64 modifiedMs = 0; qint64 size = 0; bool operator==(const FileStamp&) const = default; @@ -30,52 +30,52 @@ struct FileStamp { // GitHub when the cache is missing or a refresh is forced. class NoVersionWarningService { public: - NoVersionWarningService(const ConfigService& config_service, QNetworkAccessManager& network); + NoVersionWarningService(const ConfigService& configService, QNetworkAccessManager& network); // Cached ids, reloaded when the file changed on disk. Downloads when the cache is missing // or `force` is set. A failed download yields an empty set and keeps the previous ids. QCoro::Task> ensure(bool force = false); // Seconds since the cache file was written, or nullopt when there is no cache. - std::optional cache_age_s() const; + std::optional cacheAgeS() const; // Loads the cache synchronously when it exists; otherwise returns the current ids. - QSet load_if_exists(); + QSet loadIfExists(); private: QCoro::Task> download(); - QNetworkAccessManager& network_; - QString cache_dir_; - QString xml_path_; - QSet pids_; + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_xmlPath; + QSet m_pids; // Stamp of the file the current `pids_` were parsed from. - std::optional loaded_stamp_; + std::optional m_loadedStamp; }; // Caches the UseThisInstead replacement database (gzipped JSON) under the config directory and // refreshes it from GitHub when the cache is missing or a refresh is forced. class UseThisInsteadService { public: - UseThisInsteadService(const ConfigService& config_service, QNetworkAccessManager& network); + UseThisInsteadService(const ConfigService& configService, QNetworkAccessManager& network); // Cached entries, reloaded when the file changed on disk. Downloads when the cache is // missing or `force` is set. A failed download yields an empty map and keeps the previous // entries. QCoro::Task> ensure(bool force = false); - std::optional cache_age_s() const; + std::optional cacheAgeS() const; - QHash load_if_exists(); + QHash loadIfExists(); private: QCoro::Task> download(); - QNetworkAccessManager& network_; - QString cache_dir_; - QString json_path_; - QHash entries_; - std::optional loaded_stamp_; + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_jsonPath; + QHash m_entries; + std::optional m_loadedStamp; }; } // namespace checker diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index c89c108..5b305e4 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -7,45 +7,45 @@ namespace px::modrim::checker { namespace { -constexpr std::size_t index_of_type(EdgeType type) { +constexpr std::size_t indexOfType(EdgeType type) { return static_cast(type); } -bool str_less(const PackageId& a, const PackageId& b) { +bool strLess(const PackageId& a, const PackageId& b) { return a.str() < b.str(); } // Qt hashes are randomised per process, so the Tarjan traversal visits roots and neighbours // in name order to keep the reported cycles stable from run to run. -QList sorted_pids(const QSet& pids) { +QList sortedPids(const QSet& pids) { QList sorted(pids.cbegin(), pids.cend()); - std::ranges::sort(sorted, str_less); + std::ranges::sort(sorted, strLess); return sorted; } -QHash build_alt_map(const QHash& active_mods) { - QHash alt_map; - for (const auto& mod : active_mods) { - for (const DependencyMod& dep : mod->about_rules.dependencies) { - if (active_mods.contains(dep.package_id)) { +QHash buildAltMap(const QHash& activeMods) { + QHash altMap; + for (const auto& mod : activeMods) { + for (const DependencyMod& dep : mod->aboutRules.dependencies) { + if (activeMods.contains(dep.packageId)) { continue; } - for (const PackageId& alternative_pid : dep.alternative_package_ids) { - if (active_mods.contains(alternative_pid)) { - alt_map.insert(dep.package_id, alternative_pid); + for (const PackageId& alternativePid : dep.alternativePackageIds) { + if (activeMods.contains(alternativePid)) { + altMap.insert(dep.packageId, alternativePid); break; } } } } - return alt_map; + return altMap; } } // namespace QSet ConstraintGraph::outgoing(const PackageId& pid) const { QSet result; - if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { for (const auto& edges : it.value()) { result.unite(edges); } @@ -55,7 +55,7 @@ QSet ConstraintGraph::outgoing(const PackageId& pid) const { QSet ConstraintGraph::incoming(const PackageId& pid) const { QSet result; - if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { for (const auto& edges : it.value()) { result.unite(edges); } @@ -65,14 +65,14 @@ QSet ConstraintGraph::incoming(const PackageId& pid) const { QSet ConstraintGraph::neighbors(const PackageId& pid) const { QSet result; - if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { for (const auto& edges : it.value()) { for (const auto& edge : edges) { result.insert(edge.target); } } } - if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { for (const auto& edges : it.value()) { for (const auto& edge : edges) { result.insert(edge.source); @@ -82,116 +82,116 @@ QSet ConstraintGraph::neighbors(const PackageId& pid) const { return result; } -QSet ConstraintGraph::edges_of_type(const PackageId& pid, EdgeType type) const { - if (const auto it = outgoing_.constFind(pid); it != outgoing_.cend()) { - return it.value()[index_of_type(type)]; +QSet ConstraintGraph::edgesOfType(const PackageId& pid, EdgeType type) const { + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + return it.value()[indexOfType(type)]; } return {}; } -QSet ConstraintGraph::incoming_of_type(const PackageId& pid, EdgeType type) const { - if (const auto it = incoming_.constFind(pid); it != incoming_.cend()) { - return it.value()[index_of_type(type)]; +QSet ConstraintGraph::incomingOfType(const PackageId& pid, EdgeType type) const { + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + return it.value()[indexOfType(type)]; } return {}; } -void ConstraintGraph::build(const QHash& active_mods, - const QList& ordered_pids, +void ConstraintGraph::build(const QHash& activeMods, + const QList& orderedPids, const sort::SortSettings& settings, - const sort::CommunityRules* community_rules) { - outgoing_.clear(); - incoming_.clear(); - cycle_components_.reset(); - ordered_pids_ = ordered_pids; - rebuild_index(); + const sort::CommunityRules* communityRules) { + m_outgoing.clear(); + m_incoming.clear(); + m_cycleComponents.reset(); + m_orderedPids = orderedPids; + rebuildIndex(); // Pre-populate nodes so mods without any edges are still tracked. - nodes_ = QSet(ordered_pids.cbegin(), ordered_pids.cend()); + m_nodes = QSet(orderedPids.cbegin(), orderedPids.cend()); const Alternatives alternatives = - settings.use_alternative_package_ids ? build_alt_map(active_mods) : Alternatives{}; + settings.use_alternative_package_ids ? buildAltMap(activeMods) : Alternatives{}; - for (auto it = active_mods.cbegin(); it != active_mods.cend(); ++it) { - add_rules(it.key(), it.value()->about_rules, EdgeOrigin::AboutXml, alternatives); + for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { + addRules(it.key(), it.value()->aboutRules, EdgeOrigin::kAboutXml, alternatives); } - if (settings.use_community_rules && community_rules != nullptr && !community_rules->isEmpty()) { - for (auto it = active_mods.cbegin(); it != active_mods.cend(); ++it) { - const auto rule = community_rules->constFind(it.key()); - if (rule == community_rules->cend()) { + if (settings.use_community_rules && communityRules != nullptr && !communityRules->isEmpty()) { + for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { + const auto rule = communityRules->constFind(it.key()); + if (rule == communityRules->cend()) { continue; } - add_load_rules(it.key(), - rule->load_before, - rule->load_after, - rule->incompatible_with, - EdgeOrigin::CommunityRules, + addLoadRules(it.key(), + rule->loadBefore, + rule->loadAfter, + rule->incompatibleWith, + EdgeOrigin::kCommunityRules, alternatives); } } } -void ConstraintGraph::remove_mod(const PackageId& pid) { - cycle_components_.reset(); - const EdgesByType outgoing_edges = outgoing_.take(pid); - for (const auto& edges : outgoing_edges) { +void ConstraintGraph::removeMod(const PackageId& pid) { + m_cycleComponents.reset(); + const EdgesByType outgoingEdges = m_outgoing.take(pid); + for (const auto& edges : outgoingEdges) { for (const auto& edge : edges) { - incoming_[edge.target][index_of_type(edge.type)].remove(edge); + m_incoming[edge.target][indexOfType(edge.type)].remove(edge); } } - const EdgesByType incoming_edges = incoming_.take(pid); - for (const auto& edges : incoming_edges) { + const EdgesByType incomingEdges = m_incoming.take(pid); + for (const auto& edges : incomingEdges) { for (const auto& edge : edges) { - outgoing_[edge.source][index_of_type(edge.type)].remove(edge); + m_outgoing[edge.source][indexOfType(edge.type)].remove(edge); } } - nodes_.remove(pid); - ordered_pids_.removeAll(pid); - rebuild_index(); + m_nodes.remove(pid); + m_orderedPids.removeAll(pid); + rebuildIndex(); } -void ConstraintGraph::update_order(const QList& ordered_pids) { - ordered_pids_ = ordered_pids; - rebuild_index(); +void ConstraintGraph::updateOrder(const QList& orderedPids) { + m_orderedPids = orderedPids; + rebuildIndex(); } -void ConstraintGraph::rebuild_index() { - pid_to_index_.clear(); - for (qsizetype i = 0; i < ordered_pids_.size(); ++i) { - pid_to_index_.insert(ordered_pids_[i], static_cast(i)); +void ConstraintGraph::rebuildIndex() { + m_pidToIndex.clear(); + for (qsizetype i = 0; i < m_orderedPids.size(); ++i) { + m_pidToIndex.insert(m_orderedPids[i], static_cast(i)); } } -QList> ConstraintGraph::find_cycles() { - if (!cycle_components_) { - cycle_components_ = find_cycle_components(); +QList> ConstraintGraph::findCycles() { + if (!m_cycleComponents) { + m_cycleComponents = findCycleComponents(); } - const auto missing = static_cast(pid_to_index_.size()); + const auto missing = static_cast(m_pidToIndex.size()); QList> result; - result.reserve(cycle_components_->size()); - for (QList component : *cycle_components_) { + result.reserve(m_cycleComponents->size()); + for (QList component : *m_cycleComponents) { std::ranges::stable_sort(component, {}, [&](const PackageId& member) { - return pid_to_index_.value(member, missing); + return m_pidToIndex.value(member, missing); }); result.append(std::move(component)); } return result; } -QList> ConstraintGraph::find_cycle_components() const { +QList> ConstraintGraph::findCycleComponents() const { // Nodes without dependency or load-after edges are trivial components that can never be // part of a cycle, so they are left out of the search. QHash> adjacency; - for (auto it = outgoing_.cbegin(); it != outgoing_.cend(); ++it) { + for (auto it = m_outgoing.cbegin(); it != m_outgoing.cend(); ++it) { QSet targets; - for (const EdgeType type : {EdgeType::Dependency, EdgeType::LoadAfter}) { - for (const auto& edge : it.value()[index_of_type(type)]) { + for (const EdgeType type : {EdgeType::kDependency, EdgeType::kLoadAfter}) { + for (const auto& edge : it.value()[indexOfType(type)]) { targets.insert(edge.target); } } if (!targets.isEmpty()) { - adjacency.insert(it.key(), sorted_pids(targets)); + adjacency.insert(it.key(), sortedPids(targets)); } } for (auto& neighbors : adjacency) { @@ -206,39 +206,39 @@ QList> ConstraintGraph::find_cycle_components() const { QHash indices; QHash lowlinks; - std::vector active_stack; - QSet on_stack; + std::vector activeStack; + QSet onStack; QList> components; - int next_index = 0; + int nextIndex = 0; - for (const PackageId& root : sorted_pids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { + for (const PackageId& root : sortedPids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { if (indices.contains(root)) { continue; } - indices.insert(root, next_index); - lowlinks.insert(root, next_index); - ++next_index; - active_stack.push_back(root); - on_stack.insert(root); - std::vector dfs_stack{Frame{root, &adjacency[root], 0}}; + indices.insert(root, nextIndex); + lowlinks.insert(root, nextIndex); + ++nextIndex; + activeStack.push_back(root); + onStack.insert(root); + std::vector dfsStack{Frame{root, &adjacency[root], 0}}; - while (!dfs_stack.empty()) { - Frame& top = dfs_stack.back(); + while (!dfsStack.empty()) { + Frame& top = dfsStack.back(); const PackageId pid = top.pid; const QList& neighbors = *top.neighbors; if (top.next == neighbors.size()) { - dfs_stack.pop_back(); + dfsStack.pop_back(); if (lowlinks[pid] == indices[pid]) { QList component; while (true) { - PackageId member = active_stack.back(); - active_stack.pop_back(); - on_stack.remove(member); - const bool is_root = member == pid; + PackageId member = activeStack.back(); + activeStack.pop_back(); + onStack.remove(member); + const bool isRoot = member == pid; component.append(std::move(member)); - if (is_root) { + if (isRoot) { break; } } @@ -246,8 +246,8 @@ QList> ConstraintGraph::find_cycle_components() const { components.append(std::move(component)); } } - if (!dfs_stack.empty()) { - const PackageId& parent = dfs_stack.back().pid; + if (!dfsStack.empty()) { + const PackageId& parent = dfsStack.back().pid; lowlinks[parent] = std::min(lowlinks[parent], lowlinks[pid]); } continue; @@ -255,13 +255,13 @@ QList> ConstraintGraph::find_cycle_components() const { const PackageId neighbor = neighbors[top.next++]; if (!indices.contains(neighbor)) { - indices.insert(neighbor, next_index); - lowlinks.insert(neighbor, next_index); - ++next_index; - active_stack.push_back(neighbor); - on_stack.insert(neighbor); - dfs_stack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); - } else if (on_stack.contains(neighbor) && indices[neighbor] < lowlinks[pid]) { + indices.insert(neighbor, nextIndex); + lowlinks.insert(neighbor, nextIndex); + ++nextIndex; + activeStack.push_back(neighbor); + onStack.insert(neighbor); + dfsStack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); + } else if (onStack.contains(neighbor) && indices[neighbor] < lowlinks[pid]) { lowlinks[pid] = indices[neighbor]; } } @@ -271,59 +271,59 @@ QList> ConstraintGraph::find_cycle_components() const { } template -void ConstraintGraph::add_edges(const PackageId& source, +void ConstraintGraph::addEdges(const PackageId& source, const Targets& targets, EdgeType type, EdgeOrigin origin, const Alternatives& alternatives) { // Hot path of build(): the per-source edge set is looked up once, not per target. - QSet* out_edges = nullptr; - for (const PackageId& raw_target : targets) { - const PackageId target = alternatives.value(raw_target, raw_target); - if (out_edges == nullptr) { - nodes_.insert(source); - out_edges = &outgoing_[source][index_of_type(type)]; + QSet* outEdges = nullptr; + for (const PackageId& rawTarget : targets) { + const PackageId target = alternatives.value(rawTarget, rawTarget); + if (outEdges == nullptr) { + m_nodes.insert(source); + outEdges = &m_outgoing[source][indexOfType(type)]; } - nodes_.insert(target); + m_nodes.insert(target); const ConstraintEdge edge{source, target, type, origin}; - out_edges->insert(edge); - incoming_[target][index_of_type(type)].insert(edge); + outEdges->insert(edge); + m_incoming[target][indexOfType(type)].insert(edge); } } -void ConstraintGraph::add_rules(const PackageId& pid, +void ConstraintGraph::addRules(const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, const Alternatives& alternatives) { for (const DependencyMod& dep : rules.dependencies) { - add_edges(pid, std::array{dep.package_id}, EdgeType::Dependency, origin, alternatives); - if (!dep.alternative_package_ids.empty()) { - add_edges(pid, dep.alternative_package_ids, EdgeType::Alternative, origin, {}); + addEdges(pid, std::array{dep.packageId}, EdgeType::kDependency, origin, alternatives); + if (!dep.alternativePackageIds.empty()) { + addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, {}); } } - add_load_rules(pid, - rules.load_before, - rules.load_after, - rules.incompatible_with, + addLoadRules(pid, + rules.loadBefore, + rules.loadAfter, + rules.incompatibleWith, origin, alternatives); } -void ConstraintGraph::add_load_rules(const PackageId& pid, - const CaseInsensitiveSet& load_before, - const CaseInsensitiveSet& load_after, - const CaseInsensitiveSet& incompatible_with, +void ConstraintGraph::addLoadRules(const PackageId& pid, + const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, EdgeOrigin origin, const Alternatives& alternatives) { - if (!load_after.empty()) { - add_edges(pid, load_after, EdgeType::LoadAfter, origin, alternatives); + if (!loadAfter.empty()) { + addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); } - if (!load_before.empty()) { - add_edges(pid, load_before, EdgeType::LoadBefore, origin, alternatives); + if (!loadBefore.empty()) { + addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); } - if (!incompatible_with.empty()) { - add_edges(pid, incompatible_with, EdgeType::Incompatibility, origin, alternatives); + if (!incompatibleWith.empty()) { + addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, alternatives); } } diff --git a/src/core/checker/graph.hpp b/src/core/checker/graph.hpp index b7811f3..e118f0f 100644 --- a/src/core/checker/graph.hpp +++ b/src/core/checker/graph.hpp @@ -16,18 +16,18 @@ namespace px::modrim::checker { enum class EdgeType : std::uint8_t { - Dependency, - LoadAfter, - LoadBefore, - Incompatibility, - Alternative, + kDependency, + kLoadAfter, + kLoadBefore, + kIncompatibility, + kAlternative, }; inline constexpr std::size_t kEdgeTypeCount = 5; enum class EdgeOrigin : std::uint8_t { - AboutXml, - CommunityRules, + kAboutXml, + kCommunityRules, }; struct ConstraintEdge { @@ -49,63 +49,63 @@ class ConstraintGraph { public: using Alternatives = QHash; - const QSet& nodes() const noexcept { return nodes_; } - bool has_node(const PackageId& pid) const { return nodes_.contains(pid); } + const QSet& nodes() const noexcept { return m_nodes; } + bool hasNode(const PackageId& pid) const { return m_nodes.contains(pid); } // Union over all edge types. Returned by value: Qt containers are implicitly shared. QSet outgoing(const PackageId& pid) const; QSet incoming(const PackageId& pid) const; QSet neighbors(const PackageId& pid) const; - QSet edges_of_type(const PackageId& pid, EdgeType type) const; - QSet incoming_of_type(const PackageId& pid, EdgeType type) const; + QSet edgesOfType(const PackageId& pid, EdgeType type) const; + QSet incomingOfType(const PackageId& pid, EdgeType type) const; - int index_of(const PackageId& pid) const { return pid_to_index_.value(pid, -1); } - const QHash& pid_to_index() const noexcept { return pid_to_index_; } + int indexOf(const PackageId& pid) const { return m_pidToIndex.value(pid, -1); } + const QHash& pidToIndex() const noexcept { return m_pidToIndex; } // Replaces all edges. A null or empty `community_rules` means no community rules. - void build(const QHash& active_mods, - const QList& ordered_pids, + void build(const QHash& activeMods, + const QList& orderedPids, const sort::SortSettings& settings, - const sort::CommunityRules* community_rules = nullptr); + const sort::CommunityRules* communityRules = nullptr); - void remove_mod(const PackageId& pid); + void removeMod(const PackageId& pid); // Replaces the mod ordering without touching edges. - void update_order(const QList& ordered_pids); + void updateOrder(const QList& orderedPids); // Strongly connected components that contain a dependency or load-after cycle. Members are // sorted by load index. Cached until the edges change. - QList> find_cycles(); + QList> findCycles(); private: using EdgesByType = std::array, kEdgeTypeCount>; template - void add_edges(const PackageId& source, + void addEdges(const PackageId& source, const Targets& targets, EdgeType type, EdgeOrigin origin, const Alternatives& alternatives); - void add_rules(const PackageId& pid, + void addRules(const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, const Alternatives& alternatives); - void add_load_rules(const PackageId& pid, - const CaseInsensitiveSet& load_before, - const CaseInsensitiveSet& load_after, - const CaseInsensitiveSet& incompatible_with, + void addLoadRules(const PackageId& pid, + const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, EdgeOrigin origin, const Alternatives& alternatives); - void rebuild_index(); - QList> find_cycle_components() const; - - QHash outgoing_; - QHash incoming_; - QSet nodes_; - QHash pid_to_index_; - QList ordered_pids_; + void rebuildIndex(); + QList> findCycleComponents() const; + + QHash m_outgoing; + QHash m_incoming; + QSet m_nodes; + QHash m_pidToIndex; + QList m_orderedPids; // Cycle membership depends only on edges, so it survives reorders. - std::optional>> cycle_components_; + std::optional>> m_cycleComponents; }; } // namespace px::modrim::checker diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp index bc1ada0..54e80b2 100644 --- a/src/core/checker/issues.cpp +++ b/src/core/checker/issues.cpp @@ -6,8 +6,8 @@ namespace px::modrim::checker { -bool DependencyIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext&) const { - return !mod.about_rules.dependencies.isEmpty(); +bool DependencyIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext&) const { + return !mod.aboutRules.dependencies.isEmpty(); } QList DependencyIssueChecker::check(const AboutXmlMod& mod, @@ -17,109 +17,109 @@ QList DependencyIssueChecker::check(const AboutXmlMod& mod, } QList issues; - const bool consider_alternatives = ctx.settings.use_alternative_package_ids; + const bool considerAlternatives = ctx.settings.use_alternative_package_ids; - for (const DependencyMod& dep : mod.about_rules.dependencies) { - bool satisfied = ctx.active_mods.contains(dep.package_id); - if (!satisfied && consider_alternatives) { - satisfied = std::ranges::any_of(dep.alternative_package_ids, [&](const PackageId& alt) { - return ctx.active_mods.contains(alt); + for (const DependencyMod& dep : mod.aboutRules.dependencies) { + bool satisfied = ctx.activeMods.contains(dep.packageId); + if (!satisfied && considerAlternatives) { + satisfied = std::ranges::any_of(dep.alternativePackageIds, [&](const PackageId& alt) { + return ctx.activeMods.contains(alt); }); } if (satisfied) { continue; } - QString alt_note; - if (consider_alternatives && !dep.alternative_package_ids.empty()) { + QString altNote; + if (considerAlternatives && !dep.alternativePackageIds.empty()) { QStringList candidates; - for (const PackageId& alt : dep.alternative_package_ids) { + for (const PackageId& alt : dep.alternativePackageIds) { candidates.append(alt.str()); } - alt_note = QStringLiteral(" (alternatives: %1)").arg(candidates.join(QStringLiteral(", "))); + altNote = QStringLiteral(" (alternatives: %1)").arg(candidates.join(QStringLiteral(", "))); } issues.append(ModIssue{ .category = QStringLiteral("missing_dependency"), - .severity = IssueSeverity::Error, - .short_message = QStringLiteral("Missing dependencies"), - .category_display_name = category_display_name(), - .detail_message = - QStringLiteral("Dependency not satisfied: %1%2").arg(dep.package_id.str(), alt_note), - .related_package_ids = {dep.package_id}, + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Missing dependencies"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Dependency not satisfied: %1%2").arg(dep.packageId.str(), altNote), + .relatedPackageIds = {dep.packageId}, }); } return issues; } -bool IncompatibilityIssueChecker::should_check(const AboutXmlMod& mod, +bool IncompatibilityIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const { - const PackageId& pid = mod.package_id; - return !mod.about_rules.incompatible_with.empty() || - !ctx.graph.edges_of_type(pid, EdgeType::Incompatibility).isEmpty() || - !ctx.graph.incoming_of_type(pid, EdgeType::Incompatibility).isEmpty(); + const PackageId& pid = mod.packageId; + return !mod.aboutRules.incompatibleWith.empty() || + !ctx.graph.edgesOfType(pid, EdgeType::kIncompatibility).isEmpty() || + !ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility).isEmpty(); } QList IncompatibilityIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { QList issues; - const PackageId& pid = mod.package_id; + const PackageId& pid = mod.packageId; - for (const PackageId& incomp : mod.about_rules.incompatible_with) { - if (!ctx.active_mods.contains(incomp)) { + for (const PackageId& incomp : mod.aboutRules.incompatibleWith) { + if (!ctx.activeMods.contains(incomp)) { continue; } - const AboutXmlModPtr other = ctx.active_mods.value(incomp); + const AboutXmlModPtr other = ctx.activeMods.value(incomp); issues.append(ModIssue{ .category = QStringLiteral("incompatibility"), - .severity = IssueSeverity::Error, - .short_message = QStringLiteral("Incompatibilities detected"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Mod declares incompatibility with: %1 (%2)") + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Mod declares incompatibility with: %1 (%2)") .arg(other->name, incomp.str()), - .related_package_ids = {incomp}, + .relatedPackageIds = {incomp}, }); } // Each declaring mod is reported once, even when it is seen both in our own list and as an // incoming graph edge (about.xml and community rules can both declare the same pair). - QSet reported_sources(mod.about_rules.incompatible_with.begin(), - mod.about_rules.incompatible_with.end()); - for (const ConstraintEdge& edge : ctx.graph.incoming_of_type(pid, EdgeType::Incompatibility)) { - if (!ctx.active_mods.contains(edge.source) || edge.source == pid || - reported_sources.contains(edge.source)) { + QSet reportedSources(mod.aboutRules.incompatibleWith.begin(), + mod.aboutRules.incompatibleWith.end()); + for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility)) { + if (!ctx.activeMods.contains(edge.source) || edge.source == pid || + reportedSources.contains(edge.source)) { continue; } - reported_sources.insert(edge.source); - const AboutXmlModPtr other = ctx.active_mods.value(edge.source); + reportedSources.insert(edge.source); + const AboutXmlModPtr other = ctx.activeMods.value(edge.source); issues.append(ModIssue{ .category = QStringLiteral("incompatibility"), - .severity = IssueSeverity::Error, - .short_message = QStringLiteral("Incompatibilities detected"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Declared incompatible by: %1 (%2)") + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Declared incompatible by: %1 (%2)") .arg(other->name, edge.source.str()), - .related_package_ids = {edge.source}, + .relatedPackageIds = {edge.source}, }); } return issues; } -bool LoadOrderIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext& ctx) const { - const PackageId& pid = mod.package_id; - return !ctx.graph.edges_of_type(pid, EdgeType::LoadBefore).isEmpty() || - !ctx.graph.edges_of_type(pid, EdgeType::LoadAfter).isEmpty(); +bool LoadOrderIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + return !ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore).isEmpty() || + !ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter).isEmpty(); } -std::optional> LoadOrderIssueChecker::affected_by_moves( +std::optional> LoadOrderIssueChecker::affectedByMoves( const CheckContext& ctx, const QSet& moved) const { // Results only read the indices of the mod itself and its edge targets. QSet affected = moved; for (const PackageId& pid : moved) { - for (const EdgeType type : {EdgeType::LoadBefore, EdgeType::LoadAfter}) { - for (const ConstraintEdge& edge : ctx.graph.incoming_of_type(pid, type)) { + for (const EdgeType type : {EdgeType::kLoadBefore, EdgeType::kLoadAfter}) { + for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, type)) { affected.insert(edge.source); } } @@ -130,48 +130,48 @@ std::optional> LoadOrderIssueChecker::affected_by_moves( QList LoadOrderIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { QList issues; - const PackageId& pid = mod.package_id; - const int idx = ctx.pid_to_index.value(pid, -1); + const PackageId& pid = mod.packageId; + const int idx = ctx.pidToIndex.value(pid, -1); if (idx < 0) { return issues; } - for (const ConstraintEdge& edge : ctx.graph.edges_of_type(pid, EdgeType::LoadBefore)) { - const int other_idx = ctx.pid_to_index.value(edge.target, -1); - if (other_idx >= 0 && idx >= other_idx) { - const AboutXmlModPtr other = ctx.active_mods.value(edge.target); + for (const ConstraintEdge& edge : ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx >= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); const QString name = other ? other->name : edge.target.str(); issues.append(ModIssue{ .category = QStringLiteral("load_order"), - .severity = IssueSeverity::Warning, - .short_message = QStringLiteral("Load order violated"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Should load before: %1 (%2), but appears after " + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Should load before: %1 (%2), but appears after " "(index %3 vs %4)") .arg(name, edge.target.str()) .arg(idx) - .arg(other_idx), - .related_package_ids = {edge.target}, + .arg(otherIdx), + .relatedPackageIds = {edge.target}, }); } } - for (const ConstraintEdge& edge : ctx.graph.edges_of_type(pid, EdgeType::LoadAfter)) { - const int other_idx = ctx.pid_to_index.value(edge.target, -1); - if (other_idx >= 0 && idx <= other_idx) { - const AboutXmlModPtr other = ctx.active_mods.value(edge.target); + for (const ConstraintEdge& edge : ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx <= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); const QString name = other ? other->name : edge.target.str(); issues.append(ModIssue{ .category = QStringLiteral("load_order"), - .severity = IssueSeverity::Warning, - .short_message = QStringLiteral("Load order violated"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Should load after: %1 (%2), but appears before " + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Should load after: %1 (%2), but appears before " "(index %3 vs %4)") .arg(name, edge.target.str()) .arg(idx) - .arg(other_idx), - .related_package_ids = {edge.target}, + .arg(otherIdx), + .relatedPackageIds = {edge.target}, }); } } @@ -180,7 +180,7 @@ QList LoadOrderIssueChecker::check(const AboutXmlMod& mod, } QList CycleIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - const PackageId& pid = mod.package_id; + const PackageId& pid = mod.packageId; QList issues; for (const QList& cycle : ctx.cycles) { @@ -197,72 +197,72 @@ QList CycleIssueChecker::check(const AboutXmlMod& mod, const CheckCont } issues.append(ModIssue{ .category = QStringLiteral("cycle"), - .severity = IssueSeverity::Warning, - .short_message = QStringLiteral("Dependency cycle detected"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Participates in a dependency cycle involving: ") + + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Dependency cycle detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Participates in a dependency cycle involving: ") + members.join(QStringLiteral(", ")), - .related_package_ids = std::move(related), + .relatedPackageIds = std::move(related), }); } return issues; } -bool GameVersionIssueChecker::should_check(const AboutXmlMod& mod, const CheckContext&) const { - return !mod.supported_versions.isEmpty(); +bool GameVersionIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext&) const { + return !mod.supportedVersions.isEmpty(); } QList GameVersionIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - if (mod.supported_versions.isEmpty()) { + if (mod.supportedVersions.isEmpty()) { return {}; } - if (ctx.no_version_warning.contains(mod.package_id)) { + if (ctx.noVersionWarning.contains(mod.packageId)) { return {}; } // Only major.minor is compared, so a patch-level target still matches "1.5" support. - const QStringList target_parts = ctx.target_version.split(QLatin1Char('.')).mid(0, 2); - const QString target_major_minor = target_parts.join(QLatin1Char('.')); - if (mod.supported_versions.contains(target_major_minor)) { + const QStringList targetParts = ctx.targetVersion.split(QLatin1Char('.')).mid(0, 2); + const QString targetMajorMinor = targetParts.join(QLatin1Char('.')); + if (mod.supportedVersions.contains(targetMajorMinor)) { return {}; } - QStringList supported(mod.supported_versions.cbegin(), mod.supported_versions.cend()); + QStringList supported(mod.supportedVersions.cbegin(), mod.supportedVersions.cend()); std::ranges::sort(supported); return {ModIssue{ .category = QStringLiteral("version_mismatch"), - .severity = IssueSeverity::Warning, - .short_message = QStringLiteral("Version mismatch"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Mod supports %1, but game is on %2") - .arg(supported.join(QStringLiteral(", ")), target_major_minor), - .related_package_ids = {}, + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Version mismatch"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Mod supports %1, but game is on %2") + .arg(supported.join(QStringLiteral(", ")), targetMajorMinor), + .relatedPackageIds = {}, }}; } QList ReplacementIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - const std::optional pfid = mod.published_file_id(); + const std::optional pfid = mod.publishedFileId(); if (!pfid || pfid->isEmpty()) { return {}; } - const auto replacement = ctx.use_this_instead.constFind(*pfid); - if (replacement == ctx.use_this_instead.cend()) { + const auto replacement = ctx.useThisInstead.constFind(*pfid); + if (replacement == ctx.useThisInstead.cend()) { return {}; } const ReplacementInfo& info = replacement.value(); return {ModIssue{ .category = QStringLiteral("replacement_available"), - .severity = IssueSeverity::Warning, - .short_message = QStringLiteral("Replacement available"), - .category_display_name = category_display_name(), - .detail_message = QStringLiteral("Recommended replacement: %1 (%2) by %3") + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Replacement available"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Recommended replacement: %1 (%2) by %3") .arg(info.name, info.packageid, info.author), - .related_package_ids = {PackageId(info.packageid)}, + .relatedPackageIds = {PackageId(info.packageid)}, }}; } diff --git a/src/core/checker/issues.hpp b/src/core/checker/issues.hpp index 479f413..48be787 100644 --- a/src/core/checker/issues.hpp +++ b/src/core/checker/issues.hpp @@ -15,15 +15,15 @@ class ModIssueChecker { public: virtual ~ModIssueChecker() = default; - const QString& category_display_name() const noexcept { return category_display_name_; } + const QString& categoryDisplayName() const noexcept { return m_categoryDisplayName; } // Order-independent results are reused when only the load order changes. - bool order_dependent() const noexcept { return order_dependent_; } + bool orderDependent() const noexcept { return m_orderDependent; } - virtual bool should_check(const AboutXmlMod&, const CheckContext&) const { return true; } + virtual bool shouldCheck(const AboutXmlMod&, const CheckContext&) const { return true; } // Mods whose results may change when `moved` changed index; nullopt means all mods. - virtual std::optional> affected_by_moves(const CheckContext&, + virtual std::optional> affectedByMoves(const CheckContext&, const QSet&) const { return std::nullopt; } @@ -31,13 +31,13 @@ class ModIssueChecker { virtual QList check(const AboutXmlMod& mod, const CheckContext& ctx) const = 0; protected: - ModIssueChecker(QString category_display_name, bool order_dependent) - : category_display_name_(std::move(category_display_name)), - order_dependent_(order_dependent) {} + ModIssueChecker(QString categoryDisplayName, bool orderDependent) + : m_categoryDisplayName(std::move(categoryDisplayName)), + m_orderDependent(orderDependent) {} private: - QString category_display_name_; - bool order_dependent_; + QString m_categoryDisplayName; + bool m_orderDependent; }; // Missing or unsatisfied mod dependencies, honouring alternative package ids when enabled. @@ -45,7 +45,7 @@ class DependencyIssueChecker final : public ModIssueChecker { public: DependencyIssueChecker() : ModIssueChecker(QStringLiteral("Missing Dependency"), false) {} - bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; }; @@ -54,7 +54,7 @@ class IncompatibilityIssueChecker final : public ModIssueChecker { public: IncompatibilityIssueChecker() : ModIssueChecker(QStringLiteral("Incompatibility"), false) {} - bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; }; @@ -63,8 +63,8 @@ class LoadOrderIssueChecker final : public ModIssueChecker { public: LoadOrderIssueChecker() : ModIssueChecker(QStringLiteral("Load Order"), true) {} - bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; - std::optional> affected_by_moves(const CheckContext& ctx, + bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; + std::optional> affectedByMoves(const CheckContext& ctx, const QSet& moved) const override; QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; }; @@ -82,7 +82,7 @@ class GameVersionIssueChecker final : public ModIssueChecker { public: GameVersionIssueChecker() : ModIssueChecker(QStringLiteral("Version Mismatch"), false) {} - bool should_check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; }; diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp index cc4a977..0d988db 100644 --- a/src/core/checker/models.cpp +++ b/src/core/checker/models.cpp @@ -2,22 +2,22 @@ namespace px::modrim::checker { -QString ModDiagnostics::error_tooltip() const { +QString ModDiagnostics::errorTooltip() const { if (errors.isEmpty()) { return {}; } if (errors.size() == 1) { - return errors.front().short_message; + return errors.front().shortMessage; } return QStringLiteral("%1 errors").arg(errors.size()); } -QString ModDiagnostics::warning_tooltip() const { +QString ModDiagnostics::warningTooltip() const { if (warnings.isEmpty()) { return {}; } if (warnings.size() == 1) { - return warnings.front().short_message; + return warnings.front().shortMessage; } return QStringLiteral("%1 warnings").arg(warnings.size()); } diff --git a/src/core/checker/models.hpp b/src/core/checker/models.hpp index 72bcb58..74fd25f 100644 --- a/src/core/checker/models.hpp +++ b/src/core/checker/models.hpp @@ -18,15 +18,15 @@ using AboutXmlModPtr = std::shared_ptr; class ConstraintGraph; -enum class IssueSeverity : std::uint8_t { Error, Warning }; +enum class IssueSeverity : std::uint8_t { kError, kWarning }; struct ModIssue { QString category; IssueSeverity severity; - QString short_message; - QString category_display_name; - QString detail_message; - QList related_package_ids; + QString shortMessage; + QString categoryDisplayName; + QString detailMessage; + QList relatedPackageIds; bool operator==(const ModIssue&) const = default; }; @@ -35,11 +35,11 @@ struct ModDiagnostics { QList errors; QList warnings; - bool has_errors() const noexcept { return !errors.isEmpty(); } - bool has_warnings() const noexcept { return !warnings.isEmpty(); } + bool hasErrors() const noexcept { return !errors.isEmpty(); } + bool hasWarnings() const noexcept { return !warnings.isEmpty(); } // The single issue's message, or a count once there is more than one. - QString error_tooltip() const; - QString warning_tooltip() const; + QString errorTooltip() const; + QString warningTooltip() const; bool operator==(const ModDiagnostics&) const = default; }; @@ -47,14 +47,14 @@ struct ModDiagnostics { // Everything a checker may read while checking one mod. Callers own the referenced data and // keep it alive for the duration of the check. struct CheckContext { - const QHash& active_mods; - const QList& ordered_pids; - const QHash& pid_to_index; + const QHash& activeMods; + const QList& orderedPids; + const QHash& pidToIndex; const ConstraintGraph& graph; const sort::SortSettings& settings; - QString target_version = QStringLiteral("1.5"); - const QSet& no_version_warning; - const QHash& use_this_instead; + QString targetVersion = QStringLiteral("1.5"); + const QSet& noVersionWarning; + const QHash& useThisInstead; const QList>& cycles; }; diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp index c78b6a4..72c6bdf 100644 --- a/src/core/checker/sort.cpp +++ b/src/core/checker/sort.cpp @@ -16,53 +16,53 @@ using sort::Tier; // Dependency edges point from a mod to the mods that must load before it; load-before edges are // reversed so both shapes end up in the same map. -std::pair build_deps_and_rev_deps(const ConstraintGraph& graph, - const QSet& all_pids) { +std::pair buildDepsAndRevDeps(const ConstraintGraph& graph, + const QSet& allPids) { PackageGraph deps; - PackageGraph rev_deps; - for (const PackageId& pid : all_pids) { + PackageGraph revDeps; + for (const PackageId& pid : allPids) { deps.insert(pid, {}); - rev_deps.insert(pid, {}); + revDeps.insert(pid, {}); } for (const PackageId& pid : graph.nodes()) { - if (!all_pids.contains(pid)) { + if (!allPids.contains(pid)) { continue; } for (const ConstraintEdge& edge : graph.outgoing(pid)) { - if (!all_pids.contains(edge.target)) { + if (!allPids.contains(edge.target)) { continue; } switch (edge.type) { - case EdgeType::LoadBefore: + case EdgeType::kLoadBefore: deps[edge.target].insert(pid); - rev_deps[pid].insert(edge.target); + revDeps[pid].insert(edge.target); break; - case EdgeType::LoadAfter: - case EdgeType::Dependency: + case EdgeType::kLoadAfter: + case EdgeType::kDependency: deps[pid].insert(edge.target); - rev_deps[edge.target].insert(pid); + revDeps[edge.target].insert(pid); break; - case EdgeType::Incompatibility: - case EdgeType::Alternative: + case EdgeType::kIncompatibility: + case EdgeType::kAlternative: break; } } } - return {std::move(deps), std::move(rev_deps)}; + return {std::move(deps), std::move(revDeps)}; } // Mods that community rules flag through `flag`, or none when community rules are disabled. -QSet community_flagged(const sort::CommunityRules* rules, - const QSet& all_pids, +QSet communityFlagged(const sort::CommunityRules* rules, + const QSet& allPids, const sort::SortSettings& settings, bool sort::CommunityRule::*flag) { QSet flagged; if (!settings.use_community_rules || rules == nullptr || rules->isEmpty()) { return flagged; } - for (const PackageId& pid : all_pids) { + for (const PackageId& pid : allPids) { const auto rule = rules->constFind(pid); if (rule != rules->cend() && rule.value().*flag) { flagged.insert(pid); @@ -72,15 +72,15 @@ QSet community_flagged(const sort::CommunityRules* rules, } // Position of each package id in the tier config, so the user's explicit picks sort first. -QHash build_config_priority(const sort::TierConfig& tier_config) { +QHash buildConfigPriority(const sort::TierConfig& tierConfig) { QHash priority; - for (std::size_t i = 0; i < tier_config.tier_0.size(); ++i) { - priority.insert(tier_config.tier_0[i], static_cast(i)); + for (std::size_t i = 0; i < tierConfig.tier_0.size(); ++i) { + priority.insert(tierConfig.tier_0[i], static_cast(i)); } // Tier 1 and tier 3 only fill in ids that tier 0 did not already place, and they are // numbered after the earlier lists. - int base = static_cast(tier_config.tier_0.size()); - for (const auto* list : {&tier_config.tier_1, &tier_config.tier_3}) { + int base = static_cast(tierConfig.tier_0.size()); + for (const auto* list : {&tierConfig.tier_1, &tierConfig.tier_3}) { for (std::size_t i = 0; i < list->size(); ++i) { const PackageId& pid = (*list)[i]; if (!priority.contains(pid)) { @@ -98,13 +98,13 @@ struct SortKey { PackageId pid; }; -SortKey make_key(const PackageId& pid, const QHash& config_priority) { - return SortKey{config_priority.value(pid, static_cast(config_priority.size())), +SortKey makeKey(const PackageId& pid, const QHash& configPriority) { + return SortKey{configPriority.value(pid, static_cast(configPriority.size())), pid.str(), pid}; } -bool key_less(const SortKey& a, const SortKey& b) { +bool keyLess(const SortKey& a, const SortKey& b) { if (a.priority != b.priority) { return a.priority < b.priority; } @@ -112,81 +112,81 @@ bool key_less(const SortKey& a, const SortKey& b) { } // Heap order that pops the smallest key first. -bool key_greater(const SortKey& a, const SortKey& b) { - return key_less(b, a); +bool keyGreater(const SortKey& a, const SortKey& b) { + return keyLess(b, a); } } // namespace -QList topological_sort(const QHash>& active_mods, +QList topologicalSort(const QHash>& activeMods, const ConstraintGraph& graph, const sort::SortSettings& settings, - const sort::CommunityRules* community_rules, + const sort::CommunityRules* communityRules, Profiler* timer) { Profiler local; Profiler& t = timer != nullptr ? *timer : local; - const QSet all_pids(active_mods.keyBegin(), active_mods.keyEnd()); - if (all_pids.isEmpty()) { + const QSet allPids(activeMods.keyBegin(), activeMods.keyEnd()); + if (allPids.isEmpty()) { return {}; } PackageGraph deps; - PackageGraph rev_deps; + PackageGraph revDeps; { auto scope = t("build_deps"); - std::tie(deps, rev_deps) = build_deps_and_rev_deps(graph, all_pids); + std::tie(deps, revDeps) = buildDepsAndRevDeps(graph, allPids); } - QSet load_first; + QSet loadFirst; { auto scope = t("build_load_first"); - load_first = community_flagged(community_rules, - all_pids, + loadFirst = communityFlagged(communityRules, + allPids, settings, - &sort::CommunityRule::load_first); + &sort::CommunityRule::loadFirst); } - QSet load_last; + QSet loadLast; { auto scope = t("build_load_last"); - load_last = community_flagged(community_rules, - all_pids, + loadLast = communityFlagged(communityRules, + allPids, settings, - &sort::CommunityRule::load_last); + &sort::CommunityRule::loadLast); } QHash tiers; { auto scope = t("assign_tiers"); - tiers = sort::assign_tiers(all_pids, + tiers = sort::assignTiers(allPids, deps, - rev_deps, - load_first, - load_last, + revDeps, + loadFirst, + loadLast, settings.tier_config); } - constexpr std::array, 4> tier_passes{{ - {Tier::Tier0, "kahn_tier_0"}, - {Tier::Tier1, "kahn_tier_1"}, - {Tier::Tier2, "kahn_tier_2"}, - {Tier::Tier3, "kahn_tier_3"}, + constexpr std::array, 4> kTierPasses{{ + {Tier::kTier0, "kahn_tier_0"}, + {Tier::kTier1, "kahn_tier_1"}, + {Tier::kTier2, "kahn_tier_2"}, + {Tier::kTier3, "kahn_tier_3"}, }}; - const QHash config_priority = build_config_priority(settings.tier_config); + const QHash configPriority = buildConfigPriority(settings.tier_config); QList order; QSet ordered; - for (const auto& [tier, label] : tier_passes) { - QSet tier_pids; - for (const PackageId& pid : all_pids) { + for (const auto& [tier, label] : kTierPasses) { + QSet tierPids; + for (const PackageId& pid : allPids) { if (ordered.contains(pid)) { continue; } const auto assigned = tiers.constFind(pid); if (assigned != tiers.cend() && assigned.value() == tier) { - tier_pids.insert(pid); + tierPids.insert(pid); } } - if (tier_pids.isEmpty()) { + if (tierPids.isEmpty()) { continue; } @@ -196,25 +196,25 @@ QList topological_sort(const QHash indegree; QHash> rev; - for (const PackageId& pid : tier_pids) { - QSet sub_deps = deps.value(pid); - sub_deps.intersect(tier_pids); - indegree.insert(pid, static_cast(sub_deps.size())); - for (const PackageId& dep : sub_deps) { + for (const PackageId& pid : tierPids) { + QSet subDeps = deps.value(pid); + subDeps.intersect(tierPids); + indegree.insert(pid, static_cast(subDeps.size())); + for (const PackageId& dep : subDeps) { rev[dep].insert(pid); } } std::vector queue; - for (const PackageId& pid : tier_pids) { + for (const PackageId& pid : tierPids) { if (indegree.value(pid) == 0) { - queue.push_back(make_key(pid, config_priority)); + queue.push_back(makeKey(pid, configPriority)); } } - std::ranges::make_heap(queue, key_greater); + std::ranges::make_heap(queue, keyGreater); while (!queue.empty()) { - std::ranges::pop_heap(queue, key_greater); + std::ranges::pop_heap(queue, keyGreater); const PackageId pid = queue.back().pid; queue.pop_back(); order.append(pid); @@ -222,20 +222,20 @@ QList topological_sort(const QHash remaining; - for (const PackageId& pid : tier_pids) { + for (const PackageId& pid : tierPids) { if (!ordered.contains(pid)) { - remaining.push_back(make_key(pid, config_priority)); + remaining.push_back(makeKey(pid, configPriority)); } } - std::ranges::sort(remaining, key_less); + std::ranges::sort(remaining, keyLess); for (const SortKey& key : remaining) { order.append(key.pid); ordered.insert(key.pid); diff --git a/src/core/checker/sort.hpp b/src/core/checker/sort.hpp index ddbddaa..be4d1ea 100644 --- a/src/core/checker/sort.hpp +++ b/src/core/checker/sort.hpp @@ -16,10 +16,10 @@ namespace px::modrim::checker { // Orders active mods by tier, then by dependencies and load-order constraints, breaking ties by // the tier config priority and then by package id. Mods caught in a cycle are appended at the // end of their tier in the same key order. -QList topological_sort(const QHash>& active_mods, +QList topologicalSort(const QHash>& activeMods, const ConstraintGraph& graph, const sort::SortSettings& settings, - const sort::CommunityRules* community_rules = nullptr, + const sort::CommunityRules* communityRules = nullptr, Profiler* timer = nullptr); } // namespace px::modrim::checker diff --git a/src/core/config.cpp b/src/core/config.cpp index 3e298c6..c7895b4 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -28,22 +28,22 @@ const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); // representable while still being rejected as unsupported. constexpr double kMaxSchemaVersion = 1e15; -QString steam_app_id() { return QString(RIMWORLD_STEAM_APP_ID); } +QString steamAppId() { return QString(kRimworldSteamAppId); } -QString join_path(QString path, std::initializer_list parts) { +QString joinPath(QString path, std::initializer_list parts) { for (const auto& part : parts) { path.append(u'/').append(part); } return QDir::cleanPath(path); } -bool is_dir(const QString& path) { return QFileInfo(path).isDir(); } +bool isDir(const QString& path) { return QFileInfo(path).isDir(); } -QString find_rimworld_in_steam_root(const QString& root, const QString& steam_id); +QString findRimworldInSteamRoot(const QString& root, const QString& steamId); // Parses a libraryfolders.vdf for an alternate library holding RimWorld. -QString vdf_find_rimworld(const QString& vdf_path, const QString& steam_id) { - QFile file(vdf_path); +QString vdfFindRimworld(const QString& vdfPath, const QString& steamId) { + QFile file(vdfPath); if (!file.open(QIODevice::ReadOnly)) { spdlog::warn("Failed to parse libraryfolders.vdf"); return {}; @@ -51,22 +51,22 @@ QString vdf_find_rimworld(const QString& vdf_path, const QString& steam_id) { const QString text = QString::fromUtf8(file.readAll()); // Matches `"path" ""` and `"" {` lines of libraryfolders.vdf. - static const QRegularExpression path_pattern(QStringLiteral(R"re("path"\s+"(.+?)")re")); - const QRegularExpression app_pattern(QStringLiteral("\"") + QRegularExpression::escape(steam_id) + + static const QRegularExpression pathPattern(QStringLiteral(R"re("path"\s+"(.+?)")re")); + const QRegularExpression appPattern(QStringLiteral("\"") + QRegularExpression::escape(steamId) + QStringLiteral(R"re("\s*\{)re")); - QString current_path; - for (const auto& raw_line : text.split(u'\n')) { - const QString line = raw_line.trimmed(); - if (const auto match = path_pattern.match(line); match.hasMatch()) { - current_path = match.captured(1); + QString currentPath; + for (const auto& rawLine : text.split(u'\n')) { + const QString line = rawLine.trimmed(); + if (const auto match = pathPattern.match(line); match.hasMatch()) { + currentPath = match.captured(1); continue; } - if (!current_path.isEmpty() && app_pattern.match(line).hasMatch()) { - const QString game = join_path(current_path, {QStringLiteral("steamapps"), + if (!currentPath.isEmpty() && appPattern.match(line).hasMatch()) { + const QString game = joinPath(currentPath, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (is_dir(game)) { + if (isDir(game)) { return game; } } @@ -74,26 +74,26 @@ QString vdf_find_rimworld(const QString& vdf_path, const QString& steam_id) { return {}; } -QString find_rimworld_in_steam_root(const QString& root, const QString& steam_id) { - const QString game = join_path(root, {QStringLiteral("steamapps"), QStringLiteral("common"), +QString findRimworldInSteamRoot(const QString& root, const QString& steamId) { + const QString game = joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (is_dir(game)) { + if (isDir(game)) { return game; } - const QString config_vdf = join_path(root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); - if (QFileInfo::exists(config_vdf)) { - return vdf_find_rimworld(config_vdf, steam_id); + const QString configVdf = joinPath(root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(configVdf)) { + return vdfFindRimworld(configVdf, steamId); } - const QString steamapps_vdf = - join_path(root, {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); - if (QFileInfo::exists(steamapps_vdf)) { - return vdf_find_rimworld(steamapps_vdf, steam_id); + const QString steamappsVdf = + joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(steamappsVdf)) { + return vdfFindRimworld(steamappsVdf, steamId); } return {}; } // Workshop content lives beside the `common` folder that holds the game. -QString workshop_path_from_game(const QString& game, const QString& steam_root, const QString& steam_id) { +QString workshopPathFromGame(const QString& game, const QString& steamRoot, const QString& steamId) { const QString normalized = QDir::fromNativeSeparators(game); const QStringList parts = normalized.split(u'/', Qt::SkipEmptyParts); const qsizetype common = parts.indexOf(QStringLiteral("common")); @@ -103,13 +103,13 @@ QString workshop_path_from_game(const QString& game, const QString& steam_root, base.prepend(u'/'); } const QString workshop = - join_path(base, {QStringLiteral("workshop"), QStringLiteral("content"), steam_id}); - if (is_dir(workshop)) { + joinPath(base, {QStringLiteral("workshop"), QStringLiteral("content"), steamId}); + if (isDir(workshop)) { return workshop; } } - return join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steam_id}); + return joinPath(steamRoot, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}); } struct GamePaths { @@ -118,45 +118,45 @@ struct GamePaths { QString workshop; }; -GamePaths game_paths_for(const QString& game, const QString& workshop) { +GamePaths gamePathsFor(const QString& game, const QString& workshop) { return GamePaths{ .game = game, - .local = join_path(game, {QStringLiteral("Mods")}), + .local = joinPath(game, {QStringLiteral("Mods")}), .workshop = workshop, }; } #if defined(Q_OS_LINUX) -GamePaths find_game_paths(QString& found_root) { - const QString steam_id = steam_app_id(); +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); const QString home = QDir::homePath(); const QStringList candidates = { - join_path(home, {QStringLiteral(".steam"), QStringLiteral("debian-installation")}), - join_path(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), - join_path(home, {QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), - join_path(home, {QStringLiteral(".var"), QStringLiteral("app"), QStringLiteral("com.valvesoftware.Steam"), + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("debian-installation")}), + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), + joinPath(home, {QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), + joinPath(home, {QStringLiteral(".var"), QStringLiteral("app"), QStringLiteral("com.valvesoftware.Steam"), QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), - join_path(home, {QStringLiteral("snap"), QStringLiteral("steam"), QStringLiteral("common"), + joinPath(home, {QStringLiteral("snap"), QStringLiteral("steam"), QStringLiteral("common"), QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), }; for (const auto& root : candidates) { - if (!is_dir(root)) { + if (!isDir(root)) { continue; } - if (const QString game = find_rimworld_in_steam_root(root, steam_id); !game.isEmpty()) { - found_root = root; - return game_paths_for(game, workshop_path_from_game(game, root, steam_id)); + if (const QString game = findRimworldInSteamRoot(root, steamId); !game.isEmpty()) { + foundRoot = root; + return gamePathsFor(game, workshopPathFromGame(game, root, steamId)); } } - const QString fallback_root = join_path(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); + const QString fallbackRoot = joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); const QString fallback = - join_path(fallback_root, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (is_dir(fallback)) { - found_root = is_dir(fallback_root) ? fallback_root : QString(); - return game_paths_for(fallback, join_path(fallback_root, {QStringLiteral("steamapps"), + joinPath(fallbackRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); + if (isDir(fallback)) { + foundRoot = isDir(fallbackRoot) ? fallbackRoot : QString(); + return gamePathsFor(fallback, joinPath(fallbackRoot, {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steam_id})); + QStringLiteral("content"), steamId})); } return {}; } @@ -226,26 +226,26 @@ GamePaths find_game_paths(QString&) { return {}; } #endif // Locates the RimWorld Config folder, which the game writes beside its save data. -QString detect_config_folder([[maybe_unused]] const QString& steam_root) { +QString detectConfigFolder([[maybe_unused]] const QString& steamRoot) { #if defined(Q_OS_LINUX) - const QString steam_id = steam_app_id(); - if (!steam_root.isEmpty()) { + const QString steamId = steamAppId(); + if (!steamRoot.isEmpty()) { const QString proton = - join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), steam_id, + joinPath(steamRoot, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), steamId, QStringLiteral("pfx"), QStringLiteral("drive_c"), QStringLiteral("users"), QStringLiteral("steamuser"), QStringLiteral("AppData"), QStringLiteral("LocalLow"), QStringLiteral("Ludeon Studios"), QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); - if (is_dir(proton)) { + if (isDir(proton)) { spdlog::debug("Detected Proton config folder: {}", proton.toStdString()); return proton; } } - const QString native = join_path(QDir::homePath(), {QStringLiteral(".config"), QStringLiteral("unity3d"), + const QString native = joinPath(QDir::homePath(), {QStringLiteral(".config"), QStringLiteral("unity3d"), QStringLiteral("Ludeon Studios"), QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); - if (is_dir(native)) { + if (isDir(native)) { spdlog::debug("Detected native config folder: {}", native.toStdString()); return native; } @@ -272,39 +272,39 @@ QString detect_config_folder([[maybe_unused]] const QString& steam_root) { } // namespace -ConfigService::ConfigService(QString config_dir, JsonMigrations migrations) - : dir_(std::move(config_dir)), migrations_(std::move(migrations)) {} +ConfigService::ConfigService(QString configDir, JsonMigrations migrations) + : m_dir(std::move(configDir)), m_migrations(std::move(migrations)) {} -const QString& ConfigService::directory() const noexcept { return dir_; } +const QString& ConfigService::directory() const noexcept { return m_dir; } -QString ConfigService::file_path(const QString& filename) const { - return QDir(dir_).filePath(filename); +QString ConfigService::filePath(const QString& filename) const { + return QDir(m_dir).filePath(filename); } -bool ConfigService::is_versioned(const QString& filename) { +bool ConfigService::isVersioned(const QString& filename) { return filename == kConfigFile || filename == kUiPrefsFile; } -std::string ConfigService::with_schema_marker(std::string pretty_object) { +std::string ConfigService::withSchemaMarker(std::string prettyObject) { // Pretty output ends with the object's closing brace; the marker goes after the // last non-space character before it, which is the last member's value or the opening brace. - const auto close = pretty_object.rfind('}'); + const auto close = prettyObject.rfind('}'); if (close == std::string::npos) { - return pretty_object; + return prettyObject; } - auto last = pretty_object.find_last_not_of(" \t\r\n", close - 1); + auto last = prettyObject.find_last_not_of(" \t\r\n", close - 1); if (last == std::string::npos) { - return pretty_object; + return prettyObject; } - const bool empty_object = pretty_object[last] == '{'; + const bool emptyObject = prettyObject[last] == '{'; const std::string marker = std::string("\"") + std::string(kSchemaVersionKey) + "\": " + std::to_string(kCurrentConfigSchemaVersion); - pretty_object.insert(last + 1, empty_object ? "\n " + marker + "\n" : ",\n " + marker); - return pretty_object; + prettyObject.insert(last + 1, emptyObject ? "\n " + marker + "\n" : ",\n " + marker); + return prettyObject; } -JsonObject ConfigService::load_raw(const QString& filename) const { - auto bytes = read_file_bytes(file_path(filename)); +JsonObject ConfigService::loadRaw(const QString& filename) const { + auto bytes = readFileBytes(filePath(filename)); if (!bytes) { return {}; } @@ -315,9 +315,9 @@ JsonObject ConfigService::load_raw(const QString& filename) const { return document.get_object(); } -Result> ConfigService::load_versioned(const QString& filename) const { - const QString path = file_path(filename); - auto bytes = read_file_bytes(path); +Result> ConfigService::loadVersioned(const QString& filename) const { + const QString path = filePath(filename); + auto bytes = readFileBytes(path); if (!bytes) { spdlog::warn("Failed to load {}: {}", filename.toStdString(), bytes.error().message.toStdString()); return std::optional{}; @@ -359,7 +359,7 @@ Result> ConfigService::load_versioned(const QString& f // Keep the pre-migration file as a backup before the rewritten one replaces it. const QString backup = path + QStringLiteral(".bak.") + QString::number(QDateTime::currentSecsSinceEpoch()); - if (auto copied = copy_file_replacing(path, backup); !copied) { + if (auto copied = copyFileReplacing(path, backup); !copied) { spdlog::warn("Failed to load {}: {}", filename.toStdString(), copied.error().message.toStdString()); return std::optional{}; } @@ -369,7 +369,7 @@ Result> ConfigService::load_versioned(const QString& f spdlog::warn("Failed to load {}: cannot encode migrated document", filename.toStdString()); return std::optional{}; } - if (auto written = write_file_atomically(path, *text); !written) { + if (auto written = writeFileAtomically(path, *text); !written) { spdlog::warn("Failed to load {}: {}", filename.toStdString(), written.error().message.toStdString()); return std::optional{}; } @@ -383,8 +383,8 @@ Result ConfigService::migrate(JsonObject& data, std::int64_t current) cons .arg(kCurrentConfigSchemaVersion)}); } for (std::int64_t target = current + 1; target <= kCurrentConfigSchemaVersion; ++target) { - const auto migration = migrations_.find(static_cast(target)); - if (migration == migrations_.end()) { + const auto migration = m_migrations.find(static_cast(target)); + if (migration == m_migrations.end()) { return std::unexpected( Error{QStringLiteral("Missing JSON migration step for target version %1").arg(target)}); } @@ -393,21 +393,21 @@ Result ConfigService::migrate(JsonObject& data, std::int64_t current) cons return {}; } -QString config_dir() { +QString configDir() { const QString base = qEnvironmentVariable("XDG_CONFIG_HOME", QDir::homePath() + QStringLiteral("/.config")); return QDir(base).filePath(QStringLiteral("pxmodrim")); } -QString community_rules_file() { - return QDir(config_dir()).filePath(QStringLiteral("communityRules.json")); +QString communityRulesFile() { + return QDir(configDir()).filePath(QStringLiteral("communityRules.json")); } -std::optional read_game_version(const QString& game_path) { - const QString path = QDir(game_path).filePath(QStringLiteral("Version.txt")); +std::optional readGameVersion(const QString& gamePath) { + const QString path = QDir(gamePath).filePath(QStringLiteral("Version.txt")); if (!QFileInfo::exists(path)) { return std::nullopt; } - auto bytes = read_file_bytes(path); + auto bytes = readFileBytes(path); if (!bytes) { spdlog::warn("Failed to read game version from {}", path.toStdString()); return std::nullopt; @@ -415,40 +415,40 @@ std::optional read_game_version(const QString& game_path) { return QString::fromUtf8(bytes->data(), static_cast(bytes->size())).trimmed(); } -PathConfig detect_game_paths() { - QString found_root; - const GamePaths found = find_game_paths(found_root); +PathConfig detectGamePaths() { + QString foundRoot; + const GamePaths found = findGamePaths(foundRoot); PathConfig result; result.game = found.game.toStdString(); result.local = found.local.toStdString(); result.workshop = found.workshop.toStdString(); - result.config_folder = detect_config_folder(found_root).toStdString(); - result.community_rules_file = community_rules_file().toStdString(); + result.config_folder = detectConfigFolder(foundRoot).toStdString(); + result.community_rules_file = communityRulesFile().toStdString(); return result; } -AppConfigService::AppConfigService(ConfigService& svc) : svc_(svc) {} +AppConfigService::AppConfigService(ConfigService& svc) : m_svc(svc) {} QCoro::Task> AppConfigService::setup() { - auto loaded = svc_.load(kConfigFile); + auto loaded = m_svc.load(kConfigFile); if (!loaded) { co_return std::unexpected(loaded.error()); } - cfg_ = std::move(*loaded); + m_cfg = std::move(*loaded); co_return {}; } const AppConfig& AppConfigService::cfg() const { - Q_ASSERT(cfg_.has_value()); - return *cfg_; + Q_ASSERT(m_cfg.has_value()); + return *m_cfg; } -Result AppConfigService::save_cfg() const { - if (!cfg_) { +Result AppConfigService::saveCfg() const { + if (!m_cfg) { return {}; } - return svc_.save(kConfigFile, *cfg_); + return m_svc.save(kConfigFile, *m_cfg); } } // namespace px::modrim diff --git a/src/core/config.hpp b/src/core/config.hpp index cb35c71..a1b9af0 100644 --- a/src/core/config.hpp +++ b/src/core/config.hpp @@ -34,11 +34,12 @@ using JsonMigration = std::function; using JsonMigrations = std::map; // Version 1 is the first versioned layout, so there is nothing to rewrite yet. -inline JsonMigrations default_json_migrations() { +inline JsonMigrations defaultJsonMigrations() { return {{1, [](JsonObject&) {}}}; } // glaze does not serialise QString, so serialised fields use std::string. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct PathConfig { std::string game; std::string local; @@ -50,7 +51,9 @@ struct PathConfig { bool operator==(const PathConfig&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct AppConfig { PathConfig paths; sort::SortSettings sort; @@ -59,13 +62,14 @@ struct AppConfig { std::string log_upload_endpoint = "https://paste.rs/"; std::string launch_args; std::string launch_wrapper; - AfterLaunch after_launch = AfterLaunch::Stay; + AfterLaunch after_launch = AfterLaunch::kStay; bool launch_confirm_errors = true; bool launch_confirm_unsaved = true; bool launch_confirm_running = true; bool operator==(const AppConfig&) const = default; }; +// NOLINTEND(readability-identifier-naming) // File-based repository for typed JSON config files in one directory. // Versioned files (config.json, ui_prefs.json) carry a schema_version key and @@ -73,7 +77,7 @@ struct AppConfig { // corrupt file loads as the struct defaults, with a warning. class ConfigService { public: - explicit ConfigService(QString config_dir, JsonMigrations migrations = default_json_migrations()); + explicit ConfigService(QString configDir, JsonMigrations migrations = defaultJsonMigrations()); const QString& directory() const noexcept; @@ -83,36 +87,36 @@ class ConfigService { // Like `load`, but nullopt when the file does not exist yet. template - Result> load_existing(const QString& filename) const; + Result> loadExisting(const QString& filename) const; template Result save(const QString& filename, const T& data) const; // The undecoded top-level object of a file, or {} when it cannot be read. - JsonObject load_raw(const QString& filename) const; + JsonObject loadRaw(const QString& filename) const; private: - QString file_path(const QString& filename) const; - static bool is_versioned(const QString& filename); + QString filePath(const QString& filename) const; + static bool isVersioned(const QString& filename); // Appends the schema marker after the struct's own members, where the legacy writer put it. - static std::string with_schema_marker(std::string pretty_object); - Result> load_versioned(const QString& filename) const; + static std::string withSchemaMarker(std::string prettyObject); + Result> loadVersioned(const QString& filename) const; Result migrate(JsonObject& data, std::int64_t current) const; - QString dir_; - JsonMigrations migrations_; + QString m_dir; + JsonMigrations m_migrations; }; template Result ConfigService::load(const QString& filename) const { - const QString path = file_path(filename); + const QString path = filePath(filename); if (!QFileInfo::exists(path)) { return T{}; } std::string text; - if (is_versioned(filename)) { - auto document = load_versioned(filename); + if (isVersioned(filename)) { + auto document = loadVersioned(filename); if (!document) { return std::unexpected(document.error()); } @@ -125,7 +129,7 @@ Result ConfigService::load(const QString& filename) const { } text = std::move(*encoded); } else { - auto bytes = read_file_bytes(path); + auto bytes = readFileBytes(path); if (!bytes) { return T{}; } @@ -141,8 +145,8 @@ Result ConfigService::load(const QString& filename) const { } template -Result> ConfigService::load_existing(const QString& filename) const { - if (!QFileInfo::exists(file_path(filename))) { +Result> ConfigService::loadExisting(const QString& filename) const { + if (!QFileInfo::exists(filePath(filename))) { return std::optional{}; } auto loaded = load(filename); @@ -158,19 +162,19 @@ Result ConfigService::save(const QString& filename, const T& data) const { if (!text) { return std::unexpected(Error{QStringLiteral("Cannot encode %1").arg(filename)}); } - if (is_versioned(filename)) { - *text = with_schema_marker(std::move(*text)); + if (isVersioned(filename)) { + *text = withSchemaMarker(std::move(*text)); } - return write_file_atomically(file_path(filename), *text); + return writeFileAtomically(filePath(filename), *text); } -QString config_dir(); -QString community_rules_file(); +QString configDir(); +QString communityRulesFile(); // The game version string from Version.txt, or nullopt when it is missing or unreadable. -std::optional read_game_version(const QString& game_path); +std::optional readGameVersion(const QString& gamePath); -PathConfig detect_game_paths(); +PathConfig detectGamePaths(); // Owns the AppConfig lifecycle: load on setup, save on request. class AppConfigService { @@ -182,11 +186,11 @@ class AppConfigService { // Valid only after a successful setup(). const AppConfig& cfg() const; - Result save_cfg() const; + Result saveCfg() const; private: - ConfigService& svc_; - std::optional cfg_; + ConfigService& m_svc; + std::optional m_cfg; }; } // namespace px::modrim diff --git a/src/core/constants.hpp b/src/core/constants.hpp index 24ed41e..b6f3dfa 100644 --- a/src/core/constants.hpp +++ b/src/core/constants.hpp @@ -9,20 +9,20 @@ namespace px::modrim { -inline constexpr QLatin1StringView RIMWORLD_STEAM_APP_ID{"294100"}; +inline constexpr QLatin1StringView kRimworldSteamAppId{"294100"}; enum class LaunchStrategy : int { - Direct = 0, - Steam = 1, + kDirect = 0, + kSteam = 1, }; enum class AfterLaunch : int { - Stay = 0, - Minimize = 1, - Close = 2, + kStay = 0, + kMinimize = 1, + kClose = 2, }; -inline constexpr QLatin1StringView DEFAULT_MISSING_PACKAGEID{"missing.packageid"}; +inline constexpr QLatin1StringView kDefaultMissingPackageid{"missing.packageid"}; struct RimWorldDlc { QLatin1StringView appId; @@ -33,7 +33,7 @@ struct RimWorldDlc { }; // Ordered by Steam app id, matching the legacy dict's iteration order. -inline constexpr std::array RIMWORLD_DLC_METADATA{{ +inline constexpr std::array kRimworldDlcMetadata{{ {QLatin1StringView{"294100"}, QLatin1StringView{"ludeon.rimworld"}, QLatin1StringView{"RimWorld"}, QLatin1StringView{"https://store.steampowered.com/app/294100/RimWorld"}, @@ -61,9 +61,9 @@ inline constexpr std::array RIMWORLD_DLC_METADATA{{ }}; // Ordered package ids of RIMWORLD_DLC_METADATA. -inline const QStringList RIMWORLD_PACKAGE_IDS = [] { +inline const QStringList kRimworldPackageIds = [] { QStringList ids; - for (const RimWorldDlc& dlc : RIMWORLD_DLC_METADATA) { + for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { ids.append(QString(dlc.packageId)); } return ids; @@ -71,7 +71,7 @@ inline const QStringList RIMWORLD_PACKAGE_IDS = [] { // Exact (case-sensitive) lookup; callers pass lowercase ids as produced by CaseInsensitiveStr. inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { - for (const RimWorldDlc& dlc : RIMWORLD_DLC_METADATA) { + for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { if (packageId == QLatin1StringView(dlc.packageId)) { return &dlc; } diff --git a/src/core/context.cpp b/src/core/context.cpp index b780113..758d79c 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -25,119 +25,119 @@ constexpr qsizetype kVersionMinParts = 2; } // namespace -CoreContext::CoreContext(AppConfig cfg, ConfigService* config_service) - : cfg_(std::move(cfg)), config_service_(config_service) { - refresh_game_version(); - plugins_.register_plugin(std::make_unique()); +CoreContext::CoreContext(AppConfig cfg, ConfigService* configService) + : m_cfg(std::move(cfg)), m_configService(configService) { + refreshGameVersion(); + m_plugins.registerPlugin(std::make_unique()); } CoreContext::~CoreContext() = default; -std::unique_ptr CoreContext::create(AppConfig cfg, ConfigService* config_service) { - std::unique_ptr ctx(new CoreContext(std::move(cfg), config_service)); +std::unique_ptr CoreContext::create(AppConfig cfg, ConfigService* configService) { + std::unique_ptr ctx(new CoreContext(std::move(cfg), configService)); ProfileSession session(QStringLiteral("services.create")); Profiler& timer = session.timer(); { auto scope = timer(QStringLiteral("create_providers")); - ctx->mod_service_ = std::make_unique( - *ctx, providers::create_providers(ctx->cfg_.paths, &ctx->pool_)); + ctx->m_modService = std::make_unique( + *ctx, providers::createProviders(ctx->m_cfg.paths, &ctx->m_pool)); } { auto scope = timer(QStringLiteral("diagnostics_service")); - ctx->diagnostics_service_ = std::make_unique(*ctx, ctx->network_); + ctx->m_diagnosticsService = std::make_unique(*ctx, ctx->m_network); } { auto scope = timer(QStringLiteral("sort_service")); - ctx->sort_service_ = std::make_unique(*ctx, *ctx->diagnostics_service_); + ctx->m_sortService = std::make_unique(*ctx, *ctx->m_diagnosticsService); } { auto scope = timer(QStringLiteral("game_launcher")); - ctx->game_launcher_ = std::make_unique(*ctx); + ctx->m_gameLauncher = std::make_unique(*ctx); } { auto scope = timer(QStringLiteral("activation")); - ctx->activation_ = std::make_unique(*ctx); + ctx->m_activation = std::make_unique(*ctx); } return ctx; } -void CoreContext::load(QHash mods, QStringList active_uuids) { - mods_ = std::move(mods); - loaded_active_uuids_ = active_uuids; - active_uuids_ = std::move(active_uuids); +void CoreContext::load(QHash mods, QStringList activeUuids) { + m_mods = std::move(mods); + m_loadedActiveUuids = activeUuids; + m_activeUuids = std::move(activeUuids); } void CoreContext::setActive(QStringList uuids) { - const QSet old_set(active_uuids_.cbegin(), active_uuids_.cend()); - const QSet new_set(uuids.cbegin(), uuids.cend()); - if (old_set == new_set && active_uuids_ == uuids) { + const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QSet newSet(uuids.cbegin(), uuids.cend()); + if (oldSet == newSet && m_activeUuids == uuids) { return; } - const bool set_changed = old_set != new_set; - active_uuids_ = std::move(uuids); - if (set_changed) { - diagnostics_service_->rebuild(active_uuids_); + const bool setChanged = oldSet != newSet; + m_activeUuids = std::move(uuids); + if (setChanged) { + m_diagnosticsService->rebuild(m_activeUuids); } else { - diagnostics_service_->reorder(active_uuids_); + m_diagnosticsService->reorder(m_activeUuids); } - active_state_changed_.notify(active_uuids_); + m_activeStateChanged.notify(m_activeUuids); } QCoro::Task> CoreContext::autoSort() { - const QSet active(active_uuids_.cbegin(), active_uuids_.cend()); - const QStringList deps = sort_service_->resolve_missing_dependencies(active); + const QSet active(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QStringList deps = m_sortService->resolveMissingDependencies(active); if (!deps.isEmpty()) { - active_uuids_.append(deps); - diagnostics_service_->rebuild(active_uuids_); - active_state_changed_.notify(active_uuids_); + m_activeUuids.append(deps); + m_diagnosticsService->rebuild(m_activeUuids); + m_activeStateChanged.notify(m_activeUuids); spdlog::info("auto-sort: enabling {} missing dependencies", deps.size()); } const auto started = std::chrono::steady_clock::now(); - active_uuids_ = co_await sort_service_->sort_active_mods(); - const double elapsed_ms = + m_activeUuids = co_await m_sortService->sortActiveMods(); + const double elapsedMs = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); - diagnostics_service_->reorder(active_uuids_); - active_state_changed_.notify(active_uuids_); - const int count = static_cast(active_uuids_.size()); - spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsed_ms); - co_return std::pair{count, elapsed_ms}; + m_diagnosticsService->reorder(m_activeUuids); + m_activeStateChanged.notify(m_activeUuids); + const int count = static_cast(m_activeUuids.size()); + spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsedMs); + co_return std::pair{count, elapsedMs}; } void CoreContext::updateConfig(AppConfig cfg) { - cfg_ = std::move(cfg); - refresh_game_version(); - config_changed_.notify(cfg_); + m_cfg = std::move(cfg); + refreshGameVersion(); + m_configChanged.notify(m_cfg); } void CoreContext::resetProviders(const PathConfig& paths) { - mod_service_->reset_providers(providers::create_providers(paths, &pool_)); + m_modService->resetProviders(providers::createProviders(paths, &m_pool)); } -void CoreContext::refresh_game_version() { - if (cfg_.paths.game.empty()) { - game_version_ = QStringLiteral("Unknown"); +void CoreContext::refreshGameVersion() { + if (m_cfg.paths.game.empty()) { + m_gameVersion = QStringLiteral("Unknown"); return; } - const auto version = read_game_version(QString::fromStdString(cfg_.paths.game)); - game_version_ = version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); + const auto version = readGameVersion(QString::fromStdString(m_cfg.paths.game)); + m_gameVersion = version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); } QString CoreContext::targetVersion() const { - const QStringList parts = game_version_.split(QLatin1Char('.')); + const QStringList parts = m_gameVersion.split(QLatin1Char('.')); if (parts.size() >= kVersionMinParts) { return QStringLiteral("%1.%2").arg(parts[0], parts[1]); } return QStringLiteral("Unknown"); } -CollectionStats CoreContext::computeStats(const std::optional& active_ids) const { - const QStringList& ids = active_ids ? *active_ids : active_uuids_; +CollectionStats CoreContext::computeStats(const std::optional& activeIds) const { + const QStringList& ids = activeIds ? *activeIds : m_activeUuids; CollectionStats stats; - stats.total = mods_.size(); + stats.total = m_mods.size(); stats.active = ids.size(); stats.inactive = stats.total - stats.active; - for (const auto& mod : mods_) { + for (const auto& mod : m_mods) { if (!mod->valid) { ++stats.errors; } @@ -146,34 +146,34 @@ CollectionStats CoreContext::computeStats(const std::optional& acti } ConfigService& CoreContext::configService() const { - Q_ASSERT(config_service_ != nullptr); - return *config_service_; + Q_ASSERT(m_configService != nullptr); + return *m_configService; } DownloadManager& CoreContext::downloads() const { - auto* manager = dynamic_cast(plugins_.get(kDownloadsManagerName)); + auto* manager = dynamic_cast(m_plugins.get(kDownloadsManagerName)); Q_ASSERT(manager != nullptr); return *manager; } QCoro::Task> CoreContext::initialize() { - co_return co_await mod_service_->initialize(); + co_return co_await m_modService->initialize(); } QCoro::Task CoreContext::close() { - pool_.waitForDone(); - co_await mod_service_->startup_impact().close_connection(); - co_await mod_service_->close(); + m_pool.waitForDone(); + co_await m_modService->startupImpact().closeConnection(); + co_await m_modService->close(); } void CoreContext::closeSync() { - pool_.waitForDone(); - mod_service_->startup_impact().close_connection_sync(); - mod_service_->close_sync(); + m_pool.waitForDone(); + m_modService->startupImpact().closeConnectionSync(); + m_modService->closeSync(); } void CoreContext::registerPlugin(std::unique_ptr plugin) { - plugins_.register_plugin(std::move(plugin)); + m_plugins.registerPlugin(std::move(plugin)); } } // namespace px::modrim diff --git a/src/core/context.hpp b/src/core/context.hpp index db09989..f668bf6 100644 --- a/src/core/context.hpp +++ b/src/core/context.hpp @@ -37,7 +37,7 @@ class CoreContext { public: // `config_service` is not owned and must outlive the context. static std::unique_ptr create(AppConfig cfg, - ConfigService* config_service = nullptr); + ConfigService* configService = nullptr); ~CoreContext(); CoreContext(const CoreContext&) = delete; @@ -46,7 +46,7 @@ class CoreContext { CoreContext& operator=(CoreContext&&) = delete; // Replaces all mods and the active list, taking ownership of the data. - void load(QHash mods, QStringList active_uuids); + void load(QHash mods, QStringList activeUuids); void setActive(QStringList uuids); // Enables missing dependencies, then sorts the active mods. Resolves to the number of active // mods and the time the sort took in milliseconds. @@ -55,7 +55,7 @@ class CoreContext { void updateConfig(AppConfig cfg); // Rebuilds the providers for new paths. void resetProviders(const PathConfig& paths); - CollectionStats computeStats(const std::optional& active_ids = std::nullopt) const; + CollectionStats computeStats(const std::optional& activeIds = std::nullopt) const; QCoro::Task> initialize(); QCoro::Task close(); @@ -64,54 +64,54 @@ class CoreContext { void registerPlugin(std::unique_ptr plugin); - const AppConfig& config() const noexcept { return cfg_; } - const QHash& allMods() const noexcept { return mods_; } - const QStringList& activeUuids() const noexcept { return active_uuids_; } + const AppConfig& config() const noexcept { return m_cfg; } + const QHash& allMods() const noexcept { return m_mods; } + const QStringList& activeUuids() const noexcept { return m_activeUuids; } // Active UUIDs as last loaded from disk, unaffected by later edits. - const QStringList& loadedActiveUuids() const noexcept { return loaded_active_uuids_; } + const QStringList& loadedActiveUuids() const noexcept { return m_loadedActiveUuids; } // "Unknown" when the game is not configured or its version cannot be read. - const QString& gameVersion() const noexcept { return game_version_; } + const QString& gameVersion() const noexcept { return m_gameVersion; } // The "X.Y" part of the game version, or "Unknown". QString targetVersion() const; - Event& activeStateChanged() noexcept { return active_state_changed_; } - Event& configChanged() noexcept { return config_changed_; } + Event& activeStateChanged() noexcept { return m_activeStateChanged; } + Event& configChanged() noexcept { return m_configChanged; } - bool hasConfigService() const noexcept { return config_service_ != nullptr; } + bool hasConfigService() const noexcept { return m_configService != nullptr; } ConfigService& configService() const; - QNetworkAccessManager& network() noexcept { return network_; } + QNetworkAccessManager& network() noexcept { return m_network; } - ModService& modService() noexcept { return *mod_service_; } - DiagnosticsService& diagnosticsService() noexcept { return *diagnostics_service_; } - SortService& sortService() noexcept { return *sort_service_; } - ActivationService& activation() noexcept { return *activation_; } - services::GameLauncher& gameLauncher() noexcept { return *game_launcher_; } + ModService& modService() noexcept { return *m_modService; } + DiagnosticsService& diagnosticsService() noexcept { return *m_diagnosticsService; } + SortService& sortService() noexcept { return *m_sortService; } + ActivationService& activation() noexcept { return *m_activation; } + services::GameLauncher& gameLauncher() noexcept { return *m_gameLauncher; } DownloadManager& downloads() const; - PluginRegistry& plugins() noexcept { return plugins_; } + PluginRegistry& plugins() noexcept { return m_plugins; } private: - CoreContext(AppConfig cfg, ConfigService* config_service); - void refresh_game_version(); - - AppConfig cfg_; - ConfigService* config_service_; - QHash mods_; - QStringList active_uuids_; - QStringList loaded_active_uuids_; - QString game_version_; - Event active_state_changed_; - Event config_changed_; + CoreContext(AppConfig cfg, ConfigService* configService); + void refreshGameVersion(); + + AppConfig m_cfg; + ConfigService* m_configService; + QHash m_mods; + QStringList m_activeUuids; + QStringList m_loadedActiveUuids; + QString m_gameVersion; + Event m_activeStateChanged; + Event m_configChanged; // Shared by providers and the network; declared before the services so they outlive them. - QThreadPool pool_; - QNetworkAccessManager network_; + QThreadPool m_pool; + QNetworkAccessManager m_network; // Services are declared before the plugins, so plugins (which disconnect from service // events in their destructors) are destroyed first. - std::unique_ptr mod_service_; - std::unique_ptr diagnostics_service_; - std::unique_ptr sort_service_; - std::unique_ptr game_launcher_; - std::unique_ptr activation_; - PluginRegistry plugins_; + std::unique_ptr m_modService; + std::unique_ptr m_diagnosticsService; + std::unique_ptr m_sortService; + std::unique_ptr m_gameLauncher; + std::unique_ptr m_activation; + PluginRegistry m_plugins; }; } // namespace px::modrim diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp index 13092b4..117b915 100644 --- a/src/core/downloads/downloader.cpp +++ b/src/core/downloads/downloader.cpp @@ -8,18 +8,18 @@ namespace px::modrim { Downloader::Downloader(QString name, QString label, DownloadManager& manager) : Plugin(std::move(name), QStringList{kDownloadsManagerName}), - label_(std::move(label)), - manager_(manager) {} + m_label(std::move(label)), + m_manager(manager) {} -const QString& Downloader::label() const noexcept { return label_; } +const QString& Downloader::label() const noexcept { return m_label; } -void Downloader::setup(CoreContext&) { manager_.register_downloader(*this); } +void Downloader::setup(CoreContext&) { m_manager.registerDownloader(*this); } -QStringList Downloader::updatable_ids(const QList& mods) const { +QStringList Downloader::updatableIds(const QList& mods) const { QStringList ids; QSet seen; for (const auto& mod : mods) { - auto id = updatable_id(*mod); + auto id = updatableId(*mod); if (id && !seen.contains(*id)) { seen.insert(*id); ids.append(std::move(*id)); diff --git a/src/core/downloads/downloader.hpp b/src/core/downloads/downloader.hpp index 93b6716..59301e4 100644 --- a/src/core/downloads/downloader.hpp +++ b/src/core/downloads/downloader.hpp @@ -33,36 +33,36 @@ class Downloader : public Plugin { void setup(CoreContext& ctx) override; // Whether a batch is currently downloading. - virtual bool is_downloading() const = 0; + virtual bool isDownloading() const = 0; // Ids of the batch currently downloading. - virtual QSet active_ids() const = 0; + virtual QSet activeIds() const = 0; // Whether `mod_id` belongs to this source. - virtual bool accepts(const QString& mod_id) const = 0; + virtual bool accepts(const QString& modId) const = 0; // Id of `mod` in this source if PxModRim downloaded it from here. - virtual std::optional updatable_id(const ListedMod& mod) const = 0; + virtual std::optional updatableId(const ListedMod& mod) const = 0; // Unix time of the last successful sync of `mod`, if known. - virtual std::optional last_synced(const ListedMod& mod) const = 0; + virtual std::optional lastSynced(const ListedMod& mod) const = 0; // Downloads or updates `mod_ids`. Per-item failures are reported in the result; // an error means the whole batch was refused. - virtual QCoro::Task> download_mods(QStringList mod_ids) = 0; + virtual QCoro::Task> downloadMods(QStringList modIds) = 0; virtual void cancel() = 0; // Distinct updatable ids among `mods`, in first-seen order. - QStringList updatable_ids(const QList& mods) const; + QStringList updatableIds(const QList& mods) const; - Event status_message_changed; - Event download_progress; - Event download_item_status_changed; - Event download_item_titled; + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; // "login" -> "query" (item details) -> "run" (all items resolved). - Event download_phase_changed; - Event download_finished; - Event busy_changed; - Event batch_started; + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; private: - QString label_; - DownloadManager& manager_; + QString m_label; + DownloadManager& m_manager; }; } // namespace px::modrim diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp index 065e313..54c5b52 100644 --- a/src/core/downloads/manager.cpp +++ b/src/core/downloads/manager.cpp @@ -8,61 +8,61 @@ namespace px::modrim { DownloadManager::DownloadManager() : Plugin(kDownloadsManagerName) {} -void DownloadManager::register_downloader(Downloader& downloader) { - downloaders_.push_back(&downloader); - downloader.status_message_changed.connect([this](const QString& v) { status_message_changed.notify(v); }); - downloader.download_progress.connect([this](const DownloadProgress& v) { download_progress.notify(v); }); - downloader.download_item_status_changed.connect( - [this](const DownloadItemStatus& v) { download_item_status_changed.notify(v); }); - downloader.download_item_titled.connect([this](const DownloadItemTitle& v) { download_item_titled.notify(v); }); - downloader.download_phase_changed.connect([this](const QString& v) { download_phase_changed.notify(v); }); - downloader.download_finished.connect([this](const DownloadResult& v) { download_finished.notify(v); }); - downloader.batch_started.connect([this](const QStringList& v) { batch_started.notify(v); }); - downloader.busy_changed.connect([this](bool busy) { on_busy(busy); }); +void DownloadManager::registerDownloader(Downloader& downloader) { + m_downloaders.push_back(&downloader); + downloader.statusMessageChanged.connect([this](const QString& v) { statusMessageChanged.notify(v); }); + downloader.downloadProgress.connect([this](const DownloadProgress& v) { downloadProgress.notify(v); }); + downloader.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& v) { downloadItemStatusChanged.notify(v); }); + downloader.downloadItemTitled.connect([this](const DownloadItemTitle& v) { downloadItemTitled.notify(v); }); + downloader.downloadPhaseChanged.connect([this](const QString& v) { downloadPhaseChanged.notify(v); }); + downloader.downloadFinished.connect([this](const DownloadResult& v) { downloadFinished.notify(v); }); + downloader.batchStarted.connect([this](const QStringList& v) { batchStarted.notify(v); }); + downloader.busyChanged.connect([this](bool busy) { onBusy(busy); }); } -void DownloadManager::on_busy(bool busy) { +void DownloadManager::onBusy(bool busy) { if (busy) { - cancelled_ = false; + m_cancelled = false; } - busy_changed.notify(is_downloading()); + busyChanged.notify(isDownloading()); } -const std::vector& DownloadManager::downloaders() const noexcept { return downloaders_; } +const std::vector& DownloadManager::downloaders() const noexcept { return m_downloaders; } -bool DownloadManager::available() const noexcept { return !downloaders_.empty(); } +bool DownloadManager::available() const noexcept { return !m_downloaders.empty(); } -bool DownloadManager::is_downloading() const { - return std::ranges::any_of(downloaders_, [](const Downloader* d) { return d->is_downloading(); }); +bool DownloadManager::isDownloading() const { + return std::ranges::any_of(m_downloaders, [](const Downloader* d) { return d->isDownloading(); }); } -QSet DownloadManager::active_ids() const { +QSet DownloadManager::activeIds() const { QSet ids; - for (const auto* downloader : downloaders_) { - ids |= downloader->active_ids(); + for (const auto* downloader : m_downloaders) { + ids |= downloader->activeIds(); } return ids; } -Downloader* DownloadManager::source_of(const ListedMod& mod) const { - for (auto* downloader : downloaders_) { - if (downloader->updatable_id(mod)) { +Downloader* DownloadManager::sourceOf(const ListedMod& mod) const { + for (auto* downloader : m_downloaders) { + if (downloader->updatableId(mod)) { return downloader; } } return nullptr; } -std::optional DownloadManager::updatable_id(const ListedMod& mod) const { - const Downloader* source = source_of(mod); - return source ? source->updatable_id(mod) : std::nullopt; +std::optional DownloadManager::updatableId(const ListedMod& mod) const { + const Downloader* source = sourceOf(mod); + return source ? source->updatableId(mod) : std::nullopt; } -QStringList DownloadManager::updatable_ids(const QList& mods) const { +QStringList DownloadManager::updatableIds(const QList& mods) const { QStringList ids; QSet seen; - for (const auto* downloader : downloaders_) { - for (const auto& id : downloader->updatable_ids(mods)) { + for (const auto* downloader : m_downloaders) { + for (const auto& id : downloader->updatableIds(mods)) { if (!seen.contains(id)) { seen.insert(id); ids.append(id); @@ -72,62 +72,62 @@ QStringList DownloadManager::updatable_ids(const QList& mods) cons return ids; } -QHash DownloadManager::names_by_id(const QList& mods) const { +QHash DownloadManager::namesById(const QList& mods) const { QHash names; for (const auto& mod : mods) { - if (auto id = updatable_id(*mod)) { + if (auto id = updatableId(*mod)) { names.insert(*id, mod->name); } } return names; } -std::optional DownloadManager::last_synced(const ListedMod& mod) const { - const Downloader* source = source_of(mod); - return source ? source->last_synced(mod) : std::nullopt; +std::optional DownloadManager::lastSynced(const ListedMod& mod) const { + const Downloader* source = sourceOf(mod); + return source ? source->lastSynced(mod) : std::nullopt; } -QCoro::Task> DownloadManager::download_mods(QStringList mod_ids) { +QCoro::Task> DownloadManager::downloadMods(QStringList modIds) { // Ids are grouped by owning downloader in first-seen order; duplicates are dropped. std::vector> groups; QSet seen; - for (const auto& mod_id : mod_ids) { - if (seen.contains(mod_id)) { + for (const auto& modId : modIds) { + if (seen.contains(modId)) { continue; } - seen.insert(mod_id); + seen.insert(modId); Downloader* owner = nullptr; - for (auto* downloader : downloaders_) { - if (downloader->accepts(mod_id)) { + for (auto* downloader : m_downloaders) { + if (downloader->accepts(modId)) { owner = downloader; break; } } if (!owner) { - co_return std::unexpected(Error{QStringLiteral("No download source handles '%1'.").arg(mod_id)}); + co_return std::unexpected(Error{QStringLiteral("No download source handles '%1'.").arg(modId)}); } auto group = std::ranges::find(groups, owner, &std::pair::first); if (group == groups.end()) { groups.emplace_back(owner, QStringList{}); group = std::prev(groups.end()); } - group->second.append(mod_id); + group->second.append(modId); } if (groups.empty()) { co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); } - if (is_downloading()) { + if (isDownloading()) { co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); } - cancelled_ = false; + m_cancelled = false; DownloadResult total; for (auto& [downloader, group] : groups) { - if (cancelled_) { + if (m_cancelled) { break; } - auto result = co_await downloader->download_mods(group); + auto result = co_await downloader->downloadMods(group); if (!result) { co_return std::unexpected(result.error()); } @@ -138,12 +138,12 @@ QCoro::Task> DownloadManager::download_mods(QStringList m co_return total; } -bool DownloadManager::cancelled() const noexcept { return cancelled_; } +bool DownloadManager::cancelled() const noexcept { return m_cancelled; } void DownloadManager::cancel() { - cancelled_ = true; - for (auto* downloader : downloaders_) { - if (downloader->is_downloading()) { + m_cancelled = true; + for (auto* downloader : m_downloaders) { + if (downloader->isDownloading()) { downloader->cancel(); } } diff --git a/src/core/downloads/manager.hpp b/src/core/downloads/manager.hpp index 006a059..5a455e6 100644 --- a/src/core/downloads/manager.hpp +++ b/src/core/downloads/manager.hpp @@ -27,42 +27,42 @@ class DownloadManager : public Plugin { DownloadManager(); // Called by each downloader from its `setup`. The downloader must outlive the manager's use of it. - void register_downloader(Downloader& downloader); + void registerDownloader(Downloader& downloader); const std::vector& downloaders() const noexcept; bool available() const noexcept; - bool is_downloading() const; - QSet active_ids() const; + bool isDownloading() const; + QSet activeIds() const; // The downloader that can update `mod`, or nullptr. - Downloader* source_of(const ListedMod& mod) const; - std::optional updatable_id(const ListedMod& mod) const; - QStringList updatable_ids(const QList& mods) const; + Downloader* sourceOf(const ListedMod& mod) const; + std::optional updatableId(const ListedMod& mod) const; + QStringList updatableIds(const QList& mods) const; // Display names of installed `mods`, keyed by their source id. - QHash names_by_id(const QList& mods) const; - std::optional last_synced(const ListedMod& mod) const; + QHash namesById(const QList& mods) const; + std::optional lastSynced(const ListedMod& mod) const; // Downloads `mod_ids`, one downloader after another. Errors before anything starts // (unclaimed id, nothing selected, already running) are returned without downloading. - QCoro::Task> download_mods(QStringList mod_ids); + QCoro::Task> downloadMods(QStringList modIds); bool cancelled() const noexcept; void cancel(); - Event status_message_changed; - Event download_progress; - Event download_item_status_changed; - Event download_item_titled; - Event download_phase_changed; - Event download_finished; - Event busy_changed; - Event batch_started; + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; private: - void on_busy(bool busy); + void onBusy(bool busy); - std::vector downloaders_; - bool cancelled_ = false; + std::vector m_downloaders; + bool m_cancelled = false; }; } // namespace px::modrim diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index 32aba3c..9a9890e 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -27,7 +27,7 @@ constexpr auto kProgressLogInterval = std::chrono::seconds(15); constexpr auto kProgressEmitInterval = std::chrono::milliseconds(100); // Steam item ids are uint64; anything larger is not an item. -std::optional parse_item_id(const QString& text) { +std::optional parseItemId(const QString& text) { if (!isAsciiDigits(text)) { return std::nullopt; } @@ -41,14 +41,14 @@ std::optional parse_item_id(const QString& text) { return value; } -QString item_id_text(std::uint64_t id) { return QString::number(static_cast(id)); } +QString itemIdText(std::uint64_t id) { return QString::number(static_cast(id)); } -double unix_now() { +double unixNow() { return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); } // Settings used to live in config.json under `workshop_` keys. -QHash legacy_settings_keys() { +QHash legacySettingsKeys() { QHash keys; for (const char* field : {"auto_update_hours", "parallel_items", "threads_per_item", "proxy", "connect_timeout", "stall_timeout"}) { @@ -60,26 +60,26 @@ QHash legacy_settings_keys() { // The real client; pxsteamdl::Client is thread-safe, so the batch worker may use it directly. class PxSteamDlClient final : public WorkshopClient { public: - explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) : client_(options) {} + explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) : m_client(options) {} std::vector download(std::span ids, const std::filesystem::path& root, const pxsteamdl::Options& options) override { - return client_.download(ids, root, options); + return m_client.download(ids, root, options); } private: - pxsteamdl::Client client_; + pxsteamdl::Client m_client; }; } // namespace -QCoro::Task>> login_with_pxsteamdl(LoginOptions options) { +QCoro::Task>> loginWithPxsteamdl(LoginOptions options) { co_return co_await QtConcurrent::run( [options = std::move(options)]() -> Result> { pxsteamdl::ClientOptions native; native.proxy = options.proxy; - native.connect_timeout = std::chrono::seconds(options.connect_timeout); - native.stall_timeout = std::chrono::seconds(options.stall_timeout); + native.connect_timeout = std::chrono::seconds(options.connectTimeout); + native.stall_timeout = std::chrono::seconds(options.stallTimeout); try { return std::shared_ptr(std::make_shared(native)); } catch (const std::exception& e) { @@ -88,8 +88,8 @@ QCoro::Task>> login_with_pxsteamdl(LoginO }); } -SteamBatch::SteamBatch(int total_items) - : total(total_items), started(std::chrono::steady_clock::now()), last_logged(started) {} +SteamBatch::SteamBatch(int totalItems) + : total(totalItems), started(std::chrono::steady_clock::now()), lastLogged(started) {} QStringList SteamBatch::changed() const { QStringList result; @@ -106,72 +106,72 @@ DownloadProgress SteamBatch::progress() const { // before their Result arrives. QSet finished(succeeded.begin(), succeeded.end()); finished.unite(QSet(failed.begin(), failed.end())); - qint64 bytes_done = 0; - qint64 bytes_total = 0; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; for (auto it = bytes.cbegin(); it != bytes.cend(); ++it) { const auto [done, size] = it.value(); if (done >= size) { finished.insert(it.key()); } - bytes_done += done; - bytes_total += size; + bytesDone += done; + bytesTotal += size; } return DownloadProgress{ .total = total, .completed = static_cast(finished.size()), - .bytes_done = bytes_done, - .bytes_total = bytes_total, + .bytesDone = bytesDone, + .bytesTotal = bytesTotal, }; } SteamDownloader::SteamDownloader(SteamDownloaderDeps deps) : Downloader("steam_downloader", "Steam Workshop", deps.manager), - config_(deps.config), - local_mods_path_(std::move(deps.local_mods_path)), - installed_mods_(std::move(deps.installed_mods)), - client_factory_(std::move(deps.client_factory)), - settings_(&deps.config, QStringLiteral("steam_downloader"), legacy_settings_keys()) { - auto_timer_.setSingleShot(true); - QObject::connect(&auto_timer_, &QTimer::timeout, &auto_timer_, [this] { run_scheduled_auto_update(); }); + m_config(deps.config), + m_localModsPath(std::move(deps.localModsPath)), + m_installedMods(std::move(deps.installedMods)), + m_clientFactory(std::move(deps.clientFactory)), + m_settings(&deps.config, QStringLiteral("steam_downloader"), legacySettingsKeys()) { + m_autoTimer.setSingleShot(true); + QObject::connect(&m_autoTimer, &QTimer::timeout, &m_autoTimer, [this] { runScheduledAutoUpdate(); }); } -QCoro::Task SteamDownloader::load_sync_state() { - ConfigService& config = config_; +QCoro::Task SteamDownloader::loadSyncState() { + ConfigService& config = m_config; auto loaded = co_await QtConcurrent::run([&config] { return config.load(kWorkshopSyncFile); }); if (loaded) { - sync_ = std::move(*loaded); + m_sync = std::move(*loaded); } else { spdlog::warn("[workshop] cannot read sync times: {}", loaded.error().message.toStdString()); } } QCoro::Task SteamDownloader::init(CoreContext&) { - co_await load_sync_state(); - arm_auto_update(kAutoUpdateStartupDelay); + co_await loadSyncState(); + armAutoUpdate(kAutoUpdateStartupDelay); } QCoro::Task SteamDownloader::shutdown() { - stopping_ = true; - auto_timer_.stop(); + m_stopping = true; + m_autoTimer.stop(); cancel(); - if (running_.isValid()) { - QFuture pending = running_; + if (m_running.isValid()) { + QFuture pending = m_running; co_await pending; } } -bool SteamDownloader::is_downloading() const { return stop_.has_value(); } +bool SteamDownloader::isDownloading() const { return m_stop.has_value(); } -QSet SteamDownloader::active_ids() const { return active_ids_; } +QSet SteamDownloader::activeIds() const { return m_activeIds; } -bool SteamDownloader::accepts(const QString& mod_id) const { return isAsciiDigits(mod_id); } +bool SteamDownloader::accepts(const QString& modId) const { return isAsciiDigits(modId); } -std::optional SteamDownloader::updatable_id(const ListedMod& mod) const { - const QString local = local_path(); +std::optional SteamDownloader::updatableId(const ListedMod& mod) const { + const QString local = localPath(); if (local.isEmpty()) { return std::nullopt; } - const auto& path = mod.mod_path(); + const auto& path = mod.modPath(); if (!path) { return std::nullopt; } @@ -179,54 +179,54 @@ std::optional SteamDownloader::updatable_id(const ListedMod& mod) const if (QDir::cleanPath(info.path()) != QDir::cleanPath(local)) { return std::nullopt; } - const auto pfid = mod.published_file_id(); + const auto pfid = mod.publishedFileId(); if (!pfid || *pfid != info.fileName()) { return std::nullopt; } return pfid; } -std::optional SteamDownloader::last_synced(const ListedMod& mod) const { - const auto id = updatable_id(mod); - return id ? synced_at(*id) : std::nullopt; +std::optional SteamDownloader::lastSynced(const ListedMod& mod) const { + const auto id = updatableId(mod); + return id ? syncedAt(*id) : std::nullopt; } -QStringList SteamDownloader::stale_ids(double max_age_s, double now) const { - const double cutoff = now - max_age_s; - const auto synced_or_zero = [this](const QString& id) { return synced_at(id).value_or(0.0); }; +QStringList SteamDownloader::staleIds(double maxAgeS, double now) const { + const double cutoff = now - maxAgeS; + const auto syncedOrZero = [this](const QString& id) { return syncedAt(id).value_or(0.0); }; QStringList stale; - for (const auto& id : updatable_ids(installed_mods_())) { - if (synced_or_zero(id) < cutoff) { + for (const auto& id : updatableIds(m_installedMods())) { + if (syncedOrZero(id) < cutoff) { stale.append(id); } } std::stable_sort(stale.begin(), stale.end(), [&](const QString& a, const QString& b) { - return synced_or_zero(a) < synced_or_zero(b); + return syncedOrZero(a) < syncedOrZero(b); }); return stale; } -QCoro::Task> SteamDownloader::download_mods(QStringList mod_ids) { - if (mod_ids.isEmpty()) { +QCoro::Task> SteamDownloader::downloadMods(QStringList modIds) { + if (modIds.isEmpty()) { co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); } - for (const auto& id : mod_ids) { - if (!parse_item_id(id)) { + for (const auto& id : modIds) { + if (!parseItemId(id)) { co_return std::unexpected( Error{QStringLiteral("Published file IDs must be ASCII decimal numbers within uint64.")}); } } - const QString root = local_path(); + const QString root = localPath(); if (root.isEmpty()) { co_return std::unexpected(Error{QStringLiteral("Local mods path is not configured.")}); } - if (is_downloading()) { + if (isDownloading()) { co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); } QStringList ids; QSet seen; - for (const auto& id : mod_ids) { + for (const auto& id : modIds) { if (!seen.contains(id)) { seen.insert(id); ids.append(id); @@ -234,167 +234,167 @@ QCoro::Task> SteamDownloader::download_mods(QStringList m } auto batch = std::make_shared(static_cast(ids.size())); - stop_.emplace(); - active_ids_ = QSet(ids.begin(), ids.end()); - busy_changed.notify(true); - batch_started.notify(ids); + m_stop.emplace(); + m_activeIds = QSet(ids.begin(), ids.end()); + busyChanged.notify(true); + batchStarted.notify(ids); spdlog::info("[workshop] downloading {} items into {} (first ids: {})", ids.size(), root.toStdString(), ids.mid(0, 5).join(QStringLiteral(", ")).toStdString()); try { - co_await run_batch(ids, root, batch); + co_await runBatch(ids, root, batch); } catch (...) { - finish_batch(batch); + finishBatch(batch); throw; } - co_return finish_batch(batch); + co_return finishBatch(batch); } void SteamDownloader::cancel() { - if (stop_) { + if (m_stop) { spdlog::debug("[workshop] cancel requested"); - stop_->request_stop(); + m_stop->request_stop(); } } -QString SteamDownloader::local_path() const { return local_mods_path_(); } +QString SteamDownloader::localPath() const { return m_localModsPath(); } -std::optional SteamDownloader::synced_at(const QString& mod_id) const { - const auto it = sync_.synced.find(mod_id.toStdString()); - if (it == sync_.synced.end()) { +std::optional SteamDownloader::syncedAt(const QString& modId) const { + const auto it = m_sync.synced.find(modId.toStdString()); + if (it == m_sync.synced.end()) { return std::nullopt; } return it->second; } -LoginOptions SteamDownloader::wanted_options() const { - const auto& cfg = settings_.value(); +LoginOptions SteamDownloader::wantedOptions() const { + const auto& cfg = m_settings.value(); LoginOptions options; const QString proxy = QString::fromStdString(cfg.proxy).trimmed(); if (!proxy.isEmpty()) { options.proxy = proxy.toStdString(); } - options.connect_timeout = std::max(1, cfg.connect_timeout); - options.stall_timeout = std::max(1, cfg.stall_timeout); + options.connectTimeout = std::max(1, cfg.connect_timeout); + options.stallTimeout = std::max(1, cfg.stall_timeout); return options; } -QCoro::Task>> SteamDownloader::ensure_client() { - const LoginOptions options = wanted_options(); - if (client_ && options != client_options_) { - client_.reset(); +QCoro::Task>> SteamDownloader::ensureClient() { + const LoginOptions options = wantedOptions(); + if (m_client && options != m_clientOptions) { + m_client.reset(); } - if (!client_) { - status_message_changed.notify(QStringLiteral(u"Logging in to Steam\u2026")); - auto logged_in = co_await client_factory_(options); - if (!logged_in) { - co_return std::unexpected(logged_in.error()); + if (!m_client) { + statusMessageChanged.notify(QStringLiteral(u"Logging in to Steam\u2026")); + auto loggedIn = co_await m_clientFactory(options); + if (!loggedIn) { + co_return std::unexpected(loggedIn.error()); } - client_ = std::move(*logged_in); - client_options_ = options; + m_client = std::move(*loggedIn); + m_clientOptions = options; } - auto result = client_; + auto result = m_client; co_return result; } -QCoro::Task SteamDownloader::run_batch(QStringList ids, QString root, std::shared_ptr batch) { - const std::filesystem::path root_path(root.toStdWString()); - const std::error_code mkdir_error = co_await QtConcurrent::run([root_path] { +QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, std::shared_ptr batch) { + const std::filesystem::path rootPath(root.toStdWString()); + const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { std::error_code ec; - std::filesystem::create_directories(root_path, ec); + std::filesystem::create_directories(rootPath, ec); return ec; }); - if (mkdir_error) { + if (mkdirError) { const QString message = - QStringLiteral("Cannot create %1: %2").arg(root, QString::fromStdString(mkdir_error.message())); + QStringLiteral("Cannot create %1: %2").arg(root, QString::fromStdString(mkdirError.message())); spdlog::error("[workshop] {}", message.toStdString()); - status_message_changed.notify(message); + statusMessageChanged.notify(message); for (const auto& id : ids) { - finish_item(*batch, id, message); + finishItem(*batch, id, message); } co_return; } - if (!client_) { - download_phase_changed.notify(QStringLiteral("login")); + if (!m_client) { + downloadPhaseChanged.notify(QStringLiteral("login")); } - const auto login_started = std::chrono::steady_clock::now(); - auto login = co_await ensure_client(); + const auto loginStarted = std::chrono::steady_clock::now(); + auto login = co_await ensureClient(); if (!login) { const QString message = QStringLiteral("Steam login failed: %1").arg(login.error().message); spdlog::error("[workshop] {}", message.toStdString()); - status_message_changed.notify(message); + statusMessageChanged.notify(message); for (const auto& id : ids) { - finish_item(*batch, id, message); + finishItem(*batch, id, message); } co_return; } spdlog::debug("[workshop] steam session ready in {:.1f}s", - std::chrono::duration(std::chrono::steady_clock::now() - login_started).count()); + std::chrono::duration(std::chrono::steady_clock::now() - loginStarted).count()); const std::shared_ptr client = *login; - const auto& cfg = settings_.value(); - const int parallel_items = std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); - const int threads_per_item = std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); - spdlog::debug("[workshop] client.download start: {} items, {} parallel x {} threads", ids.size(), parallel_items, - threads_per_item); + const auto& cfg = m_settings.value(); + const int parallelItems = std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); + const int threadsPerItem = std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); + spdlog::debug("[workshop] client.download start: {} items, {} parallel x {} threads", ids.size(), parallelItems, + threadsPerItem); - std::vector item_ids; - item_ids.reserve(ids.size()); + std::vector itemIds; + itemIds.reserve(ids.size()); for (const auto& id : ids) { - item_ids.push_back(*parse_item_id(id)); // validated by download_mods + itemIds.push_back(*parseItemId(id)); // validated by download_mods } // PxSteamDL calls these on its worker threads. Their state lives on the main thread, // so each call is posted there. - QObject* const target = &dispatch_; + QObject* const target = &m_dispatch; pxsteamdl::Options options; - options.parallel_items = static_cast(parallel_items); - options.threads_per_item = static_cast(threads_per_item); - options.stop = stop_->get_token(); + options.parallel_items = static_cast(parallelItems); + options.threads_per_item = static_cast(threadsPerItem); + options.stop = m_stop->get_token(); options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { QMetaObject::invokeMethod( target, - [this, batch, id = item_id_text(info.item_id), title = QString::fromStdString(info.title)] { - on_resolved(*batch, id, title); + [this, batch, id = itemIdText(info.item_id), title = QString::fromStdString(info.title)] { + onResolved(*batch, id, title); }, Qt::QueuedConnection); }; options.on_progress = [this, target, batch](const pxsteamdl::Progress& p) { QMetaObject::invokeMethod( target, - [this, batch, id = item_id_text(p.item_id), done = static_cast(p.unpacked_bytes), - total = static_cast(p.unpacked_total)] { on_progress(*batch, id, done, total); }, + [this, batch, id = itemIdText(p.item_id), done = static_cast(p.unpacked_bytes), + total = static_cast(p.unpacked_total)] { onProgress(*batch, id, done, total); }, Qt::QueuedConnection); }; - download_phase_changed.notify(QStringLiteral("query")); + downloadPhaseChanged.notify(QStringLiteral("query")); QFuture call = QtConcurrent::run( - [client, item_ids = std::move(item_ids), root_path, options]() -> Outcome { + [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { try { - return client->download(item_ids, root_path, options); + return client->download(itemIds, rootPath, options); } catch (const std::runtime_error& e) { // Anything else propagates to the caller, as in the Python version. const std::string what = e.what(); return std::unexpected(what.empty() ? std::string("download failed") : what); } }); - running_ = call; + m_running = call; Outcome outcome; try { outcome = co_await QFuture(call); } catch (...) { - running_ = {}; + m_running = {}; throw; } - running_ = {}; + m_running = {}; if (!outcome) { // A failed call leaves the Steam session suspect; log in again next time. - client_.reset(); + m_client.reset(); const QString message = QString::fromStdString(outcome.error()); for (const auto& id : ids) { - finish_item(*batch, id, message); + finishItem(*batch, id, message); } co_return; } @@ -403,29 +403,29 @@ QCoro::Task SteamDownloader::run_batch(QStringList ids, QString root, std: // A network failure leaves the Steam session suspect; log in again next time. if (std::ranges::any_of(results, [](const pxsteamdl::Result& r) { return r.error_kind == pxsteamdl::ErrorKind::kNetwork; })) { - client_.reset(); + m_client.reset(); } // Progress posted by the worker is queued before the completion that resumes this // coroutine, so the item callbacks have already been handled here. - QHash by_id; + QHash byId; for (const auto& r : results) { - by_id.insert(item_id_text(r.item_id), &r); + byId.insert(itemIdText(r.item_id), &r); } for (const auto& id : ids) { - const pxsteamdl::Result* result = by_id.value(id, nullptr); + const pxsteamdl::Result* result = byId.value(id, nullptr); if (!result) { - finish_item(*batch, id, QStringLiteral("no result")); + finishItem(*batch, id, QStringLiteral("no result")); } else if (!result->cancelled()) { - finish_item(*batch, id, QString::fromStdString(result->error)); + finishItem(*batch, id, QString::fromStdString(result->error)); } } } -DownloadResult SteamDownloader::finish_batch(const std::shared_ptr& batch) { - stop_.reset(); - active_ids_.clear(); - busy_changed.notify(false); +DownloadResult SteamDownloader::finishBatch(const std::shared_ptr& batch) { + m_stop.reset(); + m_activeIds.clear(); + busyChanged.notify(false); DownloadResult result{ .succeeded = batch->succeeded, @@ -438,131 +438,131 @@ DownloadResult SteamDownloader::finish_batch(const std::shared_ptr& result.failed.isEmpty() ? std::string() : " (failed ids: " + result.failed.mid(0, 20).join(QStringLiteral(", ")).toStdString() + ")"); - record_synced(result.succeeded); - download_finished.notify(result); + recordSynced(result.succeeded); + downloadFinished.notify(result); return result; } -void SteamDownloader::finish_item(SteamBatch& batch, const QString& mod_id, const QString& error) { - const qint64 total = batch.bytes.value(mod_id, {0, 0}).second; +void SteamDownloader::finishItem(SteamBatch& batch, const QString& modId, const QString& error) { + const qint64 total = batch.bytes.value(modId, {0, 0}).second; DownloadItemStatus status; if (!error.isEmpty()) { - spdlog::warn("[workshop] {} failed: {}", mod_id.toStdString(), error.toStdString()); - batch.failed.append(mod_id); + spdlog::warn("[workshop] {} failed: {}", modId.toStdString(), error.toStdString()); + batch.failed.append(modId); status = DownloadItemStatus{ - .mod_id = mod_id, + .modId = modId, .status = QStringLiteral("error"), - .bytes_done = 0, - .bytes_total = 0, + .bytesDone = 0, + .bytesTotal = 0, .error = error, }; } else { - batch.succeeded.append(mod_id); - batch.bytes.insert(mod_id, {total, total}); + batch.succeeded.append(modId); + batch.bytes.insert(modId, {total, total}); status = DownloadItemStatus{ - .mod_id = mod_id, + .modId = modId, .status = QStringLiteral("success"), - .bytes_done = total, - .bytes_total = total, + .bytesDone = total, + .bytesTotal = total, .error = {}, }; } - download_item_status_changed.notify(status); - download_progress.notify(batch.progress()); + downloadItemStatusChanged.notify(status); + downloadProgress.notify(batch.progress()); } -void SteamDownloader::record_synced(const QStringList& ids) { +void SteamDownloader::recordSynced(const QStringList& ids) { if (ids.isEmpty()) { return; } - const double now = unix_now(); + const double now = unixNow(); for (const auto& id : ids) { - sync_.synced[id.toStdString()] = now; + m_sync.synced[id.toStdString()] = now; } - if (auto saved = config_.save(kWorkshopSyncFile, sync_); !saved) { + if (auto saved = m_config.save(kWorkshopSyncFile, m_sync); !saved) { spdlog::warn("[workshop] cannot save sync times: {}", saved.error().message.toStdString()); } } -void SteamDownloader::on_resolved(SteamBatch& batch, const QString& mod_id, const QString& title) { +void SteamDownloader::onResolved(SteamBatch& batch, const QString& modId, const QString& title) { ++batch.resolved; if (!title.isEmpty()) { - download_item_titled.notify(DownloadItemTitle{.mod_id = mod_id, .title = title}); + downloadItemTitled.notify(DownloadItemTitle{.modId = modId, .title = title}); } if (batch.resolved >= batch.total) { - download_phase_changed.notify(QStringLiteral("run")); + downloadPhaseChanged.notify(QStringLiteral("run")); } } -void SteamDownloader::on_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total) { - if (batch.succeeded.contains(mod_id) || batch.failed.contains(mod_id)) { +void SteamDownloader::onProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total) { + if (batch.succeeded.contains(modId) || batch.failed.contains(modId)) { return; } - if (!batch.bytes.contains(mod_id)) { + if (!batch.bytes.contains(modId)) { ++batch.seen; - spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", mod_id.toStdString(), done, + spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", modId.toStdString(), done, total, batch.seen, batch.total); } - batch.bytes.insert(mod_id, {done, total}); + batch.bytes.insert(modId, {done, total}); // PxSteamDL reports per network chunk, thousands of times a second; the UI needs far fewer. const auto now = std::chrono::steady_clock::now(); - if (done >= total || now - batch.last_emitted >= kProgressEmitInterval) { - batch.last_emitted = now; - emit_item_progress(batch, mod_id, done, total); + if (done >= total || now - batch.lastEmitted >= kProgressEmitInterval) { + batch.lastEmitted = now; + emitItemProgress(batch, modId, done, total); } - if (now - batch.last_logged >= kProgressLogInterval) { - batch.last_logged = now; + if (now - batch.lastLogged >= kProgressLogInterval) { + batch.lastLogged = now; const DownloadProgress progress = batch.progress(); spdlog::info("[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", progress.completed, - progress.total, static_cast(progress.bytes_done) / 1048576.0, + progress.total, static_cast(progress.bytesDone) / 1048576.0, std::chrono::duration(now - batch.started).count()); } } -void SteamDownloader::emit_item_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total) { - download_item_status_changed.notify(DownloadItemStatus{ - .mod_id = mod_id, +void SteamDownloader::emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total) { + downloadItemStatusChanged.notify(DownloadItemStatus{ + .modId = modId, .status = QStringLiteral("downloading"), - .bytes_done = done, - .bytes_total = total, + .bytesDone = done, + .bytesTotal = total, .error = {}, }); - download_progress.notify(batch.progress()); + downloadProgress.notify(batch.progress()); } -QCoro::Task SteamDownloader::auto_update_once() { - if (is_downloading()) { +QCoro::Task SteamDownloader::autoUpdateOnce() { + if (isDownloading()) { co_return; } - const int hours = settings_.value().auto_update_hours; + const int hours = m_settings.value().auto_update_hours; if (hours <= 0) { co_return; } - const QStringList ids = stale_ids(hours * 3600.0, unix_now()); + const QStringList ids = staleIds(hours * 3600.0, unixNow()); if (ids.isEmpty()) { co_return; } spdlog::info("[workshop] auto-update: {} mods older than {}h", ids.size(), hours); - const auto result = co_await download_mods(ids); + const auto result = co_await downloadMods(ids); if (!result) { spdlog::warn("[workshop] auto-update not started: {}", result.error().message.toStdString()); } } -void SteamDownloader::arm_auto_update(std::chrono::milliseconds delay) { - if (!stopping_) { - auto_timer_.start(delay); +void SteamDownloader::armAutoUpdate(std::chrono::milliseconds delay) { + if (!m_stopping) { + m_autoTimer.start(delay); } } -QCoro::Task SteamDownloader::run_scheduled_auto_update() { +QCoro::Task SteamDownloader::runScheduledAutoUpdate() { try { - co_await auto_update_once(); + co_await autoUpdateOnce(); } catch (const std::exception& e) { spdlog::error("[workshop] auto-update iteration failed: {}", e.what()); } - arm_auto_update(kAutoUpdateCheckInterval); + armAutoUpdate(kAutoUpdateCheckInterval); } } // namespace px::modrim diff --git a/src/core/downloads/steam.hpp b/src/core/downloads/steam.hpp index f3a2839..670444c 100644 --- a/src/core/downloads/steam.hpp +++ b/src/core/downloads/steam.hpp @@ -46,6 +46,7 @@ inline constexpr std::chrono::seconds kAutoUpdateCheckInterval{600}; // In the config folder; records when each Workshop item was last synced. inline const QString kWorkshopSyncFile = QStringLiteral("workshop_sync.json"); +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SteamSettings { int auto_update_hours = 0; int parallel_items = 8; @@ -56,17 +57,20 @@ struct SteamSettings { bool operator==(const SteamSettings&) const = default; }; +// NOLINTEND(readability-identifier-naming) // Unix seconds of the last sync, keyed by Workshop id. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct WorkshopSyncState { std::map synced; }; +// NOLINTEND(readability-identifier-naming) // Network settings of one Steam login. An unset proxy means libcurl's environment. struct LoginOptions { std::optional proxy; - int connect_timeout = 10; - int stall_timeout = 30; + int connectTimeout = 10; + int stallTimeout = 30; bool operator==(const LoginOptions&) const = default; }; @@ -84,17 +88,17 @@ class WorkshopClient { using ClientFactory = std::function>>(LoginOptions)>; // Logs in anonymously with PxSteamDL on a worker thread. -QCoro::Task>> login_with_pxsteamdl(LoginOptions options); +QCoro::Task>> loginWithPxsteamdl(LoginOptions options); // Progress of one download batch. An item counts as complete once all its bytes are in, // which can happen before PxSteamDL returns its Result. struct SteamBatch { - explicit SteamBatch(int total_items); + explicit SteamBatch(int totalItems); int total = 0; std::chrono::steady_clock::time_point started; - std::chrono::steady_clock::time_point last_logged; - std::chrono::steady_clock::time_point last_emitted; + std::chrono::steady_clock::time_point lastLogged; + std::chrono::steady_clock::time_point lastEmitted; int seen = 0; int resolved = 0; // Item id -> (bytes done, bytes total). @@ -113,10 +117,10 @@ struct SteamDownloaderDeps { DownloadManager& manager; ConfigService& config; // The configured local mods folder; empty when not set. - std::function local_mods_path; + std::function localModsPath; // Mods the application knows about; auto-update picks the stale ones among them. - std::function()> installed_mods; - ClientFactory client_factory = login_with_pxsteamdl; + std::function()> installedMods; + ClientFactory clientFactory = loginWithPxsteamdl; }; // Downloads Steam Workshop items into the local mods folder via PxSteamDL. @@ -127,69 +131,69 @@ class SteamDownloader : public Downloader { QCoro::Task init(CoreContext& ctx) override; QCoro::Task shutdown() override; - bool is_downloading() const override; - QSet active_ids() const override; - bool accepts(const QString& mod_id) const override; - std::optional updatable_id(const ListedMod& mod) const override; - std::optional last_synced(const ListedMod& mod) const override; - QCoro::Task> download_mods(QStringList mod_ids) override; + bool isDownloading() const override; + QSet activeIds() const override; + bool accepts(const QString& modId) const override; + std::optional updatableId(const ListedMod& mod) const override; + std::optional lastSynced(const ListedMod& mod) const override; + QCoro::Task> downloadMods(QStringList modIds) override; void cancel() override; - PluginConfig& settings() noexcept { return settings_; } - const PluginConfig& settings() const noexcept { return settings_; } + PluginConfig& settings() noexcept { return m_settings; } + const PluginConfig& settings() const noexcept { return m_settings; } // Loads workshop_sync.json off the UI thread; called by init(), exposed for tests. - QCoro::Task load_sync_state(); + QCoro::Task loadSyncState(); // Ids never synced, or last synced more than `max_age_s` before `now` (unix seconds), // oldest first. - QStringList stale_ids(double max_age_s, double now) const; + QStringList staleIds(double maxAgeS, double now) const; // One auto-update pass: downloads the stale mods when auto-update is enabled and nothing // is running. The timer started by init() runs this. - QCoro::Task auto_update_once(); + QCoro::Task autoUpdateOnce(); private: // The per-item results of one PxSteamDL call, or the error text if the call itself failed. using Outcome = std::expected, std::string>; - QString local_path() const; - std::optional synced_at(const QString& mod_id) const; - LoginOptions wanted_options() const; - QCoro::Task>> ensure_client(); + QString localPath() const; + std::optional syncedAt(const QString& modId) const; + LoginOptions wantedOptions() const; + QCoro::Task>> ensureClient(); - QCoro::Task run_batch(QStringList ids, QString root, std::shared_ptr batch); - DownloadResult finish_batch(const std::shared_ptr& batch); - void finish_item(SteamBatch& batch, const QString& mod_id, const QString& error); - void record_synced(const QStringList& ids); + QCoro::Task runBatch(QStringList ids, QString root, std::shared_ptr batch); + DownloadResult finishBatch(const std::shared_ptr& batch); + void finishItem(SteamBatch& batch, const QString& modId, const QString& error); + void recordSynced(const QStringList& ids); - void on_resolved(SteamBatch& batch, const QString& mod_id, const QString& title); - void on_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total); - void emit_item_progress(SteamBatch& batch, const QString& mod_id, qint64 done, qint64 total); + void onResolved(SteamBatch& batch, const QString& modId, const QString& title); + void onProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total); + void emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total); - void arm_auto_update(std::chrono::milliseconds delay); - QCoro::Task run_scheduled_auto_update(); + void armAutoUpdate(std::chrono::milliseconds delay); + QCoro::Task runScheduledAutoUpdate(); - ConfigService& config_; - std::function local_mods_path_; - std::function()> installed_mods_; - ClientFactory client_factory_; - PluginConfig settings_; - WorkshopSyncState sync_; + ConfigService& m_config; + std::function m_localModsPath; + std::function()> m_installedMods; + ClientFactory m_clientFactory; + PluginConfig m_settings; + WorkshopSyncState m_sync; - std::shared_ptr client_; - LoginOptions client_options_; + std::shared_ptr m_client; + LoginOptions m_clientOptions; // Set while a batch is downloading. - std::optional stop_; - QSet active_ids_; + std::optional m_stop; + QSet m_activeIds; // The PxSteamDL call of the running batch, so shutdown() can wait for it. - QFuture running_; + QFuture m_running; // Worker-thread callbacks are posted to this object, which lives on the main thread. - QObject dispatch_; - QTimer auto_timer_; - bool stopping_ = false; + QObject m_dispatch; + QTimer m_autoTimer; + bool m_stopping = false; }; } // namespace px::modrim diff --git a/src/core/downloads/types.hpp b/src/core/downloads/types.hpp index 58ad325..ee97c2f 100644 --- a/src/core/downloads/types.hpp +++ b/src/core/downloads/types.hpp @@ -8,21 +8,21 @@ namespace px::modrim { struct DownloadProgress { int total = 0; int completed = 0; - qint64 bytes_done = 0; - qint64 bytes_total = 0; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; }; // `status` is "downloading", "success" or "error". struct DownloadItemStatus { - QString mod_id; + QString modId; QString status; - qint64 bytes_done = 0; - qint64 bytes_total = 0; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; QString error; }; struct DownloadItemTitle { - QString mod_id; + QString modId; QString title; }; diff --git a/src/core/events.hpp b/src/core/events.hpp index c5877bc..5fd970c 100644 --- a/src/core/events.hpp +++ b/src/core/events.hpp @@ -18,16 +18,16 @@ class Event { using Id = std::uint64_t; Id connect(Handler handler) { - const Id id = next_id_++; - handlers_.emplace_back(id, std::move(handler)); + const Id id = m_nextId++; + m_handlers.emplace_back(id, std::move(handler)); return id; } // Returns false when `id` is not connected. bool disconnect(Id id) { - for (auto it = handlers_.begin(); it != handlers_.end(); ++it) { + for (auto it = m_handlers.begin(); it != m_handlers.end(); ++it) { if (it->first == id) { - handlers_.erase(it); + m_handlers.erase(it); return true; } } @@ -36,15 +36,15 @@ class Event { // Named `notify`: Qt defines `emit` as a macro, which would swallow a member named so. void notify(const T& data) const { - const auto snapshot = handlers_; + const auto snapshot = m_handlers; for (const auto& [id, handler] : snapshot) { handler(data); } } private: - std::vector> handlers_; - Id next_id_ = 1; + std::vector> m_handlers; + Id m_nextId = 1; }; } // namespace px::modrim diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp index 415ba21..bbd3ee1 100644 --- a/src/core/file_io.cpp +++ b/src/core/file_io.cpp @@ -7,7 +7,7 @@ namespace px::modrim { -Result read_file_bytes(const QString& path) { +Result readFileBytes(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { return std::unexpected(Error{file.errorString()}); @@ -16,7 +16,7 @@ Result read_file_bytes(const QString& path) { return std::string(bytes.constData(), static_cast(bytes.size())); } -Result write_file_atomically(const QString& path, std::string_view data) { +Result writeFileAtomically(const QString& path, std::string_view data) { if (!QDir().mkpath(QFileInfo(path).absolutePath())) { return std::unexpected(Error{QStringLiteral("Cannot create directory for %1").arg(path)}); } @@ -35,7 +35,7 @@ Result write_file_atomically(const QString& path, std::string_view data) { return {}; } -Result copy_file_replacing(const QString& source, const QString& target) { +Result copyFileReplacing(const QString& source, const QString& target) { QFile::remove(target); if (!QFile::copy(source, target)) { return std::unexpected(Error{QStringLiteral("Cannot copy %1 to %2").arg(source, target)}); diff --git a/src/core/file_io.hpp b/src/core/file_io.hpp index 79ec9af..5340bca 100644 --- a/src/core/file_io.hpp +++ b/src/core/file_io.hpp @@ -9,14 +9,14 @@ namespace px::modrim { -Result read_file_bytes(const QString& path); +Result readFileBytes(const QString& path); // Writes `data` to a temporary file beside `path`, syncs it, then renames it // over `path`, so readers see either the old or the new contents. Missing // parent directories are created. -Result write_file_atomically(const QString& path, std::string_view data); +Result writeFileAtomically(const QString& path, std::string_view data); // Copies `source` to `target`, replacing `target` if it exists. -Result copy_file_replacing(const QString& source, const QString& target); +Result copyFileReplacing(const QString& source, const QString& target); } // namespace px::modrim diff --git a/src/core/loading.cpp b/src/core/loading.cpp index 0cf6be9..95592fd 100644 --- a/src/core/loading.cpp +++ b/src/core/loading.cpp @@ -8,73 +8,73 @@ LoadingState::LoadingState(QObject* parent) : QObject(parent) {} QStringList LoadingState::statuses() const { QStringList result; - result.reserve(static_cast(stack_.size())); - for (const auto& frame : stack_) { + result.reserve(static_cast(m_stack.size())); + for (const auto& frame : m_stack) { result.append(frame.status); } return result; } -void LoadingState::push(const QString& status, int total_steps) { - stack_.push_back(Frame{.status = status, .current_step = 0, .total_steps = total_steps}); - emit_changed(); +void LoadingState::push(const QString& status, int totalSteps) { + m_stack.push_back(Frame{.status = status, .currentStep = 0, .totalSteps = totalSteps}); + emitChanged(); } void LoadingState::pop() { - if (!stack_.empty()) { - stack_.pop_back(); + if (!m_stack.empty()) { + m_stack.pop_back(); } - if (stack_.empty()) { + if (m_stack.empty()) { Q_EMIT finished(); } else { - emit_changed(); + emitChanged(); } } void LoadingState::step(int amount) { - if (stack_.empty()) { + if (m_stack.empty()) { return; } - auto& frame = stack_.back(); - frame.current_step = std::min(frame.total_steps, frame.current_step + amount); - emit_changed(); + auto& frame = m_stack.back(); + frame.currentStep = std::min(frame.totalSteps, frame.currentStep + amount); + emitChanged(); } -void LoadingState::set_total(int total) { - if (stack_.empty()) { +void LoadingState::setTotal(int total) { + if (m_stack.empty()) { return; } - stack_.back().total_steps = std::max(1, total); - emit_changed(); + m_stack.back().totalSteps = std::max(1, total); + emitChanged(); } -void LoadingState::set_progress(int value) { - if (stack_.empty()) { +void LoadingState::setProgress(int value) { + if (m_stack.empty()) { return; } - auto& frame = stack_.back(); - frame.current_step = std::max(0, std::min(frame.total_steps, value)); - emit_changed(); + auto& frame = m_stack.back(); + frame.currentStep = std::max(0, std::min(frame.totalSteps, value)); + emitChanged(); } -LoadingState::TaskScope LoadingState::task(const QString& status, int total_steps) { - push(status, total_steps); +LoadingState::TaskScope LoadingState::task(const QString& status, int totalSteps) { + push(status, totalSteps); return TaskScope(*this); } -void LoadingState::emit_changed() { - if (stack_.empty()) { +void LoadingState::emitChanged() { + if (m_stack.empty()) { return; } - const auto& frame = stack_.back(); + const auto& frame = m_stack.back(); // Truncated through double, as the legacy float division does: 29/100 * 100 // is 28.999... and must still report 28. - const int pct = frame.total_steps != 0 - ? static_cast((static_cast(frame.current_step) / - static_cast(frame.total_steps)) * + const int pct = frame.totalSteps != 0 + ? static_cast((static_cast(frame.currentStep) / + static_cast(frame.totalSteps)) * 100.0) : 0; - Q_EMIT changed(frame.status, pct, frame.current_step, frame.total_steps); + Q_EMIT changed(frame.status, pct, frame.currentStep, frame.totalSteps); } } // namespace px::modrim diff --git a/src/core/loading.hpp b/src/core/loading.hpp index 0bf5ef5..2c91ad8 100644 --- a/src/core/loading.hpp +++ b/src/core/loading.hpp @@ -18,32 +18,32 @@ class LoadingState : public QObject { // Pops its frame on destruction. class TaskScope { public: - explicit TaskScope(LoadingState& state) noexcept : state_(&state) {} - TaskScope(TaskScope&& other) noexcept : state_(std::exchange(other.state_, nullptr)) {} + explicit TaskScope(LoadingState& state) noexcept : m_state(&state) {} + TaskScope(TaskScope&& other) noexcept : m_state(std::exchange(other.m_state, nullptr)) {} TaskScope(const TaskScope&) = delete; TaskScope& operator=(const TaskScope&) = delete; TaskScope& operator=(TaskScope&&) = delete; ~TaskScope() { - if (state_) { - state_->pop(); + if (m_state) { + m_state->pop(); } } private: - LoadingState* state_; + LoadingState* m_state; }; explicit LoadingState(QObject* parent = nullptr); QStringList statuses() const; - void push(const QString& status, int total_steps = 100); + void push(const QString& status, int totalSteps = 100); void pop(); void step(int amount = 1); - void set_total(int total); - void set_progress(int value); + void setTotal(int total); + void setProgress(int value); - [[nodiscard]] TaskScope task(const QString& status, int total_steps = 100); + [[nodiscard]] TaskScope task(const QString& status, int totalSteps = 100); Q_SIGNALS: void changed(const QString& status, int pct, int current, int total); @@ -52,13 +52,13 @@ class LoadingState : public QObject { private: struct Frame { QString status; - int current_step = 0; - int total_steps = 100; + int currentStep = 0; + int totalSteps = 100; }; - void emit_changed(); + void emitChanged(); - std::vector stack_; + std::vector m_stack; }; } // namespace px::modrim diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp index 3b30e1b..c299a6e 100644 --- a/src/core/migrator.cpp +++ b/src/core/migrator.cpp @@ -24,12 +24,12 @@ Result execute(QSqlDatabase& db, const QString& sql) { // QSqlQuery runs one statement per exec, so a schema script is split first. // Semicolons inside string literals, quoted identifiers, and comments do not split. // CREATE TRIGGER bodies are not recognised: their inner semicolons split and fail. -QStringList split_statements(const QString& script) { +QStringList splitStatements(const QString& script) { QStringList statements; QString current; QChar quote; - bool in_line_comment = false; - bool in_block_comment = false; + bool inLineComment = false; + bool inBlockComment = false; const auto flush = [&statements](QString& text) { if (const QString statement = text.trimmed(); !statement.isEmpty()) { @@ -42,11 +42,11 @@ QStringList split_statements(const QString& script) { const QChar c = script[i]; const QChar next = i + 1 < script.size() ? script[i + 1] : QChar(); current.append(c); - if (in_line_comment) { - in_line_comment = c != u'\n'; - } else if (in_block_comment) { + if (inLineComment) { + inLineComment = c != u'\n'; + } else if (inBlockComment) { if (c == u'*' && next == u'/') { - in_block_comment = false; + inBlockComment = false; current.append(next); ++i; } @@ -60,9 +60,9 @@ QStringList split_statements(const QString& script) { } } } else if (c == u'-' && next == u'-') { - in_line_comment = true; + inLineComment = true; } else if (c == u'/' && next == u'*') { - in_block_comment = true; + inBlockComment = true; current.append(next); ++i; } else if (c == u'\'' || c == u'"' || c == u'`') { @@ -75,8 +75,8 @@ QStringList split_statements(const QString& script) { return statements; } -Result execute_script(QSqlDatabase& db, const QString& script) { - for (const auto& statement : split_statements(script)) { +Result executeScript(QSqlDatabase& db, const QString& script) { + for (const auto& statement : splitStatements(script)) { if (auto applied = execute(db, statement); !applied) { return applied; } @@ -84,7 +84,7 @@ Result execute_script(QSqlDatabase& db, const QString& script) { return {}; } -Result user_version(QSqlDatabase& db) { +Result userVersion(QSqlDatabase& db) { QSqlQuery query(db); if (!query.exec(QStringLiteral("PRAGMA user_version"))) { return std::unexpected(Error{query.lastError().text()}); @@ -93,7 +93,7 @@ Result user_version(QSqlDatabase& db) { } // `path` with its last suffix replaced, as Python's Path.with_suffix does. -QString with_suffix(const QString& path, const QString& suffix) { +QString withSuffix(const QString& path, const QString& suffix) { const QFileInfo info(path); const QString name = info.fileName(); const qsizetype dot = name.lastIndexOf(u'.'); @@ -103,15 +103,15 @@ QString with_suffix(const QString& path, const QString& suffix) { } // namespace -Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int schema_version, - const MigrationSteps& steps, const std::optional& backup_path, +Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, int schemaVersion, + const MigrationSteps& steps, const std::optional& backupPath, const QString& label) { - auto current_version = user_version(db); - if (!current_version) { - return std::unexpected(current_version.error()); + auto currentVersion = userVersion(db); + if (!currentVersion) { + return std::unexpected(currentVersion.error()); } - const int current = *current_version; - if (current >= schema_version) { + const int current = *currentVersion; + if (current >= schemaVersion) { return {}; } @@ -120,33 +120,33 @@ Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int sche }; if (current == 0) { - spdlog::info("Initializing {} (version {})", label.toStdString(), schema_version); - if (auto initialized = execute_script(db, schema_sql); !initialized) { + spdlog::info("Initializing {} (version {})", label.toStdString(), schemaVersion); + if (auto initialized = executeScript(db, schemaSql); !initialized) { return initialized; } - return stamp(schema_version); + return stamp(schemaVersion); } - spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, schema_version); - if (backup_path) { + spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, schemaVersion); + if (backupPath) { // Fold the WAL into the main file so the copy below is complete. if (auto checkpointed = execute(db, QStringLiteral("PRAGMA wal_checkpoint(FULL)")); !checkpointed) { return checkpointed; } - const QString backup = with_suffix( - *backup_path, QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); - if (auto copied = copy_file_replacing(*backup_path, backup); !copied) { + const QString backup = withSuffix( + *backupPath, QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); + if (auto copied = copyFileReplacing(*backupPath, backup); !copied) { return copied; } } - for (int target = current + 1; target <= schema_version; ++target) { + for (int target = current + 1; target <= schemaVersion; ++target) { if (!steps.contains(target)) { return std::unexpected( Error{QStringLiteral("Missing migration step for target version %1").arg(target)}); } } - for (int target = current + 1; target <= schema_version; ++target) { + for (int target = current + 1; target <= schemaVersion; ++target) { if (auto applied = steps.at(target)(); !applied) { return applied; } diff --git a/src/core/migrator.hpp b/src/core/migrator.hpp index 60b9172..01c9b5d 100644 --- a/src/core/migrator.hpp +++ b/src/core/migrator.hpp @@ -21,8 +21,8 @@ using MigrationSteps = std::map; // (version 0) gets `schema_sql`; an older one runs the steps in order, and a file // copy of `backup_path` is taken first. Runs synchronously: call it on the thread // that owns `db`. -Result ensure_schema(QSqlDatabase& db, const QString& schema_sql, int schema_version, - const MigrationSteps& steps, const std::optional& backup_path = std::nullopt, +Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, int schemaVersion, + const MigrationSteps& steps, const std::optional& backupPath = std::nullopt, const QString& label = QStringLiteral("schema")); } // namespace px::modrim diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp index c18e1ad..6faa698 100644 --- a/src/core/mod_service.cpp +++ b/src/core/mod_service.cpp @@ -18,75 +18,75 @@ namespace px::modrim { namespace { -QString config_folder_of(const CoreContext& ctx) { +QString configFolderOf(const CoreContext& ctx) { return QString::fromStdString(ctx.config().paths.config_folder); } } // namespace ModService::ModService(CoreContext& ctx, providers::ProviderList providers, - std::unique_ptr metadata_cache) - : ctx_(ctx), - providers_(std::move(providers)), - metadata_cache_(std::move(metadata_cache)), - startup_impact_(ctx) { - if (!metadata_cache_ && ctx.hasConfigService()) { - metadata_cache_ = std::make_unique( - services::metadata_cache_path(ctx.configService().directory())); + std::unique_ptr metadataCache) + : m_ctx(ctx), + m_providers(std::move(providers)), + m_metadataCache(std::move(metadataCache)), + m_startupImpact(ctx) { + if (!m_metadataCache && ctx.hasConfigService()) { + m_metadataCache = std::make_unique( + services::metadataCachePath(ctx.configService().directory())); } } -void ModService::reset_providers(providers::ProviderList providers) { - providers_ = std::move(providers); +void ModService::resetProviders(providers::ProviderList providers) { + m_providers = std::move(providers); } -providers::BaseModProvider* ModService::get_provider(const QString& provider_id) const { - for (const auto& provider : providers_) { - if (provider->provider_id() == provider_id) { +providers::BaseModProvider* ModService::getProvider(const QString& providerId) const { + for (const auto& provider : m_providers) { + if (provider->providerId() == providerId) { return provider.get(); } } return nullptr; } -QHash ModService::provider_colors() const { +QHash ModService::providerColors() const { QHash colors; - for (const auto& provider : providers_) { - colors.insert(provider->provider_id(), provider->color()); + for (const auto& provider : m_providers) { + colors.insert(provider->providerId(), provider->color()); } return colors; } -QCoro::Task> ModService::discover_one(providers::BaseModProvider& provider, - Profiler& timer, bool force_reparse) { - auto scope = timer(QStringLiteral("provider.%1").arg(provider.provider_id())); - co_return co_await provider.discover(ctx_.targetVersion(), &timer, metadata_cache_.get(), - force_reparse); +QCoro::Task> ModService::discoverOne(providers::BaseModProvider& provider, + Profiler& timer, bool forceReparse) { + auto scope = timer(QStringLiteral("provider.%1").arg(provider.providerId())); + co_return co_await provider.discover(m_ctx.targetVersion(), &timer, m_metadataCache.get(), + forceReparse); } -QCoro::Task> ModService::discover(Profiler* timer, bool force_reparse) { +QCoro::Task> ModService::discover(Profiler* timer, bool forceReparse) { // Each provider profiles into its own tree, since their discoveries interleave. - std::unique_ptr own_timer; + std::unique_ptr ownTimer; if (!timer) { - own_timer = std::make_unique(); - timer = own_timer.get(); + ownTimer = std::make_unique(); + timer = ownTimer.get(); } auto scope = (*timer)(QStringLiteral("discover")); - std::vector> child_timers; + std::vector> childTimers; std::vector>> tasks; - child_timers.reserve(providers_.size()); - tasks.reserve(providers_.size()); - for (const auto& provider : providers_) { - child_timers.push_back(std::make_unique()); - tasks.push_back(discover_one(*provider, *child_timers.back(), force_reparse)); + childTimers.reserve(m_providers.size()); + tasks.reserve(m_providers.size()); + for (const auto& provider : m_providers) { + childTimers.push_back(std::make_unique()); + tasks.push_back(discoverOne(*provider, *childTimers.back(), forceReparse)); } - providers::ModMap all_mods; + providers::ModMap allMods; std::optional failure; for (std::size_t i = 0; i < tasks.size(); ++i) { auto result = co_await tasks[i]; - timer->adopt(*child_timers[i]); + timer->adopt(*childTimers[i]); if (!result) { if (!failure) { failure = result.error(); @@ -94,55 +94,55 @@ QCoro::Task> ModService::discover(Profiler* timer, bool force_repar continue; } for (auto it = result->cbegin(); it != result->cend(); ++it) { - all_mods.insert(it.key(), it.value()); + allMods.insert(it.key(), it.value()); } } if (failure) { co_return std::unexpected(*failure); } - if (metadata_cache_) { + if (m_metadataCache) { auto prune = (*timer)(QStringLiteral("cache_prune")); - QStringList mod_paths; - for (const auto& mod : all_mods) { - if (mod->mod_path()) { - mod_paths.append(*mod->mod_path()); + QStringList modPaths; + for (const auto& mod : allMods) { + if (mod->modPath()) { + modPaths.append(*mod->modPath()); } } - const QStringList live_paths = co_await providers::runOnPool( - nullptr, [mod_paths] { return services::normalize_paths(mod_paths); }); - co_await metadata_cache_->prune_disappeared(QSet(live_paths.cbegin(), live_paths.cend())); + const QStringList livePaths = co_await providers::runOnPool( + nullptr, [modPaths] { return services::normalizePaths(modPaths); }); + co_await m_metadataCache->pruneDisappeared(QSet(livePaths.cbegin(), livePaths.cend())); } { auto resolve = (*timer)(QStringLiteral("resolve_active")); - const QStringList active = services::resolve_active_uuids(all_mods, config_folder_of(ctx_)); - ctx_.load(std::move(all_mods), active); + const QStringList active = services::resolveActiveUuids(allMods, configFolderOf(m_ctx)); + m_ctx.load(std::move(allMods), active); } - if (own_timer) { - spdlog::debug("Profile report:\n{}", own_timer->render().toStdString()); + if (ownTimer) { + spdlog::debug("Profile report:\n{}", ownTimer->render().toStdString()); } co_return {}; } -QCoro::Task> ModService::run_refresh_pipeline(const QString& label, bool force_reparse) { +QCoro::Task> ModService::runRefreshPipeline(const QString& label, bool forceReparse) { Profiler timer; auto scope = timer(QStringLiteral("mod_service.%1").arg(label)); - Profiler discover_timer; - Profiler diagnostics_timer; - Profiler impact_timer; - auto discovery = discover(&discover_timer, force_reparse); - auto diagnostics = ctx_.diagnosticsService().initialize(&diagnostics_timer); - auto impact = startup_impact_.load(); + Profiler discoverTimer; + Profiler diagnosticsTimer; + Profiler impactTimer; + auto discovery = discover(&discoverTimer, forceReparse); + auto diagnostics = m_ctx.diagnosticsService().initialize(&diagnosticsTimer); + auto impact = m_startupImpact.load(); const auto discovered = co_await discovery; const auto initialized = co_await diagnostics; const auto loaded = co_await impact; - timer.adopt(discover_timer); - timer.adopt(diagnostics_timer); - timer.adopt(impact_timer); + timer.adopt(discoverTimer); + timer.adopt(diagnosticsTimer); + timer.adopt(impactTimer); if (!discovered) { co_return std::unexpected(discovered.error()); @@ -153,59 +153,59 @@ QCoro::Task> ModService::run_refresh_pipeline(const QString& label, if (!loaded) { co_return std::unexpected(loaded.error()); } - ctx_.diagnosticsService().rebuild(ctx_.activeUuids(), &timer); + m_ctx.diagnosticsService().rebuild(m_ctx.activeUuids(), &timer); spdlog::debug("Profile report:\n{}", timer.render().toStdString()); - mods_changed.notify(std::monostate{}); + modsChanged.notify(std::monostate{}); co_return {}; } -std::optional ModService::snapshots_dir() const { - if (!ctx_.hasConfigService()) { +std::optional ModService::snapshotsDir() const { + if (!m_ctx.hasConfigService()) { return std::nullopt; } - return QDir(ctx_.configService().directory()).filePath(QStringLiteral("snapshots")); + return QDir(m_ctx.configService().directory()).filePath(QStringLiteral("snapshots")); } -void ModService::snapshot_current_config() { - const QString config_folder = config_folder_of(ctx_); - const auto snapshots = snapshots_dir(); - if (config_folder.isEmpty() || !snapshots) { +void ModService::snapshotCurrentConfig() { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { return; } - const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); - if (!QFileInfo(config_path).isFile()) { + const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + if (!QFileInfo(configPath).isFile()) { return; } - if (auto created = create_snapshot(config_path, *snapshots, ctx_.config().max_snapshots); !created) { + if (auto created = createSnapshot(configPath, *snapshots, m_ctx.config().max_snapshots); !created) { spdlog::warn("Failed to snapshot ModsConfig.xml: {}", created.error().message.toStdString()); } } QCoro::Task> ModService::initialize() { - snapshot_current_config(); - co_return co_await run_refresh_pipeline(QStringLiteral("initialize"), false); + snapshotCurrentConfig(); + co_return co_await runRefreshPipeline(QStringLiteral("initialize"), false); } QCoro::Task> ModService::reload(bool full) { - co_return co_await run_refresh_pipeline(QStringLiteral("reload"), full); + co_return co_await runRefreshPipeline(QStringLiteral("reload"), full); } -QStringList ModService::get_snapshots() const { - const auto snapshots = snapshots_dir(); - return snapshots ? px::modrim::get_snapshots(*snapshots) : QStringList{}; +QStringList ModService::getSnapshots() const { + const auto snapshots = snapshotsDir(); + return snapshots ? px::modrim::getSnapshots(*snapshots) : QStringList{}; } -QCoro::Task ModService::restore_snapshot(QString snapshot_path) { - const QString config_folder = config_folder_of(ctx_); - const auto snapshots = snapshots_dir(); - if (config_folder.isEmpty() || !snapshots) { +QCoro::Task ModService::restoreSnapshot(QString snapshotPath) { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { spdlog::warn("Cannot restore snapshot: configuration is incomplete"); co_return false; } - const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); - auto restored = px::modrim::restore_snapshot(snapshot_path, config_path, *snapshots, - ctx_.config().max_snapshots); + const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + auto restored = px::modrim::restoreSnapshot(snapshotPath, configPath, *snapshots, + m_ctx.config().max_snapshots); if (!restored) { spdlog::warn("Failed to restore snapshot: {}", restored.error().message.toStdString()); co_return false; @@ -219,35 +219,35 @@ QCoro::Task ModService::restore_snapshot(QString snapshot_path) { co_return true; } -QCoro::Task ModService::save_active_layout(QStringList active_ids) { - const QString config_folder = config_folder_of(ctx_); - const auto snapshots = snapshots_dir(); - if (config_folder.isEmpty() || !snapshots) { +QCoro::Task ModService::saveActiveLayout(QStringList activeIds) { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { co_return false; } - const auto& mods = ctx_.allMods(); - QList package_ids; - for (const QString& uuid : active_ids) { + const auto& mods = m_ctx.allMods(); + QList packageIds; + for (const QString& uuid : activeIds) { if (auto about = std::dynamic_pointer_cast(mods.value(uuid))) { - package_ids.append(about->package_id); + packageIds.append(about->packageId); } } - const QString config_path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); - const QString target_version = ctx_.targetVersion(); - const int max_snapshots = ctx_.config().max_snapshots; - const QString snapshots_path = *snapshots; + const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const QString targetVersion = m_ctx.targetVersion(); + const int maxSnapshots = m_ctx.config().max_snapshots; + const QString snapshotsPath = *snapshots; auto written = co_await providers::runOnPool>( - nullptr, [this, config_path, snapshots_path, target_version, max_snapshots, - package_ids = std::move(package_ids)] { - QMutexLocker lock(&save_mutex_); - const auto existing = parse_mods_config(config_path); + nullptr, [this, configPath, snapshotsPath, targetVersion, maxSnapshots, + packageIds = std::move(packageIds)] { + QMutexLocker lock(&m_saveMutex); + const auto existing = parseModsConfig(configPath); ModsConfig data; - data.version = target_version; - data.activeMods = package_ids; + data.version = targetVersion; + data.activeMods = packageIds; data.knownExpansions = existing ? existing->knownExpansions : QList{}; - return write_mods_config(config_path, data, snapshots_path, max_snapshots); + return writeModsConfig(configPath, data, snapshotsPath, maxSnapshots); }); if (!written) { spdlog::warn("Failed to save ModsConfig.xml: {}", written.error().message.toStdString()); @@ -257,14 +257,14 @@ QCoro::Task ModService::save_active_layout(QStringList active_ids) { } QCoro::Task ModService::close() { - if (metadata_cache_) { - co_await metadata_cache_->close(); + if (m_metadataCache) { + co_await m_metadataCache->close(); } } -void ModService::close_sync() { +void ModService::closeSync() { // Destroying the cache closes its connection and joins its worker thread. - metadata_cache_.reset(); + m_metadataCache.reset(); } } // namespace px::modrim diff --git a/src/core/mod_service.hpp b/src/core/mod_service.hpp index e7b3f25..0dc6ed2 100644 --- a/src/core/mod_service.hpp +++ b/src/core/mod_service.hpp @@ -27,19 +27,19 @@ class ModService { // `ctx` is not owned and must outlive the service. Without a `metadata_cache`, one is created // in the config folder when the context has a config service. ModService(CoreContext& ctx, providers::ProviderList providers, - std::unique_ptr metadata_cache = nullptr); + std::unique_ptr metadataCache = nullptr); - startup_impact::StartupImpactService& startup_impact() noexcept { return startup_impact_; } + startup_impact::StartupImpactService& startupImpact() noexcept { return m_startupImpact; } // Replaces the provider set. - void reset_providers(providers::ProviderList providers); + void resetProviders(providers::ProviderList providers); // The provider with `provider_id`, or nullptr. - providers::BaseModProvider* get_provider(const QString& provider_id) const; - QHash provider_colors() const; + providers::BaseModProvider* getProvider(const QString& providerId) const; + QHash providerColors() const; // Runs all providers in parallel and loads the results into the context. `timer` is optional; // without one, the profile report goes to the debug log. - QCoro::Task> discover(Profiler* timer = nullptr, bool force_reparse = false); + QCoro::Task> discover(Profiler* timer = nullptr, bool forceReparse = false); // One-time startup work: snapshot the mod list, discover, initialize diagnostics, load impact. QCoro::Task> initialize(); @@ -47,31 +47,31 @@ class ModService { QCoro::Task> reload(bool full = false); // Available mod-list snapshots, newest first. - QStringList get_snapshots() const; + QStringList getSnapshots() const; // Restores a snapshot to ModsConfig.xml and reloads live state. - QCoro::Task restore_snapshot(QString snapshot_path); + QCoro::Task restoreSnapshot(QString snapshotPath); // Persists the given active UUIDs as ModsConfig.xml. - QCoro::Task save_active_layout(QStringList active_ids); + QCoro::Task saveActiveLayout(QStringList activeIds); QCoro::Task close(); // Synchronous teardown for use after the event loop has stopped. - void close_sync(); + void closeSync(); - Event mods_changed; + Event modsChanged; private: - QCoro::Task> discover_one(providers::BaseModProvider& provider, - Profiler& timer, bool force_reparse); - QCoro::Task> run_refresh_pipeline(const QString& label, bool force_reparse); - void snapshot_current_config(); - std::optional snapshots_dir() const; - - CoreContext& ctx_; - providers::ProviderList providers_; - std::unique_ptr metadata_cache_; - startup_impact::StartupImpactService startup_impact_; + QCoro::Task> discoverOne(providers::BaseModProvider& provider, + Profiler& timer, bool forceReparse); + QCoro::Task> runRefreshPipeline(const QString& label, bool forceReparse); + void snapshotCurrentConfig(); + std::optional snapshotsDir() const; + + CoreContext& m_ctx; + providers::ProviderList m_providers; + std::unique_ptr m_metadataCache; + startup_impact::StartupImpactService m_startupImpact; // Serializes ModsConfig.xml writes; they run on the thread pool. - mutable QMutex save_mutex_; + mutable QMutex m_saveMutex; }; } // namespace px::modrim diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 3bd0e48..420de3e 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -25,7 +25,7 @@ void setInvalid(ListedMod& mod, const QString& message) { } CaseInsensitiveStr missingPackageId() { - return CaseInsensitiveStr(QString(DEFAULT_MISSING_PACKAGEID)); + return CaseInsensitiveStr(QString(kDefaultMissingPackageid)); } // Text or
  • list of an element; nullopt when the element is absent or empty. @@ -93,19 +93,19 @@ QString officialModName(const QString& packageId, const QString& name) { DependencyMod dependencyFromLi(pugi::xml_node li) { DependencyMod dep; if (const auto packageId = childText(li, "packageId")) { - dep.package_id = CaseInsensitiveStr(*packageId); + dep.packageId = CaseInsensitiveStr(*packageId); } if (const auto name = childText(li, "displayName")) { dep.name = *name; } if (const auto url = childText(li, "workshopUrl")) { - dep.workshop_url = *url; + dep.workshopUrl = *url; } if (pugi::xml_node alternatives = li.child("alternativePackageIds")) { for (pugi::xml_node alternative : alternatives.children("li")) { const QString text = rawText(alternative).trimmed(); if (!text.isEmpty()) { - dep.alternative_package_ids.add(text); + dep.alternativePackageIds.add(text); } } } @@ -130,7 +130,7 @@ std::shared_ptr aboutModWithoutXml() { template Result> withModPath(std::shared_ptr mod, const QString& path) { - if (auto set = mod->set_mod_path(path); !set) { + if (auto set = mod->setModPath(path); !set) { return std::unexpected(set.error()); } return mod; @@ -193,12 +193,12 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, hasPackageId = true; const auto text = asText(elementValue(child)); if (text && !text->isEmpty()) { - mod->package_id = CaseInsensitiveStr(*text); + mod->packageId = CaseInsensitiveStr(*text); } else { setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel " "'%1'.") - .arg(QString(DEFAULT_MISSING_PACKAGEID))); - mod->package_id = missingPackageId(); + .arg(QString(kDefaultMissingPackageid))); + mod->packageId = missingPackageId(); } } else if (tag == "steamAppId") { const auto text = asText(elementValue(child)); @@ -206,7 +206,7 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, bool ok = false; const qint64 appId = text->toLongLong(&ok); if (ok) { - mod->steam_app_id = appId; + mod->steamAppId = appId; } } } else if (tag == "name") { @@ -227,15 +227,15 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, } } else if (tag == "supportedVersions") { if (const auto list = asList(elementValue(child))) { - mod->supported_versions = QSet(list->cbegin(), list->cend()); + mod->supportedVersions = QSet(list->cbegin(), list->cend()); } } else if (tag == "modVersion") { if (const auto text = asText(elementValue(child))) { - mod->mod_version = *text; + mod->modVersion = *text; } } else if (tag == "modIconPath") { if (const auto text = asText(elementValue(child))) { - mod->mod_icon_path = *text; + mod->modIconPath = *text; } } else if (tag == "url") { if (const auto text = asText(elementValue(child))) { @@ -302,8 +302,8 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, if (!hasPackageId) { setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel '%1'.") - .arg(QString(DEFAULT_MISSING_PACKAGEID))); - mod->package_id = missingPackageId(); + .arg(QString(kDefaultMissingPackageid))); + mod->packageId = missingPackageId(); } // Versioned overrides replace the unversioned lists they shadow. @@ -327,9 +327,9 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, loadAfterLi.append(forceAfterLi); BaseRules rules; - rules.load_before = CaseInsensitiveSet(loadBeforeLi); - rules.load_after = CaseInsensitiveSet(loadAfterLi); - rules.incompatible_with = CaseInsensitiveSet(incompatLi); + rules.loadBefore = CaseInsensitiveSet(loadBeforeLi); + rules.loadAfter = CaseInsensitiveSet(loadAfterLi); + rules.incompatibleWith = CaseInsensitiveSet(incompatLi); if (depsEl) { for (pugi::xml_node li = depsEl.first_child(); li; li = li.next_sibling()) { @@ -343,34 +343,34 @@ std::shared_ptr createAboutModFromElement(pugi::xml_node root, continue; } DependencyMod dep = dependencyFromLi(li); - if (rules.find_dependency(dep.package_id)) { + if (rules.findDependency(dep.packageId)) { spdlog::warn("Duplicate dependency found: {}. Skipping.", - utf8(dep.package_id.str())); + utf8(dep.packageId.str())); } else { rules.dependencies.append(std::move(dep)); } } } - const QString packageId = mod->package_id.str(); + const QString packageId = mod->packageId.str(); mod->name = officialModName(packageId, mod->name); const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId); if (dlc && mod->description.isEmpty()) { mod->description = QString(dlc->description); } // DLC cannot load without the base game, so it always depends on RimWorld. - if (dlc && QLatin1StringView(dlc->appId) != RIMWORLD_STEAM_APP_ID) { + if (dlc && QLatin1StringView(dlc->appId) != kRimworldSteamAppId) { const CaseInsensitiveStr rimworldPid(QStringLiteral("ludeon.rimworld")); - if (!rules.has_dependency(rimworldPid)) { + if (!rules.hasDependency(rimworldPid)) { DependencyMod rimworld; rimworld.name = QStringLiteral("RimWorld"); - rimworld.package_id = rimworldPid; - rimworld.workshop_url = QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); + rimworld.packageId = rimworldPid; + rimworld.workshopUrl = QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); rules.dependencies.append(std::move(rimworld)); } } - mod->about_rules = std::move(rules); + mod->aboutRules = std::move(rules); return mod; } diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp index c9591b6..db8d950 100644 --- a/src/core/models/metadata/structures.cpp +++ b/src/core/models/metadata/structures.cpp @@ -38,60 +38,60 @@ bool hasEntries(const QString& dirPath) { CaseInsensitiveSet::CaseInsensitiveSet(const QStringList& values) { for (const QString& value : values) { - data_.insert(CaseInsensitiveStr(value)); + m_data.insert(CaseInsensitiveStr(value)); } } CaseInsensitiveSet::CaseInsensitiveSet(const QList& values) { for (const CaseInsensitiveStr& value : values) { - data_.insert(value); + m_data.insert(value); } } bool CaseInsensitiveSet::contains(QStringView value) const { - return data_.contains(CaseInsensitiveStr(value)); + return m_data.contains(CaseInsensitiveStr(value)); } bool CaseInsensitiveSet::contains(const CaseInsensitiveStr& value) const { - return data_.contains(value); + return m_data.contains(value); } -void CaseInsensitiveSet::add(QStringView value) { data_.insert(CaseInsensitiveStr(value)); } +void CaseInsensitiveSet::add(QStringView value) { m_data.insert(CaseInsensitiveStr(value)); } -void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { data_.insert(value); } +void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { m_data.insert(value); } -void CaseInsensitiveSet::discard(QStringView value) { data_.remove(CaseInsensitiveStr(value)); } +void CaseInsensitiveSet::discard(QStringView value) { m_data.remove(CaseInsensitiveStr(value)); } -void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { data_.remove(value); } +void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { m_data.remove(value); } CaseInsensitiveSet CaseInsensitiveSet::operator|(const CaseInsensitiveSet& other) const { CaseInsensitiveSet result = *this; - result.data_.unite(other.data_); + result.m_data.unite(other.m_data); return result; } -bool ModsConfig::check_active_duplicates() const { +bool ModsConfig::checkActiveDuplicates() const { const QSet unique(activeMods.cbegin(), activeMods.cend()); return activeMods.size() != unique.size(); } -bool ModsConfig::check_expansions_duplicates() const { +bool ModsConfig::checkExpansionsDuplicates() const { const QSet unique(knownExpansions.cbegin(), knownExpansions.cend()); return knownExpansions.size() != unique.size(); } -const DependencyMod* BaseRules::find_dependency(const CaseInsensitiveStr& package_id) const { +const DependencyMod* BaseRules::findDependency(const CaseInsensitiveStr& packageId) const { for (const DependencyMod& dependency : dependencies) { - if (dependency.package_id == package_id) { + if (dependency.packageId == packageId) { return &dependency; } } return nullptr; } -void BaseRules::set_dependency(DependencyMod dependency) { +void BaseRules::setDependency(DependencyMod dependency) { for (DependencyMod& existing : dependencies) { - if (existing.package_id == dependency.package_id) { + if (existing.packageId == dependency.packageId) { existing = std::move(dependency); return; } @@ -102,43 +102,43 @@ void BaseRules::set_dependency(DependencyMod dependency) { QString ListedMod::newUuid() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } QString ListedMod::uuid() const { - if (mod_path_) { - return *mod_path_; + if (m_modPath) { + return *m_modPath; } - return uuid_; + return m_uuid; } -Result ListedMod::set_mod_path(const QString& path) { - if (mod_path_) { +Result ListedMod::setModPath(const QString& path) { + if (m_modPath) { return std::unexpected(Error{QStringLiteral("Mod path already set. Cannot override.")}); } - mod_path_ = path; - published_file_id_cache_.reset(); + m_modPath = path; + m_publishedFileIdCache.reset(); return {}; } -std::optional ListedMod::mod_folder() const { - if (!mod_path_) { +std::optional ListedMod::modFolder() const { + if (!m_modPath) { return std::nullopt; } - return pathStem(QFileInfo(QDir::cleanPath(*mod_path_)).fileName()); + return pathStem(QFileInfo(QDir::cleanPath(*m_modPath)).fileName()); } -qint64 ListedMod::internal_time_touched() const { - if (!mod_path_ || !QFileInfo::exists(*mod_path_)) { +qint64 ListedMod::internalTimeTouched() const { + if (!m_modPath || !QFileInfo::exists(*m_modPath)) { return -1; } - return QFileInfo(*mod_path_).lastModified().toSecsSinceEpoch(); + return QFileInfo(*m_modPath).lastModified().toSecsSinceEpoch(); } -std::optional ListedMod::published_file_id() const { - if (!published_file_id_cache_) { - published_file_id_cache_ = [this]() -> std::optional { - if (!mod_path_) { +std::optional ListedMod::publishedFileId() const { + if (!m_publishedFileIdCache) { + m_publishedFileIdCache = [this]() -> std::optional { + if (!m_modPath) { return std::nullopt; } const QString pfidPath = - QDir(*mod_path_).filePath(QStringLiteral("About/PublishedFileId.txt")); + QDir(*m_modPath).filePath(QStringLiteral("About/PublishedFileId.txt")); if (QFileInfo::exists(pfidPath)) { QFile file(pfidPath); if (!file.open(QIODevice::ReadOnly)) { @@ -154,36 +154,36 @@ std::optional ListedMod::published_file_id() const { return content; } } - const std::optional folder = mod_folder(); + const std::optional folder = modFolder(); if (folder && isAsciiDigits(*folder)) { return folder; } return std::nullopt; }(); } - return *published_file_id_cache_; + return *m_publishedFileIdCache; } -bool ListedMod::c_sharp_mod() const { - if (!c_sharp_mod_cache_) { - c_sharp_mod_cache_ = [this] { - if (!mod_path_) { +bool ListedMod::cSharpMod() const { + if (!m_cSharpModCache) { + m_cSharpModCache = [this] { + if (!m_modPath) { return false; } - const QString assemblies = QDir(*mod_path_).filePath(QStringLiteral("Assemblies")); + const QString assemblies = QDir(*m_modPath).filePath(QStringLiteral("Assemblies")); return QFileInfo(assemblies).isDir() && hasEntries(assemblies); }(); } - return *c_sharp_mod_cache_; + return *m_cSharpModCache; } -bool ListedMod::xml_patch_mod() const { - if (!xml_patch_mod_cache_) { - xml_patch_mod_cache_ = [this] { - if (!mod_path_) { +bool ListedMod::xmlPatchMod() const { + if (!m_xmlPatchModCache) { + m_xmlPatchModCache = [this] { + if (!m_modPath) { return false; } - const QString patches = QDir(*mod_path_).filePath(QStringLiteral("Patches")); + const QString patches = QDir(*m_modPath).filePath(QStringLiteral("Patches")); if (!QFileInfo(patches).isDir()) { return false; } @@ -197,22 +197,22 @@ bool ListedMod::xml_patch_mod() const { return false; }(); } - return *xml_patch_mod_cache_; + return *m_xmlPatchModCache; } -std::optional ListedMod::preview_img_path() const { - if (!mod_path_) { +std::optional ListedMod::previewImgPath() const { + if (!m_modPath) { return std::nullopt; } - const QString candidate = QDir(*mod_path_).filePath(QStringLiteral("About/Preview.png")); + const QString candidate = QDir(*m_modPath).filePath(QStringLiteral("About/Preview.png")); if (QFileInfo::exists(candidate)) { return candidate; } return std::nullopt; } -std::optional AboutXmlMod::get_dlc_name() const { - if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(package_id.str())) { +std::optional AboutXmlMod::getDlcName() const { + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId.str())) { return QString(dlc->name); } return std::nullopt; diff --git a/src/core/models/metadata/structures.hpp b/src/core/models/metadata/structures.hpp index c0bd715..998789a 100644 --- a/src/core/models/metadata/structures.hpp +++ b/src/core/models/metadata/structures.hpp @@ -24,17 +24,17 @@ namespace px::modrim { class CaseInsensitiveStr { public: CaseInsensitiveStr() = default; - explicit CaseInsensitiveStr(QStringView pid) : value_(pid.toString().toLower()) {} + explicit CaseInsensitiveStr(QStringView pid) : m_value(pid.toString().toLower()) {} - const QString& str() const noexcept { return value_; } + const QString& str() const noexcept { return m_value; } friend bool operator==(const CaseInsensitiveStr&, const CaseInsensitiveStr&) = default; friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { - return qHash(key.value_, seed); + return qHash(key.m_value, seed); } private: - QString value_; + QString m_value; }; // Set of CaseInsensitiveStr with string-friendly membership tests. @@ -51,19 +51,19 @@ class CaseInsensitiveSet { void discard(QStringView value); void discard(const CaseInsensitiveStr& value); - qsizetype size() const noexcept { return data_.size(); } - bool empty() const noexcept { return data_.isEmpty(); } + qsizetype size() const noexcept { return m_data.size(); } + bool empty() const noexcept { return m_data.isEmpty(); } // Set union; the result keeps the elements of both operands. CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; - auto begin() const { return data_.cbegin(); } - auto end() const { return data_.cend(); } + auto begin() const { return m_data.cbegin(); } + auto end() const { return m_data.cend(); } friend bool operator==(const CaseInsensitiveSet&, const CaseInsensitiveSet&) = default; private: - QSet data_; + QSet m_data; }; // Parsed ModsConfig.xml data with active mods and known expansions. @@ -72,10 +72,10 @@ struct ModsConfig { QList activeMods; QList knownExpansions; - bool check_active_duplicates() const; - bool check_expansions_duplicates() const; - void clear_active_mods() { activeMods.clear(); } - void clear_all() { + bool checkActiveDuplicates() const; + bool checkExpansionsDuplicates() const; + void clearActiveMods() { activeMods.clear(); } + void clearAll() { activeMods.clear(); knownExpansions.clear(); } @@ -93,24 +93,24 @@ struct ReplacementInfo { // Dependency declared by a mod's About.xml (or synthesized, e.g. the RimWorld base game for DLC). struct DependencyMod { QString name; - CaseInsensitiveStr package_id{QStringLiteral("invalid.mod")}; - QString workshop_url; - CaseInsensitiveSet alternative_package_ids; + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; + QString workshopUrl; + CaseInsensitiveSet alternativePackageIds; }; // Load-order rules and dependencies. Dependencies keep declaration order. struct BaseRules { - CaseInsensitiveSet load_after; - CaseInsensitiveSet load_before; - CaseInsensitiveSet incompatible_with; + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + CaseInsensitiveSet incompatibleWith; QList dependencies; - const DependencyMod* find_dependency(const CaseInsensitiveStr& package_id) const; - bool has_dependency(const CaseInsensitiveStr& package_id) const { - return find_dependency(package_id) != nullptr; + const DependencyMod* findDependency(const CaseInsensitiveStr& packageId) const; + bool hasDependency(const CaseInsensitiveStr& packageId) const { + return findDependency(packageId) != nullptr; } // Inserts, or replaces the dependency with the same package id in place. - void set_dependency(DependencyMod dependency); + void setDependency(DependencyMod dependency); }; // Mod discovered by any provider. Polymorphic so providers can hand out AboutXmlMod (and @@ -124,42 +124,42 @@ class ListedMod { QString name = QStringLiteral("Unknown Mod Name"); bool valid = true; - QSet supported_versions; + QSet supportedVersions; QString description = QStringLiteral( "This mod is considered invalid by PxModRim (and the RimWorld game).\n\n" "This mod does NOT contain an ./About/About.xml and is likely leftover from previous " "usage."); - QString provider_id; + QString providerId; bool obsolete = false; - bool db_builder_no_name = false; + bool dbBuilderNoName = false; // Identity: the mod path once it is known, otherwise a random id assigned at construction. virtual QString uuid() const; - const std::optional& mod_path() const noexcept { return mod_path_; } + const std::optional& modPath() const noexcept { return m_modPath; } // Fails if a path was already set; a mod's directory is fixed after discovery. - Result set_mod_path(const QString& path); + Result setModPath(const QString& path); // Folder name without its last extension (Python's Path.stem); nullopt without a path. - std::optional mod_folder() const; + std::optional modFolder() const; // Modification time of the mod folder in seconds since the epoch, or -1 if unavailable. - qint64 internal_time_touched() const; + qint64 internalTimeTouched() const; // Published file id from About/PublishedFileId.txt or a numeric folder name. Cached on first // use; this touches the disk, so callers may warm it off the UI thread. - std::optional published_file_id() const; - bool c_sharp_mod() const; - bool xml_patch_mod() const; - std::optional preview_img_path() const; + std::optional publishedFileId() const; + bool cSharpMod() const; + bool xmlPatchMod() const; + std::optional previewImgPath() const; private: static QString newUuid(); - QString uuid_ = newUuid(); - std::optional mod_path_; - mutable std::optional> published_file_id_cache_; - mutable std::optional c_sharp_mod_cache_; - mutable std::optional xml_patch_mod_cache_; + QString m_uuid = newUuid(); + std::optional m_modPath; + mutable std::optional> m_publishedFileIdCache; + mutable std::optional m_cSharpModCache; + mutable std::optional m_xmlPatchModCache; }; class ScenarioMod : public ListedMod { @@ -170,67 +170,80 @@ class ScenarioMod : public ListedMod { // A mod with an About.xml: authors, version, rules, and DLC metadata. class AboutXmlMod : public ListedMod { public: - CaseInsensitiveStr package_id{QStringLiteral("invalid.mod")}; + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; QStringList authors; - QString mod_version; - std::optional mod_icon_path; - qint64 steam_app_id = -1; + QString modVersion; + std::optional modIconPath; + qint64 steamAppId = -1; QString url; double mtime = 0.0; - BaseRules about_rules; + BaseRules aboutRules; // Display name of the RimWorld DLC this package id belongs to, if any. - std::optional get_dlc_name() const; + std::optional getDlcName() const; }; using ListedModPtr = std::shared_ptr; // Pre-computed dependency graphs and tier classification for sorting. struct CompiledDependencyData { - QHash> deps_graph; - QHash> rev_deps_graph; - QSet tier_zero_mods; - QSet tier_one_mods; - QSet tier_three_mods; + QHash> depsGraph; + QHash> revDepsGraph; + QSet tierZeroMods; + QSet tierOneMods; + QSet tierThreeMods; QHash> incompatibilities; - QHash> declared_incompatibilities; + QHash> declaredIncompatibilities; }; // Glaze schemas for community rules and the Steam Workshop database. Fields the upstream JSON // may write either as a single value or as a list use std::variant. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SubExternalRule { std::variant, std::string> name = std::string{}; std::variant, std::string> comment = std::string{}; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SubExternalBoolRule { bool value = false; std::variant, std::string> comment = std::string{}; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct ExternalRule { std::map loadAfter; std::map loadBefore; SubExternalBoolRule loadTop; SubExternalBoolRule loadBottom; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct ExternalRulesSchema { std::int64_t timestamp = 0; std::map rules; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SteamDbEntryDependency { std::string name; std::string url; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SteamDbEntryBlacklist { bool value = false; std::string comment; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SteamDbEntry { bool unpublished = false; std::string url; @@ -245,24 +258,27 @@ struct SteamDbEntry { SteamDbEntryBlacklist blacklist; std::vector> tags; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SteamDbSchema { std::int64_t version = 0; std::map database; }; +// NOLINTEND(readability-identifier-naming) enum class WorkshopUpdateStatus { - Success, - NoWorkshopMods, - Partial, - Failed, + kSuccess, + kNoWorkshopMods, + kPartial, + kFailed, }; struct WorkshopUpdateResult { - WorkshopUpdateStatus status = WorkshopUpdateStatus::Failed; - int mods_checked = 0; - int mods_updated = 0; - QStringList failed_pfids; + WorkshopUpdateStatus status = WorkshopUpdateStatus::kFailed; + int modsChecked = 0; + int modsUpdated = 0; + QStringList failedPfids; QStringList errors; }; diff --git a/src/core/models/view/diagnostics.hpp b/src/core/models/view/diagnostics.hpp index 37d8c9b..2106a75 100644 --- a/src/core/models/view/diagnostics.hpp +++ b/src/core/models/view/diagnostics.hpp @@ -8,19 +8,19 @@ namespace px::modrim::view { // UI-agnostic diagnostics summary for one mod. struct ModDiagnosticsView { - bool has_errors = false; - bool has_warnings = false; - QString error_tooltip; - QString warning_tooltip; + bool hasErrors = false; + bool hasWarnings = false; + QString errorTooltip; + QString warningTooltip; bool operator==(const ModDiagnosticsView&) const = default; }; struct ModIssueView { QString category; - QString category_display_name; + QString categoryDisplayName; std::optional detail; - bool is_error = false; + bool isError = false; bool operator==(const ModIssueView&) const = default; }; diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp index 06a493b..2910500 100644 --- a/src/core/models/view/sidebar.cpp +++ b/src/core/models/view/sidebar.cpp @@ -4,14 +4,14 @@ namespace px::modrim::view { -QString provider_label(const QString& provider_id) { +QString providerLabel(const QString& providerId) { static const QHash labels{ {QStringLiteral("local"), QStringLiteral("Local")}, {QStringLiteral("steam"), QStringLiteral("Steam Workshop")}, {QStringLiteral("downloaded"), QStringLiteral("Steam Workshop")}, {QStringLiteral("core"), QStringLiteral("System / Core")}, }; - return labels.value(provider_id, provider_id); + return labels.value(providerId, providerId); } } // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.hpp b/src/core/models/view/sidebar.hpp index 96920c0..45bebe5 100644 --- a/src/core/models/view/sidebar.hpp +++ b/src/core/models/view/sidebar.hpp @@ -8,19 +8,19 @@ namespace px::modrim::view { // Display name of a mod provider; an unknown provider is shown by its id. -QString provider_label(const QString& provider_id); +QString providerLabel(const QString& providerId); // A sidebar filter and the mods it shows. `count` is derived from `visible_uuids`. struct SidebarEntry { - enum class Kind : std::uint8_t { All, Active, Inactive, WithErrors, WithWarnings, Provider }; + enum class Kind : std::uint8_t { kAll, kActive, kInactive, kWithErrors, kWithWarnings, kProvider }; - Kind kind = Kind::All; + Kind kind = Kind::kAll; QString label; - QSet visible_uuids; + QSet visibleUuids; // Only for Kind::Provider: two providers can share a label, so the id tells them apart. - QString provider_id; + QString providerId; - int count() const noexcept { return static_cast(visible_uuids.size()); } + int count() const noexcept { return static_cast(visibleUuids.size()); } }; } // namespace px::modrim::view diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp index 5164687..7908a03 100644 --- a/src/core/mods_config.cpp +++ b/src/core/mods_config.cpp @@ -30,20 +30,20 @@ constexpr auto kRootTag = "ModsConfigData"; constexpr auto kDefaultVersion = "1.5"; constexpr unsigned kParseFlags = pugi::parse_default | pugi::parse_comments | pugi::parse_pi; -std::filesystem::path to_fs(const QString& path) { return std::filesystem::path(path.toStdU16String()); } +std::filesystem::path toFs(const QString& path) { return std::filesystem::path(path.toStdU16String()); } -QString from_fs(const std::filesystem::path& path) { return QString::fromStdU16String(path.u16string()); } +QString fromFs(const std::filesystem::path& path) { return QString::fromStdU16String(path.u16string()); } // Packages listed in RIMWORLD_DLC_METADATA; used when a file declares no known expansions. -QList dlc_package_ids() { +QList dlcPackageIds() { QList ids; - for (const auto& id : RIMWORLD_PACKAGE_IDS) { + for (const auto& id : kRimworldPackageIds) { ids.append(CaseInsensitiveStr(id)); } return ids; } -std::optional parse_mods_config_bytes(std::string_view data, const QString& source) { +std::optional parseModsConfigBytes(std::string_view data, const QString& source) { pugi::xml_document doc; if (const auto result = doc.load_buffer(data.data(), data.size(), kParseFlags); !result) { spdlog::error("Failed to parse ModsConfig.xml at {}: {}", source.toStdString(), result.description()); @@ -51,19 +51,19 @@ std::optional parse_mods_config_bytes(std::string_view data, const Q } const pugi::xml_node root = doc.document_element(); - const pugi::xml_node version_node = root.child("version"); - const pugi::xml_node active_mods_node = root.child("activeMods"); - if (std::string_view(root.name()) != kRootTag || !version_node || !active_mods_node) { + const pugi::xml_node versionNode = root.child("version"); + const pugi::xml_node activeModsNode = root.child("activeMods"); + if (std::string_view(root.name()) != kRootTag || !versionNode || !activeModsNode) { spdlog::error("Invalid ModsConfig.xml structure at {}", source.toStdString()); return std::nullopt; } // lxml's `text or "1.5"`: only a missing or empty text falls back to the default. - const QString raw_version = rawText(version_node); + const QString rawVersion = rawText(versionNode); ModsConfig config; - config.version = raw_version.isEmpty() ? QString::fromLatin1(kDefaultVersion) : raw_version.trimmed(); + config.version = rawVersion.isEmpty() ? QString::fromLatin1(kDefaultVersion) : rawVersion.trimmed(); - for (const pugi::xml_node li : active_mods_node.children("li")) { + for (const pugi::xml_node li : activeModsNode.children("li")) { if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { config.activeMods.append(CaseInsensitiveStr(text)); } @@ -76,45 +76,45 @@ std::optional parse_mods_config_bytes(std::string_view data, const Q } } if (config.knownExpansions.isEmpty()) { - config.knownExpansions = dlc_package_ids(); + config.knownExpansions = dlcPackageIds(); } return config; } struct SnapshotFile { QString path; - std::int64_t mtime_ns = 0; + std::int64_t mtimeNs = 0; QString name; }; // Snapshots ordered by (mtime, name), newest first. Nanosecond mtimes match the // legacy ordering; names break ties because they embed the creation time. -QStringList snapshot_files(const QString& snapshots_dir) { +QStringList snapshotFiles(const QString& snapshotsDir) { std::vector found; std::error_code ec; - std::filesystem::directory_iterator it(to_fs(snapshots_dir), ec); + std::filesystem::directory_iterator it(toFs(snapshotsDir), ec); const std::filesystem::directory_iterator end; for (; !ec && it != end; it.increment(ec)) { const auto& entry = *it; - const QString name = from_fs(entry.path().filename()); - std::error_code entry_ec; + const QString name = fromFs(entry.path().filename()); + std::error_code entryEc; if (!QDir::match(QStringLiteral("ModsConfig_*.xml"), name) || - !std::filesystem::is_regular_file(entry.path(), entry_ec)) { + !std::filesystem::is_regular_file(entry.path(), entryEc)) { continue; } - const auto mtime = std::filesystem::last_write_time(entry.path(), entry_ec); - if (entry_ec) { + const auto mtime = std::filesystem::last_write_time(entry.path(), entryEc); + if (entryEc) { continue; } found.push_back(SnapshotFile{ - .path = from_fs(entry.path()), - .mtime_ns = std::chrono::duration_cast(mtime.time_since_epoch()).count(), + .path = fromFs(entry.path()), + .mtimeNs = std::chrono::duration_cast(mtime.time_since_epoch()).count(), .name = name, }); } std::ranges::sort(found, [](const SnapshotFile& a, const SnapshotFile& b) { - return std::tie(a.mtime_ns, a.name) > std::tie(b.mtime_ns, b.name); + return std::tie(a.mtimeNs, a.name) > std::tie(b.mtimeNs, b.name); }); QStringList paths; for (const auto& snapshot : found) { @@ -125,9 +125,9 @@ QStringList snapshot_files(const QString& snapshots_dir) { // Removes the oldest snapshots beyond `max_snapshots` (at least one is always kept), // never removing `preserved`. -void prune_snapshots(const QString& snapshots_dir, int max_snapshots, const QString& preserved = {}) { - const QStringList snapshots = get_snapshots(snapshots_dir); - qsizetype remaining = snapshots.size() - std::max(1, max_snapshots); +void pruneSnapshots(const QString& snapshotsDir, int maxSnapshots, const QString& preserved = {}) { + const QStringList snapshots = getSnapshots(snapshotsDir); + qsizetype remaining = snapshots.size() - std::max(1, maxSnapshots); for (auto it = snapshots.crbegin(); it != snapshots.crend() && remaining > 0; ++it) { if (!preserved.isEmpty() && QDir::cleanPath(*it) == QDir::cleanPath(preserved)) { continue; @@ -140,7 +140,7 @@ void prune_snapshots(const QString& snapshots_dir, int max_snapshots, const QStr } // UTC timestamp with microseconds, e.g. 20260922_100000_123456. -std::string snapshot_timestamp() { +std::string snapshotTimestamp() { using namespace std::chrono; const auto micros = duration_cast(system_clock::now().time_since_epoch()); const auto seconds = floor(micros); @@ -149,7 +149,7 @@ std::string snapshot_timestamp() { } // Replaces the children named `name` with one `li` per item. -void replace_list_items(pugi::xml_node root, const char* name, const QList& items) { +void replaceListItems(pugi::xml_node root, const char* name, const QList& items) { pugi::xml_node node = root.child(name); if (!node) { node = root.append_child(name); @@ -166,92 +166,92 @@ void replace_list_items(pugi::xml_node root, const char* name, const QList parse_mods_config(const QString& path) { - auto data = read_file_bytes(path); +std::optional parseModsConfig(const QString& path) { + auto data = readFileBytes(path); if (!data) { spdlog::warn("Failed to read ModsConfig.xml at {}: {}", path.toStdString(), data.error().message.toStdString()); return std::nullopt; } - return parse_mods_config_bytes(*data, path); + return parseModsConfigBytes(*data, path); } -QStringList get_snapshots(const QString& snapshots_dir) { - if (!QFileInfo::exists(snapshots_dir)) { +QStringList getSnapshots(const QString& snapshotsDir) { + if (!QFileInfo::exists(snapshotsDir)) { return {}; } - return snapshot_files(snapshots_dir); + return snapshotFiles(snapshotsDir); } -Result> create_snapshot(const QString& config_path, const QString& snapshots_dir, - int max_snapshots) { - if (!QFileInfo(config_path).isFile()) { +Result> createSnapshot(const QString& configPath, const QString& snapshotsDir, + int maxSnapshots) { + if (!QFileInfo(configPath).isFile()) { return std::optional{}; } - auto config_data = read_file_bytes(config_path); - if (!config_data) { - spdlog::warn("Failed to read {} for snapshot: {}", config_path.toStdString(), - config_data.error().message.toStdString()); + auto configData = readFileBytes(configPath); + if (!configData) { + spdlog::warn("Failed to read {} for snapshot: {}", configPath.toStdString(), + configData.error().message.toStdString()); return std::optional{}; } - const QStringList snapshots = get_snapshots(snapshots_dir); + const QStringList snapshots = getSnapshots(snapshotsDir); if (!snapshots.isEmpty()) { - if (auto newest = read_file_bytes(snapshots.first()); newest && *newest == *config_data) { - prune_snapshots(snapshots_dir, max_snapshots); + if (auto newest = readFileBytes(snapshots.first()); newest && *newest == *configData) { + pruneSnapshots(snapshotsDir, maxSnapshots); return std::optional(snapshots.first()); } } - const QString timestamp = QString::fromStdString(snapshot_timestamp()); - QString snapshot_path = QDir(snapshots_dir).filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); - for (int counter = 1; QFileInfo::exists(snapshot_path); ++counter) { - snapshot_path = QDir(snapshots_dir) + const QString timestamp = QString::fromStdString(snapshotTimestamp()); + QString snapshotPath = QDir(snapshotsDir).filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); + for (int counter = 1; QFileInfo::exists(snapshotPath); ++counter) { + snapshotPath = QDir(snapshotsDir) .filePath(QStringLiteral("ModsConfig_%1_%2.xml") .arg(timestamp) .arg(counter, 3, 10, QLatin1Char('0'))); } - if (auto written = write_file_atomically(snapshot_path, *config_data); !written) { + if (auto written = writeFileAtomically(snapshotPath, *configData); !written) { return std::unexpected(written.error()); } - prune_snapshots(snapshots_dir, max_snapshots); - spdlog::info("Snapshot created at {}", snapshot_path.toStdString()); - return std::optional(snapshot_path); + pruneSnapshots(snapshotsDir, maxSnapshots); + spdlog::info("Snapshot created at {}", snapshotPath.toStdString()); + return std::optional(snapshotPath); } -Result restore_snapshot(const QString& snapshot_path, const QString& target_path, - const QString& snapshots_dir, int max_snapshots) { - auto snapshot_data = read_file_bytes(snapshot_path); - if (!snapshot_data) { +Result restoreSnapshot(const QString& snapshotPath, const QString& targetPath, + const QString& snapshotsDir, int maxSnapshots) { + auto snapshotData = readFileBytes(snapshotPath); + if (!snapshotData) { return std::unexpected(Error{ - QStringLiteral("Failed to read snapshot %1: %2").arg(snapshot_path, snapshot_data.error().message)}); + QStringLiteral("Failed to read snapshot %1: %2").arg(snapshotPath, snapshotData.error().message)}); } - if (!parse_mods_config_bytes(*snapshot_data, snapshot_path)) { - return std::unexpected(Error{QStringLiteral("Snapshot is invalid or corrupted: %1").arg(snapshot_path)}); + if (!parseModsConfigBytes(*snapshotData, snapshotPath)) { + return std::unexpected(Error{QStringLiteral("Snapshot is invalid or corrupted: %1").arg(snapshotPath)}); } - if (QFileInfo::exists(target_path)) { - const int limit = std::max(1, max_snapshots); - if (auto kept = create_snapshot(target_path, snapshots_dir, limit + 1); !kept) { + if (QFileInfo::exists(targetPath)) { + const int limit = std::max(1, maxSnapshots); + if (auto kept = createSnapshot(targetPath, snapshotsDir, limit + 1); !kept) { return std::unexpected(kept.error()); } - prune_snapshots(snapshots_dir, limit, snapshot_path); + pruneSnapshots(snapshotsDir, limit, snapshotPath); } - if (auto written = write_file_atomically(target_path, *snapshot_data); !written) { + if (auto written = writeFileAtomically(targetPath, *snapshotData); !written) { return std::unexpected(written.error()); } - spdlog::info("Restored snapshot {} to {}", snapshot_path.toStdString(), target_path.toStdString()); + spdlog::info("Restored snapshot {} to {}", snapshotPath.toStdString(), targetPath.toStdString()); return {}; } -Result write_mods_config(const QString& path, const ModsConfig& data, const QString& snapshots_dir, - int max_snapshots) { +Result writeModsConfig(const QString& path, const ModsConfig& data, const QString& snapshotsDir, + int maxSnapshots) { // An existing ModsConfigData document is updated in place so unknown XML survives; // anything else starts a fresh document. pugi::xml_document doc; bool reusable = false; - if (auto existing = read_file_bytes(path); existing) { + if (auto existing = readFileBytes(path); existing) { reusable = doc.load_buffer(existing->data(), existing->size(), kParseFlags) && std::string_view(doc.document_element().name()) == kRootTag; } @@ -261,13 +261,13 @@ Result write_mods_config(const QString& path, const ModsConfig& data, cons } pugi::xml_node root = doc.document_element(); - pugi::xml_node version_node = root.child("version"); - if (!version_node) { - version_node = root.append_child("version"); + pugi::xml_node versionNode = root.child("version"); + if (!versionNode) { + versionNode = root.append_child("version"); } - version_node.text().set(data.version.toUtf8().constData()); - replace_list_items(root, "activeMods", data.activeMods); - replace_list_items(root, "knownExpansions", data.knownExpansions); + versionNode.text().set(data.version.toUtf8().constData()); + replaceListItems(root, "activeMods", data.activeMods); + replaceListItems(root, "knownExpansions", data.knownExpansions); pugi::xml_node declaration = doc.prepend_child(pugi::node_declaration); declaration.append_attribute("version") = "1.0"; @@ -277,11 +277,11 @@ Result write_mods_config(const QString& path, const ModsConfig& data, cons doc.save(out, " ", pugi::format_default, pugi::encoding_utf8); if (QFileInfo::exists(path)) { - if (auto kept = create_snapshot(path, snapshots_dir, max_snapshots); !kept) { + if (auto kept = createSnapshot(path, snapshotsDir, maxSnapshots); !kept) { return std::unexpected(kept.error()); } } - if (auto written = write_file_atomically(path, out.str()); !written) { + if (auto written = writeFileAtomically(path, out.str()); !written) { return std::unexpected(written.error()); } spdlog::info("ModsConfig.xml written to {}", path.toStdString()); diff --git a/src/core/mods_config.hpp b/src/core/mods_config.hpp index c49651e..7b62200 100644 --- a/src/core/mods_config.hpp +++ b/src/core/mods_config.hpp @@ -11,24 +11,24 @@ namespace px::modrim { // Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not a ModsConfig document. -std::optional parse_mods_config(const QString& path); +std::optional parseModsConfig(const QString& path); // Snapshot files in `snapshots_dir`, newest first. -QStringList get_snapshots(const QString& snapshots_dir); +QStringList getSnapshots(const QString& snapshotsDir); // Keeps a copy of `config_path` in `snapshots_dir`. Yields the snapshot that holds the // current contents (an existing identical one, or a new one), or nullopt when `config_path` // is not a readable file. -Result> create_snapshot(const QString& config_path, const QString& snapshots_dir, - int max_snapshots = 10); +Result> createSnapshot(const QString& configPath, const QString& snapshotsDir, + int maxSnapshots = 10); // Replaces `target_path` with a valid snapshot, keeping the replaced file as a snapshot. -Result restore_snapshot(const QString& snapshot_path, const QString& target_path, - const QString& snapshots_dir, int max_snapshots = 10); +Result restoreSnapshot(const QString& snapshotPath, const QString& targetPath, + const QString& snapshotsDir, int maxSnapshots = 10); // Writes `data` into `path`, keeping unknown XML in an existing ModsConfigData document. // The previous file is kept as a snapshot first. -Result write_mods_config(const QString& path, const ModsConfig& data, const QString& snapshots_dir, - int max_snapshots = 10); +Result writeModsConfig(const QString& path, const ModsConfig& data, const QString& snapshotsDir, + int maxSnapshots = 10); } // namespace px::modrim diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp index a7f7fd0..fe10adf 100644 --- a/src/core/organizer/db.cpp +++ b/src/core/organizer/db.cpp @@ -78,36 +78,36 @@ const QString kMoveSubtreeSql = QStringLiteral( ") " "UPDATE placements SET folder_id = ? WHERE folder_id IN (SELECT id FROM subtree)"); -Error error_of(const QSqlQuery& query) { +Error errorOf(const QSqlQuery& query) { return Error{query.lastError().text()}; } -Result run_statement(QSqlDatabase& db, const QString& sql, const QVariantList& params = {}) { +Result runStatement(QSqlDatabase& db, const QString& sql, const QVariantList& params = {}) { QSqlQuery query(db); if (!query.prepare(sql)) { - return std::unexpected(error_of(query)); + return std::unexpected(errorOf(query)); } for (qsizetype i = 0; i < params.size(); ++i) { query.bindValue(static_cast(i), params[i]); } if (!query.exec()) { - return std::unexpected(error_of(query)); + return std::unexpected(errorOf(query)); } return query; } // Prepares `sql` once and runs it for each row. Positional values are rebound every time. -Result run_batch(QSqlDatabase& db, const QString& sql, const QList& rows) { +Result runBatch(QSqlDatabase& db, const QString& sql, const QList& rows) { QSqlQuery query(db); if (!query.prepare(sql)) { - return std::unexpected(error_of(query)); + return std::unexpected(errorOf(query)); } for (const QVariantList& row : rows) { for (qsizetype i = 0; i < row.size(); ++i) { query.bindValue(static_cast(i), row[i]); } if (!query.exec()) { - return std::unexpected(error_of(query)); + return std::unexpected(errorOf(query)); } } return {}; @@ -115,8 +115,8 @@ Result run_batch(QSqlDatabase& db, const QString& sql, const QList -Result select_rows(QSqlDatabase& db, const QString& sql, const QVariantList& params, Visit&& visit) { - auto query = run_statement(db, sql, params); +Result selectRows(QSqlDatabase& db, const QString& sql, const QVariantList& params, Visit&& visit) { + auto query = runStatement(db, sql, params); if (!query) { return std::unexpected(query.error()); } @@ -124,14 +124,14 @@ Result select_rows(QSqlDatabase& db, const QString& sql, const QVariantLis visit(*query); } if (query->lastError().isValid()) { - return std::unexpected(error_of(*query)); + return std::unexpected(errorOf(*query)); } return {}; } // Runs `body` inside one transaction: commits when it succeeds, rolls back otherwise. template -auto in_transaction(QSqlDatabase& db, F&& body) { +auto inTransaction(QSqlDatabase& db, F&& body) { using R = std::invoke_result_t; if (!db.transaction()) { return R(std::unexpected(Error{db.lastError().text()})); @@ -150,9 +150,9 @@ auto in_transaction(QSqlDatabase& db, F&& body) { } // Runs an UPDATE or DELETE that must touch a row; `missing` is the error when none matched. -Result run_expecting_row(QSqlDatabase& db, const QString& sql, const QVariantList& params, +Result runExpectingRow(QSqlDatabase& db, const QString& sql, const QVariantList& params, const QString& missing) { - auto query = run_statement(db, sql, params); + auto query = runStatement(db, sql, params); if (!query) { return std::unexpected(query.error()); } @@ -162,23 +162,23 @@ Result run_expecting_row(QSqlDatabase& db, const QString& sql, const QVari return {}; } -QString folder_missing(qint64 folder_id) { - return QStringLiteral("Folder with id %1 does not exist.").arg(folder_id); +QString folderMissing(qint64 folderId) { + return QStringLiteral("Folder with id %1 does not exist.").arg(folderId); } -QString tag_missing(qint64 tag_id) { - return QStringLiteral("Tag with id %1 does not exist.").arg(tag_id); +QString tagMissing(qint64 tagId) { + return QStringLiteral("Tag with id %1 does not exist.").arg(tagId); } -Result load_state(QSqlDatabase& db) { +Result loadState(QSqlDatabase& db) { OrganizerState state; - auto folders = select_rows(db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), {}, + auto folders = selectRows(db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), {}, [&state](QSqlQuery& row) { Folder folder; folder.id = row.value(0).toLongLong(); if (!row.value(1).isNull()) { - folder.parent_id = row.value(1).toLongLong(); + folder.parentId = row.value(1).toLongLong(); } folder.name = row.value(2).toString(); folder.collapsed = row.value(3).toLongLong() != 0; @@ -188,7 +188,7 @@ Result load_state(QSqlDatabase& db) { return std::unexpected(folders.error()); } - auto placements = select_rows(db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, + auto placements = selectRows(db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, [&state](QSqlQuery& row) { state.placements[row.value(0).toString().toLower()] = row.value(1).toLongLong(); @@ -197,7 +197,7 @@ Result load_state(QSqlDatabase& db) { return std::unexpected(placements.error()); } - auto tags = select_rows(db, QStringLiteral("SELECT id, name, color FROM tags"), {}, [&state](QSqlQuery& row) { + auto tags = selectRows(db, QStringLiteral("SELECT id, name, color FROM tags"), {}, [&state](QSqlQuery& row) { const Tag tag{.id = row.value(0).toLongLong(), .name = row.value(1).toString(), .color = row.value(2).toString()}; @@ -207,25 +207,25 @@ Result load_state(QSqlDatabase& db) { return std::unexpected(tags.error()); } - auto mod_tags = select_rows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), {}, + auto modTags = selectRows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), {}, [&state](QSqlQuery& row) { - state.mod_tags[row.value(0).toString().toLower()].insert( + state.modTags[row.value(0).toString().toLower()].insert( row.value(1).toLongLong()); }); - if (!mod_tags) { - return std::unexpected(mod_tags.error()); + if (!modTags) { + return std::unexpected(modTags.error()); } - bool corrupt_rule = false; - auto rules = select_rows( + bool corruptRule = false; + auto rules = selectRows( db, QStringLiteral("SELECT id, position, field, op, pattern, folder_id FROM rules " "ORDER BY position ASC, id ASC"), - {}, [&state, &corrupt_rule](QSqlQuery& row) { - const auto field = parse_rule_field(row.value(2).toString()); - const auto op = parse_rule_op(row.value(3).toString()); + {}, [&state, &corruptRule](QSqlQuery& row) { + const auto field = parseRuleField(row.value(2).toString()); + const auto op = parseRuleOp(row.value(3).toString()); if (!field || !op) { - corrupt_rule = true; + corruptRule = true; return; } state.rules.append(Rule{.id = row.value(0).toLongLong(), @@ -233,88 +233,88 @@ Result load_state(QSqlDatabase& db) { .field = *field, .op = *op, .pattern = row.value(4).toString(), - .folder_id = row.value(5).toLongLong()}); + .folderId = row.value(5).toLongLong()}); }); if (!rules) { return std::unexpected(rules.error()); } - if (corrupt_rule) { + if (corruptRule) { return std::unexpected(Error{QStringLiteral("The organizer database holds an unknown rule field or operator.")}); } return state; } -Result insert_folder(QSqlDatabase& db, qint64 parent_id, const QString& name) { - auto inserted = run_statement(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), - {parent_id, name}); +Result insertFolder(QSqlDatabase& db, qint64 parentId, const QString& name) { + auto inserted = runStatement(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + {parentId, name}); if (!inserted) { return std::unexpected(inserted.error()); } return Folder{.id = inserted->lastInsertId().toLongLong(), - .parent_id = parent_id, + .parentId = parentId, .name = name, .collapsed = false}; } -Result upsert_placements(QSqlDatabase& db, const QStringList& package_ids, qint64 folder_id) { +Result upsertPlacements(QSqlDatabase& db, const QStringList& packageIds, qint64 folderId) { QList rows; - for (const QString& pid : package_ids) { - rows.append({pid, folder_id}); + for (const QString& pid : packageIds) { + rows.append({pid, folderId}); } - return run_batch(db, kPlaceSql, rows); + return runBatch(db, kPlaceSql, rows); } -Result insert_tag(QSqlDatabase& db, const QString& name, const QString& color) { - auto inserted = run_statement(db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), {name, color}); +Result insertTag(QSqlDatabase& db, const QString& name, const QString& color) { + auto inserted = runStatement(db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), {name, color}); if (!inserted) { return std::unexpected(inserted.error()); } return Tag{.id = inserted->lastInsertId().toLongLong(), .name = name, .color = color}; } -Result delete_folder_rows(QSqlDatabase& db, qint64 folder_id) { +Result deleteFolderRows(QSqlDatabase& db, qint64 folderId) { bool exists = false; - auto found = select_rows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), {folder_id}, + auto found = selectRows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), {folderId}, [&exists](QSqlQuery&) { exists = true; }); if (!found) { return std::unexpected(found.error()); } if (!exists) { - return std::unexpected(Error{folder_missing(folder_id)}); + return std::unexpected(Error{folderMissing(folderId)}); } - auto moved = run_statement(db, kMoveSubtreeSql, {folder_id, ROOT_ID}); + auto moved = runStatement(db, kMoveSubtreeSql, {folderId, kRootId}); if (!moved) { return std::unexpected(moved.error()); } - auto deleted = run_statement(db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folder_id}); + auto deleted = runStatement(db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folderId}); if (!deleted) { return std::unexpected(deleted.error()); } return {}; } -Result set_mod_tag_rows(QSqlDatabase& db, const QStringList& package_ids, const QSet& add, +Result setModTagRows(QSqlDatabase& db, const QStringList& packageIds, const QSet& add, const QSet& remove) { if (!remove.isEmpty()) { QList rows; - for (const QString& pid : package_ids) { - for (qint64 tag_id : remove) { - rows.append({pid, tag_id}); + for (const QString& pid : packageIds) { + for (qint64 tagId : remove) { + rows.append({pid, tagId}); } } - if (auto removed = run_batch(db, QStringLiteral("DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), rows); + if (auto removed = runBatch(db, QStringLiteral("DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), rows); !removed) { return removed; } } if (!add.isEmpty()) { QList rows; - for (const QString& pid : package_ids) { - for (qint64 tag_id : add) { - rows.append({pid, tag_id}); + for (const QString& pid : packageIds) { + for (qint64 tagId : add) { + rows.append({pid, tagId}); } } - if (auto added = run_batch(db, QStringLiteral("INSERT OR IGNORE INTO mod_tags (package_id, tag_id) VALUES (?, ?)"), rows); + if (auto added = runBatch(db, QStringLiteral("INSERT OR IGNORE INTO mod_tags (package_id, tag_id) VALUES (?, ?)"), rows); !added) { return added; } @@ -322,16 +322,16 @@ Result set_mod_tag_rows(QSqlDatabase& db, const QStringList& package_ids, return {}; } -Result> replace_rule_rows(QSqlDatabase& db, const QList& specs) { - if (auto deleted = run_statement(db, QStringLiteral("DELETE FROM rules")); !deleted) { +Result> replaceRuleRows(QSqlDatabase& db, const QList& specs) { + if (auto deleted = runStatement(db, QStringLiteral("DELETE FROM rules")); !deleted) { return std::unexpected(deleted.error()); } QList rules; for (qsizetype position = 0; position < specs.size(); ++position) { const RuleSpec& spec = specs[position]; - auto inserted = run_statement(db, kInsertRuleSql, - {static_cast(position), rule_field_name(spec.field).toString(), - rule_op_name(spec.op).toString(), spec.pattern, spec.folder_id}); + auto inserted = runStatement(db, kInsertRuleSql, + {static_cast(position), ruleFieldName(spec.field).toString(), + ruleOpName(spec.op).toString(), spec.pattern, spec.folderId}); if (!inserted) { return std::unexpected(inserted.error()); } @@ -340,67 +340,67 @@ Result> replace_rule_rows(QSqlDatabase& db, const QList& s .field = spec.field, .op = spec.op, .pattern = spec.pattern, - .folder_id = spec.folder_id}); + .folderId = spec.folderId}); } return rules; } -Result append_named_rule_rows(QSqlDatabase& db, qint64 parent_id, const QStringList& new_folders, +Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, const QStringList& newFolders, const QList& rules) { - QList folder_rows; - for (const QString& name : new_folders) { - folder_rows.append({parent_id, name}); + QList folderRows; + for (const QString& name : newFolders) { + folderRows.append({parentId, name}); } - if (auto created = run_batch(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), folder_rows); + if (auto created = runBatch(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), folderRows); !created) { return created; } - QHash folder_ids; - if (auto found = select_rows(db, QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), - {parent_id}, [&folder_ids](QSqlQuery& row) { - folder_ids[row.value(1).toString().toCaseFolded()] = row.value(0).toLongLong(); + QHash folderIds; + if (auto found = selectRows(db, QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), + {parentId}, [&folderIds](QSqlQuery& row) { + folderIds[row.value(1).toString().toCaseFolded()] = row.value(0).toLongLong(); }); !found) { return found; } qint64 start = 0; - if (auto next = select_rows(db, QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), {}, + if (auto next = selectRows(db, QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), {}, [&start](QSqlQuery& row) { start = row.value(0).toLongLong(); }); !next) { return next; } - QList rule_rows; + QList ruleRows; for (qsizetype offset = 0; offset < rules.size(); ++offset) { const StandardRule& rule = rules[offset]; - const auto folder = folder_ids.constFind(rule.folder.toCaseFolded()); - if (folder == folder_ids.cend()) { + const auto folder = folderIds.constFind(rule.folder.toCaseFolded()); + if (folder == folderIds.cend()) { return std::unexpected(Error{QStringLiteral("Folder \"%1\" does not exist.").arg(rule.folder)}); } - rule_rows.append({start + static_cast(offset), rule_field_name(rule.field).toString(), - rule_op_name(rule.op).toString(), rule.pattern, folder.value()}); + ruleRows.append({start + static_cast(offset), ruleFieldName(rule.field).toString(), + ruleOpName(rule.op).toString(), rule.pattern, folder.value()}); } - return run_batch(db, kInsertRuleSql, rule_rows); + return runBatch(db, kInsertRuleSql, ruleRows); } // Opens the connection named `name`, creating the file and schema on first use. Runs on the // worker thread only. -Result configure_connection(QSqlDatabase& db, const QString& path) { +Result configureConnection(QSqlDatabase& db, const QString& path) { if (!db.open()) { return std::unexpected(Error{db.lastError().text()}); } // WAL keeps readers from blocking the writer; the busy timeout absorbs brief contention. for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}) { - if (auto applied = run_statement(db, QString::fromLatin1(pragma)); !applied) { + if (auto applied = runStatement(db, QString::fromLatin1(pragma)); !applied) { return std::unexpected(applied.error()); } } - return ensure_schema(db, kSchema, kSchemaVersion, {}, path, QStringLiteral("organizer DB")); + return ensureSchema(db, kSchema, kSchemaVersion, {}, path, QStringLiteral("organizer DB")); } -Result open_connection(const QString& name, const QString& path) { +Result openConnection(const QString& name, const QString& path) { if (QSqlDatabase::contains(name)) { return QSqlDatabase::database(name, false); } @@ -409,7 +409,7 @@ Result open_connection(const QString& name, const QString& path) { } QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); db.setDatabaseName(path); - if (auto ready = configure_connection(db, path); !ready) { + if (auto ready = configureConnection(db, path); !ready) { // Drop the handle before removing the connection, so a retry can open it again. db.close(); db = QSqlDatabase(); @@ -419,7 +419,7 @@ Result open_connection(const QString& name, const QString& path) { return db; } -void discard_connection(const QString& name) { +void discardConnection(const QString& name) { if (!QSqlDatabase::contains(name)) { return; } @@ -432,27 +432,27 @@ void discard_connection(const QString& name) { } // namespace -QString db_path(const QString& config_dir) { - return QDir(config_dir).filePath(QStringLiteral("organizer.db")); +QString dbPath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("organizer.db")); } OrganizerDb::OrganizerDb(QString path) - : path_(std::move(path)), - connection_name_(QStringLiteral("organizer-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { - pool_.setMaxThreadCount(1); - pool_.setExpiryTimeout(-1); + : m_path(std::move(path)), + m_connectionName(QStringLiteral("organizer-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); } OrganizerDb::~OrganizerDb() { // Blocks until the worker has released the connection; the pool cannot stop before that. - QtConcurrent::run(&pool_, [name = connection_name_] { discard_connection(name); }).waitForFinished(); + QtConcurrent::run(&m_pool, [name = m_connectionName] { discardConnection(name); }).waitForFinished(); } template auto OrganizerDb::submit(F fn) { using R = std::invoke_result_t; - return QtConcurrent::run(&pool_, [name = connection_name_, path = path_, fn = std::move(fn)]() -> R { - auto db = open_connection(name, path); + return QtConcurrent::run(&m_pool, [name = m_connectionName, path = m_path, fn = std::move(fn)]() -> R { + auto db = openConnection(name, path); if (!db) { return std::unexpected(db.error()); } @@ -461,24 +461,24 @@ auto OrganizerDb::submit(F fn) { } QCoro::Task> OrganizerDb::load() { - co_return co_await submit([](QSqlDatabase& db) -> Result { return load_state(db); }); + co_return co_await submit([](QSqlDatabase& db) -> Result { return loadState(db); }); } -QCoro::Task> OrganizerDb::create_folder(qint64 parent_id, QString name) { - co_return co_await submit([parent_id, name](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { return insert_folder(db, parent_id, name); }); +QCoro::Task> OrganizerDb::createFolder(qint64 parentId, QString name) { + co_return co_await submit([parentId, name](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { return insertFolder(db, parentId, name); }); }); } -QCoro::Task> OrganizerDb::create_folder_with(qint64 parent_id, QString name, - QStringList package_ids) { - co_return co_await submit([parent_id, name, pids = clean_package_ids(package_ids)](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - auto folder = insert_folder(db, parent_id, name); +QCoro::Task> OrganizerDb::createFolderWith(qint64 parentId, QString name, + QStringList packageIds) { + co_return co_await submit([parentId, name, pids = cleanPackageIds(packageIds)](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + auto folder = insertFolder(db, parentId, name); if (!folder) { return folder; } - if (auto placed = upsert_placements(db, pids, folder->id); !placed) { + if (auto placed = upsertPlacements(db, pids, folder->id); !placed) { return std::unexpected(placed.error()); } return folder; @@ -486,46 +486,46 @@ QCoro::Task> OrganizerDb::create_folder_with(qint64 parent_id, QS }); } -QCoro::Task> OrganizerDb::rename_folder(qint64 folder_id, QString name) { - if (folder_id == ROOT_ID) { +QCoro::Task> OrganizerDb::renameFolder(qint64 folderId, QString name) { + if (folderId == kRootId) { co_return std::unexpected(Error{QStringLiteral("The root folder cannot be renamed.")}); } - co_return co_await submit([folder_id, name](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return run_expecting_row(db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), - {name, folder_id}, folder_missing(folder_id)); + co_return co_await submit([folderId, name](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), + {name, folderId}, folderMissing(folderId)); }); }); } -QCoro::Task> OrganizerDb::set_parent(qint64 folder_id, qint64 parent_id) { - if (folder_id == ROOT_ID) { +QCoro::Task> OrganizerDb::setParent(qint64 folderId, qint64 parentId) { + if (folderId == kRootId) { co_return std::unexpected(Error{QStringLiteral("The root folder cannot be moved.")}); } - if (folder_id == parent_id) { + if (folderId == parentId) { co_return std::unexpected(Error{QStringLiteral("A folder cannot be its own parent.")}); } - co_return co_await submit([folder_id, parent_id](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return run_expecting_row(db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), - {parent_id, folder_id}, folder_missing(folder_id)); + co_return co_await submit([folderId, parentId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), + {parentId, folderId}, folderMissing(folderId)); }); }); } -QCoro::Task> OrganizerDb::set_collapsed(qint64 folder_id, bool collapsed) { - co_return co_await submit([folder_id, collapsed](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return run_expecting_row(db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), - {collapsed ? 1 : 0, folder_id}, folder_missing(folder_id)); +QCoro::Task> OrganizerDb::setCollapsed(qint64 folderId, bool collapsed) { + co_return co_await submit([folderId, collapsed](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), + {collapsed ? 1 : 0, folderId}, folderMissing(folderId)); }); }); } -QCoro::Task> OrganizerDb::set_all_collapsed(bool collapsed) { +QCoro::Task> OrganizerDb::setAllCollapsed(bool collapsed) { co_return co_await submit([collapsed](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - auto updated = run_statement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), + return inTransaction(db, [&]() -> Result { + auto updated = runStatement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), {collapsed ? 1 : 0}); if (!updated) { return std::unexpected(updated.error()); @@ -535,93 +535,93 @@ QCoro::Task> OrganizerDb::set_all_collapsed(bool collapsed) { }); } -QCoro::Task> OrganizerDb::delete_folder(qint64 folder_id) { - if (folder_id == ROOT_ID) { +QCoro::Task> OrganizerDb::deleteFolder(qint64 folderId) { + if (folderId == kRootId) { co_return std::unexpected(Error{QStringLiteral("The root folder cannot be deleted.")}); } - co_return co_await submit([folder_id](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { return delete_folder_rows(db, folder_id); }); + co_return co_await submit([folderId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { return deleteFolderRows(db, folderId); }); }); } -QCoro::Task> OrganizerDb::place(QStringList package_ids, qint64 folder_id) { - const QStringList pids = clean_package_ids(package_ids); +QCoro::Task> OrganizerDb::place(QStringList packageIds, qint64 folderId) { + const QStringList pids = cleanPackageIds(packageIds); if (pids.isEmpty()) { co_return {}; } - co_return co_await submit([pids, folder_id](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { return upsert_placements(db, pids, folder_id); }); + co_return co_await submit([pids, folderId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { return upsertPlacements(db, pids, folderId); }); }); } -QCoro::Task> OrganizerDb::unplace(QStringList package_ids) { - const QStringList pids = clean_package_ids(package_ids); +QCoro::Task> OrganizerDb::unplace(QStringList packageIds) { + const QStringList pids = cleanPackageIds(packageIds); if (pids.isEmpty()) { co_return {}; } co_return co_await submit([pids](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { + return inTransaction(db, [&]() -> Result { QList rows; for (const QString& pid : pids) { rows.append({pid}); } - return run_batch(db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), rows); + return runBatch(db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), rows); }); }); } -QCoro::Task> OrganizerDb::create_tag(QString name, QString color) { +QCoro::Task> OrganizerDb::createTag(QString name, QString color) { co_return co_await submit([name, color](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { return insert_tag(db, name, color); }); + return inTransaction(db, [&]() -> Result { return insertTag(db, name, color); }); }); } -QCoro::Task> OrganizerDb::update_tag(qint64 tag_id, QString name, QString color) { - co_return co_await submit([tag_id, name, color](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return run_expecting_row(db, QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), - {name, color, tag_id}, tag_missing(tag_id)); +QCoro::Task> OrganizerDb::updateTag(qint64 tagId, QString name, QString color) { + co_return co_await submit([tagId, name, color](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), + {name, color, tagId}, tagMissing(tagId)); }); }); } -QCoro::Task> OrganizerDb::delete_tag(qint64 tag_id) { - co_return co_await submit([tag_id](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return run_expecting_row(db, QStringLiteral("DELETE FROM tags WHERE id = ?"), {tag_id}, - tag_missing(tag_id)); +QCoro::Task> OrganizerDb::deleteTag(qint64 tagId) { + co_return co_await submit([tagId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, QStringLiteral("DELETE FROM tags WHERE id = ?"), {tagId}, + tagMissing(tagId)); }); }); } -QCoro::Task> OrganizerDb::set_mod_tags(QStringList package_ids, QSet add, +QCoro::Task> OrganizerDb::setModTags(QStringList packageIds, QSet add, QSet remove) { - const QStringList pids = clean_package_ids(package_ids); + const QStringList pids = cleanPackageIds(packageIds); if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { co_return {}; } co_return co_await submit([pids, add, remove](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { return set_mod_tag_rows(db, pids, add, remove); }); + return inTransaction(db, [&]() -> Result { return setModTagRows(db, pids, add, remove); }); }); } -QCoro::Task>> OrganizerDb::replace_rules(QList specs) { +QCoro::Task>> OrganizerDb::replaceRules(QList specs) { co_return co_await submit([specs](QSqlDatabase& db) -> Result> { - return in_transaction(db, [&]() -> Result> { return replace_rule_rows(db, specs); }); + return inTransaction(db, [&]() -> Result> { return replaceRuleRows(db, specs); }); }); } -QCoro::Task> OrganizerDb::append_named_rules(qint64 parent_id, QStringList new_folders, +QCoro::Task> OrganizerDb::appendNamedRules(qint64 parentId, QStringList newFolders, QList rules) { - co_return co_await submit([parent_id, new_folders, rules](QSqlDatabase& db) -> Result { - return in_transaction(db, [&]() -> Result { - return append_named_rule_rows(db, parent_id, new_folders, rules); + co_return co_await submit([parentId, newFolders, rules](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return appendNamedRuleRows(db, parentId, newFolders, rules); }); }); } QCoro::Task OrganizerDb::close() { - co_await QtConcurrent::run(&pool_, [name = connection_name_] { discard_connection(name); }); + co_await QtConcurrent::run(&m_pool, [name = m_connectionName] { discardConnection(name); }); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/db.hpp b/src/core/organizer/db.hpp index aa49568..cc99b53 100644 --- a/src/core/organizer/db.hpp +++ b/src/core/organizer/db.hpp @@ -13,7 +13,7 @@ namespace px::modrim::organizer { // The organizer database file inside the config directory. -QString db_path(const QString& config_dir); +QString dbPath(const QString& configDir); // SQLite store for the organizer. Every operation runs on one worker thread that owns the // connection, so operations are serialized in submission order and each is atomic. @@ -28,28 +28,28 @@ class OrganizerDb { QCoro::Task> load(); - QCoro::Task> create_folder(qint64 parent_id, QString name); - QCoro::Task> create_folder_with(qint64 parent_id, QString name, - QStringList package_ids); - QCoro::Task> rename_folder(qint64 folder_id, QString name); - QCoro::Task> set_parent(qint64 folder_id, qint64 parent_id); - QCoro::Task> set_collapsed(qint64 folder_id, bool collapsed); - QCoro::Task> set_all_collapsed(bool collapsed); - QCoro::Task> delete_folder(qint64 folder_id); + QCoro::Task> createFolder(qint64 parentId, QString name); + QCoro::Task> createFolderWith(qint64 parentId, QString name, + QStringList packageIds); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> setParent(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); - QCoro::Task> place(QStringList package_ids, qint64 folder_id); - QCoro::Task> unplace(QStringList package_ids); + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> unplace(QStringList packageIds); - QCoro::Task> create_tag(QString name, QString color); - QCoro::Task> update_tag(qint64 tag_id, QString name, QString color); - QCoro::Task> delete_tag(qint64 tag_id); - QCoro::Task> set_mod_tags(QStringList package_ids, QSet add, + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, QSet remove); - QCoro::Task>> replace_rules(QList specs); + QCoro::Task>> replaceRules(QList specs); // Creates `new_folders` under `parent_id`, then appends `rules` after the existing ones, // resolving each rule's folder by name among the children of `parent_id`. - QCoro::Task> append_named_rules(qint64 parent_id, QStringList new_folders, + QCoro::Task> appendNamedRules(qint64 parentId, QStringList newFolders, QList rules); QCoro::Task close(); @@ -59,10 +59,10 @@ class OrganizerDb { template auto submit(F fn); - QString path_; - QString connection_name_; + QString m_path; + QString m_connectionName; // One thread, never expiring: the connection stays on the thread that created it. - QThreadPool pool_; + QThreadPool m_pool; }; } // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp index 9703cc3..08f1f15 100644 --- a/src/core/organizer/defaults.cpp +++ b/src/core/organizer/defaults.cpp @@ -15,7 +15,7 @@ namespace { QList exact(const QString& folder, std::initializer_list ids) { QList rules; for (const char* id : ids) { - rules.append({folder, RuleField::PackageId, RuleOp::Equals, QString::fromLatin1(id)}); + rules.append({folder, RuleField::kPackageId, RuleOp::kEquals, QString::fromLatin1(id)}); } return rules; } @@ -23,12 +23,12 @@ QList exact(const QString& folder, std::initializer_list prefixes(const QString& folder, std::initializer_list prefixes) { QList rules; for (const char* prefix : prefixes) { - rules.append({folder, RuleField::PackageId, RuleOp::Prefix, QString::fromLatin1(prefix)}); + rules.append({folder, RuleField::kPackageId, RuleOp::kPrefix, QString::fromLatin1(prefix)}); } return rules; } -QList build_standard_rules() { +QList buildStandardRules() { QList rules; const auto append = [&rules](const QList& more) { rules.append(more); }; const auto add = [&rules](StandardRule rule) { rules.append(std::move(rule)); }; @@ -68,18 +68,18 @@ QList build_standard_rules() { "vanillaquestsexpanded.", "vanillastorytellersexpanded.", })); - add({"Vanilla Expanded", RuleField::Author, RuleOp::Contains, "oskar potocki"}); + add({"Vanilla Expanded", RuleField::kAuthor, RuleOp::kContains, "oskar potocki"}); append(prefixes("Alpha Mods", {"sarg."})); append(prefixes("Combat Extended", {"ceteam."})); - add({"Combat Extended", RuleField::Name, RuleOp::Contains, "combat extended"}); + add({"Combat Extended", RuleField::kName, RuleOp::kContains, "combat extended"}); append(prefixes("Dubs Mods", {"dubwise."})); return rules; } } // namespace -const QList& standard_rules() { - static const QList rules = build_standard_rules(); +const QList& standardRules() { + static const QList rules = buildStandardRules(); return rules; } diff --git a/src/core/organizer/defaults.hpp b/src/core/organizer/defaults.hpp index 13def2f..2aa0848 100644 --- a/src/core/organizer/defaults.hpp +++ b/src/core/organizer/defaults.hpp @@ -10,12 +10,12 @@ namespace px::modrim::organizer { // A built-in auto-folder rule targeting a top-level folder by name. struct StandardRule { QString folder; - RuleField field = RuleField::PackageId; - RuleOp op = RuleOp::Equals; + RuleField field = RuleField::kPackageId; + RuleOp op = RuleOp::kEquals; QString pattern; }; // Built-in rules in priority order: the first match wins. -const QList& standard_rules(); +const QList& standardRules(); } // namespace px::modrim::organizer diff --git a/src/core/organizer/models.hpp b/src/core/organizer/models.hpp index 82b2092..6f4083b 100644 --- a/src/core/organizer/models.hpp +++ b/src/core/organizer/models.hpp @@ -12,48 +12,48 @@ namespace px::modrim::organizer { -inline constexpr qint64 ROOT_ID = 1; -inline constexpr int MAX_DEPTH = 3; +inline constexpr qint64 kRootId = 1; +inline constexpr int kMaxDepth = 3; // Names match the SQL values stored in the rules table's CHECK constraints. -enum class RuleField { PackageId, Name, Author }; -enum class RuleOp { Prefix, Contains, Equals }; +enum class RuleField { kPackageId, kName, kAuthor }; +enum class RuleOp { kPrefix, kContains, kEquals }; -inline QLatin1StringView rule_field_name(RuleField field) { +inline QLatin1StringView ruleFieldName(RuleField field) { switch (field) { - case RuleField::PackageId: return QLatin1StringView("package_id"); - case RuleField::Name: return QLatin1StringView("name"); - case RuleField::Author: return QLatin1StringView("author"); + case RuleField::kPackageId: return QLatin1StringView("package_id"); + case RuleField::kName: return QLatin1StringView("name"); + case RuleField::kAuthor: return QLatin1StringView("author"); } return {}; } -inline QLatin1StringView rule_op_name(RuleOp op) { +inline QLatin1StringView ruleOpName(RuleOp op) { switch (op) { - case RuleOp::Prefix: return QLatin1StringView("prefix"); - case RuleOp::Contains: return QLatin1StringView("contains"); - case RuleOp::Equals: return QLatin1StringView("equals"); + case RuleOp::kPrefix: return QLatin1StringView("prefix"); + case RuleOp::kContains: return QLatin1StringView("contains"); + case RuleOp::kEquals: return QLatin1StringView("equals"); } return {}; } -inline std::optional parse_rule_field(const QString& name) { - if (name == QLatin1StringView("package_id")) return RuleField::PackageId; - if (name == QLatin1StringView("name")) return RuleField::Name; - if (name == QLatin1StringView("author")) return RuleField::Author; +inline std::optional parseRuleField(const QString& name) { + if (name == QLatin1StringView("package_id")) return RuleField::kPackageId; + if (name == QLatin1StringView("name")) return RuleField::kName; + if (name == QLatin1StringView("author")) return RuleField::kAuthor; return std::nullopt; } -inline std::optional parse_rule_op(const QString& name) { - if (name == QLatin1StringView("prefix")) return RuleOp::Prefix; - if (name == QLatin1StringView("contains")) return RuleOp::Contains; - if (name == QLatin1StringView("equals")) return RuleOp::Equals; +inline std::optional parseRuleOp(const QString& name) { + if (name == QLatin1StringView("prefix")) return RuleOp::kPrefix; + if (name == QLatin1StringView("contains")) return RuleOp::kContains; + if (name == QLatin1StringView("equals")) return RuleOp::kEquals; return std::nullopt; } struct Folder { qint64 id = 0; - std::optional parent_id; // empty only for ROOT_ID + std::optional parentId; // empty only for ROOT_ID QString name; bool collapsed = false; @@ -69,10 +69,10 @@ struct Tag { }; struct RuleSpec { - RuleField field = RuleField::Name; - RuleOp op = RuleOp::Equals; + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; QString pattern; - qint64 folder_id = 0; + qint64 folderId = 0; friend bool operator==(const RuleSpec&, const RuleSpec&) = default; }; @@ -80,10 +80,10 @@ struct RuleSpec { struct Rule { qint64 id = 0; qint64 position = 0; - RuleField field = RuleField::Name; - RuleOp op = RuleOp::Equals; + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; QString pattern; - qint64 folder_id = 0; + qint64 folderId = 0; friend bool operator==(const Rule&, const Rule&) = default; }; @@ -94,17 +94,17 @@ struct OrganizerState { QHash folders; QHash placements; QHash tags; - QHash> mod_tags; + QHash> modTags; QList rules; friend bool operator==(const OrganizerState&, const OrganizerState&) = default; }; // Lowercased, trimmed, de-duplicated package ids in first-seen order; blanks are dropped. -inline QStringList clean_package_ids(const QStringList& package_ids) { +inline QStringList cleanPackageIds(const QStringList& packageIds) { QStringList cleaned; QSet seen; - for (const QString& raw : package_ids) { + for (const QString& raw : packageIds) { const QString pid = raw.trimmed().toLower(); if (!pid.isEmpty() && !seen.contains(pid)) { seen.insert(pid); diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp index 5348814..106ac4d 100644 --- a/src/core/organizer/resolve.cpp +++ b/src/core/organizer/resolve.cpp @@ -10,35 +10,35 @@ namespace px::modrim::organizer { namespace { struct Matcher { - RuleOp op = RuleOp::Equals; + RuleOp op = RuleOp::kEquals; QString pattern; // casefolded bool matches(const QString& value) const { switch (op) { - case RuleOp::Prefix: return value.startsWith(pattern); - case RuleOp::Contains: return value.contains(pattern); - case RuleOp::Equals: return value == pattern; + case RuleOp::kPrefix: return value.startsWith(pattern); + case RuleOp::kContains: return value.contains(pattern); + case RuleOp::kEquals: return value == pattern; } return false; } }; -Matcher make_matcher(RuleOp op, const QString& pattern) { +Matcher makeMatcher(RuleOp op, const QString& pattern) { return Matcher{op, pattern.toCaseFolded()}; } struct FieldRule { - RuleField field = RuleField::Name; + RuleField field = RuleField::kName; Matcher matcher; - qint64 folder_id = ROOT_ID; + qint64 folderId = kRootId; }; // The casefolded value a rule field is matched against. -QString field_value(RuleField field, const QString& pid, const QString& name, const QString& author) { +QString fieldValue(RuleField field, const QString& pid, const QString& name, const QString& author) { switch (field) { - case RuleField::PackageId: return pid.toCaseFolded(); - case RuleField::Name: return name.toCaseFolded(); - case RuleField::Author: return author.toCaseFolded(); + case RuleField::kPackageId: return pid.toCaseFolded(); + case RuleField::kName: return name.toCaseFolded(); + case RuleField::kAuthor: return author.toCaseFolded(); } return {}; } @@ -46,48 +46,48 @@ QString field_value(RuleField field, const QString& pid, const QString& name, co // Manual placements win over rules; the first matching rule by position wins. class Resolver { public: - explicit Resolver(const OrganizerState& state) : placements_(state.placements) { + explicit Resolver(const OrganizerState& state) : m_placements(state.placements) { for (const Rule& rule : state.rules) { - if (state.folders.contains(rule.folder_id) && rule.folder_id != ROOT_ID) { - rules_.append(FieldRule{rule.field, make_matcher(rule.op, rule.pattern), rule.folder_id}); + if (state.folders.contains(rule.folderId) && rule.folderId != kRootId) { + m_rules.append(FieldRule{rule.field, makeMatcher(rule.op, rule.pattern), rule.folderId}); } } } - Resolution resolve(const std::optional& package_id, const QString& name, + Resolution resolve(const std::optional& packageId, const QString& name, const QString& author) const { - if (!package_id) { - return {ROOT_ID, Placement::None}; + if (!packageId) { + return {kRootId, Placement::kNone}; } - const QString pid = package_id->toLower(); - if (const auto manual = placements_.constFind(pid); manual != placements_.cend()) { - return {manual.value(), Placement::Manual}; + const QString pid = packageId->toLower(); + if (const auto manual = m_placements.constFind(pid); manual != m_placements.cend()) { + return {manual.value(), Placement::kManual}; } - for (const FieldRule& rule : rules_) { - if (rule.matcher.matches(field_value(rule.field, pid, name, author))) { - return {rule.folder_id, Placement::Rule}; + for (const FieldRule& rule : m_rules) { + if (rule.matcher.matches(fieldValue(rule.field, pid, name, author))) { + return {rule.folderId, Placement::kRule}; } } - return {ROOT_ID, Placement::None}; + return {kRootId, Placement::kNone}; } private: - const QHash& placements_; - QList rules_; + const QHash& m_placements; + QList m_rules; }; -bool matches_query(const ModLeaf& leaf, const QString& provider_id, const TreeQuery& query) { - if (query.status == StatusFilter::Active && !leaf.enabled) { +bool matchesQuery(const ModLeaf& leaf, const QString& providerId, const TreeQuery& query) { + if (query.status == StatusFilter::kActive && !leaf.enabled) { return false; } - if (query.status == StatusFilter::Inactive && leaf.enabled) { + if (query.status == StatusFilter::kInactive && leaf.enabled) { return false; } - if (!query.provider_ids.isEmpty() && !query.provider_ids.contains(provider_id)) { + if (!query.providerIds.isEmpty() && !query.providerIds.contains(providerId)) { return false; } - for (qint64 tag_id : query.tag_ids) { - if (!leaf.tag_ids.contains(tag_id)) { + for (qint64 tagId : query.tagIds) { + if (!leaf.tagIds.contains(tagId)) { return false; } } @@ -96,26 +96,26 @@ bool matches_query(const ModLeaf& leaf, const QString& provider_id, const TreeQu return true; } return leaf.name.trimmed().toCaseFolded().contains(text) - || (leaf.package_id && leaf.package_id->contains(text)) + || (leaf.packageId && leaf.packageId->contains(text)) || leaf.author.toCaseFolded().contains(text); } // A folder with no mods counts as off, not on: "on" needs at least one enabled mod. -CheckState check_state(int total, int enabled) { +CheckState checkState(int total, int enabled) { if (total != 0 && enabled == total) { - return CheckState::On; + return CheckState::kOn; } if (enabled == 0) { - return CheckState::Off; + return CheckState::kOff; } - return CheckState::Partial; + return CheckState::kPartial; } -bool mod_less(const ModLeaf& a, const ModLeaf& b) { +bool modLess(const ModLeaf& a, const ModLeaf& b) { return std::pair(a.name.toCaseFolded(), a.uuid) < std::pair(b.name.toCaseFolded(), b.uuid); } -bool folder_less(const Folder& a, const Folder& b) { +bool folderLess(const Folder& a, const Folder& b) { return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); } @@ -123,9 +123,9 @@ bool folder_less(const Folder& a, const Folder& b) { // reachable twice is attached once. struct TreeBuilder { const OrganizerState& state; - const QHash>& children_of; - const QHash>& all_by_folder; - const QHash>& visible_by_folder; + const QHash>& childrenOf; + const QHash>& allByFolder; + const QHash>& visibleByFolder; const QSet& ruled; bool filtering; QSet visited; @@ -135,10 +135,10 @@ struct TreeBuilder { FolderNode node; node.folder = folder; node.depth = depth; - node.has_rules = ruled.contains(folder.id); + node.hasRules = ruled.contains(folder.id); - QList children = children_of.value(folder.id); - std::sort(children.begin(), children.end(), folder_less); + QList children = childrenOf.value(folder.id); + std::sort(children.begin(), children.end(), folderLess); for (const Folder& child : children) { if (visited.contains(child.id)) { continue; @@ -146,25 +146,25 @@ struct TreeBuilder { FolderNode sub = build(child, depth + 1); node.total += sub.total; node.enabled += sub.enabled; - node.visible_total += sub.visible_total; - if (!filtering || sub.visible_total != 0) { + node.visibleTotal += sub.visibleTotal; + if (!filtering || sub.visibleTotal != 0) { node.children.push_back(std::move(sub)); } } - const QList own = all_by_folder.value(folder.id); - QList visible = visible_by_folder.value(folder.id); - std::sort(visible.begin(), visible.end(), mod_less); - int own_enabled = 0; + const QList own = allByFolder.value(folder.id); + QList visible = visibleByFolder.value(folder.id); + std::sort(visible.begin(), visible.end(), modLess); + int ownEnabled = 0; for (const ModLeaf& leaf : own) { - own_enabled += leaf.enabled ? 1 : 0; + ownEnabled += leaf.enabled ? 1 : 0; } - node.own_total = static_cast(own.size()); - node.own_enabled = own_enabled; - node.total += node.own_total; - node.enabled += own_enabled; - node.visible_total += static_cast(visible.size()); - node.check = check_state(node.total, node.enabled); + node.ownTotal = static_cast(own.size()); + node.ownEnabled = ownEnabled; + node.total += node.ownTotal; + node.enabled += ownEnabled; + node.visibleTotal += static_cast(visible.size()); + node.check = checkState(node.total, node.enabled); node.mods = std::move(visible); return node; } @@ -172,25 +172,25 @@ struct TreeBuilder { } // namespace -bool TreeQuery::is_empty() const { - return text.trimmed().isEmpty() && tag_ids.isEmpty() && status == StatusFilter::All && provider_ids.isEmpty(); +bool TreeQuery::isEmpty() const { + return text.trimmed().isEmpty() && tagIds.isEmpty() && status == StatusFilter::kAll && providerIds.isEmpty(); } TreeQuery TreeFilter::query(const QString& text) const { TreeQuery query; query.text = text; query.status = status; - if (provider_id) { - query.provider_ids.insert(*provider_id); + if (providerId) { + query.providerIds.insert(*providerId); } - if (tag_id) { - query.tag_ids.insert(*tag_id); + if (tagId) { + query.tagIds.insert(*tagId); } return query; } -CheckState FolderNode::own_check() const { - return check_state(own_total, own_enabled); +CheckState FolderNode::ownCheck() const { + return checkState(ownTotal, ownEnabled); } QList FolderNode::walk() const { @@ -201,34 +201,34 @@ QList FolderNode::walk() const { return nodes; } -QList FolderNode::all_mods() const { - QList mods_out; +QList FolderNode::allMods() const { + QList modsOut; for (const FolderNode* node : walk()) { - mods_out += node->mods; + modsOut += node->mods; } - return mods_out; + return modsOut; } -std::optional mod_package_id(const ListedMod& mod) { +std::optional modPackageId(const ListedMod& mod) { if (const auto* about = dynamic_cast(&mod)) { - return about->package_id.str(); + return about->packageId.str(); } return std::nullopt; } -QString mod_author(const ListedMod& mod) { +QString modAuthor(const ListedMod& mod) { if (const auto* about = dynamic_cast(&mod)) { return about->authors.join(QStringLiteral(", ")); } return {}; } -Resolution folder_for(const OrganizerState& state, const std::optional& package_id, +Resolution folderFor(const OrganizerState& state, const std::optional& packageId, const QString& name, const QString& author) { - return Resolver(state).resolve(package_id, name, author); + return Resolver(state).resolve(packageId, name, author); } -RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& mods, +RulePreview previewRuleMatches(const OrganizerState& state, const ModsByUuid& mods, const QList& specs) { const Resolver current(state); // A spec with a blank pattern, an unknown folder or the root matches nothing. The @@ -236,28 +236,28 @@ RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& std::vector> candidates; candidates.reserve(static_cast(specs.size())); for (const RuleSpec& spec : specs) { - const bool usable = !spec.pattern.trimmed().isEmpty() && state.folders.contains(spec.folder_id) && - spec.folder_id != ROOT_ID; - candidates.push_back(usable ? std::optional(FieldRule{spec.field, make_matcher(spec.op, spec.pattern), - spec.folder_id}) + const bool usable = !spec.pattern.trimmed().isEmpty() && state.folders.contains(spec.folderId) && + spec.folderId != kRootId; + candidates.push_back(usable ? std::optional(FieldRule{spec.field, makeMatcher(spec.op, spec.pattern), + spec.folderId}) : std::nullopt); } RulePreview preview; preview.counts.assign(specs.size(), 0); for (const ListedModPtr& mod : mods) { - const auto pid = mod_package_id(*mod); + const auto pid = modPackageId(*mod); if (!pid) { continue; } - const QString author = mod_author(*mod); + const QString author = modAuthor(*mod); for (qsizetype index = 0; index < specs.size(); ++index) { const std::optional& candidate = candidates[static_cast(index)]; - if (!candidate || !candidate->matcher.matches(field_value(candidate->field, *pid, mod->name, author))) { + if (!candidate || !candidate->matcher.matches(fieldValue(candidate->field, *pid, mod->name, author))) { continue; } preview.counts[index] += 1; - if (!state.placements.contains(*pid) && current.resolve(pid, mod->name, author).folder_id == ROOT_ID) { + if (!state.placements.contains(*pid) && current.resolve(pid, mod->name, author).folderId == kRootId) { preview.assignable += 1; } break; @@ -266,14 +266,14 @@ RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& return preview; } -QList tree_filters(const ModsByUuid& mods, const QSet& active, +QList treeFilters(const ModsByUuid& mods, const QSet& active, const OrganizerState* state) { const int total = static_cast(mods.size()); int enabled = 0; - QHash per_provider; + QHash perProvider; for (auto it = mods.cbegin(); it != mods.cend(); ++it) { enabled += active.contains(it.key()) ? 1 : 0; - per_provider[it.value()->provider_id] += 1; + perProvider[it.value()->providerId] += 1; } const auto make = [](QString key, QString label, int count) { @@ -284,101 +284,101 @@ QList tree_filters(const ModsByUuid& mods, const QSet& acti return filter; }; - TreeFilter status_active = make(QStringLiteral("active"), QStringLiteral("Active"), enabled); - status_active.status = StatusFilter::Active; - TreeFilter status_inactive = make(QStringLiteral("inactive"), QStringLiteral("Inactive"), total - enabled); - status_inactive.status = StatusFilter::Inactive; - QList filters{make(QStringLiteral("all"), QStringLiteral("All"), total), std::move(status_active), - std::move(status_inactive)}; + TreeFilter statusActive = make(QStringLiteral("active"), QStringLiteral("Active"), enabled); + statusActive.status = StatusFilter::kActive; + TreeFilter statusInactive = make(QStringLiteral("inactive"), QStringLiteral("Inactive"), total - enabled); + statusInactive.status = StatusFilter::kInactive; + QList filters{make(QStringLiteral("all"), QStringLiteral("All"), total), std::move(statusActive), + std::move(statusInactive)}; std::vector> labelled; // (label, provider id) - for (auto it = per_provider.cbegin(); it != per_provider.cend(); ++it) { + for (auto it = perProvider.cbegin(); it != perProvider.cend(); ++it) { labelled.emplace_back(providerLabel(it.key()), it.key()); } std::sort(labelled.begin(), labelled.end()); for (const auto& [label, pid] : labelled) { - TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, per_provider.value(pid)); - provider.provider_id = pid; + TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, perProvider.value(pid)); + provider.providerId = pid; filters.append(std::move(provider)); } if (state && !state->tags.isEmpty()) { - QHash tag_counts; + QHash tagCounts; for (const auto& [id, tag] : state->tags.asKeyValueRange()) { - tag_counts.insert(id, 0); + tagCounts.insert(id, 0); } for (const ListedModPtr& mod : mods) { - const auto pid = mod_package_id(*mod); + const auto pid = modPackageId(*mod); if (!pid) { continue; } - for (qint64 tag_id : state->mod_tags.value(*pid)) { - if (tag_counts.contains(tag_id)) { - tag_counts[tag_id] += 1; + for (qint64 tagId : state->modTags.value(*pid)) { + if (tagCounts.contains(tagId)) { + tagCounts[tagId] += 1; } } } - std::vector sorted_tags; + std::vector sortedTags; for (const Tag& tag : state->tags) { - sorted_tags.push_back(tag); + sortedTags.push_back(tag); } - std::sort(sorted_tags.begin(), sorted_tags.end(), [](const Tag& a, const Tag& b) { + std::sort(sortedTags.begin(), sortedTags.end(), [](const Tag& a, const Tag& b) { return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); }); - for (const Tag& tag : sorted_tags) { - TreeFilter tag_filter = make(QStringLiteral("tag:%1").arg(tag.id), tag.name, tag_counts.value(tag.id)); - tag_filter.tag_id = tag.id; - filters.append(std::move(tag_filter)); + for (const Tag& tag : sortedTags) { + TreeFilter tagFilter = make(QStringLiteral("tag:%1").arg(tag.id), tag.name, tagCounts.value(tag.id)); + tagFilter.tagId = tag.id; + filters.append(std::move(tagFilter)); } } return filters; } -FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, const std::optional& query) { - const bool filtering = query && !query->is_empty(); + const bool filtering = query && !query->isEmpty(); const Resolver resolver(state); - QHash> all_by_folder; - QHash> visible_by_folder; + QHash> allByFolder; + QHash> visibleByFolder; for (auto it = mods.cbegin(); it != mods.cend(); ++it) { const ListedMod& mod = *it.value(); const QString& uuid = it.key(); - const auto pid = mod_package_id(mod); - const QString author = mod_author(mod); + const auto pid = modPackageId(mod); + const QString author = modAuthor(mod); Resolution where = resolver.resolve(pid, mod.name, author); - if (!state.folders.contains(where.folder_id)) { - where = Resolution{ROOT_ID, Placement::None}; + if (!state.folders.contains(where.folderId)) { + where = Resolution{kRootId, Placement::kNone}; } const ModLeaf leaf{ .uuid = uuid, - .package_id = pid, + .packageId = pid, .name = mod.name, .author = author, .enabled = active.contains(uuid), - .tag_ids = pid ? state.mod_tags.value(*pid) : QSet{}, + .tagIds = pid ? state.modTags.value(*pid) : QSet{}, .placement = where.placement, }; - all_by_folder[where.folder_id].append(leaf); - if (!filtering || matches_query(leaf, mod.provider_id, *query)) { - visible_by_folder[where.folder_id].append(leaf); + allByFolder[where.folderId].append(leaf); + if (!filtering || matchesQuery(leaf, mod.providerId, *query)) { + visibleByFolder[where.folderId].append(leaf); } } - QHash> children_of; + QHash> childrenOf; for (const Folder& folder : state.folders) { - if (folder.parent_id) { - children_of[*folder.parent_id].append(folder); + if (folder.parentId) { + childrenOf[*folder.parentId].append(folder); } } QSet ruled; for (const Rule& rule : state.rules) { - ruled.insert(rule.folder_id); + ruled.insert(rule.folderId); } - TreeBuilder builder{state, children_of, all_by_folder, visible_by_folder, ruled, filtering, {}}; - return builder.build(state.folders.value(ROOT_ID), 0); + TreeBuilder builder{state, childrenOf, allByFolder, visibleByFolder, ruled, filtering, {}}; + return builder.build(state.folders.value(kRootId), 0); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.hpp b/src/core/organizer/resolve.hpp index 29d5f5b..ceae35e 100644 --- a/src/core/organizer/resolve.hpp +++ b/src/core/organizer/resolve.hpp @@ -17,22 +17,22 @@ namespace px::modrim::organizer { using ModsByUuid = QHash; // Where a mod's folder came from: a manual placement, an auto-folder rule, or nowhere (root). -enum class Placement { Manual, Rule, None }; -enum class CheckState { On, Off, Partial }; -enum class StatusFilter { All, Active, Inactive }; +enum class Placement { kManual, kRule, kNone }; +enum class CheckState { kOn, kOff, kPartial }; +enum class StatusFilter { kAll, kActive, kInactive }; struct Resolution { - qint64 folder_id = ROOT_ID; - Placement placement = Placement::None; + qint64 folderId = kRootId; + Placement placement = Placement::kNone; }; struct TreeQuery { QString text; - QSet tag_ids; - StatusFilter status = StatusFilter::All; - QSet provider_ids; + QSet tagIds; + StatusFilter status = StatusFilter::kAll; + QSet providerIds; - bool is_empty() const; + bool isEmpty() const; }; // A sidebar preset (status, provider, or tag) and the mods it selects. @@ -40,21 +40,21 @@ struct TreeFilter { QString key; QString label; int count = 0; - StatusFilter status = StatusFilter::All; - std::optional provider_id; - std::optional tag_id; + StatusFilter status = StatusFilter::kAll; + std::optional providerId; + std::optional tagId; TreeQuery query(const QString& text = {}) const; }; struct ModLeaf { QString uuid; - std::optional package_id; + std::optional packageId; QString name; QString author; bool enabled = false; - QSet tag_ids; - Placement placement = Placement::None; + QSet tagIds; + Placement placement = Placement::kNone; }; struct FolderNode { @@ -65,17 +65,17 @@ struct FolderNode { QList mods; int total = 0; int enabled = 0; - CheckState check = CheckState::Off; - int visible_total = 0; - int own_total = 0; - int own_enabled = 0; - bool has_rules = false; + CheckState check = CheckState::kOff; + int visibleTotal = 0; + int ownTotal = 0; + int ownEnabled = 0; + bool hasRules = false; // Check state of the folder's direct mods only (the root's Ungrouped). - CheckState own_check() const; + CheckState ownCheck() const; // This node followed by all descendants, in pre-order. QList walk() const; - QList all_mods() const; + QList allMods() const; }; // First-match counts for a draft rule list, and the ungrouped mods it would assign. @@ -85,20 +85,20 @@ struct RulePreview { }; // Lowercased package id for About.xml mods; nullopt for mods without one. -std::optional mod_package_id(const ListedMod& mod); -QString mod_author(const ListedMod& mod); +std::optional modPackageId(const ListedMod& mod); +QString modAuthor(const ListedMod& mod); -Resolution folder_for(const OrganizerState& state, const std::optional& package_id, +Resolution folderFor(const OrganizerState& state, const std::optional& packageId, const QString& name, const QString& author); -RulePreview preview_rule_matches(const OrganizerState& state, const ModsByUuid& mods, +RulePreview previewRuleMatches(const OrganizerState& state, const ModsByUuid& mods, const QList& specs); // `state` may be null, in which case no tag filters are produced. -QList tree_filters(const ModsByUuid& mods, const QSet& active, +QList treeFilters(const ModsByUuid& mods, const QSet& active, const OrganizerState* state); -FolderNode build_tree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, const std::optional& query = std::nullopt); } // namespace px::modrim::organizer diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index fc6ff57..d86223e 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -17,7 +17,7 @@ namespace { const QHash kEmptyMods; const QStringList kEmptyActive; -Result clean_name(const QString& name, const QString& what) { +Result cleanName(const QString& name, const QString& what) { const QString cleaned = name.trimmed(); if (cleaned.isEmpty()) { return std::unexpected(Error{QStringLiteral("%1 name cannot be empty.").arg(what)}); @@ -28,42 +28,42 @@ Result clean_name(const QString& name, const QString& what) { } // namespace OrganizerService::OrganizerService(CoreContext& ctx, OrganizerDb& db) - : Plugin(QStringLiteral("organizer")), ctx_(&ctx), db_(db) {} + : Plugin(QStringLiteral("organizer")), m_ctx(&ctx), m_db(db) {} -OrganizerService::OrganizerService(AllModsFn all_mods, ActiveUuidsFn active_uuids, OrganizerDb& db) +OrganizerService::OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, OrganizerDb& db) : Plugin(QStringLiteral("organizer")), - all_mods_fn_(std::move(all_mods)), - active_uuids_fn_(std::move(active_uuids)), - db_(db) {} + m_allModsFn(std::move(allMods)), + m_activeUuidsFn(std::move(activeUuids)), + m_db(db) {} -const QHash& OrganizerService::all_mods() const { - if (all_mods_fn_) { - return all_mods_fn_(); +const QHash& OrganizerService::allMods() const { + if (m_allModsFn) { + return m_allModsFn(); } - if (ctx_) { - return ctx_->allMods(); + if (m_ctx) { + return m_ctx->allMods(); } return kEmptyMods; } -const QStringList& OrganizerService::active_uuids() const { - if (active_uuids_fn_) { - return active_uuids_fn_(); +const QStringList& OrganizerService::activeUuids() const { + if (m_activeUuidsFn) { + return m_activeUuidsFn(); } - if (ctx_) { - return ctx_->activeUuids(); + if (m_ctx) { + return m_ctx->activeUuids(); } return kEmptyActive; } QCoro::Task OrganizerService::init() { - auto loaded = co_await db_.load(); + auto loaded = co_await m_db.load(); if (!loaded) { // Plugin::init cannot report failure, so the service stays not ready and says why. spdlog::error("Cannot load organizer state: {}", loaded.error().message.toStdString()); co_return; } - state_ = std::move(*loaded); + m_state = std::move(*loaded); changed.notify({}); } @@ -72,79 +72,79 @@ QCoro::Task OrganizerService::init(CoreContext&) { } QCoro::Task OrganizerService::shutdown() { - co_await db_.close(); + co_await m_db.close(); } bool OrganizerService::ready() const noexcept { - return state_.has_value(); + return m_state.has_value(); } const OrganizerState& OrganizerService::state() const { - Q_ASSERT(state_.has_value()); - return *state_; + Q_ASSERT(m_state.has_value()); + return *m_state; } QCoro::Task> OrganizerService::commit() { - auto loaded = co_await db_.load(); + auto loaded = co_await m_db.load(); if (!loaded) { co_return std::unexpected(loaded.error()); } - state_ = std::move(*loaded); + m_state = std::move(*loaded); changed.notify({}); co_return {}; } // ── Validation helpers ─────────────────────────────────────────────────────── -Result OrganizerService::find_folder(qint64 folder_id) const { +Result OrganizerService::findFolder(qint64 folderId) const { const auto& folders = state().folders; - const auto it = folders.constFind(folder_id); + const auto it = folders.constFind(folderId); if (it == folders.cend()) { return std::unexpected(Error{QStringLiteral("That folder no longer exists.")}); } return it.value(); } -Result OrganizerService::find_non_root(qint64 folder_id) const { - if (folder_id == ROOT_ID) { +Result OrganizerService::findNonRoot(qint64 folderId) const { + if (folderId == kRootId) { return std::unexpected(Error{QStringLiteral("The root folder cannot be changed.")}); } - return find_folder(folder_id); + return findFolder(folderId); } -Result OrganizerService::find_tag(qint64 tag_id) const { +Result OrganizerService::findTag(qint64 tagId) const { const auto& tags = state().tags; - const auto it = tags.constFind(tag_id); + const auto it = tags.constFind(tagId); if (it == tags.cend()) { return std::unexpected(Error{QStringLiteral("That tag no longer exists.")}); } return it.value(); } -int OrganizerService::depth(qint64 folder_id) const { +int OrganizerService::depth(qint64 folderId) const { const auto& folders = state().folders; int count = 0; - Folder current = folders.value(folder_id); - while (current.parent_id) { + Folder current = folders.value(folderId); + while (current.parentId) { ++count; - current = folders.value(*current.parent_id); + current = folders.value(*current.parentId); } return count; } -QList OrganizerService::children(qint64 folder_id) const { +QList OrganizerService::children(qint64 folderId) const { QList result; for (const Folder& folder : state().folders) { - if (folder.parent_id == folder_id) { + if (folder.parentId == folderId) { result.append(folder); } } return result; } -QSet OrganizerService::subtree_ids(qint64 folder_id) const { - QSet ids{folder_id}; - QList pending{folder_id}; +QSet OrganizerService::subtreeIds(qint64 folderId) const { + QSet ids{folderId}; + QList pending{folderId}; while (!pending.isEmpty()) { const qint64 current = pending.takeLast(); for (const Folder& child : children(current)) { @@ -157,26 +157,26 @@ QSet OrganizerService::subtree_ids(qint64 folder_id) const { return ids; } -int OrganizerService::height(qint64 folder_id) const { +int OrganizerService::height(qint64 folderId) const { int tallest = 0; - for (const Folder& child : children(folder_id)) { + for (const Folder& child : children(folderId)) { tallest = std::max(tallest, height(child.id)); } return 1 + tallest; } -Result OrganizerService::check_depth(qint64 parent_id, int tree_height) const { - if (depth(parent_id) + tree_height > MAX_DEPTH) { +Result OrganizerService::checkDepth(qint64 parentId, int treeHeight) const { + if (depth(parentId) + treeHeight > kMaxDepth) { return std::unexpected( - Error{QStringLiteral("Folders can be nested at most %1 levels deep.").arg(MAX_DEPTH)}); + Error{QStringLiteral("Folders can be nested at most %1 levels deep.").arg(kMaxDepth)}); } return {}; } -Result OrganizerService::check_unique_folder_name(qint64 parent_id, const QString& name, +Result OrganizerService::checkUniqueFolderName(qint64 parentId, const QString& name, std::optional exclude) const { const QString key = name.toCaseFolded(); - for (const Folder& sibling : children(parent_id)) { + for (const Folder& sibling : children(parentId)) { if (exclude && sibling.id == *exclude) { continue; } @@ -187,9 +187,9 @@ Result OrganizerService::check_unique_folder_name(qint64 parent_id, const return {}; } -Result OrganizerService::check_tag(const QString& name, const QString& color, +Result OrganizerService::checkTag(const QString& name, const QString& color, std::optional exclude) const { - auto cleaned = clean_name(name, QStringLiteral("Tag")); + auto cleaned = cleanName(name, QStringLiteral("Tag")); if (!cleaned) { return std::unexpected(cleaned.error()); } @@ -202,43 +202,43 @@ Result OrganizerService::check_tag(const QString& name, const QString& return std::unexpected(Error{QStringLiteral("A tag named \"%1\" already exists.").arg(*cleaned)}); } } - static const QRegularExpression color_pattern(QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); - if (!color_pattern.match(color).hasMatch()) { + static const QRegularExpression colorPattern(QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); + if (!colorPattern.match(color).hasMatch()) { return std::unexpected(Error{QStringLiteral("\"%1\" is not a valid color (use #RRGGBB).").arg(color)}); } return *cleaned; } -Result OrganizerService::validate_new_folder(const QString& name, qint64 parent_id) const { - auto cleaned = clean_name(name, QStringLiteral("Folder")); +Result OrganizerService::validateNewFolder(const QString& name, qint64 parentId) const { + auto cleaned = cleanName(name, QStringLiteral("Folder")); if (!cleaned) { return std::unexpected(cleaned.error()); } - if (auto parent = find_folder(parent_id); !parent) { + if (auto parent = findFolder(parentId); !parent) { return std::unexpected(parent.error()); } - if (auto fits = check_depth(parent_id, 1); !fits) { + if (auto fits = checkDepth(parentId, 1); !fits) { return std::unexpected(fits.error()); } - if (auto unique = check_unique_folder_name(parent_id, *cleaned); !unique) { + if (auto unique = checkUniqueFolderName(parentId, *cleaned); !unique) { return std::unexpected(unique.error()); } return *cleaned; } -QSet OrganizerService::active_set() const { - const QStringList& active = active_uuids(); +QSet OrganizerService::activeSet() const { + const QStringList& active = activeUuids(); return QSet(active.cbegin(), active.cend()); } // ── Folders ────────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::create_folder(QString name, qint64 parent_id) { - auto cleaned = validate_new_folder(name, parent_id); +QCoro::Task> OrganizerService::createFolder(QString name, qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); if (!cleaned) { co_return std::unexpected(cleaned.error()); } - auto created = co_await db_.create_folder(parent_id, *cleaned); + auto created = co_await m_db.createFolder(parentId, *cleaned); if (!created) { co_return std::unexpected(created.error()); } @@ -248,13 +248,13 @@ QCoro::Task> OrganizerService::create_folder(QString name, qint64 co_return *created; } -QCoro::Task> OrganizerService::create_folder_from(QString name, QStringList package_ids, - qint64 parent_id) { - auto cleaned = validate_new_folder(name, parent_id); +QCoro::Task> OrganizerService::createFolderFrom(QString name, QStringList packageIds, + qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); if (!cleaned) { co_return std::unexpected(cleaned.error()); } - auto created = co_await db_.create_folder_with(parent_id, *cleaned, clean_package_ids(package_ids)); + auto created = co_await m_db.createFolderWith(parentId, *cleaned, cleanPackageIds(packageIds)); if (!created) { co_return std::unexpected(created.error()); } @@ -264,86 +264,86 @@ QCoro::Task> OrganizerService::create_folder_from(QString name, Q co_return *created; } -QCoro::Task> OrganizerService::rename_folder(qint64 folder_id, QString name) { - auto existing = find_non_root(folder_id); +QCoro::Task> OrganizerService::renameFolder(qint64 folderId, QString name) { + auto existing = findNonRoot(folderId); if (!existing) { co_return std::unexpected(existing.error()); } - auto cleaned = clean_name(name, QStringLiteral("Folder")); + auto cleaned = cleanName(name, QStringLiteral("Folder")); if (!cleaned) { co_return std::unexpected(cleaned.error()); } if (*cleaned == existing->name) { co_return {}; } - const qint64 parent_id = existing->parent_id.value_or(ROOT_ID); - if (auto unique = check_unique_folder_name(parent_id, *cleaned, folder_id); !unique) { + const qint64 parentId = existing->parentId.value_or(kRootId); + if (auto unique = checkUniqueFolderName(parentId, *cleaned, folderId); !unique) { co_return std::unexpected(unique.error()); } - if (auto renamed = co_await db_.rename_folder(folder_id, *cleaned); !renamed) { + if (auto renamed = co_await m_db.renameFolder(folderId, *cleaned); !renamed) { co_return std::unexpected(renamed.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::move_folder(qint64 folder_id, qint64 parent_id) { - auto folder = find_non_root(folder_id); +QCoro::Task> OrganizerService::moveFolder(qint64 folderId, qint64 parentId) { + auto folder = findNonRoot(folderId); if (!folder) { co_return std::unexpected(folder.error()); } - if (auto target = find_folder(parent_id); !target) { + if (auto target = findFolder(parentId); !target) { co_return std::unexpected(target.error()); } - if (folder->parent_id == parent_id) { + if (folder->parentId == parentId) { co_return {}; } - if (subtree_ids(folder_id).contains(parent_id)) { + if (subtreeIds(folderId).contains(parentId)) { co_return std::unexpected(Error{QStringLiteral("A folder cannot be moved into itself.")}); } // The whole subtree moves, so its height counts against the depth limit. - if (auto fits = check_depth(parent_id, height(folder_id)); !fits) { + if (auto fits = checkDepth(parentId, height(folderId)); !fits) { co_return std::unexpected(fits.error()); } - if (auto unique = check_unique_folder_name(parent_id, folder->name, folder_id); !unique) { + if (auto unique = checkUniqueFolderName(parentId, folder->name, folderId); !unique) { co_return std::unexpected(unique.error()); } - if (auto moved = co_await db_.set_parent(folder_id, parent_id); !moved) { + if (auto moved = co_await m_db.setParent(folderId, parentId); !moved) { co_return std::unexpected(moved.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::set_collapsed(qint64 folder_id, bool collapsed) { - auto folder = find_folder(folder_id); +QCoro::Task> OrganizerService::setCollapsed(qint64 folderId, bool collapsed) { + auto folder = findFolder(folderId); if (!folder) { co_return std::unexpected(folder.error()); } if (folder->collapsed == collapsed) { co_return {}; } - if (auto updated = co_await db_.set_collapsed(folder_id, collapsed); !updated) { + if (auto updated = co_await m_db.setCollapsed(folderId, collapsed); !updated) { co_return std::unexpected(updated.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::set_all_collapsed(bool collapsed) { +QCoro::Task> OrganizerService::setAllCollapsed(bool collapsed) { const bool unchanged = std::all_of(state().folders.cbegin(), state().folders.cend(), [collapsed](const Folder& folder) { return folder.collapsed == collapsed; }); if (unchanged) { co_return {}; } - if (auto updated = co_await db_.set_all_collapsed(collapsed); !updated) { + if (auto updated = co_await m_db.setAllCollapsed(collapsed); !updated) { co_return std::unexpected(updated.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::delete_folder(qint64 folder_id) { - if (auto folder = find_non_root(folder_id); !folder) { +QCoro::Task> OrganizerService::deleteFolder(qint64 folderId) { + if (auto folder = findNonRoot(folderId); !folder) { co_return std::unexpected(folder.error()); } - if (auto deleted = co_await db_.delete_folder(folder_id); !deleted) { + if (auto deleted = co_await m_db.deleteFolder(folderId); !deleted) { co_return std::unexpected(deleted.error()); } co_return co_await commit(); @@ -351,30 +351,30 @@ QCoro::Task> OrganizerService::delete_folder(qint64 folder_id) { // ── Placement ──────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::place(QStringList package_ids, qint64 folder_id) { - if (auto folder = find_folder(folder_id); !folder) { +QCoro::Task> OrganizerService::place(QStringList packageIds, qint64 folderId) { + if (auto folder = findFolder(folderId); !folder) { co_return std::unexpected(folder.error()); } - const QStringList pids = clean_package_ids(package_ids); + const QStringList pids = cleanPackageIds(packageIds); if (pids.isEmpty()) { co_return {}; } - if (auto placed = co_await db_.place(pids, folder_id); !placed) { + if (auto placed = co_await m_db.place(pids, folderId); !placed) { co_return std::unexpected(placed.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::ungroup(QStringList package_ids) { - co_return co_await place(std::move(package_ids), ROOT_ID); +QCoro::Task> OrganizerService::ungroup(QStringList packageIds) { + co_return co_await place(std::move(packageIds), kRootId); } -QCoro::Task> OrganizerService::reset_to_rules(QStringList package_ids) { - const QStringList pids = clean_package_ids(package_ids); +QCoro::Task> OrganizerService::resetToRules(QStringList packageIds) { + const QStringList pids = cleanPackageIds(packageIds); if (pids.isEmpty()) { co_return {}; } - if (auto unplaced = co_await db_.unplace(pids); !unplaced) { + if (auto unplaced = co_await m_db.unplace(pids); !unplaced) { co_return std::unexpected(unplaced.error()); } co_return co_await commit(); @@ -382,12 +382,12 @@ QCoro::Task> OrganizerService::reset_to_rules(QStringList package_i // ── Tags ───────────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::create_tag(QString name, QString color) { - auto cleaned = check_tag(name, color); +QCoro::Task> OrganizerService::createTag(QString name, QString color) { + auto cleaned = checkTag(name, color); if (!cleaned) { co_return std::unexpected(cleaned.error()); } - auto created = co_await db_.create_tag(*cleaned, color); + auto created = co_await m_db.createTag(*cleaned, color); if (!created) { co_return std::unexpected(created.error()); } @@ -397,45 +397,45 @@ QCoro::Task> OrganizerService::create_tag(QString name, QString colo co_return *created; } -QCoro::Task> OrganizerService::update_tag(qint64 tag_id, QString name, QString color) { - if (auto tag = find_tag(tag_id); !tag) { +QCoro::Task> OrganizerService::updateTag(qint64 tagId, QString name, QString color) { + if (auto tag = findTag(tagId); !tag) { co_return std::unexpected(tag.error()); } - auto cleaned = check_tag(name, color, tag_id); + auto cleaned = checkTag(name, color, tagId); if (!cleaned) { co_return std::unexpected(cleaned.error()); } - if (auto updated = co_await db_.update_tag(tag_id, *cleaned, color); !updated) { + if (auto updated = co_await m_db.updateTag(tagId, *cleaned, color); !updated) { co_return std::unexpected(updated.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::delete_tag(qint64 tag_id) { - if (auto tag = find_tag(tag_id); !tag) { +QCoro::Task> OrganizerService::deleteTag(qint64 tagId) { + if (auto tag = findTag(tagId); !tag) { co_return std::unexpected(tag.error()); } - if (auto deleted = co_await db_.delete_tag(tag_id); !deleted) { + if (auto deleted = co_await m_db.deleteTag(tagId); !deleted) { co_return std::unexpected(deleted.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::set_mod_tags(QStringList package_ids, QSet add, +QCoro::Task> OrganizerService::setModTags(QStringList packageIds, QSet add, QSet remove) { - const QStringList pids = clean_package_ids(package_ids); + const QStringList pids = cleanPackageIds(packageIds); if (add.intersects(remove)) { co_return std::unexpected(Error{QStringLiteral("A tag cannot be added and removed at once.")}); } - for (qint64 tag_id : add | remove) { - if (auto tag = find_tag(tag_id); !tag) { + for (qint64 tagId : add | remove) { + if (auto tag = findTag(tagId); !tag) { co_return std::unexpected(tag.error()); } } if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { co_return {}; } - if (auto updated = co_await db_.set_mod_tags(pids, add, remove); !updated) { + if (auto updated = co_await m_db.setModTags(pids, add, remove); !updated) { co_return std::unexpected(updated.error()); } co_return co_await commit(); @@ -443,7 +443,7 @@ QCoro::Task> OrganizerService::set_mod_tags(QStringList package_ids // ── Rules ──────────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::set_rules(QList specs) { +QCoro::Task> OrganizerService::setRules(QList specs) { QList cleaned; cleaned.reserve(specs.size()); for (RuleSpec spec : specs) { @@ -451,21 +451,21 @@ QCoro::Task> OrganizerService::set_rules(QList specs) { if (spec.pattern.isEmpty()) { co_return std::unexpected(Error{QStringLiteral("Rule pattern cannot be empty.")}); } - if (spec.folder_id == ROOT_ID) { + if (spec.folderId == kRootId) { co_return std::unexpected(Error{QStringLiteral("Rules must target a folder, not the root.")}); } - if (auto target = find_folder(spec.folder_id); !target) { + if (auto target = findFolder(spec.folderId); !target) { co_return std::unexpected(target.error()); } cleaned.append(std::move(spec)); } - if (auto replaced = co_await db_.replace_rules(cleaned); !replaced) { + if (auto replaced = co_await m_db.replaceRules(cleaned); !replaced) { co_return std::unexpected(replaced.error()); } co_return co_await commit(); } -QCoro::Task> OrganizerService::add_standard_rules() { +QCoro::Task> OrganizerService::addStandardRules() { // (field, op, casefolded pattern) identifies a rule regardless of folder or position. using RuleKey = std::tuple; std::set present; @@ -474,14 +474,14 @@ QCoro::Task> OrganizerService::add_standard_rules() { } QSet existing; - for (const Folder& folder : children(ROOT_ID)) { + for (const Folder& folder : children(kRootId)) { existing.insert(folder.name.toCaseFolded()); } QList rules; - QStringList new_folders; - QSet new_folder_keys; - for (const StandardRule& rule : standard_rules()) { + QStringList newFolders; + QSet newFolderKeys; + for (const StandardRule& rule : standardRules()) { const RuleKey key{static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()}; if (present.contains(key)) { continue; @@ -489,16 +489,16 @@ QCoro::Task> OrganizerService::add_standard_rules() { present.insert(key); rules.append(rule); // The first spelling of a missing top-level folder names it, as in the legacy service. - const QString folder_key = rule.folder.toCaseFolded(); - if (!existing.contains(folder_key) && !new_folder_keys.contains(folder_key)) { - new_folder_keys.insert(folder_key); - new_folders.append(rule.folder); + const QString folderKey = rule.folder.toCaseFolded(); + if (!existing.contains(folderKey) && !newFolderKeys.contains(folderKey)) { + newFolderKeys.insert(folderKey); + newFolders.append(rule.folder); } } if (rules.isEmpty()) { co_return 0; } - if (auto appended = co_await db_.append_named_rules(ROOT_ID, new_folders, rules); !appended) { + if (auto appended = co_await m_db.appendNamedRules(kRootId, newFolders, rules); !appended) { co_return std::unexpected(appended.error()); } if (auto committed = co_await commit(); !committed) { @@ -510,19 +510,19 @@ QCoro::Task> OrganizerService::add_standard_rules() { // ── Read helpers ───────────────────────────────────────────────────────────── FolderNode OrganizerService::tree(const std::optional& query) const { - return build_tree(state(), all_mods(), active_set(), query); + return buildTree(state(), allMods(), activeSet(), query); } -Result OrganizerService::folder_mod_uuids(qint64 folder_id, bool recursive) const { - if (auto folder = find_folder(folder_id); !folder) { +Result OrganizerService::folderModUuids(qint64 folderId, bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { return std::unexpected(folder.error()); } const FolderNode root = tree(); for (const FolderNode* node : root.walk()) { - if (node->folder.id != folder_id) { + if (node->folder.id != folderId) { continue; } - const QList leaves = recursive ? node->all_mods() : node->mods; + const QList leaves = recursive ? node->allMods() : node->mods; QStringList uuids; for (const ModLeaf& leaf : leaves) { uuids.append(leaf.uuid); @@ -532,22 +532,22 @@ Result OrganizerService::folder_mod_uuids(qint64 folder_id, bool re return QStringList{}; } -Result OrganizerService::folder_toggle(qint64 folder_id, bool recursive) const { - if (auto folder = find_folder(folder_id); !folder) { +Result OrganizerService::folderToggle(qint64 folderId, bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { return std::unexpected(folder.error()); } const FolderNode root = tree(); for (const FolderNode* node : root.walk()) { - if (node->folder.id != folder_id) { + if (node->folder.id != folderId) { continue; } - const QList leaves = recursive ? node->all_mods() : node->mods; + const QList leaves = recursive ? node->allMods() : node->mods; QStringList uuids; for (const ModLeaf& leaf : leaves) { uuids.append(leaf.uuid); } - const CheckState check = recursive ? node->check : node->own_check(); - if (check == CheckState::On) { + const CheckState check = recursive ? node->check : node->ownCheck(); + if (check == CheckState::kOn) { return FolderToggle{QStringList{}, uuids}; } return FolderToggle{uuids, QStringList{}}; @@ -555,21 +555,21 @@ Result OrganizerService::folder_toggle(qint64 folder_id, bool recu return FolderToggle{}; } -Result> OrganizerService::folder_move_targets(qint64 folder_id) const { - auto folder = find_non_root(folder_id); +Result> OrganizerService::folderMoveTargets(qint64 folderId) const { + auto folder = findNonRoot(folderId); if (!folder) { return std::unexpected(folder.error()); } - const QSet subtree = subtree_ids(folder_id); - const int tree_height = height(folder_id); + const QSet subtree = subtreeIds(folderId); + const int treeHeight = height(folderId); const QString key = folder->name.toCaseFolded(); QSet targets; for (const Folder& candidate : state().folders) { - if (subtree.contains(candidate.id) || candidate.id == folder->parent_id) { + if (subtree.contains(candidate.id) || candidate.id == folder->parentId) { continue; } - if (depth(candidate.id) + tree_height > MAX_DEPTH) { + if (depth(candidate.id) + treeHeight > kMaxDepth) { continue; } const QList siblings = children(candidate.id); @@ -582,8 +582,8 @@ Result> OrganizerService::folder_move_targets(qint64 folder_id) con return targets; } -QList OrganizerService::tree_filters() const { - return organizer::tree_filters(all_mods(), active_set(), &state()); +QList OrganizerService::treeFilters() const { + return organizer::treeFilters(allMods(), activeSet(), &state()); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/service.hpp b/src/core/organizer/service.hpp index 745cdce..c100cf3 100644 --- a/src/core/organizer/service.hpp +++ b/src/core/organizer/service.hpp @@ -37,7 +37,7 @@ class OrganizerService : public Plugin { using ActiveUuidsFn = std::function; OrganizerService(CoreContext& ctx, OrganizerDb& db); - OrganizerService(AllModsFn all_mods, ActiveUuidsFn active_uuids, OrganizerDb& db); + OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, OrganizerDb& db); Event changed; @@ -51,68 +51,68 @@ class OrganizerService : public Plugin { const OrganizerState& state() const; // ── Folders ────────────────────────────────────────────────────────────── - QCoro::Task> create_folder(QString name, qint64 parent_id = ROOT_ID); - QCoro::Task> create_folder_from(QString name, QStringList package_ids, - qint64 parent_id = ROOT_ID); - QCoro::Task> rename_folder(qint64 folder_id, QString name); - QCoro::Task> move_folder(qint64 folder_id, qint64 parent_id); - QCoro::Task> set_collapsed(qint64 folder_id, bool collapsed); - QCoro::Task> set_all_collapsed(bool collapsed); - QCoro::Task> delete_folder(qint64 folder_id); + QCoro::Task> createFolder(QString name, qint64 parentId = kRootId); + QCoro::Task> createFolderFrom(QString name, QStringList packageIds, + qint64 parentId = kRootId); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> moveFolder(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); // ── Placement ──────────────────────────────────────────────────────────── - QCoro::Task> place(QStringList package_ids, qint64 folder_id); - QCoro::Task> ungroup(QStringList package_ids); - QCoro::Task> reset_to_rules(QStringList package_ids); + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> ungroup(QStringList packageIds); + QCoro::Task> resetToRules(QStringList packageIds); // ── Tags ───────────────────────────────────────────────────────────────── - QCoro::Task> create_tag(QString name, QString color); - QCoro::Task> update_tag(qint64 tag_id, QString name, QString color); - QCoro::Task> delete_tag(qint64 tag_id); - QCoro::Task> set_mod_tags(QStringList package_ids, QSet add, QSet remove); + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, QSet remove); // ── Rules ──────────────────────────────────────────────────────────────── - QCoro::Task> set_rules(QList specs); + QCoro::Task> setRules(QList specs); // Appends the built-in rules not yet present. Returns how many were added. - QCoro::Task> add_standard_rules(); + QCoro::Task> addStandardRules(); // ── Read helpers ───────────────────────────────────────────────────────── FolderNode tree(const std::optional& query = std::nullopt) const; // Mods under `folder_id`; `recursive = false` keeps only its direct mods. - Result folder_mod_uuids(qint64 folder_id, bool recursive = true) const; + Result folderModUuids(qint64 folderId, bool recursive = true) const; // (enable, disable) for a folder checkbox click: a fully enabled folder disables // everything; partial or off enables all. - Result folder_toggle(qint64 folder_id, bool recursive = true) const; + Result folderToggle(qint64 folderId, bool recursive = true) const; // Legal destinations, excluding the folder's current parent. - Result> folder_move_targets(qint64 folder_id) const; - QList tree_filters() const; + Result> folderMoveTargets(qint64 folderId) const; + QList treeFilters() const; private: QCoro::Task> commit(); - Result validate_new_folder(const QString& name, qint64 parent_id) const; - Result find_folder(qint64 folder_id) const; - Result find_non_root(qint64 folder_id) const; - Result find_tag(qint64 tag_id) const; - Result check_tag(const QString& name, const QString& color, + Result validateNewFolder(const QString& name, qint64 parentId) const; + Result findFolder(qint64 folderId) const; + Result findNonRoot(qint64 folderId) const; + Result findTag(qint64 tagId) const; + Result checkTag(const QString& name, const QString& color, std::optional exclude = std::nullopt) const; - Result check_unique_folder_name(qint64 parent_id, const QString& name, + Result checkUniqueFolderName(qint64 parentId, const QString& name, std::optional exclude = std::nullopt) const; - Result check_depth(qint64 parent_id, int height) const; - int depth(qint64 folder_id) const; - int height(qint64 folder_id) const; - QList children(qint64 folder_id) const; - QSet subtree_ids(qint64 folder_id) const; - - const QHash& all_mods() const; - const QStringList& active_uuids() const; - QSet active_set() const; - - CoreContext* ctx_ = nullptr; - AllModsFn all_mods_fn_; - ActiveUuidsFn active_uuids_fn_; - OrganizerDb& db_; - std::optional state_; + Result checkDepth(qint64 parentId, int height) const; + int depth(qint64 folderId) const; + int height(qint64 folderId) const; + QList children(qint64 folderId) const; + QSet subtreeIds(qint64 folderId) const; + + const QHash& allMods() const; + const QStringList& activeUuids() const; + QSet activeSet() const; + + CoreContext* m_ctx = nullptr; + AllModsFn m_allModsFn; + ActiveUuidsFn m_activeUuidsFn; + OrganizerDb& m_db; + std::optional m_state; }; } // namespace px::modrim::organizer diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp index 6d1d32f..649ed0b 100644 --- a/src/core/plugin.hpp +++ b/src/core/plugin.hpp @@ -24,22 +24,22 @@ template class BasicPlugin { public: BasicPlugin(QString name, QStringList dependencies = {}) - : name_(std::move(name)), dependencies_(std::move(dependencies)) {} + : m_name(std::move(name)), m_dependencies(std::move(dependencies)) {} virtual ~BasicPlugin() = default; BasicPlugin(const BasicPlugin&) = delete; BasicPlugin& operator=(const BasicPlugin&) = delete; - const QString& name() const noexcept { return name_; } - const QStringList& dependencies() const noexcept { return dependencies_; } + const QString& name() const noexcept { return m_name; } + const QStringList& dependencies() const noexcept { return m_dependencies; } virtual void setup(Ctx&) {} virtual QCoro::Task init(Ctx&) { co_return; } virtual QCoro::Task shutdown() { co_return; } private: - QString name_; - QStringList dependencies_; + QString m_name; + QStringList m_dependencies; }; template @@ -48,19 +48,19 @@ class BasicPluginRegistry { using Plugin = BasicPlugin; // Replaces a plugin registered under the same name, keeping its position. - void register_plugin(std::unique_ptr plugin) { + void registerPlugin(std::unique_ptr plugin) { spdlog::debug("plugin registered: {}", plugin->name().toStdString()); - for (auto& existing : plugins_) { + for (auto& existing : m_plugins) { if (existing->name() == plugin->name()) { existing = std::move(plugin); return; } } - plugins_.push_back(std::move(plugin)); + m_plugins.push_back(std::move(plugin)); } Plugin* get(const QString& name) const { - for (const auto& plugin : plugins_) { + for (const auto& plugin : m_plugins) { if (plugin->name() == name) { return plugin.get(); } @@ -70,25 +70,25 @@ class BasicPluginRegistry { QSet names() const { QSet result; - for (const auto& plugin : plugins_) { + for (const auto& plugin : m_plugins) { result.insert(plugin->name()); } return result; } // `extra_deps` are dependencies satisfied outside the registry. - Result setup_all(Ctx& ctx, const QSet& extra_deps = {}) { - for (const auto& plugin : plugins_) { + Result setupAll(Ctx& ctx, const QSet& extraDeps = {}) { + for (const auto& plugin : m_plugins) { for (const auto& dep : plugin->dependencies()) { - if (!get(dep) && !extra_deps.contains(dep)) { + if (!get(dep) && !extraDeps.contains(dep)) { return std::unexpected(Error{QStringLiteral("Plugin '%1' depends on '%2' which is not registered") .arg(plugin->name(), dep)}); } } } - spdlog::info("setting up {} plugins...", plugins_.size()); - auto order = dependency_order(); + spdlog::info("setting up {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); if (!order) { return std::unexpected(order.error()); } @@ -99,9 +99,9 @@ class BasicPluginRegistry { return {}; } - QCoro::Task> init_all(Ctx& ctx) { - spdlog::info("initializing {} plugins...", plugins_.size()); - auto order = dependency_order(); + QCoro::Task> initAll(Ctx& ctx) { + spdlog::info("initializing {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); if (!order) { co_return std::unexpected(order.error()); } @@ -112,9 +112,9 @@ class BasicPluginRegistry { co_return {}; } - QCoro::Task> shutdown_all() { - spdlog::info("shutting down {} plugins...", plugins_.size()); - auto order = dependency_order(); + QCoro::Task> shutdownAll() { + spdlog::info("shutting down {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); if (!order) { co_return std::unexpected(order.error()); } @@ -126,19 +126,19 @@ class BasicPluginRegistry { } private: - Result> dependency_order() const { + Result> dependencyOrder() const { std::map> graph; - for (const auto& plugin : plugins_) { + for (const auto& plugin : m_plugins) { const auto& deps = plugin->dependencies(); graph.emplace(plugin->name(), QSet(deps.cbegin(), deps.cend())); } - auto levels = toposort_levels(graph); + auto levels = toposortLevels(graph); if (!levels) { return std::unexpected(levels.error()); } std::vector order; - order.reserve(plugins_.size()); + order.reserve(m_plugins.size()); for (const auto& level : *levels) { for (const auto& name : level) { order.push_back(get(name)); @@ -147,7 +147,7 @@ class BasicPluginRegistry { return order; } - std::vector> plugins_; + std::vector> m_plugins; }; using Plugin = BasicPlugin; diff --git a/src/core/plugin_config.hpp b/src/core/plugin_config.hpp index 166e3c4..749f1d5 100644 --- a/src/core/plugin_config.hpp +++ b/src/core/plugin_config.hpp @@ -24,7 +24,7 @@ class PluginConfig { public: PluginConfig(ConfigService* service, const QString& name, const QHash& legacy = {}); - const T& value() const noexcept { return value_; } + const T& value() const noexcept { return m_value; } void update(T value); @@ -34,33 +34,33 @@ class PluginConfig { T seed(const QHash& legacy) const; void store(const T& value) const; - ConfigService* service_; - QString filename_; - T value_{}; + ConfigService* m_service; + QString m_filename; + T m_value{}; }; template requires std::equality_comparable && std::default_initializable PluginConfig::PluginConfig(ConfigService* service, const QString& name, const QHash& legacy) - : service_(service), filename_(QStringLiteral("plugins/%1.json").arg(name)) { + : m_service(service), m_filename(QStringLiteral("plugins/%1.json").arg(name)) { std::optional stored; - if (service_) { + if (m_service) { // plugins/*.json is unversioned, so loading cannot fail with a migration error. - auto loaded = service_->load_existing(filename_); + auto loaded = m_service->loadExisting(m_filename); Q_ASSERT(loaded.has_value()); stored = loaded.value_or(std::nullopt); } - value_ = stored ? std::move(*stored) : seed(legacy); + m_value = stored ? std::move(*stored) : seed(legacy); } template requires std::equality_comparable && std::default_initializable T PluginConfig::seed(const QHash& legacy) const { - if (!service_ || legacy.isEmpty()) { + if (!m_service || legacy.isEmpty()) { return T{}; } - const auto raw = service_->load_raw("config.json"); + const auto raw = m_service->loadRaw("config.json"); glz::json_t::object_t fields; for (auto it = legacy.cbegin(); it != legacy.cend(); ++it) { if (auto found = raw.find(it.key().toStdString()); found != raw.end()) { @@ -70,12 +70,12 @@ T PluginConfig::seed(const QHash& legacy) const { const auto text = glz::write_json(fields); if (!text) { - spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", filename_.toStdString()); + spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", m_filename.toStdString()); return T{}; } T value{}; if (auto error = glz::read(value, *text)) { - spdlog::warn("Ignoring old settings for {}: {}", filename_.toStdString(), + spdlog::warn("Ignoring old settings for {}: {}", m_filename.toStdString(), glz::format_error(error, *text)); return T{}; } @@ -88,22 +88,22 @@ T PluginConfig::seed(const QHash& legacy) const { template requires std::equality_comparable && std::default_initializable void PluginConfig::update(T value) { - if (value == value_) { + if (value == m_value) { return; } - value_ = std::move(value); - store(value_); - changed.notify(value_); + m_value = std::move(value); + store(m_value); + changed.notify(m_value); } template requires std::equality_comparable && std::default_initializable void PluginConfig::store(const T& value) const { - if (!service_) { + if (!m_service) { return; } - if (auto saved = service_->save(filename_, value); !saved) { - spdlog::warn("Cannot save {}: {}", filename_.toStdString(), saved.error().message.toStdString()); + if (auto saved = m_service->save(m_filename, value); !saved) { + spdlog::warn("Cannot save {}: {}", m_filename.toStdString(), saved.error().message.toStdString()); } } diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp index f7e94c6..6a7c2ad 100644 --- a/src/core/profiler.cpp +++ b/src/core/profiler.cpp @@ -19,40 +19,40 @@ void renderNode(const Profiler::Node& node, int depth, QString& out) { } // namespace Profiler::Scope::Scope(Profiler& owner, QAnyStringView name) - : owner_(owner), parent_(owner.current_), start_(std::chrono::steady_clock::now()) { + : m_owner(owner), m_parent(owner.m_current), m_start(std::chrono::steady_clock::now()) { auto node = std::make_unique(name.toString()); - node_ = node.get(); - parent_->children.push_back(std::move(node)); - owner_.current_ = node_; + m_node = node.get(); + m_parent->children.push_back(std::move(node)); + m_owner.m_current = m_node; } Profiler::Scope::~Scope() { - node_->elapsed = std::chrono::steady_clock::now() - start_; - owner_.current_ = parent_; + m_node->elapsed = std::chrono::steady_clock::now() - m_start; + m_owner.m_current = m_parent; } void Profiler::adopt(Profiler& child) { - for (auto& node : child.root_.children) { - current_->children.push_back(std::move(node)); + for (auto& node : child.m_root.children) { + m_current->children.push_back(std::move(node)); } - child.root_.children.clear(); + child.m_root.children.clear(); } QString Profiler::render() const { QString out; - for (const auto& child : root_.children) { + for (const auto& child : m_root.children) { renderNode(*child, 0, out); } return out; } ProfileSession::ProfileSession(QAnyStringView label) - : scope_(std::make_unique(timer_, label)) {} + : m_scope(std::make_unique(m_timer, label)) {} ProfileSession::~ProfileSession() { // Close the top-level scope before rendering so its elapsed time is included. - scope_.reset(); - spdlog::debug("Profile report:\n{}", timer_.render().toStdString()); + m_scope.reset(); + spdlog::debug("Profile report:\n{}", m_timer.render().toStdString()); } } // namespace px::modrim diff --git a/src/core/profiler.hpp b/src/core/profiler.hpp index 49d8df2..8764190 100644 --- a/src/core/profiler.hpp +++ b/src/core/profiler.hpp @@ -31,10 +31,10 @@ class Profiler { Scope& operator=(Scope&&) = delete; private: - Profiler& owner_; - Node* node_; - Node* parent_; - std::chrono::steady_clock::time_point start_; + Profiler& m_owner; + Node* m_node; + Node* m_parent; + std::chrono::steady_clock::time_point m_start; }; Profiler() = default; @@ -48,8 +48,8 @@ class Profiler { [[nodiscard]] QString render() const; private: - Node root_{QStringLiteral("")}; - Node* current_ = &root_; + Node m_root{QStringLiteral("")}; + Node* m_current = &m_root; }; // Logs the hierarchical report of a block at debug level when it ends. @@ -60,11 +60,11 @@ class ProfileSession { ProfileSession(const ProfileSession&) = delete; ProfileSession& operator=(const ProfileSession&) = delete; - [[nodiscard]] Profiler& timer() { return timer_; } + [[nodiscard]] Profiler& timer() { return m_timer; } private: - Profiler timer_; - std::unique_ptr scope_; + Profiler m_timer; + std::unique_ptr m_scope; }; } // namespace px::modrim diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp index 9532d31..9c4c21a 100644 --- a/src/core/providers/base.cpp +++ b/src/core/providers/base.cpp @@ -57,32 +57,32 @@ QList scanModDirectory(const QString& modsPath) { return found; } -BaseModProvider::BaseModProvider(QString provider_id, QString color, QString path, - QThreadPool* pool, MetadataCacheStore* metadata_cache) - : provider_id_(std::move(provider_id)), - color_(std::move(color)), - path_(std::move(path)), - pool_(pool), - metadata_cache_(metadata_cache) {} - -QCoro::Task> BaseModProvider::load_mods(QList dirs, - QString target_version, Profiler& timer, - MetadataCacheStore* metadata_cache, - bool force_reparse) { - MetadataCacheStore* cache = metadata_cache ? metadata_cache : metadata_cache_; +BaseModProvider::BaseModProvider(QString providerId, QString color, QString path, + QThreadPool* pool, MetadataCacheStore* metadataCache) + : m_providerId(std::move(providerId)), + m_color(std::move(color)), + m_path(std::move(path)), + m_pool(pool), + m_metadataCache(metadataCache) {} + +QCoro::Task> BaseModProvider::loadMods(QList dirs, + QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, + bool forceReparse) { + MetadataCacheStore* cache = metadataCache ? metadataCache : m_metadataCache; const QList candidates = co_await runOnPool>( nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); ModMap result; QList missing; - if (force_reparse) { + if (forceReparse) { missing = candidates; } else if (cache) { auto scope = timer("cache_lookup"); - CacheLookup lookup = co_await cache->get_mods(candidates, target_version); + CacheLookup lookup = co_await cache->getMods(candidates, targetVersion); for (ListedModPtr& mod : lookup.cached) { - mod->provider_id = provider_id_; + mod->providerId = m_providerId; result.insert(mod->uuid(), mod); } missing = std::move(lookup.missing); @@ -92,7 +92,7 @@ QCoro::Task> BaseModProvider::load_mods(QList dirs, if (!missing.isEmpty()) { auto scope = timer("parse_xml"); - Result> parsed = co_await parse_candidates(missing, target_version); + Result> parsed = co_await parseCandidates(missing, targetVersion); if (!parsed) { co_return std::unexpected(parsed.error()); } @@ -104,11 +104,11 @@ QCoro::Task> BaseModProvider::load_mods(QList dirs, } } if (!cacheItems.isEmpty()) { - co_await cache->put_mods(std::move(cacheItems), target_version); + co_await cache->putMods(std::move(cacheItems), targetVersion); } } for (ParsedMod& item : *parsed) { - item.mod->provider_id = provider_id_; + item.mod->providerId = m_providerId; result.insert(item.mod->uuid(), item.mod); } } @@ -117,21 +117,21 @@ QCoro::Task> BaseModProvider::load_mods(QList dirs, const QList mods = result.values(); co_await runOnPool(nullptr, [mods] { for (const ListedModPtr& mod : mods) { - mod->published_file_id(); + mod->publishedFileId(); } }); co_return result; } -QCoro::Task>> BaseModProvider::parse_candidates( - QList missing, QString target_version) { - if (pool_ == nullptr || missing.size() <= 1) { +QCoro::Task>> BaseModProvider::parseCandidates( + QList missing, QString targetVersion) { + if (m_pool == nullptr || missing.size() <= 1) { co_return co_await runOnPool>>( - nullptr, [missing = std::move(missing), target_version = std::move(target_version)] { + nullptr, [missing = std::move(missing), targetVersion = std::move(targetVersion)] { QList parsed; parsed.reserve(missing.size()); for (const ModCandidate& candidate : missing) { - Result item = parseOne(candidate, target_version); + Result item = parseOne(candidate, targetVersion); if (!item) { return Result>(std::unexpected(item.error())); } @@ -145,7 +145,7 @@ QCoro::Task>> BaseModProvider::parse_candidates( pending.reserve(missing.size()); for (const ModCandidate& candidate : missing) { pending.append(runOnPool>( - pool_, [candidate, target_version] { return parseOne(candidate, target_version); })); + m_pool, [candidate, targetVersion] { return parseOne(candidate, targetVersion); })); } QList parsed; parsed.reserve(missing.size()); diff --git a/src/core/providers/base.hpp b/src/core/providers/base.hpp index f7bf69d..d78bc6f 100644 --- a/src/core/providers/base.hpp +++ b/src/core/providers/base.hpp @@ -79,9 +79,9 @@ class MetadataCacheStore { virtual ~MetadataCacheStore() = default; // Returns the entries that are still valid for `target_version`; everything else is missing. - virtual QCoro::Task get_mods(QList candidates, - QString target_version) = 0; - virtual QCoro::Task put_mods(QList entries, QString target_version) = 0; + virtual QCoro::Task getMods(QList candidates, + QString targetVersion) = 0; + virtual QCoro::Task putMods(QList entries, QString targetVersion) = 0; }; // Discovered mods keyed by uuid (the mod path for mods on disk). @@ -94,37 +94,37 @@ class BaseModProvider { public: // `path` is the root this provider scans. `pool` and `metadata_cache` are optional and not // owned; they must outlive the provider. - BaseModProvider(QString provider_id, QString color, QString path, - QThreadPool* pool = nullptr, MetadataCacheStore* metadata_cache = nullptr); + BaseModProvider(QString providerId, QString color, QString path, + QThreadPool* pool = nullptr, MetadataCacheStore* metadataCache = nullptr); BaseModProvider(const BaseModProvider&) = delete; BaseModProvider& operator=(const BaseModProvider&) = delete; virtual ~BaseModProvider() = default; - const QString& provider_id() const noexcept { return provider_id_; } - const QString& color() const noexcept { return color_; } - const QString& path() const noexcept { return path_; } + const QString& providerId() const noexcept { return m_providerId; } + const QString& color() const noexcept { return m_color; } + const QString& path() const noexcept { return m_path; } // `metadata_cache` overrides the cache given at construction for this call only. - virtual QCoro::Task> discover(QString target_version, + virtual QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, - MetadataCacheStore* metadata_cache = nullptr, - bool force_reparse = false) = 0; + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) = 0; protected: // Loads metadata for `dirs`, using the cache where entries are still valid. - QCoro::Task> load_mods(QList dirs, QString target_version, - Profiler& timer, MetadataCacheStore* metadata_cache, - bool force_reparse); + QCoro::Task> loadMods(QList dirs, QString targetVersion, + Profiler& timer, MetadataCacheStore* metadataCache, + bool forceReparse); private: - QCoro::Task>> parse_candidates(QList missing, - QString target_version); - - QString provider_id_; - QString color_; - QString path_; - QThreadPool* pool_; - MetadataCacheStore* metadata_cache_; + QCoro::Task>> parseCandidates(QList missing, + QString targetVersion); + + QString m_providerId; + QString m_color; + QString m_path; + QThreadPool* m_pool; + MetadataCacheStore* m_metadataCache; }; } // namespace px::modrim::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp index 9f7f758..a4a70e3 100644 --- a/src/core/providers/core.cpp +++ b/src/core/providers/core.cpp @@ -10,9 +10,9 @@ CoreModProvider::CoreModProvider(QString gamePath, MetadataCacheStore* metadataC : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), std::move(gamePath), nullptr, metadataCache) {} -QCoro::Task> CoreModProvider::discover(QString target_version, Profiler* timer, - MetadataCacheStore* metadata_cache, - bool force_reparse) { +QCoro::Task> CoreModProvider::discover(QString targetVersion, Profiler* timer, + MetadataCacheStore* metadataCache, + bool forceReparse) { Profiler ownTimer; Profiler& tm = timer ? *timer : ownTimer; @@ -29,8 +29,8 @@ QCoro::Task> CoreModProvider::discover(QString target_version, Pr nullptr, [dataDir] { return scanModDirectory(dataDir); }); } - auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, - metadata_cache, force_reparse); + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, + metadataCache, forceReparse); if (discovered) { spdlog::info("CoreModProvider discovered {} mods", discovered->size()); } diff --git a/src/core/providers/core.hpp b/src/core/providers/core.hpp index 2161fad..a030eef 100644 --- a/src/core/providers/core.hpp +++ b/src/core/providers/core.hpp @@ -10,9 +10,9 @@ class CoreModProvider final : public BaseModProvider { // `gamePath` is the RimWorld install root; `Data/` is resolved inside it. explicit CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache = nullptr); - QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, - MetadataCacheStore* metadata_cache = nullptr, - bool force_reparse = false) override; + QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; }; } // namespace px::modrim::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp index 74ffc66..ad8b11b 100644 --- a/src/core/providers/factory.cpp +++ b/src/core/providers/factory.cpp @@ -5,7 +5,7 @@ namespace px::modrim::providers { -ProviderList create_providers(const PathConfig& paths, QThreadPool* pool) { +ProviderList createProviders(const PathConfig& paths, QThreadPool* pool) { ProviderList providers; if (!paths.game.empty()) { providers.push_back(std::make_unique(QString::fromStdString(paths.game))); diff --git a/src/core/providers/factory.hpp b/src/core/providers/factory.hpp index a5be4b8..c22ddd1 100644 --- a/src/core/providers/factory.hpp +++ b/src/core/providers/factory.hpp @@ -15,6 +15,6 @@ using ProviderList = std::vector>; // The default providers for the configured paths: base game, local, downloaded and Workshop. // Paths that are not set produce no provider. `pool` is not owned and must outlive the providers; // null uses the global pool. -ProviderList create_providers(const PathConfig& paths, QThreadPool* pool = nullptr); +ProviderList createProviders(const PathConfig& paths, QThreadPool* pool = nullptr); } // namespace px::modrim::providers diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp index e74f770..3893a7c 100644 --- a/src/core/providers/local.cpp +++ b/src/core/providers/local.cpp @@ -31,9 +31,9 @@ LocalModProvider::LocalModProvider(QString localPath, QThreadPool* pool, : BaseModProvider(QStringLiteral("local"), QStringLiteral("#2ecc71"), std::move(localPath), pool, metadataCache) {} -QCoro::Task> LocalModProvider::discover(QString target_version, Profiler* timer, - MetadataCacheStore* metadata_cache, - bool force_reparse) { +QCoro::Task> LocalModProvider::discover(QString targetVersion, Profiler* timer, + MetadataCacheStore* metadataCache, + bool forceReparse) { Profiler ownTimer; Profiler& tm = timer ? *timer : ownTimer; @@ -56,8 +56,8 @@ QCoro::Task> LocalModProvider::discover(QString target_version, P } } - auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, - metadata_cache, force_reparse); + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, + metadataCache, forceReparse); if (discovered) { spdlog::info("LocalModProvider discovered {} mods", discovered->size()); } @@ -74,10 +74,10 @@ DownloadedModProvider::DownloadedModProvider(QString providerId, QString path, : BaseModProvider(std::move(providerId), QStringLiteral("#3498db"), std::move(path), pool, metadataCache) {} -QCoro::Task> DownloadedModProvider::discover(QString target_version, +QCoro::Task> DownloadedModProvider::discover(QString targetVersion, Profiler* timer, - MetadataCacheStore* metadata_cache, - bool force_reparse) { + MetadataCacheStore* metadataCache, + bool forceReparse) { Profiler ownTimer; Profiler& tm = timer ? *timer : ownTimer; @@ -100,10 +100,10 @@ QCoro::Task> DownloadedModProvider::discover(QString target_versi } } - auto discovered = co_await load_mods(std::move(dirs), std::move(target_version), tm, - metadata_cache, force_reparse); + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, + metadataCache, forceReparse); if (discovered) { - spdlog::info("{} discovered {} mods", provider_id().toStdString(), discovered->size()); + spdlog::info("{} discovered {} mods", providerId().toStdString(), discovered->size()); } co_return discovered; } diff --git a/src/core/providers/local.hpp b/src/core/providers/local.hpp index cb9110d..5ef6722 100644 --- a/src/core/providers/local.hpp +++ b/src/core/providers/local.hpp @@ -10,9 +10,9 @@ class LocalModProvider final : public BaseModProvider { LocalModProvider(QString localPath, QThreadPool* pool = nullptr, MetadataCacheStore* metadataCache = nullptr); - QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, - MetadataCacheStore* metadata_cache = nullptr, - bool force_reparse = false) override; + QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; }; // Mods downloaded by the app into the local folder: directories that carry a published file id. @@ -21,9 +21,9 @@ class DownloadedModProvider : public BaseModProvider { DownloadedModProvider(QString localPath, QThreadPool* pool = nullptr, MetadataCacheStore* metadataCache = nullptr); - QCoro::Task> discover(QString target_version, Profiler* timer = nullptr, - MetadataCacheStore* metadata_cache = nullptr, - bool force_reparse = false) override; + QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; protected: DownloadedModProvider(QString providerId, QString path, QThreadPool* pool, diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp index 17c9302..b00dd7d 100644 --- a/src/core/services/activation_service.cpp +++ b/src/core/services/activation_service.cpp @@ -29,23 +29,23 @@ using ModMap = QHash; using Edge = std::pair; // The About.xml mod behind `uuid`, or null when it is unknown or has no About.xml. -checker::AboutXmlModPtr about_mod(const QHash& all_mods, const QString& uuid) { - const auto it = all_mods.constFind(uuid); - return it == all_mods.cend() ? nullptr : std::dynamic_pointer_cast(it.value()); +checker::AboutXmlModPtr aboutMod(const QHash& allMods, const QString& uuid) { + const auto it = allMods.constFind(uuid); + return it == allMods.cend() ? nullptr : std::dynamic_pointer_cast(it.value()); } -QStringList enable_candidates(const QHash& all_mods, +QStringList enableCandidates(const QHash& allMods, const QStringList& enable, - const QSet& disable_set, - const QSet& active_set) { + const QSet& disableSet, + const QSet& activeSet) { QStringList candidates; QSet seen; for (const QString& uuid : enable) { - if (seen.contains(uuid) || disable_set.contains(uuid)) { + if (seen.contains(uuid) || disableSet.contains(uuid)) { continue; } seen.insert(uuid); - if (all_mods.contains(uuid) && !active_set.contains(uuid)) { + if (allMods.contains(uuid) && !activeSet.contains(uuid)) { candidates.append(uuid); } } @@ -54,13 +54,13 @@ QStringList enable_candidates(const QHash& all_mods, // (before, after) pairs between mods touching any of `candidates`. Edges through ids outside // `mods` (uninstalled optional mods) are dropped so they cannot chain unrelated mods together. -QList build_edges(const ModMap& mods, +QList buildEdges(const ModMap& mods, const QList& order, const QList& candidates, const sort::SortSettings& settings, - const sort::CommunityRules& community_rules) { + const sort::CommunityRules& communityRules) { ConstraintGraph graph; - graph.build(mods, order, settings, &community_rules); + graph.build(mods, order, settings, &communityRules); // Placement ignores active-to-active edges, so only edges incident to a candidate matter; // the graph still resolves the full alternative map. QSet incident; @@ -74,12 +74,12 @@ QList build_edges(const ModMap& mods, PackageId before; PackageId after; switch (edge.type) { - case EdgeType::LoadBefore: + case EdgeType::kLoadBefore: before = edge.source; after = edge.target; break; - case EdgeType::Dependency: - case EdgeType::LoadAfter: + case EdgeType::kDependency: + case EdgeType::kLoadAfter: before = edge.target; after = edge.source; break; @@ -94,7 +94,7 @@ QList build_edges(const ModMap& mods, } // Iterative Tarjan SCC. Each component lists its members in `nodes` order. -QList strongly_connected(const QStringList& nodes, +QList stronglyConnected(const QStringList& nodes, const QHash>& adjacent) { struct Frame { QString node; @@ -102,25 +102,25 @@ QList strongly_connected(const QStringList& nodes, qsizetype next = 0; }; - QHash index_of; + QHash indexOf; QHash lowlink; QStringList stack; - QSet on_stack; + QSet onStack; QList components; QList work; const auto visit = [&](const QString& node) { - const int index = static_cast(index_of.size()); - index_of.insert(node, index); + const int index = static_cast(indexOf.size()); + indexOf.insert(node, index); lowlink.insert(node, index); stack.append(node); - on_stack.insert(node); + onStack.insert(node); const QSet successors = adjacent.value(node); work.append(Frame{node, QStringList(successors.cbegin(), successors.cend()), 0}); }; for (const QString& root : nodes) { - if (index_of.contains(root)) { + if (indexOf.contains(root)) { continue; } visit(root); @@ -128,11 +128,11 @@ QList strongly_connected(const QStringList& nodes, Frame& frame = work.last(); if (frame.next < frame.children.size()) { const QString child = frame.children[frame.next++]; - if (!index_of.contains(child)) { + if (!indexOf.contains(child)) { // Invalidates `frame`; the loop re-reads work.last(). visit(child); - } else if (on_stack.contains(child)) { - lowlink[frame.node] = std::min(lowlink.value(frame.node), index_of.value(child)); + } else if (onStack.contains(child)) { + lowlink[frame.node] = std::min(lowlink.value(frame.node), indexOf.value(child)); } continue; } @@ -144,12 +144,12 @@ QList strongly_connected(const QStringList& nodes, const QString parent = work.last().node; lowlink[parent] = std::min(lowlink.value(parent), lowlink.value(node)); } - if (lowlink.value(node) == index_of.value(node)) { + if (lowlink.value(node) == indexOf.value(node)) { QStringList component; QString member; do { member = stack.takeLast(); - on_stack.remove(member); + onStack.remove(member); component.append(member); } while (member != node); components.append(component); @@ -166,146 +166,146 @@ QList strongly_connected(const QStringList& nodes, // itself may already violate constraints and must not be re-derived transitively. class Placement { public: - Placement(QStringList new_active, + Placement(QStringList newActive, QStringList candidates, - const QHash& all_mods, + const QHash& allMods, const sort::SortSettings& settings, - const sort::CommunityRules& community_rules); + const sort::CommunityRules& communityRules); QStringList assemble() const; private: - void add_edge(const PackageId& before, const PackageId& after); - std::pair> order_batch() const; - QHash propagated_ceilings(const QStringList& ordered, + void addEdge(const PackageId& before, const PackageId& after); + std::pair> orderBatch() const; + QHash propagatedCeilings(const QStringList& ordered, const QSet& cyclic) const; - int gap_for(const QString& uuid, + int gapFor(const QString& uuid, const QSet& cyclic, const QHash& ceilings, const QHash& gaps) const; - QStringList new_active_; - QStringList candidates_; - int end_; + QStringList m_newActive; + QStringList m_candidates; + int m_end; - QHash first_pos_; - QHash last_pos_; - QHash uuids_by_pid_; + QHash m_firstPos; + QHash m_lastPos; + QHash m_uuidsByPid; // Batch-internal adjacency plus direct bounds against active mods. - QHash> after_; - QHash> before_; - QHash floor_; - QHash ceiling_; + QHash> m_after; + QHash> m_before; + QHash m_floor; + QHash m_ceiling; }; -Placement::Placement(QStringList new_active, +Placement::Placement(QStringList newActive, QStringList candidates, - const QHash& all_mods, + const QHash& allMods, const sort::SortSettings& settings, - const sort::CommunityRules& community_rules) - : new_active_(std::move(new_active)), - candidates_(std::move(candidates)), - end_(static_cast(new_active_.size())) { + const sort::CommunityRules& communityRules) + : m_newActive(std::move(newActive)), + m_candidates(std::move(candidates)), + m_end(static_cast(m_newActive.size())) { ModMap mods; QList order; - for (qsizetype index = 0; index < new_active_.size(); ++index) { - const checker::AboutXmlModPtr mod = about_mod(all_mods, new_active_[index]); + for (qsizetype index = 0; index < m_newActive.size(); ++index) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, m_newActive[index]); if (!mod) { continue; } - const PackageId& pid = mod->package_id; - if (!first_pos_.contains(pid)) { - first_pos_.insert(pid, static_cast(index)); + const PackageId& pid = mod->packageId; + if (!m_firstPos.contains(pid)) { + m_firstPos.insert(pid, static_cast(index)); } - last_pos_.insert(pid, static_cast(index)); + m_lastPos.insert(pid, static_cast(index)); if (!mods.contains(pid)) { mods.insert(pid, mod); order.append(pid); } } - for (const QString& uuid : candidates_) { - after_.insert(uuid, {}); - before_.insert(uuid, {}); - const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); + for (const QString& uuid : m_candidates) { + m_after.insert(uuid, {}); + m_before.insert(uuid, {}); + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); if (!mod) { continue; } - const PackageId& pid = mod->package_id; - uuids_by_pid_[pid].append(uuid); + const PackageId& pid = mod->packageId; + m_uuidsByPid[pid].append(uuid); if (!mods.contains(pid)) { mods.insert(pid, mod); order.append(pid); } } - if (!uuids_by_pid_.isEmpty()) { + if (!m_uuidsByPid.isEmpty()) { for (const Edge& edge : - build_edges(mods, order, uuids_by_pid_.keys(), settings, community_rules)) { - add_edge(edge.first, edge.second); + buildEdges(mods, order, m_uuidsByPid.keys(), settings, communityRules)) { + addEdge(edge.first, edge.second); } } } -void Placement::add_edge(const PackageId& before, const PackageId& after) { - const QStringList before_uuids = uuids_by_pid_.value(before); - const QStringList after_uuids = uuids_by_pid_.value(after); - for (const QString& b : before_uuids) { - for (const QString& a : after_uuids) { +void Placement::addEdge(const PackageId& before, const PackageId& after) { + const QStringList beforeUuids = m_uuidsByPid.value(before); + const QStringList afterUuids = m_uuidsByPid.value(after); + for (const QString& b : beforeUuids) { + for (const QString& a : afterUuids) { if (a != b) { - after_[b].insert(a); - before_[a].insert(b); + m_after[b].insert(a); + m_before[a].insert(b); } } - if (const auto first = first_pos_.constFind(after); first != first_pos_.cend()) { - ceiling_[b] = std::min(ceiling_.value(b, first.value()), first.value()); + if (const auto first = m_firstPos.constFind(after); first != m_firstPos.cend()) { + m_ceiling[b] = std::min(m_ceiling.value(b, first.value()), first.value()); } } - if (const auto last = last_pos_.constFind(before); last != last_pos_.cend()) { - for (const QString& a : after_uuids) { - floor_[a] = std::max(floor_.value(a, last.value() + 1), last.value() + 1); + if (const auto last = m_lastPos.constFind(before); last != m_lastPos.cend()) { + for (const QString& a : afterUuids) { + m_floor[a] = std::max(m_floor.value(a, last.value() + 1), last.value() + 1); } } } QStringList Placement::assemble() const { - const auto [ordered, cyclic] = order_batch(); - const QHash ceilings = propagated_ceilings(ordered, cyclic); - QList buckets(end_ + 1); + const auto [ordered, cyclic] = orderBatch(); + const QHash ceilings = propagatedCeilings(ordered, cyclic); + QList buckets(m_end + 1); QHash gaps; for (const QString& uuid : ordered) { - const int gap = gap_for(uuid, cyclic, ceilings, gaps); + const int gap = gapFor(uuid, cyclic, ceilings, gaps); gaps.insert(uuid, gap); buckets[gap].append(uuid); } QStringList result; - for (qsizetype index = 0; index < new_active_.size(); ++index) { + for (qsizetype index = 0; index < m_newActive.size(); ++index) { result.append(buckets[index]); - result.append(new_active_[index]); + result.append(m_newActive[index]); } - result.append(buckets[end_]); + result.append(buckets[m_end]); return result; } -int Placement::gap_for(const QString& uuid, +int Placement::gapFor(const QString& uuid, const QSet& cyclic, const QHash& ceilings, const QHash& gaps) const { if (cyclic.contains(uuid)) { - return end_; + return m_end; } // Befores of an acyclic candidate sit in earlier components, so their gaps are final. std::optional earliest; - const auto raise_floor = [&earliest](int floor) { + const auto raiseFloor = [&earliest](int floor) { earliest = earliest ? std::max(*earliest, floor) : floor; }; - for (const QString& b : before_.value(uuid)) { - raise_floor(gaps.value(b)); + for (const QString& b : m_before.value(uuid)) { + raiseFloor(gaps.value(b)); } - if (const auto floor = floor_.constFind(uuid); floor != floor_.cend()) { - raise_floor(floor.value()); + if (const auto floor = m_floor.constFind(uuid); floor != m_floor.cend()) { + raiseFloor(floor.value()); } std::optional ceiling; @@ -314,14 +314,14 @@ int Placement::gap_for(const QString& uuid, } if (earliest) { if (ceiling && *earliest > *ceiling) { - return end_; + return m_end; } return *earliest; } - return ceiling ? *ceiling : end_; + return ceiling ? *ceiling : m_end; } -QHash Placement::propagated_ceilings(const QStringList& ordered, +QHash Placement::propagatedCeilings(const QStringList& ordered, const QSet& cyclic) const { // Earliest active position each candidate must precede, via batch chains. QHash ceilings; @@ -333,12 +333,12 @@ QHash Placement::propagated_ceilings(const QStringList& ordered, const auto tighten = [&bound](int value) { bound = bound ? std::min(*bound, value) : value; }; - for (const QString& a : after_.value(*it)) { + for (const QString& a : m_after.value(*it)) { if (const auto found = ceilings.constFind(a); found != ceilings.cend()) { tighten(found.value()); } } - if (const auto own = ceiling_.constFind(*it); own != ceiling_.cend()) { + if (const auto own = m_ceiling.constFind(*it); own != m_ceiling.cend()) { tighten(own.value()); } if (bound) { @@ -349,23 +349,23 @@ QHash Placement::propagated_ceilings(const QStringList& ordered, } // Topologically orders candidates by input order; cycle members are placed as a unit. -std::pair> Placement::order_batch() const { - auto components = strongly_connected(candidates_, after_); +std::pair> Placement::orderBatch() const { + auto components = stronglyConnected(m_candidates, m_after); - QHash input_index; - for (qsizetype index = 0; index < candidates_.size(); ++index) { - input_index.insert(candidates_[index], static_cast(index)); + QHash inputIndex; + for (qsizetype index = 0; index < m_candidates.size(); ++index) { + inputIndex.insert(m_candidates[index], static_cast(index)); } - QHash component_of; + QHash componentOf; QSet cyclic; for (int index = 0; index < components.size(); ++index) { QStringList& members = components[index]; - std::sort(members.begin(), members.end(), [&input_index](const QString& a, const QString& b) { - return input_index.value(a) < input_index.value(b); + std::sort(members.begin(), members.end(), [&inputIndex](const QString& a, const QString& b) { + return inputIndex.value(a) < inputIndex.value(b); }); for (const QString& member : members) { - component_of.insert(member, index); + componentOf.insert(member, index); } if (members.size() > 1) { cyclic.unite(QSet(members.cbegin(), members.cend())); @@ -374,10 +374,10 @@ std::pair> Placement::order_batch() const { QList> successors(components.size()); QList indegree(components.size(), 0); - for (auto it = after_.cbegin(); it != after_.cend(); ++it) { - const int source = component_of.value(it.key()); + for (auto it = m_after.cbegin(); it != m_after.cend(); ++it) { + const int source = componentOf.value(it.key()); for (const QString& successor : it.value()) { - const int target = component_of.value(successor); + const int target = componentOf.value(successor); if (target != source && !successors[source].contains(target)) { successors[source].insert(target); ++indegree[target]; @@ -390,7 +390,7 @@ std::pair> Placement::order_batch() const { std::priority_queue, std::greater<>> ready; for (int index = 0; index < components.size(); ++index) { if (indegree[index] == 0) { - ready.emplace(input_index.value(components[index].front()), index); + ready.emplace(inputIndex.value(components[index].front()), index); } } QStringList ordered; @@ -400,7 +400,7 @@ std::pair> Placement::order_batch() const { ordered.append(components[index]); for (const int target : successors[index]) { if (--indegree[target] == 0) { - ready.emplace(input_index.value(components[target].front()), target); + ready.emplace(inputIndex.value(components[target].front()), target); } } } @@ -409,21 +409,21 @@ std::pair> Placement::order_batch() const { } // namespace -ActivationService::ActivationService(CoreContext& ctx) : ctx_(ctx) {} +ActivationService::ActivationService(CoreContext& ctx) : m_ctx(ctx) {} -QStringList ActivationService::dependents_of(const QStringList& uuids, +QStringList ActivationService::dependentsOf(const QStringList& uuids, const QStringList& enable) const { const QSet excluded(uuids.cbegin(), uuids.cend()); - const QHash& all_mods = ctx_.allMods(); - const QStringList& active = ctx_.activeUuids(); + const QHash& allMods = m_ctx.allMods(); + const QStringList& active = m_ctx.activeUuids(); QSet remaining; const auto keep = [&](const QString& uuid) { if (excluded.contains(uuid)) { return; } - if (const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid)) { - remaining.insert(mod->package_id); + if (const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid)) { + remaining.insert(mod->packageId); } }; for (const QString& uuid : active) { @@ -435,21 +435,21 @@ QStringList ActivationService::dependents_of(const QStringList& uuids, QSet roots; for (const QString& uuid : excluded) { - const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); - if (mod && !remaining.contains(mod->package_id)) { - roots.insert(mod->package_id); + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && !remaining.contains(mod->packageId)) { + roots.insert(mod->packageId); } } if (roots.isEmpty()) { return {}; } - const ConstraintGraph& graph = ctx_.diagnosticsService().constraint_graph(); + const ConstraintGraph& graph = m_ctx.diagnosticsService().constraintGraph(); QSet dependents; QList pending(roots.cbegin(), roots.cend()); while (!pending.isEmpty()) { const PackageId target = pending.takeLast(); - const auto edges = graph.incoming_of_type(target, EdgeType::Dependency); + const auto edges = graph.incomingOfType(target, EdgeType::kDependency); for (const ConstraintEdge& edge : edges) { if (!roots.contains(edge.source) && !dependents.contains(edge.source)) { dependents.insert(edge.source); @@ -463,8 +463,8 @@ QStringList ActivationService::dependents_of(const QStringList& uuids, if (excluded.contains(uuid)) { continue; } - const checker::AboutXmlModPtr mod = about_mod(all_mods, uuid); - if (mod && dependents.contains(mod->package_id)) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && dependents.contains(mod->packageId)) { result.append(uuid); } } @@ -472,29 +472,29 @@ QStringList ActivationService::dependents_of(const QStringList& uuids, } bool ActivationService::apply(const QStringList& enable, const QStringList& disable) { - const QStringList& active = ctx_.activeUuids(); - const QSet disable_set(disable.cbegin(), disable.cend()); - QStringList new_active; + const QStringList& active = m_ctx.activeUuids(); + const QSet disableSet(disable.cbegin(), disable.cend()); + QStringList newActive; for (const QString& uuid : active) { - if (!disable_set.contains(uuid)) { - new_active.append(uuid); + if (!disableSet.contains(uuid)) { + newActive.append(uuid); } } - const QSet active_set(active.cbegin(), active.cend()); + const QSet activeSet(active.cbegin(), active.cend()); QStringList candidates = - enable_candidates(ctx_.allMods(), enable, disable_set, active_set); + enableCandidates(m_ctx.allMods(), enable, disableSet, activeSet); - const Placement placement(std::move(new_active), + const Placement placement(std::move(newActive), std::move(candidates), - ctx_.allMods(), - ctx_.config().sort, - ctx_.diagnosticsService().community_rules()); + m_ctx.allMods(), + m_ctx.config().sort, + m_ctx.diagnosticsService().communityRules()); const QStringList result = placement.assemble(); if (result == active) { return false; } - ctx_.setActive(result); + m_ctx.setActive(result); return true; } diff --git a/src/core/services/activation_service.hpp b/src/core/services/activation_service.hpp index 1081e83..5be9740 100644 --- a/src/core/services/activation_service.hpp +++ b/src/core/services/activation_service.hpp @@ -14,14 +14,14 @@ class ActivationService { // Active mods that depend on packages lost once `uuids` are disabled and `enable` is // enabled. The inputs themselves are excluded; order follows the active list. - QStringList dependents_of(const QStringList& uuids, const QStringList& enable = {}) const; + QStringList dependentsOf(const QStringList& uuids, const QStringList& enable = {}) const; // Applies a batch of enable/disable changes and returns whether the active state changed. // A uuid in both lists is disabled. bool apply(const QStringList& enable = {}, const QStringList& disable = {}); private: - CoreContext& ctx_; + CoreContext& m_ctx; }; } // namespace px::modrim diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp index cb8361e..6a01b53 100644 --- a/src/core/services/diagnostics_service.cpp +++ b/src/core/services/diagnostics_service.cpp @@ -20,11 +20,11 @@ namespace { // Cached databases older than this are refreshed in the background. constexpr double kDatabaseMaxAgeS = 24 * 3600; -bool needs_refresh(bool loaded, const std::optional& age) { +bool needsRefresh(bool loaded, const std::optional& age) { return !loaded || !age || *age > kDatabaseMaxAgeS; } -std::vector> default_checkers() { +std::vector> defaultCheckers() { std::vector> checkers; checkers.push_back(std::make_unique()); checkers.push_back(std::make_unique()); @@ -35,29 +35,29 @@ std::vector> default_checkers() { return checkers; } -view::SidebarEntry make_entry(view::SidebarEntry::Kind kind, +view::SidebarEntry makeEntry(view::SidebarEntry::Kind kind, QString label, - QSet visible_uuids, - QString provider_id = {}) { + QSet visibleUuids, + QString providerId = {}) { view::SidebarEntry entry; entry.kind = kind; entry.label = std::move(label); - entry.visible_uuids = std::move(visible_uuids); - entry.provider_id = std::move(provider_id); + entry.visibleUuids = std::move(visibleUuids); + entry.providerId = std::move(providerId); return entry; } } // namespace DiagnosticsService::DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network) - : ctx_(ctx), - no_version_warning_service_(ctx.configService(), network), - use_this_instead_service_(ctx.configService(), network), - checker_(default_checkers(), + : m_ctx(ctx), + m_noVersionWarningService(ctx.configService(), network), + m_useThisInsteadService(ctx.configService(), network), + m_checker(defaultCheckers(), ctx.config().sort, ctx.targetVersion(), [this](const checker::DiagnosticsByUuid& diagnostics) { - on_checker_diagnostics_changed(diagnostics); + onCheckerDiagnosticsChanged(diagnostics); }) {} // ── Lifecycle ────────────────────────────────────────────── @@ -65,18 +65,18 @@ DiagnosticsService::DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& QCoro::Task> DiagnosticsService::initialize(Profiler* timer) { Profiler local; Profiler& t = timer != nullptr ? *timer : local; - auto init_scope = t("diagnostics.init"); + auto initScope = t("diagnostics.init"); { auto scope = t("ensure_databases"); - co_await ensure_databases(); + co_await ensureDatabases(); } std::optional rules; { auto scope = t("load_community_rules"); - if (const auto path = community_rules_path()) { + if (const auto path = communityRulesPath()) { auto loaded = co_await QtConcurrent::run( - [p = *path] { return sort::load_community_rules(p); }); + [p = *path] { return sort::loadCommunityRules(p); }); if (!loaded) { co_return std::unexpected(loaded.error()); } @@ -84,65 +84,65 @@ QCoro::Task> DiagnosticsService::initialize(Profiler* timer) { } } if (rules && !rules->isEmpty()) { - checker_.set_community_rules(std::move(*rules)); + m_checker.setCommunityRules(std::move(*rules)); } co_return {}; } -QCoro::Task DiagnosticsService::ensure_databases() { +QCoro::Task DiagnosticsService::ensureDatabases() { // The cache files are read off the UI thread; the service is only touched from here. auto nvw = co_await QtConcurrent::run( - [this] { return no_version_warning_service_.load_if_exists(); }); - const bool nvw_loaded = !nvw.isEmpty(); - if (nvw_loaded) { - checker_.set_no_version_warning(std::move(nvw)); + [this] { return m_noVersionWarningService.loadIfExists(); }); + const bool nvwLoaded = !nvw.isEmpty(); + if (nvwLoaded) { + m_checker.setNoVersionWarning(std::move(nvw)); } auto uti = co_await QtConcurrent::run( - [this] { return use_this_instead_service_.load_if_exists(); }); - const bool uti_loaded = !uti.isEmpty(); - if (uti_loaded) { - checker_.set_use_this_instead(std::move(uti)); + [this] { return m_useThisInsteadService.loadIfExists(); }); + const bool utiLoaded = !uti.isEmpty(); + if (utiLoaded) { + m_checker.setUseThisInstead(std::move(uti)); } - const bool refresh_nvw = - needs_refresh(nvw_loaded, no_version_warning_service_.cache_age_s()); - const bool refresh_uti = needs_refresh(uti_loaded, use_this_instead_service_.cache_age_s()); - if ((refresh_nvw || refresh_uti) && (!db_task_ || db_task_->isReady())) { - db_task_ = download_databases(refresh_nvw, refresh_uti); + const bool refreshNvw = + needsRefresh(nvwLoaded, m_noVersionWarningService.cacheAgeS()); + const bool refreshUti = needsRefresh(utiLoaded, m_useThisInsteadService.cacheAgeS()); + if ((refreshNvw || refreshUti) && (!m_dbTask || m_dbTask->isReady())) { + m_dbTask = downloadDatabases(refreshNvw, refreshUti); } } -QCoro::Task DiagnosticsService::download_databases(bool nvw, bool uti) { - background_task_changed.notify(QStringLiteral(u"Downloading rule databases\u2026")); +QCoro::Task DiagnosticsService::downloadDatabases(bool nvw, bool uti) { + backgroundTaskChanged.notify(QStringLiteral(u"Downloading rule databases\u2026")); bool changed = false; if (nvw) { - auto pids = co_await no_version_warning_service_.ensure(true); + auto pids = co_await m_noVersionWarningService.ensure(true); if (!pids.isEmpty()) { - checker_.set_no_version_warning(std::move(pids)); + m_checker.setNoVersionWarning(std::move(pids)); changed = true; } } if (uti) { - auto entries = co_await use_this_instead_service_.ensure(true); + auto entries = co_await m_useThisInsteadService.ensure(true); if (!entries.isEmpty()) { - checker_.set_use_this_instead(std::move(entries)); + m_checker.setUseThisInstead(std::move(entries)); changed = true; } } if (changed) { rebuild(); } - background_task_changed.notify(QString{}); + backgroundTaskChanged.notify(QString{}); } -std::optional DiagnosticsService::community_rules_path() const { - const AppConfig& config = ctx_.config(); +std::optional DiagnosticsService::communityRulesPath() const { + const AppConfig& config = m_ctx.config(); if (!config.sort.use_community_rules) { return std::nullopt; } QString path = QString::fromStdString(config.paths.community_rules_file); if (path.isEmpty()) { - path = QDir(ctx_.configService().directory()).filePath(QStringLiteral("communityRules.json")); + path = QDir(m_ctx.configService().directory()).filePath(QStringLiteral("communityRules.json")); } if (!QFileInfo::exists(path)) { return std::nullopt; @@ -153,76 +153,76 @@ std::optional DiagnosticsService::community_rules_path() const { // ── Mutations ────────────────────────────────────────────── void DiagnosticsService::rebuild(Profiler* timer) { - rebuild(ctx_.activeUuids(), timer); + rebuild(m_ctx.activeUuids(), timer); } -void DiagnosticsService::rebuild(const QStringList& active_uuids, Profiler* timer) { - last_active_uuids_ = active_uuids; +void DiagnosticsService::rebuild(const QStringList& activeUuids, Profiler* timer) { + m_lastActiveUuids = activeUuids; Profiler local; Profiler& t = timer != nullptr ? *timer : local; auto scope = t("diagnostics.rebuild"); - checker_.rebuild(ctx_.allMods(), active_uuids, &t); + m_checker.rebuild(m_ctx.allMods(), activeUuids, &t); } -void DiagnosticsService::reorder(const QStringList& active_uuids) { - last_active_uuids_ = active_uuids; - checker_.reorder(active_uuids); +void DiagnosticsService::reorder(const QStringList& activeUuids) { + m_lastActiveUuids = activeUuids; + m_checker.reorder(activeUuids); } // ── Queries ──────────────────────────────────────────────── -QHash DiagnosticsService::active_mods_by_pid() const { - return checker_.active_mods(); +QHash DiagnosticsService::activeModsByPid() const { + return m_checker.activeMods(); } -const checker::ConstraintGraph& DiagnosticsService::constraint_graph() const { - return checker_.graph(); +const checker::ConstraintGraph& DiagnosticsService::constraintGraph() const { + return m_checker.graph(); } -QList DiagnosticsService::active_ordered_pids() const { - return checker_.ordered_pids(); +QList DiagnosticsService::activeOrderedPids() const { + return m_checker.orderedPids(); } -const sort::CommunityRules& DiagnosticsService::community_rules() const { - return checker_.community_rules(); +const sort::CommunityRules& DiagnosticsService::communityRules() const { + return m_checker.communityRules(); } -QList DiagnosticsService::issues_for(const QString& uuid) const { - const checker::ModDiagnostics* diag = checker_.diagnostics_for(uuid); - return diag != nullptr ? to_issue_views(*diag) : QList{}; +QList DiagnosticsService::issuesFor(const QString& uuid) const { + const checker::ModDiagnostics* diag = m_checker.diagnosticsFor(uuid); + return diag != nullptr ? toIssueViews(*diag) : QList{}; } -std::optional DiagnosticsService::summary_for(const QString& uuid) const { - const auto it = last_summary_.constFind(uuid); - if (it == last_summary_.cend()) { +std::optional DiagnosticsService::summaryFor(const QString& uuid) const { + const auto it = m_lastSummary.constFind(uuid); + if (it == m_lastSummary.cend()) { return std::nullopt; } return it.value(); } -int DiagnosticsService::active_error_count() const { - const auto diagnostics = checker_.active_mod_diagnostics(); +int DiagnosticsService::activeErrorCount() const { + const auto diagnostics = m_checker.activeModDiagnostics(); int count = 0; for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { - count += diag.has_errors() ? 1 : 0; + count += diag.hasErrors() ? 1 : 0; } return count; } // ── Conversions ──────────────────────────────────────────── -QList DiagnosticsService::to_issue_views(const checker::ModDiagnostics& diag) { +QList DiagnosticsService::toIssueViews(const checker::ModDiagnostics& diag) { QList views; - const auto append = [&views](const QList& issues, bool is_error) { + const auto append = [&views](const QList& issues, bool isError) { for (const checker::ModIssue& issue : issues) { view::ModIssueView view; view.category = issue.category; - view.category_display_name = - issue.category_display_name.isEmpty() ? issue.category : issue.category_display_name; - if (!issue.detail_message.isEmpty()) { - view.detail = issue.detail_message; + view.categoryDisplayName = + issue.categoryDisplayName.isEmpty() ? issue.category : issue.categoryDisplayName; + if (!issue.detailMessage.isEmpty()) { + view.detail = issue.detailMessage; } - view.is_error = is_error; + view.isError = isError; views.append(std::move(view)); } }; @@ -231,92 +231,92 @@ QList DiagnosticsService::to_issue_views(const checker::ModD return views; } -view::ModDiagnosticsView DiagnosticsService::to_view(const checker::ModDiagnostics& diag) { +view::ModDiagnosticsView DiagnosticsService::toView(const checker::ModDiagnostics& diag) { view::ModDiagnosticsView view; - view.has_errors = diag.has_errors(); - view.has_warnings = diag.has_warnings(); - view.error_tooltip = diag.error_tooltip(); - view.warning_tooltip = diag.warning_tooltip(); + view.hasErrors = diag.hasErrors(); + view.hasWarnings = diag.hasWarnings(); + view.errorTooltip = diag.errorTooltip(); + view.warningTooltip = diag.warningTooltip(); return view; } // ── Checker callback ─────────────────────────────────────── -void DiagnosticsService::on_checker_diagnostics_changed( +void DiagnosticsService::onCheckerDiagnosticsChanged( const checker::DiagnosticsByUuid& diagnostics) { QHash summary; summary.reserve(diagnostics.size()); for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { - summary.insert(it.key(), to_view(it.value())); + summary.insert(it.key(), toView(it.value())); } - last_summary_ = std::move(summary); + m_lastSummary = std::move(summary); // Sidebar first: the summary handler re-applies the current sidebar filter and must see the // entries for this state, not the previous one. - sidebar_entries_changed.notify(build_sidebar_entries()); - diagnostics_summary_changed.notify(last_summary_); - status_message_changed.notify(format_status()); + sidebarEntriesChanged.notify(buildSidebarEntries()); + diagnosticsSummaryChanged.notify(m_lastSummary); + statusMessageChanged.notify(formatStatus()); } // ── Status ───────────────────────────────────────────────── -QString DiagnosticsService::format_status() const { - const auto diagnostics = checker_.active_mod_diagnostics(); - int err_count = 0; - int warn_count = 0; +QString DiagnosticsService::formatStatus() const { + const auto diagnostics = m_checker.activeModDiagnostics(); + int errCount = 0; + int warnCount = 0; for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { - err_count += diag.has_errors() ? 1 : 0; - warn_count += diag.has_warnings() ? 1 : 0; + errCount += diag.hasErrors() ? 1 : 0; + warnCount += diag.hasWarnings() ? 1 : 0; } - QStringList parts{QString::number(last_active_uuids_.size()) + QStringLiteral(" active")}; - if (err_count > 0) { - parts.append(QString::number(err_count) + QStringLiteral(" with errors")); + QStringList parts{QString::number(m_lastActiveUuids.size()) + QStringLiteral(" active")}; + if (errCount > 0) { + parts.append(QString::number(errCount) + QStringLiteral(" with errors")); } - if (warn_count > 0) { - parts.append(QString::number(warn_count) + QStringLiteral(" with warnings")); + if (warnCount > 0) { + parts.append(QString::number(warnCount) + QStringLiteral(" with warnings")); } return parts.join(QStringLiteral(" | ")); } // ── Sidebar ──────────────────────────────────────────────── -QList DiagnosticsService::build_sidebar_entries() { - const QHash& mods = ctx_.allMods(); - if (!sidebar_base_ || !sidebar_base_->matches(mods)) { - sidebar_base_.emplace(mods); +QList DiagnosticsService::buildSidebarEntries() { + const QHash& mods = m_ctx.allMods(); + if (!m_sidebarBase || !m_sidebarBase->matches(mods)) { + m_sidebarBase.emplace(mods); } - const SidebarBase& base = *sidebar_base_; + const SidebarBase& base = *m_sidebarBase; QSet errors = base.invalid; QSet warnings; - const auto diagnostics = checker_.active_mod_diagnostics(); + const auto diagnostics = m_checker.activeModDiagnostics(); for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { if (!mods.contains(it.key())) { continue; } - if (it.value().has_errors()) { + if (it.value().hasErrors()) { errors.insert(it.key()); } - if (it.value().has_warnings()) { + if (it.value().hasWarnings()) { warnings.insert(it.key()); } } - const QSet active(last_active_uuids_.cbegin(), last_active_uuids_.cend()); - QSet inactive = base.all_uuids; + const QSet active(m_lastActiveUuids.cbegin(), m_lastActiveUuids.cend()); + QSet inactive = base.allUuids; inactive.subtract(active); using Kind = view::SidebarEntry::Kind; QList entries{ - make_entry(Kind::All, QStringLiteral("All"), base.all_uuids), - make_entry(Kind::Active, QStringLiteral("Active"), active), - make_entry(Kind::Inactive, QStringLiteral("Inactive"), std::move(inactive)), - make_entry(Kind::WithErrors, QStringLiteral("With errors"), std::move(errors)), - make_entry(Kind::WithWarnings, QStringLiteral("With warnings"), std::move(warnings)), + makeEntry(Kind::kAll, QStringLiteral("All"), base.allUuids), + makeEntry(Kind::kActive, QStringLiteral("Active"), active), + makeEntry(Kind::kInactive, QStringLiteral("Inactive"), std::move(inactive)), + makeEntry(Kind::kWithErrors, QStringLiteral("With errors"), std::move(errors)), + makeEntry(Kind::kWithWarnings, QStringLiteral("With warnings"), std::move(warnings)), }; - for (auto it = base.by_provider.cbegin(); it != base.by_provider.cend(); ++it) { - entries.append(make_entry(Kind::Provider, - view::provider_label(it.key()), + for (auto it = base.byProvider.cbegin(); it != base.byProvider.cend(); ++it) { + entries.append(makeEntry(Kind::kProvider, + view::providerLabel(it.key()), it.value(), it.key())); } @@ -326,8 +326,8 @@ QList DiagnosticsService::build_sidebar_entries() { DiagnosticsService::SidebarBase::SidebarBase(const QHash& current) : mods(current) { for (auto it = current.cbegin(); it != current.cend(); ++it) { - all_uuids.insert(it.key()); - by_provider[it.value()->provider_id].insert(it.key()); + allUuids.insert(it.key()); + byProvider[it.value()->providerId].insert(it.key()); if (!it.value()->valid) { invalid.insert(it.key()); } diff --git a/src/core/services/diagnostics_service.hpp b/src/core/services/diagnostics_service.hpp index 87428c6..1758e97 100644 --- a/src/core/services/diagnostics_service.hpp +++ b/src/core/services/diagnostics_service.hpp @@ -34,10 +34,10 @@ class DiagnosticsService { // `network` is not owned and must outlive the service. DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network); - Event> diagnostics_summary_changed; - Event status_message_changed; - Event background_task_changed; - Event> sidebar_entries_changed; + Event> diagnosticsSummaryChanged; + Event statusMessageChanged; + Event backgroundTaskChanged; + Event> sidebarEntriesChanged; // ── Lifecycle ───────────────────────────────────────────── @@ -49,55 +49,55 @@ class DiagnosticsService { // Rebuilds the checker for the context's active mods, or for `active_uuids`. void rebuild(Profiler* timer = nullptr); - void rebuild(const QStringList& active_uuids, Profiler* timer = nullptr); + void rebuild(const QStringList& activeUuids, Profiler* timer = nullptr); // Notifies the checker of a new active mod order. - void reorder(const QStringList& active_uuids); + void reorder(const QStringList& activeUuids); // ── Queries ─────────────────────────────────────────────── - QHash active_mods_by_pid() const; - const checker::ConstraintGraph& constraint_graph() const; - QList active_ordered_pids() const; - const sort::CommunityRules& community_rules() const; + QHash activeModsByPid() const; + const checker::ConstraintGraph& constraintGraph() const; + QList activeOrderedPids() const; + const sort::CommunityRules& communityRules() const; // Views of every issue on a mod; empty when the mod is not active. - QList issues_for(const QString& uuid) const; - std::optional summary_for(const QString& uuid) const; - int active_error_count() const; + QList issuesFor(const QString& uuid) const; + std::optional summaryFor(const QString& uuid) const; + int activeErrorCount() const; private: // Partitions that do not depend on diagnostics; reused until the mod list changes. struct SidebarBase { QHash mods; - QSet all_uuids; + QSet allUuids; QSet invalid; - QMap> by_provider; + QMap> byProvider; explicit SidebarBase(const QHash& current); bool matches(const QHash& current) const; }; - QCoro::Task ensure_databases(); - QCoro::Task download_databases(bool nvw, bool uti); + QCoro::Task ensureDatabases(); + QCoro::Task downloadDatabases(bool nvw, bool uti); // The community rules file to load, or nullopt when rules are disabled or no file exists. - std::optional community_rules_path() const; - - static QList to_issue_views(const checker::ModDiagnostics& diag); - static view::ModDiagnosticsView to_view(const checker::ModDiagnostics& diag); - - void on_checker_diagnostics_changed(const checker::DiagnosticsByUuid& diagnostics); - QString format_status() const; - QList build_sidebar_entries(); - - CoreContext& ctx_; - checker::NoVersionWarningService no_version_warning_service_; - checker::UseThisInsteadService use_this_instead_service_; - checker::ModChecker checker_; - std::optional> db_task_; - QStringList last_active_uuids_; - QHash last_summary_; - std::optional sidebar_base_; + std::optional communityRulesPath() const; + + static QList toIssueViews(const checker::ModDiagnostics& diag); + static view::ModDiagnosticsView toView(const checker::ModDiagnostics& diag); + + void onCheckerDiagnosticsChanged(const checker::DiagnosticsByUuid& diagnostics); + QString formatStatus() const; + QList buildSidebarEntries(); + + CoreContext& m_ctx; + checker::NoVersionWarningService m_noVersionWarningService; + checker::UseThisInsteadService m_useThisInsteadService; + checker::ModChecker m_checker; + std::optional> m_dbTask; + QStringList m_lastActiveUuids; + QHash m_lastSummary; + std::optional m_sidebarBase; }; } // namespace px::modrim diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp index 882cd8b..c91ff93 100644 --- a/src/core/services/game_launcher.cpp +++ b/src/core/services/game_launcher.cpp @@ -19,16 +19,16 @@ namespace px::modrim::services { namespace { -Result direct_argv(const QString& exe, const AppConfig& cfg, QHash& env) { +Result directArgv(const QString& exe, const AppConfig& cfg, QHash& env) { const QString args = QString::fromStdString(cfg.launch_args); - if (current_platform() == Platform::MacOS) { - auto split = split_arguments(args); + if (currentPlatform() == Platform::kMacOs) { + auto split = splitArguments(args); if (!split) { return std::unexpected(split.error()); } return QStringList{QStringLiteral("open"), exe, QStringLiteral("--args")} + *split; } - auto command = build_direct_command(exe, QString::fromStdString(cfg.launch_wrapper), args); + auto command = buildDirectCommand(exe, QString::fromStdString(cfg.launch_wrapper), args); if (!command) { return std::unexpected(command.error()); } @@ -39,47 +39,47 @@ Result direct_argv(const QString& exe, const AppConfig& cfg, QHash< } // namespace GameLauncher::GameLauncher(CoreContext& ctx, ProcessLister lister, MonitorTiming timing) - : ctx_(ctx), lister_(lister), monitor_(std::move(lister), timing) {} + : m_ctx(ctx), m_lister(lister), m_monitor(std::move(lister), timing) {} QCoro::Task> GameLauncher::launch(LaunchStrategy strategy) { - if (ctx_.config().paths.game.empty()) { + if (m_ctx.config().paths.game.empty()) { spdlog::warn("Launch aborted — game path not configured"); co_return std::unexpected(Error{QStringLiteral("Game path not configured")}); } spdlog::info("Launching game with strategy: {}", - strategy == LaunchStrategy::Direct ? "Direct" : "Steam"); - co_await monitor_.capture_baseline(); - if (strategy == LaunchStrategy::Direct) { - co_return co_await launch_direct(); + strategy == LaunchStrategy::kDirect ? "Direct" : "Steam"); + co_await m_monitor.captureBaseline(); + if (strategy == LaunchStrategy::kDirect) { + co_return co_await launchDirect(); } - co_return co_await launch_steam(); + co_return co_await launchSteam(); } -bool GameLauncher::is_running() const { - return is_game_running(lister_); +bool GameLauncher::isRunning() const { + return isGameRunning(m_lister); } -QCoro::Task GameLauncher::wait_for_start(std::chrono::milliseconds timeout) { - return monitor_.wait_for_start(timeout); +QCoro::Task GameLauncher::waitForStart(std::chrono::milliseconds timeout) { + return m_monitor.waitForStart(timeout); } -QCoro::Task> GameLauncher::wait_for_exit() { - return monitor_.wait_for_exit(); +QCoro::Task> GameLauncher::waitForExit() { + return m_monitor.waitForExit(); } -QCoro::Task> GameLauncher::launch_direct() { - const QString game = QString::fromStdString(ctx_.config().paths.game); - const auto exe = find_executable(game); +QCoro::Task> GameLauncher::launchDirect() { + const QString game = QString::fromStdString(m_ctx.config().paths.game); + const auto exe = findExecutable(game); if (!exe) { spdlog::warn("Direct launch failed — executable not found in {}", game.toStdString()); co_return std::unexpected(Error{QStringLiteral("Game executable not found")}); } - ensure_steam_appid(game); + ensureSteamAppid(game); QHash env; - auto argv = direct_argv(*exe, ctx_.config(), env); + auto argv = directArgv(*exe, m_ctx.config(), env); if (!argv) { spdlog::warn("Invalid launch arguments: {}", argv.error().message.toStdString()); co_return std::unexpected( @@ -110,14 +110,14 @@ QCoro::Task> GameLauncher::launch_direct() { co_return std::unexpected(Error{QStringLiteral("Failed to launch: %1").arg(reason)}); } - monitor_.attach_process(std::move(proc)); + m_monitor.attachProcess(std::move(proc)); co_return QStringLiteral("Game launched"); } -QCoro::Task> GameLauncher::launch_steam() { - const QString args = QString::fromStdString(ctx_.config().launch_args); +QCoro::Task> GameLauncher::launchSteam() { + const QString args = QString::fromStdString(m_ctx.config().launch_args); auto opened = co_await providers::runOnPool>(nullptr, [args] { - return open_steam_url(args); + return openSteamUrl(args); }); if (!opened) { spdlog::warn("Steam launch failed: {}", opened.error().message.toStdString()); diff --git a/src/core/services/game_launcher.hpp b/src/core/services/game_launcher.hpp index 4fe4f14..183414c 100644 --- a/src/core/services/game_launcher.hpp +++ b/src/core/services/game_launcher.hpp @@ -20,28 +20,28 @@ namespace px::modrim::services { class GameLauncher { public: // `ctx` is not owned and must outlive the launcher. - GameLauncher(CoreContext& ctx, ProcessLister lister = host_process_lister, + GameLauncher(CoreContext& ctx, ProcessLister lister = hostProcessLister, MonitorTiming timing = {}); // Resolves to a status message when the launch was started, or to the reason it was not. QCoro::Task> launch(LaunchStrategy strategy); // Whether a game process is running right now. Blocks while listing processes. - bool is_running() const; + bool isRunning() const; - QCoro::Task wait_for_start(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + QCoro::Task waitForStart(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); // Resolves to the spawned process's exit code, or nullopt when it was not spawned here or // has not exited. - QCoro::Task> wait_for_exit(); + QCoro::Task> waitForExit(); private: - QCoro::Task> launch_direct(); - QCoro::Task> launch_steam(); + QCoro::Task> launchDirect(); + QCoro::Task> launchSteam(); - CoreContext& ctx_; - ProcessLister lister_; - GameProcessMonitor monitor_; + CoreContext& m_ctx; + ProcessLister m_lister; + GameProcessMonitor m_monitor; }; } // namespace px::modrim::services diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 45cfc92..1510dce 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -50,15 +50,15 @@ const QSet kProcessNames = { }; // Simple POSIX shlex-style tokenizer. Handles single/double quotes and backslash escapes. -Result split_posix(const QString& text) { +Result splitPosix(const QString& text) { QStringList tokens; QString current; - enum State { Space, Word, SingleQuote, DoubleQuote } state = Space; + enum State { kSpace, kWord, kSingleQuote, kDoubleQuote } state = kSpace; bool escaped = false; for (const QChar ch : text) { if (escaped) { - if (state == DoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && ch != u'`') { + if (state == kDoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && ch != u'`') { current.append(u'\\'); } current.append(ch); @@ -67,52 +67,52 @@ Result split_posix(const QString& text) { } switch (state) { - case Space: + case kSpace: if (ch.isSpace()) { continue; } if (ch == u'\\') { escaped = true; - state = Word; + state = kWord; } else if (ch == u'\'') { - state = SingleQuote; + state = kSingleQuote; } else if (ch == u'"') { - state = DoubleQuote; + state = kDoubleQuote; } else { current.append(ch); - state = Word; + state = kWord; } break; - case Word: + case kWord: if (ch.isSpace()) { tokens.append(current); current.clear(); - state = Space; + state = kSpace; } else if (ch == u'\\') { escaped = true; } else if (ch == u'\'') { - state = SingleQuote; + state = kSingleQuote; } else if (ch == u'"') { - state = DoubleQuote; + state = kDoubleQuote; } else { current.append(ch); } break; - case SingleQuote: + case kSingleQuote: if (ch == u'\'') { - state = Word; + state = kWord; } else { current.append(ch); } break; - case DoubleQuote: + case kDoubleQuote: if (ch == u'\\') { escaped = true; } else if (ch == u'"') { - state = Word; + state = kWord; } else { current.append(ch); } @@ -120,10 +120,10 @@ Result split_posix(const QString& text) { } } - if (escaped || state == SingleQuote || state == DoubleQuote) { + if (escaped || state == kSingleQuote || state == kDoubleQuote) { return std::unexpected(Error{QStringLiteral("No closing quotation")}); } - if (state == Word || !current.isEmpty()) { + if (state == kWord || !current.isEmpty()) { tokens.append(current); } return tokens; @@ -131,7 +131,7 @@ Result split_posix(const QString& text) { // Python's shlex.split(posix=False), which the Windows launch path uses: quotes are kept in the // token and there are no escapes, so whitespace inside quotes does not split. -Result split_windows(const QString& text) { +Result splitWindows(const QString& text) { QStringList tokens; QString current; bool inToken = false; @@ -168,34 +168,34 @@ Result split_windows(const QString& text) { return tokens; } -bool is_env_assignment(const QString& token) { +bool isEnvAssignment(const QString& token) { static const QRegularExpression pattern(QStringLiteral("^[A-Za-z_][A-Za-z0-9_]*=")); return pattern.match(token).hasMatch(); } } // namespace -Platform current_platform() { +Platform currentPlatform() { #if defined(Q_OS_MACOS) return Platform::MacOS; #elif defined(Q_OS_WIN) return Platform::Windows; #else - return Platform::Linux; + return Platform::kLinux; #endif } -Result split_arguments(const QString& text, Platform platform) { - return platform == Platform::Windows ? split_windows(text) : split_posix(text); +Result splitArguments(const QString& text, Platform platform) { + return platform == Platform::kWindows ? splitWindows(text) : splitPosix(text); } -Result build_direct_command(const QString& exe, const QString& wrapper, +Result buildDirectCommand(const QString& exe, const QString& wrapper, const QString& args, Platform platform) { - auto splitArgs = split_arguments(args, platform); + auto splitArgs = splitArguments(args, platform); if (!splitArgs) { return std::unexpected(splitArgs.error()); } - auto splitWrapper = split_arguments(wrapper, platform); + auto splitWrapper = splitArguments(wrapper, platform); if (!splitWrapper) { return std::unexpected(splitWrapper.error()); } @@ -206,7 +206,7 @@ Result build_direct_command(const QString& exe, const QString& wr QStringList tokens = *splitWrapper; DirectCommand result; - while (!tokens.isEmpty() && is_env_assignment(tokens.front())) { + while (!tokens.isEmpty() && isEnvAssignment(tokens.front())) { const QString token = tokens.takeFirst(); const qsizetype eq = token.indexOf(u'='); result.env.insert(token.left(eq), token.mid(eq + 1)); @@ -224,9 +224,9 @@ Result build_direct_command(const QString& exe, const QString& wr return result; } -std::optional find_executable(const QString& game_path, Platform platform) { - const QDir dir(game_path); - if (platform == Platform::Linux) { +std::optional findExecutable(const QString& gamePath, Platform platform) { + const QDir dir(gamePath); + if (platform == Platform::kLinux) { for (const char* name : {"RimWorld", "RimWorldLinux"}) { const QString candidate = dir.filePath(QString::fromLatin1(name)); const QFileInfo info(candidate); @@ -236,7 +236,7 @@ std::optional find_executable(const QString& game_path, Platform platfo } return std::nullopt; } - if (platform == Platform::MacOS) { + if (platform == Platform::kMacOs) { const QString primary = dir.filePath(QStringLiteral("RimWorld.app")); if (QFileInfo(primary).isDir()) { return primary; @@ -257,33 +257,33 @@ std::optional find_executable(const QString& game_path, Platform platfo return std::nullopt; } -void ensure_steam_appid(const QString& game_path, Platform platform) { +void ensureSteamAppid(const QString& gamePath, Platform platform) { const QString targetDir = - platform == Platform::MacOS ? QFileInfo(game_path).dir().absolutePath() : game_path; + platform == Platform::kMacOs ? QFileInfo(gamePath).dir().absolutePath() : gamePath; const QString path = QDir(targetDir).filePath(QStringLiteral("steam_appid.txt")); if (QFileInfo::exists(path)) { return; } - const std::string_view appId(RIMWORLD_STEAM_APP_ID.data(), RIMWORLD_STEAM_APP_ID.size()); - if (auto written = write_file_atomically(path, appId); !written) { + const std::string_view appId(kRimworldSteamAppId.data(), kRimworldSteamAppId.size()); + if (auto written = writeFileAtomically(path, appId); !written) { spdlog::warn("Failed to create steam_appid.txt: {}", written.error().message.toStdString()); } else { spdlog::debug("Created {}", path.toStdString()); } } -QString steam_run_url(const QString& launch_args) { - const QString trimmed = launch_args.trimmed(); +QString steamRunUrl(const QString& launchArgs) { + const QString trimmed = launchArgs.trimmed(); if (trimmed.isEmpty()) { - return QStringLiteral("steam://rungameid/%1").arg(QString(RIMWORLD_STEAM_APP_ID)); + return QStringLiteral("steam://rungameid/%1").arg(QString(kRimworldSteamAppId)); } const QByteArray encoded = QUrl::toPercentEncoding(trimmed); return QStringLiteral("steam://run/%1//%2/") - .arg(QString(RIMWORLD_STEAM_APP_ID), QString::fromUtf8(encoded)); + .arg(QString(kRimworldSteamAppId), QString::fromUtf8(encoded)); } -Result open_steam_url(const QString& launch_args) { - const QString url = steam_run_url(launch_args); +Result openSteamUrl(const QString& launchArgs) { + const QString url = steamRunUrl(launchArgs); spdlog::info("Opening Steam URL: {}", url.toStdString()); #if defined(Q_OS_WIN) const bool started = QProcess::startDetached( @@ -300,7 +300,7 @@ Result open_steam_url(const QString& launch_args) { return {}; } -std::vector host_process_lister() { +std::vector hostProcessLister() { std::vector procs; #if defined(Q_OS_LINUX) DIR* proc = opendir("/proc"); @@ -367,7 +367,7 @@ std::vector host_process_lister() { return procs; } -QSet live_game_pids(const ProcessLister& lister) { +QSet liveGamePids(const ProcessLister& lister) { QSet pids; for (const ProcessInfo& proc : lister()) { if (!proc.zombie && kProcessNames.contains(proc.name)) { @@ -377,76 +377,76 @@ QSet live_game_pids(const ProcessLister& lister) { return pids; } -bool is_game_running(const ProcessLister& lister) { - return !live_game_pids(lister).isEmpty(); +bool isGameRunning(const ProcessLister& lister) { + return !liveGamePids(lister).isEmpty(); } GameProcessMonitor::GameProcessMonitor(ProcessLister lister, MonitorTiming timing) - : lister_(std::move(lister)), timing_(timing) {} + : m_lister(std::move(lister)), m_timing(timing) {} GameProcessMonitor::~GameProcessMonitor() { - drop_process(); + dropProcess(); } -QCoro::Task GameProcessMonitor::capture_baseline() { - baseline_ = co_await providers::runOnPool>(nullptr, [this] { - return live_game_pids(lister_); +QCoro::Task GameProcessMonitor::captureBaseline() { + m_baseline = co_await providers::runOnPool>(nullptr, [this] { + return liveGamePids(m_lister); }); } -void GameProcessMonitor::attach_process(std::unique_ptr proc) { - drop_process(); - proc_ = std::move(proc); +void GameProcessMonitor::attachProcess(std::unique_ptr proc) { + dropProcess(); + m_proc = std::move(proc); } -bool GameProcessMonitor::launched_alive() const { - const QSet live = live_game_pids(lister_); - return !(live - baseline_).isEmpty(); +bool GameProcessMonitor::launchedAlive() const { + const QSet live = liveGamePids(m_lister); + return !(live - m_baseline).isEmpty(); } -void GameProcessMonitor::drop_process() { - if (proc_ && proc_->state() != QProcess::NotRunning) { +void GameProcessMonitor::dropProcess() { + if (m_proc && m_proc->state() != QProcess::NotRunning) { // Intentionally leaked: the child keeps running after the monitor is gone, as it did // under Popen, and destroying a running QProcess would kill it. - static_cast(proc_.release()); + static_cast(m_proc.release()); return; } - proc_.reset(); + m_proc.reset(); } -QCoro::Task GameProcessMonitor::wait_for_start(std::chrono::milliseconds timeout) { +QCoro::Task GameProcessMonitor::waitForStart(std::chrono::milliseconds timeout) { const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { const bool alive = co_await providers::runOnPool(nullptr, [this] { - return launched_alive(); + return launchedAlive(); }); if (alive) { co_return true; } - if (proc_ && proc_->state() == QProcess::NotRunning) { + if (m_proc && m_proc->state() == QProcess::NotRunning) { co_return false; } - if (timing_.poll_start.count() > 0) { - co_await QCoro::sleepFor(timing_.poll_start); + if (m_timing.pollStart.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollStart); } } co_return false; } -QCoro::Task> GameProcessMonitor::wait_for_exit() { - while (co_await providers::runOnPool(nullptr, [this] { return launched_alive(); })) { - if (timing_.poll_exit.count() > 0) { - co_await QCoro::sleepFor(timing_.poll_exit); +QCoro::Task> GameProcessMonitor::waitForExit() { + while (co_await providers::runOnPool(nullptr, [this] { return launchedAlive(); })) { + if (m_timing.pollExit.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollExit); } } - if (!proc_) { + if (!m_proc) { co_return std::nullopt; } std::optional code; - if (proc_->state() == QProcess::NotRunning) { - code = proc_->exitCode(); + if (m_proc->state() == QProcess::NotRunning) { + code = m_proc->exitCode(); } - drop_process(); + dropProcess(); co_return code; } diff --git a/src/core/services/game_process.hpp b/src/core/services/game_process.hpp index 4825489..01275cf 100644 --- a/src/core/services/game_process.hpp +++ b/src/core/services/game_process.hpp @@ -18,17 +18,17 @@ namespace px::modrim::services { enum class Platform { - Linux, - Windows, - MacOS, + kLinux, + kWindows, + kMacOs, }; // Returns the host's platform. -Platform current_platform(); +Platform currentPlatform(); // Splits a command line the way the platform's launcher does: POSIX shell quoting, or the // Windows command-line rules. Fails on unbalanced quotes. -Result split_arguments(const QString& text, Platform platform = current_platform()); +Result splitArguments(const QString& text, Platform platform = currentPlatform()); struct DirectCommand { QStringList argv; @@ -38,24 +38,24 @@ struct DirectCommand { // Expands wrapper and arguments into argv plus extra environment variables. Leading KEY=VALUE // tokens in the wrapper set environment variables. %command% marks where the game goes; // without it the wrapper is a prefix. Fails on unbalanced quotes. -Result build_direct_command(const QString& exe, const QString& wrapper, +Result buildDirectCommand(const QString& exe, const QString& wrapper, const QString& args, - Platform platform = current_platform()); + Platform platform = currentPlatform()); // Locates the RimWorld executable inside `game_path`, preferring modern Waylith binaries over // leftover Unity files on disk. -std::optional find_executable(const QString& game_path, - Platform platform = current_platform()); +std::optional findExecutable(const QString& gamePath, + Platform platform = currentPlatform()); // Writes steam_appid.txt containing the RimWorld Steam app id into the game folder, unless it // already exists. On macOS the file lives in the game directory's parent. -void ensure_steam_appid(const QString& game_path, Platform platform = current_platform()); +void ensureSteamAppid(const QString& gamePath, Platform platform = currentPlatform()); // The steam:// URL that launches RimWorld with the given launch arguments. -QString steam_run_url(const QString& launch_args); +QString steamRunUrl(const QString& launchArgs); // Hands the steam:// URL to the desktop's handler. Fails when no opener could be started. -Result open_steam_url(const QString& launch_args); +Result openSteamUrl(const QString& launchArgs); // A running process seen on the host. struct ProcessInfo { @@ -68,17 +68,17 @@ struct ProcessInfo { using ProcessLister = std::function()>; // Reads /proc on Linux, Toolhelp32Snapshot on Windows, libproc on macOS. -std::vector host_process_lister(); +std::vector hostProcessLister(); // Returns true when a non-zombie RimWorld game process is currently running. -bool is_game_running(const ProcessLister& lister = host_process_lister); +bool isGameRunning(const ProcessLister& lister = hostProcessLister); // Returns the set of non-zombie RimWorld PIDs found by `lister`. -QSet live_game_pids(const ProcessLister& lister = host_process_lister); +QSet liveGamePids(const ProcessLister& lister = hostProcessLister); struct MonitorTiming { - std::chrono::milliseconds poll_start{500}; - std::chrono::milliseconds poll_exit{2000}; + std::chrono::milliseconds pollStart{500}; + std::chrono::milliseconds pollExit{2000}; }; // Tracks the game process lifecycle: detects when a newly spawned game process starts, and when @@ -86,7 +86,7 @@ struct MonitorTiming { // methods never blocks the event loop. class GameProcessMonitor { public: - explicit GameProcessMonitor(ProcessLister lister = host_process_lister, + explicit GameProcessMonitor(ProcessLister lister = hostProcessLister, MonitorTiming timing = {}); ~GameProcessMonitor(); @@ -94,29 +94,29 @@ class GameProcessMonitor { GameProcessMonitor& operator=(const GameProcessMonitor&) = delete; // Records the game PIDs already running, so they are not counted as newly started. - QCoro::Task capture_baseline(); + QCoro::Task captureBaseline(); // Takes ownership of the spawned process; it must already be started. - void attach_process(std::unique_ptr proc); + void attachProcess(std::unique_ptr proc); // Waits until a non-baseline game process appears. Returns false if the timeout expires or // the spawned process dies before a game process appears. - QCoro::Task wait_for_start(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + QCoro::Task waitForStart(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); // Waits until every non-baseline game process exits. Returns the spawned process's exit code // if one was attached and has exited, or nullopt otherwise. - QCoro::Task> wait_for_exit(); + QCoro::Task> waitForExit(); private: - bool launched_alive() const; + bool launchedAlive() const; // Forgets the spawned process. A child still running is released rather than destroyed, // because destroying a QProcess kills its child and the game must outlive the manager. - void drop_process(); + void dropProcess(); - ProcessLister lister_; - MonitorTiming timing_; - QSet baseline_; - std::unique_ptr proc_; + ProcessLister m_lister; + MonitorTiming m_timing; + QSet m_baseline; + std::unique_ptr m_proc; }; } // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp index 3a98d4f..4c1a46d 100644 --- a/src/core/services/metadata_cache.cpp +++ b/src/core/services/metadata_cache.cpp @@ -56,12 +56,12 @@ Result exec(QSqlDatabase& db, const QString& sql) { // ---- Path normalization ------------------------------------------------------------------- -QString join_path(const QString& dir, const QString& name) { +QString joinPath(const QString& dir, const QString& name) { return dir.endsWith(u'/') ? dir + name : dir + u'/' + name; } // "/" for POSIX paths, "C:/" for drive paths. -QString root_of(const QString& absolute) { +QString rootOf(const QString& absolute) { if (absolute.size() >= 3 && absolute[0].isLetter() && absolute[1] == u':' && absolute[2] == u'/') { return absolute.left(3); @@ -69,7 +69,7 @@ QString root_of(const QString& absolute) { return QStringLiteral("/"); } -QString parent_of(const QString& resolved, const QString& root) { +QString parentOf(const QString& resolved, const QString& root) { if (resolved.size() <= root.size()) { return root; } @@ -79,12 +79,12 @@ QString parent_of(const QString& resolved, const QString& root) { // Resolves symlinks component by component, consuming ".." after the link target has been // followed, as Python's posixpath.realpath does. -QString resolve_path(const QString& path) { +QString resolvePath(const QString& path) { QString absolute = QDir::fromNativeSeparators(path); if (!QDir::isAbsolutePath(absolute)) { - absolute = join_path(QDir::currentPath(), absolute); + absolute = joinPath(QDir::currentPath(), absolute); } - const QString root = root_of(absolute); + const QString root = rootOf(absolute); QStringList pending = absolute.mid(root.size()).split(u'/', Qt::SkipEmptyParts); QString resolved = root; int hops = 0; @@ -94,10 +94,10 @@ QString resolve_path(const QString& path) { continue; } if (name == QLatin1String("..")) { - resolved = parent_of(resolved, root); + resolved = parentOf(resolved, root); continue; } - const QString candidate = join_path(resolved, name); + const QString candidate = joinPath(resolved, name); if (!QFileInfo(candidate).isSymLink()) { resolved = candidate; continue; @@ -108,7 +108,7 @@ QString resolve_path(const QString& path) { const QString target = QDir::fromNativeSeparators(QFile::symLinkTarget(candidate)); if (QDir::isAbsolutePath(target)) { resolved = root; - pending = target.mid(root_of(target).size()).split(u'/', Qt::SkipEmptyParts) + pending; + pending = target.mid(rootOf(target).size()).split(u'/', Qt::SkipEmptyParts) + pending; } else { pending = target.split(u'/', Qt::SkipEmptyParts) + pending; } @@ -118,14 +118,14 @@ QString resolve_path(const QString& path) { // ---- Payload (de)serialization ------------------------------------------------------------ -QJsonArray sorted_array(const QStringList& values) { +QJsonArray sortedArray(const QStringList& values) { QStringList sorted = values; sorted.sort(); return QJsonArray::fromStringList(sorted); } template -QStringList sorted_strings(const Set& set) { +QStringList sortedStrings(const Set& set) { QStringList values; values.reserve(set.size()); for (const auto& item : set) { @@ -135,18 +135,18 @@ QStringList sorted_strings(const Set& set) { return values; } -QJsonObject serialize_mod(const AboutXmlMod& mod) { +QJsonObject serializeMod(const AboutXmlMod& mod) { QJsonObject object; object[QStringLiteral("name")] = mod.name; - object[QStringLiteral("package_id")] = mod.package_id.str(); + object[QStringLiteral("package_id")] = mod.packageId.str(); object[QStringLiteral("authors")] = QJsonArray::fromStringList(mod.authors); - object[QStringLiteral("mod_version")] = mod.mod_version; + object[QStringLiteral("mod_version")] = mod.modVersion; object[QStringLiteral("mod_icon_path")] = - mod.mod_icon_path ? QJsonValue(*mod.mod_icon_path) : QJsonValue(QJsonValue::Null); - object[QStringLiteral("steam_app_id")] = QJsonValue(static_cast(mod.steam_app_id)); + mod.modIconPath ? QJsonValue(*mod.modIconPath) : QJsonValue(QJsonValue::Null); + object[QStringLiteral("steam_app_id")] = QJsonValue(static_cast(mod.steamAppId)); object[QStringLiteral("url")] = mod.url; object[QStringLiteral("description")] = mod.description; - QStringList versions = mod.supported_versions.values(); + QStringList versions = mod.supportedVersions.values(); versions.sort(); object[QStringLiteral("supported_versions")] = QJsonArray::fromStringList(versions); object[QStringLiteral("valid")] = mod.valid; @@ -155,31 +155,31 @@ QJsonObject serialize_mod(const AboutXmlMod& mod) { // Declaration order matters to the checker, which QJsonObject would sort away, so // dependencies are an array here. deserialize_mod still reads the legacy object form. QJsonArray dependencies; - for (const DependencyMod& dep : mod.about_rules.dependencies) { + for (const DependencyMod& dep : mod.aboutRules.dependencies) { QJsonObject entry; entry[QStringLiteral("name")] = dep.name; - entry[QStringLiteral("package_id")] = dep.package_id.str(); - entry[QStringLiteral("workshop_url")] = dep.workshop_url; + entry[QStringLiteral("package_id")] = dep.packageId.str(); + entry[QStringLiteral("workshop_url")] = dep.workshopUrl; entry[QStringLiteral("alternative_package_ids")] = - sorted_array(sorted_strings(dep.alternative_package_ids)); + sortedArray(sortedStrings(dep.alternativePackageIds)); dependencies.append(entry); } QJsonObject rules; - rules[QStringLiteral("load_before")] = sorted_array(sorted_strings(mod.about_rules.load_before)); - rules[QStringLiteral("load_after")] = sorted_array(sorted_strings(mod.about_rules.load_after)); + rules[QStringLiteral("load_before")] = sortedArray(sortedStrings(mod.aboutRules.loadBefore)); + rules[QStringLiteral("load_after")] = sortedArray(sortedStrings(mod.aboutRules.loadAfter)); rules[QStringLiteral("incompatible_with")] = - sorted_array(sorted_strings(mod.about_rules.incompatible_with)); + sortedArray(sortedStrings(mod.aboutRules.incompatibleWith)); rules[QStringLiteral("dependencies")] = dependencies; object[QStringLiteral("about_rules")] = rules; return object; } -QString string_or(const QJsonObject& object, const char* key, const QString& fallback) { +QString stringOr(const QJsonObject& object, const char* key, const QString& fallback) { const QJsonValue value = object.value(QLatin1StringView(key)); return value.isString() ? value.toString() : fallback; } -QStringList string_list(const QJsonValue& value) { +QStringList stringList(const QJsonValue& value) { QStringList values; for (const QJsonValue& item : value.toArray()) { if (item.isString()) { @@ -189,34 +189,34 @@ QStringList string_list(const QJsonValue& value) { return values; } -void add_dependency(BaseRules& rules, const QJsonObject& data, const QString& fallback_pid) { +void addDependency(BaseRules& rules, const QJsonObject& data, const QString& fallbackPid) { DependencyMod dep; - dep.package_id = CaseInsensitiveStr(string_or(data, "package_id", fallback_pid)); - dep.name = string_or(data, "name", QString()); - dep.workshop_url = string_or(data, "workshop_url", QString()); - dep.alternative_package_ids = CaseInsensitiveSet(string_list(data.value(QLatin1StringView("alternative_package_ids")))); - rules.set_dependency(std::move(dep)); + dep.packageId = CaseInsensitiveStr(stringOr(data, "package_id", fallbackPid)); + dep.name = stringOr(data, "name", QString()); + dep.workshopUrl = stringOr(data, "workshop_url", QString()); + dep.alternativePackageIds = CaseInsensitiveSet(stringList(data.value(QLatin1StringView("alternative_package_ids")))); + rules.setDependency(std::move(dep)); } -void read_dependencies(BaseRules& rules, const QJsonValue& value) { +void readDependencies(BaseRules& rules, const QJsonValue& value) { if (value.isArray()) { for (const QJsonValue& item : value.toArray()) { if (item.isObject()) { - add_dependency(rules, item.toObject(), QStringLiteral("invalid.mod")); + addDependency(rules, item.toObject(), QStringLiteral("invalid.mod")); } } } else if (value.isObject()) { const QJsonObject legacy = value.toObject(); for (auto it = legacy.constBegin(); it != legacy.constEnd(); ++it) { if (it.value().isObject()) { - add_dependency(rules, it.value().toObject(), it.key()); + addDependency(rules, it.value().toObject(), it.key()); } } } } // The payload as a fresh AboutXmlMod, without a mod path; the caller sets it. -Result> deserialize_mod(const QString& payload) { +Result> deserializeMod(const QString& payload) { const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8()); if (document.isNull() || !document.isObject()) { return std::unexpected(Error{QStringLiteral("payload is not a JSON object")}); @@ -224,29 +224,29 @@ Result> deserialize_mod(const QString& payload) { const QJsonObject data = document.object(); auto mod = std::make_shared(); - mod->name = string_or(data, "name", QStringLiteral("Unknown Mod Name")); - mod->package_id = CaseInsensitiveStr(string_or(data, "package_id", QStringLiteral("invalid.mod"))); - mod->authors = string_list(data.value(QLatin1StringView("authors"))); - mod->mod_version = string_or(data, "mod_version", QString()); - const QString icon = string_or(data, "mod_icon_path", QString()); + mod->name = stringOr(data, "name", QStringLiteral("Unknown Mod Name")); + mod->packageId = CaseInsensitiveStr(stringOr(data, "package_id", QStringLiteral("invalid.mod"))); + mod->authors = stringList(data.value(QLatin1StringView("authors"))); + mod->modVersion = stringOr(data, "mod_version", QString()); + const QString icon = stringOr(data, "mod_icon_path", QString()); if (!icon.isEmpty()) { - mod->mod_icon_path = icon; + mod->modIconPath = icon; } - mod->steam_app_id = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); - mod->url = string_or(data, "url", QString()); - mod->description = string_or(data, "description", QString()); - const QStringList versions = string_list(data.value(QLatin1StringView("supported_versions"))); - mod->supported_versions = QSet(versions.begin(), versions.end()); + mod->steamAppId = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); + mod->url = stringOr(data, "url", QString()); + mod->description = stringOr(data, "description", QString()); + const QStringList versions = stringList(data.value(QLatin1StringView("supported_versions"))); + mod->supportedVersions = QSet(versions.begin(), versions.end()); mod->valid = data.value(QLatin1StringView("valid")).toBool(true); mod->mtime = data.value(QLatin1StringView("mtime")).toDouble(0.0); - const QJsonValue rules_value = data.value(QLatin1StringView("about_rules")); - const QJsonObject rules_data = rules_value.isObject() ? rules_value.toObject() : QJsonObject{}; - mod->about_rules.load_before = CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("load_before")))); - mod->about_rules.load_after = CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("load_after")))); - mod->about_rules.incompatible_with = - CaseInsensitiveSet(string_list(rules_data.value(QLatin1StringView("incompatible_with")))); - read_dependencies(mod->about_rules, rules_data.value(QLatin1StringView("dependencies"))); + const QJsonValue rulesValue = data.value(QLatin1StringView("about_rules")); + const QJsonObject rulesData = rulesValue.isObject() ? rulesValue.toObject() : QJsonObject{}; + mod->aboutRules.loadBefore = CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("load_before")))); + mod->aboutRules.loadAfter = CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("load_after")))); + mod->aboutRules.incompatibleWith = + CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("incompatible_with")))); + readDependencies(mod->aboutRules, rulesData.value(QLatin1StringView("dependencies"))); return mod; } @@ -268,13 +268,13 @@ struct NormalizedCandidates { } }; -NormalizedCandidates normalize_candidates(const QList& candidates) { - QStringList mod_paths; - mod_paths.reserve(candidates.size()); +NormalizedCandidates normalizeCandidates(const QList& candidates) { + QStringList modPaths; + modPaths.reserve(candidates.size()); for (const ModCandidate& candidate : candidates) { - mod_paths.append(candidate.modPath); + modPaths.append(candidate.modPath); } - const QStringList normalized = normalize_paths(mod_paths); + const QStringList normalized = normalizePaths(modPaths); NormalizedCandidates result; for (qsizetype i = 0; i < candidates.size(); ++i) { result.insert(normalized[i], candidates[i]); @@ -283,17 +283,17 @@ NormalizedCandidates normalize_candidates(const QList& candidates) } struct StoredRow { - QString mod_path; + QString modPath; double mtime = 0.0; - qint64 file_size = 0; - QString target_version; + qint64 fileSize = 0; + QString targetVersion; QString payload; }; -Result> fetch_rows(QSqlDatabase& db, const QStringList& norm_paths) { +Result> fetchRows(QSqlDatabase& db, const QStringList& normPaths) { QList rows; - for (qsizetype start = 0; start < norm_paths.size(); start += kFetchChunk) { - const qsizetype count = std::min(kFetchChunk, norm_paths.size() - start); + for (qsizetype start = 0; start < normPaths.size(); start += kFetchChunk) { + const qsizetype count = std::min(kFetchChunk, normPaths.size() - start); QStringList placeholders(count, QStringLiteral("?")); QSqlQuery query(db); const QString sql = QStringLiteral("SELECT mod_path, about_path, mtime, file_size, " @@ -304,7 +304,7 @@ Result> fetch_rows(QSqlDatabase& db, const QStringList& norm_pa return std::unexpected(Error{query.lastError().text()}); } for (qsizetype i = 0; i < count; ++i) { - query.addBindValue(norm_paths[start + i]); + query.addBindValue(normPaths[start + i]); } if (!query.exec()) { return std::unexpected(Error{query.lastError().text()}); @@ -320,16 +320,16 @@ Result> fetch_rows(QSqlDatabase& db, const QStringList& norm_pa } // namespace -QString metadata_cache_path(const QString& config_dir) { - return QDir(config_dir).filePath(QStringLiteral("metadata-cache.db")); +QString metadataCachePath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("metadata-cache.db")); } -QString normalize_path(const QString& path) { - return resolve_path(path); +QString normalizePath(const QString& path) { + return resolvePath(path); } -QStringList normalize_paths(const QStringList& paths) { - QHash resolved_parents; +QStringList normalizePaths(const QStringList& paths) { + QHash resolvedParents; QStringList out; out.reserve(paths.size()); for (const QString& raw : paths) { @@ -338,104 +338,104 @@ QStringList normalize_paths(const QStringList& paths) { const QString tail = slash < 0 ? QString() : path.mid(slash + 1); if (slash < 0 || tail.isEmpty() || tail == QLatin1String(".") || tail == QLatin1String("..") || QFileInfo(raw).isSymLink()) { - out.append(resolve_path(path)); + out.append(resolvePath(path)); continue; } const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); - auto memo = resolved_parents.constFind(head); - if (memo == resolved_parents.constEnd()) { - memo = resolved_parents.insert(head, resolve_path(head)); + auto memo = resolvedParents.constFind(head); + if (memo == resolvedParents.constEnd()) { + memo = resolvedParents.insert(head, resolvePath(head)); } - out.append(join_path(*memo, tail)); + out.append(joinPath(*memo, tail)); } return out; } MetadataCache::MetadataCache(QString path) - : path_(std::move(path)), - connection_name_(QStringLiteral("px-metadata-cache-%1") + : m_path(std::move(path)), + m_connectionName(QStringLiteral("px-metadata-cache-%1") .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { - worker_.setMaxThreadCount(1); + m_worker.setMaxThreadCount(1); // Keeps the single worker alive between calls, so the connection stays on one thread. - worker_.setExpiryTimeout(-1); + m_worker.setExpiryTimeout(-1); } MetadataCache::~MetadataCache() { - providers::runOnPool(&worker_, [this] { close_connection(); }).waitForFinished(); + providers::runOnPool(&m_worker, [this] { closeConnection(); }).waitForFinished(); } -QCoro::Task MetadataCache::get_mods(QList candidates, - QString target_version) { +QCoro::Task MetadataCache::getMods(QList candidates, + QString targetVersion) { if (candidates.isEmpty()) { co_return CacheLookup{}; } co_return co_await providers::runOnPool( - &worker_, [this, candidates = std::move(candidates), - target_version = std::move(target_version)] { - return lookup(candidates, target_version); + &m_worker, [this, candidates = std::move(candidates), + targetVersion = std::move(targetVersion)] { + return lookup(candidates, targetVersion); }); } -QCoro::Task MetadataCache::put_mods(QList entries, QString target_version) { +QCoro::Task MetadataCache::putMods(QList entries, QString targetVersion) { if (entries.isEmpty()) { co_return; } co_await providers::runOnPool( - &worker_, [this, entries = std::move(entries), target_version = std::move(target_version)] { - store(entries, target_version); + &m_worker, [this, entries = std::move(entries), targetVersion = std::move(targetVersion)] { + store(entries, targetVersion); }); } -QCoro::Task MetadataCache::prune_disappeared(QSet live_mod_paths) { +QCoro::Task MetadataCache::pruneDisappeared(QSet liveModPaths) { co_await providers::runOnPool( - &worker_, [this, live = std::move(live_mod_paths)] { prune(live); }); + &m_worker, [this, live = std::move(liveModPaths)] { prune(live); }); } QCoro::Task MetadataCache::close() { - co_await providers::runOnPool(&worker_, [this] { close_connection(); }); + co_await providers::runOnPool(&m_worker, [this] { closeConnection(); }); } -void MetadataCache::close_connection() { - if (connection_) { - connection_->close(); - connection_.reset(); - QSqlDatabase::removeDatabase(connection_name_); +void MetadataCache::closeConnection() { + if (m_connection) { + m_connection->close(); + m_connection.reset(); + QSqlDatabase::removeDatabase(m_connectionName); } } -QSqlDatabase* MetadataCache::open_connection() { - if (disabled_) { +QSqlDatabase* MetadataCache::openConnection() { + if (m_disabled) { return nullptr; } - if (connection_) { - return &*connection_; + if (m_connection) { + return &*m_connection; } - if (!QDir().mkpath(QFileInfo(path_).absolutePath())) { + if (!QDir().mkpath(QFileInfo(m_path).absolutePath())) { spdlog::warn("Metadata cache unavailable at {}: cannot create its directory. Disabling cache.", - path_.toStdString()); - disabled_ = true; + m_path.toStdString()); + m_disabled = true; return nullptr; } - if (auto opened = open_database(); !opened) { + if (auto opened = openDatabase(); !opened) { spdlog::warn("Corrupt metadata cache database at {}: {}. Attempting recovery.", - path_.toStdString(), opened.error().message.toStdString()); - remove_database_files(); - if (auto reopened = open_database(); !reopened) { + m_path.toStdString(), opened.error().message.toStdString()); + removeDatabaseFiles(); + if (auto reopened = openDatabase(); !reopened) { spdlog::error("Failed to re-initialize metadata cache at {}: {}. Disabling cache.", - path_.toStdString(), reopened.error().message.toStdString()); - disabled_ = true; + m_path.toStdString(), reopened.error().message.toStdString()); + m_disabled = true; return nullptr; } } - return &*connection_; + return &*m_connection; } -Result MetadataCache::open_database() { +Result MetadataCache::openDatabase() { Result ready; { - QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connection_name_); - db.setDatabaseName(path_); + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(m_path); if (db.open()) { ready = Result{}; } else { @@ -451,41 +451,41 @@ Result MetadataCache::open_database() { if (ready) { MigrationSteps steps; steps[kSchemaVersion] = [&db] { return exec(db, QStringLiteral("DELETE FROM mod_metadata")); }; - ready = ensure_schema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, path_, + ready = ensureSchema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, m_path, QStringLiteral("metadata cache schema")); } if (ready) { - connection_ = db; + m_connection = db; return {}; } db.close(); } // Dropped only once no handle to the connection remains. - QSqlDatabase::removeDatabase(connection_name_); + QSqlDatabase::removeDatabase(m_connectionName); return ready; } -void MetadataCache::remove_database_files() const { - for (const QString& file : {path_, path_ + QStringLiteral("-wal"), path_ + QStringLiteral("-shm")}) { +void MetadataCache::removeDatabaseFiles() const { + for (const QString& file : {m_path, m_path + QStringLiteral("-wal"), m_path + QStringLiteral("-shm")}) { QFile::remove(file); } } CacheLookup MetadataCache::lookup(const QList& candidates, - const QString& target_version) { - QSqlDatabase* db = open_connection(); + const QString& targetVersion) { + QSqlDatabase* db = openConnection(); if (!db) { return CacheLookup{{}, candidates}; } - const NormalizedCandidates normalized = normalize_candidates(candidates); + const NormalizedCandidates normalized = normalizeCandidates(candidates); QStringList keys; keys.reserve(normalized.entries.size()); for (const auto& entry : normalized.entries) { keys.append(entry.first); } - auto rows = fetch_rows(*db, keys); + auto rows = fetchRows(*db, keys); if (!rows) { spdlog::warn("Error reading metadata cache: {}", rows.error().message.toStdString()); return CacheLookup{{}, candidates}; @@ -494,26 +494,26 @@ CacheLookup MetadataCache::lookup(const QList& candidates, CacheLookup result; QSet found; for (const StoredRow& row : *rows) { - const auto index = normalized.index.constFind(row.mod_path); + const auto index = normalized.index.constFind(row.modPath); if (index == normalized.index.constEnd()) { continue; } const ModCandidate& candidate = normalized.entries[*index].second; if (std::abs(row.mtime - candidate.mtime) >= kMtimeTolerance || - row.file_size != candidate.size || row.target_version != target_version) { + row.fileSize != candidate.size || row.targetVersion != targetVersion) { continue; } - auto mod = deserialize_mod(row.payload); + auto mod = deserializeMod(row.payload); if (!mod) { - spdlog::warn("Corrupted metadata cache payload for {}: {}", row.mod_path.toStdString(), + spdlog::warn("Corrupted metadata cache payload for {}: {}", row.modPath.toStdString(), mod.error().message.toStdString()); continue; } - if (auto set = (*mod)->set_mod_path(candidate.modPath); !set) { + if (auto set = (*mod)->setModPath(candidate.modPath); !set) { continue; } result.cached.append(*mod); - found.insert(row.mod_path); + found.insert(row.modPath); } for (const auto& entry : normalized.entries) { if (!found.contains(entry.first)) { @@ -523,27 +523,27 @@ CacheLookup MetadataCache::lookup(const QList& candidates, return result; } -void MetadataCache::store(const QList& entries, const QString& target_version) { - QSqlDatabase* db = open_connection(); +void MetadataCache::store(const QList& entries, const QString& targetVersion) { + QSqlDatabase* db = openConnection(); if (!db) { return; } struct Row { - QString mod_path; - QString about_path; + QString modPath; + QString aboutPath; double mtime; - qint64 file_size; + qint64 fileSize; QString payload; }; QList rows; for (const CachedMod& entry : entries) { - if (!entry.mod->mod_path()) { + if (!entry.mod->modPath()) { continue; } - const QJsonDocument document(serialize_mod(*entry.mod)); - rows.append(Row{normalize_path(*entry.mod->mod_path()), - normalize_path(entry.candidate.aboutXmlPath), entry.candidate.mtime, + const QJsonDocument document(serializeMod(*entry.mod)); + rows.append(Row{normalizePath(*entry.mod->modPath()), + normalizePath(entry.candidate.aboutXmlPath), entry.candidate.mtime, entry.candidate.size, QString::fromUtf8(document.toJson(QJsonDocument::Compact))}); } @@ -556,11 +556,11 @@ void MetadataCache::store(const QList& entries, const QString& target } db->transaction(); for (const Row& row : rows) { - query.addBindValue(row.mod_path); - query.addBindValue(row.about_path); + query.addBindValue(row.modPath); + query.addBindValue(row.aboutPath); query.addBindValue(row.mtime); - query.addBindValue(row.file_size); - query.addBindValue(target_version); + query.addBindValue(row.fileSize); + query.addBindValue(targetVersion); query.addBindValue(row.payload); if (!query.exec()) { spdlog::warn("Error writing to metadata cache: {}", query.lastError().text().toStdString()); @@ -573,8 +573,8 @@ void MetadataCache::store(const QList& entries, const QString& target } } -void MetadataCache::prune(const QSet& live_mod_paths) { - QSqlDatabase* db = open_connection(); +void MetadataCache::prune(const QSet& liveModPaths) { + QSqlDatabase* db = openConnection(); if (!db) { return; } @@ -593,7 +593,7 @@ void MetadataCache::prune(const QSet& live_mod_paths) { QStringList disappeared; for (const QString& path : cached) { - if (!live_mod_paths.contains(path)) { + if (!liveModPaths.contains(path)) { disappeared.append(path); } } diff --git a/src/core/services/metadata_cache.hpp b/src/core/services/metadata_cache.hpp index 37cb8a8..bcee8ae 100644 --- a/src/core/services/metadata_cache.hpp +++ b/src/core/services/metadata_cache.hpp @@ -14,14 +14,14 @@ namespace px::modrim::services { // The cache database inside the config directory. -QString metadata_cache_path(const QString& config_dir); +QString metadataCachePath(const QString& configDir); // The symlink-resolved absolute form of `path`, as Python's os.path.realpath gives it. The path // need not exist: components that do not exist are kept as written. -QString normalize_path(const QString& path); +QString normalizePath(const QString& path); // normalize_path for many paths; each shared parent directory is resolved once. -QStringList normalize_paths(const QStringList& paths); +QStringList normalizePaths(const QStringList& paths); // Persistent SQLite cache of parsed About.xml metadata (metadata-cache.db). All database work runs // on one private worker thread, which owns the connection, so the connection is never used from two @@ -32,33 +32,33 @@ class MetadataCache final : public providers::MetadataCacheStore { explicit MetadataCache(QString path); ~MetadataCache() override; - QCoro::Task get_mods(QList candidates, - QString target_version) override; - QCoro::Task put_mods(QList entries, - QString target_version) override; + QCoro::Task getMods(QList candidates, + QString targetVersion) override; + QCoro::Task putMods(QList entries, + QString targetVersion) override; // Removes entries whose normalized mod path is not in `live_mod_paths`. - QCoro::Task prune_disappeared(QSet live_mod_paths); + QCoro::Task pruneDisappeared(QSet liveModPaths); // Closes the connection; the next operation reopens it. QCoro::Task close(); private: - void close_connection(); - QSqlDatabase* open_connection(); - Result open_database(); - void remove_database_files() const; + void closeConnection(); + QSqlDatabase* openConnection(); + Result openDatabase(); + void removeDatabaseFiles() const; providers::CacheLookup lookup(const QList& candidates, - const QString& target_version); - void store(const QList& entries, const QString& target_version); - void prune(const QSet& live_mod_paths); + const QString& targetVersion); + void store(const QList& entries, const QString& targetVersion); + void prune(const QSet& liveModPaths); - QString path_; - QString connection_name_; - QThreadPool worker_; - std::optional connection_; // worker thread only - bool disabled_ = false; // worker thread only + QString m_path; + QString m_connectionName; + QThreadPool m_worker; + std::optional m_connection; // worker thread only + bool m_disabled = false; // worker thread only }; } // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp index befaf41..3273bb4 100644 --- a/src/core/services/mod_discovery.cpp +++ b/src/core/services/mod_discovery.cpp @@ -12,7 +12,7 @@ namespace px::modrim::services { namespace { -QString strip_steam(QString value) { +QString stripSteam(QString value) { if (value.endsWith(QLatin1String("_steam"), Qt::CaseInsensitive)) { value.chop(6); } @@ -21,37 +21,37 @@ QString strip_steam(QString value) { } // namespace -QStringList resolve_active_uuids(const QHash& all_mods, - const QString& config_folder) { - if (config_folder.isEmpty()) { +QStringList resolveActiveUuids(const QHash& allMods, + const QString& configFolder) { + if (configFolder.isEmpty()) { return {}; } - const QString path = QDir(config_folder).filePath(QStringLiteral("ModsConfig.xml")); - const auto data = parse_mods_config(path); + const QString path = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const auto data = parseModsConfig(path); if (!data) { return {}; } // Sort UUIDs so the first-match tiebreaker is deterministic across hash iterations. - QStringList uuids = all_mods.keys(); + QStringList uuids = allMods.keys(); std::ranges::sort(uuids); - QHash pid_to_uuid; + QHash pidToUuid; for (const QString& uuid : uuids) { - const auto mod = std::dynamic_pointer_cast(all_mods.value(uuid)); + const auto mod = std::dynamic_pointer_cast(allMods.value(uuid)); if (!mod) { continue; } - const QString pid = strip_steam(mod->package_id.str()); - if (!pid_to_uuid.contains(pid)) { - pid_to_uuid.insert(pid, uuid); + const QString pid = stripSteam(mod->packageId.str()); + if (!pidToUuid.contains(pid)) { + pidToUuid.insert(pid, uuid); } } QStringList active; for (const CaseInsensitiveStr& pid : data->activeMods) { - const QString normalized = strip_steam(pid.str()); - if (const auto found = pid_to_uuid.constFind(normalized); found != pid_to_uuid.constEnd()) { + const QString normalized = stripSteam(pid.str()); + if (const auto found = pidToUuid.constFind(normalized); found != pidToUuid.constEnd()) { active.append(*found); } } diff --git a/src/core/services/mod_discovery.hpp b/src/core/services/mod_discovery.hpp index d28448e..dfb8248 100644 --- a/src/core/services/mod_discovery.hpp +++ b/src/core/services/mod_discovery.hpp @@ -11,7 +11,7 @@ namespace px::modrim::services { // Parses ModsConfig.xml inside `config_folder` and returns the ordered list of UUIDs matching // active mods. If multiple discovered mods share a package id, the first match (in sorted UUID // order) is chosen. The "_steam" suffix on package ids is ignored. -QStringList resolve_active_uuids(const QHash& all_mods, - const QString& config_folder); +QStringList resolveActiveUuids(const QHash& allMods, + const QString& configFolder); } // namespace px::modrim::services diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp index 270bfee..a0f03df 100644 --- a/src/core/services/sort_service.cpp +++ b/src/core/services/sort_service.cpp @@ -15,94 +15,94 @@ namespace px::modrim { namespace { -std::shared_ptr about_xml(const ListedModPtr& mod) { +std::shared_ptr aboutXml(const ListedModPtr& mod) { return std::dynamic_pointer_cast(mod); } } // namespace SortService::SortService(CoreContext& ctx, DiagnosticsService& diagnostics) - : ctx_(ctx), diagnostics_(diagnostics) {} + : m_ctx(ctx), m_diagnostics(diagnostics) {} // Python: asyncio.to_thread(topological_sort, ...) -> QtConcurrent::run + co_await. The inputs are // copies or borrowed from the diagnostics service, which is not mutated while awaiting. -QCoro::Task SortService::sort_active_mods() { +QCoro::Task SortService::sortActiveMods() { ProfileSession session(QStringLiteral("sort")); Profiler& timer = session.timer(); - const auto active_mods = diagnostics_.active_mods_by_pid(); - const auto& graph = diagnostics_.constraint_graph(); - const sort::SortSettings settings = ctx_.config().sort; - const sort::CommunityRules* rules = &diagnostics_.community_rules(); + const auto activeMods = m_diagnostics.activeModsByPid(); + const auto& graph = m_diagnostics.constraintGraph(); + const sort::SortSettings settings = m_ctx.config().sort; + const sort::CommunityRules* rules = &m_diagnostics.communityRules(); const QList sorted = co_await QtConcurrent::run([&] { - return checker::topological_sort(active_mods, graph, settings, rules, &timer); + return checker::topologicalSort(activeMods, graph, settings, rules, &timer); }); - co_return order_uuids(sorted, ctx_.allMods(), ctx_.activeUuids()); + co_return orderUuids(sorted, m_ctx.allMods(), m_ctx.activeUuids()); } -QStringList SortService::resolve_missing_dependencies(const QSet& active_uuids) const { - return missing_dependency_uuids(ctx_.allMods(), active_uuids); +QStringList SortService::resolveMissingDependencies(const QSet& activeUuids) const { + return missingDependencyUuids(m_ctx.allMods(), activeUuids); } -QStringList SortService::order_uuids(const QList& sorted_pids, - const QHash& all_mods, - const QStringList& active_uuids) { - QHash pid_to_uuid; - for (const auto& uuid : active_uuids) { - if (auto about = about_xml(all_mods.value(uuid))) { - pid_to_uuid.insert(about->package_id, uuid); +QStringList SortService::orderUuids(const QList& sortedPids, + const QHash& allMods, + const QStringList& activeUuids) { + QHash pidToUuid; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId, uuid); } } QStringList ordered; - for (const auto& pid : sorted_pids) { - if (auto it = pid_to_uuid.constFind(pid); it != pid_to_uuid.constEnd()) { + for (const auto& pid : sortedPids) { + if (auto it = pidToUuid.constFind(pid); it != pidToUuid.constEnd()) { ordered.append(it.value()); } } return ordered; } -QStringList SortService::missing_dependency_uuids(const QHash& all_mods, - const QSet& active_uuids) { +QStringList SortService::missingDependencyUuids(const QHash& allMods, + const QSet& activeUuids) { // Visiting uuids in sorted order keeps the choice between duplicate inactive copies stable. - QStringList all_uuids = all_mods.keys(); - std::ranges::sort(all_uuids); - QHash pid_to_uuid; - for (const auto& uuid : all_uuids) { - if (auto about = about_xml(all_mods.value(uuid))) { - pid_to_uuid.insert(about->package_id.str(), uuid); + QStringList allUuids = allMods.keys(); + std::ranges::sort(allUuids); + QHash pidToUuid; + for (const auto& uuid : allUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId.str(), uuid); } } - QSet active_pids; - for (const auto& uuid : active_uuids) { - if (auto about = about_xml(all_mods.value(uuid))) { - active_pids.insert(about->package_id.str()); + QSet activePids; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + activePids.insert(about->packageId.str()); } } QStringList missing; - for (const auto& uuid : active_uuids) { - auto about = about_xml(all_mods.value(uuid)); + for (const auto& uuid : activeUuids) { + auto about = aboutXml(allMods.value(uuid)); if (!about) { continue; } - for (const auto& dep : about->about_rules.dependencies) { - const QString dep_pid = dep.package_id.str(); - if (active_pids.contains(dep_pid)) { + for (const auto& dep : about->aboutRules.dependencies) { + const QString depPid = dep.packageId.str(); + if (activePids.contains(depPid)) { continue; } - const bool satisfied_by_alternative = std::ranges::any_of( - dep.alternative_package_ids, [&](const CaseInsensitiveStr& alt) { - return active_pids.contains(alt.str()); + const bool satisfiedByAlternative = std::ranges::any_of( + dep.alternativePackageIds, [&](const CaseInsensitiveStr& alt) { + return activePids.contains(alt.str()); }); - if (satisfied_by_alternative) { + if (satisfiedByAlternative) { continue; } - auto dep_uuid = pid_to_uuid.constFind(dep_pid); - if (dep_uuid != pid_to_uuid.constEnd() && !active_uuids.contains(dep_uuid.value())) { - missing.append(dep_uuid.value()); + auto depUuid = pidToUuid.constFind(depPid); + if (depUuid != pidToUuid.constEnd() && !activeUuids.contains(depUuid.value())) { + missing.append(depUuid.value()); } } } diff --git a/src/core/services/sort_service.hpp b/src/core/services/sort_service.hpp index c0aa651..c84c011 100644 --- a/src/core/services/sort_service.hpp +++ b/src/core/services/sort_service.hpp @@ -21,26 +21,26 @@ class SortService { // Orders the active mods by tier, dependencies and load-order rules. Resolves to their uuids // in that order. - QCoro::Task sort_active_mods(); + QCoro::Task sortActiveMods(); // Uuids of inactive mods that the active mods need and that are not already covered. - QStringList resolve_missing_dependencies(const QSet& active_uuids) const; + QStringList resolveMissingDependencies(const QSet& activeUuids) const; // Maps topologically sorted package ids back to the uuids of the active mods that declare them. // Only the active uuid of a package id is considered, so an inactive duplicate never stands in // for an active mod. - static QStringList order_uuids(const QList& sorted_pids, - const QHash& all_mods, - const QStringList& active_uuids); + static QStringList orderUuids(const QList& sortedPids, + const QHash& allMods, + const QStringList& activeUuids); // Uuids of inactive mods that satisfy a dependency of an active mod which no active mod (or // alternative package id) already covers. Duplicates are removed, keeping first occurrence. - static QStringList missing_dependency_uuids(const QHash& all_mods, - const QSet& active_uuids); + static QStringList missingDependencyUuids(const QHash& allMods, + const QSet& activeUuids); private: - CoreContext& ctx_; - DiagnosticsService& diagnostics_; + CoreContext& m_ctx; + DiagnosticsService& m_diagnostics; }; } // namespace px::modrim diff --git a/src/core/services/startup_impact_service/db.cpp b/src/core/services/startup_impact_service/db.cpp index f0fbfa1..4e60631 100644 --- a/src/core/services/startup_impact_service/db.cpp +++ b/src/core/services/startup_impact_service/db.cpp @@ -24,17 +24,17 @@ namespace { // Recency weights: the newest session weighs WEIGHT_SPREAD, the next WEIGHT_SPREAD-1, and so // on down to 1. Sessions beyond HISTORY_DEPTH are not kept. -constexpr int HISTORY_DEPTH = 5; -constexpr int WEIGHT_SPREAD = 10; +constexpr int kHistoryDepth = 5; +constexpr int kWeightSpread = 10; -constexpr int SCHEMA_VERSION = 1; +constexpr int kSchemaVersion = 1; // Above this size the file is rebuilt after a store, since freed pages are never returned // otherwise. -constexpr qint64 VACUUM_THRESHOLD_BYTES = 10 * 1024 * 1024; +constexpr qint64 kVacuumThresholdBytes = 10 * 1024 * 1024; // Schema text is identical to the legacy app's, so both can open the same cache.db. -constexpr QLatin1StringView SCHEMA{R"sql( +constexpr QLatin1StringView kSchema{R"sql( CREATE TABLE IF NOT EXISTS startup_impact_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, recorded_at REAL NOT NULL, @@ -63,7 +63,7 @@ constexpr QLatin1StringView SCHEMA{R"sql( // Ranks the newest HISTORY_DEPTH sessions and assigns each a recency weight. // Binds, in order: HISTORY_DEPTH, WEIGHT_SPREAD. Exposes columns session_id and weight. -constexpr QLatin1StringView WEIGHTED_SESSIONS_CTE{R"sql( +constexpr QLatin1StringView kWeightedSessionsCte{R"sql( ranked AS ( SELECT id AS session_id, ROW_NUMBER() OVER (ORDER BY id DESC) AS rn @@ -79,18 +79,18 @@ constexpr QLatin1StringView WEIGHTED_SESSIONS_CTE{R"sql( )sql"}; // Wraps a SELECT that joins the weighted sessions as `w`. -QString with_weights(const QString& select_sql) { - return QStringLiteral("WITH ") + QString(WEIGHTED_SESSIONS_CTE) + select_sql; +QString withWeights(const QString& selectSql) { + return QStringLiteral("WITH ") + QString(kWeightedSessionsCte) + selectSql; } // The CTE's two parameters come first, followed by the query's own. -QVariantList with_cte_binds(const QVariantList& rest = {}) { - QVariantList binds{HISTORY_DEPTH, WEIGHT_SPREAD}; +QVariantList withCteBinds(const QVariantList& rest = {}) { + QVariantList binds{kHistoryDepth, kWeightSpread}; binds.append(rest); return binds; } -QVariantList to_variants(const QStringList& values) { +QVariantList toVariants(const QStringList& values) { QVariantList binds; binds.reserve(values.size()); for (const QString& value : values) { @@ -99,7 +99,7 @@ QVariantList to_variants(const QStringList& values) { return binds; } -QString placeholder_list(qsizetype count) { +QString placeholderList(qsizetype count) { QStringList marks(count, QStringLiteral("?")); return marks.join(QLatin1Char(',')); } @@ -128,8 +128,8 @@ Result run(QSqlDatabase& db, const QString& sql, const QVariantList& binds } // Stored metric maps hold seconds already. Non-numeric values are skipped. -Result> load_metric_map(const QString& json_text) { - const QByteArray bytes = json_text.isEmpty() ? QByteArrayLiteral("{}") : json_text.toUtf8(); +Result> loadMetricMap(const QString& jsonText) { + const QByteArray bytes = jsonText.isEmpty() ? QByteArrayLiteral("{}") : jsonText.toUtf8(); QJsonParseError error; const QJsonDocument document = QJsonDocument::fromJson(bytes, &error); if (error.error != QJsonParseError::NoError || !document.isObject()) { @@ -146,7 +146,7 @@ Result> load_metric_map(const QString& json_text) { return values; } -QString metrics_to_json(const QMap& metrics) { +QString metricsToJson(const QMap& metrics) { QJsonObject object; for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { object.insert(it.key(), it.value()); @@ -154,46 +154,46 @@ QString metrics_to_json(const QMap& metrics) { return QString::fromUtf8(QJsonDocument(object).toJson(QJsonDocument::Compact)); } -Result row_to_mod(const QSqlQuery& row) { - const QString package_id = row.value(0).toString(); - const QString mod_name = row.value(1).toString(); +Result rowToMod(const QSqlQuery& row) { + const QString packageId = row.value(0).toString(); + const QString modName = row.value(1).toString(); // A report without a package id stores the mod name in its place. Read it back as absent. - std::optional pid = package_id; - if (package_id == mod_name && package_id != BASE_GAME_PID) { + std::optional pid = packageId; + if (packageId == modName && packageId != kBaseGamePid) { pid = std::nullopt; } - auto metrics = load_metric_map(row.value(3).toString()); + auto metrics = loadMetricMap(row.value(3).toString()); if (!metrics) { return std::unexpected(metrics.error()); } - auto off_thread = load_metric_map(row.value(4).toString()); - if (!off_thread) { - return std::unexpected(off_thread.error()); + auto offThread = loadMetricMap(row.value(4).toString()); + if (!offThread) { + return std::unexpected(offThread.error()); } return StartupImpactMod{ - .mod_name = mod_name, - .package_id = pid, - .total_impact_s = row.value(2).toDouble(), + .modName = modName, + .packageId = pid, + .totalImpactS = row.value(2).toDouble(), .metrics = std::move(*metrics), - .off_thread_metrics = std::move(*off_thread), - .off_thread_total_impact_s = row.value(5).toDouble(), + .offThreadMetrics = std::move(*offThread), + .offThreadTotalImpactS = row.value(5).toDouble(), }; } -Result> load_mods(QSqlDatabase& db, qlonglong session_id) { +Result> loadMods(QSqlDatabase& db, qlonglong sessionId) { auto query = exec(db, "SELECT package_id, mod_name, total_impact_s," " metrics_json, off_thread_metrics_json, off_thread_total_s" " FROM startup_impact_entries WHERE session_id = ?", - {session_id}); + {sessionId}); if (!query) { return std::unexpected(query.error()); } QList mods; while (query->next()) { - auto mod = row_to_mod(*query); + auto mod = rowToMod(*query); if (!mod) { return std::unexpected(mod.error()); } @@ -202,7 +202,7 @@ Result> load_mods(QSqlDatabase& db, qlonglong session_id return mods; } -void close_connection(StartupImpactDb::Connection& conn) { +void closeConnection(StartupImpactDb::Connection& conn) { if (!conn.open) { return; } @@ -218,11 +218,11 @@ void close_connection(StartupImpactDb::Connection& conn) { // Opens the connection on the worker, applying the legacy pragmas and schema. Reuses the open // connection when the path has not changed. -Result open_connection(StartupImpactDb::Connection& conn, const QString& path) { +Result openConnection(StartupImpactDb::Connection& conn, const QString& path) { if (conn.open && conn.path == path) { return QSqlDatabase::database(conn.name, false); } - close_connection(conn); + closeConnection(conn); QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), conn.name); db.setDatabaseName(path); @@ -238,7 +238,7 @@ Result open_connection(StartupImpactDb::Connection& conn, const QS return applied; } } - return ensure_schema(db, QString(SCHEMA), SCHEMA_VERSION, {}, path, + return ensureSchema(db, QString(kSchema), kSchemaVersion, {}, path, QStringLiteral("DB schema")); }; @@ -254,10 +254,10 @@ Result open_connection(StartupImpactDb::Connection& conn, const QS } // Read paths return empty results for a missing file rather than creating one. -bool file_exists(const QString& path) { return QFileInfo::exists(path); } +bool fileExists(const QString& path) { return QFileInfo::exists(path); } -Result ensure_parent_dir(const QString& path) { - if (file_exists(path)) { +Result ensureParentDir(const QString& path) { + if (fileExists(path)) { return {}; } const QString dir = QFileInfo(path).absolutePath(); @@ -267,30 +267,30 @@ Result ensure_parent_dir(const QString& path) { return {}; } -Result insert_report(QSqlDatabase& db, const StartupImpactReport& report) { +Result insertReport(QSqlDatabase& db, const StartupImpactReport& report) { const QString timestamp = report.timestamp.isNull() ? QStringLiteral("") : report.timestamp; auto session = exec(db, "INSERT INTO startup_impact_sessions" " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", - {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loading_time_s, + {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loadingTimeS, timestamp}); if (!session) { return std::unexpected(session.error()); } - const qlonglong session_id = session->lastInsertId().toLongLong(); + const qlonglong sessionId = session->lastInsertId().toLongLong(); for (const StartupImpactMod& mod : report.mods) { - const bool has_package_id = mod.package_id && !mod.package_id->isEmpty(); - const QString name = mod.mod_name.isNull() ? QStringLiteral("") : mod.mod_name; - const QString pid = has_package_id ? *mod.package_id : name; + const bool hasPackageId = mod.packageId && !mod.packageId->isEmpty(); + const QString name = mod.modName.isNull() ? QStringLiteral("") : mod.modName; + const QString pid = hasPackageId ? *mod.packageId : name; auto entry = run(db, "INSERT INTO startup_impact_entries" " (session_id, package_id, mod_name, total_impact_s," " metrics_json, off_thread_metrics_json, off_thread_total_s)" " VALUES (?, ?, ?, ?, ?, ?, ?)", - {session_id, pid, name, mod.total_impact_s, - metrics_to_json(mod.metrics), metrics_to_json(mod.off_thread_metrics), - mod.off_thread_total_impact_s}); + {sessionId, pid, name, mod.totalImpactS, + metricsToJson(mod.metrics), metricsToJson(mod.offThreadMetrics), + mod.offThreadTotalImpactS}); if (!entry) { return entry; } @@ -299,7 +299,7 @@ Result insert_report(QSqlDatabase& db, const StartupImpactReport& report) return run(db, "DELETE FROM startup_impact_sessions WHERE id NOT IN" " (SELECT id FROM startup_impact_sessions ORDER BY id DESC LIMIT ?)", - {HISTORY_DEPTH}); + {kHistoryDepth}); } Result compact(QSqlDatabase& db, const QString& path) { @@ -310,7 +310,7 @@ Result compact(QSqlDatabase& db, const QString& path) { if (auto vacuumed = run(db, "PRAGMA incremental_vacuum"); !vacuumed) { return vacuumed; } - if (QFileInfo(path).size() > VACUUM_THRESHOLD_BYTES) { + if (QFileInfo(path).size() > kVacuumThresholdBytes) { return run(db, "VACUUM"); } return {}; @@ -318,7 +318,7 @@ Result compact(QSqlDatabase& db, const QString& path) { // Runs `work` inside one transaction, rolling back when it fails. template -Result in_transaction(QSqlDatabase& db, Work&& work) { +Result inTransaction(QSqlDatabase& db, Work&& work) { if (!db.transaction()) { return std::unexpected(Error{db.lastError().text()}); } @@ -334,28 +334,28 @@ Result in_transaction(QSqlDatabase& db, Work&& work) { return {}; } -Result store_report_sync(StartupImpactDb::Connection& conn, const QString& path, +Result storeReportSync(StartupImpactDb::Connection& conn, const QString& path, const StartupImpactReport& report) { - if (auto dir = ensure_parent_dir(path); !dir) { + if (auto dir = ensureParentDir(path); !dir) { return dir; } - auto opened = open_connection(conn, path); + auto opened = openConnection(conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - if (auto stored = in_transaction(db, [&] { return insert_report(db, report); }); !stored) { + if (auto stored = inTransaction(db, [&] { return insertReport(db, report); }); !stored) { return stored; } return compact(db, path); } -Result> get_latest_report_sync(StartupImpactDb::Connection& conn, +Result> getLatestReportSync(StartupImpactDb::Connection& conn, const QString& path) { - if (!file_exists(path)) { + if (!fileExists(path)) { return std::nullopt; } - auto opened = open_connection(conn, path); + auto opened = openConnection(conn, path); if (!opened) { return std::unexpected(opened.error()); } @@ -370,12 +370,12 @@ Result> get_latest_report_sync(StartupImpactD if (!session->next()) { return std::nullopt; } - const qlonglong session_id = session->value(0).toLongLong(); + const qlonglong sessionId = session->value(0).toLongLong(); StartupImpactReport report; - report.loading_time_s = session->value(1).toDouble(); + report.loadingTimeS = session->value(1).toDouble(); report.timestamp = session->value(2).toString(); - auto mods = load_mods(db, session_id); + auto mods = loadMods(db, sessionId); if (!mods) { return std::unexpected(mods.error()); } @@ -383,23 +383,23 @@ Result> get_latest_report_sync(StartupImpactD return report; } -Result average_for(QSqlDatabase& db, const QString& package_id) { +Result averageFor(QSqlDatabase& db, const QString& packageId) { auto query = exec(db, - with_weights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" + withWeights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" " / NULLIF(SUM(w.weight), 0), 0)" " FROM startup_impact_entries e" " JOIN w ON w.session_id = e.session_id" " WHERE e.package_id = ?"), - with_cte_binds({package_id})); + withCteBinds({packageId})); if (!query) { return std::unexpected(query.error()); } return query->next() ? query->value(0).toDouble() : 0.0; } -Result> totals_for(QSqlDatabase& db, const QStringList& package_ids) { +Result> totalsFor(QSqlDatabase& db, const QStringList& packageIds) { auto query = exec(db, - with_weights("SELECT e.package_id," + withWeights("SELECT e.package_id," " COALESCE(SUM(e.total_impact_s * w.weight)" " * 1.0 / NULLIF(SUM(w.weight), 0), 0)," " COALESCE(SUM(e.off_thread_total_s * w.weight)" @@ -407,9 +407,9 @@ Result> totals_for(QSqlDatabase& db, const QStrin " FROM startup_impact_entries e" " JOIN w ON w.session_id = e.session_id" " WHERE e.package_id IN (" + - placeholder_list(package_ids.size()) + + placeholderList(packageIds.size()) + ") GROUP BY e.package_id"), - with_cte_binds(to_variants(package_ids))); + withCteBinds(toVariants(packageIds))); if (!query) { return std::unexpected(query.error()); } @@ -421,15 +421,15 @@ Result> totals_for(QSqlDatabase& db, const QStrin return totals; } -Result> all_averages_for(QSqlDatabase& db) { +Result> allAveragesFor(QSqlDatabase& db) { auto query = exec(db, - with_weights("SELECT e.package_id," + withWeights("SELECT e.package_id," " COALESCE(SUM(e.total_impact_s * w.weight)" " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" " FROM startup_impact_entries e" " JOIN w ON w.session_id = e.session_id" " GROUP BY e.package_id"), - with_cte_binds()); + withCteBinds()); if (!query) { return std::unexpected(query.error()); } @@ -440,16 +440,16 @@ Result> all_averages_for(QSqlDatabase& db) { return averages; } -Result clear_sync(StartupImpactDb::Connection& conn, const QString& path) { - if (!file_exists(path)) { +Result clearSync(StartupImpactDb::Connection& conn, const QString& path) { + if (!fileExists(path)) { return {}; } - auto opened = open_connection(conn, path); + auto opened = openConnection(conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - if (auto cleared = in_transaction(db, [&]() -> Result { + if (auto cleared = inTransaction(db, [&]() -> Result { if (auto entries = run(db, "DELETE FROM startup_impact_entries"); !entries) { return entries; } @@ -466,116 +466,116 @@ Result clear_sync(StartupImpactDb::Connection& conn, const QString& path) } // namespace -QString db_path(const QString& config_dir) { - return QDir(config_dir).filePath(QStringLiteral("cache.db")); +QString dbPath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("cache.db")); } StartupImpactDb::StartupImpactDb() { // A single thread keeps the connection on one thread. Idle threads must not expire, since // the connection is bound to the thread that opened it. - pool_.setMaxThreadCount(1); - pool_.setExpiryTimeout(-1); - conn_.name = QStringLiteral("startup_impact_%1") + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); + m_conn.name = QStringLiteral("startup_impact_%1") .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); } -StartupImpactDb::~StartupImpactDb() { close_sync(); } +StartupImpactDb::~StartupImpactDb() { closeSync(); } -QCoro::Task> StartupImpactDb::store_report(QString path, StartupImpactReport report) { - co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path), +QCoro::Task> StartupImpactDb::storeReport(QString path, StartupImpactReport report) { + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path), report = std::move(report)] { - return store_report_sync(conn_, path, report); + return storeReportSync(m_conn, path, report); }); } -QCoro::Task>> StartupImpactDb::get_latest_report( +QCoro::Task>> StartupImpactDb::getLatestReport( QString path) { - co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path)] { - return get_latest_report_sync(conn_, path); + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return getLatestReportSync(m_conn, path); }); } -QCoro::Task> StartupImpactDb::get_average(QString path, QString package_id) { +QCoro::Task> StartupImpactDb::getAverage(QString path, QString packageId) { co_return co_await QtConcurrent::run( - &pool_, [this, path = std::move(path), package_id = std::move(package_id)]() -> Result { - if (!file_exists(path)) { + &m_pool, [this, path = std::move(path), packageId = std::move(packageId)]() -> Result { + if (!fileExists(path)) { return 0.0; } - auto opened = open_connection(conn_, path); + auto opened = openConnection(m_conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - return average_for(db, package_id); + return averageFor(db, packageId); }); } -QCoro::Task>> StartupImpactDb::get_totals_for( - QString path, QStringList package_ids) { +QCoro::Task>> StartupImpactDb::getTotalsFor( + QString path, QStringList packageIds) { co_return co_await QtConcurrent::run( - &pool_, [this, path = std::move(path), package_ids = std::move(package_ids)]() + &m_pool, [this, path = std::move(path), packageIds = std::move(packageIds)]() -> Result> { // Ids without history are left out, so callers treat them as zero. - if (package_ids.isEmpty() || !file_exists(path)) { + if (packageIds.isEmpty() || !fileExists(path)) { return QHash{}; } - auto opened = open_connection(conn_, path); + auto opened = openConnection(m_conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - return totals_for(db, package_ids); + return totalsFor(db, packageIds); }); } -QCoro::Task>> StartupImpactDb::get_all_averages(QString path) { +QCoro::Task>> StartupImpactDb::getAllAverages(QString path) { co_return co_await QtConcurrent::run( - &pool_, [this, path = std::move(path)]() -> Result> { - if (!file_exists(path)) { + &m_pool, [this, path = std::move(path)]() -> Result> { + if (!fileExists(path)) { return QHash{}; } - auto opened = open_connection(conn_, path); + auto opened = openConnection(m_conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - return all_averages_for(db); + return allAveragesFor(db); }); } -QCoro::Task> StartupImpactDb::get_latest_with_averages( - QString path, QStringList active_pids, std::optional selected_pid) { +QCoro::Task> StartupImpactDb::getLatestWithAverages( + QString path, QStringList activePids, std::optional selectedPid) { co_return co_await QtConcurrent::run( - &pool_, [this, path = std::move(path), active_pids = std::move(active_pids), - selected_pid = std::move(selected_pid)]() -> Result { + &m_pool, [this, path = std::move(path), activePids = std::move(activePids), + selectedPid = std::move(selectedPid)]() -> Result { LatestWithAverages result; - for (const QString& pid : active_pids) { + for (const QString& pid : activePids) { result.totals.insert(pid, ThreadAverages{}); } - if (!file_exists(path)) { + if (!fileExists(path)) { return result; } - auto latest = get_latest_report_sync(conn_, path); + auto latest = getLatestReportSync(m_conn, path); if (!latest) { return std::unexpected(latest.error()); } result.report = std::move(*latest); - auto opened = open_connection(conn_, path); + auto opened = openConnection(m_conn, path); if (!opened) { return std::unexpected(opened.error()); } QSqlDatabase db = *opened; - auto base = average_for(db, QString(BASE_GAME_PID)); + auto base = averageFor(db, QString(kBaseGamePid)); if (!base) { return std::unexpected(base.error()); } - result.base_game_avg = *base; + result.baseGameAvg = *base; - if (!active_pids.isEmpty()) { - auto totals = totals_for(db, active_pids); + if (!activePids.isEmpty()) { + auto totals = totalsFor(db, activePids); if (!totals) { return std::unexpected(totals.error()); } @@ -584,29 +584,29 @@ QCoro::Task> StartupImpactDb::get_latest_with_average } } - if (selected_pid) { - auto selected = average_for(db, *selected_pid); + if (selectedPid) { + auto selected = averageFor(db, *selectedPid); if (!selected) { return std::unexpected(selected.error()); } - result.selected_avg = *selected; + result.selectedAvg = *selected; } return result; }); } QCoro::Task> StartupImpactDb::clear(QString path) { - co_return co_await QtConcurrent::run(&pool_, [this, path = std::move(path)] { - return clear_sync(conn_, path); + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return clearSync(m_conn, path); }); } QCoro::Task StartupImpactDb::close() { - co_await QtConcurrent::run(&pool_, [this] { close_connection(conn_); }); + co_await QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }); } -void StartupImpactDb::close_sync() { - QtConcurrent::run(&pool_, [this] { close_connection(conn_); }).waitForFinished(); +void StartupImpactDb::closeSync() { + QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }).waitForFinished(); } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/db.hpp b/src/core/services/startup_impact_service/db.hpp index 001ba07..8ff4cdf 100644 --- a/src/core/services/startup_impact_service/db.hpp +++ b/src/core/services/startup_impact_service/db.hpp @@ -15,20 +15,20 @@ namespace px::modrim::startup_impact { // Average on-thread and off-thread seconds for one package id. struct ThreadAverages { - double on_thread = 0.0; - double off_thread = 0.0; + double onThread = 0.0; + double offThread = 0.0; }; struct LatestWithAverages { std::optional report; - double base_game_avg = 0.0; + double baseGameAvg = 0.0; // Keyed by every requested package id; ids without history map to zeros. QHash totals; - double selected_avg = 0.0; + double selectedAvg = 0.0; }; // `cache.db` in the config directory. Shared with the metadata cache, which owns other tables. -QString db_path(const QString& config_dir); +QString dbPath(const QString& configDir); // Startup impact history in SQLite, kept to the most recent sessions. Averages weight newer // sessions more heavily. The schema matches the legacy Python app so either can read the file. @@ -50,24 +50,24 @@ class StartupImpactDb { StartupImpactDb(const StartupImpactDb&) = delete; StartupImpactDb& operator=(const StartupImpactDb&) = delete; - QCoro::Task> store_report(QString path, StartupImpactReport report); - QCoro::Task>> get_latest_report(QString path); - QCoro::Task> get_average(QString path, QString package_id); - QCoro::Task>> get_totals_for(QString path, - QStringList package_ids); - QCoro::Task>> get_all_averages(QString path); - QCoro::Task> get_latest_with_averages( - QString path, QStringList active_pids, std::optional selected_pid); + QCoro::Task> storeReport(QString path, StartupImpactReport report); + QCoro::Task>> getLatestReport(QString path); + QCoro::Task> getAverage(QString path, QString packageId); + QCoro::Task>> getTotalsFor(QString path, + QStringList packageIds); + QCoro::Task>> getAllAverages(QString path); + QCoro::Task> getLatestWithAverages( + QString path, QStringList activePids, std::optional selectedPid); QCoro::Task> clear(QString path); QCoro::Task close(); // For shutdown paths that cannot await. Blocks until the connection is closed. - void close_sync(); + void closeSync(); private: // The destructor drains the pool through close_sync() before these members are destroyed. - QThreadPool pool_; - Connection conn_; + QThreadPool m_pool; + Connection m_conn; }; } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.cpp b/src/core/services/startup_impact_service/labels.cpp index b2c34db..d5eabea 100644 --- a/src/core/services/startup_impact_service/labels.cpp +++ b/src/core/services/startup_impact_service/labels.cpp @@ -9,7 +9,7 @@ namespace { constexpr QLatin1StringView kPrefix{"LoadingProgress.StartupImpact."}; -const QHash& metric_labels() { +const QHash& metricLabels() { static const QHash labels{ {QStringLiteral("ModConstructor"), QStringLiteral("Running mod constructor")}, {QStringLiteral("LoadDefs"), QStringLiteral("Loading defs")}, @@ -85,8 +85,8 @@ const QHash& metric_labels() { } // The exact key, else its last dot-separated segment, else the key itself. -QString base_label(const QString& key) { - const auto& labels = metric_labels(); +QString baseLabel(const QString& key) { + const auto& labels = metricLabels(); if (const auto found = labels.constFind(key); found != labels.cend()) { return *found; } @@ -99,16 +99,16 @@ QString base_label(const QString& key) { } // namespace -QString strip_metric_prefix(const QString& key) { +QString stripMetricPrefix(const QString& key) { return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; } -QString metric_label(const QString& key) { - const QString stripped = strip_metric_prefix(key); +QString metricLabel(const QString& key) { + const QString stripped = stripMetricPrefix(key); const qsizetype bar = stripped.indexOf(u'|'); const QString base = bar < 0 ? stripped : stripped.left(bar); const QString target = bar < 0 ? QString() : stripped.mid(bar + 1); - const QString label = base_label(base); + const QString label = baseLabel(base); if (target.isEmpty()) { return label; } diff --git a/src/core/services/startup_impact_service/labels.hpp b/src/core/services/startup_impact_service/labels.hpp index 00f876c..0f498a0 100644 --- a/src/core/services/startup_impact_service/labels.hpp +++ b/src/core/services/startup_impact_service/labels.hpp @@ -5,10 +5,10 @@ namespace px::modrim::startup_impact { // Drops the "LoadingProgress.StartupImpact." prefix the game writes before every metric key. -QString strip_metric_prefix(const QString& key); +QString stripMetricPrefix(const QString& key); // Human-readable name for a metric key. A "|Target" suffix, as written for deferred tasks, // is shown in parentheses after the label. -QString metric_label(const QString& key); +QString metricLabel(const QString& key); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp index 0a811bf..343ead0 100644 --- a/src/core/services/startup_impact_service/models.cpp +++ b/src/core/services/startup_impact_service/models.cpp @@ -7,7 +7,7 @@ namespace px::modrim::startup_impact { namespace { template -double sum_values(const Map& map) { +double sumValues(const Map& map) { double total = 0.0; for (const double value : map) { total += value; @@ -15,46 +15,46 @@ double sum_values(const Map& map) { return total; } -bool is_set(const std::optional& value) { return value && !value->isEmpty(); } +bool isSet(const std::optional& value) { return value && !value->isEmpty(); } } // namespace -double StartupImpactReport::total_time() const { +double StartupImpactReport::totalTime() const { double total = 0.0; for (const StartupImpactMod& mod : mods) { - total += mod.total_impact_s; + total += mod.totalImpactS; } return total; } -double StartupImpactReport::off_thread_total_time() const { +double StartupImpactReport::offThreadTotalTime() const { double total = 0.0; for (const StartupImpactMod& mod : mods) { - total += mod.off_thread_total_impact_s; + total += mod.offThreadTotalImpactS; } return total; } -double StartupImpactReport::mods_total_time() const { - return total_time() + off_thread_total_time(); +double StartupImpactReport::modsTotalTime() const { + return totalTime() + offThreadTotalTime(); } -double StartupImpactReport::base_game_time() const { return sum_values(metrics); } +double StartupImpactReport::baseGameTime() const { return sumValues(metrics); } -const StartupImpactMod* StartupImpactReport::find(const std::optional& package_id, - const std::optional& mod_name) const { - if (is_set(package_id)) { - const QString normalized = normalize_package_id(*package_id); +const StartupImpactMod* StartupImpactReport::find(const std::optional& packageId, + const std::optional& modName) const { + if (isSet(packageId)) { + const QString normalized = normalizePackageId(*packageId); for (const StartupImpactMod& mod : mods) { - if (mod.package_id == normalized) { + if (mod.packageId == normalized) { return &mod; } } } - if (is_set(mod_name)) { - const QString name_lower = mod_name->toLower(); + if (isSet(modName)) { + const QString nameLower = modName->toLower(); for (const StartupImpactMod& mod : mods) { - if (mod.mod_name.toLower() == name_lower) { + if (mod.modName.toLower() == nameLower) { return &mod; } } @@ -62,16 +62,16 @@ const StartupImpactMod* StartupImpactReport::find(const std::optional& return nullptr; } -QString normalize_package_id(QStringView package_id) { - QString normalized = package_id.toString().toLower(); - constexpr QLatin1StringView steam_suffix{"_steam"}; - if (normalized.endsWith(steam_suffix)) { - normalized.chop(steam_suffix.size()); +QString normalizePackageId(QStringView packageId) { + QString normalized = packageId.toString().toLower(); + constexpr QLatin1StringView kSteamSuffix{"_steam"}; + if (normalized.endsWith(kSteamSuffix)) { + normalized.chop(kSteamSuffix.size()); } return normalized; } -QString format_impact(double seconds) { +QString formatImpact(double seconds) { const auto ms = static_cast(std::nearbyint(seconds * 1000.0)); return QStringLiteral("%1ms").arg(ms); } diff --git a/src/core/services/startup_impact_service/models.hpp b/src/core/services/startup_impact_service/models.hpp index 8c4a0bd..4f86ac6 100644 --- a/src/core/services/startup_impact_service/models.hpp +++ b/src/core/services/startup_impact_service/models.hpp @@ -10,47 +10,47 @@ namespace px::modrim::startup_impact { -inline constexpr double IMPACT_WARN_THRESHOLD_S = 1.0; -inline constexpr double IMPACT_HIGH_THRESHOLD_S = 5.0; +inline constexpr double kImpactWarnThresholdS = 1.0; +inline constexpr double kImpactHighThresholdS = 5.0; // Stored as the mod's package id for the RimWorld base game's own profiler metrics. -inline constexpr QLatin1StringView BASE_GAME_PID{"__base_game__"}; +inline constexpr QLatin1StringView kBaseGamePid{"__base_game__"}; // Impact durations are in seconds. Metric maps are keyed by profiler metric name and // ordered by name, so iteration is deterministic. struct StartupImpactMod { - QString mod_name; - std::optional package_id; - double total_impact_s = 0.0; + QString modName; + std::optional packageId; + double totalImpactS = 0.0; QMap metrics; - QMap off_thread_metrics; - double off_thread_total_impact_s = 0.0; + QMap offThreadMetrics; + double offThreadTotalImpactS = 0.0; }; struct StartupImpactReport { QString path; - double loading_time_s = 0.0; + double loadingTimeS = 0.0; QList mods; QString timestamp; QMap metrics; - double total_impact = 0.0; - QMap off_thread_metrics; - double off_thread_total_impact = 0.0; + double totalImpact = 0.0; + QMap offThreadMetrics; + double offThreadTotalImpact = 0.0; - double total_time() const; - double off_thread_total_time() const; - double mods_total_time() const; - double base_game_time() const; + double totalTime() const; + double offThreadTotalTime() const; + double modsTotalTime() const; + double baseGameTime() const; // Matches by normalized package id first, then by case-insensitive mod name. - const StartupImpactMod* find(const std::optional& package_id, - const std::optional& mod_name) const; + const StartupImpactMod* find(const std::optional& packageId, + const std::optional& modName) const; }; // Lowercases and drops the "_steam" suffix that Steam Workshop folders add to package ids. -QString normalize_package_id(QStringView package_id); +QString normalizePackageId(QStringView packageId); // Rounds half to even, as Python's round() does, so displayed values match the legacy app. -QString format_impact(double seconds); +QString formatImpact(double seconds); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp index 15acfac..56aca8a 100644 --- a/src/core/services/startup_impact_service/parser.cpp +++ b/src/core/services/startup_impact_service/parser.cpp @@ -17,75 +17,75 @@ namespace { constexpr QLatin1StringView kReportFilename{"StartupImpactData.json"}; // The game writes milliseconds; the rest of the app works in seconds. -double ms_to_s(double ms) { return ms / 1000.0; } +double msToS(double ms) { return ms / 1000.0; } // Python's bool is an int subclass, so the legacy parser accepts true/false as 1/0 ms. -bool is_numeric(const QJsonValue& value) { return value.isDouble() || value.isBool(); } +bool isNumeric(const QJsonValue& value) { return value.isDouble() || value.isBool(); } -double numeric_ms(const QJsonValue& value) { +double numericMs(const QJsonValue& value) { return value.isBool() ? static_cast(value.toBool()) : value.toDouble(); } -double as_seconds(const QJsonValue& value) { - if (is_numeric(value)) { - return ms_to_s(numeric_ms(value)); +double asSeconds(const QJsonValue& value) { + if (isNumeric(value)) { + return msToS(numericMs(value)); } if (value.isString()) { bool ok = false; const double ms = value.toString().toDouble(&ok); - return ok ? ms_to_s(ms) : 0.0; + return ok ? msToS(ms) : 0.0; } return 0.0; } // Non-numeric values are skipped, as the legacy parser skips them. -QMap as_seconds_map(const QJsonValue& value) { +QMap asSecondsMap(const QJsonValue& value) { QMap result; if (!value.isObject()) { return result; } const QJsonObject object = value.toObject(); for (auto it = object.constBegin(); it != object.constEnd(); ++it) { - if (is_numeric(it.value())) { - result.insert(it.key(), ms_to_s(numeric_ms(it.value()))); + if (isNumeric(it.value())) { + result.insert(it.key(), msToS(numericMs(it.value()))); } } return result; } -std::optional parse_mod(const QJsonValue& value) { +std::optional parseMod(const QJsonValue& value) { if (!value.isObject()) { return std::nullopt; } const QJsonObject entry = value.toObject(); - const QJsonValue mod_name = entry.value(QStringLiteral("modName")); - if (!mod_name.isString() || mod_name.toString().isEmpty()) { + const QJsonValue modName = entry.value(QStringLiteral("modName")); + if (!modName.isString() || modName.toString().isEmpty()) { return std::nullopt; } - const QJsonValue raw_package_id = entry.value(QStringLiteral("modPackageId")); - std::optional package_id; - if (raw_package_id.isString() && !raw_package_id.toString().isEmpty()) { - package_id = normalize_package_id(raw_package_id.toString()); + const QJsonValue rawPackageId = entry.value(QStringLiteral("modPackageId")); + std::optional packageId; + if (rawPackageId.isString() && !rawPackageId.toString().isEmpty()) { + packageId = normalizePackageId(rawPackageId.toString()); } return StartupImpactMod{ - .mod_name = mod_name.toString(), - .package_id = package_id, - .total_impact_s = as_seconds(entry.value(QStringLiteral("totalImpact"))), - .metrics = as_seconds_map(entry.value(QStringLiteral("metrics"))), - .off_thread_metrics = as_seconds_map(entry.value(QStringLiteral("offThreadMetrics"))), - .off_thread_total_impact_s = - as_seconds(entry.value(QStringLiteral("offThreadTotalImpact"))), + .modName = modName.toString(), + .packageId = packageId, + .totalImpactS = asSeconds(entry.value(QStringLiteral("totalImpact"))), + .metrics = asSecondsMap(entry.value(QStringLiteral("metrics"))), + .offThreadMetrics = asSecondsMap(entry.value(QStringLiteral("offThreadMetrics"))), + .offThreadTotalImpactS = + asSeconds(entry.value(QStringLiteral("offThreadTotalImpact"))), }; } } // namespace -QString get_startup_impact_path(const QString& config_folder) { +QString getStartupImpactPath(const QString& configFolder) { // Mirrors Path(config_folder).parent: a trailing separator is ignored and a bare name // has the current directory as its parent. - const QString clean = QDir::cleanPath(config_folder); + const QString clean = QDir::cleanPath(configFolder); const qsizetype slash = clean.lastIndexOf(u'/'); const QString parent = slash < 0 ? QStringLiteral(".") : slash == 0 ? QStringLiteral("/") @@ -93,7 +93,7 @@ QString get_startup_impact_path(const QString& config_folder) { return QDir::cleanPath(QDir(parent).filePath(kReportFilename.toString())); } -std::optional parse_startup_impact(const QString& path) { +std::optional parseStartupImpact(const QString& path) { if (!QFileInfo::exists(path)) { spdlog::debug("Startup impact report not found at {}", path.toStdString()); return std::nullopt; @@ -121,20 +121,20 @@ std::optional parse_startup_impact(const QString& path) { const QJsonObject data = document.object(); StartupImpactReport report; report.path = QDir::cleanPath(path); - report.loading_time_s = as_seconds(data.value(QStringLiteral("loadingTime"))); + report.loadingTimeS = asSeconds(data.value(QStringLiteral("loadingTime"))); if (const QJsonValue timestamp = data.value(QStringLiteral("timestamp")); timestamp.isString()) { report.timestamp = timestamp.toString(); } - report.metrics = as_seconds_map(data.value(QStringLiteral("metrics"))); - report.total_impact = as_seconds(data.value(QStringLiteral("totalImpact"))); - report.off_thread_metrics = as_seconds_map(data.value(QStringLiteral("offThreadMetrics"))); - report.off_thread_total_impact = - as_seconds(data.value(QStringLiteral("offThreadTotalImpact"))); + report.metrics = asSecondsMap(data.value(QStringLiteral("metrics"))); + report.totalImpact = asSeconds(data.value(QStringLiteral("totalImpact"))); + report.offThreadMetrics = asSecondsMap(data.value(QStringLiteral("offThreadMetrics"))); + report.offThreadTotalImpact = + asSeconds(data.value(QStringLiteral("offThreadTotalImpact"))); // A non-array "mods" yields no entries, as iterating the legacy value did. for (const QJsonValue& entry : data.value(QStringLiteral("mods")).toArray()) { - if (auto mod = parse_mod(entry)) { + if (auto mod = parseMod(entry)) { report.mods.append(std::move(*mod)); } } diff --git a/src/core/services/startup_impact_service/parser.hpp b/src/core/services/startup_impact_service/parser.hpp index 41425b0..bc82139 100644 --- a/src/core/services/startup_impact_service/parser.hpp +++ b/src/core/services/startup_impact_service/parser.hpp @@ -9,10 +9,10 @@ namespace px::modrim::startup_impact { // The report lives next to the game's config folder, not inside it. -QString get_startup_impact_path(const QString& config_folder); +QString getStartupImpactPath(const QString& configFolder); // Reads a StartupImpactData.json report. Returns nullopt when the file is missing, unreadable, // or not a JSON object. Malformed entries are skipped rather than failing the whole report. -std::optional parse_startup_impact(const QString& path); +std::optional parseStartupImpact(const QString& path); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp index c4408ef..ba5bd66 100644 --- a/src/core/services/startup_impact_service/phases.cpp +++ b/src/core/services/startup_impact_service/phases.cpp @@ -12,80 +12,80 @@ namespace px::modrim::startup_impact { namespace { -const QHash& exact_phases() { +const QHash& exactPhases() { static const QHash phases{ - {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), Phase::Content}, - {QStringLiteral("ModConstructor"), Phase::ModConstructors}, - {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), Phase::StaticConstructors}, - {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::Deferred}, - {QStringLiteral("LoadDefs"), Phase::Defs}, - {QStringLiteral("LoadModXml"), Phase::Defs}, - {QStringLiteral("CombineXml"), Phase::Defs}, - {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::Defs}, - {QStringLiteral("RegisterXmlInheritance"), Phase::Defs}, - {QStringLiteral("ResolveXmlInheritance"), Phase::Defs}, - {QStringLiteral("ClearCachedXmlInheritance"), Phase::Defs}, - {QStringLiteral("DefDatabaseAddAllInMods"), Phase::Defs}, - {QStringLiteral("ResolveReferences"), Phase::Defs}, - {QStringLiteral("ErrorCheckAllDefs"), Phase::Defs}, - {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::Defs}, + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), Phase::kContent}, + {QStringLiteral("ModConstructor"), Phase::kModConstructors}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), Phase::kStaticConstructors}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::kDeferred}, + {QStringLiteral("LoadDefs"), Phase::kDefs}, + {QStringLiteral("LoadModXml"), Phase::kDefs}, + {QStringLiteral("CombineXml"), Phase::kDefs}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::kDefs}, + {QStringLiteral("RegisterXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ResolveXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ClearCachedXmlInheritance"), Phase::kDefs}, + {QStringLiteral("DefDatabaseAddAllInMods"), Phase::kDefs}, + {QStringLiteral("ResolveReferences"), Phase::kDefs}, + {QStringLiteral("ErrorCheckAllDefs"), Phase::kDefs}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::kDefs}, }; return phases; } // Checked in order after the exact names; the first matching prefix wins. constexpr std::array, 4> kPrefixPhases{{ - {QLatin1StringView{"ModContentPackReloadContentInt"}, Phase::Content}, - {QLatin1StringView{"ResolveAllWantedCrossReferences"}, Phase::Defs}, - {QLatin1StringView{"DefGeneratorGenerateImpliedDefs"}, Phase::Defs}, - {QLatin1StringView{"DefOfHelperRebindAllDefOfs"}, Phase::Defs}, + {QLatin1StringView{"ModContentPackReloadContentInt"}, Phase::kContent}, + {QLatin1StringView{"ResolveAllWantedCrossReferences"}, Phase::kDefs}, + {QLatin1StringView{"DefGeneratorGenerateImpliedDefs"}, Phase::kDefs}, + {QLatin1StringView{"DefOfHelperRebindAllDefOfs"}, Phase::kDefs}, }}; } // namespace -QString phase_label(Phase phase) { +QString phaseLabel(Phase phase) { switch (phase) { - case Phase::Content: + case Phase::kContent: return QStringLiteral("Textures & audio"); - case Phase::ModConstructors: + case Phase::kModConstructors: return QStringLiteral("Mod constructors"); - case Phase::StaticConstructors: + case Phase::kStaticConstructors: return QStringLiteral("Static constructors"); - case Phase::Patches: + case Phase::kPatches: return QStringLiteral("Patches"); - case Phase::Defs: + case Phase::kDefs: return QStringLiteral("Defs & XML"); - case Phase::Deferred: + case Phase::kDeferred: return QStringLiteral("Deferred init"); - case Phase::Other: + case Phase::kOther: return QStringLiteral("Other"); } return QString(); } -Phase metric_phase(const QString& key) { - const QString stripped = strip_metric_prefix(key); +Phase metricPhase(const QString& key) { + const QString stripped = stripMetricPrefix(key); const qsizetype bar = stripped.indexOf(u'|'); const QString name = bar < 0 ? stripped : stripped.left(bar); - if (const auto found = exact_phases().constFind(name); found != exact_phases().cend()) { + if (const auto found = exactPhases().constFind(name); found != exactPhases().cend()) { return *found; } if (name.endsWith(QLatin1StringView{"Patches"})) { - return Phase::Patches; + return Phase::kPatches; } for (const auto& [prefix, phase] : kPrefixPhases) { if (name.startsWith(prefix)) { return phase; } } - return Phase::Other; + return Phase::kOther; } -PhaseTotals phase_totals(const QMap& metrics) { +PhaseTotals phaseTotals(const QMap& metrics) { PhaseTotals totals{}; for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { - totals[phase_index(metric_phase(it.key()))] += it.value(); + totals[phaseIndex(metricPhase(it.key()))] += it.value(); } return totals; } diff --git a/src/core/services/startup_impact_service/phases.hpp b/src/core/services/startup_impact_service/phases.hpp index d9e047d..8078e8e 100644 --- a/src/core/services/startup_impact_service/phases.hpp +++ b/src/core/services/startup_impact_service/phases.hpp @@ -10,28 +10,28 @@ namespace px::modrim::startup_impact { enum class Phase : std::uint8_t { - Content, - ModConstructors, - StaticConstructors, - Patches, - Defs, - Deferred, - Other, + kContent, + kModConstructors, + kStaticConstructors, + kPatches, + kDefs, + kDeferred, + kOther, }; -inline constexpr std::size_t PHASE_COUNT = 7; +inline constexpr std::size_t kPhaseCount = 7; // Seconds per phase, indexed by `phase_index`. -using PhaseTotals = std::array; +using PhaseTotals = std::array; -constexpr std::size_t phase_index(Phase phase) { return static_cast(phase); } +constexpr std::size_t phaseIndex(Phase phase) { return static_cast(phase); } -QString phase_label(Phase phase); +QString phaseLabel(Phase phase); // Phase a profiler metric key belongs to. Unknown keys fall into Other. -Phase metric_phase(const QString& key); +Phase metricPhase(const QString& key); // Sums metric seconds per phase. Every phase is present, even when it has no metrics. -PhaseTotals phase_totals(const QMap& metrics); +PhaseTotals phaseTotals(const QMap& metrics); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp index 2681bf7..40b0624 100644 --- a/src/core/services/startup_impact_service/service.cpp +++ b/src/core/services/startup_impact_service/service.cpp @@ -15,95 +15,95 @@ namespace { constexpr double kMinBaseGameTimeS = 0.001; -QString dbPathFor(CoreContext& ctx) { return db_path(ctx.configService().directory()); } +QString dbPathFor(CoreContext& ctx) { return dbPath(ctx.configService().directory()); } QStringList normalized(const QStringList& pids) { QStringList out; out.reserve(pids.size()); - for (const QString& pid : pids) out.push_back(normalize_package_id(pid)); + for (const QString& pid : pids) out.push_back(normalizePackageId(pid)); return out; } } // namespace -StartupImpactService::StartupImpactService(CoreContext& ctx) : ctx_(ctx) {} +StartupImpactService::StartupImpactService(CoreContext& ctx) : m_ctx(ctx) {} QCoro::Task>> StartupImpactService::load() { - const std::string& configFolder = ctx_.config().paths.config_folder; + const std::string& configFolder = m_ctx.config().paths.config_folder; if (configFolder.empty()) co_return std::optional{}; - const QString dbPath = dbPathFor(ctx_); - const QString jsonPath = get_startup_impact_path(QString::fromStdString(configFolder)); + const QString dbPath = dbPathFor(m_ctx); + const QString jsonPath = getStartupImpactPath(QString::fromStdString(configFolder)); spdlog::debug("Loading startup impact from {}", jsonPath.toStdString()); - std::optional report = parse_startup_impact(jsonPath); + std::optional report = parseStartupImpact(jsonPath); if (report) { - const double base = std::max(0.0, report->base_game_time()); + const double base = std::max(0.0, report->baseGameTime()); QList entries = report->mods; if (base > kMinBaseGameTimeS) { StartupImpactMod baseGame; - baseGame.mod_name = QString(BASE_GAME_PID); - baseGame.package_id = QString(BASE_GAME_PID); - baseGame.total_impact_s = base; + baseGame.modName = QString(kBaseGamePid); + baseGame.packageId = QString(kBaseGamePid); + baseGame.totalImpactS = base; baseGame.metrics = report->metrics; - baseGame.off_thread_metrics = report->off_thread_metrics; - baseGame.off_thread_total_impact_s = - std::accumulate(report->off_thread_metrics.cbegin(), - report->off_thread_metrics.cend(), 0.0); + baseGame.offThreadMetrics = report->offThreadMetrics; + baseGame.offThreadTotalImpactS = + std::accumulate(report->offThreadMetrics.cbegin(), + report->offThreadMetrics.cend(), 0.0); entries.push_back(std::move(baseGame)); } StartupImpactReport full; full.path = report->path; - full.loading_time_s = report->loading_time_s; + full.loadingTimeS = report->loadingTimeS; full.mods = std::move(entries); full.timestamp = report->timestamp; - auto stored = co_await db_.store_report(dbPath, std::move(full)); + auto stored = co_await m_db.storeReport(dbPath, std::move(full)); if (!stored) co_return std::unexpected(stored.error()); } co_return report; } -QCoro::Task>> StartupImpactService::get_latest_report() { - co_return co_await db_.get_latest_report(dbPathFor(ctx_)); +QCoro::Task>> StartupImpactService::getLatestReport() { + co_return co_await m_db.getLatestReport(dbPathFor(m_ctx)); } -QCoro::Task> StartupImpactService::estimated_impact_for(QString package_id) { - co_return co_await db_.get_average(dbPathFor(ctx_), normalize_package_id(package_id)); +QCoro::Task> StartupImpactService::estimatedImpactFor(QString packageId) { + co_return co_await m_db.getAverage(dbPathFor(m_ctx), normalizePackageId(packageId)); } -QCoro::Task> StartupImpactService::estimated_total(QStringList active_pids) { - const QString dbPath = dbPathFor(ctx_); - auto base = co_await db_.get_average(dbPath, QString(BASE_GAME_PID)); +QCoro::Task> StartupImpactService::estimatedTotal(QStringList activePids) { + const QString dbPath = dbPathFor(m_ctx); + auto base = co_await m_db.getAverage(dbPath, QString(kBaseGamePid)); if (!base) co_return std::unexpected(base.error()); - auto totals = co_await db_.get_totals_for(dbPath, normalized(active_pids)); + auto totals = co_await m_db.getTotalsFor(dbPath, normalized(activePids)); if (!totals) co_return std::unexpected(totals.error()); double modSum = 0.0; - for (const ThreadAverages& t : *totals) modSum += t.on_thread + t.off_thread; + for (const ThreadAverages& t : *totals) modSum += t.onThread + t.offThread; co_return *base + modSum; } -QCoro::Task>> StartupImpactService::get_all_averages() { - co_return co_await db_.get_all_averages(dbPathFor(ctx_)); +QCoro::Task>> StartupImpactService::getAllAverages() { + co_return co_await m_db.getAllAverages(dbPathFor(m_ctx)); } -QCoro::Task> StartupImpactService::base_game_average() { - co_return co_await db_.get_average(dbPathFor(ctx_), QString(BASE_GAME_PID)); +QCoro::Task> StartupImpactService::baseGameAverage() { + co_return co_await m_db.getAverage(dbPathFor(m_ctx), QString(kBaseGamePid)); } QCoro::Task> StartupImpactService::snapshot( - QStringList active_pids, std::optional selected_pid) { + QStringList activePids, std::optional selectedPid) { std::optional selected; - if (selected_pid && !selected_pid->isEmpty()) selected = normalize_package_id(*selected_pid); - co_return co_await db_.get_latest_with_averages(dbPathFor(ctx_), normalized(active_pids), + if (selectedPid && !selectedPid->isEmpty()) selected = normalizePackageId(*selectedPid); + co_return co_await m_db.getLatestWithAverages(dbPathFor(m_ctx), normalized(activePids), std::move(selected)); } QCoro::Task> StartupImpactService::clear() { - co_return co_await db_.clear(dbPathFor(ctx_)); + co_return co_await m_db.clear(dbPathFor(m_ctx)); } -QCoro::Task StartupImpactService::close_connection() { co_await db_.close(); } +QCoro::Task StartupImpactService::closeConnection() { co_await m_db.close(); } -void StartupImpactService::close_connection_sync() { db_.close_sync(); } +void StartupImpactService::closeConnectionSync() { m_db.closeSync(); } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.hpp b/src/core/services/startup_impact_service/service.hpp index d3333ab..6d7eefe 100644 --- a/src/core/services/startup_impact_service/service.hpp +++ b/src/core/services/startup_impact_service/service.hpp @@ -28,22 +28,22 @@ class StartupImpactService { // nullopt when no config folder is set or no report can be read. QCoro::Task>> load(); - QCoro::Task>> get_latest_report(); - QCoro::Task> estimated_impact_for(QString package_id); + QCoro::Task>> getLatestReport(); + QCoro::Task> estimatedImpactFor(QString packageId); // Base-game average plus the on-thread and off-thread averages of each active mod. - QCoro::Task> estimated_total(QStringList active_pids); - QCoro::Task>> get_all_averages(); - QCoro::Task> base_game_average(); - QCoro::Task> snapshot(QStringList active_pids, - std::optional selected_pid = {}); + QCoro::Task> estimatedTotal(QStringList activePids); + QCoro::Task>> getAllAverages(); + QCoro::Task> baseGameAverage(); + QCoro::Task> snapshot(QStringList activePids, + std::optional selectedPid = {}); QCoro::Task> clear(); - QCoro::Task close_connection(); - void close_connection_sync(); + QCoro::Task closeConnection(); + void closeConnectionSync(); private: - CoreContext& ctx_; - StartupImpactDb db_; + CoreContext& m_ctx; + StartupImpactDb m_db; }; } // namespace px::modrim::startup_impact diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp index 184f0f5..f4dc99e 100644 --- a/src/core/services/update_service.cpp +++ b/src/core/services/update_service.cpp @@ -26,18 +26,18 @@ struct ParsedVersion { }; // Python's str.strip("-._ ") on both ends. -QString strip_separators(QString text) { - constexpr QStringView separators = u"-._ "; - while (!text.isEmpty() && separators.contains(text.front())) { +QString stripSeparators(QString text) { + constexpr QStringView kSeparators = u"-._ "; + while (!text.isEmpty() && kSeparators.contains(text.front())) { text.removeFirst(); } - while (!text.isEmpty() && separators.contains(text.back())) { + while (!text.isEmpty() && kSeparators.contains(text.back())) { text.chop(1); } return text; } -std::optional split_version(const QString& text) { +std::optional splitVersion(const QString& text) { static const QRegularExpression pattern(QStringLiteral(R"(^v?([0-9]+(?:\.[0-9]+)*)([^+]*))")); const QRegularExpressionMatch match = pattern.match(text.trimmed()); if (!match.hasMatch()) { @@ -47,13 +47,13 @@ std::optional split_version(const QString& text) { for (const QString& part : match.captured(1).split(u'.')) { parsed.numbers.push_back(part.toLongLong()); } - parsed.suffix = strip_separators(match.captured(2)).toLower(); + parsed.suffix = stripSeparators(match.captured(2)).toLower(); return parsed; } // Orders two versions the way the legacy key did: padded numbers, then release over // pre-release, then the suffix text. -int compare_versions(const ParsedVersion& a, const ParsedVersion& b) { +int compareVersions(const ParsedVersion& a, const ParsedVersion& b) { const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); auto padded = [width](std::vector numbers) { numbers.resize(width, 0); @@ -79,7 +79,7 @@ struct ReplyDeleter { void operator()(QNetworkReply* reply) const { reply->deleteLater(); } }; -QString string_field(const QJsonObject& object, const char* key) { +QString stringField(const QJsonObject& object, const char* key) { const QJsonValue value = object.value(QLatin1StringView(key)); if (value.isString()) { return value.toString(); @@ -92,36 +92,36 @@ QString string_field(const QJsonObject& object, const char* key) { } // namespace -std::optional> parse_version(const QString& text) { - const auto parsed = split_version(text); +std::optional> parseVersion(const QString& text) { + const auto parsed = splitVersion(text); if (!parsed) { return std::nullopt; } return parsed->numbers; } -bool is_newer(const QString& tag, const QString& current) { - const auto latest = split_version(tag); - const auto running = split_version(current); +bool isNewer(const QString& tag, const QString& current) { + const auto latest = splitVersion(tag); + const auto running = splitVersion(current); if (!latest || !running) { return false; } - return compare_versions(*latest, *running) > 0; + return compareVersions(*latest, *running) > 0; } -UpdateService::UpdateService(QString current_version, QNetworkAccessManager& network, - QString release_url) - : current_(std::move(current_version)), - network_(network), - release_url_(std::move(release_url)) {} +UpdateService::UpdateService(QString currentVersion, QNetworkAccessManager& network, + QString releaseUrl) + : m_current(std::move(currentVersion)), + m_network(network), + m_releaseUrl(std::move(releaseUrl)) {} QCoro::Task>> UpdateService::check() { - QNetworkRequest request{QUrl(release_url_)}; + QNetworkRequest request{QUrl(m_releaseUrl)}; request.setRawHeader("Accept", "application/vnd.github+json"); - request.setRawHeader("User-Agent", QStringLiteral("PxModRim/%1").arg(current_).toUtf8()); + request.setRawHeader("User-Agent", QStringLiteral("PxModRim/%1").arg(m_current).toUtf8()); request.setTransferTimeout(kTransferTimeoutMs); - QNetworkReply* pending = network_.get(request); + QNetworkReply* pending = m_network.get(request); std::unique_ptr reply(pending); co_await pending; @@ -130,7 +130,7 @@ QCoro::Task>> UpdateService::check() { co_return std::unexpected(Error{reply->errorString()}); } if (status < 200 || status >= 300) { - co_return std::unexpected(Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(release_url_)}); + co_return std::unexpected(Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(m_releaseUrl)}); } const QByteArray body = reply->readAll(); @@ -144,23 +144,23 @@ QCoro::Task>> UpdateService::check() { } const QJsonObject data = document.object(); - const QString tag = string_field(data, "tag_name"); - const QString url = string_field(data, "html_url"); + const QString tag = stringField(data, "tag_name"); + const QString url = stringField(data, "html_url"); if (tag.isEmpty() || url.isEmpty()) { co_return std::unexpected(Error{QStringLiteral("Latest release response lacks tag_name/html_url")}); } - if (!is_newer(tag, current_)) { - spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), current_.toStdString()); + if (!isNewer(tag, m_current)) { + spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), m_current.toStdString()); co_return std::optional{}; } - spdlog::info("Update available: {} (running {})", tag.toStdString(), current_.toStdString()); - QString name = string_field(data, "name"); + spdlog::info("Update available: {} (running {})", tag.toStdString(), m_current.toStdString()); + QString name = stringField(data, "name"); if (name.isEmpty()) { name = tag; } co_return std::optional( - ReleaseInfo{tag, name, string_field(data, "body"), url}); + ReleaseInfo{tag, name, stringField(data, "body"), url}); } } // namespace px::modrim::services diff --git a/src/core/services/update_service.hpp b/src/core/services/update_service.hpp index 2f24dd8..26142eb 100644 --- a/src/core/services/update_service.hpp +++ b/src/core/services/update_service.hpp @@ -13,7 +13,7 @@ class QNetworkAccessManager; namespace px::modrim::services { -inline constexpr QLatin1StringView LATEST_RELEASE_URL{ +inline constexpr QLatin1StringView kLatestReleaseUrl{ "https://api.github.com/repos/PyXiion/PxModRim/releases/latest"}; struct ReleaseInfo { @@ -25,31 +25,31 @@ struct ReleaseInfo { // The numeric components of `text` ("v1.2.3" -> 1, 2, 3), ignoring any suffix; nullopt when the // text does not start with a version. -std::optional> parse_version(const QString& text); +std::optional> parseVersion(const QString& text); // True when `tag` is a strictly higher release than `current`. A pre-release suffix ("-rc1") // sorts below the plain release with the same numbers. Versions that do not parse are never // reported as outdated. -bool is_newer(const QString& tag, const QString& current); +bool isNewer(const QString& tag, const QString& current); // Looks up the newest published GitHub release. class UpdateService { public: // `network` is not owned and must outlive the service. `release_url` is the latest-release // endpoint; the default is the project's own. - UpdateService(QString current_version, QNetworkAccessManager& network, - QString release_url = QString(LATEST_RELEASE_URL)); + UpdateService(QString currentVersion, QNetworkAccessManager& network, + QString releaseUrl = QString(kLatestReleaseUrl)); - const QString& current_version() const noexcept { return current_; } + const QString& currentVersion() const noexcept { return m_current; } // The latest release when it is newer than the running app, nullopt otherwise. Fails on a // network error, a non-success HTTP status or a malformed response. QCoro::Task>> check(); private: - QString current_; - QNetworkAccessManager& network_; - QString release_url_; + QString m_current; + QNetworkAccessManager& m_network; + QString m_releaseUrl; }; } // namespace px::modrim::services diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp index 506450b..06014f5 100644 --- a/src/core/sort/community.cpp +++ b/src/core/sort/community.cpp @@ -11,7 +11,7 @@ namespace px::modrim::sort { namespace { -CaseInsensitiveSet to_package_set(const std::map& rules) { +CaseInsensitiveSet toPackageSet(const std::map& rules) { CaseInsensitiveSet result; for (const auto& [pid, _] : rules) { result.add(QString::fromStdString(pid)); @@ -21,26 +21,26 @@ CaseInsensitiveSet to_package_set(const std::map& } // namespace -CommunityRules to_community_rules(const ExternalRulesSchema& schema) { +CommunityRules toCommunityRules(const ExternalRulesSchema& schema) { CommunityRules result; for (const auto& [pid_str, rule] : schema.rules) { CommunityRule community; - community.package_id = PackageId(QString::fromStdString(pid_str)); - community.load_after = to_package_set(rule.loadAfter); - community.load_before = to_package_set(rule.loadBefore); - community.load_first = rule.loadTop.value; - community.load_last = rule.loadBottom.value; - result.insert(community.package_id, std::move(community)); + community.packageId = PackageId(QString::fromStdString(pid_str)); + community.loadAfter = toPackageSet(rule.loadAfter); + community.loadBefore = toPackageSet(rule.loadBefore); + community.loadFirst = rule.loadTop.value; + community.loadLast = rule.loadBottom.value; + result.insert(community.packageId, std::move(community)); } return result; } -Result load_community_rules(const QString& json_path) { - if (!QFileInfo::exists(json_path)) { +Result loadCommunityRules(const QString& jsonPath) { + if (!QFileInfo::exists(jsonPath)) { return CommunityRules{}; } - auto bytes = read_file_bytes(json_path); + auto bytes = readFileBytes(jsonPath); if (!bytes) { return std::unexpected(bytes.error()); } @@ -48,10 +48,10 @@ Result load_community_rules(const QString& json_path) { ExternalRulesSchema schema; if (auto error = glz::read(schema, *bytes)) { return std::unexpected(Error{QStringLiteral("Failed to parse community rules %1: %2") - .arg(json_path, QString::fromStdString( + .arg(jsonPath, QString::fromStdString( glz::format_error(error, *bytes)))}); } - return to_community_rules(schema); + return toCommunityRules(schema); } } // namespace px::modrim::sort diff --git a/src/core/sort/community.hpp b/src/core/sort/community.hpp index 5784d48..b21a4d3 100644 --- a/src/core/sort/community.hpp +++ b/src/core/sort/community.hpp @@ -10,9 +10,9 @@ namespace px::modrim::sort { // Converts the upstream schema into rules keyed by package id. Only the keys of loadAfter and // loadBefore matter; their values are ignored. -CommunityRules to_community_rules(const ExternalRulesSchema& schema); +CommunityRules toCommunityRules(const ExternalRulesSchema& schema); // Reads the community rules file. A missing file means no rules; a malformed one is an error. -Result load_community_rules(const QString& json_path); +Result loadCommunityRules(const QString& jsonPath); } // namespace px::modrim::sort diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp index ef9f147..88911b5 100644 --- a/src/core/sort/community_service.cpp +++ b/src/core/sort/community_service.cpp @@ -32,13 +32,13 @@ constexpr int kHeadTransferTimeoutMs = 10'000; constexpr int kGetTransferTimeoutMs = 30'000; constexpr int kExtractChunkBytes = 64 * 1024; -Error network_error(const QNetworkReply& reply) { +Error networkError(const QNetworkReply& reply) { return Error{reply.errorString()}; } // Writes one archive entry under `dest`. Entries whose names would escape `dest` are skipped, // as the Python zipfile sanitiser also refuses to write outside the target directory. -Result extract_entry(zip_t* archive, zip_uint64_t index, const QDir& dest) { +Result extractEntry(zip_t* archive, zip_uint64_t index, const QDir& dest) { zip_stat_t stat; zip_stat_init(&stat); if (zip_stat_index(archive, index, 0, &stat) != 0 || !(stat.valid & ZIP_STAT_NAME)) { @@ -67,7 +67,7 @@ Result extract_entry(zip_t* archive, zip_uint64_t index, const QDir& dest) if (!entry) { return std::unexpected(Error{QStringLiteral("Cannot read %1 from archive").arg(name)}); } - auto close_entry = qScopeGuard([entry] { zip_fclose(entry); }); + auto closeEntry = qScopeGuard([entry] { zip_fclose(entry); }); const QString target = dest.filePath(relative); QFile out(target); @@ -91,22 +91,22 @@ Result extract_entry(zip_t* archive, zip_uint64_t index, const QDir& dest) return {}; } -Result extract_zip(const QString& zip_path, const QString& dest_dir) { - int error_code = 0; - zip_t* archive = zip_open(QFile::encodeName(zip_path).constData(), ZIP_RDONLY, &error_code); +Result extractZip(const QString& zipPath, const QString& destDir) { + int errorCode = 0; + zip_t* archive = zip_open(QFile::encodeName(zipPath).constData(), ZIP_RDONLY, &errorCode); if (!archive) { return std::unexpected( - Error{QStringLiteral("Cannot open %1 (libzip error %2)").arg(zip_path).arg(error_code)}); + Error{QStringLiteral("Cannot open %1 (libzip error %2)").arg(zipPath).arg(errorCode)}); } - auto close_archive = qScopeGuard([archive] { zip_close(archive); }); + auto closeArchive = qScopeGuard([archive] { zip_close(archive); }); - if (!QDir().mkpath(dest_dir)) { - return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(dest_dir)}); + if (!QDir().mkpath(destDir)) { + return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(destDir)}); } - const QDir dest(dest_dir); + const QDir dest(destDir); const zip_int64_t count = zip_get_num_entries(archive, 0); for (zip_int64_t i = 0; i < count; ++i) { - if (auto extracted = extract_entry(archive, static_cast(i), dest); !extracted) { + if (auto extracted = extractEntry(archive, static_cast(i), dest); !extracted) { return extracted; } } @@ -115,19 +115,19 @@ Result extract_zip(const QString& zip_path, const QString& dest_dir) { } // namespace -CommunityRulesService::CommunityRulesService(const ConfigService& config_service, +CommunityRulesService::CommunityRulesService(const ConfigService& configService, QNetworkAccessManager& network) - : cache_dir_(config_service.directory()), - json_path_(QDir(cache_dir_).filePath(kRulesFilename)), - network_(network) {} + : m_cacheDir(configService.directory()), + m_jsonPath(QDir(m_cacheDir).filePath(kRulesFilename)), + m_network(network) {} -QCoro::Task> CommunityRulesService::ensure_rules(LoadingState& loading, +QCoro::Task> CommunityRulesService::ensureRules(LoadingState& loading, bool force) { - if (QFileInfo::exists(json_path_) && !force) { - co_return json_path_; + if (QFileInfo::exists(m_jsonPath) && !force) { + co_return m_jsonPath; } - auto result = co_await download_rules(loading); + auto result = co_await downloadRules(loading); if (!result) { spdlog::warn("Failed to download community rules: {}", result.error().message.toStdString()); co_return std::nullopt; @@ -135,13 +135,13 @@ QCoro::Task> CommunityRulesService::ensure_rules(LoadingS co_return *result; } -QCoro::Task>> CommunityRulesService::download_rules(LoadingState& loading) { - if (!QDir().mkpath(cache_dir_)) { - co_return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(cache_dir_)}); +QCoro::Task>> CommunityRulesService::downloadRules(LoadingState& loading) { + if (!QDir().mkpath(m_cacheDir)) { + co_return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(m_cacheDir)}); } - const QDir cache(cache_dir_); - const QString zip_path = cache.filePath(QStringLiteral("community_rules.zip")); - const QString extract_dir = cache.filePath(QStringLiteral("community_rules_extracted")); + const QDir cache(m_cacheDir); + const QString zipPath = cache.filePath(QStringLiteral("community_rules.zip")); + const QString extractDir = cache.filePath(QStringLiteral("community_rules_extracted")); QNetworkRequest request{QUrl(kRulesUrl)}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); @@ -149,66 +149,66 @@ QCoro::Task>> CommunityRulesService::download_rule // The HEAD request only sizes the progress bar; a missing Content-Length falls back to 100 // steps, which is what the original did. request.setTransferTimeout(kHeadTransferTimeoutMs); - QNetworkReply* head = network_.head(request); - auto release_head = qScopeGuard([head] { head->deleteLater(); }); + QNetworkReply* head = m_network.head(request); + auto releaseHead = qScopeGuard([head] { head->deleteLater(); }); co_await qCoro(head).waitForFinished(); if (head->error() != QNetworkReply::NoError) { - co_return std::unexpected(network_error(*head)); + co_return std::unexpected(networkError(*head)); } - const qint64 content_length = head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); - const int total_size = content_length > 0 - ? static_cast(std::min(content_length, + const qint64 contentLength = head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + const int totalSize = contentLength > 0 + ? static_cast(std::min(contentLength, std::numeric_limits::max())) : 100; request.setTransferTimeout(kGetTransferTimeoutMs); - QNetworkReply* get = network_.get(request); - auto release_get = qScopeGuard([get] { get->deleteLater(); }); + QNetworkReply* get = m_network.get(request); + auto releaseGet = qScopeGuard([get] { get->deleteLater(); }); - QFile zip_file(zip_path); - if (!zip_file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - co_return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(zip_path)}); + QFile zipFile(zipPath); + if (!zipFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + co_return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(zipPath)}); } { - auto download = loading.task(QStringLiteral("Downloading community rules..."), total_size); - QObject::connect(get, &QNetworkReply::readyRead, get, [get, &zip_file, &loading] { + auto download = loading.task(QStringLiteral("Downloading community rules..."), totalSize); + QObject::connect(get, &QNetworkReply::readyRead, get, [get, &zipFile, &loading] { const QByteArray chunk = get->readAll(); - zip_file.write(chunk); + zipFile.write(chunk); loading.step(static_cast(chunk.size())); }); co_await qCoro(get).waitForFinished(); if (get->error() != QNetworkReply::NoError) { - co_return std::unexpected(network_error(*get)); + co_return std::unexpected(networkError(*get)); } - zip_file.write(get->readAll()); - if (zip_file.error() != QFileDevice::NoError) { - co_return std::unexpected(Error{zip_file.errorString()}); + zipFile.write(get->readAll()); + if (zipFile.error() != QFileDevice::NoError) { + co_return std::unexpected(Error{zipFile.errorString()}); } - zip_file.close(); + zipFile.close(); } { auto extract = loading.task(QStringLiteral("Extracting..."), 100); - if (auto extracted = extract_zip(zip_path, extract_dir); !extracted) { + if (auto extracted = extractZip(zipPath, extractDir); !extracted) { co_return std::unexpected(extracted.error()); } loading.step(50); - QDirIterator rules(extract_dir, {kRulesFilename}, QDir::Files, QDirIterator::Subdirectories); + QDirIterator rules(extractDir, {kRulesFilename}, QDir::Files, QDirIterator::Subdirectories); if (!rules.hasNext()) { // An archive without the rules file is not an error: there is simply nothing to cache. co_return std::optional{}; } - if (auto copied = copy_file_replacing(rules.next(), json_path_); !copied) { + if (auto copied = copyFileReplacing(rules.next(), m_jsonPath); !copied) { co_return std::unexpected(copied.error()); } loading.step(50); } - co_return std::optional(json_path_); + co_return std::optional(m_jsonPath); } -Result CommunityRulesService::load_rules() const { - return load_community_rules(json_path_); +Result CommunityRulesService::loadRules() const { + return loadCommunityRules(m_jsonPath); } } // namespace px::modrim::sort diff --git a/src/core/sort/community_service.hpp b/src/core/sort/community_service.hpp index 81fa592..5e1fef6 100644 --- a/src/core/sort/community_service.hpp +++ b/src/core/sort/community_service.hpp @@ -19,21 +19,21 @@ namespace px::modrim::sort { class CommunityRulesService { public: // `network` is not owned and must outlive the service. - CommunityRulesService(const ConfigService& config_service, QNetworkAccessManager& network); + CommunityRulesService(const ConfigService& configService, QNetworkAccessManager& network); // Returns the rules file path, downloading first when it is missing or `force` is set. // nullopt when the download or extraction failed, or the archive has no rules file; the // failure is logged, not returned. - QCoro::Task> ensure_rules(LoadingState& loading, bool force = false); + QCoro::Task> ensureRules(LoadingState& loading, bool force = false); - Result load_rules() const; + Result loadRules() const; private: - QCoro::Task>> download_rules(LoadingState& loading); + QCoro::Task>> downloadRules(LoadingState& loading); - QString cache_dir_; - QString json_path_; - QNetworkAccessManager& network_; + QString m_cacheDir; + QString m_jsonPath; + QNetworkAccessManager& m_network; }; } // namespace px::modrim::sort diff --git a/src/core/sort/config.hpp b/src/core/sort/config.hpp index 3780e49..1c68f51 100644 --- a/src/core/sort/config.hpp +++ b/src/core/sort/config.hpp @@ -8,9 +8,10 @@ namespace px::modrim::sort { using PackageId = CaseInsensitiveStr; -enum class Tier : int { Tier0 = 0, Tier1 = 1, Tier2 = 2, Tier3 = 3 }; +enum class Tier : int { kTier0 = 0, kTier1 = 1, kTier2 = 2, kTier3 = 3 }; // Serialised by glaze into config.json: member names are the JSON keys. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct TierConfig { std::vector tier_0; std::vector tier_1; @@ -20,7 +21,9 @@ struct TierConfig { bool operator==(const TierConfig&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct SortSettings { bool use_alternative_package_ids = true; bool check_missing_dependencies = true; @@ -29,5 +32,6 @@ struct SortSettings { bool operator==(const SortSettings&) const = default; }; +// NOLINTEND(readability-identifier-naming) } // namespace px::modrim::sort diff --git a/src/core/sort/models.hpp b/src/core/sort/models.hpp index 807232a..699fa9d 100644 --- a/src/core/sort/models.hpp +++ b/src/core/sort/models.hpp @@ -10,12 +10,12 @@ namespace px::modrim::sort { // Load-order rule from the community database for one package id. struct CommunityRule { - PackageId package_id; - CaseInsensitiveSet load_after; - CaseInsensitiveSet load_before; - bool load_first = false; - bool load_last = false; - CaseInsensitiveSet incompatible_with; + PackageId packageId; + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + bool loadFirst = false; + bool loadLast = false; + CaseInsensitiveSet incompatibleWith; bool operator==(const CommunityRule&) const = default; }; diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp index da3e255..d9ce2e4 100644 --- a/src/core/sort/tiers.cpp +++ b/src/core/sort/tiers.cpp @@ -28,7 +28,7 @@ QSet reachable(const PackageId& start, const PackageGraph& graph) { } // Adds `ids` to `tiers` as `tier`, skipping ids that are not canonical (keys of `tiers`). -void assign_present(QHash& tiers, const QSet& ids, Tier tier) { +void assignPresent(QHash& tiers, const QSet& ids, Tier tier) { for (const auto& pid : ids) { if (auto it = tiers.find(pid); it != tiers.end()) { *it = tier; @@ -38,72 +38,72 @@ void assign_present(QHash& tiers, const QSet& ids, T } // namespace -QHash assign_tiers(const QSet& canonical_ids, +QHash assignTiers(const QSet& canonicalIds, const PackageGraph& deps, - const PackageGraph& rev_deps, - const QSet& load_first, - const QSet& load_last, - const TierConfig& tier_config) { + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig) { QHash tiers; - for (const auto& pid : canonical_ids) { - tiers.insert(pid, Tier::Tier2); + for (const auto& pid : canonicalIds) { + tiers.insert(pid, Tier::kTier2); } // Tier 3 is the bottom of the load order, so dependents of late mods also sink there. - QSet tier3_set = load_last; - for (const auto& pid : load_last) { - tier3_set |= reachable(pid, rev_deps); + QSet tier3Set = loadLast; + for (const auto& pid : loadLast) { + tier3Set |= reachable(pid, revDeps); } - for (const auto& pid : tier_config.tier_3) { - if (canonical_ids.contains(pid)) { - tier3_set.insert(pid); - tier3_set |= reachable(pid, rev_deps); + for (const auto& pid : tierConfig.tier_3) { + if (canonicalIds.contains(pid)) { + tier3Set.insert(pid); + tier3Set |= reachable(pid, revDeps); } } - assign_present(tiers, tier3_set, Tier::Tier3); + assignPresent(tiers, tier3Set, Tier::kTier3); // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a tier-3 mark. - QSet tier0_set; - for (const auto& pid : tier_config.tier_0) { - if (canonical_ids.contains(pid)) { - tier0_set.insert(pid); - tier0_set |= reachable(pid, deps); + QSet tier0Set; + for (const auto& pid : tierConfig.tier_0) { + if (canonicalIds.contains(pid)) { + tier0Set.insert(pid); + tier0Set |= reachable(pid, deps); } } - assign_present(tiers, tier0_set, Tier::Tier0); + assignPresent(tiers, tier0Set, Tier::kTier0); // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. - QSet tier1_set; - for (const auto& pid : tier_config.tier_1) { - if (canonical_ids.contains(pid)) { - tier1_set.insert(pid); - tier1_set |= reachable(pid, deps); + QSet tier1Set; + for (const auto& pid : tierConfig.tier_1) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); } } - for (const auto& pid : load_first) { - if (canonical_ids.contains(pid)) { - tier1_set.insert(pid); - tier1_set |= reachable(pid, deps); + for (const auto& pid : loadFirst) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); } } - for (const auto& pid : tier1_set) { - if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::Tier2) { - *it = Tier::Tier1; + for (const auto& pid : tier1Set) { + if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::kTier2) { + *it = Tier::kTier1; } } return tiers; } -QSet get_deps_recursive(const PackageId& start, const PackageGraph& deps) { +QSet getDepsRecursive(const PackageId& start, const PackageGraph& deps) { return reachable(start, deps); } -QSet get_reverse_deps_recursive(const PackageId& start, const PackageGraph& rev_deps) { - return reachable(start, rev_deps); +QSet getReverseDepsRecursive(const PackageId& start, const PackageGraph& revDeps) { + return reachable(start, revDeps); } -std::optional> find_cycle(const QSet& nodes, const PackageGraph& deps) { +std::optional> findCycle(const QSet& nodes, const PackageGraph& deps) { // One frame per node on the DFS stack; `next` indexes the node's outgoing edges. struct Frame { PackageId pid; @@ -112,7 +112,7 @@ std::optional> find_cycle(const QSet& nodes, const P }; QSet visited; - QSet rec_stack; + QSet recStack; QHash parent; for (const auto& root : nodes) { @@ -122,12 +122,12 @@ std::optional> find_cycle(const QSet& nodes, const P parent.insert(root, root); visited.insert(root); - rec_stack.insert(root); + recStack.insert(root); std::vector stack{Frame{root, deps.value(root).values()}}; while (!stack.empty()) { Frame& top = stack.back(); if (top.next == top.edges.size()) { - rec_stack.remove(top.pid); + recStack.remove(top.pid); stack.pop_back(); continue; } @@ -140,10 +140,10 @@ std::optional> find_cycle(const QSet& nodes, const P if (!visited.contains(dep)) { parent.insert(dep, pid); visited.insert(dep); - rec_stack.insert(dep); + recStack.insert(dep); // `top` is invalid after this push. stack.push_back(Frame{dep, deps.value(dep).values()}); - } else if (rec_stack.contains(dep)) { + } else if (recStack.contains(dep)) { // Walk parents from the current node back to `dep`, which is on the DFS path. QList cycle{dep}; PackageId current = pid; diff --git a/src/core/sort/tiers.hpp b/src/core/sort/tiers.hpp index 2fb1195..62bfd34 100644 --- a/src/core/sort/tiers.hpp +++ b/src/core/sort/tiers.hpp @@ -12,21 +12,21 @@ namespace px::modrim::sort { // Assigns each canonical id to a Tier from load order and dependency constraints. Ids outside // `canonical_ids` are never returned. -QHash assign_tiers(const QSet& canonical_ids, +QHash assignTiers(const QSet& canonicalIds, const PackageGraph& deps, - const PackageGraph& rev_deps, - const QSet& load_first, - const QSet& load_last, - const TierConfig& tier_config); + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig); // Transitive closure over `deps`, excluding `start` itself unless it is reachable from itself. -QSet get_deps_recursive(const PackageId& start, const PackageGraph& deps); +QSet getDepsRecursive(const PackageId& start, const PackageGraph& deps); // Transitive closure over `rev_deps`, excluding `start` itself unless it is reachable from itself. -QSet get_reverse_deps_recursive(const PackageId& start, const PackageGraph& rev_deps); +QSet getReverseDepsRecursive(const PackageId& start, const PackageGraph& revDeps); // Returns one cycle among `nodes` as a closed path (first == last), or nullopt when the nodes // form a DAG. Edges leaving `nodes` are ignored. -std::optional> find_cycle(const QSet& nodes, const PackageGraph& deps); +std::optional> findCycle(const QSet& nodes, const PackageGraph& deps); } // namespace px::modrim::sort diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp index d1d68b7..a3fae6b 100644 --- a/src/core/support/report.cpp +++ b/src/core/support/report.cpp @@ -31,29 +31,29 @@ namespace px::modrim::support { namespace { -constexpr qint64 MIB = 1024 * 1024; -constexpr qint64 KIB = 1024; -constexpr qint64 MIN_READ_WINDOW = 10 * MIB; +constexpr qint64 kMib = 1024 * 1024; +constexpr qint64 kKib = 1024; +constexpr qint64 kMinReadWindow = 10 * kMib; // Upload responses are shown back to the user, so only a short prefix is echoed. -constexpr qsizetype MAX_ECHOED_BODY = 100; +constexpr qsizetype kMaxEchoedBody = 100; // A path component may not run into adjacent name characters, so "ludeon.rimworld" is never // mistaken for a path that contains the username "rimworld". -constexpr QLatin1StringView COMPONENT_START{R"re((?= MIB) { - return QString::number(static_cast(bytes) / static_cast(MIB), 'f', 1) + +QString formatSize(qint64 bytes) { + if (bytes >= kMib) { + return QString::number(static_cast(bytes) / static_cast(kMib), 'f', 1) + QStringLiteral(" MiB"); } - if (bytes >= KIB) { - return QString::number(static_cast(bytes) / static_cast(KIB), 'f', 1) + + if (bytes >= kKib) { + return QString::number(static_cast(bytes) / static_cast(kKib), 'f', 1) + QStringLiteral(" KiB"); } return QString::number(bytes) + QStringLiteral(" B"); @@ -81,7 +81,7 @@ QString format_size(qint64 bytes) { // Splits the way Python's str.splitlines() does: several characters end a line, and a trailing // terminator does not start an empty line. -QStringList split_lines(const QString& text) { +QStringList splitLines(const QString& text) { QStringList lines; qsizetype start = 0; for (qsizetype i = 0; i < text.size(); ++i) { @@ -106,7 +106,7 @@ QStringList split_lines(const QString& text) { // loguru writes each record as a JSON object whose "text" holds the formatted line. Lines // that are not such records pass through unchanged. -QString log_line_text(const QString& line) { +QString logLineText(const QString& line) { const QString trimmed = line.trimmed(); if (!trimmed.startsWith(u'{') || !trimmed.endsWith(u'}')) { return line; @@ -127,25 +127,25 @@ QString log_line_text(const QString& line) { return result; } -QString parse_log_text(const QString& raw_text) { - QStringList lines = split_lines(raw_text); +QString parseLogText(const QString& rawText) { + QStringList lines = splitLines(rawText); for (QString& line : lines) { - line = log_line_text(line); + line = logLineText(line); } QString result = lines.join(QLatin1Char('\n')); - if (raw_text.endsWith(u'\n') && !result.isEmpty()) { + if (rawText.endsWith(u'\n') && !result.isEmpty()) { result += u'\n'; } return result; } // Drops the partial line at the start of a tail slice. -QByteArray after_first_newline(const QByteArray& data) { +QByteArray afterFirstNewline(const QByteArray& data) { const qsizetype newline = data.indexOf('\n'); return newline < 0 ? data : data.mid(newline + 1); } -Result read_raw_tail(QFile& file, qint64 size, qint64 window) { +Result readRawTail(QFile& file, qint64 size, qint64 window) { if (!file.open(QIODevice::ReadOnly)) { return std::unexpected(Error{file.errorString()}); } @@ -153,11 +153,11 @@ Result read_raw_tail(QFile& file, qint64 size, qint64 window) { return file.readAll(); } file.seek(size - window); - return after_first_newline(file.read(window)); + return afterFirstNewline(file.read(window)); } // Python's repr() of a string, for quoting part of a server response in an error message. -QString py_repr(const QString& text) { +QString pyRepr(const QString& text) { const QChar quote = text.contains(u'\'') && !text.contains(u'"') ? QChar(u'"') : QChar(u'\''); QString out(quote); for (const QChar c : text) { @@ -187,11 +187,11 @@ QString py_repr(const QString& text) { } // namespace -QString redact(const QString& text, const std::optional& home_dir, +QString redact(const QString& text, const std::optional& homeDir, const std::optional& username) { QString result = text; - QString home = home_dir ? *home_dir : QDir::homePath(); + QString home = homeDir ? *homeDir : QDir::homePath(); while (home.endsWith(u'/') || home.endsWith(u'\\')) { home.chop(1); } @@ -210,27 +210,27 @@ QString redact(const QString& text, const std::optional& home_dir, for (const QString& variant : ordered) { alternatives.append(QRegularExpression::escape(variant)); } - const QRegularExpression home_pattern = unicode_regex( - QString(COMPONENT_START) + QStringLiteral("(?:") + alternatives.join(u'|') + - QStringLiteral(")") + QString(COMPONENT_END)); - result.replace(home_pattern, QStringLiteral("~")); + const QRegularExpression homePattern = unicodeRegex( + QString(kComponentStart) + QStringLiteral("(?:") + alternatives.join(u'|') + + QStringLiteral(")") + QString(kComponentEnd)); + result.replace(homePattern, QStringLiteral("~")); } // Only in path contexts: a bare word match would corrupt ids such as ludeon.rimworld. - const QString user = username ? *username : current_username(); + const QString user = username ? *username : currentUsername(); if (!user.isEmpty()) { - const QRegularExpression user_pattern = unicode_regex( - QStringLiteral("(") + QString(USER_DIR_PREFIX) + QStringLiteral(")") + - QRegularExpression::escape(user) + QString(COMPONENT_END), + const QRegularExpression userPattern = unicodeRegex( + QStringLiteral("(") + QString(kUserDirPrefix) + QStringLiteral(")") + + QRegularExpression::escape(user) + QString(kComponentEnd), QRegularExpression::CaseInsensitiveOption); - result.replace(user_pattern, QStringLiteral("\\1")); + result.replace(userPattern, QStringLiteral("\\1")); } return result; } -QString read_log_tail(const std::optional& log_path, qint64 max_bytes) { - const QString path = log_path ? *log_path - : QDir(config_dir()).filePath(QStringLiteral("logs/pxmodrim.log")); +QString readLogTail(const std::optional& logPath, qint64 maxBytes) { + const QString path = logPath ? *logPath + : QDir(configDir()).filePath(QStringLiteral("logs/pxmodrim.log")); const QFileInfo info(path); if (!info.isFile()) { return QStringLiteral(""); @@ -241,28 +241,28 @@ QString read_log_tail(const std::optional& log_path, qint64 max_bytes) } // JSON records shrink once unpacked, so read a wider raw window than the byte budget. - const qint64 read_window = std::max(max_bytes * 5, MIN_READ_WINDOW); + const qint64 readWindow = std::max(maxBytes * 5, kMinReadWindow); QFile file(path); - auto raw = read_raw_tail(file, size, read_window); + auto raw = readRawTail(file, size, readWindow); if (!raw) { return QStringLiteral("").arg(raw.error().message); } - const QString plain = parse_log_text(QString::fromUtf8(*raw)); + const QString plain = parseLogText(QString::fromUtf8(*raw)); const QByteArray encoded = plain.toUtf8(); - if (encoded.size() <= max_bytes) { + if (encoded.size() <= maxBytes) { return plain; } - const QByteArray tail = after_first_newline(encoded.right(max_bytes)); + const QByteArray tail = afterFirstNewline(encoded.right(maxBytes)); return QStringLiteral("[... truncated, showing last %1 of %2 ...]\n") - .arg(format_size(tail.size()), format_size(encoded.size())) + + .arg(formatSize(tail.size()), formatSize(encoded.size())) + QString::fromUtf8(tail); } -QString build_report(const ReportSource& source, const std::optional& log_path, - const std::optional& home_dir, const std::optional& username, - qint64 max_log_bytes) { +QString buildReport(const ReportSource& source, const std::optional& logPath, + const std::optional& homeDir, const std::optional& username, + qint64 maxLogBytes) { QStringList lines; lines << QStringLiteral("=== PxModRim System Report ==="); @@ -275,67 +275,67 @@ QString build_report(const ReportSource& source, const std::optional& l const PathConfig& paths = source.paths; lines << QStringLiteral("=== RimWorld Configuration ==="); - lines << QStringLiteral("Game Version: ") + source.game_version; - const auto or_none = [](const std::string& value) { + lines << QStringLiteral("Game Version: ") + source.gameVersion; + const auto orNone = [](const std::string& value) { return value.empty() ? QStringLiteral("") : QString::fromStdString(value); }; - lines << QStringLiteral("Game Path: ") + or_none(paths.game); - lines << QStringLiteral("Config Folder: ") + or_none(paths.config_folder); - lines << QStringLiteral("Local Mods Path: ") + or_none(paths.local); - lines << QStringLiteral("Workshop Mods Path: ") + or_none(paths.workshop); + lines << QStringLiteral("Game Path: ") + orNone(paths.game); + lines << QStringLiteral("Config Folder: ") + orNone(paths.config_folder); + lines << QStringLiteral("Local Mods Path: ") + orNone(paths.local); + lines << QStringLiteral("Workshop Mods Path: ") + orNone(paths.workshop); lines << QString(); lines << QStringLiteral("=== Mod Collection ==="); - lines << QStringLiteral("Total Mods: %1").arg(source.all_mods.size()); - lines << QStringLiteral("Active Mods: %1").arg(source.active_uuids.size()); + lines << QStringLiteral("Total Mods: %1").arg(source.allMods.size()); + lines << QStringLiteral("Active Mods: %1").arg(source.activeUuids.size()); lines << QString(); lines << QStringLiteral("=== Active Mods (Load Order) ==="); - if (source.active_uuids.isEmpty()) { + if (source.activeUuids.isEmpty()) { lines << QStringLiteral(" "); } - for (qsizetype i = 0; i < source.active_uuids.size(); ++i) { - const QString& uuid = source.active_uuids[i]; - QString mod_name = QStringLiteral("Unknown Mod"); + for (qsizetype i = 0; i < source.activeUuids.size(); ++i) { + const QString& uuid = source.activeUuids[i]; + QString modName = QStringLiteral("Unknown Mod"); QString pid = uuid; - if (const auto found = source.all_mods.constFind(uuid); found != source.all_mods.cend()) { - mod_name = (*found)->name; + if (const auto found = source.allMods.constFind(uuid); found != source.allMods.cend()) { + modName = (*found)->name; const auto* about = dynamic_cast(found->get()); - pid = about ? about->package_id.str() : QStringLiteral("Unknown"); + pid = about ? about->packageId.str() : QStringLiteral("Unknown"); } lines << QStringLiteral("%1. %2 (%3)") - .arg(QString::number(i + 1).rightJustified(3), mod_name, pid); + .arg(QString::number(i + 1).rightJustified(3), modName, pid); } lines << QString(); lines << QStringLiteral("=== Application Log ==="); - lines << read_log_tail(log_path, max_log_bytes); + lines << readLogTail(logPath, maxLogBytes); - return redact(lines.join(QLatin1Char('\n')), home_dir, username); + return redact(lines.join(QLatin1Char('\n')), homeDir, username); } PasteUploader::PasteUploader(QNetworkAccessManager& network, QString endpoint, std::chrono::milliseconds timeout) - : network_(network), endpoint_(std::move(endpoint)), timeout_(timeout) {} + : m_network(network), m_endpoint(std::move(endpoint)), m_timeout(timeout) {} QCoro::Task> PasteUploader::upload(QString text) { if (text.trimmed().isEmpty()) { co_return std::unexpected(Error{QStringLiteral("Report text is empty")}); } - QNetworkRequest request{QUrl(endpoint_)}; + QNetworkRequest request{QUrl(m_endpoint)}; request.setRawHeader("User-Agent", (QStringLiteral("PxModRim/") + appVersion()).toUtf8()); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/plain; charset=utf-8")); - request.setTransferTimeout(static_cast(timeout_.count())); + request.setTransferTimeout(static_cast(m_timeout.count())); - QNetworkReply* reply = network_.post(request, text.toUtf8()); - const auto release_reply = qScopeGuard([reply] { reply->deleteLater(); }); + QNetworkReply* reply = m_network.post(request, text.toUtf8()); + const auto releaseReply = qScopeGuard([reply] { reply->deleteLater(); }); co_await qCoro(reply).waitForFinished(); if (reply->error() == QNetworkReply::TimeoutError) { co_return std::unexpected(Error{QStringLiteral("Upload failed: request timed out after %1s") - .arg(QString::number(timeout_.count() / 1000.0))}); + .arg(QString::number(m_timeout.count() / 1000.0))}); } // No HTTP status means the request never got a response. const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); @@ -360,7 +360,7 @@ QCoro::Task> PasteUploader::upload(QString text) { !body.startsWith(QLatin1StringView("https://"))) { co_return std::unexpected(Error{ QStringLiteral("Upload failed: server did not return a valid URL: %1") - .arg(py_repr(body.left(MAX_ECHOED_BODY)))}); + .arg(pyRepr(body.left(kMaxEchoedBody)))}); } co_return body; } diff --git a/src/core/support/report.hpp b/src/core/support/report.hpp index 74f137c..737d2c2 100644 --- a/src/core/support/report.hpp +++ b/src/core/support/report.hpp @@ -17,50 +17,50 @@ class QNetworkAccessManager; namespace px::modrim::support { -inline constexpr qint64 MAX_LOG_BYTES = 2 * 1024 * 1024; -inline constexpr QLatin1StringView DEFAULT_UPLOAD_ENDPOINT{"https://paste.rs/"}; +inline constexpr qint64 kMaxLogBytes = 2 * 1024 * 1024; +inline constexpr QLatin1StringView kDefaultUploadEndpoint{"https://paste.rs/"}; // The app state a diagnostic report describes. Copied out of the live application so that // building a report never touches it. struct ReportSource { - QString game_version = QStringLiteral("Unknown"); + QString gameVersion = QStringLiteral("Unknown"); PathConfig paths; // Keyed by uuid, as the mod registry is. - QHash all_mods; - QStringList active_uuids; + QHash allMods; + QStringList activeUuids; }; // Replaces the home directory with "~" and user-directory usernames with "". The home // directory and username default to the current user's when not given. -QString redact(const QString& text, const std::optional& home_dir = std::nullopt, +QString redact(const QString& text, const std::optional& homeDir = std::nullopt, const std::optional& username = std::nullopt); // The last `max_bytes` of the log as plain text, with loguru JSON records unpacked. Defaults to // the app's own log under the config directory. -QString read_log_tail(const std::optional& log_path = std::nullopt, - qint64 max_bytes = MAX_LOG_BYTES); +QString readLogTail(const std::optional& logPath = std::nullopt, + qint64 maxBytes = kMaxLogBytes); // Plain-text system and log report, with the user's details redacted. -QString build_report(const ReportSource& source, - const std::optional& log_path = std::nullopt, - const std::optional& home_dir = std::nullopt, +QString buildReport(const ReportSource& source, + const std::optional& logPath = std::nullopt, + const std::optional& homeDir = std::nullopt, const std::optional& username = std::nullopt, - qint64 max_log_bytes = MAX_LOG_BYTES); + qint64 maxLogBytes = kMaxLogBytes); // Posts plain-text reports to a paste service. Not owned; `network` must outlive the uploader. class PasteUploader { public: PasteUploader(QNetworkAccessManager& network, - QString endpoint = QString(DEFAULT_UPLOAD_ENDPOINT), + QString endpoint = QString(kDefaultUploadEndpoint), std::chrono::milliseconds timeout = std::chrono::seconds(30)); // Resolves to the URL the service returned, or to a user-facing error message. QCoro::Task> upload(QString text); private: - QNetworkAccessManager& network_; - QString endpoint_; - std::chrono::milliseconds timeout_; + QNetworkAccessManager& m_network; + QString m_endpoint; + std::chrono::milliseconds m_timeout; }; } // namespace px::modrim::support diff --git a/src/core/toposort.hpp b/src/core/toposort.hpp index fd91710..d18ca0d 100644 --- a/src/core/toposort.hpp +++ b/src/core/toposort.hpp @@ -18,7 +18,7 @@ namespace px::modrim { // dependencies all sit in earlier levels. Names within a level are ascending, // so the result is deterministic. Self-dependencies and dependencies that are // not keys of `graph` are ignored. -inline Result> toposort_levels( +inline Result> toposortLevels( const std::map>& graph) { std::map> pending; for (const auto& [node, deps] : graph) { diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index dc2cdb6..82bb5d7 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -20,7 +20,7 @@ namespace { constexpr qsizetype kCacheLimit = 200; constexpr qsizetype kDiskLimit = 300; -QStringList to_qstrings(const std::vector& values) { +QStringList toQstrings(const std::vector& values) { QStringList out; out.reserve(static_cast(values.size())); for (const std::string& value : values) { @@ -29,7 +29,7 @@ QStringList to_qstrings(const std::vector& values) { return out; } -const std::string& title_of(const CatalogEntry& entry) { +const std::string& titleOf(const CatalogEntry& entry) { return std::visit([](const auto& item) -> const std::string& { return item.title; }, entry); } @@ -37,8 +37,8 @@ const std::string& title_of(const CatalogEntry& entry) { namespace detail { -Result write_cached(const QString& directory, const QString& path, const std::string& text) { - if (auto saved = write_file_atomically(path, text); !saved) { +Result writeCached(const QString& directory, const QString& path, const std::string& text) { + if (auto saved = writeFileAtomically(path, text); !saved) { return saved; } QFileInfoList files = QDir(directory).entryInfoList({QStringLiteral("*.json")}, QDir::Files); @@ -53,119 +53,119 @@ Result write_cached(const QString& directory, const QString& path, const s WorkshopCatalog::WorkshopCatalog() : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), - settings_(nullptr, QStringLiteral("workshop_catalog")), - base_url_(normalize_base_url(QString::fromStdString(settings_.value().url))), favourites_(nullptr) { - settings_.changed.connect([this](const CatalogSettings& value) { change_url(QString::fromStdString(value.url)); }); + m_settings(nullptr, QStringLiteral("workshop_catalog")), + m_baseUrl(normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), m_favourites(nullptr) { + m_settings.changed.connect([this](const CatalogSettings& value) { changeUrl(QString::fromStdString(value.url)); }); } void WorkshopCatalog::setup(CoreContext& ctx) { - context_ = &ctx; - installed_mods_ = [&ctx] { return ctx.allMods().values(); }; + m_context = &ctx; + m_installedMods = [&ctx] { return ctx.allMods().values(); }; if (ctx.hasConfigService()) { - settings_ = PluginConfig(&ctx.configService(), QStringLiteral("workshop_catalog"), + m_settings = PluginConfig(&ctx.configService(), QStringLiteral("workshop_catalog"), {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}); - settings_.changed.connect([this](const CatalogSettings& value) { change_url(QString::fromStdString(value.url)); }); - change_url(QString::fromStdString(settings_.value().url)); - cache_dir_ = ctx.configService().directory() + QStringLiteral("/workshop-cache"); - favourites_ = FavouriteCollections(&ctx.configService()); + m_settings.changed.connect([this](const CatalogSettings& value) { changeUrl(QString::fromStdString(value.url)); }); + changeUrl(QString::fromStdString(m_settings.value().url)); + m_cacheDir = ctx.configService().directory() + QStringLiteral("/workshop-cache"); + m_favourites = FavouriteCollections(&ctx.configService()); } - bind_downloads(ctx.downloads()); - mods_changed_id_ = ctx.modService().mods_changed.connect([this](const std::monostate&) { invalidate_installed(); }); - config_changed_id_ = ctx.configChanged().connect([this](const AppConfig&) { invalidate_installed(); }); + bindDownloads(ctx.downloads()); + m_modsChangedId = ctx.modService().modsChanged.connect([this](const std::monostate&) { invalidateInstalled(); }); + m_configChangedId = ctx.configChanged().connect([this](const AppConfig&) { invalidateInstalled(); }); } WorkshopCatalog::WorkshopCatalog(ConfigService* config, DownloadManager& downloads, - InstalledModsProvider installed_mods) + InstalledModsProvider installedMods) : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), - settings_(config, QStringLiteral("workshop_catalog"), + m_settings(config, QStringLiteral("workshop_catalog"), {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}), - base_url_(normalize_base_url(QString::fromStdString(settings_.value().url))), - favourites_(config), - downloads_(&downloads), - installed_mods_(std::move(installed_mods)) { + m_baseUrl(normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), + m_favourites(config), + m_downloads(&downloads), + m_installedMods(std::move(installedMods)) { if (config) { - cache_dir_ = config->directory() + QStringLiteral("/workshop-cache"); + m_cacheDir = config->directory() + QStringLiteral("/workshop-cache"); } - settings_.changed.connect( - [this](const CatalogSettings& settings) { change_url(QString::fromStdString(settings.url)); }); - bind_downloads(downloads); + m_settings.changed.connect( + [this](const CatalogSettings& settings) { changeUrl(QString::fromStdString(settings.url)); }); + bindDownloads(downloads); } -void WorkshopCatalog::bind_downloads(DownloadManager& downloads) { - downloads_ = &downloads; - download_finished_id_ = downloads_->download_finished.connect([this](const DownloadResult&) { invalidate_installed(); }); - busy_changed_id_ = downloads_->busy_changed.connect([this](bool busy) { on_busy_changed(busy); }); +void WorkshopCatalog::bindDownloads(DownloadManager& downloads) { + m_downloads = &downloads; + m_downloadFinishedId = m_downloads->downloadFinished.connect([this](const DownloadResult&) { invalidateInstalled(); }); + m_busyChangedId = m_downloads->busyChanged.connect([this](bool busy) { onBusyChanged(busy); }); } -void WorkshopCatalog::disconnect_events() { - if (downloads_) { - downloads_->download_finished.disconnect(download_finished_id_); - downloads_->busy_changed.disconnect(busy_changed_id_); - download_finished_id_ = 0; - busy_changed_id_ = 0; +void WorkshopCatalog::disconnectEvents() { + if (m_downloads) { + m_downloads->downloadFinished.disconnect(m_downloadFinishedId); + m_downloads->busyChanged.disconnect(m_busyChangedId); + m_downloadFinishedId = 0; + m_busyChangedId = 0; } - if (context_) { - context_->modService().mods_changed.disconnect(mods_changed_id_); - context_->configChanged().disconnect(config_changed_id_); - context_ = nullptr; + if (m_context) { + m_context->modService().modsChanged.disconnect(m_modsChangedId); + m_context->configChanged().disconnect(m_configChangedId); + m_context = nullptr; } } WorkshopCatalog::~WorkshopCatalog() { - disconnect_events(); + disconnectEvents(); // Destroying the queue's coroutine must not leave the downloads holding its handle. - idle_waiters_.clear(); - runner_.reset(); + m_idleWaiters.clear(); + m_runner.reset(); } QCoro::Task WorkshopCatalog::shutdown() { - disconnect_events(); - cancel_queue(); - idle_waiters_.clear(); - runner_.reset(); - draining_ = false; - for (const auto& client : clients_) { + disconnectEvents(); + cancelQueue(); + m_idleWaiters.clear(); + m_runner.reset(); + m_draining = false; + for (const auto& client : m_clients) { client->shutdown(); } co_return; } -void WorkshopCatalog::set_base_url(const QString& url) { - settings_.update(CatalogSettings{.url = normalize_base_url(url).toStdString()}); +void WorkshopCatalog::setBaseUrl(const QString& url) { + m_settings.update(CatalogSettings{.url = normalizeBaseUrl(url).toStdString()}); } -void WorkshopCatalog::change_url(const QString& url) { - const QString normalized = normalize_base_url(url); - if (normalized == base_url_) { +void WorkshopCatalog::changeUrl(const QString& url) { + const QString normalized = normalizeBaseUrl(url); + if (normalized == m_baseUrl) { return; } - base_url_ = normalized; - client_ = nullptr; - catalog_url_changed.notify(base_url_); + m_baseUrl = normalized; + m_client = nullptr; + catalogUrlChanged.notify(m_baseUrl); } CatalogClient& WorkshopCatalog::client() { - if (!client_) { - clients_.push_back(std::make_unique(base_url_)); - client_ = clients_.back().get(); + if (!m_client) { + m_clients.push_back(std::make_unique(m_baseUrl)); + m_client = m_clients.back().get(); } - return *client_; + return *m_client; } -QString WorkshopCatalog::cache_file(const QString& key) const { +QString WorkshopCatalog::cacheFile(const QString& key) const { const QByteArray digest = QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256).toHex(); - return *cache_dir_ + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + QStringLiteral(".json"); + return *m_cacheDir + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + QStringLiteral(".json"); } -void WorkshopCatalog::remember(const QString& key, std::any value, bool move_to_end) { - if (move_to_end || !responses_.contains(key)) { - response_order_.removeAll(key); - response_order_.append(key); +void WorkshopCatalog::remember(const QString& key, std::any value, bool moveToEnd) { + if (moveToEnd || !m_responses.contains(key)) { + m_responseOrder.removeAll(key); + m_responseOrder.append(key); } - responses_.insert(key, std::move(value)); - while (response_order_.size() > kCacheLimit) { - responses_.remove(response_order_.first()); - response_order_.removeFirst(); + m_responses.insert(key, std::move(value)); + while (m_responseOrder.size() > kCacheLimit) { + m_responses.remove(m_responseOrder.first()); + m_responseOrder.removeFirst(); } } @@ -190,58 +190,58 @@ QCoro::Task>> WorkshopCatalog::col } const WorkshopCatalog::InstalledIndex& WorkshopCatalog::installed() { - if (!installed_index_) { + if (!m_installedIndex) { InstalledIndex index; - for (const ListedModPtr& mod : installed_mods_()) { - if (auto id = mod->published_file_id()) { + for (const ListedModPtr& mod : m_installedMods()) { + if (auto id = mod->publishedFileId()) { index[*id].append(mod); } } - installed_index_ = std::move(index); + m_installedIndex = std::move(index); } - return *installed_index_; + return *m_installedIndex; } -InstallState WorkshopCatalog::install_state_of(const CatalogMod& mod, const InstalledIndex& index) const { +InstallState WorkshopCatalog::installStateOf(const CatalogMod& mod, const InstalledIndex& index) const { const QList local = index.value(QString::fromStdString(mod.id)); if (local.isEmpty()) { - return InstallState::missing; + return InstallState::kMissing; } // Only copies downloaded through the manager have a sync time; a copy without one is never outdated. - std::optional newest_sync; + std::optional newestSync; for (const ListedModPtr& item : local) { - if (auto synced = downloads_->last_synced(*item)) { - newest_sync = newest_sync ? std::max(*newest_sync, *synced) : *synced; + if (auto synced = m_downloads->lastSynced(*item)) { + newestSync = newestSync ? std::max(*newestSync, *synced) : *synced; } } - if (newest_sync && mod.updated_at && *newest_sync < static_cast(*mod.updated_at)) { - return InstallState::outdated; + if (newestSync && mod.updated_at && *newestSync < static_cast(*mod.updated_at)) { + return InstallState::kOutdated; } - return InstallState::installed; + return InstallState::kInstalled; } -InstallState WorkshopCatalog::install_state(const CatalogMod& mod) { - return install_state_of(mod, installed()); +InstallState WorkshopCatalog::installState(const CatalogMod& mod) { + return installStateOf(mod, installed()); } -void WorkshopCatalog::invalidate_installed() { - installed_index_.reset(); - installed_changed.notify(std::monostate{}); +void WorkshopCatalog::invalidateInstalled() { + m_installedIndex.reset(); + installedChanged.notify(std::monostate{}); } -QCoro::Task>> WorkshopCatalog::installed_with_updates() { +QCoro::Task>> WorkshopCatalog::installedWithUpdates() { QStringList ids = installed().keys(); // The index is a hash, so sort the ids to request them in a stable order. ids.sort(); - auto batch = co_await client().mods_batch(std::move(ids)); + auto batch = co_await client().modsBatch(std::move(ids)); if (!batch) { co_return std::unexpected(batch.error()); } co_return std::move(batch->items); } -QCoro::Task> WorkshopCatalog::plan(QStringList mod_ids, QStringList collection_ids) { - auto resolved = co_await client().resolve(std::move(mod_ids), std::move(collection_ids)); +QCoro::Task> WorkshopCatalog::plan(QStringList modIds, QStringList collectionIds) { + auto resolved = co_await client().resolve(std::move(modIds), std::move(collectionIds)); if (!resolved) { co_return std::unexpected(resolved.error()); } @@ -255,84 +255,84 @@ QCoro::Task> WorkshopCatalog::plan(QStringList mod_i QStringLiteral("The Workshop catalog returned an invalid download plan."), std::nullopt}); } const QString qid = QString::fromStdString(id); - if (install_state_of(*mod, index) == InstallState::installed) { + if (installStateOf(*mod, index) == InstallState::kInstalled) { result.already_current.append(qid); } else { result.to_download.append(qid); } } for (const auto& [id, entry] : resolved->items) { - result.titles.insert(QString::fromStdString(id), QString::fromStdString(title_of(entry))); + result.titles.insert(QString::fromStdString(id), QString::fromStdString(titleOf(entry))); } - result.unavailable_ids = to_qstrings(resolved->unavailable_ids); - result.incomplete_collection_ids = to_qstrings(resolved->incomplete_collection_ids); + result.unavailable_ids = toQstrings(resolved->unavailable_ids); + result.incomplete_collection_ids = toQstrings(resolved->incomplete_collection_ids); result.is_complete = resolved->is_complete; co_return result; } -QCoro::Task> WorkshopCatalog::download(DownloadPlan download_plan) { - co_return co_await downloads_->download_mods(std::move(download_plan.to_download)); +QCoro::Task> WorkshopCatalog::download(DownloadPlan downloadPlan) { + co_return co_await m_downloads->downloadMods(std::move(downloadPlan.to_download)); } -void WorkshopCatalog::enqueue(const DownloadPlan& download_plan) { - const QSet active = downloads_->active_ids(); +void WorkshopCatalog::enqueue(const DownloadPlan& downloadPlan) { + const QSet active = m_downloads->activeIds(); QStringList ids; - for (const QString& id : download_plan.to_download) { - if (!queued_.contains(id) && !active.contains(id)) { + for (const QString& id : downloadPlan.to_download) { + if (!m_queued.contains(id) && !active.contains(id)) { ids.append(id); } } if (ids.isEmpty()) { return; } - queue_.append(ids); - queued_.unite(QSet(ids.begin(), ids.end())); - queue_changed.notify(std::monostate{}); - if (!draining_) { - draining_ = true; - runner_.emplace(drain()); + m_queue.append(ids); + m_queued.unite(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (!m_draining) { + m_draining = true; + m_runner.emplace(drain()); } } -void WorkshopCatalog::cancel_queue() { - queue_.clear(); - queued_.clear(); - queue_changed.notify(std::monostate{}); +void WorkshopCatalog::cancelQueue() { + m_queue.clear(); + m_queued.clear(); + queueChanged.notify(std::monostate{}); } QCoro::Task WorkshopCatalog::drain() { - while (!queue_.isEmpty()) { - const QStringList ids = queue_.takeFirst(); + while (!m_queue.isEmpty()) { + const QStringList ids = m_queue.takeFirst(); // A batch that waited for the running download stops the queue if that download was cancelled. - const bool waited = downloads_->is_downloading(); - while (downloads_->is_downloading()) { + const bool waited = m_downloads->isDownloading(); + while (m_downloads->isDownloading()) { co_await IdleWait{*this}; } - if (waited && downloads_->cancelled()) { - cancel_queue(); + if (waited && m_downloads->cancelled()) { + cancelQueue(); break; } - auto result = co_await downloads_->download_mods(ids); + auto result = co_await m_downloads->downloadMods(ids); if (!result) { spdlog::warn("[workshop] queued download failed: {}", result.error().message.toStdString()); } - queued_.subtract(QSet(ids.begin(), ids.end())); - queue_changed.notify(std::monostate{}); - if (downloads_->cancelled()) { - cancel_queue(); + m_queued.subtract(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (m_downloads->cancelled()) { + cancelQueue(); break; } } - draining_ = false; + m_draining = false; } -void WorkshopCatalog::on_busy_changed(bool busy) { +void WorkshopCatalog::onBusyChanged(bool busy) { if (busy) { return; } - auto waiters = std::move(idle_waiters_); - idle_waiters_.clear(); + auto waiters = std::move(m_idleWaiters); + m_idleWaiters.clear(); for (std::coroutine_handle<> waiter : waiters) { waiter.resume(); } diff --git a/src/core/workshop/catalog.hpp b/src/core/workshop/catalog.hpp index 0346d2a..8ffab66 100644 --- a/src/core/workshop/catalog.hpp +++ b/src/core/workshop/catalog.hpp @@ -42,29 +42,33 @@ inline constexpr std::string_view kDefaultCatalogUrl = "https://api.modrim.pyxii inline constexpr double kFreshSeconds = 300.0; // Stored as plugins/workshop_catalog.json. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CatalogSettings { std::string url = std::string(kDefaultCatalogUrl); bool operator==(const CatalogSettings&) const = default; }; +// NOLINTEND(readability-identifier-naming) // The installed mods the catalog compares against. The composition root supplies it, so the catalog // does not reach for global state. using InstalledModsProvider = std::function()>; // A cached value with the time it was fetched (seconds since the epoch). +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire template struct Remembered { double at = 0; T value{}; }; +// NOLINTEND(readability-identifier-naming) namespace detail { // A missing, unreadable or undecodable cache file is treated as a miss. template -std::optional> read_cached(const QString& path) { - auto bytes = read_file_bytes(path); +std::optional> readCached(const QString& path) { + auto bytes = readFileBytes(path); if (!bytes) { return std::nullopt; } @@ -76,7 +80,7 @@ std::optional> read_cached(const QString& path) { } // Writes one cache file, then drops the oldest files beyond the disk limit. -Result write_cached(const QString& directory, const QString& path, const std::string& text); +Result writeCached(const QString& directory, const QString& path, const std::string& text); } // namespace detail @@ -87,7 +91,7 @@ class WorkshopCatalog : public Plugin { WorkshopCatalog(); void setup(CoreContext& ctx) override; // `config` may be null: settings, favourites and the response cache then stay in memory. - WorkshopCatalog(ConfigService* config, DownloadManager& downloads, InstalledModsProvider installed_mods); + WorkshopCatalog(ConfigService* config, DownloadManager& downloads, InstalledModsProvider installedMods); ~WorkshopCatalog(); WorkshopCatalog(const WorkshopCatalog&) = delete; @@ -96,15 +100,15 @@ class WorkshopCatalog : public Plugin { // Stops the queue and aborts requests in flight. QCoro::Task shutdown() override; - Event catalog_url_changed; - Event installed_changed; - Event queue_changed; + Event catalogUrlChanged; + Event installedChanged; + Event queueChanged; - const CatalogSettings& settings() const noexcept { return settings_.value(); } - void set_base_url(const QString& url); - bool configured() const noexcept { return !base_url_.isEmpty(); } + const CatalogSettings& settings() const noexcept { return m_settings.value(); } + void setBaseUrl(const QString& url); + bool configured() const noexcept { return !m_baseUrl.isEmpty(); } - FavouriteCollections& favourites() noexcept { return favourites_; } + FavouriteCollections& favourites() noexcept { return m_favourites; } QCoro::Task> discover(); QCoro::Task>> mods(CatalogQuery query); @@ -117,25 +121,25 @@ class WorkshopCatalog : public Plugin { // value also survives a restart. `T` must be serialisable with glaze and comparable. template QCoro::Task> cached(QString key, std::function>()> produce, - std::function show, double fresh_for = kFreshSeconds, + std::function show, double freshFor = kFreshSeconds, bool persist = false); // Installed state is cached until invalidate_installed(). - InstallState install_state(const CatalogMod& mod); - QCoro::Task>> installed_with_updates(); + InstallState installState(const CatalogMod& mod); + QCoro::Task>> installedWithUpdates(); // Resolves ids into the mods to fetch, skipping those already current. - QCoro::Task> plan(QStringList mod_ids, QStringList collection_ids); - QCoro::Task> download(DownloadPlan download_plan); + QCoro::Task> plan(QStringList modIds, QStringList collectionIds); + QCoro::Task> download(DownloadPlan downloadPlan); // Ids waiting in the queue or downloading. - QSet queued_ids() const { return queued_; } + QSet queuedIds() const { return m_queued; } // Queues `plan` behind any running download without waiting for it. - void enqueue(const DownloadPlan& download_plan); - void cancel_queue(); + void enqueue(const DownloadPlan& downloadPlan); + void cancelQueue(); // Call when the installed mods changed (mods changed, config changed). - void invalidate_installed(); + void invalidateInstalled(); private: using InstalledIndex = QHash>; @@ -144,59 +148,61 @@ class WorkshopCatalog : public Plugin { struct IdleWait { WorkshopCatalog& catalog; - bool await_ready() const noexcept { return false; } - void await_suspend(std::coroutine_handle<> handle) const { catalog.idle_waiters_.push_back(handle); } + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) const { catalog.m_idleWaiters.push_back(handle); } void await_resume() const noexcept {} + // NOLINTEND(readability-identifier-naming) }; CatalogClient& client(); const InstalledIndex& installed(); - void bind_downloads(DownloadManager& downloads); - void disconnect_events(); - InstallState install_state_of(const CatalogMod& mod, const InstalledIndex& installed) const; - void change_url(const QString& url); - void on_busy_changed(bool busy); + void bindDownloads(DownloadManager& downloads); + void disconnectEvents(); + InstallState installStateOf(const CatalogMod& mod, const InstalledIndex& installed) const; + void changeUrl(const QString& url); + void onBusyChanged(bool busy); QCoro::Task drain(); template std::optional> recall(const QString& key) const; // Keeps the memory cache within its limit. `move_to_end` marks the entry as the newest. - void remember(const QString& key, std::any value, bool move_to_end); - QString cache_file(const QString& key) const; - - PluginConfig settings_; - QString base_url_; - std::optional cache_dir_; - FavouriteCollections favourites_; - DownloadManager* downloads_ = nullptr; - InstalledModsProvider installed_mods_; - Event::Id download_finished_id_ = 0; - Event::Id busy_changed_id_ = 0; - CoreContext* context_ = nullptr; - Event::Id mods_changed_id_ = 0; - Event::Id config_changed_id_ = 0; - - std::optional installed_index_; + void remember(const QString& key, std::any value, bool moveToEnd); + QString cacheFile(const QString& key) const; + + PluginConfig m_settings; + QString m_baseUrl; + std::optional m_cacheDir; + FavouriteCollections m_favourites; + DownloadManager* m_downloads = nullptr; + InstalledModsProvider m_installedMods; + Event::Id m_downloadFinishedId = 0; + Event::Id m_busyChangedId = 0; + CoreContext* m_context = nullptr; + Event::Id m_modsChangedId = 0; + Event::Id m_configChangedId = 0; + + std::optional m_installedIndex; // Insertion-ordered: the oldest entries are evicted first. - QList response_order_; + QList m_responseOrder; // Each value is a Remembered for the T it was stored with. - QHash responses_; + QHash m_responses; // Every client made so far stays alive until shutdown, so requests in flight on a replaced client finish. - std::vector> clients_; - CatalogClient* client_ = nullptr; - - QList queue_; - QSet queued_; - bool draining_ = false; - std::optional> runner_; - std::vector> idle_waiters_; + std::vector> m_clients; + CatalogClient* m_client = nullptr; + + QList m_queue; + QSet m_queued; + bool m_draining = false; + std::optional> m_runner; + std::vector> m_idleWaiters; }; template std::optional> WorkshopCatalog::recall(const QString& key) const { - const auto found = responses_.constFind(key); - if (found == responses_.constEnd()) { + const auto found = m_responses.constFind(key); + if (found == m_responses.constEnd()) { return std::nullopt; } const auto* typed = std::any_cast>(&found.value()); @@ -209,25 +215,25 @@ std::optional> WorkshopCatalog::recall(const QString& key) const { template QCoro::Task> WorkshopCatalog::cached(QString key, std::function>()> produce, - std::function show, double fresh_for, + std::function show, double freshFor, bool persist) { - const QString full_key = base_url_ + QLatin1Char('|') + key; + const QString fullKey = m_baseUrl + QLatin1Char('|') + key; std::optional path; - if (persist && cache_dir_) { - path = cache_file(full_key); + if (persist && m_cacheDir) { + path = cacheFile(fullKey); } - std::optional> entry = recall(full_key); + std::optional> entry = recall(fullKey); if (!entry && path) { - entry = co_await QtConcurrent::run([path = *path] { return detail::read_cached(path); }); + entry = co_await QtConcurrent::run([path = *path] { return detail::readCached(path); }); if (entry) { - remember(full_key, *entry, false); + remember(fullKey, *entry, false); } } if (entry) { show(entry->value); const double age = QDateTime::currentMSecsSinceEpoch() / 1000.0 - entry->at; - if (age < fresh_for) { + if (age < freshFor) { co_return {}; } } @@ -243,14 +249,14 @@ QCoro::Task> WorkshopCatalog::cached(QString key, value = entry->value; } const Remembered remembered{QDateTime::currentMSecsSinceEpoch() / 1000.0, value}; - remember(full_key, remembered, true); + remember(fullKey, remembered, true); if (path) { auto encoded = glz::write(remembered); if (encoded) { auto saved = co_await QtConcurrent::run( - [directory = *cache_dir_, file = *path, bytes = std::move(*encoded)] { - return detail::write_cached(directory, file, bytes); + [directory = *m_cacheDir, file = *path, bytes = std::move(*encoded)] { + return detail::writeCached(directory, file, bytes); }); if (!saved) { spdlog::warn("[workshop] cannot write cache: {}", saved.error().message.toStdString()); diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index ca09f8c..9f97e3c 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -31,21 +31,25 @@ struct ErrorEnvelope { ErrorMessage error; }; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct BatchWire { std::vector items; std::vector unavailable_ids; }; +// NOLINTEND(readability-identifier-naming) struct IdsBody { std::vector ids; }; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolveBody { std::vector ids; std::vector collection_ids; }; +// NOLINTEND(readability-identifier-naming) -std::vector to_utf8(const QStringList& list) { +std::vector toUtf8(const QStringList& list) { std::vector out; out.reserve(static_cast(list.size())); for (const QString& value : list) { @@ -55,11 +59,11 @@ std::vector to_utf8(const QStringList& list) { } // Percent-encodes everything except the unreserved characters, as Python's quote(safe="") does. -QString encode_segment(const QString& segment) { +QString encodeSegment(const QString& segment) { return QString::fromLatin1(QUrl::toPercentEncoding(segment)); } -QUrlQuery query_of(const CatalogQuery& query) { +QUrlQuery queryOf(const CatalogQuery& query) { QUrlQuery params; params.addQueryItem(QStringLiteral("q"), query.query); params.addQueryItem(QStringLiteral("sort"), query.sort); @@ -78,7 +82,7 @@ QUrlQuery query_of(const CatalogQuery& query) { } template -std::string to_json(const T& value) { +std::string toJson(const T& value) { auto text = glz::write(value); Q_ASSERT(text.has_value()); return std::move(*text); @@ -94,7 +98,7 @@ CatalogResult decode(const std::string& body, int status) { } // Appends the values not seen before, keeping first-occurrence order (Python dict.fromkeys). -void add_unique(std::vector& out, std::unordered_set& seen, +void addUnique(std::vector& out, std::unordered_set& seen, const std::vector& values) { for (const std::string& value : values) { if (seen.insert(value).second) { @@ -105,7 +109,7 @@ void add_unique(std::vector& out, std::unordered_set& } // namespace -QString normalize_base_url(QString url) { +QString normalizeBaseUrl(QString url) { url = url.trimmed(); while (url.endsWith(u'/')) { url.chop(1); @@ -113,24 +117,24 @@ QString normalize_base_url(QString url) { return url; } -CatalogClient::CatalogClient(QString base_url) : base_url_(normalize_base_url(std::move(base_url))) {} +CatalogClient::CatalogClient(QString baseUrl) : m_baseUrl(normalizeBaseUrl(std::move(baseUrl))) {} void CatalogClient::shutdown() { // abort() can resume waiting requests, which remove themselves from pending_: iterate a copy. - const auto replies = pending_; + const auto replies = m_pending; for (QNetworkReply* reply : replies) { reply->abort(); } } QUrl CatalogClient::endpoint(const QString& path, const QUrlQuery& query) const { - QUrl url(base_url_ + QStringLiteral("/catalog/") + path); + QUrl url(m_baseUrl + QStringLiteral("/catalog/") + path); url.setQuery(query); return url; } QCoro::Task> CatalogClient::send(bool post, QUrl url, std::string payload) { - if (base_url_.isEmpty()) { + if (m_baseUrl.isEmpty()) { co_return std::unexpected(CatalogError{QStringLiteral("Workshop catalog is disabled."), std::nullopt}); } @@ -139,11 +143,11 @@ QCoro::Task> CatalogClient::send(bool post, QNetworkReply* reply = nullptr; if (post) { request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); - reply = network_.post(request, QByteArray::fromStdString(payload)); + reply = m_network.post(request, QByteArray::fromStdString(payload)); } else { - reply = network_.get(request); + reply = m_network.get(request); } - pending_.insert(reply); + m_pending.insert(reply); // Runs however the coroutine leaves, so a finished reply is never left registered for abort. struct Release { QSet& pending; @@ -152,18 +156,18 @@ QCoro::Task> CatalogClient::send(bool post, pending.remove(reply); reply->deleteLater(); } - } release{pending_, reply}; + } release{m_pending, reply}; co_await qCoro(reply).waitForFinished(); // An HTTP status is only present when the server answered; without one the request failed in transport. - const QVariant status_attribute = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - if (!status_attribute.isValid()) { + const QVariant statusAttribute = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (!statusAttribute.isValid()) { co_return std::unexpected(CatalogError{ QStringLiteral("Cannot reach the Workshop catalog: %1").arg(reply->errorString()), std::nullopt}); } - const int status = status_attribute.toInt(); + const int status = statusAttribute.toInt(); Reply received{status, reply->readAll().toStdString()}; if (status >= 200 && status < 300) { co_return received; @@ -196,16 +200,16 @@ QCoro::Task> CatalogClient::discover() { } QCoro::Task>> CatalogClient::mods(CatalogQuery query) { - co_return co_await call>(false, endpoint(QStringLiteral("mods"), query_of(query))); + co_return co_await call>(false, endpoint(QStringLiteral("mods"), queryOf(query))); } QCoro::Task>> CatalogClient::collections(CatalogQuery query) { co_return co_await call>( - false, endpoint(QStringLiteral("collections"), query_of(query))); + false, endpoint(QStringLiteral("collections"), queryOf(query))); } QCoro::Task>> CatalogClient::mod(QString id) { - auto found = co_await call(false, endpoint(QStringLiteral("mods/") + encode_segment(id))); + auto found = co_await call(false, endpoint(QStringLiteral("mods/") + encodeSegment(id))); if (found) { co_return std::optional(std::move(*found)); } @@ -226,7 +230,7 @@ QCoro::Task>> CatalogClient::colle } auto found = co_await call( - false, endpoint(QStringLiteral("collections/") + source + QLatin1Char('/') + encode_segment(key))); + false, endpoint(QStringLiteral("collections/") + source + QLatin1Char('/') + encodeSegment(key))); if (found) { co_return std::optional(std::move(*found)); } @@ -236,50 +240,50 @@ QCoro::Task>> CatalogClient::colle co_return std::unexpected(found.error()); } -QCoro::Task> CatalogClient::mods_batch(QStringList ids) { +QCoro::Task> CatalogClient::modsBatch(QStringList ids) { BatchResult result; for (qsizetype start = 0; start < ids.size(); start += kIdsPerBatch) { auto batch = co_await call( true, endpoint(QStringLiteral("mods/batch")), - to_json(IdsBody{to_utf8(ids.mid(start, kIdsPerBatch))})); + toJson(IdsBody{toUtf8(ids.mid(start, kIdsPerBatch))})); if (!batch) { co_return std::unexpected(batch.error()); } result.items.insert(result.items.end(), std::make_move_iterator(batch->items.begin()), std::make_move_iterator(batch->items.end())); - result.unavailable_ids.insert(result.unavailable_ids.end(), batch->unavailable_ids.begin(), + result.unavailableIds.insert(result.unavailableIds.end(), batch->unavailable_ids.begin(), batch->unavailable_ids.end()); } co_return result; } -QCoro::Task> CatalogClient::resolve(QStringList ids, QStringList collection_ids) { +QCoro::Task> CatalogClient::resolve(QStringList ids, QStringList collectionIds) { // One request covers up to 50 mods and 10 collections; the longer list sets the number of requests. const qsizetype batches = std::max({(ids.size() + kModIdsPerResolve - 1) / kModIdsPerResolve, - (collection_ids.size() + kCollectionIdsPerResolve - 1) / kCollectionIdsPerResolve, + (collectionIds.size() + kCollectionIdsPerResolve - 1) / kCollectionIdsPerResolve, qsizetype{1}}); ResolvedDownload merged; merged.is_complete = true; - std::unordered_set roots_seen; - std::unordered_set mod_ids_seen; - std::unordered_set unavailable_seen; - std::unordered_set incomplete_seen; + std::unordered_set rootsSeen; + std::unordered_set modIdsSeen; + std::unordered_set unavailableSeen; + std::unordered_set incompleteSeen; for (qsizetype batch = 0; batch < batches; ++batch) { ResolveBody body{ - to_utf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), - to_utf8(collection_ids.mid(batch * kCollectionIdsPerResolve, kCollectionIdsPerResolve)), + toUtf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), + toUtf8(collectionIds.mid(batch * kCollectionIdsPerResolve, kCollectionIdsPerResolve)), }; - auto resolved = co_await call(true, endpoint(QStringLiteral("resolve")), to_json(body)); + auto resolved = co_await call(true, endpoint(QStringLiteral("resolve")), toJson(body)); if (!resolved) { co_return std::unexpected(resolved.error()); } - add_unique(merged.roots, roots_seen, resolved->roots); + addUnique(merged.roots, rootsSeen, resolved->roots); for (auto& [id, entry] : resolved->items) { merged.items.insert_or_assign(id, std::move(entry)); } - add_unique(merged.mod_ids, mod_ids_seen, resolved->mod_ids); - add_unique(merged.unavailable_ids, unavailable_seen, resolved->unavailable_ids); - add_unique(merged.incomplete_collection_ids, incomplete_seen, resolved->incomplete_collection_ids); + addUnique(merged.mod_ids, modIdsSeen, resolved->mod_ids); + addUnique(merged.unavailable_ids, unavailableSeen, resolved->unavailable_ids); + addUnique(merged.incomplete_collection_ids, incompleteSeen, resolved->incomplete_collection_ids); merged.is_complete = merged.is_complete && resolved->is_complete; } co_return merged; diff --git a/src/core/workshop/client.hpp b/src/core/workshop/client.hpp index da08e83..c76b411 100644 --- a/src/core/workshop/client.hpp +++ b/src/core/workshop/client.hpp @@ -17,7 +17,7 @@ namespace px::modrim::workshop { // Trims whitespace and trailing slashes, so "https://x/" and "https://x" name the same catalog. -QString normalize_base_url(QString url); +QString normalizeBaseUrl(QString url); // HTTP client for the Workshop catalog service. An empty base URL disables the catalog: every call // fails with a CatalogError and nothing is sent. @@ -25,10 +25,10 @@ class CatalogClient { public: struct BatchResult { std::vector items; - std::vector unavailable_ids; + std::vector unavailableIds; }; - explicit CatalogClient(QString base_url); + explicit CatalogClient(QString baseUrl); CatalogClient(const CatalogClient&) = delete; CatalogClient& operator=(const CatalogClient&) = delete; @@ -42,8 +42,8 @@ class CatalogClient { QCoro::Task>> mod(QString id); // nullopt when the catalog does not know the collection (HTTP 404). QCoro::Task>> collection(QString id); - QCoro::Task> mods_batch(QStringList ids); - QCoro::Task> resolve(QStringList ids, QStringList collection_ids); + QCoro::Task> modsBatch(QStringList ids); + QCoro::Task> resolve(QStringList ids, QStringList collectionIds); private: struct Reply { @@ -57,10 +57,10 @@ class CatalogClient { QCoro::Task> send(bool post, QUrl url, std::string payload); QUrl endpoint(const QString& path, const QUrlQuery& query = {}) const; - QString base_url_; - QNetworkAccessManager network_; + QString m_baseUrl; + QNetworkAccessManager m_network; // Requests in flight, aborted by shutdown(). - QSet pending_; + QSet m_pending; }; } // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp index b5e19c4..647422e 100644 --- a/src/core/workshop/favourites.cpp +++ b/src/core/workshop/favourites.cpp @@ -6,14 +6,14 @@ namespace px::modrim::workshop { FavouriteCollections::FavouriteCollections(ConfigService* service) - : config_(service, QStringLiteral("workshop_favourites")) { - for (const CatalogCollection& collection : config_.value().collections) { - ids_.insert(collection.id); + : m_config(service, QStringLiteral("workshop_favourites")) { + for (const CatalogCollection& collection : m_config.value().collections) { + m_ids.insert(collection.id); } } -bool FavouriteCollections::contains(const std::string& collection_id) const { - return ids_.contains(collection_id); +bool FavouriteCollections::contains(const std::string& collectionId) const { + return m_ids.contains(collectionId); } void FavouriteCollections::set(const CatalogCollection& collection, bool favourite) { @@ -21,7 +21,7 @@ void FavouriteCollections::set(const CatalogCollection& collection, bool favouri return; } std::vector rest; - for (const CatalogCollection& stored : config_.value().collections) { + for (const CatalogCollection& stored : m_config.value().collections) { if (stored.id != collection.id) { rest.push_back(stored); } @@ -36,7 +36,7 @@ void FavouriteCollections::remember(const CatalogCollection& collection) { if (!contains(collection.id)) { return; } - const auto& stored = config_.value().collections; + const auto& stored = m_config.value().collections; if (std::ranges::find(stored, collection) != stored.end()) { return; } @@ -50,11 +50,11 @@ void FavouriteCollections::remember(const CatalogCollection& collection) { } void FavouriteCollections::save(std::vector collections) { - ids_.clear(); + m_ids.clear(); for (const CatalogCollection& collection : collections) { - ids_.insert(collection.id); + m_ids.insert(collection.id); } - config_.update(FavouritesState{std::move(collections)}); + m_config.update(FavouritesState{std::move(collections)}); } } // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.hpp b/src/core/workshop/favourites.hpp index d0d8830..fc4954f 100644 --- a/src/core/workshop/favourites.hpp +++ b/src/core/workshop/favourites.hpp @@ -12,11 +12,13 @@ namespace px::modrim::workshop { // The persisted form, plugins/workshop_favourites.json. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct FavouritesState { std::vector collections; bool operator==(const FavouritesState&) const = default; }; +// NOLINTEND(readability-identifier-naming) // Collections the user starred, newest first. Each entry is the last catalog snapshot of the collection, // so the list shows without a network round trip and survives the catalog dropping it. @@ -25,9 +27,9 @@ class FavouriteCollections { // Without a service the favourites live in memory only. explicit FavouriteCollections(ConfigService* service); - Event& changed() noexcept { return config_.changed; } - const std::vector& items() const noexcept { return config_.value().collections; } - bool contains(const std::string& collection_id) const; + Event& changed() noexcept { return m_config.changed; } + const std::vector& items() const noexcept { return m_config.value().collections; } + bool contains(const std::string& collectionId) const; void set(const CatalogCollection& collection, bool favourite); // Refreshes the stored snapshot of a starred collection; other collections are ignored. @@ -36,8 +38,8 @@ class FavouriteCollections { private: void save(std::vector collections); - PluginConfig config_; - std::unordered_set ids_; + PluginConfig m_config; + std::unordered_set m_ids; }; } // namespace px::modrim::workshop diff --git a/src/core/workshop/types.hpp b/src/core/workshop/types.hpp index 0acbc1a..21ee686 100644 --- a/src/core/workshop/types.hpp +++ b/src/core/workshop/types.hpp @@ -24,14 +24,15 @@ inline constexpr glz::opts kWireOpts{ }; // Literal-typed wire fields are enums so that a value outside the catalog's contract fails to decode. -enum class PreviewType { image, video }; -enum class ModSource { steam }; -enum class ModDescriptionFormat { bbcode }; -enum class ModKind { mod }; -enum class CollectionSource { steam, picked }; -enum class CollectionDescriptionFormat { bbcode, text }; -enum class CollectionKind { collection }; - +enum class PreviewType { kImage, kVideo }; +enum class ModSource { kSteam }; +enum class ModDescriptionFormat { kBbcode }; +enum class ModKind { kMod }; +enum class CollectionSource { kSteam, kPicked }; +enum class CollectionDescriptionFormat { kBbcode, kText }; +enum class CollectionKind { kCollection }; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct Author { std::optional id; std::optional name; @@ -39,14 +40,18 @@ struct Author { bool operator==(const Author&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct Preview { - PreviewType type = PreviewType::image; + PreviewType type = PreviewType::kImage; std::string url; bool operator==(const Preview&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct Votes { std::int64_t up = 0; std::int64_t down = 0; @@ -54,14 +59,16 @@ struct Votes { bool operator==(const Votes&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CatalogMod { std::string id; - ModSource source = ModSource::steam; + ModSource source = ModSource::kSteam; std::string title; Author author; std::string description; - ModDescriptionFormat description_format = ModDescriptionFormat::bbcode; + ModDescriptionFormat description_format = ModDescriptionFormat::kBbcode; std::optional preview_url; std::vector previews; std::string workshop_url; @@ -76,19 +83,21 @@ struct CatalogMod { std::optional package_id; bool incompatible = false; // The wire tag: decoding a collection payload as a mod fails here. - ModKind kind = ModKind::mod; + ModKind kind = ModKind::kMod; bool operator==(const CatalogMod&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CatalogCollection { std::string id; - CollectionSource source = CollectionSource::steam; + CollectionSource source = CollectionSource::kSteam; std::optional steam_id; std::string title; Author author; std::string description; - CollectionDescriptionFormat description_format = CollectionDescriptionFormat::text; + CollectionDescriptionFormat description_format = CollectionDescriptionFormat::kText; std::optional preview_url; std::vector previews; std::optional workshop_url; @@ -102,14 +111,16 @@ struct CatalogCollection { std::optional total_size; bool no_common_version = false; // The wire tag: decoding a mod payload as a collection fails here. - CollectionKind kind = CollectionKind::collection; + CollectionKind kind = CollectionKind::kCollection; bool operator==(const CatalogCollection&) const = default; }; +// NOLINTEND(readability-identifier-naming) // A mod or a collection, told apart by the wire `kind` tag. using CatalogEntry = std::variant; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire template struct CatalogPage { std::vector items; @@ -118,7 +129,9 @@ struct CatalogPage { bool operator==(const CatalogPage&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CollectionDetail { CatalogCollection collection; std::vector members; @@ -127,14 +140,18 @@ struct CollectionDetail { bool operator==(const CollectionDetail&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct Discover { CatalogPage mods; CatalogPage collections; bool operator==(const Discover&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct ResolvedDownload { std::vector roots; std::map items; @@ -145,7 +162,9 @@ struct ResolvedDownload { bool operator==(const ResolvedDownload&) const = default; }; +// NOLINTEND(readability-identifier-naming) +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CatalogQuery { QString query; std::optional tag; @@ -155,10 +174,12 @@ struct CatalogQuery { std::optional cursor; QString source = QStringLiteral("all"); }; +// NOLINTEND(readability-identifier-naming) -enum class InstallState { missing, installed, outdated }; +enum class InstallState { kMissing, kInstalled, kOutdated }; // What a download would fetch, in dependency order. Built on the client side, so it uses Qt types. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct DownloadPlan { QStringList to_download{}; QStringList already_current{}; @@ -167,57 +188,64 @@ struct DownloadPlan { QHash titles{}; bool is_complete = false; }; +// NOLINTEND(readability-identifier-naming) // Failure of a catalog request. `status` is the HTTP status when the server answered, empty when the // request never got a response (disabled catalog, unreachable host, undecodable payload). +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct CatalogError { QString message; std::optional status; }; +// NOLINTEND(readability-identifier-naming) template using CatalogResult = std::expected; } // namespace px::modrim::workshop +// The JSON strings are part of the catalog API, so they are spelled out instead of derived from +// the enumerator names. +// NOLINTBEGIN(readability-identifier-naming): glaze looks the member up by this exact name template <> struct glz::meta { using enum px::modrim::workshop::PreviewType; - static constexpr auto value = glz::enumerate(image, video); + static constexpr auto value = glz::enumerate("image", kImage, "video", kVideo); }; template <> struct glz::meta { using enum px::modrim::workshop::ModSource; - static constexpr auto value = glz::enumerate(steam); + static constexpr auto value = glz::enumerate("steam", kSteam); }; template <> struct glz::meta { using enum px::modrim::workshop::ModDescriptionFormat; - static constexpr auto value = glz::enumerate(bbcode); + static constexpr auto value = glz::enumerate("bbcode", kBbcode); }; template <> struct glz::meta { using enum px::modrim::workshop::ModKind; - static constexpr auto value = glz::enumerate(mod); + static constexpr auto value = glz::enumerate("mod", kMod); }; template <> struct glz::meta { using enum px::modrim::workshop::CollectionSource; - static constexpr auto value = glz::enumerate(steam, picked); + static constexpr auto value = glz::enumerate("steam", kSteam, "picked", kPicked); }; template <> struct glz::meta { using enum px::modrim::workshop::CollectionDescriptionFormat; - static constexpr auto value = glz::enumerate(bbcode, text); + static constexpr auto value = glz::enumerate("bbcode", kBbcode, "text", kText); }; template <> struct glz::meta { using enum px::modrim::workshop::CollectionKind; - static constexpr auto value = glz::enumerate(collection); + static constexpr auto value = glz::enumerate("collection", kCollection); }; +// NOLINTEND(readability-identifier-naming) diff --git a/src/ui/Downloads.qml b/src/ui/Downloads.qml index 7077f05..a1749bb 100644 --- a/src/ui/Downloads.qml +++ b/src/ui/Downloads.qml @@ -130,7 +130,7 @@ Rectangle { } Connections { target: downloadsModel - function onSpeed_changed() { graph.requestPaint() } + function onSpeedChanged() { graph.requestPaint() } } } diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp index d660811..00e6f9c 100644 --- a/src/ui/components/banner_widget.cpp +++ b/src/ui/components/banner_widget.cpp @@ -27,29 +27,29 @@ int floorHalf(int value) { } // namespace AspectRatioBanner::AspectRatioBanner(QWidget* parent, int maxHeight) - : QWidget(parent), max_height_(maxHeight) { + : QWidget(parent), m_maxHeight(maxHeight) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } void AspectRatioBanner::setPixmap(const QPixmap& pixmap) { - pixmap_ = pixmap; + m_pixmap = pixmap; recomputeScaledPixmap(); updateGeometry(); update(); } void AspectRatioBanner::setTitle(const QString& title) { - title_ = title; + m_title = title; update(); } void AspectRatioBanner::setSubtitle(const QString& subtitle) { - subtitle_ = subtitle; + m_subtitle = subtitle; update(); } void AspectRatioBanner::setShowOverlay(bool show) { - show_overlay_ = show; + m_showOverlay = show; update(); } @@ -58,7 +58,7 @@ bool AspectRatioBanner::hasHeightForWidth() const { } int AspectRatioBanner::heightForWidth(int w) const { - return std::min(w * BANNER_ASPECT_H / BANNER_ASPECT_W, max_height_); + return std::min(w * kBannerAspectH / kBannerAspectW, m_maxHeight); } QSize AspectRatioBanner::sizeHint() const { @@ -67,12 +67,12 @@ QSize AspectRatioBanner::sizeHint() const { } void AspectRatioBanner::recomputeScaledPixmap() { - if (pixmap_.isNull() || width() <= 0) { - scaled_pixmap_ = QPixmap(); + if (m_pixmap.isNull() || width() <= 0) { + m_scaledPixmap = QPixmap(); return; } - scaled_pixmap_ = pixmap_.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, + m_scaledPixmap = m_pixmap.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); } @@ -81,26 +81,26 @@ void AspectRatioBanner::paintEvent(QPaintEvent*) { painter.setRenderHint(QPainter::SmoothPixmapTransform); painter.setClipRect(rect()); - if (scaled_pixmap_.isNull()) { + if (m_scaledPixmap.isNull()) { painter.fillRect(rect(), panelBgQ()); } else { - const int x = floorHalf(width() - scaled_pixmap_.width()); - const int y = floorHalf(height() - scaled_pixmap_.height()); - painter.drawPixmap(x, y, scaled_pixmap_); + const int x = floorHalf(width() - m_scaledPixmap.width()); + const int y = floorHalf(height() - m_scaledPixmap.height()); + painter.drawPixmap(x, y, m_scaledPixmap); } - if (title_.isEmpty() || !show_overlay_) { + if (m_title.isEmpty() || !m_showOverlay) { return; } - const int overlay_top = height() - BANNER_OVERLAY_HEIGHT; - QLinearGradient gradient(0, overlay_top, 0, height()); + const int overlayTop = height() - kBannerOverlayHeight; + QLinearGradient gradient(0, overlayTop, 0, height()); gradient.setColorAt(0.0, QColor(0, 0, 0, 0)); gradient.setColorAt(1.0, QColor(0, 0, 0, 180)); - painter.fillRect(0, overlay_top, width(), BANNER_OVERLAY_HEIGHT, gradient); + painter.fillRect(0, overlayTop, width(), kBannerOverlayHeight, gradient); - const int text_area_top = overlay_top + 12; - const int text_area_w = width() - kOverlayPad * 2; + const int textAreaTop = overlayTop + 12; + const int textAreaW = width() - kOverlayPad * 2; QFont font = painter.font(); font.setPointSize(18); @@ -108,22 +108,22 @@ void AspectRatioBanner::paintEvent(QPaintEvent*) { painter.setFont(font); painter.setPen(textMainQ()); - int title_h = BANNER_OVERLAY_HEIGHT - 24; - if (!subtitle_.isEmpty()) { - title_h = title_h * 3 / 5; + int titleH = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) { + titleH = titleH * 3 / 5; } - painter.drawText(kOverlayPad, text_area_top, text_area_w, title_h, - Qt::AlignLeft | Qt::AlignBottom, title_); + painter.drawText(kOverlayPad, textAreaTop, textAreaW, titleH, + Qt::AlignLeft | Qt::AlignBottom, m_title); - if (!subtitle_.isEmpty()) { + if (!m_subtitle.isEmpty()) { font.setPointSize(12); font.setBold(false); painter.setFont(font); painter.setPen(textMutedQ()); - const int subtitle_top = text_area_top + title_h + 2; - const int subtitle_h = BANNER_OVERLAY_HEIGHT - title_h - 26; - painter.drawText(kOverlayPad, subtitle_top, text_area_w, subtitle_h, - Qt::AlignLeft | Qt::AlignTop, subtitle_); + const int subtitleTop = textAreaTop + titleH + 2; + const int subtitleH = kBannerOverlayHeight - titleH - 26; + painter.drawText(kOverlayPad, subtitleTop, textAreaW, subtitleH, + Qt::AlignLeft | Qt::AlignTop, m_subtitle); } } diff --git a/src/ui/components/banner_widget.hpp b/src/ui/components/banner_widget.hpp index 84f75fb..092eb06 100644 --- a/src/ui/components/banner_widget.hpp +++ b/src/ui/components/banner_widget.hpp @@ -17,7 +17,7 @@ class AspectRatioBanner : public QWidget { Q_OBJECT public: - explicit AspectRatioBanner(QWidget* parent = nullptr, int maxHeight = BANNER_MAX_HEIGHT); + explicit AspectRatioBanner(QWidget* parent = nullptr, int maxHeight = kBannerMaxHeight); void setPixmap(const QPixmap& pixmap); void setTitle(const QString& title); @@ -35,12 +35,12 @@ class AspectRatioBanner : public QWidget { private: void recomputeScaledPixmap(); - QPixmap pixmap_; - QPixmap scaled_pixmap_; - QString title_; - QString subtitle_; - int max_height_; - bool show_overlay_ = true; + QPixmap m_pixmap; + QPixmap m_scaledPixmap; + QString m_title; + QString m_subtitle; + int m_maxHeight; + bool m_showOverlay = true; }; } // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp index 1907dda..8b4e182 100644 --- a/src/ui/components/dialog_chrome.cpp +++ b/src/ui/components/dialog_chrome.cpp @@ -43,31 +43,31 @@ class CloseButton : public QPushButton { setObjectName(QStringLiteral("dialogCloseBtn")); setFixedSize(kCloseSize, kCloseSize); // A global QPushButton min-height would otherwise beat setFixedSize. - constexpr int inner = kCloseSize - 2; // the 1px QSS border sits outside the content box + constexpr int kInner = kCloseSize - 2; // the 1px QSS border sits outside the content box setStyleSheet(QStringLiteral("min-width: %1px; max-width: %1px; min-height: %1px; max-height: %1px;") - .arg(inner)); + .arg(kInner)); setToolTip(QStringLiteral("Close")); setCursor(Qt::PointingHandCursor); setFocusPolicy(Qt::NoFocus); - normal_icon_ = requireIcon(u"close", 12, paletteValue(u"DANGER")); - hover_icon_ = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); - setIcon(normal_icon_); + m_normalIcon = requireIcon(u"close", 12, paletteValue(u"DANGER")); + m_hoverIcon = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); + setIcon(m_normalIcon); } protected: void enterEvent(QEnterEvent* event) override { - setIcon(hover_icon_); + setIcon(m_hoverIcon); QPushButton::enterEvent(event); } void leaveEvent(QEvent* event) override { - setIcon(normal_icon_); + setIcon(m_normalIcon); QPushButton::leaveEvent(event); } private: - QIcon normal_icon_; - QIcon hover_icon_; + QIcon m_normalIcon; + QIcon m_hoverIcon; }; // Closes the dialog the way Escape does: a message box clicks its escape button @@ -123,16 +123,16 @@ class TitleBar : public QWidget { // Keeps the overlay title bar and size grip pinned to the dialog frame. class Chrome : public QObject { public: - Chrome(QDialog* dialog, bool closable, bool resizable) : QObject(dialog), dialog_(dialog) { - bar_ = new TitleBar(dialog, closable); - grip_ = resizable ? new QSizeGrip(dialog) : nullptr; + Chrome(QDialog* dialog, bool closable, bool resizable) : QObject(dialog), m_dialog(dialog) { + m_bar = new TitleBar(dialog, closable); + m_grip = resizable ? new QSizeGrip(dialog) : nullptr; dialog->installEventFilter(this); place(); } protected: bool eventFilter(QObject* watched, QEvent* event) override { - if (watched == dialog_ && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + if (watched == m_dialog && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { place(); } return false; @@ -140,19 +140,19 @@ class Chrome : public QObject { private: void place() { - const int width = dialog_->width(); - bar_->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); - bar_->raise(); - if (grip_) { - grip_->setGeometry(width - kGripSize - kBorder, dialog_->height() - kGripSize - kBorder, + const int width = m_dialog->width(); + m_bar->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); + m_bar->raise(); + if (m_grip) { + m_grip->setGeometry(width - kGripSize - kBorder, m_dialog->height() - kGripSize - kBorder, kGripSize, kGripSize); - grip_->raise(); + m_grip->raise(); } } - QDialog* dialog_; - TitleBar* bar_ = nullptr; - QSizeGrip* grip_ = nullptr; + QDialog* m_dialog; + TitleBar* m_bar = nullptr; + QSizeGrip* m_grip = nullptr; }; } // namespace diff --git a/src/ui/components/dialogs.hpp b/src/ui/components/dialogs.hpp index ace0d50..891a023 100644 --- a/src/ui/components/dialogs.hpp +++ b/src/ui/components/dialogs.hpp @@ -22,24 +22,26 @@ namespace px::modrim { class DialogFinished { public: explicit DialogFinished(QDialog* dialog) - : connection_(QObject::connect(dialog, &QDialog::finished, dialog, [this](int code) { - code_ = code; - finished_ = true; - if (waiter_) std::exchange(waiter_, nullptr).resume(); + : m_connection(QObject::connect(dialog, &QDialog::finished, dialog, [this](int code) { + m_code = code; + m_finished = true; + if (m_waiter) std::exchange(m_waiter, nullptr).resume(); })) {} DialogFinished(const DialogFinished&) = delete; DialogFinished& operator=(const DialogFinished&) = delete; - ~DialogFinished() { QObject::disconnect(connection_); } + ~DialogFinished() { QObject::disconnect(m_connection); } - bool await_ready() const noexcept { return finished_; } - void await_suspend(std::coroutine_handle<> waiting) noexcept { waiter_ = waiting; } - int await_resume() const noexcept { return code_; } + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return m_finished; } + void await_suspend(std::coroutine_handle<> waiting) noexcept { m_waiter = waiting; } + int await_resume() const noexcept { return m_code; } + // NOLINTEND(readability-identifier-naming) private: - QMetaObject::Connection connection_; - std::coroutine_handle<> waiter_; - int code_ = QDialog::Rejected; - bool finished_ = false; + QMetaObject::Connection m_connection; + std::coroutine_handle<> m_waiter; + int m_code = QDialog::Rejected; + bool m_finished = false; }; template T, class... Args> diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp index 423a0cd..8c02fa1 100644 --- a/src/ui/components/filter_sidebar.cpp +++ b/src/ui/components/filter_sidebar.cpp @@ -13,7 +13,7 @@ FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWid setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/FilterSidebar.qml"))); if (auto* root = rootObject()) connect(root, SIGNAL(activated(int)), this, SIGNAL(activated(int))); } -int FilterSidebar::current_index() const { +int FilterSidebar::currentIndex() const { const auto* root = rootObject(); const auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; return view ? view->property("currentIndex").toInt() : -1; diff --git a/src/ui/components/filter_sidebar.hpp b/src/ui/components/filter_sidebar.hpp index da2bc75..6be4c13 100644 --- a/src/ui/components/filter_sidebar.hpp +++ b/src/ui/components/filter_sidebar.hpp @@ -6,7 +6,7 @@ class FilterSidebar : public QQuickWidget { Q_OBJECT public: FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent = nullptr); - int current_index() const; + int currentIndex() const; signals: void activated(int index); }; diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp index a7fbc93..1396a43 100644 --- a/src/ui/components/header_controller.cpp +++ b/src/ui/components/header_controller.cpp @@ -11,9 +11,9 @@ namespace { QString strategyLabel(LaunchStrategy strategy) { switch (strategy) { - case LaunchStrategy::Direct: + case LaunchStrategy::kDirect: return QStringLiteral("Direct executable"); - case LaunchStrategy::Steam: + case LaunchStrategy::kSteam: return QStringLiteral("Steam"); } return {}; @@ -24,15 +24,15 @@ QString strategyLabel(LaunchStrategy strategy) { HeaderController::HeaderController(bool isFrameless, int initialStrategy, QString appVersion, QVariantMap tooltips, bool downloadsAvailable, QObject* parent) : QObject(parent), - is_frameless_(isFrameless), - app_version_(std::move(appVersion)), - tooltips_(std::move(tooltips)), - downloads_available_(downloadsAvailable), - strategy_index_(initialStrategy) {} + m_isFrameless(isFrameless), + m_appVersion(std::move(appVersion)), + m_tooltips(std::move(tooltips)), + m_downloadsAvailable(downloadsAvailable), + m_strategyIndex(initialStrategy) {} QVariantList HeaderController::strategies() const { QVariantList list; - for (const auto strategy : {LaunchStrategy::Direct, LaunchStrategy::Steam}) { + for (const auto strategy : {LaunchStrategy::kDirect, LaunchStrategy::kSteam}) { list.append(QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, {QStringLiteral("label"), strategyLabel(strategy)}}); } @@ -40,51 +40,51 @@ QVariantList HeaderController::strategies() const { } void HeaderController::setMaximized(bool value) { - if (maximized_ != value) { - maximized_ = value; + if (m_maximized != value) { + m_maximized = value; emit maximizedChanged(); } } void HeaderController::setUnsavedChanges(bool value) { - if (unsaved_changes_ != value) { - unsaved_changes_ = value; + if (m_unsavedChanges != value) { + m_unsavedChanges = value; emit unsavedChangesChanged(); } } void HeaderController::setLaunchState(const QString& state) { - if (launch_state_ != state) { - launch_state_ = state; + if (m_launchState != state) { + m_launchState = state; emit launchStateChanged(); } } void HeaderController::setTaskText(const QString& text) { - if (task_text_ != text) { - task_text_ = text; + if (m_taskText != text) { + m_taskText = text; emit taskTextChanged(); } } void HeaderController::setDownloadsBusy(bool busy) { - if (downloads_busy_ != busy) { - downloads_busy_ = busy; + if (m_downloadsBusy != busy) { + m_downloadsBusy = busy; emit downloadsBusyChanged(); } } void HeaderController::setDownloadsProgressShown(bool shown) { - if (downloads_progress_shown_ != shown) { - downloads_progress_shown_ = shown; + if (m_downloadsProgressShown != shown) { + m_downloadsProgressShown = shown; emit downloadsProgressShownChanged(); } } void HeaderController::setDownloadsProgress(const QString& text, int value, int total) { - downloads_text_ = text; - downloads_value_ = value; - downloads_total_ = total; + m_downloadsText = text; + m_downloadsValue = value; + m_downloadsTotal = total; emit downloadsProgressChanged(); } @@ -107,8 +107,8 @@ void HeaderController::maximize() { emit maximizeRequested(); } void HeaderController::closeWindow() { emit closeRequested(); } void HeaderController::setStrategy(int index) { - if (strategy_index_ != index) { - strategy_index_ = index; + if (m_strategyIndex != index) { + m_strategyIndex = index; emit strategyChanged(index); } } diff --git a/src/ui/components/header_controller.hpp b/src/ui/components/header_controller.hpp index 17a5f44..69a39e4 100644 --- a/src/ui/components/header_controller.hpp +++ b/src/ui/components/header_controller.hpp @@ -34,23 +34,23 @@ class HeaderController : public QObject { HeaderController(bool isFrameless, int initialStrategy, QString appVersion, QVariantMap tooltips, bool downloadsAvailable, QObject* parent = nullptr); - bool isFrameless() const { return is_frameless_; } - const QString& appVersion() const { return app_version_; } - const QVariantMap& tooltips() const { return tooltips_; } - bool downloadsAvailable() const { return downloads_available_; } + bool isFrameless() const { return m_isFrameless; } + const QString& appVersion() const { return m_appVersion; } + const QVariantMap& tooltips() const { return m_tooltips; } + bool downloadsAvailable() const { return m_downloadsAvailable; } // [{index, label}] for each launch strategy, in enum order. QVariantList strategies() const; - bool maximized() const { return maximized_; } - int strategyIndex() const { return strategy_index_; } - bool unsavedChanges() const { return unsaved_changes_; } - const QString& launchState() const { return launch_state_; } - const QString& taskText() const { return task_text_; } - bool downloadsBusy() const { return downloads_busy_; } - bool downloadsProgressShown() const { return downloads_progress_shown_; } - const QString& downloadsText() const { return downloads_text_; } - int downloadsValue() const { return downloads_value_; } - int downloadsTotal() const { return downloads_total_; } + bool maximized() const { return m_maximized; } + int strategyIndex() const { return m_strategyIndex; } + bool unsavedChanges() const { return m_unsavedChanges; } + const QString& launchState() const { return m_launchState; } + const QString& taskText() const { return m_taskText; } + bool downloadsBusy() const { return m_downloadsBusy; } + bool downloadsProgressShown() const { return m_downloadsProgressShown; } + const QString& downloadsText() const { return m_downloadsText; } + int downloadsValue() const { return m_downloadsValue; } + int downloadsTotal() const { return m_downloadsTotal; } void setMaximized(bool value); void setUnsavedChanges(bool value); @@ -95,21 +95,21 @@ class HeaderController : public QObject { void downloadsProgressChanged(); private: - bool is_frameless_; - QString app_version_; - QVariantMap tooltips_; - bool downloads_available_; + bool m_isFrameless; + QString m_appVersion; + QVariantMap m_tooltips; + bool m_downloadsAvailable; - bool maximized_ = false; - int strategy_index_; - bool unsaved_changes_ = false; - QString launch_state_ = QStringLiteral("idle"); - QString task_text_; - bool downloads_busy_ = false; - bool downloads_progress_shown_ = true; - QString downloads_text_; - int downloads_value_ = 0; - int downloads_total_ = 0; + bool m_maximized = false; + int m_strategyIndex; + bool m_unsavedChanges = false; + QString m_launchState = QStringLiteral("idle"); + QString m_taskText; + bool m_downloadsBusy = false; + bool m_downloadsProgressShown = true; + QString m_downloadsText; + int m_downloadsValue = 0; + int m_downloadsTotal = 0; }; } // namespace px::modrim diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp index ce6a193..b12cc47 100644 --- a/src/ui/components/icon_button.cpp +++ b/src/ui/components/icon_button.cpp @@ -22,7 +22,7 @@ QIcon requireIcon(QStringView name, int size, QStringView color) { IconButton::IconButton(const QString& iconName, const QString& tooltip, bool primary, int size, QWidget* parent) - : QPushButton(parent), icon_name_(iconName), icon_px_(std::max(16, size / 2)) { + : QPushButton(parent), m_iconName(iconName), m_iconPx(std::max(16, size / 2)) { setIconColor(paletteValue(u"TEXT_MUTED")); setFixedSize(size, size); setObjectName(QStringLiteral("iconBtn")); @@ -40,7 +40,7 @@ IconButton::IconButton(const QString& iconName, const QString& tooltip, bool pri } void IconButton::setIconColor(const QString& color) { - setIcon(requireIcon(icon_name_, icon_px_, color)); + setIcon(requireIcon(m_iconName, m_iconPx, color)); } } // namespace px::modrim diff --git a/src/ui/components/icon_button.hpp b/src/ui/components/icon_button.hpp index 10d0be2..3513ab8 100644 --- a/src/ui/components/icon_button.hpp +++ b/src/ui/components/icon_button.hpp @@ -16,8 +16,8 @@ class IconButton : public QPushButton { void setIconColor(const QString& color); private: - QString icon_name_; - int icon_px_; + QString m_iconName; + int m_iconPx; }; } // namespace px::modrim diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp index 127424a..3d21155 100644 --- a/src/ui/components/mod_activation.cpp +++ b/src/ui/components/mod_activation.cpp @@ -17,17 +17,17 @@ DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); } -QCoro::Task apply_activation(CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { // Copy before suspension: callers often pass temporary batches. const QStringList enabling = enable; QStringList disabling = disable; - const auto dependents = ctx.activation().dependents_of(disabling, enabling); + const auto dependents = ctx.activation().dependentsOf(disabling, enabling); if (!dependents.isEmpty()) { QStringList details; for (const auto& uuid : dependents) { const auto& mod = ctx.allMods().value(uuid); const auto* about = dynamic_cast(mod.get()); - details.append(about ? QStringLiteral("%1 (%2)").arg(mod->name, about->package_id.str()) : mod->name); + details.append(about ? QStringLiteral("%1 (%2)").arg(mod->name, about->packageId.str()) : mod->name); } const auto [result, dialog] = co_await awaitDialog(details, parent); if (result == QMessageBox::Cancel) co_return false; @@ -35,9 +35,9 @@ QCoro::Task apply_activation(CoreContext& ctx, QWidget* parent, const QStr } co_return ctx.activation().apply(enabling, disabling); } -QCoro::Task toggle_mods(CoreContext& ctx, QWidget* parent, const QStringList& uuids) { +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, const QStringList& uuids) { QStringList enable, disable; for (const auto& uuid : uuids) (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); - co_return co_await apply_activation(ctx, parent, enable, disable); + co_return co_await applyActivation(ctx, parent, enable, disable); } } diff --git a/src/ui/components/mod_activation.hpp b/src/ui/components/mod_activation.hpp index 03e07a6..c6b5cc7 100644 --- a/src/ui/components/mod_activation.hpp +++ b/src/ui/components/mod_activation.hpp @@ -8,6 +8,6 @@ class DependentModsDialog : public QMessageBox { public: DependentModsDialog(const QStringList& dependents, QWidget* parent); }; -QCoro::Task apply_activation(CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); -QCoro::Task toggle_mods(CoreContext& ctx, QWidget* parent, const QStringList& uuids); +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, const QStringList& uuids); } diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp index fa7124e..53734ec 100644 --- a/src/ui/components/mod_updates.cpp +++ b/src/ui/components/mod_updates.cpp @@ -7,28 +7,28 @@ #include #include namespace px::modrim { -QStringList updatable_ids(CoreContext& ctx, const QStringList& uuids) { +QStringList updatableIds(CoreContext& ctx, const QStringList& uuids) { QList mods; mods.reserve(uuids.size()); for (const auto& uuid : uuids) { const auto it = ctx.allMods().constFind(uuid); if (it != ctx.allMods().cend()) mods.append(it.value()); } - return ctx.downloads().updatable_ids(mods); + return ctx.downloads().updatableIds(mods); } -UpdateState update_state(CoreContext& ctx, const QStringList& uuids) { - const auto ids = updatable_ids(ctx, uuids); +UpdateState updateState(CoreContext& ctx, const QStringList& uuids) { + const auto ids = updatableIds(ctx, uuids); QString label = QStringLiteral("Update"); if (uuids.size() > 1) label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); const auto& downloads = ctx.downloads(); if (!downloads.available()) return {label, false, QStringLiteral("No download sources are enabled")}; - if (downloads.is_downloading()) return {label, false, QStringLiteral("A download is in progress")}; + if (downloads.isDownloading()) return {label, false, QStringLiteral("A download is in progress")}; if (ids.isEmpty()) return {label, false, QStringLiteral("None of these mods were downloaded by PxModRim")}; return {label, true, {}}; } -QAction* add_update_action(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler) { +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler) { if (!ctx.downloads().available()) return nullptr; - const auto state = update_state(ctx, uuids); + const auto state = updateState(ctx, uuids); auto* action = menu->addAction(state.label); QObject::connect(action, &QAction::triggered, menu, [handler = std::move(handler)] { handler(); }); action->setEnabled(state.enabled); action->setToolTip(state.tooltip); @@ -43,15 +43,15 @@ ConfirmUpdateDialog::ConfirmUpdateDialog(int count, QWidget* parent) : QMessageB button(QMessageBox::Yes)->setText(QStringLiteral("Update")); button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); } -QCoro::Task confirm_update(int count, QWidget* parent) { - if (count < CONFIRM_THRESHOLD) co_return true; +QCoro::Task confirmUpdate(int count, QWidget* parent) { + if (count < kConfirmThreshold) co_return true; const auto [result, dialog] = co_await awaitDialog(count, parent); co_return result == QMessageBox::Yes; } -QCoro::Task update_mods(CoreContext& ctx, const QStringList& uuids, QWidget* parent) { - const auto ids = updatable_ids(ctx, uuids); - if (ids.isEmpty() || !(co_await confirm_update(static_cast(ids.size()), parent))) co_return; - const auto result = co_await ctx.downloads().download_mods(ids); +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, QWidget* parent) { + const auto ids = updatableIds(ctx, uuids); + if (ids.isEmpty() || !(co_await confirmUpdate(static_cast(ids.size()), parent))) co_return; + const auto result = co_await ctx.downloads().downloadMods(ids); if (!result) qWarning() << "Update not started:" << result.error().message; } } diff --git a/src/ui/components/mod_updates.hpp b/src/ui/components/mod_updates.hpp index 929f4a4..d45db10 100644 --- a/src/ui/components/mod_updates.hpp +++ b/src/ui/components/mod_updates.hpp @@ -7,15 +7,15 @@ class QMenu; class QAction; namespace px::modrim { class CoreContext; } namespace px::modrim { -inline constexpr int CONFIRM_THRESHOLD = 25; +inline constexpr int kConfirmThreshold = 25; struct UpdateState { QString label; bool enabled; QString tooltip; }; -QStringList updatable_ids(CoreContext& ctx, const QStringList& uuids); -UpdateState update_state(CoreContext& ctx, const QStringList& uuids); -QAction* add_update_action(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler); +QStringList updatableIds(CoreContext& ctx, const QStringList& uuids); +UpdateState updateState(CoreContext& ctx, const QStringList& uuids); +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler); class ConfirmUpdateDialog : public QMessageBox { public: ConfirmUpdateDialog(int count, QWidget* parent); }; -QCoro::Task confirm_update(int count, QWidget* parent); -QCoro::Task update_mods(CoreContext& ctx, const QStringList& uuids, QWidget* parent); +QCoro::Task confirmUpdate(int count, QWidget* parent); +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, QWidget* parent); } diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp index a9d7c59..cd5bff8 100644 --- a/src/ui/components/procedural_preview.cpp +++ b/src/ui/components/procedural_preview.cpp @@ -20,9 +20,9 @@ QString initials(const QString& title) { static const QSet stop{"a", "an", "and", "for", "of", "the", "to", "in", "on"}; QString stripped = title; stripped.replace(tags, " "); - const bool use_stripped = words.match(stripped).hasMatch(); + const bool useStripped = words.match(stripped).hasMatch(); QStringList all, meaningful; - auto chunks = words.globalMatch(use_stripped ? stripped : title); + auto chunks = words.globalMatch(useStripped ? stripped : title); while (chunks.hasNext()) { const QString chunk = chunks.next().captured(); auto parts = camel.globalMatch(chunk); @@ -33,11 +33,11 @@ QString initials(const QString& title) { if (meaningful.isEmpty()) meaningful = all; if (meaningful.isEmpty()) return title.trimmed().isEmpty() ? QStringLiteral("?") : title.trimmed().left(2).toUpper(); if (meaningful.size() == 1) { - auto tag_matches = tags.globalMatch(title); - QString tag_text; - while (tag_matches.hasNext()) tag_text += tag_matches.next().captured() + " "; - const auto tag_word = words.match(tag_text); - if (use_stripped && tag_word.hasMatch()) return (tag_word.captured().left(1) + meaningful.front().left(1)).toUpper(); + auto tagMatches = tags.globalMatch(title); + QString tagText; + while (tagMatches.hasNext()) tagText += tagMatches.next().captured() + " "; + const auto tagWord = words.match(tagText); + if (useStripped && tagWord.hasMatch()) return (tagWord.captured().left(1) + meaningful.front().left(1)).toUpper(); return meaningful.front().left(2).toUpper(); } return (meaningful[0].left(1) + meaningful[1].left(1)).toUpper(); @@ -62,7 +62,7 @@ void blob(QPainter& painter, double x, double y, double radius, const QColor& co painter.drawEllipse(QPointF(x, y), radius, radius); } } -QImage generate_preview(const QString& title, int width, int height) { +QImage generatePreview(const QString& title, int width, int height) { static QMutex mutex; static QCache cache(16); const QString key = QString::number(width) + ":" + QString::number(height) + ":" + title; @@ -88,7 +88,7 @@ QImage generate_preview(const QString& title, int width, int height) { font.setLetterSpacing(QFont::PercentageSpacing, 96); painter.setFont(font); painter.setPen(QColor(255, 255, 255, 210)); - const int visible = std::max(height - BANNER_OVERLAY_HEIGHT, height / 2); + const int visible = std::max(height - kBannerOverlayHeight, height / 2); painter.drawText(QRectF(0, 0, width, visible), Qt::AlignCenter, initials(title)); painter.end(); QMutexLocker lock(&mutex); diff --git a/src/ui/components/procedural_preview.hpp b/src/ui/components/procedural_preview.hpp index 1aeb57a..e54d2fe 100644 --- a/src/ui/components/procedural_preview.hpp +++ b/src/ui/components/procedural_preview.hpp @@ -3,5 +3,5 @@ #include namespace px::modrim { QString initials(const QString& title); -QImage generate_preview(const QString& title, int width, int height); +QImage generatePreview(const QString& title, int width, int height); } diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp index c0b5c1a..1c42cc7 100644 --- a/src/ui/components/progress_dialog.cpp +++ b/src/ui/components/progress_dialog.cpp @@ -33,52 +33,52 @@ QIcon requireIcon(QStringView name, int size, QStringView color) { ProgressDialog::ProgressDialog(LoadingState& loading, QWidget* parent) : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | Qt::WindowTitleHint), - loading_(loading) { + m_loading(loading) { setWindowTitle(QStringLiteral("Loading")); setMinimumWidth(420); setModal(true); - crumbs_ = new QWidget(this); - crumbs_layout_ = new QHBoxLayout(crumbs_); - crumbs_layout_->setContentsMargins(0, 0, 0, 8); - crumbs_layout_->setSpacing(4); + m_crumbs = new QWidget(this); + m_crumbsLayout = new QHBoxLayout(m_crumbs); + m_crumbsLayout->setContentsMargins(0, 0, 0, 8); + m_crumbsLayout->setSpacing(4); - progress_ = new QProgressBar(this); - progress_->setTextVisible(false); - progress_->setRange(0, 100); + m_progress = new QProgressBar(this); + m_progress->setTextVisible(false); + m_progress->setRange(0, 100); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(12, 12, 12, 12); layout->setSpacing(0); - layout->addWidget(crumbs_); - layout->addWidget(progress_); + layout->addWidget(m_crumbs); + layout->addWidget(m_progress); installDialogChrome(this, false); - hide_timer_ = new QTimer(this); - hide_timer_->setSingleShot(true); - hide_timer_->setInterval(kHideDelayMs); - connect(hide_timer_, &QTimer::timeout, this, &ProgressDialog::beginHide); + m_hideTimer = new QTimer(this); + m_hideTimer->setSingleShot(true); + m_hideTimer->setInterval(kHideDelayMs); + connect(m_hideTimer, &QTimer::timeout, this, &ProgressDialog::beginHide); - fade_ = new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); - connect(fade_, &QPropertyAnimation::finished, this, &ProgressDialog::onFadeFinished); + m_fade = new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); + connect(m_fade, &QPropertyAnimation::finished, this, &ProgressDialog::onFadeFinished); - connections_.push_back(QObject::connect( - &loading_, &LoadingState::changed, this, + m_connections.push_back(QObject::connect( + &m_loading, &LoadingState::changed, this, [this](const QString&, int pct, int, int) { onChanged(pct); })); - connections_.push_back( - QObject::connect(&loading_, &LoadingState::finished, this, &ProgressDialog::onFinished)); + m_connections.push_back( + QObject::connect(&m_loading, &LoadingState::finished, this, &ProgressDialog::onFinished)); } ProgressDialog::~ProgressDialog() { - for (const auto& connection : connections_) { + for (const auto& connection : m_connections) { QObject::disconnect(connection); } } void ProgressDialog::onChanged(int pct) { - hide_timer_->stop(); - if (!shown_) { - shown_ = true; + m_hideTimer->stop(); + if (!m_shown) { + m_shown = true; if (!isVisible()) { setWindowOpacity(0.0); show(); @@ -86,20 +86,20 @@ void ProgressDialog::onChanged(int pct) { fadeTo(1.0, kFadeInMs); } - const QStringList statuses = loading_.statuses(); + const QStringList statuses = m_loading.statuses(); if (!statuses.isEmpty()) { setWindowTitle(statuses.first()); } updateBreadcrumbs(statuses); - progress_->setValue(pct); + m_progress->setValue(pct); } void ProgressDialog::onFinished() { - hide_timer_->start(); + m_hideTimer->start(); } void ProgressDialog::updateBreadcrumbs(const QStringList& statuses) { - while (QLayoutItem* item = crumbs_layout_->takeAt(0)) { + while (QLayoutItem* item = m_crumbsLayout->takeAt(0)) { if (QWidget* widget = item->widget()) { widget->hide(); widget->deleteLater(); @@ -109,38 +109,38 @@ void ProgressDialog::updateBreadcrumbs(const QStringList& statuses) { const qsizetype last = statuses.size() - 1; for (qsizetype i = 0; i < statuses.size(); ++i) { - auto* label = new QLabel(statuses[i], crumbs_); + auto* label = new QLabel(statuses[i], m_crumbs); if (i == last) { QFont font = label->font(); font.setBold(true); label->setFont(font); } - crumbs_layout_->addWidget(label); + m_crumbsLayout->addWidget(label); if (i < last) { - auto* separator = new QLabel(crumbs_); + auto* separator = new QLabel(m_crumbs); separator->setPixmap(requireIcon(u"chevron", 12, paletteValue(u"TEXT_DIM")).pixmap(12, 12)); - crumbs_layout_->addWidget(separator); + m_crumbsLayout->addWidget(separator); } } - crumbs_layout_->addStretch(); + m_crumbsLayout->addStretch(); } void ProgressDialog::beginHide() { - shown_ = false; + m_shown = false; fadeTo(0.0, kFadeOutMs); } void ProgressDialog::fadeTo(qreal end, int durationMs) { - fade_->stop(); - fade_->setDuration(durationMs); - fade_->setStartValue(windowOpacity()); - fade_->setEndValue(end); - fade_->start(); + m_fade->stop(); + m_fade->setDuration(durationMs); + m_fade->setStartValue(windowOpacity()); + m_fade->setEndValue(end); + m_fade->start(); } void ProgressDialog::onFadeFinished() { - if (!shown_) { + if (!m_shown) { hide(); } } diff --git a/src/ui/components/progress_dialog.hpp b/src/ui/components/progress_dialog.hpp index 3298dff..269e9cc 100644 --- a/src/ui/components/progress_dialog.hpp +++ b/src/ui/components/progress_dialog.hpp @@ -25,7 +25,7 @@ class ProgressDialog : public QDialog { ProgressDialog(LoadingState& loading, QWidget* parent); ~ProgressDialog() override; - LoadingState& loading() const noexcept { return loading_; } + LoadingState& loading() const noexcept { return m_loading; } private: void onChanged(int pct); @@ -35,16 +35,16 @@ class ProgressDialog : public QDialog { void fadeTo(qreal end, int durationMs); void onFadeFinished(); - LoadingState& loading_; - bool shown_ = false; + LoadingState& m_loading; + bool m_shown = false; - QWidget* crumbs_ = nullptr; - QHBoxLayout* crumbs_layout_ = nullptr; - QProgressBar* progress_ = nullptr; - QTimer* hide_timer_ = nullptr; - QPropertyAnimation* fade_ = nullptr; + QWidget* m_crumbs = nullptr; + QHBoxLayout* m_crumbsLayout = nullptr; + QProgressBar* m_progress = nullptr; + QTimer* m_hideTimer = nullptr; + QPropertyAnimation* m_fade = nullptr; - std::vector connections_; + std::vector m_connections; }; } // namespace px::modrim diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp index 103e999..1de0f85 100644 --- a/src/ui/components/toast.cpp +++ b/src/ui/components/toast.cpp @@ -48,23 +48,23 @@ Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) : QWidge const LevelStyle& style = kLevelStyles[static_cast(level)]; - opacity_effect_ = new QGraphicsOpacityEffect(this); - opacity_effect_->setOpacity(0.0); - setGraphicsEffect(opacity_effect_); + m_opacityEffect = new QGraphicsOpacityEffect(this); + m_opacityEffect->setOpacity(0.0); + setGraphicsEffect(m_opacityEffect); auto* layout = new QHBoxLayout(this); layout->setContentsMargins(12, 10, 8, 10); layout->setSpacing(8); - auto* icon_label = new QLabel(this); - icon_label->setPixmap(requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); - icon_label->setFixedSize(16, 16); - layout->addWidget(icon_label); + auto* iconLabel = new QLabel(this); + iconLabel->setPixmap(requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); + iconLabel->setFixedSize(16, 16); + layout->addWidget(iconLabel); - auto* message_label = new QLabel(message, this); - message_label->setObjectName(QStringLiteral("toastMessage")); - message_label->setWordWrap(true); - layout->addWidget(message_label, 1); + auto* messageLabel = new QLabel(message, this); + messageLabel->setObjectName(QStringLiteral("toastMessage")); + messageLabel->setWordWrap(true); + layout->addWidget(messageLabel, 1); auto* close = new QPushButton(this); close->setObjectName(QStringLiteral("toastClose")); @@ -74,47 +74,47 @@ Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) : QWidge connect(close, &QPushButton::clicked, this, &Toast::dismiss); layout->addWidget(close); - opacity_anim_ = new QPropertyAnimation(opacity_effect_, QByteArrayLiteral("opacity"), this); - opacity_anim_->setDuration(200); - opacity_anim_->setStartValue(0.0); - opacity_anim_->setEndValue(1.0); + m_opacityAnim = new QPropertyAnimation(m_opacityEffect, QByteArrayLiteral("opacity"), this); + m_opacityAnim->setDuration(200); + m_opacityAnim->setStartValue(0.0); + m_opacityAnim->setEndValue(1.0); - fade_out_anim_ = new QPropertyAnimation(opacity_effect_, QByteArrayLiteral("opacity"), this); - fade_out_anim_->setDuration(150); - fade_out_anim_->setEndValue(0.0); - connect(fade_out_anim_, &QPropertyAnimation::finished, this, &Toast::dismissed); + m_fadeOutAnim = new QPropertyAnimation(m_opacityEffect, QByteArrayLiteral("opacity"), this); + m_fadeOutAnim->setDuration(150); + m_fadeOutAnim->setEndValue(0.0); + connect(m_fadeOutAnim, &QPropertyAnimation::finished, this, &Toast::dismissed); } void Toast::showWithAnimation() { show(); raise(); - opacity_anim_->setDirection(QAbstractAnimation::Direction::Forward); - opacity_anim_->start(); + m_opacityAnim->setDirection(QAbstractAnimation::Direction::Forward); + m_opacityAnim->start(); } void Toast::dismiss() { - if (dismissing_) { + if (m_dismissing) { return; } - dismissing_ = true; - opacity_anim_->stop(); - fade_out_anim_->setStartValue(opacity_effect_->opacity()); - fade_out_anim_->start(); + m_dismissing = true; + m_opacityAnim->stop(); + m_fadeOutAnim->setStartValue(m_opacityEffect->opacity()); + m_fadeOutAnim->start(); } ToastManager::ToastManager(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("toastManager")); setAttribute(Qt::WA_TransparentForMouseEvents, true); - layout_ = new QVBoxLayout(this); - layout_->setContentsMargins(0, 0, 16, 16); - layout_->setSpacing(8); - layout_->setAlignment(Qt::AlignBottom | Qt::AlignRight); + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 16, 16); + m_layout->setSpacing(8); + m_layout->setAlignment(Qt::AlignBottom | Qt::AlignRight); } void ToastManager::showToast(const QString& message, ToastLevel level, int durationMs) { auto* toast = new Toast(message, level, this); - layout_->addWidget(toast); + m_layout->addWidget(toast); connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); toast->showWithAnimation(); @@ -125,19 +125,19 @@ void ToastManager::showToast(const QString& message, ToastLevel level, int durat } void ToastManager::info(const QString& message, int durationMs) { - showToast(message, ToastLevel::Info, durationMs); + showToast(message, ToastLevel::kInfo, durationMs); } void ToastManager::success(const QString& message, int durationMs) { - showToast(message, ToastLevel::Success, durationMs); + showToast(message, ToastLevel::kSuccess, durationMs); } void ToastManager::warning(const QString& message, int durationMs) { - showToast(message, ToastLevel::Warning, durationMs); + showToast(message, ToastLevel::kWarning, durationMs); } void ToastManager::error(const QString& message, int durationMs) { - showToast(message, ToastLevel::Error, durationMs); + showToast(message, ToastLevel::kError, durationMs); } void ToastManager::resizeToParent() { @@ -148,7 +148,7 @@ void ToastManager::resizeToParent() { void ToastManager::remove(Toast* toast) { toast->hide(); - layout_->removeWidget(toast); + m_layout->removeWidget(toast); toast->deleteLater(); } diff --git a/src/ui/components/toast.hpp b/src/ui/components/toast.hpp index 26a8f33..c835a7f 100644 --- a/src/ui/components/toast.hpp +++ b/src/ui/components/toast.hpp @@ -11,7 +11,7 @@ class QVBoxLayout; namespace px::modrim { -enum class ToastLevel { Info, Success, Warning, Error }; +enum class ToastLevel { kInfo, kSuccess, kWarning, kError }; // One message card. Fades in on show; dismiss() fades it out and then emits dismissed(). class Toast : public QWidget { @@ -27,10 +27,10 @@ class Toast : public QWidget { void dismissed(); private: - QGraphicsOpacityEffect* opacity_effect_ = nullptr; - QPropertyAnimation* opacity_anim_ = nullptr; - QPropertyAnimation* fade_out_anim_ = nullptr; - bool dismissing_ = false; + QGraphicsOpacityEffect* m_opacityEffect = nullptr; + QPropertyAnimation* m_opacityAnim = nullptr; + QPropertyAnimation* m_fadeOutAnim = nullptr; + bool m_dismissing = false; }; // Stacks toasts bottom-right over its parent. Durations are milliseconds; 0 keeps the toast @@ -41,7 +41,7 @@ class ToastManager : public QWidget, public Notifier { public: explicit ToastManager(QWidget* parent = nullptr); - void showToast(const QString& message, ToastLevel level = ToastLevel::Info, int durationMs = 3000); + void showToast(const QString& message, ToastLevel level = ToastLevel::kInfo, int durationMs = 3000); void info(const QString& message, int durationMs = 3000) override; void success(const QString& message, int durationMs = 3000) override; @@ -54,7 +54,7 @@ class ToastManager : public QWidget, public Notifier { private: void remove(Toast* toast); - QVBoxLayout* layout_ = nullptr; + QVBoxLayout* m_layout = nullptr; }; } // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp index 48bcf8c..fb1c454 100644 --- a/src/ui/components/unity_rich_text.cpp +++ b/src/ui/components/unity_rich_text.cpp @@ -7,7 +7,7 @@ #include namespace px::modrim { -QString escape_html(const QString& text) { +QString escapeHtml(const QString& text) { QString out = text.toHtmlEscaped(); return out.replace('\'', QStringLiteral("'")); } @@ -34,7 +34,7 @@ QString color(QString value) { {"gray", "#808080"}, {"grey", "#808080"}, {"orange", "#ffa500"}, {"purple", "#800080"}}; return names.value(value.toLower(), "#ffffff"); } -QString close_tag(const QString& tag) { +QString closeTag(const QString& tag) { if (tag == "b" || tag == "bold") return ""; if (tag == "i" || tag == "italic") return ""; if (tag == "u" || tag == "underline" || tag == "size") return ""; @@ -45,7 +45,7 @@ QString close_tag(const QString& tag) { if (tag == "mark" || tag == "sub" || tag == "sup") return ""; return {}; } -QString open_tag(const QString& tag, QString attr) { +QString openTag(const QString& tag, QString attr) { attr = unquote(attr); if (tag == "b" || tag == "bold") return ""; if (tag == "i" || tag == "italic") return ""; @@ -98,12 +98,12 @@ QString open_tag(const QString& tag, QString attr) { const QUrl url(attr); const QString scheme = url.scheme().toLower(); if ((scheme == "http" || scheme == "https") && !url.authority().isEmpty()) - return ""; + return ""; } return {}; } } -QString unity_rich_text_to_html(const QString& text) { +QString unityRichTextToHtml(const QString& text) { static const QRegularExpression tags("\\s]+))?\\s*>"); QString result; result.reserve(text.size()); @@ -112,24 +112,24 @@ QString unity_rich_text_to_html(const QString& text) { auto matches = tags.globalMatch(text); while (matches.hasNext()) { const auto match = matches.next(); - result += escape_html(text.mid(end, match.capturedStart() - end)); + result += escapeHtml(text.mid(end, match.capturedStart() - end)); const QString tag = match.captured(1).toLower(); if (match.captured().startsWith("= 0) { while (stack.size() > index) stack.removeLast(); - result += close_tag(tag); + result += closeTag(tag); } } else { - const QString html = open_tag(tag, match.captured(2)); + const QString html = openTag(tag, match.captured(2)); if (!html.isEmpty()) { stack.append(tag); result += html; } } end = match.capturedEnd(); } - result += escape_html(text.mid(end)); + result += escapeHtml(text.mid(end)); result.replace('\n', "
    "); result.replace("\\n", "
    "); - for (auto it = stack.crbegin(); it != stack.crend(); ++it) result += close_tag(*it); + for (auto it = stack.crbegin(); it != stack.crend(); ++it) result += closeTag(*it); return result; } } diff --git a/src/ui/components/unity_rich_text.hpp b/src/ui/components/unity_rich_text.hpp index 9c75d88..50b2d5c 100644 --- a/src/ui/components/unity_rich_text.hpp +++ b/src/ui/components/unity_rich_text.hpp @@ -1,6 +1,6 @@ #pragma once #include namespace px::modrim { -QString escape_html(const QString& text); -QString unity_rich_text_to_html(const QString& text); +QString escapeHtml(const QString& text); +QString unityRichTextToHtml(const QString& text); } diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp index ac0be8b..6adaf62 100644 --- a/src/ui/components/view_rail_panel.cpp +++ b/src/ui/components/view_rail_panel.cpp @@ -15,8 +15,8 @@ ViewRailPanel::ViewRailPanel(const QVariantList& tabs, const QVariantList& helpE : QQuickWidget(engine, parent) { setResizeMode(QQuickWidget::SizeRootObjectToView); setClearColor(paletteColor(u"ELEVATE_2")); - setMinimumWidth(RAIL_MIN_WIDTH); - setMaximumWidth(RAIL_MAX_WIDTH); + setMinimumWidth(kRailMinWidth); + setMaximumWidth(kRailMaxWidth); rootContext()->setContextProperty(QStringLiteral("railModel"), tabs); rootContext()->setContextProperty(QStringLiteral("railHelpEntries"), helpEntries); diff --git a/src/ui/config.cpp b/src/ui/config.cpp index 2d2ef73..30e4a8e 100644 --- a/src/ui/config.cpp +++ b/src/ui/config.cpp @@ -14,27 +14,27 @@ Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc) { return svc.save(kUiPrefsFile, prefs); } -UiPrefsService::UiPrefsService(ConfigService& svc) : svc_(svc) {} +UiPrefsService::UiPrefsService(ConfigService& svc) : m_svc(svc) {} QCoro::Task> UiPrefsService::setup() { - auto loaded = svc_.load(kUiPrefsFile); + auto loaded = m_svc.load(kUiPrefsFile); if (!loaded) { co_return std::unexpected(loaded.error()); } - prefs_ = std::move(*loaded); + m_prefs = std::move(*loaded); co_return {}; } const UiPrefs& UiPrefsService::prefs() const { - Q_ASSERT(prefs_.has_value()); - return *prefs_; + Q_ASSERT(m_prefs.has_value()); + return *m_prefs; } Result UiPrefsService::savePrefs() const { - if (!prefs_) { + if (!m_prefs) { return {}; } - return saveUiPrefs(*prefs_, svc_); + return saveUiPrefs(*m_prefs, m_svc); } } // namespace px::modrim diff --git a/src/ui/config.hpp b/src/ui/config.hpp index d11554a..0d3542c 100644 --- a/src/ui/config.hpp +++ b/src/ui/config.hpp @@ -27,8 +27,8 @@ class UiPrefsService { Result savePrefs() const; private: - ConfigService& svc_; - std::optional prefs_; + ConfigService& m_svc; + std::optional m_prefs; }; } // namespace px::modrim diff --git a/src/ui/context.cpp b/src/ui/context.cpp index fa123fe..aae661c 100644 --- a/src/ui/context.cpp +++ b/src/ui/context.cpp @@ -8,7 +8,7 @@ namespace px::modrim { RailView::RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : QWidget(parent), ctx_(ctx), engine_(engine), app_(app) {} + : QWidget(parent), m_ctx(ctx), m_engine(engine), m_app(app) {} void RailView::openRoute(const QStringList&) {} @@ -17,54 +17,54 @@ void RailView::preload() {} void RailView::teardown() {} AppContext::AppContext(CoreContext& core, UiPrefs prefs) - : core_(core), ui_prefs_(std::move(prefs)) {} + : m_core(core), m_uiPrefs(std::move(prefs)) {} AppContext::~AppContext() = default; void AppContext::registerPlugin(std::unique_ptr plugin) { - plugins_.register_plugin(std::move(plugin)); + m_plugins.registerPlugin(std::move(plugin)); } void AppContext::addSettingsSection(SettingsSectionFactory factory) { - settings_sections_.push_back(std::move(factory)); + m_settingsSections.push_back(std::move(factory)); } void AppContext::navigate(const QString& url) const { - if (navigate_) { - navigate_(url); + if (m_navigate) { + m_navigate(url); } } void AppContext::setNavigator(std::function navigate) { - navigate_ = std::move(navigate); + m_navigate = std::move(navigate); } Result AppContext::setupAll() { - if (auto core = core_.plugins().setup_all(core_); !core) { + if (auto core = m_core.plugins().setupAll(m_core); !core) { return std::unexpected(core.error()); } - return plugins_.setup_all(*this, core_.plugins().names()); + return m_plugins.setupAll(*this, m_core.plugins().names()); } QCoro::Task> AppContext::initAll() { - if (auto core = co_await core_.plugins().init_all(core_); !core) { + if (auto core = co_await m_core.plugins().initAll(m_core); !core) { co_return std::unexpected(core.error()); } - co_return co_await plugins_.init_all(*this); + co_return co_await m_plugins.initAll(*this); } QCoro::Task> AppContext::refreshMods(bool full) { - if (auto reloaded = co_await core_.modService().reload(full); !reloaded) { + if (auto reloaded = co_await m_core.modService().reload(full); !reloaded) { co_return std::unexpected(reloaded.error()); } - co_return static_cast(core_.allMods().size()); + co_return static_cast(m_core.allMods().size()); } QCoro::Task> AppContext::shutdownAll() { - if (auto ui = co_await plugins_.shutdown_all(); !ui) { + if (auto ui = co_await m_plugins.shutdownAll(); !ui) { co_return std::unexpected(ui.error()); } - co_return co_await core_.plugins().shutdown_all(); + co_return co_await m_core.plugins().shutdownAll(); } } // namespace px::modrim diff --git a/src/ui/context.hpp b/src/ui/context.hpp index 61495a8..c1c03eb 100644 --- a/src/ui/context.hpp +++ b/src/ui/context.hpp @@ -67,9 +67,9 @@ class RailView : public QWidget { virtual void teardown(); protected: - CoreContext& ctx_; - QQmlEngine* engine_; - AppContext& app_; + CoreContext& m_ctx; + QQmlEngine* m_engine; + AppContext& m_app; }; using RailViewFactory = std::function; @@ -98,23 +98,23 @@ class AppContext { // ── Plugin system (UI layer) ────────────────────── void registerPlugin(std::unique_ptr plugin); - UiPluginRegistry& plugins() noexcept { return plugins_; } + UiPluginRegistry& plugins() noexcept { return m_plugins; } // ── Rail views ──────────────────────────────────── // Appends, or inserts at `position` when given. template void addRailView(std::optional position = std::nullopt); - const std::vector& railViews() const noexcept { return rail_views_; } + const std::vector& railViews() const noexcept { return m_railViews; } // ── Settings sections ───────────────────────────── // Each section is built per dialog, parented to that dialog. void addSettingsSection(SettingsSectionFactory factory); - const std::vector& settingsSections() const noexcept { return settings_sections_; } + const std::vector& settingsSections() const noexcept { return m_settingsSections; } // ── Toasts ──────────────────────────────────────── // Not owned; nullptr when no toast surface is shown. - Notifier* toasts() const noexcept { return toasts_; } - void setToasts(Notifier* notifier) noexcept { toasts_ = notifier; } + Notifier* toasts() const noexcept { return m_toasts; } + void setToasts(Notifier* notifier) noexcept { m_toasts = notifier; } // ── Navigation ──────────────────────────────────── // Opens a `modrim://[/…]` link; a no-op until a navigator is set. @@ -122,11 +122,11 @@ class AppContext { void setNavigator(std::function navigate); // ── UI prefs ────────────────────────────────────── - UiPrefs& uiPrefs() noexcept { return ui_prefs_; } - const UiPrefs& uiPrefs() const noexcept { return ui_prefs_; } + UiPrefs& uiPrefs() noexcept { return m_uiPrefs; } + const UiPrefs& uiPrefs() const noexcept { return m_uiPrefs; } // ── Core access ─────────────────────────────────── - CoreContext& coreContext() const noexcept { return core_; } + CoreContext& coreContext() const noexcept { return m_core; } // ── Lifecycle ───────────────────────────────────── // Core plugins first, then UI plugins; UI plugins may depend on core plugin names. @@ -138,13 +138,13 @@ class AppContext { QCoro::Task> shutdownAll(); private: - CoreContext& core_; - UiPluginRegistry plugins_; - std::vector rail_views_; - std::vector settings_sections_; - Notifier* toasts_ = nullptr; - std::function navigate_; - UiPrefs ui_prefs_; + CoreContext& m_core; + UiPluginRegistry m_plugins; + std::vector m_railViews; + std::vector m_settingsSections; + Notifier* m_toasts = nullptr; + std::function m_navigate; + UiPrefs m_uiPrefs; }; template @@ -156,9 +156,9 @@ void AppContext::addRailView(std::optional position) { }, }; if (position) { - rail_views_.insert(rail_views_.begin() + static_cast(*position), std::move(spec)); + m_railViews.insert(m_railViews.begin() + static_cast(*position), std::move(spec)); } else { - rail_views_.push_back(std::move(spec)); + m_railViews.push_back(std::move(spec)); } } diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp index 4fe7c31..377b379 100644 --- a/src/ui/mod_selection.cpp +++ b/src/ui/mod_selection.cpp @@ -7,50 +7,50 @@ namespace px::modrim { ModSelectionPresenter::ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink) - : ctx_(ctx), sink_(sink) {} + : m_ctx(ctx), m_sink(sink) {} QCoro::Task ModSelectionPresenter::show(QString uuid) { - const std::uint64_t generation = ++generation_; + const std::uint64_t generation = ++m_generation; if (uuid.isEmpty()) { - sink_.clear(); + m_sink.clear(); co_return; } - const auto& mods = ctx_.allMods(); + const auto& mods = m_ctx.allMods(); const auto found = mods.constFind(uuid); if (found == mods.cend()) { - sink_.clear(); + m_sink.clear(); co_return; } const ListedMod& mod = *found.value(); - sink_.showMod(mod, ctx_.diagnosticsService().issues_for(uuid)); + m_sink.showMod(mod, m_ctx.diagnosticsService().issuesFor(uuid)); std::optional packageId; if (const auto* about = dynamic_cast(&mod)) { - packageId = about->package_id.str(); + packageId = about->packageId.str(); } const QStringList activePackageIds = resolveActivePackageIds(); - if (generation != generation_) { + if (generation != m_generation) { co_return; } - co_await sink_.setTimeAnalytics(packageId, activePackageIds); + co_await m_sink.setTimeAnalytics(packageId, activePackageIds); } void ModSelectionPresenter::clear() { - ++generation_; - sink_.clear(); + ++m_generation; + m_sink.clear(); } QStringList ModSelectionPresenter::resolveActivePackageIds() const { - const QSet active(ctx_.activeUuids().cbegin(), ctx_.activeUuids().cend()); + const QSet active(m_ctx.activeUuids().cbegin(), m_ctx.activeUuids().cend()); QStringList packageIds; - for (const ListedModPtr& mod : ctx_.allMods()) { + for (const ListedModPtr& mod : m_ctx.allMods()) { const auto* about = dynamic_cast(mod.get()); if (about && active.contains(about->uuid())) { - packageIds.append(about->package_id.str()); + packageIds.append(about->packageId.str()); } } return packageIds; diff --git a/src/ui/mod_selection.hpp b/src/ui/mod_selection.hpp index 68cd84c..13f54f9 100644 --- a/src/ui/mod_selection.hpp +++ b/src/ui/mod_selection.hpp @@ -38,9 +38,9 @@ class ModSelectionPresenter { private: QStringList resolveActivePackageIds() const; - CoreContext& ctx_; - ModInfoSink& sink_; - std::uint64_t generation_ = 0; + CoreContext& m_ctx; + ModInfoSink& m_sink; + std::uint64_t m_generation = 0; }; } // namespace px::modrim diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp index bb0f2d2..b27558c 100644 --- a/src/ui/models/impact.cpp +++ b/src/ui/models/impact.cpp @@ -3,17 +3,17 @@ #include "ui/theme/palette.hpp" #include namespace px::modrim { -QString format_duration(double seconds) { +QString formatDuration(double seconds) { if (seconds < 0.001) return QStringLiteral("0 ms"); if (seconds < 1) return QStringLiteral("%1 ms").arg(static_cast(std::nearbyint(seconds * 1000))); if (seconds < 60) return QStringLiteral("%1 s").arg(seconds, 0, 'f', 2); const auto rounded = static_cast(std::nearbyint(seconds)); return QStringLiteral("%1m %2s").arg(rounded / 60).arg(rounded % 60, 2, 10, QLatin1Char('0')); } -QString impact_color(double seconds) { +QString impactColor(double seconds) { using namespace startup_impact; - if (seconds < IMPACT_WARN_THRESHOLD_S) return paletteValue(u"SUCCESS"); - if (seconds < IMPACT_HIGH_THRESHOLD_S) return paletteValue(u"WARNING"); + if (seconds < kImpactWarnThresholdS) return paletteValue(u"SUCCESS"); + if (seconds < kImpactHighThresholdS) return paletteValue(u"WARNING"); return paletteValue(u"DANGER"); } } diff --git a/src/ui/models/impact.hpp b/src/ui/models/impact.hpp index 7858807..0930e55 100644 --- a/src/ui/models/impact.hpp +++ b/src/ui/models/impact.hpp @@ -1,6 +1,6 @@ #pragma once #include namespace px::modrim { -QString format_duration(double seconds); -QString impact_color(double seconds); +QString formatDuration(double seconds); +QString impactColor(double seconds); } diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp index e2210b9..ca6575d 100644 --- a/src/ui/models/mod_list_model.cpp +++ b/src/ui/models/mod_list_model.cpp @@ -6,9 +6,9 @@ #include namespace px::modrim { -QString provider_label(const QString& provider_id, const QString& package_id) { - const auto pid = provider_id.trimmed().toLower(); - const auto pkg = package_id.trimmed().toLower(); +QString providerLabel(const QString& providerId, const QString& packageId) { + const auto pid = providerId.trimmed().toLower(); + const auto pkg = packageId.trimmed().toLower(); if (pid == "steam" || pid == "downloaded") return QStringLiteral("Workshop"); if (pid == "local") return QStringLiteral("Local"); if (pid == "core") return !pkg.isEmpty() && pkg != "ludeon.rimworld" ? QStringLiteral("DLC") : QStringLiteral("Core"); @@ -16,55 +16,55 @@ QString provider_label(const QString& provider_id, const QString& package_id) { if (pkg == "ludeon.rimworld") return QStringLiteral("Core"); return pid.isEmpty() ? pid : pid.first(1).toUpper() + pid.sliced(1); } -QString section_name(bool checked, int active_count, int inactive_count) { - return checked || active_count == 0 || inactive_count == 0 ? QString{} : QStringLiteral("Inactive (%1)").arg(inactive_count); +QString sectionName(bool checked, int activeCount, int inactiveCount) { + return checked || activeCount == 0 || inactiveCount == 0 ? QString{} : QStringLiteral("Inactive (%1)").arg(inactiveCount); } ModListModel::ModListModel(QHash colors, DiagnosticsService* diagnostics, QObject* parent) - : QAbstractListModel(parent), provider_colors_(std::move(colors)), diagnostics_(diagnostics) { - if (diagnostics_) diagnostics_connection_ = diagnostics_->diagnostics_summary_changed.connect( - [this](const auto& summaries) { diagnostics_changed(summaries); }); + : QAbstractListModel(parent), m_providerColors(std::move(colors)), m_diagnostics(diagnostics) { + if (m_diagnostics) m_diagnosticsConnection = m_diagnostics->diagnosticsSummaryChanged.connect( + [this](const auto& summaries) { diagnosticsChanged(summaries); }); } ModListModel::~ModListModel() { - if (diagnostics_) diagnostics_->diagnostics_summary_changed.disconnect(diagnostics_connection_); + if (m_diagnostics) m_diagnostics->diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); } -int ModListModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } -const ModItem* ModListModel::get_item(int row) const { return row >= 0 && row < items_.size() ? &items_[row] : nullptr; } -const ModItem* ModListModel::get_item_by_uuid(const QString& uuid) const { - for (const auto& item : items_) if (item.uuid == uuid) return &item; +int ModListModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_items.size()); } +const ModItem* ModListModel::getItem(int row) const { return row >= 0 && row < m_items.size() ? &m_items[row] : nullptr; } +const ModItem* ModListModel::getItemByUuid(const QString& uuid) const { + for (const auto& item : m_items) if (item.uuid == uuid) return &item; return nullptr; } QVariant ModListModel::data(const QModelIndex& index, int role) const { - const auto* item = index.isValid() ? get_item(index.row()) : nullptr; + const auto* item = index.isValid() ? getItem(index.row()) : nullptr; if (!item) return {}; const auto* about = dynamic_cast(item->mod.get()); switch (role) { case Qt::DisplayRole: return item->mod->name; case Qt::ToolTipRole: return item->mod->description; - case CheckStateRole: return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); - case PackageIdRole: return about ? about->package_id.str() : QString{}; - case ModVersionRole: return about ? about->mod_version : QString{}; - case ProviderColorRole: return item->provider_color; - case UuidRole: return item->uuid; - case StartupImpactRole: return item->startup_impact_s; - case StartupImpactColorRole: return impact_color(item->startup_impact_s); - case StartupImpactTextRole: return format_duration(item->startup_impact_s); - case LoadIndexRole: return item->load_index; - case IsActiveRole: return item->checked; - case SectionNameRole: return section_name(item->checked, active_count_, static_cast(items_.size()) - active_count_); - case ProviderLabelRole: return item->provider_label; - case HasErrorRole: case HasWarningRole: case ErrorTooltipRole: case WarningTooltipRole: { - const auto diag = diagnostics_ ? diagnostics_->summary_for(item->uuid) : std::nullopt; - if (role == HasErrorRole) return diag && diag->has_errors ? 1 : 0; - if (role == HasWarningRole) return diag && diag->has_warnings ? 1 : 0; - if (role == ErrorTooltipRole) return diag ? diag->error_tooltip : QString{}; - return diag ? diag->warning_tooltip : QString{}; + case kCheckStateRole: return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); + case kPackageIdRole: return about ? about->packageId.str() : QString{}; + case kModVersionRole: return about ? about->modVersion : QString{}; + case kProviderColorRole: return item->providerColor; + case kUuidRole: return item->uuid; + case kStartupImpactRole: return item->startupImpactS; + case kStartupImpactColorRole: return impactColor(item->startupImpactS); + case kStartupImpactTextRole: return formatDuration(item->startupImpactS); + case kLoadIndexRole: return item->loadIndex; + case kIsActiveRole: return item->checked; + case kSectionNameRole: return sectionName(item->checked, m_activeCount, static_cast(m_items.size()) - m_activeCount); + case kProviderLabelRole: return item->providerLabel; + case kHasErrorRole: case kHasWarningRole: case kErrorTooltipRole: case kWarningTooltipRole: { + const auto diag = m_diagnostics ? m_diagnostics->summaryFor(item->uuid) : std::nullopt; + if (role == kHasErrorRole) return diag && diag->hasErrors ? 1 : 0; + if (role == kHasWarningRole) return diag && diag->hasWarnings ? 1 : 0; + if (role == kErrorTooltipRole) return diag ? diag->errorTooltip : QString{}; + return diag ? diag->warningTooltip : QString{}; } default: return {}; } } bool ModListModel::setData(const QModelIndex& index, const QVariant& value, int role) { - if (!index.isValid() || !get_item(index.row()) || role != CheckStateRole) return false; - set_check_states({index.row()}, value.toInt() == Qt::Checked); + if (!index.isValid() || !getItem(index.row()) || role != kCheckStateRole) return false; + setCheckStates({index.row()}, value.toInt() == Qt::Checked); return true; } Qt::ItemFlags ModListModel::flags(const QModelIndex& index) const { @@ -72,129 +72,129 @@ Qt::ItemFlags ModListModel::flags(const QModelIndex& index) const { return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | Qt::ItemIsUserCheckable; } QHash ModListModel::roleNames() const { - return {{Qt::DisplayRole,"name"},{CheckStateRole,"checkState"},{PackageIdRole,"packageId"}, - {ModVersionRole,"modVersion"},{ProviderColorRole,"providerColor"},{UuidRole,"uuid"}, - {HasErrorRole,"hasError"},{HasWarningRole,"hasWarning"},{ErrorTooltipRole,"errorTooltip"}, - {WarningTooltipRole,"warningTooltip"},{StartupImpactRole,"startupImpact"},{LoadIndexRole,"loadIndex"}, - {IsActiveRole,"isActive"},{SectionNameRole,"sectionName"},{ProviderLabelRole,"providerLabel"}, - {StartupImpactColorRole,"startupImpactColor"},{StartupImpactTextRole,"startupImpactText"},{Qt::ToolTipRole,"toolTip"}}; -} -void ModListModel::update_provider_colors(QHash colors) { provider_colors_ = std::move(colors); } -ModItem ModListModel::make_item(const QString& uuid, ListedModPtr mod, bool checked) const { + return {{Qt::DisplayRole,"name"},{kCheckStateRole,"checkState"},{kPackageIdRole,"packageId"}, + {kModVersionRole,"modVersion"},{kProviderColorRole,"providerColor"},{kUuidRole,"uuid"}, + {kHasErrorRole,"hasError"},{kHasWarningRole,"hasWarning"},{kErrorTooltipRole,"errorTooltip"}, + {kWarningTooltipRole,"warningTooltip"},{kStartupImpactRole,"startupImpact"},{kLoadIndexRole,"loadIndex"}, + {kIsActiveRole,"isActive"},{kSectionNameRole,"sectionName"},{kProviderLabelRole,"providerLabel"}, + {kStartupImpactColorRole,"startupImpactColor"},{kStartupImpactTextRole,"startupImpactText"},{Qt::ToolTipRole,"toolTip"}}; +} +void ModListModel::updateProviderColors(QHash colors) { m_providerColors = std::move(colors); } +ModItem ModListModel::makeItem(const QString& uuid, ListedModPtr mod, bool checked) const { const auto* about = dynamic_cast(mod.get()); - auto label = provider_label(mod->provider_id, about ? about->package_id.str() : QString{}); - auto color = provider_colors_.value(mod->provider_id, paletteValue(u"TEXT_DIM")); + auto label = providerLabel(mod->providerId, about ? about->packageId.str() : QString{}); + auto color = m_providerColors.value(mod->providerId, paletteValue(u"TEXT_DIM")); return {std::move(mod), uuid, checked, std::move(color), 0, -1, std::move(label)}; } -void ModListModel::load_mods(const QHash& mods, const QStringList& active_uuids) { +void ModListModel::loadMods(const QHash& mods, const QStringList& activeUuids) { beginResetModel(); - items_.clear(); - items_.reserve(mods.size()); - const QSet active(active_uuids.begin(), active_uuids.end()); - for (const auto& uuid : active_uuids) { + m_items.clear(); + m_items.reserve(mods.size()); + const QSet active(activeUuids.begin(), activeUuids.end()); + for (const auto& uuid : activeUuids) { const auto it = mods.constFind(uuid); - if (it != mods.cend()) items_.append(make_item(uuid, it.value(), true)); + if (it != mods.cend()) m_items.append(makeItem(uuid, it.value(), true)); } QList inactive; - inactive.reserve(mods.size() - items_.size()); + inactive.reserve(mods.size() - m_items.size()); for (auto it = mods.cbegin(); it != mods.cend(); ++it) - if (!active.contains(it.key())) inactive.append(make_item(it.key(), it.value(), false)); + if (!active.contains(it.key())) inactive.append(makeItem(it.key(), it.value(), false)); std::stable_sort(inactive.begin(), inactive.end(), [](const auto& a, const auto& b) { return a.mod->name.toLower() < b.mod->name.toLower(); }); - items_.append(std::move(inactive)); - update_load_indices(); + m_items.append(std::move(inactive)); + updateLoadIndices(); endResetModel(); } -QStringList ModListModel::active_uuids() const { +QStringList ModListModel::activeUuids() const { QStringList result; - result.reserve(active_count_); - for (const auto& item : items_) if (item.checked) result.append(item.uuid); + result.reserve(m_activeCount); + for (const auto& item : m_items) if (item.checked) result.append(item.uuid); return result; } -QList ModListModel::update_load_indices() { +QList ModListModel::updateLoadIndices() { QList changed; int count = 0; - for (int row = 0; row < items_.size(); ++row) { - auto& item = items_[row]; + for (int row = 0; row < m_items.size(); ++row) { + auto& item = m_items[row]; const int index = item.checked ? ++count : -1; - if (item.load_index != index) { item.load_index = index; changed.append(row); } + if (item.loadIndex != index) { item.loadIndex = index; changed.append(row); } } - if (active_count_ != count) { active_count_ = count; emit active_count_changed(); } + if (m_activeCount != count) { m_activeCount = count; emit activeCountChanged(); } return changed; } -bool ModListModel::move_row(int source, int target) { - if (source == target || !get_item(source) || !get_item(target)) return false; +bool ModListModel::moveRow(int source, int target) { + if (source == target || !getItem(source) || !getItem(target)) return false; beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); - items_.move(source, target); + m_items.move(source, target); endMoveRows(); - const auto changed = update_load_indices(); - if (!changed.isEmpty()) emit dataChanged(index(changed.first()), index(changed.last()), {LoadIndexRole,SectionNameRole}); + const auto changed = updateLoadIndices(); + if (!changed.isEmpty()) emit dataChanged(index(changed.first()), index(changed.last()), {kLoadIndexRole,kSectionNameRole}); return true; } void ModListModel::commitOrder(const QStringList& uuids) { - QHash old_rows; - for (int row = 0; row < items_.size(); ++row) old_rows.insert(items_[row].uuid, row); + QHash oldRows; + for (int row = 0; row < m_items.size(); ++row) oldRows.insert(m_items[row].uuid, row); QList order; - order.reserve(items_.size()); + order.reserve(m_items.size()); QSet ordered; - for (const auto& uuid : uuids) if (old_rows.contains(uuid)) { order.append(old_rows.value(uuid)); ordered.insert(uuid); } + for (const auto& uuid : uuids) if (oldRows.contains(uuid)) { order.append(oldRows.value(uuid)); ordered.insert(uuid); } QList inactive; - for (int row = 0; row < items_.size(); ++row) if (!ordered.contains(items_[row].uuid)) { - if (items_[row].checked) order.append(row); else inactive.append(row); + for (int row = 0; row < m_items.size(); ++row) if (!ordered.contains(m_items[row].uuid)) { + if (m_items[row].checked) order.append(row); else inactive.append(row); } - std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { return items_[a].mod->name.toLower() < items_[b].mod->name.toLower(); }); + std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { return m_items[a].mod->name.toLower() < m_items[b].mod->name.toLower(); }); order.append(inactive); - bool same = order.size() == items_.size(); + bool same = order.size() == m_items.size(); for (int i = 0; same && i < order.size(); ++i) same = order[i] == i; if (same) return; emit layoutAboutToBeChanged(); const auto persistent = persistentIndexList(); QList next; next.reserve(order.size()); - QHash new_rows; - for (int row = 0; row < order.size(); ++row) { new_rows.insert(order[row], row); next.append(items_[order[row]]); } - items_ = std::move(next); - update_load_indices(); + QHash newRows; + for (int row = 0; row < order.size(); ++row) { newRows.insert(order[row], row); next.append(m_items[order[row]]); } + m_items = std::move(next); + updateLoadIndices(); QModelIndexList replacements; replacements.reserve(persistent.size()); - for (const auto& previous : persistent) replacements.append(index(new_rows.value(previous.row(), -1))); + for (const auto& previous : persistent) replacements.append(index(newRows.value(previous.row(), -1))); changePersistentIndexList(persistent, replacements); emit layoutChanged(); } -void ModListModel::set_checkable(const QSet& uuids, bool checked) { +void ModListModel::setCheckable(const QSet& uuids, bool checked) { QList rows; - for (int row = 0; row < items_.size(); ++row) - if (uuids.contains(items_[row].uuid) && items_[row].checked != checked) rows.append(row); - set_check_states(rows, checked); -} -void ModListModel::set_check_states(const QList& rows, bool checked) { - int first = static_cast(items_.size()); - for (int row : rows) if (get_item(row) && items_[row].checked != checked) { - items_[row].checked = checked; first = std::min(first, row); + for (int row = 0; row < m_items.size(); ++row) + if (uuids.contains(m_items[row].uuid) && m_items[row].checked != checked) rows.append(row); + setCheckStates(rows, checked); +} +void ModListModel::setCheckStates(const QList& rows, bool checked) { + int first = static_cast(m_items.size()); + for (int row : rows) if (getItem(row) && m_items[row].checked != checked) { + m_items[row].checked = checked; first = std::min(first, row); } - if (first == items_.size()) return; - const auto load_changed = update_load_indices(); - if (!load_changed.isEmpty()) first = std::min(first, load_changed.first()); - emit dataChanged(index(first), index(rowCount()-1), {CheckStateRole,LoadIndexRole,IsActiveRole,SectionNameRole}); - emit active_mods_changed(); -} -void ModListModel::set_startup_impact(const QHash& impacts) { - if (items_.isEmpty()) return; - for (auto& item : items_) { + if (first == m_items.size()) return; + const auto loadChanged = updateLoadIndices(); + if (!loadChanged.isEmpty()) first = std::min(first, loadChanged.first()); + emit dataChanged(index(first), index(rowCount()-1), {kCheckStateRole,kLoadIndexRole,kIsActiveRole,kSectionNameRole}); + emit activeModsChanged(); +} +void ModListModel::setStartupImpact(const QHash& impacts) { + if (m_items.isEmpty()) return; + for (auto& item : m_items) { const auto* about = dynamic_cast(item.mod.get()); - item.startup_impact_s = about ? impacts.value(about->package_id.str()) : 0; + item.startupImpactS = about ? impacts.value(about->packageId.str()) : 0; } - emit dataChanged(index(0), index(rowCount()-1), {StartupImpactRole,StartupImpactColorRole,StartupImpactTextRole}); + emit dataChanged(index(0), index(rowCount()-1), {kStartupImpactRole,kStartupImpactColorRole,kStartupImpactTextRole}); } -void ModListModel::diagnostics_changed(const QHash& summaries) { - if (items_.isEmpty()) return; +void ModListModel::diagnosticsChanged(const QHash& summaries) { + if (m_items.isEmpty()) return; int first = rowCount(), last = -1; - for (int row = 0; row < items_.size(); ++row) { - const auto& uuid = items_[row].uuid; - if (last_diagnostics_.contains(uuid) != summaries.contains(uuid) || last_diagnostics_.value(uuid) != summaries.value(uuid)) { + for (int row = 0; row < m_items.size(); ++row) { + const auto& uuid = m_items[row].uuid; + if (m_lastDiagnostics.contains(uuid) != summaries.contains(uuid) || m_lastDiagnostics.value(uuid) != summaries.value(uuid)) { first = std::min(first, row); last = row; } } - last_diagnostics_ = summaries; - if (last >= 0) emit dataChanged(index(first), index(last), {HasErrorRole,HasWarningRole,ErrorTooltipRole,WarningTooltipRole}); + m_lastDiagnostics = summaries; + if (last >= 0) emit dataChanged(index(first), index(last), {kHasErrorRole,kHasWarningRole,kErrorTooltipRole,kWarningTooltipRole}); } } diff --git a/src/ui/models/mod_list_model.hpp b/src/ui/models/mod_list_model.hpp index 08ae2fd..00085a1 100644 --- a/src/ui/models/mod_list_model.hpp +++ b/src/ui/models/mod_list_model.hpp @@ -10,31 +10,31 @@ namespace px::modrim { class DiagnosticsService; } namespace px::modrim { -QString provider_label(const QString& provider_id, const QString& package_id = {}); -QString section_name(bool checked, int active_count, int inactive_count); +QString providerLabel(const QString& providerId, const QString& packageId = {}); +QString sectionName(bool checked, int activeCount, int inactiveCount); struct ModItem { ListedModPtr mod; QString uuid; bool checked = false; - QString provider_color; - double startup_impact_s = 0; - int load_index = -1; - QString provider_label; + QString providerColor; + double startupImpactS = 0; + int loadIndex = -1; + QString providerLabel; }; class ModListModel : public QAbstractListModel { Q_OBJECT - Q_PROPERTY(int activeCount READ active_count NOTIFY active_count_changed) + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) public: enum Role { - CheckStateRole = Qt::UserRole + 1, PackageIdRole, ModVersionRole, ProviderColorRole, - UuidRole, HasErrorRole, HasWarningRole, ErrorTooltipRole, WarningTooltipRole, - StartupImpactRole, LoadIndexRole, IsActiveRole, SectionNameRole, ProviderLabelRole, - StartupImpactColorRole, StartupImpactTextRole + kCheckStateRole = Qt::UserRole + 1, kPackageIdRole, kModVersionRole, kProviderColorRole, + kUuidRole, kHasErrorRole, kHasWarningRole, kErrorTooltipRole, kWarningTooltipRole, + kStartupImpactRole, kLoadIndexRole, kIsActiveRole, kSectionNameRole, kProviderLabelRole, + kStartupImpactColorRole, kStartupImpactTextRole }; Q_ENUM(Role) - explicit ModListModel(QHash provider_colors = {}, + explicit ModListModel(QHash providerColors = {}, DiagnosticsService* diagnostics = nullptr, QObject* parent = nullptr); ~ModListModel() override; int rowCount(const QModelIndex& parent = {}) const override; @@ -42,29 +42,29 @@ class ModListModel : public QAbstractListModel { bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; Qt::ItemFlags flags(const QModelIndex& index) const override; QHash roleNames() const override; - void update_provider_colors(QHash colors); - void load_mods(const QHash& mods, const QStringList& active_uuids); - QStringList active_uuids() const; - int active_count() const { return active_count_; } - const ModItem* get_item(int row) const; - const ModItem* get_item_by_uuid(const QString& uuid) const; - bool move_row(int source_row, int target_row); + void updateProviderColors(QHash colors); + void loadMods(const QHash& mods, const QStringList& activeUuids); + QStringList activeUuids() const; + int activeCount() const { return m_activeCount; } + const ModItem* getItem(int row) const; + const ModItem* getItemByUuid(const QString& uuid) const; + bool moveRow(int sourceRow, int targetRow); void commitOrder(const QStringList& uuids); - void set_checkable(const QSet& uuids, bool checked); - void set_check_states(const QList& rows, bool checked); - void set_startup_impact(const QHash& impacts = {}); + void setCheckable(const QSet& uuids, bool checked); + void setCheckStates(const QList& rows, bool checked); + void setStartupImpact(const QHash& impacts = {}); signals: - void active_mods_changed(); - void active_count_changed(); + void activeModsChanged(); + void activeCountChanged(); private: - QList update_load_indices(); - ModItem make_item(const QString& uuid, ListedModPtr mod, bool checked) const; - void diagnostics_changed(const QHash& summaries); - QHash provider_colors_; - QList items_; - int active_count_ = 0; - DiagnosticsService* diagnostics_; - std::uint64_t diagnostics_connection_ = 0; - QHash last_diagnostics_; + QList updateLoadIndices(); + ModItem makeItem(const QString& uuid, ListedModPtr mod, bool checked) const; + void diagnosticsChanged(const QHash& summaries); + QHash m_providerColors; + QList m_items; + int m_activeCount = 0; + DiagnosticsService* m_diagnostics; + std::uint64_t m_diagnosticsConnection = 0; + QHash m_lastDiagnostics; }; } diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp index 159812f..38d6a1e 100644 --- a/src/ui/models/mod_list_proxy_model.cpp +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -2,136 +2,136 @@ #include #include namespace px::modrim { -ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), source_(source) { - connect(source_, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::source_data_changed); - connect(source_, &QAbstractItemModel::layoutChanged, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); - connect(source_, &QAbstractItemModel::modelReset, this, [this] { beginResetModel(); rebuild(); endResetModel(); }); - connect(source_, &QAbstractItemModel::rowsMoved, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); - connect(source_, &QAbstractItemModel::rowsInserted, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); - connect(source_, &QAbstractItemModel::rowsRemoved, this, [this] { if (!in_proxy_move_) layout_rebuild(); }); +ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) { + connect(m_source, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::sourceDataChanged); + connect(m_source, &QAbstractItemModel::layoutChanged, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); + connect(m_source, &QAbstractItemModel::modelReset, this, [this] { beginResetModel(); rebuild(); endResetModel(); }); + connect(m_source, &QAbstractItemModel::rowsMoved, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); + connect(m_source, &QAbstractItemModel::rowsInserted, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); + connect(m_source, &QAbstractItemModel::rowsRemoved, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); rebuild(); } -int ModListProxyModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(visible_uuids_.size()); } -void ModListProxyModel::rebuild_source_mapping() { - source_rows_.clear(); - for (int row = 0; row < source_->rowCount(); ++row) source_rows_.insert(source_->get_item(row)->uuid, row); +int ModListProxyModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_visibleUuids.size()); } +void ModListProxyModel::rebuildSourceMapping() { + m_sourceRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) m_sourceRows.insert(m_source->getItem(row)->uuid, row); } -bool ModListProxyModel::accept_item(const ModItem& item) const { - if (sidebar_uuids_ && !sidebar_uuids_->contains(item.uuid)) return false; - if (search_text_.isEmpty() || item.mod->name.trimmed().toCaseFolded().contains(search_text_)) return true; +bool ModListProxyModel::acceptItem(const ModItem& item) const { + if (m_sidebarUuids && !m_sidebarUuids->contains(item.uuid)) return false; + if (m_searchText.isEmpty() || item.mod->name.trimmed().toCaseFolded().contains(m_searchText)) return true; const auto* about = dynamic_cast(item.mod.get()); - return about && about->package_id.str().trimmed().toCaseFolded().contains(search_text_); + return about && about->packageId.str().trimmed().toCaseFolded().contains(m_searchText); } void ModListProxyModel::rebuild() { - rebuild_source_mapping(); - visible_uuids_.clear(); proxy_rows_.clear(); - for (int row = 0; row < source_->rowCount(); ++row) { - const auto& item = *source_->get_item(row); - if (accept_item(item)) { proxy_rows_.insert(item.uuid, static_cast(visible_uuids_.size())); visible_uuids_.append(item.uuid); } + rebuildSourceMapping(); + m_visibleUuids.clear(); m_proxyRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) { + const auto& item = *m_source->getItem(row); + if (acceptItem(item)) { m_proxyRows.insert(item.uuid, static_cast(m_visibleUuids.size())); m_visibleUuids.append(item.uuid); } } - update_visible_counts(); + updateVisibleCounts(); } -void ModListProxyModel::update_visible_counts() { +void ModListProxyModel::updateVisibleCounts() { int active = 0; - for (const auto& uuid : visible_uuids_) { - const auto* item = source_->get_item(source_rows_.value(uuid, -1)); + for (const auto& uuid : m_visibleUuids) { + const auto* item = m_source->getItem(m_sourceRows.value(uuid, -1)); if (item && item->checked) ++active; } - const bool changed = active != visible_active_count_; - visible_active_count_ = active; - visible_inactive_count_ = static_cast(visible_uuids_.size()) - active; - if (changed) emit active_count_changed(); + const bool changed = active != m_visibleActiveCount; + m_visibleActiveCount = active; + m_visibleInactiveCount = static_cast(m_visibleUuids.size()) - active; + if (changed) emit activeCountChanged(); } -int ModListProxyModel::proxy_row_for_uuid(const QString& uuid) const { return proxy_rows_.value(uuid, -1); } +int ModListProxyModel::proxyRowForUuid(const QString& uuid) const { return m_proxyRows.value(uuid, -1); } QModelIndex ModListProxyModel::mapToSource(const QModelIndex& index) const { - if (!index.isValid() || index.row() < 0 || index.row() >= visible_uuids_.size()) return {}; - return source_->index(source_rows_.value(visible_uuids_[index.row()], -1)); -} -QModelIndex ModListProxyModel::mapFromSource(const QModelIndex& source_index) const { - const auto* item = source_index.isValid() ? source_->get_item(source_index.row()) : nullptr; - return item ? index(proxy_row_for_uuid(item->uuid)) : QModelIndex{}; -} -QVariant ModListProxyModel::data(const QModelIndex& proxy_index, int role) const { - const auto source_index = mapToSource(proxy_index); - if (!source_index.isValid()) return {}; - if (role == ModListModel::SectionNameRole) { - return section_name(source_->get_item(source_index.row())->checked, visible_active_count_, visible_inactive_count_); + if (!index.isValid() || index.row() < 0 || index.row() >= m_visibleUuids.size()) return {}; + return m_source->index(m_sourceRows.value(m_visibleUuids[index.row()], -1)); +} +QModelIndex ModListProxyModel::mapFromSource(const QModelIndex& sourceIndex) const { + const auto* item = sourceIndex.isValid() ? m_source->getItem(sourceIndex.row()) : nullptr; + return item ? index(proxyRowForUuid(item->uuid)) : QModelIndex{}; +} +QVariant ModListProxyModel::data(const QModelIndex& proxyIndex, int role) const { + const auto sourceIndex = mapToSource(proxyIndex); + if (!sourceIndex.isValid()) return {}; + if (role == ModListModel::kSectionNameRole) { + return sectionName(m_source->getItem(sourceIndex.row())->checked, m_visibleActiveCount, m_visibleInactiveCount); } - return source_->data(source_index, role); + return m_source->data(sourceIndex, role); } bool ModListProxyModel::setData(const QModelIndex& index, const QVariant& value, int role) { - const auto source_index = mapToSource(index); - return source_index.isValid() && source_->setData(source_index, value, role); + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() && m_source->setData(sourceIndex, value, role); } Qt::ItemFlags ModListProxyModel::flags(const QModelIndex& index) const { if (!index.isValid()) return Qt::ItemIsDropEnabled; - const auto source_index = mapToSource(index); - return source_index.isValid() ? source_->flags(source_index) : Qt::NoItemFlags; + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? m_source->flags(sourceIndex) : Qt::NoItemFlags; } -QHash ModListProxyModel::roleNames() const { return source_->roleNames(); } -void ModListProxyModel::layout_rebuild() { +QHash ModListProxyModel::roleNames() const { return m_source->roleNames(); } +void ModListProxyModel::layoutRebuild() { const auto persistent = persistentIndexList(); QStringList uuids; uuids.reserve(persistent.size()); - for (const auto& index : persistent) uuids.append(index.isValid() && index.row() < visible_uuids_.size() ? visible_uuids_[index.row()] : QString{}); + for (const auto& index : persistent) uuids.append(index.isValid() && index.row() < m_visibleUuids.size() ? m_visibleUuids[index.row()] : QString{}); emit layoutAboutToBeChanged(); rebuild(); QModelIndexList replacements; replacements.reserve(uuids.size()); - for (const auto& uuid : uuids) replacements.append(index(proxy_row_for_uuid(uuid))); + for (const auto& uuid : uuids) replacements.append(index(proxyRowForUuid(uuid))); changePersistentIndexList(persistent, replacements); emit layoutChanged(); } -void ModListProxyModel::set_search_filter(const QString& text) { +void ModListProxyModel::setSearchFilter(const QString& text) { const auto normalized = text.trimmed().toCaseFolded(); - if (normalized == search_text_) return; - const bool was_filtered = is_filtered(); - search_text_ = normalized; - layout_rebuild(); - if (was_filtered != is_filtered()) emit is_filtered_changed(); -} -void ModListProxyModel::set_sidebar_filter(std::optional> uuids) { - if (sidebar_uuids_ == uuids) return; - const bool was_filtered = is_filtered(); - sidebar_uuids_ = std::move(uuids); - layout_rebuild(); - if (was_filtered != is_filtered()) emit is_filtered_changed(); -} -bool ModListProxyModel::move_row(int source, int target, bool clamp_to_active) { - if (source < 0 || source >= visible_uuids_.size()) return false; - const auto uuid = visible_uuids_[source]; - const int source_row = source_rows_.value(uuid, -1); - if (clamp_to_active) { - const auto* item = source_->get_item(source_row); - if (is_filtered() || !item || !item->checked) return false; - target = std::max(0, std::min(target, visible_active_count_ - 1)); + if (normalized == m_searchText) return; + const bool wasFiltered = isFiltered(); + m_searchText = normalized; + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); +} +void ModListProxyModel::setSidebarFilter(std::optional> uuids) { + if (m_sidebarUuids == uuids) return; + const bool wasFiltered = isFiltered(); + m_sidebarUuids = std::move(uuids); + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); +} +bool ModListProxyModel::moveRow(int source, int target, bool clampToActive) { + if (source < 0 || source >= m_visibleUuids.size()) return false; + const auto uuid = m_visibleUuids[source]; + const int sourceRow = m_sourceRows.value(uuid, -1); + if (clampToActive) { + const auto* item = m_source->getItem(sourceRow); + if (isFiltered() || !item || !item->checked) return false; + target = std::max(0, std::min(target, m_visibleActiveCount - 1)); } - if (source == target || target < 0 || target >= visible_uuids_.size()) return false; - const int target_row = source_rows_.value(visible_uuids_[target], -1); - if (source_row < 0 || target_row < 0) return false; + if (source == target || target < 0 || target >= m_visibleUuids.size()) return false; + const int targetRow = m_sourceRows.value(m_visibleUuids[target], -1); + if (sourceRow < 0 || targetRow < 0) return false; // Source data signals refer to post-move rows, while the proxy is still moving. - in_proxy_move_ = true; + m_inProxyMove = true; beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); - visible_uuids_.move(source, target); - proxy_rows_.clear(); - for (int row = 0; row < visible_uuids_.size(); ++row) proxy_rows_.insert(visible_uuids_[row], row); - source_->move_row(source_row, target_row); - rebuild_source_mapping(); + m_visibleUuids.move(source, target); + m_proxyRows.clear(); + for (int row = 0; row < m_visibleUuids.size(); ++row) m_proxyRows.insert(m_visibleUuids[row], row); + m_source->moveRow(sourceRow, targetRow); + rebuildSourceMapping(); endMoveRows(); - in_proxy_move_ = false; - emit dataChanged(index(std::min(source,target)), index(std::max(source,target)), {ModListModel::LoadIndexRole,ModListModel::SectionNameRole}); + m_inProxyMove = false; + emit dataChanged(index(std::min(source,target)), index(std::max(source,target)), {ModListModel::kLoadIndexRole,ModListModel::kSectionNameRole}); return true; } -void ModListProxyModel::source_data_changed(const QModelIndex& top, const QModelIndex& bottom, const QList& roles) { - if (in_proxy_move_) return; - if (roles.isEmpty() || roles.contains(ModListModel::CheckStateRole) || roles.contains(ModListModel::IsActiveRole)) update_visible_counts(); +void ModListProxyModel::sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, const QList& roles) { + if (m_inProxyMove) return; + if (roles.isEmpty() || roles.contains(ModListModel::kCheckStateRole) || roles.contains(ModListModel::kIsActiveRole)) updateVisibleCounts(); int first = rowCount(), last = -1; for (int row = top.row(); row <= bottom.row(); ++row) { - const auto* item = source_->get_item(row); + const auto* item = m_source->getItem(row); if (!item) continue; - const int proxy_row = proxy_row_for_uuid(item->uuid); - if (proxy_row < 0) continue; - first = std::min(first, proxy_row); last = std::max(last, proxy_row); + const int proxyRow = proxyRowForUuid(item->uuid); + if (proxyRow < 0) continue; + first = std::min(first, proxyRow); last = std::max(last, proxyRow); } if (last >= 0) emit dataChanged(index(first), index(last), roles); } diff --git a/src/ui/models/mod_list_proxy_model.hpp b/src/ui/models/mod_list_proxy_model.hpp index 4522cee..aa31dc7 100644 --- a/src/ui/models/mod_list_proxy_model.hpp +++ b/src/ui/models/mod_list_proxy_model.hpp @@ -5,8 +5,8 @@ namespace px::modrim { class ModListProxyModel : public QAbstractListModel { Q_OBJECT - Q_PROPERTY(bool isFiltered READ is_filtered NOTIFY is_filtered_changed) - Q_PROPERTY(int activeCount READ active_count NOTIFY active_count_changed) + Q_PROPERTY(bool isFiltered READ isFiltered NOTIFY isFilteredChanged) + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) public: explicit ModListProxyModel(ModListModel* source, QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; @@ -16,29 +16,29 @@ class ModListProxyModel : public QAbstractListModel { QHash roleNames() const override; QModelIndex mapToSource(const QModelIndex& index) const; QModelIndex mapFromSource(const QModelIndex& index) const; - int proxy_row_for_uuid(const QString& uuid) const; - void set_search_filter(const QString& text); - void set_sidebar_filter(std::optional> uuids); - bool is_filtered() const { return !search_text_.isEmpty() || sidebar_uuids_.has_value(); } - int active_count() const { return visible_active_count_; } - bool move_row(int source, int target, bool clamp_to_active = false); + int proxyRowForUuid(const QString& uuid) const; + void setSearchFilter(const QString& text); + void setSidebarFilter(std::optional> uuids); + bool isFiltered() const { return !m_searchText.isEmpty() || m_sidebarUuids.has_value(); } + int activeCount() const { return m_visibleActiveCount; } + bool moveRow(int source, int target, bool clampToActive = false); signals: - void is_filtered_changed(); - void active_count_changed(); + void isFilteredChanged(); + void activeCountChanged(); private: void rebuild(); - void rebuild_source_mapping(); - void layout_rebuild(); - void update_visible_counts(); - bool accept_item(const ModItem& item) const; - void source_data_changed(const QModelIndex& top, const QModelIndex& bottom, const QList& roles); - ModListModel* source_; - QString search_text_; - std::optional> sidebar_uuids_; - QStringList visible_uuids_; - QHash source_rows_, proxy_rows_; - bool in_proxy_move_ = false; - int visible_active_count_ = 0; - int visible_inactive_count_ = 0; + void rebuildSourceMapping(); + void layoutRebuild(); + void updateVisibleCounts(); + bool acceptItem(const ModItem& item) const; + void sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, const QList& roles); + ModListModel* m_source; + QString m_searchText; + std::optional> m_sidebarUuids; + QStringList m_visibleUuids; + QHash m_sourceRows, m_proxyRows; + bool m_inProxyMove = false; + int m_visibleActiveCount = 0; + int m_visibleInactiveCount = 0; }; } diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp index 184192f..ff1abee 100644 --- a/src/ui/models/sidebar_model.cpp +++ b/src/ui/models/sidebar_model.cpp @@ -2,73 +2,73 @@ #include "ui/theme/palette.hpp" namespace px::modrim { SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} -int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(items_.size()); } +int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_items.size()); } QVariant SidebarModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; - const auto& item = items_[index.row()]; + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; + const auto& item = m_items[index.row()]; switch (role) { - case Qt::DisplayRole: case LabelRole: return item.entry.label; - case CountRole: return item.entry.count(); - case BadgeBgRole: return item.badge_bg; - case BadgeFgRole: return item.badge_fg; - case IconRole: return item.icon_name; - case IconColorRole: return item.icon_color; - case SectionRole: return item.section_name; + case Qt::DisplayRole: case kLabelRole: return item.entry.label; + case kCountRole: return item.entry.count(); + case kBadgeBgRole: return item.badgeBg; + case kBadgeFgRole: return item.badgeFg; + case kIconRole: return item.iconName; + case kIconColorRole: return item.iconColor; + case kSectionRole: return item.sectionName; default: return {}; } } QHash SidebarModel::roleNames() const { - return {{LabelRole,"filterLabel"},{CountRole,"count"},{BadgeBgRole,"badgeBg"},{BadgeFgRole,"badgeFg"}, - {IconRole,"iconName"},{IconColorRole,"iconColor"},{SectionRole,"sectionName"}}; + return {{kLabelRole,"filterLabel"},{kCountRole,"count"},{kBadgeBgRole,"badgeBg"},{kBadgeFgRole,"badgeFg"}, + {kIconRole,"iconName"},{kIconColorRole,"iconColor"},{kSectionRole,"sectionName"}}; } -SidebarItem SidebarModel::make_item(const view::SidebarEntry& entry) { +SidebarItem SidebarModel::makeItem(const view::SidebarEntry& entry) { using Kind = view::SidebarEntry::Kind; SidebarItem item{entry,paletteValue(u"ELEVATE_4"),paletteValue(u"TEXT_MUTED"),QStringLiteral("grid"),paletteValue(u"TEXT_DIM"),QStringLiteral("Status")}; switch (entry.kind) { - case Kind::All: break; - case Kind::Active: item.icon_name = QStringLiteral("check-circle"); break; - case Kind::Inactive: item.icon_name = QStringLiteral("ban"); break; - case Kind::WithErrors: - item.badge_bg = paletteValue(u"DANGER_BG_QML"); item.badge_fg = item.icon_color = paletteValue(u"DANGER"); item.icon_name = QStringLiteral("error"); break; - case Kind::WithWarnings: - item.badge_bg = paletteValue(u"WARNING_BG_QML"); item.badge_fg = item.icon_color = paletteValue(u"WARNING"); item.icon_name = QStringLiteral("warning"); break; - case Kind::Provider: { - item.section_name = QStringLiteral("Providers"); - item.icon_name = QStringLiteral("folder"); + case Kind::kAll: break; + case Kind::kActive: item.iconName = QStringLiteral("check-circle"); break; + case Kind::kInactive: item.iconName = QStringLiteral("ban"); break; + case Kind::kWithErrors: + item.badgeBg = paletteValue(u"DANGER_BG_QML"); item.badgeFg = item.iconColor = paletteValue(u"DANGER"); item.iconName = QStringLiteral("error"); break; + case Kind::kWithWarnings: + item.badgeBg = paletteValue(u"WARNING_BG_QML"); item.badgeFg = item.iconColor = paletteValue(u"WARNING"); item.iconName = QStringLiteral("warning"); break; + case Kind::kProvider: { + item.sectionName = QStringLiteral("Providers"); + item.iconName = QStringLiteral("folder"); const auto label = entry.label.toLower(); - if (label.contains("steam") || label.contains("downloaded")) item.icon_name = QStringLiteral("steam"); - else if (label.contains("local")) item.icon_name = QStringLiteral("folder"); - else if (label.contains("core")) item.icon_name = QStringLiteral("grid"); - else if (label.contains("git")) item.icon_name = QStringLiteral("git"); + if (label.contains("steam") || label.contains("downloaded")) item.iconName = QStringLiteral("steam"); + else if (label.contains("local")) item.iconName = QStringLiteral("folder"); + else if (label.contains("core")) item.iconName = QStringLiteral("grid"); + else if (label.contains("git")) item.iconName = QStringLiteral("git"); break; } } return item; } -void SidebarModel::set_entries(const QList& entries) { - beginResetModel(); items_.clear(); items_.reserve(entries.size()); - for (const auto& entry : entries) items_.append(make_item(entry)); +void SidebarModel::setEntries(const QList& entries) { + beginResetModel(); m_items.clear(); m_items.reserve(entries.size()); + for (const auto& entry : entries) m_items.append(makeItem(entry)); endResetModel(); } -void SidebarModel::update_entries(const QList& entries) { - if (entries.size() != items_.size()) { set_entries(entries); return; } +void SidebarModel::updateEntries(const QList& entries) { + if (entries.size() != m_items.size()) { setEntries(entries); return; } QSet changed; for (int row = 0; row < entries.size(); ++row) { - auto next = make_item(entries[row]); - const auto& old = items_[row]; - if (old.entry.label != next.entry.label) changed.insert(LabelRole); - if (old.entry.count() != next.entry.count()) changed.insert(CountRole); - if (old.badge_bg != next.badge_bg) changed.insert(BadgeBgRole); - if (old.badge_fg != next.badge_fg) changed.insert(BadgeFgRole); - if (old.icon_name != next.icon_name) changed.insert(IconRole); - if (old.icon_color != next.icon_color) changed.insert(IconColorRole); - if (old.section_name != next.section_name) changed.insert(SectionRole); - items_[row] = std::move(next); + auto next = makeItem(entries[row]); + const auto& old = m_items[row]; + if (old.entry.label != next.entry.label) changed.insert(kLabelRole); + if (old.entry.count() != next.entry.count()) changed.insert(kCountRole); + if (old.badgeBg != next.badgeBg) changed.insert(kBadgeBgRole); + if (old.badgeFg != next.badgeFg) changed.insert(kBadgeFgRole); + if (old.iconName != next.iconName) changed.insert(kIconRole); + if (old.iconColor != next.iconColor) changed.insert(kIconColorRole); + if (old.sectionName != next.sectionName) changed.insert(kSectionRole); + m_items[row] = std::move(next); } - if (!changed.isEmpty() && !items_.isEmpty()) emit dataChanged(index(0), index(rowCount()-1), changed.values()); + if (!changed.isEmpty() && !m_items.isEmpty()) emit dataChanged(index(0), index(rowCount()-1), changed.values()); } -std::optional SidebarModel::get_entry(int row) const { - if (row < 0 || row >= items_.size()) return std::nullopt; - return items_[row].entry; +std::optional SidebarModel::getEntry(int row) const { + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].entry; } } diff --git a/src/ui/models/sidebar_model.hpp b/src/ui/models/sidebar_model.hpp index 9e9c020..5cf1515 100644 --- a/src/ui/models/sidebar_model.hpp +++ b/src/ui/models/sidebar_model.hpp @@ -5,22 +5,22 @@ namespace px::modrim { struct SidebarItem { view::SidebarEntry entry; - QString badge_bg, badge_fg, icon_name, icon_color, section_name; + QString badgeBg, badgeFg, iconName, iconColor, sectionName; }; class SidebarModel : public QAbstractListModel { Q_OBJECT public: - enum Role { LabelRole = Qt::UserRole + 1, CountRole, BadgeBgRole, BadgeFgRole, IconRole, IconColorRole, SectionRole }; + enum Role { kLabelRole = Qt::UserRole + 1, kCountRole, kBadgeBgRole, kBadgeFgRole, kIconRole, kIconColorRole, kSectionRole }; Q_ENUM(Role) explicit SidebarModel(QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void set_entries(const QList& entries); - void update_entries(const QList& entries); - std::optional get_entry(int row) const; + void setEntries(const QList& entries); + void updateEntries(const QList& entries); + std::optional getEntry(int row) const; private: - static SidebarItem make_item(const view::SidebarEntry& entry); - QList items_; + static SidebarItem makeItem(const view::SidebarEntry& entry); + QList m_items; }; } diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp index 3f482b8..6bd0dd5 100644 --- a/src/ui/navigation.cpp +++ b/src/ui/navigation.cpp @@ -5,7 +5,7 @@ namespace px::modrim { -QString Route::url() const { return buildRoute(view_id, path); } +QString Route::url() const { return buildRoute(viewId, path); } QString buildRoute(const QString& viewId, const QStringList& path) { QString url = QStringLiteral("%1://%2").arg(QString(kRouteScheme), viewId); diff --git a/src/ui/navigation.hpp b/src/ui/navigation.hpp index 4f39538..46b4468 100644 --- a/src/ui/navigation.hpp +++ b/src/ui/navigation.hpp @@ -13,7 +13,7 @@ inline constexpr QLatin1StringView kRouteScheme{"modrim"}; inline constexpr QLatin1StringView kSettingsViewId{"settings"}; struct Route { - QString view_id; + QString viewId; QStringList path; QString url() const; diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp index 8dd6ac0..f52ad45 100644 --- a/src/ui/panels/about_panel.cpp +++ b/src/ui/panels/about_panel.cpp @@ -66,7 +66,7 @@ QString AboutPanel::creditsHtml() { } AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) - : QDialog(parent), ctx_(ctx), toast_(toast) { + : QDialog(parent), m_ctx(ctx), m_toast(toast) { setObjectName(QStringLiteral("aboutPanel")); setWindowTitle(tr("About PxModRim")); setModal(true); @@ -198,13 +198,13 @@ AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) copy->setObjectName(QStringLiteral("appBtn")); connect(copy, &QPushButton::clicked, this, [this] { QApplication::clipboard()->setText(systemInformation()); - toast_.success(tr("System info copied")); + m_toast.success(tr("System info copied")); }); actions->addWidget(copy); - upload_ = new QPushButton(tr("Upload log && system info…"), this); - upload_->setObjectName(QStringLiteral("appBtn")); - connect(upload_, &QPushButton::clicked, this, [this] { uploadReport(); }); - actions->addWidget(upload_); + m_upload = new QPushButton(tr("Upload log && system info…"), this); + m_upload->setObjectName(QStringLiteral("appBtn")); + connect(m_upload, &QPushButton::clicked, this, [this] { uploadReport(); }); + actions->addWidget(m_upload); auto* showCredits = new QPushButton(tr("Open-source credits"), this); showCredits->setObjectName(QStringLiteral("appBtn")); connect(showCredits, &QPushButton::clicked, credits, &QDialog::open); @@ -225,9 +225,9 @@ AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) } QCoro::Task AboutPanel::uploadReport() { - QPointer button(upload_); - upload_->setEnabled(false); - co_await handle_upload_report(this, ctx_, toast_); + QPointer button(m_upload); + m_upload->setEnabled(false); + co_await handleUploadReport(this, m_ctx, m_toast); if (button) button->setEnabled(true); } } // namespace px::modrim diff --git a/src/ui/panels/about_panel.hpp b/src/ui/panels/about_panel.hpp index 981797f..17aff88 100644 --- a/src/ui/panels/about_panel.hpp +++ b/src/ui/panels/about_panel.hpp @@ -17,8 +17,8 @@ class AboutPanel : public QDialog { private: QCoro::Task uploadReport(); - CoreContext& ctx_; - Notifier& toast_; - QPushButton* upload_; + CoreContext& m_ctx; + Notifier& m_toast; + QPushButton* m_upload; }; } // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.hpp b/src/ui/panels/keyboard_shortcuts_dialog.hpp index 23077a4..54a9bd0 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.hpp +++ b/src/ui/panels/keyboard_shortcuts_dialog.hpp @@ -6,7 +6,7 @@ #include namespace px::modrim { -inline const QList> QML_SHORTCUTS{ +inline const QList> kQmlShortcuts{ {QStringLiteral("Up / Down"), QStringLiteral("Navigate mod list")}, {QStringLiteral("Shift+Up / Shift+Down"), QStringLiteral("Extend mod selection")}, {QStringLiteral("Return / Space"), QStringLiteral("Toggle selected mods")}, diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp index b63fd4a..4d81269 100644 --- a/src/ui/panels/mod_info_data.cpp +++ b/src/ui/panels/mod_info_data.cpp @@ -28,30 +28,30 @@ QRegularExpression insensitive(const QString& pattern) { return QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); } } -QString bbcode_images(QString text) { +QString bbcodeImages(QString text) { static const auto re = insensitive(R"(\[img\]((https?)://[^\s\[<"]+)\[/img\])"); return substitute(text, re, [](const auto& match) { const QString url = match.captured(1); return match.captured(2).compare("https", Qt::CaseInsensitive) == 0 - ? "" + ? "" : "
    " + url + ""; }); } -QString restore_entities(QString text) { +QString restoreEntities(QString text) { static const QRegularExpression re(R"(&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});)"); return text.replace(re, "&\\1;"); } -QString bbcode_markup(QString text) { - static const auto named_url = insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); - static const auto bare_url = insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); +QString bbcodeMarkup(QString text) { + static const auto namedUrl = insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); + static const auto bareUrl = insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); static const auto quote = insensitive(R"(\[quote=([^\]]+)\])"); static const auto table = insensitive(R"(\[table[^\]]*\])"); static const auto strip = insensitive(R"(\[/?(?:spoiler|noparse)\])"); static const auto hr = insensitive(R"(\[hr\])"); static const auto paired = insensitive(R"(\[(/?)(b|i|u|s|strike|h1|h2|h3|code|quote|list|olist|table|tr|td|th)\])"); static const QHash names{{"strike", "s"}, {"code", "pre"}, {"quote", "blockquote"}, {"list", "ul"}, {"olist", "ol"}}; - text.replace(named_url, "\\2"); - text.replace(bare_url, "\\1"); + text.replace(namedUrl, "\\2"); + text.replace(bareUrl, "\\1"); text.replace(quote, "
    \\1 wrote:
    "); text.replace(table, "
  • "); text.replace(strip, ""); text.replace(hr, "
    "); text.replace("[*]", "
  • "); text = substitute(text, paired, [](const auto& match) { @@ -66,7 +66,7 @@ QString bbcode_markup(QString text) { QString autolink(const QString& text) { static const QRegularExpression tags("<[^>]+>"); static const QRegularExpression urls(R"(https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>"])+)"); - const auto link_text = [](const QString& part) { + const auto linkText = [](const QString& part) { return substitute(part, urls, [](const auto& match) { const QString url = match.captured(); QString stripped = url; @@ -81,7 +81,7 @@ QString autolink(const QString& text) { while (matches.hasNext()) { const auto match = matches.next(); const QString plain = text.mid(end, match.capturedStart() - end); - out += anchor ? plain : link_text(plain); + out += anchor ? plain : linkText(plain); const QString tag = match.captured(), lowered = tag.toLower(); if (lowered.startsWith("") anchor = true; else if (lowered == "") anchor = false; @@ -89,32 +89,32 @@ QString autolink(const QString& text) { end = match.capturedEnd(); } const QString tail = text.mid(end); - return out + (anchor ? tail : link_text(tail)); + return out + (anchor ? tail : linkText(tail)); } -QString description_to_html(const QString& text, const QString& description_format) { - if (description_format != "bbcode") return escape_html(text).replace('\n', "
    "); - return autolink(bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text))))); +QString descriptionToHtml(const QString& text, const QString& descriptionFormat) { + if (descriptionFormat != "bbcode") return escapeHtml(text).replace('\n', "
    "); + return autolink(bbcodeMarkup(bbcodeImages(restoreEntities(unityRichTextToHtml(text))))); } -QString format_age(double mtime, std::optional now) { +QString formatAge(double mtime, std::optional now) { if (mtime <= 0) return {}; const double delta = now.value_or(QDateTime::currentMSecsSinceEpoch() / 1000.0) - mtime; - static constexpr std::array, 5> units{{{"year", 365 * 86400}, {"month", 30 * 86400}, {"week", 7 * 86400}, {"day", 86400}, {"hour", 3600}}}; - for (const auto& [name, seconds] : units) if (delta >= seconds) { + static constexpr std::array, 5> kUnits{{{"year", 365 * 86400}, {"month", 30 * 86400}, {"week", 7 * 86400}, {"day", 86400}, {"hour", 3600}}}; + for (const auto& [name, seconds] : kUnits) if (delta >= seconds) { const auto count = static_cast(delta / seconds); return QString::number(count) + " " + name + (count == 1 ? "" : "s") + " ago"; } return "just now"; } -QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QList& issues) { +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, const QList& issues) { const auto* about = dynamic_cast(&mod); const auto& active = ctx.activeUuids(); const auto position = active.indexOf(mod.uuid()); - QVariantList issue_rows, needs, needed_by, details; + QVariantList issueRows, needs, neededBy, details; QStringList conflicts; int errors = 0; for (const auto& issue : issues) { - errors += issue.is_error; - issue_rows.append(QVariantMap{{"title", issue.category_display_name}, {"detail", issue.detail.value_or(QString{})}, {"isError", issue.is_error}}); + errors += issue.isError; + issueRows.append(QVariantMap{{"title", issue.categoryDisplayName}, {"detail", issue.detail.value_or(QString{})}, {"isError", issue.isError}}); } const auto warnings = issues.size() - errors; const QString level = errors ? "error" : warnings ? "warning" : "ok"; @@ -123,31 +123,31 @@ QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QList index; for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get())) - if (!index.contains(other->package_id.str())) index.insert(other->package_id.str(), other); - for (const auto& dep : about->about_rules.dependencies) { - auto* found = index.value(dep.package_id.str(), nullptr); - if (!found) for (const auto& alternative : dep.alternative_package_ids) { + if (!index.contains(other->packageId.str())) index.insert(other->packageId.str(), other); + for (const auto& dep : about->aboutRules.dependencies) { + auto* found = index.value(dep.packageId.str(), nullptr); + if (!found) for (const auto& alternative : dep.alternativePackageIds) { found = index.value(alternative.str(), nullptr); if (found) break; } - needs.append(QVariantMap{{"name", dep.name.isEmpty() ? dep.package_id.str() : dep.name}, + needs.append(QVariantMap{{"name", dep.name.isEmpty() ? dep.packageId.str() : dep.name}, {"state", !found ? "missing" : active.contains(found->uuid()) ? "active" : "inactive"}, {"uuid", found ? found->uuid() : QString{}}}); } - for (const auto& pid : about->about_rules.incompatible_with) { + for (const auto& pid : about->aboutRules.incompatibleWith) { const auto* other = index.value(pid.str(), nullptr); if (other && active.contains(other->uuid())) conflicts.append(other->name); } std::sort(conflicts.begin(), conflicts.end(), [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); QList dependents; for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get()); other && other->uuid() != mod.uuid()) { - for (const auto& dep : other->about_rules.dependencies) - if (dep.package_id == about->package_id || dep.alternative_package_ids.contains(about->package_id)) { dependents.append(other); break; } + for (const auto& dep : other->aboutRules.dependencies) + if (dep.packageId == about->packageId || dep.alternativePackageIds.contains(about->packageId)) { dependents.append(other); break; } } std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { return a->name.toLower() < b->name.toLower(); }); - for (const auto* other : dependents) needed_by.append(QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + for (const auto* other : dependents) neededBy.append(QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); } auto& downloads = ctx.downloads(); - auto* source = downloads.source_of(mod); + auto* source = downloads.sourceOf(mod); const auto row = [&details](const QString& label, const QString& value, bool copy = false) { QVariantMap item{{"label", label}, {"value", value}}; if (copy) item.insert("copy", true); @@ -156,20 +156,20 @@ QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QListauthors.isEmpty()) row("Author", about->authors.join(", ")); - if (!about->mod_version.isEmpty()) row("Version", about->mod_version); - provider = provider_label(mod.provider_id, about->package_id.str()); + if (!about->modVersion.isEmpty()) row("Version", about->modVersion); + provider = providerLabel(mod.providerId, about->packageId.str()); if (!provider.isEmpty()) row("Source", provider); - if (about->mtime > 0) row("Updated", format_age(about->mtime)); - if (const auto synced = downloads.last_synced(mod); synced && *synced != 0) row("Last synced", format_age(*synced)); - row("Package ID", about->package_id.str(), true); + if (about->mtime > 0) row("Updated", formatAge(about->mtime)); + if (const auto synced = downloads.lastSynced(mod); synced && *synced != 0) row("Last synced", formatAge(*synced)); + row("Package ID", about->packageId.str(), true); } - if (mod.mod_path()) row("Path", *mod.mod_path(), true); + if (mod.modPath()) row("Path", *mod.modPath(), true); const QString target = ctx.targetVersion(); - const bool known = !mod.supported_versions.isEmpty() && target != "Unknown"; + const bool known = !mod.supportedVersions.isEmpty() && target != "Unknown"; return {{"name", mod.name}, {"author", about ? about->authors.join(", ") : QString{}}, {"provider", provider}, - {"status", QVariantMap{{"level", level}, {"title", title}, {"active", position >= 0}, {"position", position >= 0 ? position + 1 : 0}, {"total", active.size()}, {"issues", issue_rows}, {"obsolete", mod.obsolete}, {"valid", mod.valid}}}, - {"version", QVariantMap{{"known", known}, {"ok", known && mod.supported_versions.contains(target)}, {"target", target}}}, - {"needs", needs}, {"conflicts", conflicts}, {"neededBy", needed_by}, {"description", description_to_html(mod.description)}, {"details", details}, - {"canOpenFolder", mod.mod_path() && QFileInfo::exists(*mod.mod_path())}, {"canUpdate", source != nullptr}, {"updateSource", source ? source->label() : QString{}}, {"url", about ? about->url : QString{}}}; + {"status", QVariantMap{{"level", level}, {"title", title}, {"active", position >= 0}, {"position", position >= 0 ? position + 1 : 0}, {"total", active.size()}, {"issues", issueRows}, {"obsolete", mod.obsolete}, {"valid", mod.valid}}}, + {"version", QVariantMap{{"known", known}, {"ok", known && mod.supportedVersions.contains(target)}, {"target", target}}}, + {"needs", needs}, {"conflicts", conflicts}, {"neededBy", neededBy}, {"description", descriptionToHtml(mod.description)}, {"details", details}, + {"canOpenFolder", mod.modPath() && QFileInfo::exists(*mod.modPath())}, {"canUpdate", source != nullptr}, {"updateSource", source ? source->label() : QString{}}, {"url", about ? about->url : QString{}}}; } } diff --git a/src/ui/panels/mod_info_data.hpp b/src/ui/panels/mod_info_data.hpp index f119e98..bb7876a 100644 --- a/src/ui/panels/mod_info_data.hpp +++ b/src/ui/panels/mod_info_data.hpp @@ -4,12 +4,12 @@ #include "core/context.hpp" #include "core/models/view/diagnostics.hpp" namespace px::modrim { -inline const QString IMG_WIDTH_TOKEN = QStringLiteral("__IMG_WIDTH__"); -QString bbcode_images(QString text); -QString restore_entities(QString text); -QString bbcode_markup(QString text); +inline const QString kImgWidthToken = QStringLiteral("__IMG_WIDTH__"); +QString bbcodeImages(QString text); +QString restoreEntities(QString text); +QString bbcodeMarkup(QString text); QString autolink(const QString& text); -QString description_to_html(const QString& text, const QString& description_format = QStringLiteral("bbcode")); -QString format_age(double mtime, std::optional now = std::nullopt); -QVariantMap build_mod_info(CoreContext& ctx, const ListedMod& mod, const QList& issues); +QString descriptionToHtml(const QString& text, const QString& descriptionFormat = QStringLiteral("bbcode")); +QString formatAge(double mtime, std::optional now = std::nullopt); +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, const QList& issues); } diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp index 98d4f32..c99781c 100644 --- a/src/ui/panels/mod_info_panel.cpp +++ b/src/ui/panels/mod_info_panel.cpp @@ -32,44 +32,44 @@ class ModInfoBanner final : public QWidget { public: explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } bool hasHeightForWidth() const override { return true; } - int heightForWidth(int width) const override { return std::min(width * BANNER_ASPECT_H / BANNER_ASPECT_W, BANNER_MAX_HEIGHT); } + int heightForWidth(int width) const override { return std::min(width * kBannerAspectH / kBannerAspectW, kBannerMaxHeight); } QSize sizeHint() const override { const int w = width() ? width() : 300; return {w, heightForWidth(w)}; } - void setTitle(QString title, QString subtitle) { title_ = std::move(title); subtitle_ = std::move(subtitle); update(); } - void setImage(QImage image, bool overlay) { image_ = std::move(image); overlay_ = overlay; rescale(); update(); } + void setTitle(QString title, QString subtitle) { m_title = std::move(title); m_subtitle = std::move(subtitle); update(); } + void setImage(QImage image, bool overlay) { m_image = std::move(image); m_overlay = overlay; rescale(); update(); } protected: void resizeEvent(QResizeEvent* event) override { rescale(); QWidget::resizeEvent(event); } void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.setRenderHint(QPainter::SmoothPixmapTransform); painter.setClipRect(rect()); - if (scaled_.isNull()) painter.fillRect(rect(), panelBgQ()); - else painter.drawImage((width() - scaled_.width()) / 2, (height() - scaled_.height()) / 2, scaled_); - if (overlay_ && !title_.isEmpty()) { - QLinearGradient gradient(0, height() - BANNER_OVERLAY_HEIGHT, 0, height()); + if (m_scaled.isNull()) painter.fillRect(rect(), panelBgQ()); + else painter.drawImage((width() - m_scaled.width()) / 2, (height() - m_scaled.height()) / 2, m_scaled); + if (m_overlay && !m_title.isEmpty()) { + QLinearGradient gradient(0, height() - kBannerOverlayHeight, 0, height()); gradient.setColorAt(0, QColor(0, 0, 0, 0)); gradient.setColorAt(1, QColor(0, 0, 0, 180)); - painter.fillRect(0, height() - BANNER_OVERLAY_HEIGHT, width(), BANNER_OVERLAY_HEIGHT, gradient); - const int top = height() - BANNER_OVERLAY_HEIGHT + 12; - int title_height = BANNER_OVERLAY_HEIGHT - 24; - if (!subtitle_.isEmpty()) title_height = title_height * 3 / 5; + painter.fillRect(0, height() - kBannerOverlayHeight, width(), kBannerOverlayHeight, gradient); + const int top = height() - kBannerOverlayHeight + 12; + int titleHeight = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) titleHeight = titleHeight * 3 / 5; auto font = painter.font(); font.setPointSize(18); font.setBold(true); painter.setFont(font); painter.setPen(textMainQ()); - painter.drawText(QRect(16, top, width() - 32, title_height), Qt::AlignLeft | Qt::AlignBottom, title_); - if (!subtitle_.isEmpty()) { + painter.drawText(QRect(16, top, width() - 32, titleHeight), Qt::AlignLeft | Qt::AlignBottom, m_title); + if (!m_subtitle.isEmpty()) { font.setPointSize(12); font.setBold(false); painter.setFont(font); painter.setPen(textMutedQ()); - painter.drawText(QRect(16, top + title_height + 2, width() - 32, BANNER_OVERLAY_HEIGHT - title_height - 26), Qt::AlignLeft | Qt::AlignTop, subtitle_); + painter.drawText(QRect(16, top + titleHeight + 2, width() - 32, kBannerOverlayHeight - titleHeight - 26), Qt::AlignLeft | Qt::AlignTop, m_subtitle); } } } private: void rescale() { - scaled_ = image_.isNull() || width() <= 0 ? QImage{} : image_.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + m_scaled = m_image.isNull() || width() <= 0 ? QImage{} : m_image.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); } - QImage image_, scaled_; - QString title_, subtitle_; - bool overlay_ = true; + QImage m_image, m_scaled; + QString m_title, m_subtitle; + bool m_overlay = true; }; namespace { -QString first_sentence(QString text) { +QString firstSentence(QString text) { static const QRegularExpression markup(R"(\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\])", QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); text.replace(markup, " "); text = QTextDocumentFragment::fromHtml(text).toPlainText().simplified(); @@ -85,19 +85,19 @@ QString first_sentence(QString text) { } } ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent) - : QWidget(parent), ctx_(ctx), engine_(engine), prefs_(prefs), banner_(new ModInfoBanner(this)), - placeholder_(new QLabel("Select a mod to view details", this)), qml_(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { + : QWidget(parent), m_ctx(ctx), m_engine(engine), m_prefs(prefs), m_banner(new ModInfoBanner(this)), + m_placeholder(new QLabel("Select a mod to view details", this)), m_qml(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - banner_->hide(); layout->addWidget(banner_, 0, Qt::AlignTop); - placeholder_->setAlignment(Qt::AlignCenter); placeholder_->setObjectName("placeholder"); - layout->addWidget(placeholder_, 0, Qt::AlignTop); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(paletteColor(u"ELEVATE_2")); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); - qml_->hide(); layout->addWidget(qml_, 1); - if (auto* root = qml_->rootObject()) { - root->setProperty("descExpanded", prefs_.desc_expanded); + m_banner->hide(); layout->addWidget(m_banner, 0, Qt::AlignTop); + m_placeholder->setAlignment(Qt::AlignCenter); m_placeholder->setObjectName("placeholder"); + layout->addWidget(m_placeholder, 0, Qt::AlignTop); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); + m_qml->hide(); layout->addWidget(m_qml, 1); + if (auto* root = m_qml->rootObject()) { + root->setProperty("descExpanded", m_prefs.desc_expanded); connect(root, SIGNAL(openFolder()), this, SLOT(openFolder())); connect(root, SIGNAL(openUrl()), this, SLOT(openUrl())); connect(root, SIGNAL(selectMod(QString)), this, SIGNAL(modRequested(QString))); @@ -106,107 +106,107 @@ ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, connect(root, SIGNAL(descToggled(bool)), this, SLOT(descToggled(bool))); connect(root, SIGNAL(updateMods()), this, SLOT(updateMod())); } - setQml("downloadsBusy", ctx_.downloads().is_downloading()); - busyConnection_ = ctx_.downloads().busy_changed.connect([this](bool busy) { setQml("downloadsBusy", busy); syncUpdating(); }); + setQml("downloadsBusy", m_ctx.downloads().isDownloading()); + m_busyConnection = m_ctx.downloads().busyChanged.connect([this](bool busy) { setQml("downloadsBusy", busy); syncUpdating(); }); } -ModInfoPanel::~ModInfoPanel() { ctx_.downloads().busy_changed.disconnect(busyConnection_); } -void ModInfoPanel::setQml(const char* name, const QVariant& value) { if (auto* root = qml_->rootObject()) root->setProperty(name, value); } +ModInfoPanel::~ModInfoPanel() { m_ctx.downloads().busyChanged.disconnect(m_busyConnection); } +void ModInfoPanel::setQml(const char* name, const QVariant& value) { if (auto* root = m_qml->rootObject()) root->setProperty(name, value); } void ModInfoPanel::showMod(const ListedMod& mod, const QList& issues) { - const auto token = ++previewToken_; - mod_ = ctx_.allMods().value(mod.uuid()); - if (!mod_) { - if (const auto* about = dynamic_cast(&mod)) mod_ = std::make_shared(*about); - else mod_ = std::make_shared(mod); + const auto token = ++m_previewToken; + m_mod = m_ctx.allMods().value(mod.uuid()); + if (!m_mod) { + if (const auto* about = dynamic_cast(&mod)) m_mod = std::make_shared(*about); + else m_mod = std::make_shared(mod); } - placeholder_->hide(); banner_->show(); qml_->show(); + m_placeholder->hide(); m_banner->show(); m_qml->show(); const auto* about = dynamic_cast(&mod); - banner_->setTitle(mod.name, !mod.description.isEmpty() ? first_sentence(mod.description) : about ? about->package_id.str() : QString{}); - ++startupToken_; + m_banner->setTitle(mod.name, !mod.description.isEmpty() ? firstSentence(mod.description) : about ? about->packageId.str() : QString{}); + ++m_startupToken; setQml("startup", QVariant{}); setIssues(issues); syncUpdating(); - if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, "resetScroll"); - loadPreview(mod.mod_path(), mod.name, token); + if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, "resetScroll"); + loadPreview(mod.modPath(), mod.name, token); } void ModInfoPanel::setIssues(const QList& issues) { - if (!mod_) return; - auto data = build_mod_info(ctx_, *mod_, issues); + if (!m_mod) return; + auto data = buildModInfo(m_ctx, *m_mod, issues); const QString description = data.take("description").toString(); setQml("info", data); - if (description != description_) { description_ = description; setQml("description", description); } + if (description != m_description) { m_description = description; setQml("description", description); } } void ModInfoPanel::clear() { - mod_.reset(); ++previewToken_; ++startupToken_; - placeholder_->show(); banner_->hide(); qml_->hide(); - description_.clear(); startupPid_.reset(); startupActivePids_.clear(); + m_mod.reset(); ++m_previewToken; ++m_startupToken; + m_placeholder->show(); m_banner->hide(); m_qml->hide(); + m_description.clear(); m_startupPid.reset(); m_startupActivePids.clear(); setQml("info", QVariant{}); setQml("description", QString{}); setQml("startup", QVariant{}); } void ModInfoPanel::syncUpdating() { - const auto id = mod_ ? ctx_.downloads().updatable_id(*mod_) : std::nullopt; - setQml("updatingThis", id && ctx_.downloads().active_ids().contains(*id)); + const auto id = m_mod ? m_ctx.downloads().updatableId(*m_mod) : std::nullopt; + setQml("updatingThis", id && m_ctx.downloads().activeIds().contains(*id)); } -void ModInfoPanel::openFolder() { if (mod_ && mod_->mod_path()) QDesktopServices::openUrl(QUrl::fromLocalFile(*mod_->mod_path())); } +void ModInfoPanel::openFolder() { if (m_mod && m_mod->modPath()) QDesktopServices::openUrl(QUrl::fromLocalFile(*m_mod->modPath())); } void ModInfoPanel::openUrl() { - const auto* about = dynamic_cast(mod_.get()); + const auto* about = dynamic_cast(m_mod.get()); if (!about) return; const QUrl url(about->url); const auto scheme = url.scheme().toLower(); if (scheme == "http" || scheme == "https") QDesktopServices::openUrl(url); } void ModInfoPanel::copyText(const QString& text) { QGuiApplication::clipboard()->setText(text); } -void ModInfoPanel::descToggled(bool expanded) { prefs_.desc_expanded = expanded; if (ctx_.hasConfigService()) saveUiPrefs(prefs_, ctx_.configService()); } +void ModInfoPanel::descToggled(bool expanded) { m_prefs.desc_expanded = expanded; if (m_ctx.hasConfigService()) saveUiPrefs(m_prefs, m_ctx.configService()); } void ModInfoPanel::updateMod() { - if (!mod_) return; - if (auto id = ctx_.downloads().updatable_id(*mod_)) performUpdate(*id); + if (!m_mod) return; + if (auto id = m_ctx.downloads().updatableId(*m_mod)) performUpdate(*id); } QCoro::Task ModInfoPanel::performUpdate(QString id) { - const auto result = co_await ctx_.downloads().download_mods({id}); + const auto result = co_await m_ctx.downloads().downloadMods({id}); if (!result) qWarning().noquote() << "Mod update not started:" << result.error().message; } QCoro::Task ModInfoPanel::loadPreview(std::optional path, QString title, quint64 token) { QPointer guard(this); - const int width = banner_->width() ? banner_->width() : 300; - const int height = banner_->heightForWidth(width); + const int width = m_banner->width() ? m_banner->width() : 300; + const int height = m_banner->heightForWidth(width); auto future = QtConcurrent::run([path = std::move(path), title = std::move(title), width, height] { QImage image; if (path) image.load(*path + "/About/Preview.png"); const bool overlay = image.isNull(); - if (overlay) image = generate_preview(title, width, height); + if (overlay) image = generatePreview(title, width, height); return std::pair{image, overlay}; }); auto [image, overlay] = co_await future; - if (guard && token == guard->previewToken_) guard->banner_->setImage(std::move(image), overlay); + if (guard && token == guard->m_previewToken) guard->m_banner->setImage(std::move(image), overlay); } QCoro::Task ModInfoPanel::setTimeAnalytics(const std::optional& pid, const QStringList& activePids) { - const auto token = ++startupToken_; - startupPid_ = pid; startupActivePids_ = activePids; + const auto token = ++m_startupToken; + m_startupPid = pid; m_startupActivePids = activePids; if (!pid) { setQml("startup", QVariantMap{{"available", false}, {"message", "Startup impact is unavailable."}}); co_return; } QPointer guard(this); const auto selectedPid = *pid; const auto active = activePids; - auto result = co_await ctx_.modService().startup_impact().snapshot(active, selectedPid); - if (!guard || token != guard->startupToken_) co_return; + auto result = co_await m_ctx.modService().startupImpact().snapshot(active, selectedPid); + if (!guard || token != guard->m_startupToken) co_return; if (!result || !result->report) { guard->setQml("startup", QVariantMap{{"available", false}, {"message", result ? QStringLiteral("No startup data recorded yet. Launch the game to generate it.") : result.error().message}}); co_return; } const auto& report = *result->report; const auto* mod = report.find(selectedPid, std::nullopt); - const double own = result->selected_avg; + const double own = result->selectedAvg; if (!mod || own < .001) { guard->setQml("startup", QVariantMap{{"available", false}, {"message", "This mod wasn't measured in the last recorded launch."}}); co_return; } int rank = 1; - for (const auto& entry : report.mods) rank += entry.total_impact_s > own; + for (const auto& entry : report.mods) rank += entry.totalImpactS > own; QList> metrics; for (auto it = mod->metrics.cbegin(); it != mod->metrics.cend(); ++it) metrics.append({it.key(), it.value()}); std::stable_sort(metrics.begin(), metrics.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); QVariantList rows; for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) if (metrics[i].second >= .001) - rows.append(QVariantMap{{"label", startup_impact::metric_label(metrics[i].first)}, {"value", format_duration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); - guard->setQml("startup", QVariantMap{{"available", true}, {"own", format_duration(own)}, {"rank", QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, - {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, {"color", impact_color(own)}, {"rows", rows}}); + rows.append(QVariantMap{{"label", startup_impact::metricLabel(metrics[i].first)}, {"value", formatDuration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); + guard->setQml("startup", QVariantMap{{"available", true}, {"own", formatDuration(own)}, {"rank", QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, + {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, {"color", impactColor(own)}, {"rows", rows}}); } void ModInfoPanel::openStartupDetails() { showStartupDetails(); } QCoro::Task ModInfoPanel::showStartupDetails() { - if (!startupDialog_) startupDialog_ = new StartupImpactDialog(&ctx_.modService().startup_impact(), engine_, this); - startupDialog_->show(); startupDialog_->raise(); startupDialog_->activateWindow(); - co_await startupDialog_->panel->setData(startupPid_, startupActivePids_); + if (!m_startupDialog) m_startupDialog = new StartupImpactDialog(&m_ctx.modService().startupImpact(), m_engine, this); + m_startupDialog->show(); m_startupDialog->raise(); m_startupDialog->activateWindow(); + co_await m_startupDialog->panel->setData(m_startupPid, m_startupActivePids); } } diff --git a/src/ui/panels/mod_info_panel.hpp b/src/ui/panels/mod_info_panel.hpp index 2847ede..3eefced 100644 --- a/src/ui/panels/mod_info_panel.hpp +++ b/src/ui/panels/mod_info_panel.hpp @@ -34,19 +34,19 @@ private slots: QCoro::Task loadPreview(std::optional path, QString title, quint64 token); QCoro::Task performUpdate(QString id); QCoro::Task showStartupDetails(); - CoreContext& ctx_; - QQmlEngine* engine_; - UiPrefs& prefs_; - ListedModPtr mod_; - ModInfoBanner* banner_; - QLabel* placeholder_; - QQuickWidget* qml_; - QString description_; - quint64 previewToken_ = 0; - quint64 startupToken_ = 0; - std::optional startupPid_; - QStringList startupActivePids_; - QPointer startupDialog_; - Event::Id busyConnection_; + CoreContext& m_ctx; + QQmlEngine* m_engine; + UiPrefs& m_prefs; + ListedModPtr m_mod; + ModInfoBanner* m_banner; + QLabel* m_placeholder; + QQuickWidget* m_qml; + QString m_description; + quint64 m_previewToken = 0; + quint64 m_startupToken = 0; + std::optional m_startupPid; + QStringList m_startupActivePids; + QPointer m_startupDialog; + Event::Id m_busyConnection; }; } diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp index f467772..4697899 100644 --- a/src/ui/panels/mod_list_panel.cpp +++ b/src/ui/panels/mod_list_panel.cpp @@ -20,132 +20,132 @@ #include namespace px::modrim { ModListPanel::ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) - : QWidget(parent), ctx_(ctx), model_(new ModListModel({}, &ctx.diagnosticsService(), this)), - proxy_(new ModListProxyModel(model_, this)), qml_(new QQuickWidget(engine, this)), - compact_mode_(ctx.config().compact_mod_list), highlight_timer_(new QTimer(this)) { + : QWidget(parent), m_ctx(ctx), m_model(new ModListModel({}, &ctx.diagnosticsService(), this)), + m_proxy(new ModListProxyModel(m_model, this)), m_qml(new QQuickWidget(engine, this)), + m_compactMode(ctx.config().compact_mod_list), m_highlightTimer(new QTimer(this)) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); auto* search = new QWidget(this); search->setObjectName(QStringLiteral("searchBox")); search->setFixedHeight(56); - auto* search_layout = new QHBoxLayout(search); search_layout->setContentsMargins(16,8,16,8); - search_input = new QLineEdit(search); - search_input->setObjectName(QStringLiteral("searchInput")); - search_input->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); - search_input->setClearButtonEnabled(true); - connect(search_input, &QLineEdit::textChanged, this, &ModListPanel::set_search_filter); - if (const auto search_icon = icon(u"search",16,paletteValue(u"TEXT_DIM"))) search_input->addAction(*search_icon,QLineEdit::LeadingPosition); - search_layout->addWidget(search_input); layout->addWidget(search); - connect(model_, &ModListModel::active_mods_changed, this, &ModListPanel::active_mods_changed); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setAttribute(Qt::WA_AlwaysStackOnTop,false); - qml_->setClearColor(paletteColor(u"ELEVATE_1")); qml_->setAcceptDrops(true); - qml_->rootContext()->setContextProperty(QStringLiteral("modListPanel"),this); - qml_->rootContext()->setContextProperty(QStringLiteral("modListModel"),proxy_); - qml_->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),false); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); - layout->addWidget(qml_); - qml_->setFocusPolicy(Qt::StrongFocus); qml_->installEventFilter(this); - highlight_timer_->setSingleShot(true); highlight_timer_->setInterval(1200); - connect(highlight_timer_, &QTimer::timeout, this, [this] { set_highlighted({}); }); - active_connection_ = ctx_.activeStateChanged().connect([this](const auto& active) { core_state_changed(active); }); - config_connection_ = ctx_.configChanged().connect([this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); - mods_connection_ = ctx_.modService().mods_changed.connect([this](const auto&) { refresh(); }); + auto* searchLayout = new QHBoxLayout(search); searchLayout->setContentsMargins(16,8,16,8); + searchInput = new QLineEdit(search); + searchInput->setObjectName(QStringLiteral("searchInput")); + searchInput->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); + searchInput->setClearButtonEnabled(true); + connect(searchInput, &QLineEdit::textChanged, this, &ModListPanel::setSearchFilter); + if (const auto searchIcon = icon(u"search",16,paletteValue(u"TEXT_DIM"))) searchInput->addAction(*searchIcon,QLineEdit::LeadingPosition); + searchLayout->addWidget(searchInput); layout->addWidget(search); + connect(m_model, &ModListModel::activeModsChanged, this, &ModListPanel::activeModsChanged); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop,false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); m_qml->setAcceptDrops(true); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListPanel"),this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListModel"),m_proxy); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),false); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); + layout->addWidget(m_qml); + m_qml->setFocusPolicy(Qt::StrongFocus); m_qml->installEventFilter(this); + m_highlightTimer->setSingleShot(true); m_highlightTimer->setInterval(1200); + connect(m_highlightTimer, &QTimer::timeout, this, [this] { setHighlighted({}); }); + m_activeConnection = m_ctx.activeStateChanged().connect([this](const auto& active) { coreStateChanged(active); }); + m_configConnection = m_ctx.configChanged().connect([this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); + m_modsConnection = m_ctx.modService().modsChanged.connect([this](const auto&) { refresh(); }); } ModListPanel::~ModListPanel() { - ctx_.activeStateChanged().disconnect(active_connection_); - ctx_.configChanged().disconnect(config_connection_); - ctx_.modService().mods_changed.disconnect(mods_connection_); + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.configChanged().disconnect(m_configConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); // QML bindings refer to the models and panel during destruction. - delete qml_; + delete m_qml; } -void ModListPanel::load_mods(const QHash& mods, const QStringList& active) { model_->load_mods(mods,active); } -QStringList ModListPanel::active_uuids() const { return model_->active_uuids(); } -void ModListPanel::set_sidebar_filter(std::optional> uuids) { proxy_->set_sidebar_filter(std::move(uuids)); } -void ModListPanel::set_search_filter(const QString& text) { proxy_->set_search_filter(text); } +void ModListPanel::loadMods(const QHash& mods, const QStringList& active) { m_model->loadMods(mods,active); } +QStringList ModListPanel::activeUuids() const { return m_model->activeUuids(); } +void ModListPanel::setSidebarFilter(std::optional> uuids) { m_proxy->setSidebarFilter(std::move(uuids)); } +void ModListPanel::setSearchFilter(const QString& text) { m_proxy->setSearchFilter(text); } void ModListPanel::setCompactMode(bool enabled) { - if (compact_mode_ == enabled) return; - compact_mode_ = enabled; emit compact_mode_changed(enabled); + if (m_compactMode == enabled) return; + m_compactMode = enabled; emit compactModeChanged(enabled); } -bool ModListPanel::isHighlighted(const QString& uuid) const { return highlighted_.contains(uuid); } -void ModListPanel::modSelected(const QString& uuid) { emit mod_selected(uuid); } -int ModListPanel::proxy_to_source_row(int row) const { return proxy_->mapToSource(proxy_->index(row)).row(); } -QString ModListPanel::uuidAt(int row) const { const auto* item = model_->get_item(proxy_to_source_row(row)); return item ? item->uuid : QString{}; } -int ModListPanel::rowForUuid(const QString& uuid) const { return proxy_->proxy_row_for_uuid(uuid); } -bool ModListPanel::isChecked(int row) const { const auto* item = model_->get_item(proxy_to_source_row(row)); return item && item->checked; } -bool ModListPanel::select_uuid(const QString& uuid) { +bool ModListPanel::isHighlighted(const QString& uuid) const { return m_highlighted.contains(uuid); } +void ModListPanel::modSelected(const QString& uuid) { emit modPicked(uuid); } +int ModListPanel::proxyToSourceRow(int row) const { return m_proxy->mapToSource(m_proxy->index(row)).row(); } +QString ModListPanel::uuidAt(int row) const { const auto* item = m_model->getItem(proxyToSourceRow(row)); return item ? item->uuid : QString{}; } +int ModListPanel::rowForUuid(const QString& uuid) const { return m_proxy->proxyRowForUuid(uuid); } +bool ModListPanel::isChecked(int row) const { const auto* item = m_model->getItem(proxyToSourceRow(row)); return item && item->checked; } +bool ModListPanel::selectUuid(const QString& uuid) { const int row = rowForUuid(uuid); - auto* root = qml_->rootObject(); + auto* root = m_qml->rootObject(); if (row < 0 || !root) return false; return QMetaObject::invokeMethod(root,"revealRow",Qt::DirectConnection,Q_ARG(QVariant,QVariant(row)),Q_ARG(QVariant,QVariant(uuid))); } void ModListPanel::clearSelection() { - auto* root = qml_->rootObject(); + auto* root = m_qml->rootObject(); auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; if (view) { view->setProperty("currentIndex",-1); view->setProperty("anchorIndex",-1); view->setProperty("selectedIndices",QVariantList{}); view->setProperty("selectedUuids",QVariantList{}); view->setProperty("currentUuid",QString{}); view->setProperty("anchorUuid",QString{}); } - emit selection_changed({}); emit mod_selected({}); + emit selectionUpdated({}); emit modPicked({}); } QCoro::Task ModListPanel::refresh() { - model_->update_provider_colors(ctx_.modService().provider_colors()); - model_->load_mods(ctx_.allMods(),ctx_.activeUuids()); + m_model->updateProviderColors(m_ctx.modService().providerColors()); + m_model->loadMods(m_ctx.allMods(),m_ctx.activeUuids()); QPointer self(this); - const auto impacts = co_await ctx_.modService().startup_impact().get_all_averages(); - if (self && impacts) self->model_->set_startup_impact(*impacts); + const auto impacts = co_await m_ctx.modService().startupImpact().getAllAverages(); + if (self && impacts) self->m_model->setStartupImpact(*impacts); } QCoro::QmlTask ModListPanel::toggleChecked(const QVariantList& rows) { QList selected; selected.reserve(rows.size()); for (const auto& row : rows) selected.append(row.toInt()); - return toggle_rows(std::move(selected)); + return toggleRows(std::move(selected)); } -QCoro::Task ModListPanel::toggle_rows(QList rows) { +QCoro::Task ModListPanel::toggleRows(QList rows) { QStringList selected; for (const int row : rows) { const auto uuid = uuidAt(row); if (!uuid.isEmpty()) selected.append(uuid); } if (selected.isEmpty()) co_return; - const QSet previous(ctx_.activeUuids().begin(),ctx_.activeUuids().end()); + const QSet previous(m_ctx.activeUuids().begin(),m_ctx.activeUuids().end()); QPointer self(this); - co_await toggle_mods(ctx_,this,selected); + co_await toggleMods(m_ctx,this,selected); if (!self) co_return; - QStringList newly_enabled; - for (const auto& uuid : selected) if (!previous.contains(uuid) && ctx_.activeUuids().contains(uuid)) newly_enabled.append(uuid); - if (!newly_enabled.isEmpty()) highlight_rows(newly_enabled); + QStringList newlyEnabled; + for (const auto& uuid : selected) if (!previous.contains(uuid) && m_ctx.activeUuids().contains(uuid)) newlyEnabled.append(uuid); + if (!newlyEnabled.isEmpty()) highlightRows(newlyEnabled); } -void ModListPanel::set_highlighted(const QStringList& uuids) { - highlighted_ = QSet(uuids.begin(),uuids.end()); ++highlight_revision_; emit highlighted_changed(); +void ModListPanel::setHighlighted(const QStringList& uuids) { + m_highlighted = QSet(uuids.begin(),uuids.end()); ++m_highlightRevision; emit highlightedChanged(); } -void ModListPanel::highlight_rows(const QStringList& uuids) { +void ModListPanel::highlightRows(const QStringList& uuids) { int last = -1; for (const auto& uuid : uuids) if (const int row = rowForUuid(uuid); row >= 0) last = row; - auto* root = qml_->rootObject(); + auto* root = m_qml->rootObject(); if (last < 0 || !root) return; if (auto* view = root->findChild(QStringLiteral("listView"))) QMetaObject::invokeMethod(view,"positionViewAtIndex",Qt::DirectConnection,Q_ARG(int,last),Q_ARG(int,4)); - set_highlighted(uuids); highlight_timer_->start(); + setHighlighted(uuids); m_highlightTimer->start(); } void ModListPanel::showContextMenu(const QVariantList& values) { - if (!ctx_.downloads().available()) return; + if (!m_ctx.downloads().available()) return; QStringList uuids; uuids.reserve(values.size()); for (const auto& value : values) uuids.append(value.toString()); auto* menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); menu->setToolTipsVisible(true); - add_update_action(menu,ctx_,uuids,[this,uuids] { update_mods(ctx_,uuids,this); }); + addUpdateAction(menu,m_ctx,uuids,[this,uuids] { updateMods(m_ctx,uuids,this); }); menu->popup(QCursor::pos()); } -void ModListPanel::core_state_changed(const QStringList& uuids) { - const auto current_list = model_->active_uuids(); - const QSet current(current_list.begin(),current_list.end()), next(uuids.begin(),uuids.end()); - model_->set_checkable(next - current,true); model_->set_checkable(current - next,false); model_->commitOrder(uuids); +void ModListPanel::coreStateChanged(const QStringList& uuids) { + const auto currentList = m_model->activeUuids(); + const QSet current(currentList.begin(),currentList.end()), next(uuids.begin(),uuids.end()); + m_model->setCheckable(next - current,true); m_model->setCheckable(current - next,false); m_model->commitOrder(uuids); } -void ModListPanel::commitOrder(const QStringList& uuids) { model_->commitOrder(uuids); ctx_.setActive(model_->active_uuids()); emit order_changed(); } -void ModListPanel::moveRow(int source, int target) { proxy_->move_row(source,target,true); } -void ModListPanel::dragEnded() { ctx_.setActive(model_->active_uuids()); emit order_changed(); } +void ModListPanel::commitOrder(const QStringList& uuids) { m_model->commitOrder(uuids); m_ctx.setActive(m_model->activeUuids()); emit orderChanged(); } +void ModListPanel::moveRow(int source, int target) { m_proxy->moveRow(source,target,true); } +void ModListPanel::dragEnded() { m_ctx.setActive(m_model->activeUuids()); emit orderChanged(); } void ModListPanel::selectionChanged(const QVariantList& values) { QStringList uuids; for (const auto& value : values) if (!value.toString().isEmpty()) uuids.append(value.toString()); - emit selection_changed(uuids); + emit selectionUpdated(uuids); } bool ModListPanel::eventFilter(QObject* object, QEvent* event) { - if (object == qml_ && (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) - qml_->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),event->type() == QEvent::FocusIn); + if (object == m_qml && (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),event->type() == QEvent::FocusIn); return QWidget::eventFilter(object,event); } } diff --git a/src/ui/panels/mod_list_panel.hpp b/src/ui/panels/mod_list_panel.hpp index 34d0975..28c6c73 100644 --- a/src/ui/panels/mod_list_panel.hpp +++ b/src/ui/panels/mod_list_panel.hpp @@ -12,32 +12,32 @@ namespace px::modrim { class CoreContext; } namespace px::modrim { class ModListPanel : public QWidget { Q_OBJECT - Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY compact_mode_changed) - Q_PROPERTY(int highlightRevision READ highlightRevision NOTIFY highlighted_changed) + Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY compactModeChanged) + Q_PROPERTY(int highlightRevision READ highlightRevision NOTIFY highlightedChanged) public: ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); ~ModListPanel() override; - void load_mods(const QHash& mods, const QStringList& active); - QStringList active_uuids() const; - ModListModel* model() const { return model_; } - ModListProxyModel* proxy_model() const { return proxy_; } - QQuickWidget* qml_widget() const { return qml_; } - void set_sidebar_filter(std::optional> uuids); - void set_search_filter(const QString& text); - bool select_uuid(const QString& uuid); - bool compactMode() const { return compact_mode_; } - int highlightRevision() const { return highlight_revision_; } - void highlight_rows(const QStringList& uuids); - QCoro::Task toggle_rows(QList rows); + void loadMods(const QHash& mods, const QStringList& active); + QStringList activeUuids() const; + ModListModel* model() const { return m_model; } + ModListProxyModel* proxyModel() const { return m_proxy; } + QQuickWidget* qmlWidget() const { return m_qml; } + void setSidebarFilter(std::optional> uuids); + void setSearchFilter(const QString& text); + bool selectUuid(const QString& uuid); + bool compactMode() const { return m_compactMode; } + int highlightRevision() const { return m_highlightRevision; } + void highlightRows(const QStringList& uuids); + QCoro::Task toggleRows(QList rows); QCoro::Task refresh(); - QLineEdit* search_input; + QLineEdit* searchInput; Q_INVOKABLE void setCompactMode(bool enabled); Q_INVOKABLE bool isHighlighted(const QString& uuid) const; Q_INVOKABLE void modSelected(const QString& uuid); Q_INVOKABLE void clearSelection(); Q_INVOKABLE QString uuidAt(int row) const; Q_INVOKABLE int rowForUuid(const QString& uuid) const; - Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggle_rows({row}); } + Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggleRows({row}); } Q_INVOKABLE QCoro::QmlTask toggleChecked(const QVariantList& rows); Q_INVOKABLE void showContextMenu(const QVariantList& uuids); Q_INVOKABLE bool isChecked(int row) const; @@ -46,26 +46,26 @@ class ModListPanel : public QWidget { Q_INVOKABLE void dragEnded(); Q_INVOKABLE void selectionChanged(const QVariantList& uuids); signals: - void mod_selected(const QString& uuid); - void active_mods_changed(); - void order_changed(); - void selection_changed(const QStringList& uuids); - void compact_mode_changed(bool enabled); - void highlighted_changed(); + void modPicked(const QString& uuid); + void activeModsChanged(); + void orderChanged(); + void selectionUpdated(const QStringList& uuids); + void compactModeChanged(bool enabled); + void highlightedChanged(); protected: bool eventFilter(QObject* object, QEvent* event) override; private: - int proxy_to_source_row(int row) const; - void core_state_changed(const QStringList& uuids); - void set_highlighted(const QStringList& uuids); - CoreContext& ctx_; - ModListModel* model_; - ModListProxyModel* proxy_; - QQuickWidget* qml_; - bool compact_mode_; - QSet highlighted_; - int highlight_revision_ = 0; - QTimer* highlight_timer_; - std::uint64_t active_connection_, config_connection_, mods_connection_; + int proxyToSourceRow(int row) const; + void coreStateChanged(const QStringList& uuids); + void setHighlighted(const QStringList& uuids); + CoreContext& m_ctx; + ModListModel* m_model; + ModListProxyModel* m_proxy; + QQuickWidget* m_qml; + bool m_compactMode; + QSet m_highlighted; + int m_highlightRevision = 0; + QTimer* m_highlightTimer; + std::uint64_t m_activeConnection, m_configConnection, m_modsConnection; }; } diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp index cf288f2..499796c 100644 --- a/src/ui/panels/restore_snapshot_dialog.cpp +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -49,11 +49,11 @@ std::optional timestampFromStem(const QString& stem) { return std::nullopt; } const QDate day(date.left(4).toInt(), date.mid(4, 2).toInt(), date.mid(6, 2).toInt()); - const QTime time_of_day(time.left(2).toInt(), time.mid(2, 2).toInt(), time.mid(4, 2).toInt()); - if (!day.isValid() || !time_of_day.isValid()) { + const QTime timeOfDay(time.left(2).toInt(), time.mid(2, 2).toInt(), time.mid(4, 2).toInt()); + if (!day.isValid() || !timeOfDay.isValid()) { return std::nullopt; } - return QDateTime(day, time_of_day, QTimeZone::utc()); + return QDateTime(day, timeOfDay, QTimeZone::utc()); } QString formatUtc(const QDateTime& when) { @@ -77,9 +77,9 @@ RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, QWidg setWindowTitle(QStringLiteral("Restore Mod List")); resize(560, 420); - snapshots_.reserve(static_cast(snapshots.size())); + m_snapshots.reserve(static_cast(snapshots.size())); for (const QString& path : snapshots) { - snapshots_.push_back(Snapshot{path, parse_mods_config(path)}); + m_snapshots.push_back(Snapshot{path, parseModsConfig(path)}); } auto* layout = new QVBoxLayout(this); @@ -89,47 +89,47 @@ RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, QWidg instruction->setWordWrap(true); layout->addWidget(instruction); - list_ = new QListWidget(this); + m_list = new QListWidget(this); populateList(); - layout->addWidget(list_); + layout->addWidget(m_list); - details_ = new QLabel(this); - details_->setWordWrap(true); - layout->addWidget(details_); + m_details = new QLabel(this); + m_details->setWordWrap(true); + layout->addWidget(m_details); auto* buttons = new QHBoxLayout(); buttons->addStretch(); - restore_ = new QPushButton(QStringLiteral("Restore"), this); - restore_->setObjectName(QStringLiteral("primaryAction")); - restore_->setEnabled(false); - connect(restore_, &QPushButton::clicked, this, &RestoreSnapshotDialog::onRestoreClicked); + m_restore = new QPushButton(QStringLiteral("Restore"), this); + m_restore->setObjectName(QStringLiteral("primaryAction")); + m_restore->setEnabled(false); + connect(m_restore, &QPushButton::clicked, this, &RestoreSnapshotDialog::onRestoreClicked); auto* cancel = new QPushButton(QStringLiteral("Cancel"), this); cancel->setObjectName(QStringLiteral("appBtn")); connect(cancel, &QPushButton::clicked, this, &QDialog::reject); buttons->addWidget(cancel); - buttons->addWidget(restore_); + buttons->addWidget(m_restore); layout->addLayout(buttons); - connect(list_, &QListWidget::currentItemChanged, this, &RestoreSnapshotDialog::onCurrentItemChanged); - connect(list_, &QListWidget::itemDoubleClicked, this, &RestoreSnapshotDialog::onRestoreClicked); + connect(m_list, &QListWidget::currentItemChanged, this, &RestoreSnapshotDialog::onCurrentItemChanged); + connect(m_list, &QListWidget::itemDoubleClicked, this, &RestoreSnapshotDialog::onRestoreClicked); // Connected before selecting, so the initial selection updates the details and the button. - if (list_->count() > 0) { - list_->setCurrentRow(0); + if (m_list->count() > 0) { + m_list->setCurrentRow(0); } } std::optional RestoreSnapshotDialog::selectedSnapshot() const { - return selected_; + return m_selected; } void RestoreSnapshotDialog::populateList() { - for (std::size_t i = 0; i < snapshots_.size(); ++i) { - const Snapshot& snapshot = snapshots_[i]; - auto* item = new QListWidgetItem(list_); + for (std::size_t i = 0; i < m_snapshots.size(); ++i) { + const Snapshot& snapshot = m_snapshots[i]; + auto* item = new QListWidgetItem(m_list); const QString timestamp = snapshotTimestamp(snapshot.path); if (snapshot.parsed) { @@ -150,31 +150,31 @@ void RestoreSnapshotDialog::populateList() { void RestoreSnapshotDialog::onCurrentItemChanged(QListWidgetItem* current) { if (current == nullptr) { - selected_.reset(); - restore_->setEnabled(false); - details_->clear(); + m_selected.reset(); + m_restore->setEnabled(false); + m_details->clear(); return; } - const Snapshot& snapshot = snapshots_[static_cast(current->data(Qt::UserRole).toInt())]; + const Snapshot& snapshot = m_snapshots[static_cast(current->data(Qt::UserRole).toInt())]; const QString name = QFileInfo(snapshot.path).fileName(); if (snapshot.parsed) { - selected_ = snapshot.path; - restore_->setEnabled(true); - details_->setText(QStringLiteral("File: %1
    Active mods: %2 | Game version: %3") + m_selected = snapshot.path; + m_restore->setEnabled(true); + m_details->setText(QStringLiteral("File: %1
    Active mods: %2 | Game version: %3") .arg(name) .arg(QString::number(snapshot.parsed->activeMods.size())) .arg(snapshot.parsed->version)); } else { - selected_.reset(); - restore_->setEnabled(false); - details_->setText(QStringLiteral("This saved mod list cannot be read. %1 will not be restored.") + m_selected.reset(); + m_restore->setEnabled(false); + m_details->setText(QStringLiteral("This saved mod list cannot be read. %1 will not be restored.") .arg(name)); } } void RestoreSnapshotDialog::onRestoreClicked() { - if (selected_) { + if (m_selected) { accept(); } } diff --git a/src/ui/panels/restore_snapshot_dialog.hpp b/src/ui/panels/restore_snapshot_dialog.hpp index 6752203..abcf7fe 100644 --- a/src/ui/panels/restore_snapshot_dialog.hpp +++ b/src/ui/panels/restore_snapshot_dialog.hpp @@ -41,11 +41,11 @@ private slots: void populateList(); - std::vector snapshots_; - std::optional selected_; - QListWidget* list_ = nullptr; - QLabel* details_ = nullptr; - QPushButton* restore_ = nullptr; + std::vector m_snapshots; + std::optional m_selected; + QListWidget* m_list = nullptr; + QLabel* m_details = nullptr; + QPushButton* m_restore = nullptr; }; // Asks before the current mod list is replaced by a snapshot. diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp index 8fcaa36..ad92dc4 100644 --- a/src/ui/panels/settings_panel.cpp +++ b/src/ui/panels/settings_panel.cpp @@ -25,30 +25,30 @@ class FolderDialog : public QFileDialog { }; } SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections) - : QObject(dialog), ctx_(ctx), dialog_(dialog) { + : QObject(dialog), m_ctx(ctx), m_dialog(dialog) { for (auto* section : sections) - sections_.append(QVariantMap{{"title", section->title()}, {"source", section->source().toString()}, + m_sections.append(QVariantMap{{"title", section->title()}, {"source", section->source().toString()}, {"section", QVariant::fromValue(static_cast(section))}}); const auto& cfg = ctx.config(); const auto text = [](const std::string& value) { return QString::fromStdString(value); }; - initial_ = {{"game", text(cfg.paths.game)}, {"local", text(cfg.paths.local)}, + m_initial = {{"game", text(cfg.paths.game)}, {"local", text(cfg.paths.local)}, {"workshop", text(cfg.paths.workshop)}, {"config", text(cfg.paths.config_folder)}, {"compact", cfg.compact_mod_list}, {"launchArgs", text(cfg.launch_args)}, {"launchWrapper", text(cfg.launch_wrapper)}, {"afterLaunch", static_cast(cfg.after_launch)}, {"confirmErrors", cfg.launch_confirm_errors}, {"confirmUnsaved", cfg.launch_confirm_unsaved}, {"confirmRunning", cfg.launch_confirm_running}, {"useAltIds", cfg.sort.use_alternative_package_ids}, {"checkMissing", cfg.sort.check_missing_dependencies}, {"useCommunity", cfg.sort.use_community_rules}}; - const auto path = community_rules_file(); - communityStatus_ = QFileInfo::exists(path) ? "Found: " + path : QStringLiteral("Not downloaded"); - cacheAvailable_ = !cfg.paths.config_folder.empty(); - cacheStatus_ = cacheAvailable_ ? QStringLiteral("Cache ready") : QStringLiteral("No config folder — not available"); + const auto path = communityRulesFile(); + m_communityStatus = QFileInfo::exists(path) ? "Found: " + path : QStringLiteral("Not downloaded"); + m_cacheAvailable = !cfg.paths.config_folder.empty(); + m_cacheStatus = m_cacheAvailable ? QStringLiteral("Cache ready") : QStringLiteral("No config folder — not available"); } QCoro::Task SettingsBackend::browse(QString key) { static const QMap titles{{"game", "Select RimWorld Game Folder"}, {"local", "Select Local Mods Folder"}, {"workshop", "Select Workshop Mods Folder"}, {"config", "Select RimWorld Config Folder"}}; if (!titles.contains(key)) co_return; QPointer guard(this); - const auto [result, picker] = co_await awaitDialog(dialog_, titles.value(key)); + const auto [result, picker] = co_await awaitDialog(m_dialog, titles.value(key)); const auto files = picker->selectedFiles(); if (guard && result == QDialog::Accepted && !files.isEmpty()) emit guard->pathPicked(key, files.front()); } @@ -57,83 +57,83 @@ QString SettingsBackend::localCandidate(const QString& gamePath) const { return QFileInfo(path).isDir() ? path : QString{}; } void SettingsBackend::autoDetect() { - const auto paths = detect_game_paths(); + const auto paths = detectGamePaths(); const std::pair values[]{{"game", &paths.game}, {"local", &paths.local}, {"workshop", &paths.workshop}, {"config", &paths.config_folder}}; for (const auto& [key, value] : values) if (!value->empty()) emit pathPicked(QString::fromLatin1(key), QString::fromStdString(*value)); } QCoro::Task SettingsBackend::downloadCommunityRules() { - communityStatus_ = QStringLiteral("Downloading…"); - communityBusy_ = true; + m_communityStatus = QStringLiteral("Downloading…"); + m_communityBusy = true; emit communityChanged(); - if (!ctx_.hasConfigService()) { - communityStatus_ = QStringLiteral("Download failed"); - communityBusy_ = false; + if (!m_ctx.hasConfigService()) { + m_communityStatus = QStringLiteral("Download failed"); + m_communityBusy = false; emit communityChanged(); co_return; } LoadingState loading; - auto* progress = new QProgressDialog(QStringLiteral("Downloading community rules…"), QString{}, 0, 100, dialog_); + auto* progress = new QProgressDialog(QStringLiteral("Downloading community rules…"), QString{}, 0, 100, m_dialog); progress->setWindowModality(Qt::WindowModal); progress->setCancelButton(nullptr); progress->setMinimumDuration(0); connect(&loading, &LoadingState::changed, progress, [progress](const QString& status, int percent, int, int) { progress->setLabelText(status); progress->setValue(percent); }); progress->show(); - sort::CommunityRulesService service(ctx_.configService(), ctx_.network()); + sort::CommunityRulesService service(m_ctx.configService(), m_ctx.network()); QPointer guard(this); QPointer progressGuard(progress); - const auto path = co_await service.ensure_rules(loading, true); + const auto path = co_await service.ensureRules(loading, true); if (progressGuard) { progressGuard->close(); progressGuard->deleteLater(); } if (!guard) co_return; - guard->communityStatus_ = path ? "Downloaded: " + *path : QStringLiteral("Download failed"); - guard->communityBusy_ = false; + guard->m_communityStatus = path ? "Downloaded: " + *path : QStringLiteral("Download failed"); + guard->m_communityBusy = false; emit guard->communityChanged(); } QCoro::Task SettingsBackend::clearCache() { - cacheStatus_ = QStringLiteral("Clearing…"); - cacheBusy_ = true; + m_cacheStatus = QStringLiteral("Clearing…"); + m_cacheBusy = true; emit cacheChanged(); QPointer guard(this); - const auto result = co_await ctx_.modService().startup_impact().clear(); + const auto result = co_await m_ctx.modService().startupImpact().clear(); if (!guard) co_return; - guard->cacheStatus_ = result ? QStringLiteral("Cache cleared") : "Error: " + result.error().message; - guard->cacheBusy_ = false; + guard->m_cacheStatus = result ? QStringLiteral("Cache cleared") : "Error: " + result.error().message; + guard->m_cacheBusy = false; emit guard->cacheChanged(); } void SettingsBackend::reportSaveError(const QString& message) { - auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), message, QMessageBox::Ok, dialog_); + auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), message, QMessageBox::Ok, m_dialog); box->setAttribute(Qt::WA_DeleteOnClose); box->open(); } SettingsPanel::SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, const std::vector& factories) - : QDialog(parent), config_(ctx.config()), backend_(nullptr), qml_(new QQuickWidget(engine, this)) { + : QDialog(parent), m_config(ctx.config()), m_backend(nullptr), m_qml(new QQuickWidget(engine, this)) { setObjectName(QStringLiteral("settingsPanel")); setWindowTitle(QStringLiteral("Settings")); setModal(true); resize(700, 560); - for (const auto& factory : factories) sections_.push_back(factory(this)); - backend_ = new SettingsBackend(ctx, this, sections_); - connect(backend_, &SettingsBackend::saveRequested, this, &SettingsPanel::save); - connect(backend_, &SettingsBackend::cancelRequested, this, &QDialog::reject); + for (const auto& factory : factories) m_sections.push_back(factory(this)); + m_backend = new SettingsBackend(ctx, this, m_sections); + connect(m_backend, &SettingsBackend::saveRequested, this, &SettingsPanel::save); + connect(m_backend, &SettingsBackend::cancelRequested, this, &QDialog::reject); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setAttribute(Qt::WA_AlwaysStackOnTop, false); - qml_->setClearColor(paletteColor(u"ELEVATE_2")); - qml_->rootContext()->setContextProperty(QStringLiteral("settings"), backend_); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); - layout->addWidget(qml_); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->rootContext()->setContextProperty(QStringLiteral("settings"), m_backend); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); + layout->addWidget(m_qml); // A QML click handler must finish before its root object is destroyed. - connect(this, &QDialog::finished, this, [this] { qml_->setSource(QUrl{}); }, Qt::QueuedConnection); + connect(this, &QDialog::finished, this, [this] { m_qml->setSource(QUrl{}); }, Qt::QueuedConnection); } SettingsPanel::SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent) : SettingsPanel(app.coreContext(), engine, parent, app.settingsSections()) {} -SettingsPanel::~SettingsPanel() { qml_->setSource(QUrl{}); } +SettingsPanel::~SettingsPanel() { m_qml->setSource(QUrl{}); } void SettingsPanel::save(const QVariantMap& values) { - auto updated = config_; + auto updated = m_config; const auto text = [&](const char* key) { return values.value(QLatin1String(key)).toString().trimmed().toStdString(); }; updated.paths.game = text("game"); updated.paths.local = text("local"); @@ -149,11 +149,11 @@ void SettingsPanel::save(const QVariantMap& values) { updated.launch_confirm_errors = values.value("confirmErrors").toBool(); updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); updated.launch_confirm_running = values.value("confirmRunning").toBool(); - for (auto* section : sections_) { + for (auto* section : m_sections) { const auto result = section->apply(); - if (!result) { backend_->reportSaveError(result.error().message); return; } + if (!result) { m_backend->reportSaveError(result.error().message); return; } } - config_ = std::move(updated); + m_config = std::move(updated); accept(); } } // namespace px::modrim diff --git a/src/ui/panels/settings_panel.hpp b/src/ui/panels/settings_panel.hpp index 4c40ad7..ce27c43 100644 --- a/src/ui/panels/settings_panel.hpp +++ b/src/ui/panels/settings_panel.hpp @@ -20,13 +20,13 @@ class SettingsBackend : public QObject { Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) public: SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections); - QVariantMap initial() const { return initial_; } - QVariantList sections() const { return sections_; } - QString communityStatus() const { return communityStatus_; } - bool communityBusy() const { return communityBusy_; } - QString cacheStatus() const { return cacheStatus_; } - bool cacheAvailable() const { return cacheAvailable_; } - bool cacheBusy() const { return cacheBusy_; } + QVariantMap initial() const { return m_initial; } + QVariantList sections() const { return m_sections; } + QString communityStatus() const { return m_communityStatus; } + bool communityBusy() const { return m_communityBusy; } + QString cacheStatus() const { return m_cacheStatus; } + bool cacheAvailable() const { return m_cacheAvailable; } + bool cacheBusy() const { return m_cacheBusy; } Q_INVOKABLE QCoro::Task browse(QString key); Q_INVOKABLE QString localCandidate(const QString& gamePath) const; Q_INVOKABLE void autoDetect(); @@ -42,12 +42,12 @@ class SettingsBackend : public QObject { void communityChanged(); void cacheChanged(); private: - CoreContext& ctx_; - QDialog* dialog_; - QVariantMap initial_; - QVariantList sections_; - QString communityStatus_, cacheStatus_; - bool communityBusy_ = false, cacheBusy_ = false, cacheAvailable_ = false; + CoreContext& m_ctx; + QDialog* m_dialog; + QVariantMap m_initial; + QVariantList m_sections; + QString m_communityStatus, m_cacheStatus; + bool m_communityBusy = false, m_cacheBusy = false, m_cacheAvailable = false; }; class SettingsPanel : public QDialog { Q_OBJECT @@ -56,12 +56,12 @@ class SettingsPanel : public QDialog { const std::vector& sections = {}); SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); ~SettingsPanel() override; - const AppConfig& getConfig() const { return config_; } + const AppConfig& getConfig() const { return m_config; } private: void save(const QVariantMap& values); - AppConfig config_; - std::vector sections_; - SettingsBackend* backend_; - QQuickWidget* qml_; + AppConfig m_config; + std::vector m_sections; + SettingsBackend* m_backend; + QQuickWidget* m_qml; }; } // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp index 2ab8ac8..af4ed67 100644 --- a/src/ui/panels/sidebar_panel.cpp +++ b/src/ui/panels/sidebar_panel.cpp @@ -4,30 +4,30 @@ #include namespace px::modrim { SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) - : FilterSidebar(engine, nullptr, parent), ctx_(ctx), model_(new SidebarModel(this)) { - if (rootObject()) rootObject()->setProperty("model", QVariant::fromValue(model_)); - connect(this, &FilterSidebar::activated, this, &SidebarPanel::on_activated); - sidebar_connection_ = ctx_.diagnosticsService().sidebar_entries_changed.connect([this](const auto& entries) { set_entries(entries); }); + : FilterSidebar(engine, nullptr, parent), m_ctx(ctx), m_model(new SidebarModel(this)) { + if (rootObject()) rootObject()->setProperty("model", QVariant::fromValue(m_model)); + connect(this, &FilterSidebar::activated, this, &SidebarPanel::onActivated); + m_sidebarConnection = m_ctx.diagnosticsService().sidebarEntriesChanged.connect([this](const auto& entries) { setEntries(entries); }); } -SidebarPanel::~SidebarPanel() { ctx_.diagnosticsService().sidebar_entries_changed.disconnect(sidebar_connection_); } -void SidebarPanel::set_entries(const QList& entries) { - const bool reset = entries.size() != entries_.size(); - entries_ = entries; - model_->update_entries(entries); - if (reset) if (const auto current = current_entry()) emit entry_selected(*current); +SidebarPanel::~SidebarPanel() { m_ctx.diagnosticsService().sidebarEntriesChanged.disconnect(m_sidebarConnection); } +void SidebarPanel::setEntries(const QList& entries) { + const bool reset = entries.size() != m_entries.size(); + m_entries = entries; + m_model->updateEntries(entries); + if (reset) if (const auto current = currentEntry()) emit entrySelected(*current); } -std::optional SidebarPanel::current_entry() const { - const int selected = current_index(); - if (selected >= 0 && selected < entries_.size()) return entries_[selected]; +std::optional SidebarPanel::currentEntry() const { + const int selected = currentIndex(); + if (selected >= 0 && selected < m_entries.size()) return m_entries[selected]; return std::nullopt; } -void SidebarPanel::select_all_entry() { +void SidebarPanel::selectAllEntry() { auto* root = rootObject(); auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; if (!view) return; - for (int row = 0; row < entries_.size(); ++row) if (entries_[row].kind == view::SidebarEntry::Kind::All) { - view->setProperty("currentIndex", row); on_activated(row); return; + for (int row = 0; row < m_entries.size(); ++row) if (m_entries[row].kind == view::SidebarEntry::Kind::kAll) { + view->setProperty("currentIndex", row); onActivated(row); return; } } -void SidebarPanel::on_activated(int row) { if (row >= 0 && row < entries_.size()) emit entry_selected(entries_[row]); } +void SidebarPanel::onActivated(int row) { if (row >= 0 && row < m_entries.size()) emit entrySelected(m_entries[row]); } } diff --git a/src/ui/panels/sidebar_panel.hpp b/src/ui/panels/sidebar_panel.hpp index 4812f60..9c9596d 100644 --- a/src/ui/panels/sidebar_panel.hpp +++ b/src/ui/panels/sidebar_panel.hpp @@ -9,17 +9,17 @@ class SidebarPanel : public FilterSidebar { public: SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); ~SidebarPanel() override; - void set_entries(const QList& entries); - std::optional current_entry() const; - void select_all_entry(); - SidebarModel* model() const { return model_; } + void setEntries(const QList& entries); + std::optional currentEntry() const; + void selectAllEntry(); + SidebarModel* model() const { return m_model; } signals: - void entry_selected(const px::modrim::view::SidebarEntry& entry); + void entrySelected(const px::modrim::view::SidebarEntry& entry); private: - void on_activated(int row); - CoreContext& ctx_; - SidebarModel* model_; - QList entries_; - std::uint64_t sidebar_connection_; + void onActivated(int row); + CoreContext& m_ctx; + SidebarModel* m_model; + QList m_entries; + std::uint64_t m_sidebarConnection; }; } diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp index 8f710fe..5a6fbd7 100644 --- a/src/ui/panels/startup_impact_data.cpp +++ b/src/ui/panels/startup_impact_data.cpp @@ -14,11 +14,11 @@ namespace px::modrim { using namespace startup_impact; namespace { -constexpr double minSeconds = 0.001; +constexpr double kMinSeconds = 0.001; QString phaseColor(std::size_t i) { - static constexpr std::array colors{ + static constexpr std::array kColors{ "#b57bee", "#f06ba8", "#43b581", "#f0883e", "#4fb3e8", "#e8c547"}; - return i < colors.size() ? QString::fromLatin1(colors[i]) : paletteValue(u"TEXT_DIM"); + return i < kColors.size() ? QString::fromLatin1(kColors[i]) : paletteValue(u"TEXT_DIM"); } QString tooltip(const QMap& metrics) { QList> sorted; @@ -26,30 +26,30 @@ QString tooltip(const QMap& metrics) { std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); QStringList lines; for (qsizetype i = 0; i < std::min(8, sorted.size()); ++i) - if (sorted[i].second >= minSeconds) lines.append(sorted[i].first + ": " + format_duration(sorted[i].second)); + if (sorted[i].second >= kMinSeconds) lines.append(sorted[i].first + ": " + formatDuration(sorted[i].second)); if (sorted.size() > 8) lines.append(QStringLiteral("+ %1 more").arg(sorted.size() - 8)); return lines.join('\n'); } using Entry = std::tuple*>; QVariantList phaseRows(const QList& entries, const std::optional& selected) { using Contributor = std::tuple; - std::array, PHASE_COUNT> contributors; - std::array, PHASE_COUNT> metrics; + std::array, kPhaseCount> contributors; + std::array, kPhaseCount> metrics; PhaseTotals sums{}; for (const auto& [name, key, base, values] : entries) { - const auto totals = phase_totals(*values); - for (std::size_t p = 0; p < PHASE_COUNT; ++p) { - if (totals[p] < minSeconds) continue; + const auto totals = phaseTotals(*values); + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; contributors[p].append({totals[p], name, key, base}); sums[p] += totals[p]; } for (auto it = values->cbegin(); it != values->cend(); ++it) - metrics[phase_index(metric_phase(it.key()))][metric_label(it.key().section('|', 0, 0))] += it.value(); + metrics[phaseIndex(metricPhase(it.key()))][metricLabel(it.key().section('|', 0, 0))] += it.value(); } double grand = 0; for (double value : sums) grand += value; const double maximum = *std::max_element(sums.begin(), sums.end()); - std::array order{0, 1, 2, 3, 4, 5, 6}; + std::array order{0, 1, 2, 3, 4, 5, 6}; std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return sums[a] > sums[b]; }); QVariantList rows; for (auto p : order) { @@ -59,83 +59,83 @@ QVariantList phaseRows(const QList& entries, const std::optional QVariantList mods; const double top = std::get<0>(items.front()); for (const auto& [seconds, name, key, base] : items) - mods.append(QVariantMap{{"name", name}, {"key", key}, {"value", format_duration(seconds)}, + mods.append(QVariantMap{{"name", name}, {"key", key}, {"value", formatDuration(seconds)}, {"fraction", seconds / top}, {"base", base}, {"selected", selected && key == *selected}}); - rows.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}, - {"seconds", sums[p]}, {"value", format_duration(sums[p])}, + rows.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}, + {"seconds", sums[p]}, {"value", formatDuration(sums[p])}, {"share", grand > 0 ? QString::number(static_cast(std::nearbyint(100 * sums[p] / grand))) + '%' : QString{}}, {"fraction", sums[p] / maximum}, {"tooltip", tooltip(metrics[p])}, {"mods", mods}}); } return rows; } } -QVariantMap build_startup_impact(const StartupImpactReport& report, const QStringList& active_pids, - const std::optional& selected_pid, double estimated_s) { +QVariantMap buildStartupImpact(const StartupImpactReport& report, const QStringList& activePids, + const std::optional& selectedPid, double estimatedS) { QSet active; - for (const auto& pid : active_pids) active.insert(normalize_package_id(pid)); - const auto selected = selected_pid && !selected_pid->isEmpty() - ? std::optional{normalize_package_id(*selected_pid)} : std::nullopt; + for (const auto& pid : activePids) active.insert(normalizePackageId(pid)); + const auto selected = selectedPid && !selectedPid->isEmpty() + ? std::optional{normalizePackageId(*selectedPid)} : std::nullopt; const StartupImpactMod* base = nullptr; QList mods; double modsSeconds = 0, offSeconds = 0; for (const auto& mod : report.mods) { - offSeconds += mod.off_thread_total_impact_s; - if (mod.package_id && *mod.package_id == BASE_GAME_PID) { if (!base) base = &mod; } - else { mods.append(&mod); modsSeconds += mod.total_impact_s; } + offSeconds += mod.offThreadTotalImpactS; + if (mod.packageId && *mod.packageId == kBaseGamePid) { if (!base) base = &mod; } + else { mods.append(&mod); modsSeconds += mod.totalImpactS; } } - std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { return a->total_impact_s > b->total_impact_s; }); - const double baseSeconds = base ? base->total_impact_s : 0; + std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { return a->totalImpactS > b->totalImpactS; }); + const double baseSeconds = base ? base->totalImpactS : 0; const double barTotal = baseSeconds + modsSeconds + offSeconds; - const double maxMod = mods.isEmpty() ? 0 : mods.front()->total_impact_s; + const double maxMod = mods.isEmpty() ? 0 : mods.front()->totalImpactS; QVariantList rows, legend, summary; QList entries; int activeCount = 0; QString selectedKey; for (qsizetype index = 0; index < mods.size(); ++index) { const auto& mod = *mods[index]; - const QString key = mod.package_id && !mod.package_id->isEmpty() ? *mod.package_id : mod.mod_name; - const bool isActive = mod.package_id && active.contains(*mod.package_id); - const bool isSelected = mod.package_id && selected && *mod.package_id == *selected; + const QString key = mod.packageId && !mod.packageId->isEmpty() ? *mod.packageId : mod.modName; + const bool isActive = mod.packageId && active.contains(*mod.packageId); + const bool isSelected = mod.packageId && selected && *mod.packageId == *selected; if (isActive) ++activeCount; if (isSelected && selectedKey.isEmpty()) selectedKey = key; - const auto totals = phase_totals(mod.metrics); - std::array, PHASE_COUNT> grouped; + const auto totals = phaseTotals(mod.metrics); + std::array, kPhaseCount> grouped; for (auto it = mod.metrics.cbegin(); it != mod.metrics.cend(); ++it) - grouped[phase_index(metric_phase(it.key()))][metric_label(it.key())] += it.value(); + grouped[phaseIndex(metricPhase(it.key()))][metricLabel(it.key())] += it.value(); QList order; QVariantList segments, phases; - for (std::size_t p = 0; p < PHASE_COUNT; ++p) { - if (totals[p] < minSeconds) continue; + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; order.append(p); segments.append(QVariantMap{{"color", phaseColor(p)}, {"fraction", maxMod > 0 ? totals[p] / maxMod : 0}}); } std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return totals[a] > totals[b]; }); for (auto p : order) - phases.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}, - {"value", format_duration(totals[p])}, {"fraction", totals[p] / totals[order.front()]}, {"tooltip", tooltip(grouped[p])}}); - rows.append(QVariantMap{{"key", key}, {"name", mod.mod_name}, {"rank", index + 1}, - {"seconds", mod.total_impact_s}, {"time", format_duration(mod.total_impact_s)}, {"color", impact_color(mod.total_impact_s)}, + phases.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}, + {"value", formatDuration(totals[p])}, {"fraction", totals[p] / totals[order.front()]}, {"tooltip", tooltip(grouped[p])}}); + rows.append(QVariantMap{{"key", key}, {"name", mod.modName}, {"rank", index + 1}, + {"seconds", mod.totalImpactS}, {"time", formatDuration(mod.totalImpactS)}, {"color", impactColor(mod.totalImpactS)}, {"active", isActive}, {"selected", isSelected}, - {"off_thread", mod.off_thread_total_impact_s >= minSeconds ? format_duration(mod.off_thread_total_impact_s) : QString{}}, - {"dominant", order.isEmpty() ? QString{} : phase_label(static_cast(order.front()))}, {"segments", segments}, {"phases", phases}}); - entries.append({mod.mod_name, key, false, &mod.metrics}); + {"off_thread", mod.offThreadTotalImpactS >= kMinSeconds ? formatDuration(mod.offThreadTotalImpactS) : QString{}}, + {"dominant", order.isEmpty() ? QString{} : phaseLabel(static_cast(order.front()))}, {"segments", segments}, {"phases", phases}}); + entries.append({mod.modName, key, false, &mod.metrics}); } - for (std::size_t p = 0; p < PHASE_COUNT; ++p) - legend.append(QVariantMap{{"label", phase_label(static_cast(p))}, {"color", phaseColor(p)}}); + for (std::size_t p = 0; p < kPhaseCount; ++p) + legend.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}}); const std::array labels{QStringLiteral("Base game"), QStringLiteral("Mods, main thread"), QStringLiteral("Off-thread (parallel)")}; const std::array values{baseSeconds, modsSeconds, offSeconds}; for (std::size_t i = 0; i < values.size(); ++i) - if (values[i] >= minSeconds) summary.append(QVariantMap{{"label", labels[i]}, {"value", format_duration(values[i])}, + if (values[i] >= kMinSeconds) summary.append(QVariantMap{{"label", labels[i]}, {"value", formatDuration(values[i])}, {"fraction", barTotal > 0 ? values[i] / barTotal : 0}, {"color", paletteValue(i == 0 ? u"TEXT_DIM" : u"PRIMARY")}, {"parallel", i == 2}}); const auto modsPhases = phaseRows(entries, selected); const QMap empty; - entries.prepend({QStringLiteral("Base game"), QString(BASE_GAME_PID), true, base ? &base->metrics : &empty}); + entries.prepend({QStringLiteral("Base game"), QString(kBaseGamePid), true, base ? &base->metrics : &empty}); auto timestamp = QDateTime::fromString(report.timestamp, Qt::ISODateWithMs); if (!timestamp.isValid()) timestamp = QDateTime::fromString(report.timestamp, Qt::ISODate); return {{"timestamp", timestamp.isValid() ? timestamp.toLocalTime().toString("yyyy-MM-dd HH:mm") : report.timestamp}, - {"last_launch", format_duration(report.loading_time_s != 0 ? report.loading_time_s : baseSeconds + modsSeconds)}, - {"estimated", active_pids.isEmpty() ? QString{} : format_duration(estimated_s)}, {"summary", summary}, - {"base_value", format_duration(baseSeconds)}, {"mods_value", format_duration(modsSeconds)}, {"off_value", format_duration(offSeconds)}, + {"last_launch", formatDuration(report.loadingTimeS != 0 ? report.loadingTimeS : baseSeconds + modsSeconds)}, + {"estimated", activePids.isEmpty() ? QString{} : formatDuration(estimatedS)}, {"summary", summary}, + {"base_value", formatDuration(baseSeconds)}, {"mods_value", formatDuration(modsSeconds)}, {"off_value", formatDuration(offSeconds)}, {"legend", legend}, {"mods", rows}, {"active_count", activeCount}, {"selected_key", selectedKey}, {"phases_with_base", phaseRows(entries, selected)}, {"phases_mods_only", modsPhases}}; } diff --git a/src/ui/panels/startup_impact_data.hpp b/src/ui/panels/startup_impact_data.hpp index b752a02..4e7520b 100644 --- a/src/ui/panels/startup_impact_data.hpp +++ b/src/ui/panels/startup_impact_data.hpp @@ -7,7 +7,7 @@ #include "ui/models/impact.hpp" namespace px::modrim { -QVariantMap build_startup_impact(const startup_impact::StartupImpactReport& report, - const QStringList& active_pids, - const std::optional& selected_pid, double estimated_s); +QVariantMap buildStartupImpact(const startup_impact::StartupImpactReport& report, + const QStringList& activePids, + const std::optional& selectedPid, double estimatedS); } // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp index 092ba22..1806663 100644 --- a/src/ui/panels/time_analytics_panel.cpp +++ b/src/ui/panels/time_analytics_panel.cpp @@ -9,31 +9,31 @@ #include "ui/theme/palette.hpp" namespace px::modrim { TimeAnalyticsPanel::TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) - : QWidget(parent), service_(service), qml_(new QQuickWidget(engine, this)) { + : QWidget(parent), m_service(service), m_qml(new QQuickWidget(engine, this)) { auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setAttribute(Qt::WA_AlwaysStackOnTop, false); - qml_->setClearColor(paletteColor(u"ELEVATE_1")); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); - layout->addWidget(qml_); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); + layout->addWidget(m_qml); } QCoro::Task TimeAnalyticsPanel::setData(std::optional pid, QStringList activePids) { - const auto token = ++requestToken_; - QPointer root = qml_->rootObject(); + const auto token = ++m_requestToken; + QPointer root = m_qml->rootObject(); if (!root) co_return; - if (!service_) { root->setProperty("sourceData", QVariant{}); co_return; } + if (!m_service) { root->setProperty("sourceData", QVariant{}); co_return; } QPointer guard(this); - auto result = co_await service_->snapshot(activePids); - if (!guard || !root || token != guard->requestToken_) co_return; + auto result = co_await m_service->snapshot(activePids); + if (!guard || !root || token != guard->m_requestToken) co_return; if (!result || !result->report) { root->setProperty("sourceData", QVariant{}); co_return; } - double estimated = result->base_game_avg; - for (const auto& total : result->totals) estimated += total.on_thread + total.off_thread; - root->setProperty("sourceData", build_startup_impact(*result->report, activePids, pid, estimated)); + double estimated = result->baseGameAvg; + for (const auto& total : result->totals) estimated += total.onThread + total.offThread; + root->setProperty("sourceData", buildStartupImpact(*result->report, activePids, pid, estimated)); } void TimeAnalyticsPanel::clear() { - ++requestToken_; - if (auto* root = qml_->rootObject()) root->setProperty("sourceData", QVariant{}); + ++m_requestToken; + if (auto* root = m_qml->rootObject()) root->setProperty("sourceData", QVariant{}); } StartupImpactDialog::StartupImpactDialog(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) : QDialog(parent), panel(new TimeAnalyticsPanel(service, engine, this)) { diff --git a/src/ui/panels/time_analytics_panel.hpp b/src/ui/panels/time_analytics_panel.hpp index 254a713..a6aa521 100644 --- a/src/ui/panels/time_analytics_panel.hpp +++ b/src/ui/panels/time_analytics_panel.hpp @@ -15,9 +15,9 @@ class TimeAnalyticsPanel : public QWidget { QCoro::Task setData(std::optional pid, QStringList activePids); void clear(); private: - startup_impact::StartupImpactService* service_; - QQuickWidget* qml_; - quint64 requestToken_ = 0; + startup_impact::StartupImpactService* m_service; + QQuickWidget* m_qml; + quint64 m_requestToken = 0; }; class StartupImpactDialog : public QDialog { Q_OBJECT diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp index 0dd82a1..1fc2977 100644 --- a/src/ui/panels/update_dialog.cpp +++ b/src/ui/panels/update_dialog.cpp @@ -35,7 +35,7 @@ UpdateDialog::UpdateDialog(const services::ReleaseInfo& release, auto* buttons = new QHBoxLayout; auto* skip = new QPushButton(tr("Skip This Version"), this); skip->setObjectName(QStringLiteral("appBtn")); - connect(skip, &QPushButton::clicked, this, [this] { done(SKIP_RESULT); }); + connect(skip, &QPushButton::clicked, this, [this] { done(kSkipResult); }); auto* later = new QPushButton(tr("Later"), this); later->setObjectName(QStringLiteral("appBtn")); connect(later, &QPushButton::clicked, this, &QDialog::reject); diff --git a/src/ui/panels/update_dialog.hpp b/src/ui/panels/update_dialog.hpp index 8e76679..18113c2 100644 --- a/src/ui/panels/update_dialog.hpp +++ b/src/ui/panels/update_dialog.hpp @@ -4,7 +4,7 @@ #include "core/services/update_service.hpp" namespace px::modrim { -inline constexpr int SKIP_RESULT = 2; +inline constexpr int kSkipResult = 2; class UpdateDialog : public QDialog { public: diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp index 1a83bab..73178ae 100644 --- a/src/ui/panels/upload_report_dialog.cpp +++ b/src/ui/panels/upload_report_dialog.cpp @@ -43,7 +43,7 @@ UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { buttons->setSpacing(10); auto* save = new QPushButton(tr("Save to file instead…"), this); save->setObjectName(QStringLiteral("appBtn")); - connect(save, &QPushButton::clicked, this, [this] { done(static_cast(UploadChoice::SAVE_TO_FILE)); }); + connect(save, &QPushButton::clicked, this, [this] { done(static_cast(UploadChoice::kSaveToFile)); }); buttons->addWidget(save); buttons->addStretch(); auto* cancel = new QPushButton(tr("Cancel"), this); @@ -115,24 +115,24 @@ QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { } } // namespace -QCoro::Task> handle_upload_report(QWidget* parent, CoreContext& ctx, +QCoro::Task> handleUploadReport(QWidget* parent, CoreContext& ctx, Notifier& toast) { QPointer owner(parent); auto [choice, dialog] = co_await awaitDialog(parent); Q_UNUSED(dialog); - if (choice == static_cast(UploadChoice::CANCEL)) co_return std::nullopt; + if (choice == static_cast(UploadChoice::kCancel)) co_return std::nullopt; support::ReportSource source{ctx.gameVersion(), ctx.config().paths, ctx.allMods(), ctx.activeUuids()}; const QString report = co_await providers::runOnPool(nullptr, - [source = std::move(source)] { return support::build_report(source); }); + [source = std::move(source)] { return support::buildReport(source); }); if (parent && !owner) co_return std::nullopt; - if (choice == static_cast(UploadChoice::SAVE_TO_FILE)) { + if (choice == static_cast(UploadChoice::kSaveToFile)) { co_await saveReport(owner, report, toast); co_return std::nullopt; } toast.info(QObject::tr("Uploading log & system info…"), 2000); QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); - if (endpoint.isEmpty()) endpoint = QString(support::DEFAULT_UPLOAD_ENDPOINT); + if (endpoint.isEmpty()) endpoint = QString(support::kDefaultUploadEndpoint); support::PasteUploader uploader(ctx.network(), std::move(endpoint)); auto uploaded = co_await uploader.upload(report); if (parent && !owner) co_return std::nullopt; diff --git a/src/ui/panels/upload_report_dialog.hpp b/src/ui/panels/upload_report_dialog.hpp index fbb943f..0c87bf9 100644 --- a/src/ui/panels/upload_report_dialog.hpp +++ b/src/ui/panels/upload_report_dialog.hpp @@ -7,7 +7,7 @@ namespace px::modrim { class CoreContext; } namespace px::modrim { class Notifier; -enum class UploadChoice { CANCEL = QDialog::Rejected, UPLOAD = QDialog::Accepted, SAVE_TO_FILE = 2 }; +enum class UploadChoice { kCancel = QDialog::Rejected, kUpload = QDialog::Accepted, kSaveToFile = 2 }; class UploadConfirmDialog : public QDialog { public: explicit UploadConfirmDialog(QWidget* parent = nullptr); @@ -16,6 +16,6 @@ class UploadFailedDialog : public QDialog { public: explicit UploadFailedDialog(const QString& errorMessage, QWidget* parent = nullptr); }; -QCoro::Task> handle_upload_report(QWidget* parent, CoreContext& ctx, +QCoro::Task> handleUploadReport(QWidget* parent, CoreContext& ctx, Notifier& toast); } // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp index c6b47b6..5353f82 100644 --- a/src/ui/plugins/downloads/model.cpp +++ b/src/ui/plugins/downloads/model.cpp @@ -2,169 +2,169 @@ namespace px::modrim { namespace { -bool is_active(const QString& state) { +bool isActive(const QString& state) { return state == QLatin1String("queued") || state == QLatin1String("checking") || state == QLatin1String("downloading"); } -QString final_state(qint64 total) { +QString finalState(qint64 total) { return total > 0 ? QStringLiteral("updated") : QStringLiteral("unchanged"); } } DownloadsModel::DownloadsModel(QObject* parent) - : QAbstractListModel(parent), timer_(this), speed_timer_(this) { - timer_.setSingleShot(true); - timer_.setInterval(150); - connect(&timer_, &QTimer::timeout, this, &DownloadsModel::flush); - speed_timer_.setInterval(1000); - connect(&speed_timer_, &QTimer::timeout, this, &DownloadsModel::sample_speed); + : QAbstractListModel(parent), m_timer(this), m_speedTimer(this) { + m_timer.setSingleShot(true); + m_timer.setInterval(150); + connect(&m_timer, &QTimer::timeout, this, &DownloadsModel::flush); + m_speedTimer.setInterval(1000); + connect(&m_speedTimer, &QTimer::timeout, this, &DownloadsModel::sampleSpeed); } void DownloadsModel::begin(const QStringList& ids, const QHash& titles) { - timer_.stop(); - dirty_.clear(); - bytes_ = sampled_bytes_ = 0; - speed_ = 0; - history_.clear(); - emit speed_changed(); - set_phase({}); + m_timer.stop(); + m_dirty.clear(); + m_bytes = m_sampledBytes = 0; + m_speed = 0; + m_history.clear(); + emit speedChanged(); + setPhase({}); beginResetModel(); - rows_.clear(); - by_id_.clear(); - rows_.reserve(ids.size()); + m_rows.clear(); + m_byId.clear(); + m_rows.reserve(ids.size()); for (const auto& id : ids) { - by_id_.insert(id, rows_.size()); - rows_.append(Row{id, titles.value(id, id)}); + m_byId.insert(id, m_rows.size()); + m_rows.append(Row{id, titles.value(id, id)}); } recount(); select(); endResetModel(); - emit summary_changed(); + emit summaryChanged(); } void DownloadsModel::apply(const DownloadItemStatus& status) { - const auto found = by_id_.constFind(status.mod_id); - if (found == by_id_.cend()) return; - auto& row = rows_[*found]; - const auto done = status.status == QLatin1String("error") ? row.bytes_done : status.bytes_done; - bytes_ += done - row.bytes_done; - row.bytes_done = done; + const auto found = m_byId.constFind(status.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; + const auto done = status.status == QLatin1String("error") ? row.bytesDone : status.bytesDone; + m_bytes += done - row.bytesDone; + row.bytesDone = done; if (status.status == QLatin1String("error")) { row.state = QStringLiteral("failed"); row.error = status.error; row.progress = 0; } else if (status.status == QLatin1String("success")) { - row.state = final_state(status.bytes_total); + row.state = finalState(status.bytesTotal); row.progress = 1; - } else if (status.bytes_total > 0 && status.bytes_done < status.bytes_total) { + } else if (status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { row.state = QStringLiteral("downloading"); - row.progress = static_cast(status.bytes_done) / status.bytes_total; + row.progress = static_cast(status.bytesDone) / status.bytesTotal; } else { - row.state = final_state(status.bytes_total); + row.state = finalState(status.bytesTotal); row.progress = 1; } mark(row); } -void DownloadsModel::set_title(const DownloadItemTitle& item) { - const auto found = by_id_.constFind(item.mod_id); - if (found == by_id_.cend()) return; - auto& row = rows_[*found]; +void DownloadsModel::setTitle(const DownloadItemTitle& item) { + const auto found = m_byId.constFind(item.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; if (row.title == row.id) row.title = item.title; if (row.state == QLatin1String("queued")) row.state = QStringLiteral("checking"); mark(row); } -void DownloadsModel::set_phase(const QString& phase) { - if (phase_ == phase) return; - phase_ = phase; - emit phase_changed(); +void DownloadsModel::setPhase(const QString& phase) { + if (m_phase == phase) return; + m_phase = phase; + emit phaseChanged(); } void DownloadsModel::finish(const DownloadResult&) { - for (auto& row : rows_) { - if (is_active(row.state)) { + for (auto& row : m_rows) { + if (isActive(row.state)) { row.state = QStringLiteral("cancelled"); row.progress = 0; - dirty_.insert(row.id); + m_dirty.insert(row.id); } } - timer_.stop(); + m_timer.stop(); flush(); } -void DownloadsModel::set_busy(bool busy) { - if (busy_ == busy) return; - busy_ = busy; +void DownloadsModel::setBusy(bool busy) { + if (m_busy == busy) return; + m_busy = busy; if (busy) { - sampled_bytes_ = bytes_; - speed_timer_.start(); + m_sampledBytes = m_bytes; + m_speedTimer.start(); } else { - speed_timer_.stop(); - speed_ = 0; - emit speed_changed(); + m_speedTimer.stop(); + m_speed = 0; + emit speedChanged(); } - emit summary_changed(); + emit summaryChanged(); } -QStringList DownloadsModel::failed_ids() const { +QStringList DownloadsModel::failedIds() const { QStringList ids; - for (const auto& row : rows_) { + for (const auto& row : m_rows) { if (row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled")) ids.append(row.id); } return ids; } void DownloadsModel::clear() { - if (!busy_) begin({}, {}); + if (!m_busy) begin({}, {}); } void DownloadsModel::setFilter(const QString& name) { - if (filter_ == name) return; - filter_ = name; + if (m_filter == name) return; + m_filter = name; beginResetModel(); select(); endResetModel(); - emit summary_changed(); + emit summaryChanged(); } -void DownloadsModel::sample_speed() { - speed_ = static_cast(bytes_ - sampled_bytes_); - sampled_bytes_ = bytes_; - if (history_.size() == HistoryLength) history_.removeFirst(); - history_.append(speed_); - emit speed_changed(); +void DownloadsModel::sampleSpeed() { + m_speed = static_cast(m_bytes - m_sampledBytes); + m_sampledBytes = m_bytes; + if (m_history.size() == kHistoryLength) m_history.removeFirst(); + m_history.append(m_speed); + emit speedChanged(); } void DownloadsModel::mark(const Row& row) { - dirty_.insert(row.id); - if (!timer_.isActive()) timer_.start(); + m_dirty.insert(row.id); + if (!m_timer.isActive()) m_timer.start(); } bool DownloadsModel::matches(const Row& row) const { - if (filter_ == QLatin1String("active")) return is_active(row.state); - if (filter_ == QLatin1String("updated")) return row.state == QLatin1String("updated"); - if (filter_ == QLatin1String("failed")) return row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled"); + if (m_filter == QLatin1String("active")) return isActive(row.state); + if (m_filter == QLatin1String("updated")) return row.state == QLatin1String("updated"); + if (m_filter == QLatin1String("failed")) return row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled"); return true; } void DownloadsModel::select() { - visible_.clear(); - for (qsizetype pos = 0; pos < rows_.size(); ++pos) { - if (matches(rows_[pos])) visible_.append(pos); + m_visible.clear(); + for (qsizetype pos = 0; pos < m_rows.size(); ++pos) { + if (matches(m_rows[pos])) m_visible.append(pos); } } void DownloadsModel::recount() { - counts_.clear(); - for (const auto& row : rows_) ++counts_[row.state]; + m_counts.clear(); + for (const auto& row : m_rows) ++m_counts[row.state]; } void DownloadsModel::flush() { - if (dirty_.isEmpty()) return; + if (m_dirty.isEmpty()) return; recount(); - if (filter_ == QLatin1String("all")) { - for (qsizetype pos = 0; pos < visible_.size(); ++pos) { - if (dirty_.contains(rows_[visible_[pos]].id)) { + if (m_filter == QLatin1String("all")) { + for (qsizetype pos = 0; pos < m_visible.size(); ++pos) { + if (m_dirty.contains(m_rows[m_visible[pos]].id)) { const auto idx = index(static_cast(pos)); emit dataChanged(idx, idx); } @@ -174,29 +174,29 @@ void DownloadsModel::flush() { select(); endResetModel(); } - dirty_.clear(); - emit summary_changed(); + m_dirty.clear(); + emit summaryChanged(); } int DownloadsModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(visible_.size()); + return parent.isValid() ? 0 : static_cast(m_visible.size()); } QVariant DownloadsModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= visible_.size()) return {}; - const auto& row = rows_[visible_[index.row()]]; + if (!index.isValid() || index.row() < 0 || index.row() >= m_visible.size()) return {}; + const auto& row = m_rows[m_visible[index.row()]]; switch (role) { - case IdRole: return row.id; - case TitleRole: return row.title; - case StateRole: return row.state; - case ProgressRole: return row.progress; - case ErrorRole: return row.error; + case kIdRole: return row.id; + case kTitleRole: return row.title; + case kStateRole: return row.state; + case kProgressRole: return row.progress; + case kErrorRole: return row.error; default: return {}; } } QHash DownloadsModel::roleNames() const { - return {{IdRole, "id"}, {TitleRole, "title"}, {StateRole, "state"}, {ProgressRole, "progress"}, {ErrorRole, "error"}}; + return {{kIdRole, "id"}, {kTitleRole, "title"}, {kStateRole, "state"}, {kProgressRole, "progress"}, {kErrorRole, "error"}}; } } // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.hpp b/src/ui/plugins/downloads/model.hpp index 0eed4a7..d48d591 100644 --- a/src/ui/plugins/downloads/model.hpp +++ b/src/ui/plugins/downloads/model.hpp @@ -12,23 +12,23 @@ namespace px::modrim { class DownloadsModel final : public QAbstractListModel { Q_OBJECT - Q_PROPERTY(int total READ total NOTIFY summary_changed) - Q_PROPERTY(int finished READ finished NOTIFY summary_changed) - Q_PROPERTY(int updated READ updated NOTIFY summary_changed) - Q_PROPERTY(int unchanged READ unchanged NOTIFY summary_changed) - Q_PROPERTY(int failed READ failed NOTIFY summary_changed) - Q_PROPERTY(int active READ active NOTIFY summary_changed) - Q_PROPERTY(int resolved READ resolved NOTIFY summary_changed) - Q_PROPERTY(bool busy READ busy NOTIFY summary_changed) - Q_PROPERTY(QString filter READ filter NOTIFY summary_changed) - Q_PROPERTY(QString phase READ phase NOTIFY phase_changed) - Q_PROPERTY(double speed READ speed NOTIFY speed_changed) - Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speed_changed) - Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speed_changed) + Q_PROPERTY(int total READ total NOTIFY summaryChanged) + Q_PROPERTY(int finished READ finished NOTIFY summaryChanged) + Q_PROPERTY(int updated READ updated NOTIFY summaryChanged) + Q_PROPERTY(int unchanged READ unchanged NOTIFY summaryChanged) + Q_PROPERTY(int failed READ failed NOTIFY summaryChanged) + Q_PROPERTY(int active READ active NOTIFY summaryChanged) + Q_PROPERTY(int resolved READ resolved NOTIFY summaryChanged) + Q_PROPERTY(bool busy READ busy NOTIFY summaryChanged) + Q_PROPERTY(QString filter READ filter NOTIFY summaryChanged) + Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged) + Q_PROPERTY(double speed READ speed NOTIFY speedChanged) + Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speedChanged) + Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speedChanged) public: - enum Role { IdRole = Qt::UserRole + 1, TitleRole, StateRole, ProgressRole, ErrorRole }; - static constexpr qsizetype HistoryLength = 90; + enum Role { kIdRole = Qt::UserRole + 1, kTitleRole, kStateRole, kProgressRole, kErrorRole }; + static constexpr qsizetype kHistoryLength = 90; explicit DownloadsModel(QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; @@ -37,33 +37,33 @@ class DownloadsModel final : public QAbstractListModel { void begin(const QStringList& ids, const QHash& titles); void apply(const DownloadItemStatus& status); - void set_title(const DownloadItemTitle& item); - void set_phase(const QString& phase); + void setTitle(const DownloadItemTitle& item); + void setPhase(const QString& phase); void finish(const DownloadResult& result); - void set_busy(bool busy); - QStringList failed_ids() const; + void setBusy(bool busy); + QStringList failedIds() const; void flush(); Q_INVOKABLE void clear(); Q_INVOKABLE void setFilter(const QString& name); - int total() const { return static_cast(rows_.size()); } + int total() const { return static_cast(m_rows.size()); } int finished() const { return updated() + unchanged() + failed(); } - int updated() const { return counts_.value(QStringLiteral("updated")); } - int unchanged() const { return counts_.value(QStringLiteral("unchanged")); } - int failed() const { return counts_.value(QStringLiteral("failed")) + counts_.value(QStringLiteral("cancelled")); } - int active() const { return counts_.value(QStringLiteral("queued")) + counts_.value(QStringLiteral("checking")) + counts_.value(QStringLiteral("downloading")); } - int resolved() const { return total() - counts_.value(QStringLiteral("queued")); } - bool busy() const { return busy_; } - QString filter() const { return filter_; } - QString phase() const { return phase_; } - double speed() const { return speed_; } - double bytesDone() const { return static_cast(bytes_); } - QVariantList speedHistory() const { return history_; } + int updated() const { return m_counts.value(QStringLiteral("updated")); } + int unchanged() const { return m_counts.value(QStringLiteral("unchanged")); } + int failed() const { return m_counts.value(QStringLiteral("failed")) + m_counts.value(QStringLiteral("cancelled")); } + int active() const { return m_counts.value(QStringLiteral("queued")) + m_counts.value(QStringLiteral("checking")) + m_counts.value(QStringLiteral("downloading")); } + int resolved() const { return total() - m_counts.value(QStringLiteral("queued")); } + bool busy() const { return m_busy; } + QString filter() const { return m_filter; } + QString phase() const { return m_phase; } + double speed() const { return m_speed; } + double bytesDone() const { return static_cast(m_bytes); } + QVariantList speedHistory() const { return m_history; } signals: - void summary_changed(); - void speed_changed(); - void phase_changed(); + void summaryChanged(); + void speedChanged(); + void phaseChanged(); private: struct Row { @@ -72,27 +72,27 @@ class DownloadsModel final : public QAbstractListModel { QString state = QStringLiteral("queued"); double progress = 0; QString error; - qint64 bytes_done = 0; + qint64 bytesDone = 0; }; void mark(const Row& row); bool matches(const Row& row) const; void select(); void recount(); - void sample_speed(); - QList rows_; - QHash by_id_; - QList visible_; - QString filter_ = QStringLiteral("all"); - QString phase_; - bool busy_ = false; - QHash counts_; - QSet dirty_; - QTimer timer_; - QTimer speed_timer_; - qint64 bytes_ = 0; - qint64 sampled_bytes_ = 0; - double speed_ = 0; - QVariantList history_; + void sampleSpeed(); + QList m_rows; + QHash m_byId; + QList m_visible; + QString m_filter = QStringLiteral("all"); + QString m_phase; + bool m_busy = false; + QHash m_counts; + QSet m_dirty; + QTimer m_timer; + QTimer m_speedTimer; + qint64 m_bytes = 0; + qint64 m_sampledBytes = 0; + double m_speed = 0; + QVariantList m_history; }; } // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp index 256cd61..94d41fe 100644 --- a/src/ui/plugins/downloads/view.cpp +++ b/src/ui/plugins/downloads/view.cpp @@ -9,43 +9,46 @@ namespace px::modrim { DownloadsViewPanel::DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx, engine, parent, app), downloads_(ctx.downloads()), model_(new DownloadsModel(this)) { + : BaseViewPanel(ctx, engine, parent, app), m_downloads(ctx.downloads()), m_model(new DownloadsModel(this)) { auto* qml = new QQuickWidget(engine, this); qml->setObjectName(QStringLiteral("downloadsView")); qml->setResizeMode(QQuickWidget::SizeRootObjectToView); qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); qml->rootContext()->setContextProperty(QStringLiteral("downloadsPanel"), this); - qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), model_); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), m_model); qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Downloads.qml"))); - root_->addWidget(qml, 1); - model_->set_busy(downloads_.is_downloading()); - batch_id_ = downloads_.batch_started.connect([this](const QStringList& ids) { - model_->begin(ids, downloads_.names_by_id(ctx_.allMods().values())); + m_root->addWidget(qml, 1); + m_model->setBusy(m_downloads.isDownloading()); + m_batchId = m_downloads.batchStarted.connect([this](const QStringList& ids) { + m_model->begin(ids, m_downloads.namesById(m_ctx.allMods().values())); }); - status_id_ = downloads_.download_item_status_changed.connect([this](const DownloadItemStatus& status) { model_->apply(status); }); - title_id_ = downloads_.download_item_titled.connect([this](const DownloadItemTitle& item) { model_->set_title(item); }); - phase_id_ = downloads_.download_phase_changed.connect([this](const QString& phase) { model_->set_phase(phase); }); - finished_id_ = downloads_.download_finished.connect([this](const DownloadResult& result) { model_->finish(result); }); - busy_id_ = downloads_.busy_changed.connect([this](bool busy) { model_->set_busy(busy); }); + m_statusId = m_downloads.downloadItemStatusChanged.connect([this](const DownloadItemStatus& status) { m_model->apply(status); }); + m_titleId = m_downloads.downloadItemTitled.connect([this](const DownloadItemTitle& item) { m_model->setTitle(item); }); + m_phaseId = m_downloads.downloadPhaseChanged.connect([this](const QString& phase) { m_model->setPhase(phase); }); + m_finishedId = m_downloads.downloadFinished.connect([this](const DownloadResult& result) { m_model->finish(result); }); + m_busyId = m_downloads.busyChanged.connect([this](bool busy) { m_model->setBusy(busy); }); } DownloadsViewPanel::~DownloadsViewPanel() { - downloads_.batch_started.disconnect(batch_id_); - downloads_.download_item_status_changed.disconnect(status_id_); - downloads_.download_item_titled.disconnect(title_id_); - downloads_.download_phase_changed.disconnect(phase_id_); - downloads_.download_finished.disconnect(finished_id_); - downloads_.busy_changed.disconnect(busy_id_); + // The QML bindings read downloadsModel, which is a child of this panel and would otherwise be + // destroyed before the view that binds to it. + delete findChild(QStringLiteral("downloadsView")); + m_downloads.batchStarted.disconnect(m_batchId); + m_downloads.downloadItemStatusChanged.disconnect(m_statusId); + m_downloads.downloadItemTitled.disconnect(m_titleId); + m_downloads.downloadPhaseChanged.disconnect(m_phaseId); + m_downloads.downloadFinished.disconnect(m_finishedId); + m_downloads.busyChanged.disconnect(m_busyId); } -void DownloadsViewPanel::stop() { downloads_.cancel(); } +void DownloadsViewPanel::stop() { m_downloads.cancel(); } -QCoro::QmlTask DownloadsViewPanel::retryFailed() { return retry_failed(); } +QCoro::QmlTask DownloadsViewPanel::retryFailed() { return retryFailedAsync(); } -QCoro::Task DownloadsViewPanel::retry_failed() { - auto ids = model_->failed_ids(); +QCoro::Task DownloadsViewPanel::retryFailedAsync() { + auto ids = m_model->failedIds(); if (ids.isEmpty()) co_return; - auto result = co_await downloads_.download_mods(std::move(ids)); + auto result = co_await m_downloads.downloadMods(std::move(ids)); if (!result) spdlog::warn("downloads retry not started: {}", result.error().message.toStdString()); } diff --git a/src/ui/plugins/downloads/view.hpp b/src/ui/plugins/downloads/view.hpp index 7abdf6e..25b7c29 100644 --- a/src/ui/plugins/downloads/view.hpp +++ b/src/ui/plugins/downloads/view.hpp @@ -19,15 +19,15 @@ class DownloadsViewPanel final : public BaseViewPanel { Q_INVOKABLE QCoro::QmlTask retryFailed(); private: - QCoro::Task retry_failed(); - DownloadManager& downloads_; - DownloadsModel* model_; - Event::Id batch_id_; - Event::Id status_id_; - Event::Id title_id_; - Event::Id phase_id_; - Event::Id finished_id_; - Event::Id busy_id_; + QCoro::Task retryFailedAsync(); + DownloadManager& m_downloads; + DownloadsModel* m_model; + Event::Id m_batchId; + Event::Id m_statusId; + Event::Id m_titleId; + Event::Id m_phaseId; + Event::Id m_finishedId; + Event::Id m_busyId; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp index 01fd118..c87bbb7 100644 --- a/src/ui/plugins/organizer/dialogs.cpp +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -17,11 +17,11 @@ namespace px::modrim { namespace { using px::modrim::organizer::FolderNode; -using px::modrim::organizer::ROOT_ID; +using px::modrim::organizer::kRootId; -QPushButton* makeButton(const QString& text, QWidget* parent, const QString& object_name) { +QPushButton* makeButton(const QString& text, QWidget* parent, const QString& objectName) { auto* button = new QPushButton(text, parent); - button->setObjectName(object_name); + button->setObjectName(objectName); button->setCursor(Qt::PointingHandCursor); return button; } @@ -54,12 +54,12 @@ FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, setMinimumWidth(360); auto* layout = new QVBoxLayout(this); layout->addWidget(new QLabel(QStringLiteral("Folder name"), this)); - edit_ = new QLineEdit(initial, this); - layout->addWidget(edit_); + m_edit = new QLineEdit(initial, this); + layout->addWidget(m_edit); - const QString primary_text = + const QString primaryText = verb.isEmpty() ? title.section(u' ', 0, 0, QString::SectionSkipEmpty) : verb; - auto* ok = makeButton(primary_text, this, QStringLiteral("primaryAction")); + auto* ok = makeButton(primaryText, this, QStringLiteral("primaryAction")); ok->setDefault(true); auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); connect(ok, &QPushButton::clicked, this, &QDialog::accept); @@ -67,14 +67,14 @@ FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, addButtonRow(layout, cancel, ok); auto validate = [ok](const QString& text) { ok->setEnabled(!text.trimmed().isEmpty()); }; - connect(edit_, &QLineEdit::textChanged, this, validate); - connect(edit_, &QLineEdit::returnPressed, ok, &QPushButton::click); + connect(m_edit, &QLineEdit::textChanged, this, validate); + connect(m_edit, &QLineEdit::returnPressed, ok, &QPushButton::click); validate(initial); - edit_->selectAll(); + m_edit->selectAll(); } QString FolderNameDialog::textValue() const { - return edit_->text().trimmed(); + return m_edit->text().trimmed(); } DeleteFolderDialog::DeleteFolderDialog(const QString& name, QWidget* parent) @@ -101,42 +101,42 @@ FolderPickerDialog::FolderPickerDialog(const FolderNode& tree, const QString& ti resize(380, 450); auto* layout = new QVBoxLayout(this); layout->addWidget(new QLabel(QStringLiteral("Destination folder"), this)); - tree_ = new QTreeWidget(this); - tree_->setHeaderHidden(true); - tree_->setRootIsDecorated(true); - layout->addWidget(tree_); - - auto* top = new QTreeWidgetItem(tree_, QStringList{QStringLiteral("Ungrouped (top level)")}); - top->setData(0, Qt::UserRole, ROOT_ID); - if (!allowed.contains(ROOT_ID)) { + m_tree = new QTreeWidget(this); + m_tree->setHeaderHidden(true); + m_tree->setRootIsDecorated(true); + layout->addWidget(m_tree); + + auto* top = new QTreeWidgetItem(m_tree, QStringList{QStringLiteral("Ungrouped (top level)")}); + top->setData(0, Qt::UserRole, kRootId); + if (!allowed.contains(kRootId)) { top->setDisabled(true); } for (const FolderNode& folder : tree.children) { addFolder(top, folder, allowed); } - tree_->expandAll(); + m_tree->expandAll(); auto* ok = makeButton(verb, this, QStringLiteral("primaryAction")); ok->setDefault(true); ok->setEnabled(false); auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); - auto accept_valid = [this, ok] { + auto acceptValid = [this, ok] { if (ok->isEnabled()) { accept(); } }; - connect(tree_, &QTreeWidget::currentItemChanged, this, [ok](QTreeWidgetItem* item) { + connect(m_tree, &QTreeWidget::currentItemChanged, this, [ok](QTreeWidgetItem* item) { ok->setEnabled(item != nullptr && !item->isDisabled()); }); - connect(tree_, &QTreeWidget::itemDoubleClicked, this, accept_valid); - connect(ok, &QPushButton::clicked, this, accept_valid); + connect(m_tree, &QTreeWidget::itemDoubleClicked, this, acceptValid); + connect(ok, &QPushButton::clicked, this, acceptValid); connect(cancel, &QPushButton::clicked, this, &QDialog::reject); addButtonRow(layout, cancel, ok); } -qint64 FolderPickerDialog::folder_id() const { - QTreeWidgetItem* item = tree_->currentItem(); +qint64 FolderPickerDialog::folderId() const { + QTreeWidgetItem* item = m_tree->currentItem(); Q_ASSERT(item != nullptr); return item->data(0, Qt::UserRole).toLongLong(); } diff --git a/src/ui/plugins/organizer/dialogs.hpp b/src/ui/plugins/organizer/dialogs.hpp index 28c1d9b..f54262c 100644 --- a/src/ui/plugins/organizer/dialogs.hpp +++ b/src/ui/plugins/organizer/dialogs.hpp @@ -26,7 +26,7 @@ class FolderNameDialog : public QDialog { QString textValue() const; private: - QLineEdit* edit_ = nullptr; + QLineEdit* m_edit = nullptr; }; // Confirms deleting a folder. Its mods become ungrouped; load order is unchanged. @@ -45,10 +45,10 @@ class FolderPickerDialog : public QDialog { const QString& verb = QStringLiteral("Move")); // The id of the highlighted folder. Only meaningful once the confirm button is enabled. - qint64 folder_id() const; + qint64 folderId() const; private: - QTreeWidget* tree_ = nullptr; + QTreeWidget* m_tree = nullptr; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp index 4849a9b..f0c4b04 100644 --- a/src/ui/plugins/organizer/filter_model.cpp +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -5,64 +5,64 @@ namespace px::modrim { OrganizerFilterModel::OrganizerFilterModel(QObject* parent) : QAbstractListModel(parent) {} int OrganizerFilterModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(items_.size()); + return parent.isValid() ? 0 : static_cast(m_items.size()); } QVariant OrganizerFilterModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; - const auto& entry = items_[index.row()]; - const bool tag = entry.tag_id.has_value() || entry.key.startsWith("tag:"); + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; + const auto& entry = m_items[index.row()]; + const bool tag = entry.tagId.has_value() || entry.key.startsWith("tag:"); switch (role) { case Qt::DisplayRole: - case LabelRole: return entry.label; - case CountRole: return entry.count; - case KeyRole: return entry.key; - case SectionRole: + case kLabelRole: return entry.label; + case kCountRole: return entry.count; + case kKeyRole: return entry.key; + case kSectionRole: if (tag) return QStringLiteral("Tags"); - if (entry.provider_id || entry.key.startsWith("provider:")) return QStringLiteral("Providers"); + if (entry.providerId || entry.key.startsWith("provider:")) return QStringLiteral("Providers"); return QStringLiteral("Status"); - case IconRole: + case kIconRole: if (tag) return QStringLiteral("tag"); if (entry.key == "all") return QStringLiteral("grid"); if (entry.key == "active") return QStringLiteral("check-circle"); if (entry.key == "inactive") return QStringLiteral("ban"); - if (entry.provider_id == "steam" || entry.provider_id == "downloaded") return QStringLiteral("steam"); - if (entry.provider_id == "core") return QStringLiteral("grid"); - if (entry.provider_id == "git") return QStringLiteral("git"); + if (entry.providerId == "steam" || entry.providerId == "downloaded") return QStringLiteral("steam"); + if (entry.providerId == "core") return QStringLiteral("grid"); + if (entry.providerId == "git") return QStringLiteral("git"); return QStringLiteral("folder"); default: return {}; } } QHash OrganizerFilterModel::roleNames() const { - return {{Qt::DisplayRole, "filterLabel"}, {CountRole, "count"}, - {IconRole, "iconName"}, {SectionRole, "sectionName"}, {KeyRole, "key"}}; + return {{Qt::DisplayRole, "filterLabel"}, {kCountRole, "count"}, + {kIconRole, "iconName"}, {kSectionRole, "sectionName"}, {kKeyRole, "key"}}; } void OrganizerFilterModel::update(const QList& entries) { - bool same_keys = entries.size() == items_.size(); - for (qsizetype i = 0; same_keys && i < entries.size(); ++i) - same_keys = entries[i].key == items_[i].key; - if (!same_keys) { + bool sameKeys = entries.size() == m_items.size(); + for (qsizetype i = 0; sameKeys && i < entries.size(); ++i) + sameKeys = entries[i].key == m_items[i].key; + if (!sameKeys) { beginResetModel(); - items_ = entries; + m_items = entries; endResetModel(); } else { - items_ = entries; - if (!items_.isEmpty()) - emit dataChanged(index(0), index(static_cast(items_.size()) - 1), - {CountRole, LabelRole, Qt::DisplayRole}); + m_items = entries; + if (!m_items.isEmpty()) + emit dataChanged(index(0), index(static_cast(m_items.size()) - 1), + {kCountRole, kLabelRole, Qt::DisplayRole}); } } -std::optional OrganizerFilterModel::key_at(int row) const { - if (row < 0 || row >= items_.size()) return std::nullopt; - return items_[row].key; +std::optional OrganizerFilterModel::keyAt(int row) const { + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].key; } -organizer::TreeFilter OrganizerFilterModel::for_key(const QString& key) const { - for (const auto& entry : items_) if (entry.key == key) return entry; - if (!items_.isEmpty()) return items_.first(); +organizer::TreeFilter OrganizerFilterModel::forKey(const QString& key) const { + for (const auto& entry : m_items) if (entry.key == key) return entry; + if (!m_items.isEmpty()) return m_items.first(); return {QStringLiteral("all"), QStringLiteral("All"), 0}; } diff --git a/src/ui/plugins/organizer/filter_model.hpp b/src/ui/plugins/organizer/filter_model.hpp index ad01d68..693f084 100644 --- a/src/ui/plugins/organizer/filter_model.hpp +++ b/src/ui/plugins/organizer/filter_model.hpp @@ -9,18 +9,18 @@ namespace px::modrim { class OrganizerFilterModel : public QAbstractListModel { Q_OBJECT public: - enum Role { LabelRole = Qt::UserRole + 1, CountRole, IconRole, SectionRole, KeyRole }; + enum Role { kLabelRole = Qt::UserRole + 1, kCountRole, kIconRole, kSectionRole, kKeyRole }; Q_ENUM(Role) explicit OrganizerFilterModel(QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; void update(const QList& entries); - std::optional key_at(int row) const; - organizer::TreeFilter for_key(const QString& key) const; + std::optional keyAt(int row) const; + organizer::TreeFilter forKey(const QString& key) const; private: - QList items_; + QList m_items; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index c344930..6bc3935 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -9,18 +9,18 @@ namespace px::modrim { namespace { using namespace organizer; -QString provider_icon(const QString& id) { +QString providerIcon(const QString& id) { if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); if (id == "core") return QStringLiteral("grid"); if (id == "git") return QStringLiteral("git"); return QStringLiteral("folder"); } -int check_state(CheckState state) { +int checkState(CheckState state) { switch (state) { - case CheckState::On: return Qt::Checked; - case CheckState::Partial: return Qt::PartiallyChecked; - case CheckState::Off: return Qt::Unchecked; + case CheckState::kOn: return Qt::Checked; + case CheckState::kPartial: return Qt::PartiallyChecked; + case CheckState::kOff: return Qt::Unchecked; } return Qt::Unchecked; } @@ -28,7 +28,7 @@ int check_state(CheckState state) { std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, bool filtering) { auto top = std::make_unique(); top->kind = QStringLiteral("root"); - auto add_mods = [&](TreeNode* parent, const QList& leaves) { + auto addMods = [&](TreeNode* parent, const QList& leaves) { for (const auto& leaf : leaves) { auto item = std::make_unique(); item->kind = QStringLiteral("mod"); @@ -37,15 +37,15 @@ std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, const auto it = mods.constFind(leaf.uuid); if (it != mods.cend() && *it) { const auto& mod = **it; - item->provider_id = mod.provider_id; - item->provider_label = provider_label(mod.provider_id, mod_package_id(mod).value_or(QString{})); + item->providerId = mod.providerId; + item->providerLabel = providerLabel(mod.providerId, modPackageId(mod).value_or(QString{})); if (const auto* about = dynamic_cast(&mod)) - item->mod_version = about->mod_version; + item->modVersion = about->modVersion; } parent->add(std::move(item)); } }; - auto add_folder = [&](auto&& self, TreeNode* parent, const FolderNode& folder) -> void { + auto addFolder = [&](auto&& self, TreeNode* parent, const FolderNode& folder) -> void { auto item = std::make_unique(); item->kind = QStringLiteral("folder"); item->key = "f:" + QString::number(folder.folder.id); @@ -53,39 +53,39 @@ std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, TreeNode* ptr = item.get(); parent->add(std::move(item)); for (const auto& child : folder.children) self(self, ptr, child); - add_mods(ptr, folder.mods); + addMods(ptr, folder.mods); }; - for (const auto& child : root.children) add_folder(add_folder, top.get(), child); + for (const auto& child : root.children) addFolder(addFolder, top.get(), child); if (!root.mods.isEmpty() || !filtering) { auto item = std::make_unique(); item->kind = QStringLiteral("ungrouped"); - item->key = UNGROUPED_KEY; + item->key = kUngroupedKey; item->folder = &root; - add_mods(item.get(), root.mods); + addMods(item.get(), root.mods); top->add(std::move(item)); } return top; } -bool same_shape(const TreeNode& a, const TreeNode& b) { +bool sameShape(const TreeNode& a, const TreeNode& b) { if (a.key != b.key || a.children.size() != b.children.size()) return false; for (size_t i = 0; i < a.children.size(); ++i) - if (!same_shape(*a.children[i], *b.children[i])) return false; + if (!sameShape(*a.children[i], *b.children[i])) return false; return true; } -void copy_payload(TreeNode& dst, TreeNode& src) { +void copyPayload(TreeNode& dst, TreeNode& src) { dst.folder = src.folder; dst.leaf = src.leaf; - dst.provider_id = std::move(src.provider_id); - dst.provider_label = std::move(src.provider_label); - dst.mod_version = std::move(src.mod_version); + dst.providerId = std::move(src.providerId); + dst.providerLabel = std::move(src.providerLabel); + dst.modVersion = std::move(src.modVersion); for (size_t i = 0; i < dst.children.size(); ++i) - copy_payload(*dst.children[i], *src.children[i]); + copyPayload(*dst.children[i], *src.children[i]); } -const QList diagnostic_roles{ModTreeModel::HasErrorRole, ModTreeModel::ErrorTooltipRole, - ModTreeModel::HasWarningRole, ModTreeModel::WarningTooltipRole}; +const QList kDiagnosticRoles{ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, + ModTreeModel::kHasWarningRole, ModTreeModel::kWarningTooltipRole}; } // namespace void TreeNode::add(std::unique_ptr child) { @@ -107,26 +107,26 @@ QList TreeNode::walk() const { } TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, const organizer::FolderNode* folder, const organizer::ModLeaf* leaf, - QString provider_id) + QString providerId) : kind(std::move(kind)), key(std::move(key)), parent(parent), folder(folder), leaf(leaf), - provider_id(std::move(provider_id)) {} + providerId(std::move(providerId)) {} -qint64 TreeNode::folder_id() const { +qint64 TreeNode::folderId() const { if (kind == "folder" && folder) return folder->folder.id; - if (kind == "mod" && parent) return parent->folder_id(); - return organizer::ROOT_ID; + if (kind == "mod" && parent) return parent->folderId(); + return organizer::kRootId; } -bool TreeNode::is_container() const { return kind == "folder" || kind == "ungrouped"; } +bool TreeNode::isContainer() const { return kind == "folder" || kind == "ungrouped"; } -ModTreeModel::ModTreeModel(SummaryFor summary_for, QObject* parent) - : QAbstractItemModel(parent), summary_for_(std::move(summary_for)), root_(std::make_unique()) { - root_->kind = QStringLiteral("root"); +ModTreeModel::ModTreeModel(SummaryFor summaryFor, QObject* parent) + : QAbstractItemModel(parent), m_summaryFor(std::move(summaryFor)), m_root(std::make_unique()) { + m_root->kind = QStringLiteral("root"); } TreeNode* ModTreeModel::node(const QModelIndex& index) const { - return index.isValid() ? static_cast(index.internalPointer()) : root_.get(); + return index.isValid() ? static_cast(index.internalPointer()) : m_root.get(); } QModelIndex ModTreeModel::index(int row, int column, const QModelIndex& parent) const { @@ -138,7 +138,7 @@ QModelIndex ModTreeModel::index(int row, int column, const QModelIndex& parent) QModelIndex ModTreeModel::parent(const QModelIndex& child) const { if (!child.isValid()) return {}; const auto* up = node(child)->parent; - return !up || up == root_.get() ? QModelIndex{} : index_of(up); + return !up || up == m_root.get() ? QModelIndex{} : indexOf(up); } int ModTreeModel::rowCount(const QModelIndex& parent) const { @@ -154,126 +154,126 @@ QVariant ModTreeModel::data(const QModelIndex& index, int role) const { if (item->kind == "folder" && item->folder) return item->folder->folder.name; return item->leaf ? item->leaf->name : QString{}; } - if (role == KindRole) return item->kind; - if (role == KeyRole) return item->key; - if (role == SelectedRole) return selected_.contains(item->key); - if (role == FilteringRole) return filtering_; + if (role == kKindRole) return item->kind; + if (role == kKeyRole) return item->key; + if (role == kSelectedRole) return m_selected.contains(item->key); + if (role == kFilteringRole) return m_filtering; if (item->kind != "mod") { if (!item->folder) return {}; const auto& folder = *item->folder; const bool own = item->kind == "ungrouped"; switch (role) { - case CheckStateRole: return check_state(own ? folder.own_check() : folder.check); - case EnabledCountRole: return own ? folder.own_enabled : folder.enabled; - case TotalCountRole: return own ? folder.own_total : folder.total; - case VisibleCountRole: return own ? static_cast(folder.mods.size()) : folder.visible_total; - case HasRuleRole: return folder.has_rules && !own; + case kCheckStateRole: return checkState(own ? folder.ownCheck() : folder.check); + case kEnabledCountRole: return own ? folder.ownEnabled : folder.enabled; + case kTotalCountRole: return own ? folder.ownTotal : folder.total; + case kVisibleCountRole: return own ? static_cast(folder.mods.size()) : folder.visibleTotal; + case kHasRuleRole: return folder.hasRules && !own; default: return {}; } } if (!item->leaf) return {}; const auto& leaf = *item->leaf; switch (role) { - case UuidRole: return leaf.uuid; - case PackageIdRole: return leaf.package_id.value_or(QString{}); - case CheckStateRole: return leaf.enabled ? Qt::Checked : Qt::Unchecked; - case ProviderLabelRole: return item->provider_label; - case ModVersionRole: return item->mod_version; - case ProviderIconRole: return provider_icon(item->provider_id); - case ProviderColorRole: { - const auto it = provider_colors_.constFind(item->provider_id); - return it != provider_colors_.cend() ? *it : paletteValue(u"TEXT_DIM"); + case kUuidRole: return leaf.uuid; + case kPackageIdRole: return leaf.packageId.value_or(QString{}); + case kCheckStateRole: return leaf.enabled ? Qt::Checked : Qt::Unchecked; + case kProviderLabelRole: return item->providerLabel; + case kModVersionRole: return item->modVersion; + case kProviderIconRole: return providerIcon(item->providerId); + case kProviderColorRole: { + const auto it = m_providerColors.constFind(item->providerId); + return it != m_providerColors.cend() ? *it : paletteValue(u"TEXT_DIM"); } - case CanPlaceRole: return leaf.package_id.has_value(); - case TagsRole: return tag_rows_.value(leaf.package_id.value_or(QString{})); + case kCanPlaceRole: return leaf.packageId.has_value(); + case kTagsRole: return m_tagRows.value(leaf.packageId.value_or(QString{})); default: break; } - if (diagnostic_roles.contains(role)) { - const auto summary = leaf.enabled ? summary_for_(leaf.uuid) : std::nullopt; - if (role == HasErrorRole) return summary && summary->has_errors; - if (role == HasWarningRole) return summary && summary->has_warnings; - if (role == ErrorTooltipRole) return summary ? summary->error_tooltip : QString{}; - return summary ? summary->warning_tooltip : QString{}; + if (kDiagnosticRoles.contains(role)) { + const auto summary = leaf.enabled ? m_summaryFor(leaf.uuid) : std::nullopt; + if (role == kHasErrorRole) return summary && summary->hasErrors; + if (role == kHasWarningRole) return summary && summary->hasWarnings; + if (role == kErrorTooltipRole) return summary ? summary->errorTooltip : QString{}; + return summary ? summary->warningTooltip : QString{}; } return {}; } QHash ModTreeModel::roleNames() const { - return {{Qt::DisplayRole, "name"}, {KindRole, "kind"}, {KeyRole, "key"}, - {UuidRole, "uuid"}, {PackageIdRole, "packageId"}, {CheckStateRole, "checkState"}, - {EnabledCountRole, "enabledCount"}, {TotalCountRole, "totalCount"}, - {VisibleCountRole, "visibleCount"}, {FilteringRole, "filtering"}, {HasRuleRole, "hasRule"}, - {ProviderIconRole, "providerIcon"}, {ProviderLabelRole, "providerLabel"}, - {ModVersionRole, "modVersion"}, {ProviderColorRole, "providerColor"}, - {HasErrorRole, "hasError"}, {ErrorTooltipRole, "errorTooltip"}, - {HasWarningRole, "hasWarning"}, {WarningTooltipRole, "warningTooltip"}, - {CanPlaceRole, "canPlace"}, {SelectedRole, "selected"}, {TagsRole, "tags"}}; + return {{Qt::DisplayRole, "name"}, {kKindRole, "kind"}, {kKeyRole, "key"}, + {kUuidRole, "uuid"}, {kPackageIdRole, "packageId"}, {kCheckStateRole, "checkState"}, + {kEnabledCountRole, "enabledCount"}, {kTotalCountRole, "totalCount"}, + {kVisibleCountRole, "visibleCount"}, {kFilteringRole, "filtering"}, {kHasRuleRole, "hasRule"}, + {kProviderIconRole, "providerIcon"}, {kProviderLabelRole, "providerLabel"}, + {kModVersionRole, "modVersion"}, {kProviderColorRole, "providerColor"}, + {kHasErrorRole, "hasError"}, {kErrorTooltipRole, "errorTooltip"}, + {kHasWarningRole, "hasWarning"}, {kWarningTooltipRole, "warningTooltip"}, + {kCanPlaceRole, "canPlace"}, {kSelectedRole, "selected"}, {kTagsRole, "tags"}}; } -bool ModTreeModel::filtering() const { return filtering_; } +bool ModTreeModel::filtering() const { return m_filtering; } -void ModTreeModel::set_tree(const organizer::FolderNode& root, - const organizer::ModsByUuid& mods, const QHash& provider_colors, +void ModTreeModel::setTree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, const QHash& providerColors, bool filtering, const QHash& tags) { auto payload = std::make_unique(root); auto fresh = build(*payload, mods, filtering); - provider_colors_ = provider_colors; - tag_rows_.clear(); + m_providerColors = providerColors; + m_tagRows.clear(); for (const auto* item : fresh->walk()) { - if (!item->leaf || !item->leaf->package_id || item->leaf->package_id->isEmpty() || item->leaf->tag_ids.isEmpty()) continue; - QList ids(item->leaf->tag_ids.begin(), item->leaf->tag_ids.end()); + if (!item->leaf || !item->leaf->packageId || item->leaf->packageId->isEmpty() || item->leaf->tagIds.isEmpty()) continue; + QList ids(item->leaf->tagIds.begin(), item->leaf->tagIds.end()); std::sort(ids.begin(), ids.end()); QVariantList rows; for (qint64 id : ids) { const auto tag = tags.constFind(id); if (tag != tags.cend()) rows.append(QVariantMap{{"name", tag->name}, {"color", tag->color}}); } - tag_rows_.insert(*item->leaf->package_id, rows); + m_tagRows.insert(*item->leaf->packageId, rows); } - if (filtering_ == filtering && same_shape(*root_, *fresh)) { - copy_payload(*root_, *fresh); - payload_ = std::move(payload); - emit_all_changed(root_.get()); + if (m_filtering == filtering && sameShape(*m_root, *fresh)) { + copyPayload(*m_root, *fresh); + m_payload = std::move(payload); + emitAllChanged(m_root.get()); } else { beginResetModel(); - root_ = std::move(fresh); - payload_ = std::move(payload); - filtering_ = filtering; + m_root = std::move(fresh); + m_payload = std::move(payload); + m_filtering = filtering; endResetModel(); } QSet live; - for (const auto* item : root_->walk()) live.insert(item->key); - selected_.intersect(live); + for (const auto* item : m_root->walk()) live.insert(item->key); + m_selected.intersect(live); } -QModelIndex ModTreeModel::index_of(const TreeNode* item) const { - return item == root_.get() ? QModelIndex{} : createIndex(item->row, 0, const_cast(item)); +QModelIndex ModTreeModel::indexOf(const TreeNode* item) const { + return item == m_root.get() ? QModelIndex{} : createIndex(item->row, 0, const_cast(item)); } -void ModTreeModel::emit_all_changed(TreeNode* item, const QList& roles) { +void ModTreeModel::emitAllChanged(TreeNode* item, const QList& roles) { if (item->children.empty()) return; - const auto parent = index_of(item); + const auto parent = indexOf(item); emit dataChanged(index(0, 0, parent), index(static_cast(item->children.size()) - 1, 0, parent), roles); - for (const auto& child : item->children) emit_all_changed(child.get(), roles); + for (const auto& child : item->children) emitAllChanged(child.get(), roles); } -void ModTreeModel::refresh_diagnostics() { emit_all_changed(root_.get(), diagnostic_roles); } -TreeNode* ModTreeModel::node_at(const QModelIndex& index) const { return index.isValid() ? node(index) : nullptr; } -QModelIndex ModTreeModel::index_for_key(const QString& key) const { - for (const auto* item : root_->walk()) if (item->key == key) return index_of(item); +void ModTreeModel::refreshDiagnostics() { emitAllChanged(m_root.get(), kDiagnosticRoles); } +TreeNode* ModTreeModel::nodeAt(const QModelIndex& index) const { return index.isValid() ? node(index) : nullptr; } +QModelIndex ModTreeModel::indexForKey(const QString& key) const { + for (const auto* item : m_root->walk()) if (item->key == key) return indexOf(item); return {}; } -QModelIndex ModTreeModel::index_for_uuid(const QString& uuid) const { - for (const auto* item : root_->walk()) - if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) return index_of(item); +QModelIndex ModTreeModel::indexForUuid(const QString& uuid) const { + for (const auto* item : m_root->walk()) + if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) return indexOf(item); return {}; } -QList ModTreeModel::expanded_indexes(const QModelIndex& parent) const { +QList ModTreeModel::expandedIndexes(const QModelIndex& parent) const { QList result; auto visit = [&](auto&& self, const TreeNode* item) -> void { for (const auto& child : item->children) { - if (child->is_container() && (filtering_ || (child->folder && !child->folder->folder.collapsed))) { - result.append(index_of(child.get())); + if (child->isContainer() && (m_filtering || (child->folder && !child->folder->folder.collapsed))) { + result.append(indexOf(child.get())); self(self, child.get()); } } @@ -281,34 +281,34 @@ QList ModTreeModel::expanded_indexes(const QModelIndex& parent) con visit(visit, node(parent)); return result; } -QList ModTreeModel::selected_nodes() const { +QList ModTreeModel::selectedNodes() const { QList result; - for (auto* item : root_->walk()) if (selected_.contains(item->key)) result.append(item); + for (auto* item : m_root->walk()) if (m_selected.contains(item->key)) result.append(item); return result; } -bool ModTreeModel::is_selected(const QModelIndex& index) const { - const auto* item = node_at(index); - return item && selected_.contains(item->key); +bool ModTreeModel::isSelected(const QModelIndex& index) const { + const auto* item = nodeAt(index); + return item && m_selected.contains(item->key); } void ModTreeModel::select(const QList& indexes, const QString& mode) { QSet keys; - for (const auto& index : indexes) if (const auto* item = node_at(index)) keys.insert(item->key); - const auto before = selected_; - if (mode == "replace") selected_ = keys; - else if (mode == "toggle") selected_ = (selected_ - keys) | (keys - selected_); - else selected_.unite(keys); - notify_selection((before - selected_) | (selected_ - before)); + for (const auto& index : indexes) if (const auto* item = nodeAt(index)) keys.insert(item->key); + const auto before = m_selected; + if (mode == "replace") m_selected = keys; + else if (mode == "toggle") m_selected = (m_selected - keys) | (keys - m_selected); + else m_selected.unite(keys); + notifySelection((before - m_selected) | (m_selected - before)); } -void ModTreeModel::clear_selection() { - const auto before = selected_; - selected_.clear(); - notify_selection(before); +void ModTreeModel::clearSelection() { + const auto before = m_selected; + m_selected.clear(); + notifySelection(before); } -void ModTreeModel::notify_selection(const QSet& changed) { +void ModTreeModel::notifySelection(const QSet& changed) { if (changed.isEmpty()) return; - for (const auto* item : root_->walk()) if (changed.contains(item->key)) { - const auto index = index_of(item); - emit dataChanged(index, index, {SelectedRole}); + for (const auto* item : m_root->walk()) if (changed.contains(item->key)) { + const auto index = indexOf(item); + emit dataChanged(index, index, {kSelectedRole}); } } diff --git a/src/ui/plugins/organizer/tree_model.hpp b/src/ui/plugins/organizer/tree_model.hpp index 5d767fd..befc0be 100644 --- a/src/ui/plugins/organizer/tree_model.hpp +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -12,7 +12,7 @@ namespace px::modrim { -inline const QString UNGROUPED_KEY = QStringLiteral("ungrouped"); +inline const QString kUngroupedKey = QStringLiteral("ungrouped"); struct TreeNode { QString kind; @@ -20,34 +20,34 @@ struct TreeNode { TreeNode* parent = nullptr; const organizer::FolderNode* folder = nullptr; const organizer::ModLeaf* leaf = nullptr; - QString provider_id; - QString provider_label; - QString mod_version; + QString providerId; + QString providerLabel; + QString modVersion; int row = 0; std::vector> children; void add(std::unique_ptr child); QList walk() const; - qint64 folder_id() const; + qint64 folderId() const; TreeNode(QString kind = QStringLiteral("root"), QString key = {}, TreeNode* parent = nullptr, const organizer::FolderNode* folder = nullptr, - const organizer::ModLeaf* leaf = nullptr, QString provider_id = {}); - bool is_container() const; + const organizer::ModLeaf* leaf = nullptr, QString providerId = {}); + bool isContainer() const; }; class ModTreeModel : public QAbstractItemModel { Q_OBJECT public: enum Role { - KindRole = Qt::UserRole + 1, KeyRole, UuidRole, PackageIdRole, - CheckStateRole, EnabledCountRole, TotalCountRole, VisibleCountRole, - FilteringRole, HasRuleRole, ProviderIconRole, ProviderColorRole, - HasErrorRole, ErrorTooltipRole, CanPlaceRole, SelectedRole, - HasWarningRole, WarningTooltipRole, TagsRole, ProviderLabelRole, ModVersionRole + kKindRole = Qt::UserRole + 1, kKeyRole, kUuidRole, kPackageIdRole, + kCheckStateRole, kEnabledCountRole, kTotalCountRole, kVisibleCountRole, + kFilteringRole, kHasRuleRole, kProviderIconRole, kProviderColorRole, + kHasErrorRole, kErrorTooltipRole, kCanPlaceRole, kSelectedRole, + kHasWarningRole, kWarningTooltipRole, kTagsRole, kProviderLabelRole, kModVersionRole }; Q_ENUM(Role) using SummaryFor = std::function(const QString&)>; - explicit ModTreeModel(SummaryFor summary_for, QObject* parent = nullptr); + explicit ModTreeModel(SummaryFor summaryFor, QObject* parent = nullptr); QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; QModelIndex parent(const QModelIndex& child) const override; int rowCount(const QModelIndex& parent = {}) const override; @@ -56,32 +56,32 @@ class ModTreeModel : public QAbstractItemModel { QHash roleNames() const override; bool filtering() const; - void set_tree(const organizer::FolderNode& root, + void setTree(const organizer::FolderNode& root, const organizer::ModsByUuid& mods, - const QHash& provider_colors, bool filtering, + const QHash& providerColors, bool filtering, const QHash& tags = {}); - void refresh_diagnostics(); - TreeNode* node_at(const QModelIndex& index) const; - QModelIndex index_for_key(const QString& key) const; - QModelIndex index_for_uuid(const QString& uuid) const; - QList expanded_indexes(const QModelIndex& parent = {}) const; - QList selected_nodes() const; - bool is_selected(const QModelIndex& index) const; + void refreshDiagnostics(); + TreeNode* nodeAt(const QModelIndex& index) const; + QModelIndex indexForKey(const QString& key) const; + QModelIndex indexForUuid(const QString& uuid) const; + QList expandedIndexes(const QModelIndex& parent = {}) const; + QList selectedNodes() const; + bool isSelected(const QModelIndex& index) const; void select(const QList& indexes, const QString& mode = QStringLiteral("replace")); - void clear_selection(); + void clearSelection(); private: TreeNode* node(const QModelIndex& index) const; - QModelIndex index_of(const TreeNode* node) const; - void emit_all_changed(TreeNode* node, const QList& roles = {}); - void notify_selection(const QSet& changed); - SummaryFor summary_for_; - std::unique_ptr root_; - std::unique_ptr payload_; - bool filtering_ = false; - QHash provider_colors_; - QHash tag_rows_; - QSet selected_; + QModelIndex indexOf(const TreeNode* node) const; + void emitAllChanged(TreeNode* node, const QList& roles = {}); + void notifySelection(const QSet& changed); + SummaryFor m_summaryFor; + std::unique_ptr m_root; + std::unique_ptr m_payload; + bool m_filtering = false; + QHash m_providerColors; + QHash m_tagRows; + QSet m_selected; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp index 2cba6e1..1fe8985 100644 --- a/src/ui/plugins/organizer/view.cpp +++ b/src/ui/plugins/organizer/view.cpp @@ -56,20 +56,20 @@ QPushButton* button(const QString& text, QWidget* parent, QHBoxLayout* layout) { } OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx, engine, parent, app), service_(organizerService(ctx)), - model_(new ModTreeModel([&ctx](const QString& uuid) { return ctx.diagnosticsService().summary_for(uuid); }, this)), - filters_(new OrganizerFilterModel(this)) { + : BaseViewPanel(ctx, engine, parent, app), m_service(organizerService(ctx)), + m_model(new ModTreeModel([&ctx](const QString& uuid) { return ctx.diagnosticsService().summaryFor(uuid); }, this)), + m_filters(new OrganizerFilterModel(this)) { auto* content = new QWidget(this); content->setObjectName(QStringLiteral("contentArea")); auto* row = new QHBoxLayout(content); row->setContentsMargins(0, 0, 0, 0); row->setSpacing(0); - auto* sidebar = new FilterSidebar(engine, filters_, content); - sidebar->setFixedWidth(SIDEBAR_WIDTH); + auto* sidebar = new FilterSidebar(engine, m_filters, content); + sidebar->setFixedWidth(kSidebarWidth); row->addWidget(sidebar); connect(sidebar, &FilterSidebar::activated, this, [this](int index) { - const auto key = filters_->key_at(index); - if (key && *key != filter_key_) { filter_key_ = *key; invalidate(); } + const auto key = m_filters->keyAt(index); + if (key && *key != m_filterKey) { m_filterKey = *key; invalidate(); } }); auto* main = new QWidget(content); main->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); @@ -81,10 +81,10 @@ OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, QWi toolbar->setFixedHeight(56); auto* tools = new QHBoxLayout(toolbar); tools->setContentsMargins(16, 8, 16, 8); - search_ = new QLineEdit(toolbar); - search_->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); - search_->setClearButtonEnabled(true); - tools->addWidget(search_, 1); + m_search = new QLineEdit(toolbar); + m_search->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); + m_search->setClearButtonEnabled(true); + tools->addWidget(m_search, 1); auto* create = button(QStringLiteral("New folder"), toolbar, tools); create->setObjectName(QStringLiteral("primaryAction")); connect(create, &QPushButton::clicked, this, &OrganizerViewPanel::newFolder); @@ -95,154 +95,154 @@ OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, QWi connect(button(QStringLiteral("Collapse all"), toolbar, tools), &QPushButton::clicked, this, [this] { treeCommand("collapseAll"); }); layout->addWidget(toolbar); - qml_ = new QQuickWidget(engine, main); - qml_->setObjectName(QStringLiteral("organizerTree")); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(paletteColor(u"ELEVATE_1")); - qml_->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); - qml_->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), model_); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); - rules_dialog_ = new QDialog(this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); - rules_dialog_->setObjectName(QStringLiteral("organizerRuleEditorWindow")); - rules_dialog_->setWindowTitle(QStringLiteral("Auto-Folder Rules")); - rules_dialog_->setWindowModality(Qt::WindowModal); - rules_dialog_->setMinimumSize(720, 520); - rules_dialog_->setSizeGripEnabled(true); - auto* rules_layout = new QVBoxLayout(rules_dialog_); - rules_layout->setContentsMargins(0, 0, 0, 0); - rules_qml_ = new QQuickWidget(engine, rules_dialog_); - rules_qml_->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); - rules_qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - rules_qml_->setClearColor(paletteColor(u"ELEVATE_2")); - rules_qml_->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); - rules_qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); - rules_layout->addWidget(rules_qml_); - installDialogChrome(rules_dialog_); - connect(rules_dialog_, &QDialog::finished, this, [this] { - if (auto* root = rules_qml_->rootObject()) QMetaObject::invokeMethod(root, "closeEditor"); + m_qml = new QQuickWidget(engine, main); + m_qml->setObjectName(QStringLiteral("organizerTree")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), m_model); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); + m_rulesDialog = new QDialog(this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); + m_rulesDialog->setObjectName(QStringLiteral("organizerRuleEditorWindow")); + m_rulesDialog->setWindowTitle(QStringLiteral("Auto-Folder Rules")); + m_rulesDialog->setWindowModality(Qt::WindowModal); + m_rulesDialog->setMinimumSize(720, 520); + m_rulesDialog->setSizeGripEnabled(true); + auto* rulesLayout = new QVBoxLayout(m_rulesDialog); + rulesLayout->setContentsMargins(0, 0, 0, 0); + m_rulesQml = new QQuickWidget(engine, m_rulesDialog); + m_rulesQml->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); + m_rulesQml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_rulesQml->setClearColor(paletteColor(u"ELEVATE_2")); + m_rulesQml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); + m_rulesQml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); + rulesLayout->addWidget(m_rulesQml); + installDialogChrome(m_rulesDialog); + connect(m_rulesDialog, &QDialog::finished, this, [this] { + if (auto* root = m_rulesQml->rootObject()) QMetaObject::invokeMethod(root, "closeEditor"); }); auto* hint = new QWidget(main); hint->setObjectName(QStringLiteral("organizerHint")); auto* hints = new QHBoxLayout(hint); hints->setContentsMargins(12, 5, 12, 5); - auto* hint_text = new QLabel(QStringLiteral("Folders and tags are for browsing only. Edit load order in Mods; checkboxes enable or disable the same mods."), hint); - hint_text->setWordWrap(true); - hints->addWidget(hint_text, 1); + auto* hintText = new QLabel(QStringLiteral("Folders and tags are for browsing only. Edit load order in Mods; checkboxes enable or disable the same mods."), hint); + hintText->setWordWrap(true); + hints->addWidget(hintText, 1); auto* dismiss = button(QStringLiteral("×"), hint, hints); dismiss->setToolTip(QStringLiteral("Dismiss")); connect(dismiss, &QPushButton::clicked, hint, &QWidget::hide); layout->addWidget(hint); - layout->addWidget(qml_, 1); - selection_bar_ = new QWidget(main); - auto* bar = new QHBoxLayout(selection_bar_); + layout->addWidget(m_qml, 1); + m_selectionBar = new QWidget(main); + auto* bar = new QHBoxLayout(m_selectionBar); bar->setContentsMargins(12, 6, 12, 6); - selection_label_ = new QLabel(selection_bar_); - bar->addWidget(selection_label_); + m_selectionLabel = new QLabel(m_selectionBar); + bar->addWidget(m_selectionLabel); const QList> actions{{QStringLiteral("Enable"), QStringLiteral("enable")}, {QStringLiteral("Disable"), QStringLiteral("disable")}, {QStringLiteral("Move to folder…"), QStringLiteral("move")}, {QStringLiteral("Manage tags…"), QStringLiteral("tags")}, {QStringLiteral("Create folder from selection"), QStringLiteral("create")}, {QStringLiteral("Update"), QStringLiteral("update")}}; for (const auto& [label, action] : actions) { - auto* item = button(label, selection_bar_, bar); - if (action == QStringLiteral("update")) update_button_ = item; + auto* item = button(label, m_selectionBar, bar); + if (action == QStringLiteral("update")) m_updateButton = item; connect(item, &QPushButton::clicked, this, [this, action] { selectionAction(action); }); } - selection_bar_->hide(); - layout->addWidget(selection_bar_); + m_selectionBar->hide(); + layout->addWidget(m_selectionBar); row->addWidget(main, 3); - info_ = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); - info_->setObjectName(QStringLiteral("modInfoPanel")); - info_->setMinimumWidth(300); - row->addWidget(info_, 2); - selection_ = std::make_unique(ctx, *info_); - connect(info_, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { - const auto index = model_->index_for_uuid(uuid); + m_info = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + m_info->setObjectName(QStringLiteral("modInfoPanel")); + m_info->setMinimumWidth(300); + row->addWidget(m_info, 2); + m_selection = std::make_unique(ctx, *m_info); + connect(m_info, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { + const auto index = m_model->indexForUuid(uuid); if (index.isValid()) selectSingle(index); }); - root_->addWidget(content, 1); - status_ = new QLabel(this); - status_->setObjectName(QStringLiteral("organizerStatus")); - root_->addWidget(status_); - menu_ = new QMenu(this); - menu_->setToolTipsVisible(true); - search_timer_ = new QTimer(this); - search_timer_->setSingleShot(true); - search_timer_->setInterval(220); - connect(search_, &QLineEdit::textChanged, this, [this] { search_timer_->start(); }); - connect(search_timer_, &QTimer::timeout, this, [this] { - if (search_text_ != search_->text()) { search_text_ = search_->text(); invalidate(); } + m_root->addWidget(content, 1); + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("organizerStatus")); + m_root->addWidget(m_status); + m_menu = new QMenu(this); + m_menu->setToolTipsVisible(true); + m_searchTimer = new QTimer(this); + m_searchTimer->setSingleShot(true); + m_searchTimer->setInterval(220); + connect(m_search, &QLineEdit::textChanged, this, [this] { m_searchTimer->start(); }); + connect(m_searchTimer, &QTimer::timeout, this, [this] { + if (m_searchText != m_search->text()) { m_searchText = m_search->text(); invalidate(); } }); - connect(model_, &QAbstractItemModel::modelReset, this, [this] { selection_anchor_ = QPersistentModelIndex{}; }); - changed_id_ = service_.changed.connect([this](const auto&) { invalidate(); Q_EMIT editorChanged(); }); - active_id_ = ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); - mods_id_ = ctx.modService().mods_changed.connect([this](const auto&) { invalidate(); }); - diagnostics_id_ = ctx.diagnosticsService().diagnostics_summary_changed.connect([this](const auto&) { - model_->refresh_diagnostics(); - if (!selected_uuid_.isEmpty()) info_->setIssues(ctx_.diagnosticsService().issues_for(selected_uuid_)); + connect(m_model, &QAbstractItemModel::modelReset, this, [this] { m_selectionAnchor = QPersistentModelIndex{}; }); + m_changedId = m_service.changed.connect([this](const auto&) { invalidate(); Q_EMIT editorChanged(); }); + m_activeId = ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); + m_modsId = ctx.modService().modsChanged.connect([this](const auto&) { invalidate(); }); + m_diagnosticsId = ctx.diagnosticsService().diagnosticsSummaryChanged.connect([this](const auto&) { + m_model->refreshDiagnostics(); + if (!m_selectedUuid.isEmpty()) m_info->setIssues(m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); }); - busy_id_ = ctx.downloads().busy_changed.connect([this](bool) { syncInfo(); }); + m_busyId = ctx.downloads().busyChanged.connect([this](bool) { syncInfo(); }); } OrganizerViewPanel::~OrganizerViewPanel() { - service_.changed.disconnect(changed_id_); - ctx_.activeStateChanged().disconnect(active_id_); - ctx_.modService().mods_changed.disconnect(mods_id_); - ctx_.diagnosticsService().diagnostics_summary_changed.disconnect(diagnostics_id_); - ctx_.downloads().busy_changed.disconnect(busy_id_); + m_service.changed.disconnect(m_changedId); + m_ctx.activeStateChanged().disconnect(m_activeId); + m_ctx.modService().modsChanged.disconnect(m_modsId); + m_ctx.diagnosticsService().diagnosticsSummaryChanged.disconnect(m_diagnosticsId); + m_ctx.downloads().busyChanged.disconnect(m_busyId); } -bool OrganizerViewPanel::ready() const { return service_.ready(); } +bool OrganizerViewPanel::ready() const { return m_service.ready(); } void OrganizerViewPanel::showEvent(QShowEvent* event) { BaseViewPanel::showEvent(event); - if (dirty_) rebuild(); + if (m_dirty) rebuild(); Q_EMIT readyChanged(); - if (auto* root = rules_qml_->rootObject()) QMetaObject::invokeMethod(root, "schedulePreview"); + if (auto* root = m_rulesQml->rootObject()) QMetaObject::invokeMethod(root, "schedulePreview"); } -void OrganizerViewPanel::invalidate() { dirty_ = true; if (isVisible()) rebuild(); } +void OrganizerViewPanel::invalidate() { m_dirty = true; if (isVisible()) rebuild(); } void OrganizerViewPanel::rebuild() { if (!ready()) return; - dirty_ = false; - filters_->update(service_.tree_filters()); - const auto query = filters_->for_key(filter_key_).query(search_text_); - const auto tree = service_.tree(query); - model_->set_tree(tree, ctx_.allMods(), ctx_.modService().provider_colors(), !query.is_empty(), service_.state().tags); - const auto ungrouped = query.is_empty() ? tree.mods.size() : service_.tree().mods.size(); - status_->setText(QStringLiteral("%1 · %2 active · %3 · %4 ungrouped").arg(plural(ctx_.allMods().size(), QStringLiteral("mod"))).arg(ctx_.activeUuids().size()).arg(plural(service_.state().folders.size() - 1, QStringLiteral("folder"))).arg(ungrouped)); + m_dirty = false; + m_filters->update(m_service.treeFilters()); + const auto query = m_filters->forKey(m_filterKey).query(m_searchText); + const auto tree = m_service.tree(query); + m_model->setTree(tree, m_ctx.allMods(), m_ctx.modService().providerColors(), !query.isEmpty(), m_service.state().tags); + const auto ungrouped = query.isEmpty() ? tree.mods.size() : m_service.tree().mods.size(); + m_status->setText(QStringLiteral("%1 · %2 active · %3 · %4 ungrouped").arg(plural(m_ctx.allMods().size(), QStringLiteral("mod"))).arg(m_ctx.activeUuids().size()).arg(plural(m_service.state().folders.size() - 1, QStringLiteral("folder"))).arg(ungrouped)); syncInfo(true); Q_EMIT readyChanged(); } QStringList OrganizerViewPanel::selectedPackageIds() const { QStringList result; - for (const auto* node : model_->selected_nodes()) if (node->leaf && node->leaf->package_id && !result.contains(*node->leaf->package_id)) result.append(*node->leaf->package_id); + for (const auto* node : m_model->selectedNodes()) if (node->leaf && node->leaf->packageId && !result.contains(*node->leaf->packageId)) result.append(*node->leaf->packageId); return result; } QStringList OrganizerViewPanel::selectedUuids() const { QStringList result; - for (const auto* node : model_->selected_nodes()) if (node->leaf) result.append(node->leaf->uuid); + for (const auto* node : m_model->selectedNodes()) if (node->leaf) result.append(node->leaf->uuid); return result; } void OrganizerViewPanel::syncInfo(bool force) { - const auto nodes = model_->selected_nodes(); + const auto nodes = m_model->selectedNodes(); const auto uuids = selectedUuids(); Q_EMIT editorChanged(); - selection_label_->setText(plural(uuids.size(), QStringLiteral("mod")) + QStringLiteral(" selected")); - selection_bar_->setVisible(uuids.size() > 1); - const auto state = update_state(ctx_, uuids); - update_button_->setVisible(ctx_.downloads().available()); - update_button_->setText(state.label); - update_button_->setEnabled(state.enabled); - update_button_->setToolTip(state.tooltip); + m_selectionLabel->setText(plural(uuids.size(), QStringLiteral("mod")) + QStringLiteral(" selected")); + m_selectionBar->setVisible(uuids.size() > 1); + const auto state = updateState(m_ctx, uuids); + m_updateButton->setVisible(m_ctx.downloads().available()); + m_updateButton->setText(state.label); + m_updateButton->setEnabled(state.enabled); + m_updateButton->setToolTip(state.tooltip); const QString uuid = nodes.size() == 1 && nodes.front()->leaf ? nodes.front()->leaf->uuid : QString{}; - if (uuid == selected_uuid_ && !force) return; - selected_uuid_ = uuid; - if (uuid.isEmpty()) selection_->clear(); else selection_->show(uuid); + if (uuid == m_selectedUuid && !force) return; + m_selectedUuid = uuid; + if (uuid.isEmpty()) m_selection->clear(); else m_selection->show(uuid); } int OrganizerViewPanel::selectedTagCount() const { return selectedPackageIds().size(); } QVariantList OrganizerViewPanel::tagRows() const { QVariantList result; if (!ready()) return result; - auto tags = service_.state().tags.values(); + auto tags = m_service.state().tags.values(); std::ranges::sort(tags, [](const Tag& a, const Tag& b) { return a.name.toCaseFolded() < b.name.toCaseFolded(); }); const auto pids = selectedPackageIds(); for (const auto& tag : tags) { int matched = 0; - for (const auto& pid : pids) if (service_.state().mod_tags.value(pid).contains(tag.id)) ++matched; + for (const auto& pid : pids) if (m_service.state().modTags.value(pid).contains(tag.id)) ++matched; result.append(QVariantMap{{QStringLiteral("id"), tag.id}, {QStringLiteral("name"), tag.name}, {QStringLiteral("color"), tag.color}, {QStringLiteral("checkState"), matched > 0 && matched < pids.size() ? 2 : int(matched > 0)}}); } return result; @@ -250,23 +250,23 @@ QVariantList OrganizerViewPanel::tagRows() const { QVariantList OrganizerViewPanel::ruleRows() const { QVariantList result; if (!ready()) return result; - for (const auto& rule : service_.state().rules) result.append(QVariantMap{{QStringLiteral("field"), QString(rule_field_name(rule.field))}, {QStringLiteral("op"), QString(rule_op_name(rule.op))}, {QStringLiteral("pattern"), rule.pattern}, {QStringLiteral("folder_id"), rule.folder_id}}); + for (const auto& rule : m_service.state().rules) result.append(QVariantMap{{QStringLiteral("field"), QString(ruleFieldName(rule.field))}, {QStringLiteral("op"), QString(ruleOpName(rule.op))}, {QStringLiteral("pattern"), rule.pattern}, {QStringLiteral("folder_id"), rule.folderId}}); return result; } QVariantList OrganizerViewPanel::ruleFolders() const { QVariantList result; if (!ready()) return result; - const auto& folders = service_.state().folders; + const auto& folders = m_service.state().folders; QList> paths; paths.reserve(folders.size() - 1); for (const auto& folder : folders) { - if (folder.id == ROOT_ID) continue; + if (folder.id == kRootId) continue; QStringList names; qint64 id = folder.id; - while (id != ROOT_ID) { + while (id != kRootId) { const auto& ancestor = *folders.constFind(id); names.prepend(ancestor.name); - id = *ancestor.parent_id; + id = *ancestor.parentId; } paths.append({names.join(QStringLiteral(" / ")), folder.id}); } @@ -283,8 +283,8 @@ Result> OrganizerViewPanel::ruleSpecs(const QVariantList& rows) specs.reserve(rows.size()); for (const auto& value : rows) { const auto row = value.toMap(); - const auto field = parse_rule_field(row.value(QStringLiteral("field")).toString()); - const auto op = parse_rule_op(row.value(QStringLiteral("op")).toString()); + const auto field = parseRuleField(row.value(QStringLiteral("field")).toString()); + const auto op = parseRuleOp(row.value(QStringLiteral("op")).toString()); if (!field || !op) return std::unexpected(Error{QStringLiteral("Invalid rule field or operator")}); specs.append(RuleSpec{*field, *op, row.value(QStringLiteral("pattern")).toString(), row.value(QStringLiteral("folder_id")).toLongLong()}); } @@ -296,7 +296,7 @@ QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, in const auto specs = ruleSpecs(rows); if (!specs) co_return; const QPointer alive(this); - auto future = QtConcurrent::run([state = service_.state(), mods = ctx_.allMods(), specs = *specs] { return preview_rule_matches(state, mods, specs); }); + auto future = QtConcurrent::run([state = m_service.state(), mods = m_ctx.allMods(), specs = *specs] { return previewRuleMatches(state, mods, specs); }); const auto preview = co_await future; if (!alive) co_return; QVariantList counts; @@ -308,31 +308,31 @@ QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, in Q_EMIT rulePreviewReady(revision, result); } void OrganizerViewPanel::setEditorError(QString message) { - if (message != editor_error_) { editor_error_ = std::move(message); Q_EMIT editorChanged(); } + if (message != m_editorError) { m_editorError = std::move(message); Q_EMIT editorChanged(); } } void OrganizerViewPanel::reportError(const Error& error) { - if (auto* toasts = app_.toasts()) toasts->error(error.message); + if (auto* toasts = m_app.toasts()) toasts->error(error.message); else spdlog::warn("organizer: {}", error.message.toStdString()); } -void OrganizerViewPanel::treeCommand(const char* method) { if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, method); } +void OrganizerViewPanel::treeCommand(const char* method) { if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, method); } void OrganizerViewPanel::openTags() { setEditorError(); treeCommand("openTags"); } void OrganizerViewPanel::openRules() { setEditorError(); - auto* root = rules_qml_->rootObject(); + auto* root = m_rulesQml->rootObject(); if (!root) return; auto* host = window(); - if (!rules_opened_once_) { + if (!m_rulesOpenedOnce) { const auto available = host->screen()->availableGeometry(); - rules_dialog_->resize(std::min({960, std::max(800, host->width() - 48), available.width() - 32}), std::min({760, std::max(600, host->height() - 80), available.height() - 32})); - rules_opened_once_ = true; + m_rulesDialog->resize(std::min({960, std::max(800, host->width() - 48), available.width() - 32}), std::min({760, std::max(600, host->height() - 80), available.height() - 32})); + m_rulesOpenedOnce = true; } - rules_dialog_->move(host->frameGeometry().center() - rules_dialog_->rect().center()); - rules_dialog_->open(); - rules_dialog_->activateWindow(); - rules_qml_->setFocus(Qt::OtherFocusReason); + m_rulesDialog->move(host->frameGeometry().center() - m_rulesDialog->rect().center()); + m_rulesDialog->open(); + m_rulesDialog->activateWindow(); + m_rulesQml->setFocus(Qt::OtherFocusReason); QMetaObject::invokeMethod(root, "prepareForOpen"); } -void OrganizerViewPanel::closeRules() { rules_dialog_->close(); } +void OrganizerViewPanel::closeRules() { m_rulesDialog->close(); } QCoro::QmlTask OrganizerViewPanel::createTag(QString name, QString color) { return mutateTag(QStringLiteral("create"), 0, std::move(name), std::move(color)); } QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, QString color) { return mutateTag(QStringLiteral("update"), id, std::move(name), std::move(color)); } QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { return mutateTag(QStringLiteral("delete"), id); } @@ -340,15 +340,15 @@ QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { return mutat QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, QString name, QString color, bool add) { const QPointer alive(this); if (operation == QStringLiteral("create")) { - const auto result = co_await service_.create_tag(std::move(name), std::move(color)); + const auto result = co_await m_service.createTag(std::move(name), std::move(color)); if (!alive) co_return; if (!result) { setEditorError(result.error().message); co_return; } Q_EMIT tagCreated(); } else { Result result; - if (operation == QStringLiteral("update")) result = co_await service_.update_tag(id, std::move(name), std::move(color)); - else if (operation == QStringLiteral("delete")) result = co_await service_.delete_tag(id); - else result = co_await service_.set_mod_tags(selectedPackageIds(), add ? QSet{id} : QSet{}, add ? QSet{} : QSet{id}); + if (operation == QStringLiteral("update")) result = co_await m_service.updateTag(id, std::move(name), std::move(color)); + else if (operation == QStringLiteral("delete")) result = co_await m_service.deleteTag(id); + else result = co_await m_service.setModTags(selectedPackageIds(), add ? QSet{id} : QSet{}, add ? QSet{} : QSet{id}); if (!alive) co_return; if (!result) { setEditorError(result.error().message); co_return; } if (operation == QStringLiteral("update")) Q_EMIT tagUpdated(id); @@ -360,7 +360,7 @@ QCoro::Task OrganizerViewPanel::persistRules(QVariantList rows) { const QPointer alive(this); auto specs = ruleSpecs(rows); if (!specs) { setEditorError(specs.error().message); co_return; } - const auto result = co_await service_.set_rules(std::move(*specs)); + const auto result = co_await m_service.setRules(std::move(*specs)); if (!alive) co_return; if (!result) { setEditorError(result.error().message); co_return; } setEditorError(); @@ -369,56 +369,56 @@ QCoro::Task OrganizerViewPanel::persistRules(QVariantList rows) { QCoro::QmlTask OrganizerViewPanel::addStandardRules() { return standardRules(); } QCoro::Task OrganizerViewPanel::standardRules() { const QPointer alive(this); - const auto result = co_await service_.add_standard_rules(); + const auto result = co_await m_service.addStandardRules(); if (!alive) co_return; setEditorError(result ? QString{} : result.error().message); } QVariantList OrganizerViewPanel::expandedIndexes() const { QVariantList result; - for (const auto& index : model_->expanded_indexes()) result.append(QVariant::fromValue(index)); + for (const auto& index : m_model->expandedIndexes()) result.append(QVariant::fromValue(index)); return result; } void OrganizerViewPanel::selectRow(const QModelIndex& index, int modifiers, const QVariantList& range) { - if ((modifiers & Qt::ShiftModifier) && selection_anchor_.isValid()) { + if ((modifiers & Qt::ShiftModifier) && m_selectionAnchor.isValid()) { QList indexes; for (const auto& value : range) indexes.append(value.value()); - model_->select(indexes); + m_model->select(indexes); } else { - model_->select({index}, modifiers & Qt::ControlModifier ? QStringLiteral("toggle") : QStringLiteral("replace")); - selection_anchor_ = index; + m_model->select({index}, modifiers & Qt::ControlModifier ? QStringLiteral("toggle") : QStringLiteral("replace")); + m_selectionAnchor = index; } syncInfo(); } void OrganizerViewPanel::selectSingle(const QModelIndex& index) { - if (!model_->is_selected(index)) model_->select({index}); - selection_anchor_ = index; + if (!m_model->isSelected(index)) m_model->select({index}); + m_selectionAnchor = index; syncInfo(); } QCoro::QmlTask OrganizerViewPanel::persistAllCollapsed(bool collapsed) { return persistAllCollapse(collapsed); } QCoro::Task OrganizerViewPanel::persistAllCollapse(bool collapsed) { const QPointer alive(this); - const auto result = co_await service_.set_all_collapsed(collapsed); + const auto result = co_await m_service.setAllCollapsed(collapsed); if (!alive) co_return; if (!result) reportError(result.error()); } QCoro::QmlTask OrganizerViewPanel::persistCollapsed(const QModelIndex& index, bool collapsed) { return persistCollapse(index, collapsed); } QCoro::Task OrganizerViewPanel::persistCollapse(QModelIndex index, bool collapsed) { - const auto* node = model_->node_at(index); - if (!node || !node->is_container() || model_->filtering()) co_return; + const auto* node = m_model->nodeAt(index); + if (!node || !node->isContainer() || m_model->filtering()) co_return; const QPointer alive(this); - const auto result = co_await service_.set_collapsed(node->folder_id(), collapsed); + const auto result = co_await m_service.setCollapsed(node->folderId(), collapsed); if (!alive) co_return; if (!result) reportError(result.error()); } QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { return toggle(index); } QCoro::Task OrganizerViewPanel::toggle(QModelIndex index) { - const auto* node = model_->node_at(index); + const auto* node = m_model->nodeAt(index); if (!node) co_return; - if (node->leaf) co_await toggle_mods(ctx_, this, {node->leaf->uuid}); - else if (node->is_container()) { - const auto change = service_.folder_toggle(node->folder_id(), node->kind != QStringLiteral("ungrouped")); + if (node->leaf) co_await toggleMods(m_ctx, this, {node->leaf->uuid}); + else if (node->isContainer()) { + const auto change = m_service.folderToggle(node->folderId(), node->kind != QStringLiteral("ungrouped")); if (!change) { reportError(change.error()); co_return; } - if (!change->enable.isEmpty() || !change->disable.isEmpty()) co_await apply_activation(ctx_, this, change->enable, change->disable); + if (!change->enable.isEmpty() || !change->disable.isEmpty()) co_await applyActivation(m_ctx, this, change->enable, change->disable); } } QCoro::QmlTask OrganizerViewPanel::selectionAction(const QString& action) { return runSelectionAction(action); } @@ -428,10 +428,10 @@ QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { if (action == QStringLiteral("tags")) openTags(); else if (action == QStringLiteral("move") || action == QStringLiteral("create")) { const auto pids = selectedPackageIds(); - if (!pids.isEmpty()) co_await editFolder(action, ROOT_ID, pids); - } else if (action == QStringLiteral("update")) co_await update_mods(ctx_, uuids, this); - else if (action == QStringLiteral("enable")) co_await apply_activation(ctx_, this, uuids); - else if (action == QStringLiteral("disable")) co_await apply_activation(ctx_, this, {}, uuids); + if (!pids.isEmpty()) co_await editFolder(action, kRootId, pids); + } else if (action == QStringLiteral("update")) co_await updateMods(m_ctx, uuids, this); + else if (action == QStringLiteral("enable")) co_await applyActivation(m_ctx, this, uuids); + else if (action == QStringLiteral("disable")) co_await applyActivation(m_ctx, this, {}, uuids); } QCoro::QmlTask OrganizerViewPanel::newFolder() { return editFolder(QStringLiteral("new")); } QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStringList pids) { @@ -439,7 +439,7 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr Result result; if (action == QStringLiteral("new") || action == QStringLiteral("create") || action == QStringLiteral("rename")) { const bool rename = action == QStringLiteral("rename"); - const QString initial = rename ? service_.state().folders.value(id).name : QString{}; + const QString initial = rename ? m_service.state().folders.value(id).name : QString{}; QString title; if (rename) title = QStringLiteral("Rename Folder"); else if (action == QStringLiteral("create")) title = QStringLiteral("Create Folder from Selection"); @@ -450,11 +450,11 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr if (!alive) co_return; if (code != QDialog::Accepted) co_return; const auto name = dialog->textValue(); - if (rename) result = co_await service_.rename_folder(id, name); + if (rename) result = co_await m_service.renameFolder(id, name); else { Result created; - if (action == QStringLiteral("create")) created = co_await service_.create_folder_from(name, pids); - else created = co_await service_.create_folder(name, id); + if (action == QStringLiteral("create")) created = co_await m_service.createFolderFrom(name, pids); + else created = co_await m_service.createFolder(name, id); if (!alive) co_return; if (!created) reportError(created.error()); co_return; @@ -462,26 +462,26 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr } else if (action == QStringLiteral("move") || action == QStringLiteral("moveFolder")) { QSet allowed; if (action == QStringLiteral("moveFolder")) { - const auto targets = service_.folder_move_targets(id); + const auto targets = m_service.folderMoveTargets(id); if (!targets) { reportError(targets.error()); co_return; } allowed = *targets; } else { - for (auto it = service_.state().folders.cbegin(); it != service_.state().folders.cend(); ++it) allowed.insert(it.key()); + for (auto it = m_service.state().folders.cbegin(); it != m_service.state().folders.cend(); ++it) allowed.insert(it.key()); } - const auto pickerResult = co_await awaitDialog(service_.tree(), action == QStringLiteral("move") ? QStringLiteral("Move to Folder") : QStringLiteral("Move Folder"), allowed, this); + const auto pickerResult = co_await awaitDialog(m_service.tree(), action == QStringLiteral("move") ? QStringLiteral("Move to Folder") : QStringLiteral("Move Folder"), allowed, this); const int code = pickerResult.first; auto* const dialog = pickerResult.second; if (!alive) co_return; if (code != QDialog::Accepted) co_return; - const auto destination = dialog->folder_id(); - if (action == QStringLiteral("moveFolder")) result = co_await service_.move_folder(id, destination); + const auto destination = dialog->folderId(); + if (action == QStringLiteral("moveFolder")) result = co_await m_service.moveFolder(id, destination); else { co_await placeMods(pids, destination); co_return; } } else if (action == QStringLiteral("delete")) { - const auto deleteResult = co_await awaitDialog(service_.state().folders.value(id).name, this); + const auto deleteResult = co_await awaitDialog(m_service.state().folders.value(id).name, this); const int code = deleteResult.first; if (!alive) co_return; if (code != QMessageBox::Yes) co_return; - result = co_await service_.delete_folder(id); + result = co_await m_service.deleteFolder(id); } if (!alive) co_return; if (!result) reportError(result.error()); @@ -489,97 +489,97 @@ QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStr QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, qint64 destination) { const QPointer alive(this); Result result; - if (destination == ROOT_ID) result = co_await service_.ungroup(pids); - else result = co_await service_.place(pids, destination); + if (destination == kRootId) result = co_await m_service.ungroup(pids); + else result = co_await m_service.place(pids, destination); if (!alive) co_return; if (!result) reportError(result.error()); } QCoro::Task OrganizerViewPanel::resetRules(QStringList pids) { const QPointer alive(this); - const auto result = co_await service_.reset_to_rules(pids); + const auto result = co_await m_service.resetToRules(pids); if (!alive) co_return; if (!result) reportError(result.error()); } void OrganizerViewPanel::contextMenu(const QModelIndex& index) { - const auto* node = model_->node_at(index); - menu_->clear(); - if (!node) menu_->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); + const auto* node = m_model->nodeAt(index); + m_menu->clear(); + if (!node) m_menu->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); else if (node->leaf) { selectSingle(index); modMenu(); } - else { model_->select({index}); folderMenu(*node); } + else { m_model->select({index}); folderMenu(*node); } syncInfo(); - menu_->popup(QCursor::pos()); + m_menu->popup(QCursor::pos()); } void OrganizerViewPanel::modMenu() { - const auto nodes = model_->selected_nodes(); + const auto nodes = m_model->selectedNodes(); const auto uuids = selectedUuids(); bool inactive = false, active = false; - for (const auto& uuid : uuids) { if (ctx_.activeUuids().contains(uuid)) active = true; else inactive = true; } - if (inactive) menu_->addAction(QStringLiteral("Enable"), this, [this, uuids] { apply_activation(ctx_, this, uuids); }); - if (active) menu_->addAction(QStringLiteral("Disable"), this, [this, uuids] { apply_activation(ctx_, this, {}, uuids); }); - add_update_action(menu_, ctx_, uuids, [this, uuids] { update_mods(ctx_, uuids, this); }); - menu_->addSeparator(); + for (const auto& uuid : uuids) { if (m_ctx.activeUuids().contains(uuid)) active = true; else inactive = true; } + if (inactive) m_menu->addAction(QStringLiteral("Enable"), this, [this, uuids] { applyActivation(m_ctx, this, uuids); }); + if (active) m_menu->addAction(QStringLiteral("Disable"), this, [this, uuids] { applyActivation(m_ctx, this, {}, uuids); }); + addUpdateAction(m_menu, m_ctx, uuids, [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); const auto pids = selectedPackageIds(); bool placeable = !pids.isEmpty(), grouped = false; QStringList manual; for (const auto* node : nodes) if (node->leaf) { - placeable &= node->leaf->package_id.has_value(); - grouped |= node->folder_id() != ROOT_ID; - if (node->leaf->package_id && node->leaf->placement == Placement::Manual && !manual.contains(*node->leaf->package_id)) manual.append(*node->leaf->package_id); + placeable &= node->leaf->packageId.has_value(); + grouped |= node->folderId() != kRootId; + if (node->leaf->packageId && node->leaf->placement == Placement::kManual && !manual.contains(*node->leaf->packageId)) manual.append(*node->leaf->packageId); } - auto* move = menu_->addAction(QStringLiteral("Move to folder…"), this, [this, pids] { editFolder(QStringLiteral("move"), ROOT_ID, pids); }); - auto* remove = menu_->addAction(QStringLiteral("Remove from folder"), this, [this, pids] { placeMods(pids, ROOT_ID); }); - auto* create = menu_->addAction(QStringLiteral("Create folder from selection…"), this, [this, pids] { editFolder(QStringLiteral("create"), ROOT_ID, pids); }); + auto* move = m_menu->addAction(QStringLiteral("Move to folder…"), this, [this, pids] { editFolder(QStringLiteral("move"), kRootId, pids); }); + auto* remove = m_menu->addAction(QStringLiteral("Remove from folder"), this, [this, pids] { placeMods(pids, kRootId); }); + auto* create = m_menu->addAction(QStringLiteral("Create folder from selection…"), this, [this, pids] { editFolder(QStringLiteral("create"), kRootId, pids); }); for (auto* action : {move, remove, create}) { action->setEnabled(placeable); action->setToolTip(placeable ? QString{} : QStringLiteral("Mods without a package ID cannot be placed in folders")); } remove->setEnabled(placeable && grouped); - menu_->addAction(QStringLiteral("Manage tags…"), this, &OrganizerViewPanel::openTags)->setEnabled(placeable); - if (placeable && !manual.isEmpty()) menu_->addAction(QStringLiteral("Use auto rules"), this, [this, manual] { resetRules(manual); }); + m_menu->addAction(QStringLiteral("Manage tags…"), this, &OrganizerViewPanel::openTags)->setEnabled(placeable); + if (placeable && !manual.isEmpty()) m_menu->addAction(QStringLiteral("Use auto rules"), this, [this, manual] { resetRules(manual); }); } void OrganizerViewPanel::folderMenu(const TreeNode& node) { - const auto id = node.folder_id(); - const auto result = service_.folder_mod_uuids(id, node.kind != QStringLiteral("ungrouped")); + const auto id = node.folderId(); + const auto result = m_service.folderModUuids(id, node.kind != QStringLiteral("ungrouped")); if (!result) { reportError(result.error()); return; } const auto uuids = *result; - menu_->addAction(QStringLiteral("Enable all"), this, [this, uuids] { apply_activation(ctx_, this, uuids); })->setEnabled(!uuids.isEmpty()); - menu_->addAction(QStringLiteral("Disable all"), this, [this, uuids] { apply_activation(ctx_, this, {}, uuids); })->setEnabled(!uuids.isEmpty()); - add_update_action(menu_, ctx_, uuids, [this, uuids] { update_mods(ctx_, uuids, this); }); - menu_->addSeparator(); - if (node.kind == QStringLiteral("ungrouped")) { menu_->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); return; } - if (node.folder && node.folder->depth < MAX_DEPTH) menu_->addAction(QStringLiteral("New subfolder…"), this, [this, id] { editFolder(QStringLiteral("new"), id); }); - menu_->addAction(QStringLiteral("Rename…"), this, [this, id] { editFolder(QStringLiteral("rename"), id); }); - const auto targets = service_.folder_move_targets(id); - menu_->addAction(QStringLiteral("Move to…"), this, [this, id] { editFolder(QStringLiteral("moveFolder"), id); })->setEnabled(targets && !targets->isEmpty()); - menu_->addAction(QStringLiteral("Delete…"), this, [this, id] { editFolder(QStringLiteral("delete"), id); }); + m_menu->addAction(QStringLiteral("Enable all"), this, [this, uuids] { applyActivation(m_ctx, this, uuids); })->setEnabled(!uuids.isEmpty()); + m_menu->addAction(QStringLiteral("Disable all"), this, [this, uuids] { applyActivation(m_ctx, this, {}, uuids); })->setEnabled(!uuids.isEmpty()); + addUpdateAction(m_menu, m_ctx, uuids, [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); + if (node.kind == QStringLiteral("ungrouped")) { m_menu->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); return; } + if (node.folder && node.folder->depth < kMaxDepth) m_menu->addAction(QStringLiteral("New subfolder…"), this, [this, id] { editFolder(QStringLiteral("new"), id); }); + m_menu->addAction(QStringLiteral("Rename…"), this, [this, id] { editFolder(QStringLiteral("rename"), id); }); + const auto targets = m_service.folderMoveTargets(id); + m_menu->addAction(QStringLiteral("Move to…"), this, [this, id] { editFolder(QStringLiteral("moveFolder"), id); })->setEnabled(targets && !targets->isEmpty()); + m_menu->addAction(QStringLiteral("Delete…"), this, [this, id] { editFolder(QStringLiteral("delete"), id); }); } bool OrganizerViewPanel::canDrop(const QModelIndex& source, const QModelIndex& target) const { - const auto* src = model_->node_at(source); - const auto* dst = model_->node_at(target); - if (!src || !dst || !dst->is_container() || src == dst) return false; + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + if (!src || !dst || !dst->isContainer() || src == dst) return false; if (src->leaf) { - if (!model_->is_selected(source)) return src->leaf->package_id.has_value(); + if (!m_model->isSelected(source)) return src->leaf->packageId.has_value(); bool any = false; - for (const auto* node : model_->selected_nodes()) { + for (const auto* node : m_model->selectedNodes()) { if (!node->leaf) continue; any = true; - if (!node->leaf->package_id) return false; + if (!node->leaf->packageId) return false; } return any; } - if (src->kind == QStringLiteral("folder")) { const auto targets = service_.folder_move_targets(src->folder_id()); return targets && targets->contains(dst->folder_id()); } + if (src->kind == QStringLiteral("folder")) { const auto targets = m_service.folderMoveTargets(src->folderId()); return targets && targets->contains(dst->folderId()); } return false; } QCoro::QmlTask OrganizerViewPanel::dropOn(const QModelIndex& source, const QModelIndex& target) { return performDrop(source, target); } QCoro::Task OrganizerViewPanel::performDrop(QModelIndex source, QModelIndex target) { const QPointer alive(this); if (!canDrop(source, target)) co_return; - const auto* src = model_->node_at(source); - const auto* dst = model_->node_at(target); - const auto destination = dst->folder_id(); + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + const auto destination = dst->folderId(); if (src->kind == QStringLiteral("folder")) { - const auto result = co_await service_.move_folder(src->folder_id(), destination); + const auto result = co_await m_service.moveFolder(src->folderId(), destination); if (!alive) co_return; if (!result) reportError(result.error()); } else { - const auto pids = model_->is_selected(source) ? selectedPackageIds() : QStringList{*src->leaf->package_id}; + const auto pids = m_model->isSelected(source) ? selectedPackageIds() : QStringList{*src->leaf->packageId}; co_await placeMods(pids, destination); } } diff --git a/src/ui/plugins/organizer/view.hpp b/src/ui/plugins/organizer/view.hpp index 3d443de..8573859 100644 --- a/src/ui/plugins/organizer/view.hpp +++ b/src/ui/plugins/organizer/view.hpp @@ -43,7 +43,7 @@ class OrganizerViewPanel final : public BaseViewPanel { QVariantList tagRows() const; QVariantList ruleRows() const; QVariantList ruleFolders() const; - QString editorError() const { return editor_error_; } + QString editorError() const { return m_editorError; } Q_INVOKABLE QCoro::QmlTask requestRulePreview(const QVariantList&, int revision); Q_INVOKABLE void openTags(); Q_INVOKABLE void openRules(); @@ -92,39 +92,39 @@ class OrganizerViewPanel final : public BaseViewPanel { QCoro::Task persistAllCollapse(bool); QCoro::Task toggle(QModelIndex); QCoro::Task runSelectionAction(QString); - QCoro::Task editFolder(QString action, qint64 id = organizer::ROOT_ID, QStringList pids = {}); + QCoro::Task editFolder(QString action, qint64 id = organizer::kRootId, QStringList pids = {}); QCoro::Task placeMods(QStringList, qint64); QCoro::Task resetRules(QStringList); QCoro::Task performDrop(QModelIndex, QModelIndex); void modMenu(); void folderMenu(const TreeNode&); - organizer::OrganizerService& service_; - ModTreeModel* model_; - OrganizerFilterModel* filters_; - ModInfoPanel* info_; - std::unique_ptr selection_; - QQuickWidget* qml_; - QQuickWidget* rules_qml_; - QDialog* rules_dialog_; - QLineEdit* search_; - QLabel* status_; - QLabel* selection_label_; - QWidget* selection_bar_; - QPushButton* update_button_; - QMenu* menu_; - QTimer* search_timer_; - QPersistentModelIndex selection_anchor_; - QString selected_uuid_; - QString filter_key_ = QStringLiteral("all"); - QString search_text_; - QString editor_error_; - bool dirty_ = true; - bool rules_opened_once_ = false; - std::uint64_t changed_id_ = 0; - std::uint64_t active_id_ = 0; - std::uint64_t mods_id_ = 0; - std::uint64_t diagnostics_id_ = 0; - std::uint64_t busy_id_ = 0; + organizer::OrganizerService& m_service; + ModTreeModel* m_model; + OrganizerFilterModel* m_filters; + ModInfoPanel* m_info; + std::unique_ptr m_selection; + QQuickWidget* m_qml; + QQuickWidget* m_rulesQml; + QDialog* m_rulesDialog; + QLineEdit* m_search; + QLabel* m_status; + QLabel* m_selectionLabel; + QWidget* m_selectionBar; + QPushButton* m_updateButton; + QMenu* m_menu; + QTimer* m_searchTimer; + QPersistentModelIndex m_selectionAnchor; + QString m_selectedUuid; + QString m_filterKey = QStringLiteral("all"); + QString m_searchText; + QString m_editorError; + bool m_dirty = true; + bool m_rulesOpenedOnce = false; + std::uint64_t m_changedId = 0; + std::uint64_t m_activeId = 0; + std::uint64_t m_modsId = 0; + std::uint64_t m_diagnosticsId = 0; + std::uint64_t m_busyId = 0; }; } diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp index d812034..13b0375 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.cpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -14,7 +14,7 @@ namespace px::modrim { namespace { -bool is_published_file_id(const QJsonValue& value) { +bool isPublishedFileId(const QJsonValue& value) { if (!value.isString()) return false; const auto text = value.toString(); return !text.isEmpty() && std::ranges::all_of(text, [](QChar c) { return c >= QLatin1Char('0') && c <= QLatin1Char('9'); }); @@ -22,77 +22,77 @@ bool is_published_file_id(const QJsonValue& value) { } PxModRimBridge::PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent) - : QObject(parent), plugin_(plugin) {} + : QObject(parent), m_plugin(plugin) {} -void PxModRimBridge::call(const QString& request_id, const QString& method, const QString& payload) { - handle(request_id, method, payload); +void PxModRimBridge::call(const QString& requestId, const QString& method, const QString& payload) { + handle(requestId, method, payload); } -void PxModRimBridge::respond(const QString& request_id, bool ok, const QJsonValue& result, const QString& error) { +void PxModRimBridge::respond(const QString& requestId, bool ok, const QJsonValue& result, const QString& error) { QJsonObject response{{QStringLiteral("ok"), ok}}; if (ok) response.insert(QStringLiteral("result"), result); else response.insert(QStringLiteral("error"), error); - emit result_ready(request_id, QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact))); + emit resultReady(requestId, QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact))); } -QCoro::Task PxModRimBridge::handle(QString request_id, QString method, QString payload) { - QJsonParseError parse_error; - const auto document = QJsonDocument::fromJson(payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parse_error); - if (parse_error.error != QJsonParseError::NoError || !document.isObject()) { - respond(request_id, false, {}, QStringLiteral("Invalid request payload")); +QCoro::Task PxModRimBridge::handle(QString requestId, QString method, QString payload) { + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson(payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + respond(requestId, false, {}, QStringLiteral("Invalid request payload")); co_return; } const auto params = document.object(); - const auto mod_id = params.value(QStringLiteral("mod_id")); + const auto modId = params.value(QStringLiteral("mod_id")); if (method == QLatin1String("init_ready")) { - plugin_.sync_all(); + m_plugin.syncAll(); } else if (method == QLatin1String("toggle_download_checked")) { - if (is_published_file_id(mod_id)) plugin_.toggle_download_checked(mod_id.toString(), params.value(QStringLiteral("title")).toString(), params.value(QStringLiteral("checked")).toBool()); + if (isPublishedFileId(modId)) m_plugin.toggleDownloadChecked(modId.toString(), params.value(QStringLiteral("title")).toString(), params.value(QStringLiteral("checked")).toBool()); } else if (method == QLatin1String("batch_toggle_download_checked")) { const auto ids = params.value(QStringLiteral("mod_ids")); const auto titles = params.value(QStringLiteral("titles")); if (!ids.isArray() || !titles.isArray() || ids.toArray().size() != titles.toArray().size()) { - respond(request_id, false, {}, QStringLiteral("mod_ids and titles must have equal lengths")); + respond(requestId, false, {}, QStringLiteral("mod_ids and titles must have equal lengths")); co_return; } - const auto id_array = ids.toArray(); - const auto title_array = titles.toArray(); - QStringList valid_ids; - QStringList valid_titles; - for (qsizetype pos = 0; pos < id_array.size(); ++pos) { - if (!is_published_file_id(id_array[pos])) continue; - valid_ids.append(id_array[pos].toString()); - valid_titles.append(title_array[pos].toString()); + const auto idArray = ids.toArray(); + const auto titleArray = titles.toArray(); + QStringList validIds; + QStringList validTitles; + for (qsizetype pos = 0; pos < idArray.size(); ++pos) { + if (!isPublishedFileId(idArray[pos])) continue; + validIds.append(idArray[pos].toString()); + validTitles.append(titleArray[pos].toString()); } - if (!valid_ids.isEmpty()) plugin_.batch_toggle_download_checked(valid_ids, valid_titles, params.value(QStringLiteral("checked")).toBool()); + if (!validIds.isEmpty()) m_plugin.batchToggleDownloadChecked(validIds, validTitles, params.value(QStringLiteral("checked")).toBool()); } else if (method == QLatin1String("toggle_active")) { - if (is_published_file_id(mod_id)) plugin_.toggle_active(mod_id.toString(), params.value(QStringLiteral("active")).toBool()); + if (isPublishedFileId(modId)) m_plugin.toggleActive(modId.toString(), params.value(QStringLiteral("active")).toBool()); } else if (method == QLatin1String("batch_toggle_active")) { const auto ids = params.value(QStringLiteral("mod_ids")); if (!ids.isArray()) { - respond(request_id, false, {}, QStringLiteral("mod_ids must be an array")); + respond(requestId, false, {}, QStringLiteral("mod_ids must be an array")); co_return; } - QStringList valid_ids; + QStringList validIds; for (const auto& id : ids.toArray()) { - if (is_published_file_id(id)) valid_ids.append(id.toString()); + if (isPublishedFileId(id)) validIds.append(id.toString()); } - if (!valid_ids.isEmpty()) plugin_.batch_toggle_active(valid_ids, params.value(QStringLiteral("active")).toBool()); + if (!validIds.isEmpty()) m_plugin.batchToggleActive(validIds, params.value(QStringLiteral("active")).toBool()); } else if (method == QLatin1String("fetch_mod_deps")) { - if (is_published_file_id(mod_id)) { + if (isPublishedFileId(modId)) { QPointer guard(this); - auto result = co_await plugin_.fetch_mod_deps(mod_id.toString()); + auto result = co_await m_plugin.fetchModDeps(modId.toString()); if (guard) { - if (result) guard->respond(request_id, true, *result); - else guard->respond(request_id, false, {}, result.error().message); + if (result) guard->respond(requestId, true, *result); + else guard->respond(requestId, false, {}, result.error().message); } co_return; } } else { - respond(request_id, false, {}, QStringLiteral("Unknown method: %1").arg(method)); + respond(requestId, false, {}, QStringLiteral("Unknown method: %1").arg(method)); co_return; } - respond(request_id, true); + respond(requestId, true); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/bridge_obj.hpp b/src/ui/plugins/steam_workshop/bridge_obj.hpp index 02f8882..049b474 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.hpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.hpp @@ -14,13 +14,13 @@ class PxModRimBridge final : public QObject { Q_OBJECT public: PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); - Q_INVOKABLE void call(const QString& request_id, const QString& method, const QString& payload); + Q_INVOKABLE void call(const QString& requestId, const QString& method, const QString& payload); signals: - void result_ready(const QString& request_id, const QString& payload); + void resultReady(const QString& requestId, const QString& payload); private: - QCoro::Task handle(QString request_id, QString method, QString payload); - void respond(const QString& request_id, bool ok, const QJsonValue& result = QJsonValue(QJsonValue::Null), const QString& error = {}); - SteamWorkshopUiPlugin& plugin_; + QCoro::Task handle(QString requestId, QString method, QString payload); + void respond(const QString& requestId, bool ok, const QJsonValue& result = QJsonValue(QJsonValue::Null), const QString& error = {}); + SteamWorkshopUiPlugin& m_plugin; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp index 6cbbe71..27879f3 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.cpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -5,45 +5,45 @@ namespace px::modrim { DownloadQueueModel::DownloadQueueModel(QObject* parent) : QAbstractListModel(parent) {} int DownloadQueueModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(items_.size()); + return parent.isValid() ? 0 : static_cast(m_items.size()); } QVariant DownloadQueueModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= items_.size()) return {}; - const auto& item = items_[index.row()]; + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; + const auto& item = m_items[index.row()]; switch (role) { - case IdRole: return item.id; - case TitleRole: return item.title; - case DisplayRole: return item.display; - case StatusRole: return item.status; - case ProgressRole: return item.progress; + case kIdRole: return item.id; + case kTitleRole: return item.title; + case kDisplayRole: return item.display; + case kStatusRole: return item.status; + case kProgressRole: return item.progress; default: return {}; } } QHash DownloadQueueModel::roleNames() const { - return {{IdRole, "id"}, {TitleRole, "title"}, {DisplayRole, "display"}, {StatusRole, "status"}, {ProgressRole, "progress"}}; + return {{kIdRole, "id"}, {kTitleRole, "title"}, {kDisplayRole, "display"}, {kStatusRole, "status"}, {kProgressRole, "progress"}}; } -void DownloadQueueModel::sync_from(const CheckedDownloads& checked, const QHash& statuses) { +void DownloadQueueModel::syncFrom(const CheckedDownloads& checked, const QHash& statuses) { beginResetModel(); - items_.clear(); - items_.reserve(checked.size()); + m_items.clear(); + m_items.reserve(checked.size()); for (const auto& [id, title] : checked) { - items_.append(Item{id, title.isEmpty() ? id : title, + m_items.append(Item{id, title.isEmpty() ? id : title, title.isEmpty() ? id : QStringLiteral("%1 (%2)").arg(title, id), statuses.value(id)}); } endResetModel(); } -void DownloadQueueModel::update_status(const QString& id, const QString& status, qint64 done, qint64 total) { - for (qsizetype pos = 0; pos < items_.size(); ++pos) { - auto& item = items_[pos]; +void DownloadQueueModel::updateStatus(const QString& id, const QString& status, qint64 done, qint64 total) { + for (qsizetype pos = 0; pos < m_items.size(); ++pos) { + auto& item = m_items[pos]; if (item.id != id) continue; item.status = status; item.progress = total ? static_cast(done) / total : 0; const auto idx = index(static_cast(pos)); - emit dataChanged(idx, idx, {StatusRole, ProgressRole}); + emit dataChanged(idx, idx, {kStatusRole, kProgressRole}); return; } } diff --git a/src/ui/plugins/steam_workshop/download_queue_model.hpp b/src/ui/plugins/steam_workshop/download_queue_model.hpp index 43809a5..b0e2c6c 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.hpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.hpp @@ -13,13 +13,13 @@ using CheckedDownloads = QList>; class DownloadQueueModel final : public QAbstractListModel { Q_OBJECT public: - enum Role { IdRole = Qt::UserRole + 1, TitleRole, DisplayRole, StatusRole, ProgressRole }; + enum Role { kIdRole = Qt::UserRole + 1, kTitleRole, kDisplayRole, kStatusRole, kProgressRole }; explicit DownloadQueueModel(QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void sync_from(const CheckedDownloads& checked, const QHash& statuses = {}); - void update_status(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); + void syncFrom(const CheckedDownloads& checked, const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); private: struct Item { @@ -29,7 +29,7 @@ class DownloadQueueModel final : public QAbstractListModel { QString status; double progress = 0; }; - QList items_; + QList m_items; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp index 867fcd6..d8a87d1 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.cpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -11,32 +11,32 @@ namespace px::modrim { DownloadSidebar::DownloadSidebar(QQmlEngine* engine, QWidget* parent) - : QWidget(parent), model_(new DownloadQueueModel(this)), qml_(new QQuickWidget(engine, this)) { + : QWidget(parent), m_model(new DownloadQueueModel(this)), m_qml(new QQuickWidget(engine, this)) { setObjectName(QStringLiteral("downloadSidebar")); - setFixedWidth(SIDEBAR_WIDTH); + setFixedWidth(kSidebarWidth); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); - qml_->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), this); - qml_->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), model_); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); - layout->addWidget(qml_); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), this); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), m_model); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); + layout->addWidget(m_qml); } -void DownloadSidebar::downloadRequested() { emit download_requested(); } -void DownloadSidebar::stopRequested() { emit stop_requested(); } -void DownloadSidebar::removeItem(const QString& id) { emit item_removed(id); } -void DownloadSidebar::clearQueue() { emit clear_requested(); } -void DownloadSidebar::set_download_enabled(bool enabled) { - if (auto* root = qml_->rootObject()) root->setProperty("downloadEnabled", enabled); +void DownloadSidebar::downloadRequested() { emit downloadTriggered(); } +void DownloadSidebar::stopRequested() { emit stopTriggered(); } +void DownloadSidebar::removeItem(const QString& id) { emit itemRemoved(id); } +void DownloadSidebar::clearQueue() { emit clearRequested(); } +void DownloadSidebar::setDownloadEnabled(bool enabled) { + if (auto* root = m_qml->rootObject()) root->setProperty("downloadEnabled", enabled); } -void DownloadSidebar::sync_from(const CheckedDownloads& checked, const QHash& statuses) { - model_->sync_from(checked, statuses); +void DownloadSidebar::syncFrom(const CheckedDownloads& checked, const QHash& statuses) { + m_model->syncFrom(checked, statuses); } -void DownloadSidebar::update_status(const QString& id, const QString& status, qint64 done, qint64 total) { - model_->update_status(id, status, done, total); +void DownloadSidebar::updateStatus(const QString& id, const QString& status, qint64 done, qint64 total) { + m_model->updateStatus(id, status, done, total); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.hpp b/src/ui/plugins/steam_workshop/download_sidebar.hpp index 22e8830..078ded8 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.hpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.hpp @@ -17,17 +17,17 @@ class DownloadSidebar final : public QWidget { Q_INVOKABLE void stopRequested(); Q_INVOKABLE void removeItem(const QString& id); Q_INVOKABLE void clearQueue(); - void set_download_enabled(bool enabled); - void sync_from(const CheckedDownloads& checked, const QHash& statuses = {}); - void update_status(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); + void setDownloadEnabled(bool enabled); + void syncFrom(const CheckedDownloads& checked, const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); signals: - void download_requested(); - void stop_requested(); - void item_removed(const QString& id); - void clear_requested(); + void downloadTriggered(); + void stopTriggered(); + void itemRemoved(const QString& id); + void clearRequested(); private: - DownloadQueueModel* model_; - QQuickWidget* qml_; + DownloadQueueModel* m_model; + QQuickWidget* m_qml; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp index 1175db9..c7eaf27 100644 --- a/src/ui/plugins/steam_workshop/plugin.cpp +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -21,190 +21,190 @@ namespace px::modrim { SteamWorkshopUiPlugin::SteamWorkshopUiPlugin() : UiPlugin(QStringLiteral("steamworkshop"), {QStringLiteral("steam_downloader")}) {} -SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnect_events(); } +SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnectEvents(); } void SteamWorkshopUiPlugin::setup(AppContext& ctx) { - core_ = &ctx.coreContext(); - app_ = &ctx; - service_ = static_cast(core_->plugins().get(QStringLiteral("steam_downloader"))); + m_core = &ctx.coreContext(); + m_app = &ctx; + m_service = static_cast(m_core->plugins().get(QStringLiteral("steam_downloader"))); ctx.addRailView(); - mods_id_ = core_->modService().mods_changed.connect([this](const std::monostate&) { - refresh_cached_ids(); - badges_refresh_requested.notify(installed_ids_.values()); - active_refresh_requested.notify(active_ids_.values()); + m_modsId = m_core->modService().modsChanged.connect([this](const std::monostate&) { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); }); - active_id_ = core_->activeStateChanged().connect([this](const QStringList&) { - refresh_cached_ids(); - badges_refresh_requested.notify(installed_ids_.values()); - active_refresh_requested.notify(active_ids_.values()); + m_activeId = m_core->activeStateChanged().connect([this](const QStringList&) { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); }); - status_id_ = service_->download_item_status_changed.connect([this](const DownloadItemStatus& status) { on_item_status(status); }); - finished_id_ = service_->download_finished.connect([this](const DownloadResult& result) { on_download_finished(result); }); - busy_id_ = service_->busy_changed.connect([this](bool busy) { download_busy_changed.notify(busy); }); + m_statusId = m_service->downloadItemStatusChanged.connect([this](const DownloadItemStatus& status) { onItemStatus(status); }); + m_finishedId = m_service->downloadFinished.connect([this](const DownloadResult& result) { onDownloadFinished(result); }); + m_busyId = m_service->busyChanged.connect([this](bool busy) { downloadBusyChanged.notify(busy); }); } QCoro::Task SteamWorkshopUiPlugin::init(AppContext&) { - refresh_cached_ids(); + refreshCachedIds(); co_return; } QCoro::Task SteamWorkshopUiPlugin::shutdown() { - disconnect_events(); + disconnectEvents(); co_return; } -void SteamWorkshopUiPlugin::disconnect_events() { - if (!core_) return; - core_->modService().mods_changed.disconnect(mods_id_); - core_->activeStateChanged().disconnect(active_id_); - service_->download_item_status_changed.disconnect(status_id_); - service_->download_finished.disconnect(finished_id_); - service_->busy_changed.disconnect(busy_id_); - core_ = nullptr; - app_ = nullptr; +void SteamWorkshopUiPlugin::disconnectEvents() { + if (!m_core) return; + m_core->modService().modsChanged.disconnect(m_modsId); + m_core->activeStateChanged().disconnect(m_activeId); + m_service->downloadItemStatusChanged.disconnect(m_statusId); + m_service->downloadFinished.disconnect(m_finishedId); + m_service->busyChanged.disconnect(m_busyId); + m_core = nullptr; + m_app = nullptr; } -void SteamWorkshopUiPlugin::toggle_download_checked(const QString& id, const QString& title, bool checked) { - const auto it = std::ranges::find(checked_ids_, id, &CheckedDownloads::value_type::first); +void SteamWorkshopUiPlugin::toggleDownloadChecked(const QString& id, const QString& title, bool checked) { + const auto it = std::ranges::find(m_checkedIds, id, &CheckedDownloads::value_type::first); if (checked) { - if (it == checked_ids_.end()) checked_ids_.append({id, title}); + if (it == m_checkedIds.end()) m_checkedIds.append({id, title}); else it->second = title; - } else if (it != checked_ids_.end()) { - checked_ids_.erase(it); + } else if (it != m_checkedIds.end()) { + m_checkedIds.erase(it); } - notify_sidebar(); + notifySidebar(); } -void SteamWorkshopUiPlugin::batch_toggle_download_checked(const QStringList& ids, const QStringList& titles, bool checked) { +void SteamWorkshopUiPlugin::batchToggleDownloadChecked(const QStringList& ids, const QStringList& titles, bool checked) { for (qsizetype pos = 0; pos < ids.size(); ++pos) { - const auto it = std::ranges::find(checked_ids_, ids[pos], &CheckedDownloads::value_type::first); + const auto it = std::ranges::find(m_checkedIds, ids[pos], &CheckedDownloads::value_type::first); if (checked) { - if (it == checked_ids_.end()) checked_ids_.append({ids[pos], titles[pos]}); + if (it == m_checkedIds.end()) m_checkedIds.append({ids[pos], titles[pos]}); else it->second = titles[pos]; - } else if (it != checked_ids_.end()) { - checked_ids_.erase(it); + } else if (it != m_checkedIds.end()) { + m_checkedIds.erase(it); } } - if (!ids.isEmpty()) notify_sidebar(); + if (!ids.isEmpty()) notifySidebar(); } -void SteamWorkshopUiPlugin::toggle_active(const QString& id, bool active) { - if (!core_) return; - for (auto it = core_->allMods().cbegin(); it != core_->allMods().cend(); ++it) { - if (it.value()->published_file_id() != id) continue; - auto current = core_->activeUuids(); +void SteamWorkshopUiPlugin::toggleActive(const QString& id, bool active) { + if (!m_core) return; + for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); ++it) { + if (it.value()->publishedFileId() != id) continue; + auto current = m_core->activeUuids(); if (active) { if (!current.contains(it.key())) current.append(it.key()); } else { current.removeAll(it.key()); } - core_->setActive(std::move(current)); + m_core->setActive(std::move(current)); return; } } -void SteamWorkshopUiPlugin::batch_toggle_active(const QStringList& ids, bool active) { - if (!core_) return; - QHash by_published_id; - for (auto it = core_->allMods().cbegin(); it != core_->allMods().cend(); ++it) { - if (auto id = it.value()->published_file_id()) by_published_id.insert(*id, it.key()); +void SteamWorkshopUiPlugin::batchToggleActive(const QStringList& ids, bool active) { + if (!m_core) return; + QHash byPublishedId; + for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); ++it) { + if (auto id = it.value()->publishedFileId()) byPublishedId.insert(*id, it.key()); } - auto current = core_->activeUuids(); + auto current = m_core->activeUuids(); for (const auto& id : ids) { - const auto it = by_published_id.constFind(id); - if (it == by_published_id.cend()) continue; + const auto it = byPublishedId.constFind(id); + if (it == byPublishedId.cend()) continue; if (active) { if (!current.contains(*it)) current.append(*it); } else { current.removeAll(*it); } } - core_->setActive(std::move(current)); + m_core->setActive(std::move(current)); } -QCoro::Task SteamWorkshopUiPlugin::request_download() { - if (checked_ids_.isEmpty()) co_return; - if (!core_ || core_->config().paths.local.empty()) { - service_->status_message_changed.notify(QStringLiteral("Local mods path is not configured")); +QCoro::Task SteamWorkshopUiPlugin::requestDownload() { + if (m_checkedIds.isEmpty()) co_return; + if (!m_core || m_core->config().paths.local.empty()) { + m_service->statusMessageChanged.notify(QStringLiteral("Local mods path is not configured")); co_return; } - if (service_->is_downloading()) { - service_->status_message_changed.notify(QStringLiteral("A download is already running")); + if (m_service->isDownloading()) { + m_service->statusMessageChanged.notify(QStringLiteral("A download is already running")); co_return; } QStringList ids; - ids.reserve(checked_ids_.size()); - for (const auto& [id, title] : checked_ids_) { + ids.reserve(m_checkedIds.size()); + for (const auto& [id, title] : m_checkedIds) { ids.append(id); - statuses_.insert(id, QStringLiteral("queued")); + m_statuses.insert(id, QStringLiteral("queued")); } - own_download_ = true; - if (app_) app_->navigate(buildRoute(QStringLiteral("downloads"))); - notify_sidebar(); - auto result = co_await service_->download_mods(std::move(ids)); - own_download_ = false; - if (!result) service_->status_message_changed.notify(result.error().message); + m_ownDownload = true; + if (m_app) m_app->navigate(buildRoute(QStringLiteral("downloads"))); + notifySidebar(); + auto result = co_await m_service->downloadMods(std::move(ids)); + m_ownDownload = false; + if (!result) m_service->statusMessageChanged.notify(result.error().message); } -void SteamWorkshopUiPlugin::stop_download() { service_->cancel(); } -bool SteamWorkshopUiPlugin::is_downloading() const { return service_->is_downloading(); } +void SteamWorkshopUiPlugin::stopDownload() { m_service->cancel(); } +bool SteamWorkshopUiPlugin::isDownloading() const { return m_service->isDownloading(); } -void SteamWorkshopUiPlugin::remove_item(const QString& id) { - checked_ids_.removeIf([&id](const auto& item) { return item.first == id; }); - statuses_.remove(id); - notify_sidebar(); - uncheck_mod_requested.notify(id); +void SteamWorkshopUiPlugin::removeItem(const QString& id) { + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); + notifySidebar(); + uncheckModRequested.notify(id); } -void SteamWorkshopUiPlugin::clear_queue() { - checked_ids_.clear(); - statuses_.clear(); - notify_sidebar(); - clear_checked_requested.notify(std::monostate{}); +void SteamWorkshopUiPlugin::clearQueue() { + m_checkedIds.clear(); + m_statuses.clear(); + notifySidebar(); + clearCheckedRequested.notify(std::monostate{}); } -void SteamWorkshopUiPlugin::notify_sidebar() { sidebar_sync_requested.notify(SidebarSync{checked_ids_, statuses_}); } +void SteamWorkshopUiPlugin::notifySidebar() { sidebarSyncRequested.notify(SidebarSync{m_checkedIds, m_statuses}); } -void SteamWorkshopUiPlugin::sync_all() { - refresh_cached_ids(); - badges_refresh_requested.notify(installed_ids_.values()); - active_refresh_requested.notify(active_ids_.values()); - notify_sidebar(); +void SteamWorkshopUiPlugin::syncAll() { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + notifySidebar(); } -void SteamWorkshopUiPlugin::on_item_status(const DownloadItemStatus& status) { - if (!own_download_) return; - statuses_.insert(status.mod_id, status.status); - item_status_changed.notify(status); +void SteamWorkshopUiPlugin::onItemStatus(const DownloadItemStatus& status) { + if (!m_ownDownload) return; + m_statuses.insert(status.modId, status.status); + itemStatusChanged.notify(status); } -void SteamWorkshopUiPlugin::on_download_finished(const DownloadResult& result) { - if (!own_download_) return; +void SteamWorkshopUiPlugin::onDownloadFinished(const DownloadResult& result) { + if (!m_ownDownload) return; for (const auto& id : result.succeeded) { - checked_ids_.removeIf([&id](const auto& item) { return item.first == id; }); - statuses_.remove(id); + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); } - for (auto it = statuses_.begin(); it != statuses_.end();) { - if (it.value() == QLatin1String("queued") || it.value() == QLatin1String("downloading")) it = statuses_.erase(it); + for (auto it = m_statuses.begin(); it != m_statuses.end();) { + if (it.value() == QLatin1String("queued") || it.value() == QLatin1String("downloading")) it = m_statuses.erase(it); else ++it; } - notify_sidebar(); -} - -void SteamWorkshopUiPlugin::refresh_cached_ids() { - if (!core_) return; - installed_ids_.clear(); - active_ids_.clear(); - const QSet active(core_->activeUuids().cbegin(), core_->activeUuids().cend()); - for (const auto& mod : core_->allMods()) { - if (auto id = mod->published_file_id()) { - installed_ids_.insert(*id); - if (active.contains(mod->uuid())) active_ids_.insert(*id); + notifySidebar(); +} + +void SteamWorkshopUiPlugin::refreshCachedIds() { + if (!m_core) return; + m_installedIds.clear(); + m_activeIds.clear(); + const QSet active(m_core->activeUuids().cbegin(), m_core->activeUuids().cend()); + for (const auto& mod : m_core->allMods()) { + if (auto id = mod->publishedFileId()) { + m_installedIds.insert(*id); + if (active.contains(mod->uuid())) m_activeIds.insert(*id); } } } -QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id) { +QCoro::Task> SteamWorkshopUiPlugin::fetchModDeps(QString id) { QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); QUrlQuery query; query.addQueryItem(QStringLiteral("id"), id); @@ -212,7 +212,7 @@ QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id QNetworkRequest request(url); request.setRawHeader("User-Agent", "PxModRim/1.0"); request.setTransferTimeout(30000); - auto* reply = core_->network().get(request); + auto* reply = m_core->network().get(request); if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { reply->abort(); reply->deleteLater(); @@ -227,9 +227,9 @@ QCoro::Task> SteamWorkshopUiPlugin::fetch_mod_deps(QString id if (status && status != 200) co_return QJsonValue(QJsonValue::Null); if (error != QNetworkReply::NoError) co_return std::unexpected(Error{message}); if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); - QJsonParseError parse_error; - const auto document = QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parse_error); - if (parse_error.error != QJsonParseError::NoError) co_return std::unexpected(Error{parse_error.errorString()}); + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parseError); + if (parseError.error != QJsonParseError::NoError) co_return std::unexpected(Error{parseError.errorString()}); co_return document.array().at(0); } diff --git a/src/ui/plugins/steam_workshop/plugin.hpp b/src/ui/plugins/steam_workshop/plugin.hpp index 33d8cdf..8c3f805 100644 --- a/src/ui/plugins/steam_workshop/plugin.hpp +++ b/src/ui/plugins/steam_workshop/plugin.hpp @@ -14,7 +14,7 @@ namespace px::modrim { struct SidebarSync { - CheckedDownloads checked_ids; + CheckedDownloads checkedIds; QHash statuses; }; @@ -26,45 +26,45 @@ class SteamWorkshopUiPlugin final : public UiPlugin { QCoro::Task init(AppContext& ctx) override; QCoro::Task shutdown() override; - void toggle_download_checked(const QString& id, const QString& title, bool checked); - void batch_toggle_download_checked(const QStringList& ids, const QStringList& titles, bool checked); - void toggle_active(const QString& id, bool active); - void batch_toggle_active(const QStringList& ids, bool active); - QCoro::Task request_download(); - void stop_download(); - void remove_item(const QString& id); - void clear_queue(); - void sync_all(); - bool is_downloading() const; - QCoro::Task> fetch_mod_deps(QString id); + void toggleDownloadChecked(const QString& id, const QString& title, bool checked); + void batchToggleDownloadChecked(const QStringList& ids, const QStringList& titles, bool checked); + void toggleActive(const QString& id, bool active); + void batchToggleActive(const QStringList& ids, bool active); + QCoro::Task requestDownload(); + void stopDownload(); + void removeItem(const QString& id); + void clearQueue(); + void syncAll(); + bool isDownloading() const; + QCoro::Task> fetchModDeps(QString id); - Event badges_refresh_requested; - Event active_refresh_requested; - Event sidebar_sync_requested; - Event item_status_changed; - Event download_busy_changed; - Event uncheck_mod_requested; - Event clear_checked_requested; + Event badgesRefreshRequested; + Event activeRefreshRequested; + Event sidebarSyncRequested; + Event itemStatusChanged; + Event downloadBusyChanged; + Event uncheckModRequested; + Event clearCheckedRequested; private: - void disconnect_events(); - void refresh_cached_ids(); - void notify_sidebar(); - void on_item_status(const DownloadItemStatus& status); - void on_download_finished(const DownloadResult& result); - CoreContext* core_ = nullptr; - AppContext* app_ = nullptr; - SteamDownloader* service_ = nullptr; - QSet installed_ids_; - QSet active_ids_; - CheckedDownloads checked_ids_; - QHash statuses_; - bool own_download_ = false; - Event::Id mods_id_ = 0; - Event::Id active_id_ = 0; - Event::Id status_id_ = 0; - Event::Id finished_id_ = 0; - Event::Id busy_id_ = 0; + void disconnectEvents(); + void refreshCachedIds(); + void notifySidebar(); + void onItemStatus(const DownloadItemStatus& status); + void onDownloadFinished(const DownloadResult& result); + CoreContext* m_core = nullptr; + AppContext* m_app = nullptr; + SteamDownloader* m_service = nullptr; + QSet m_installedIds; + QSet m_activeIds; + CheckedDownloads m_checkedIds; + QHash m_statuses; + bool m_ownDownload = false; + Event::Id m_modsId = 0; + Event::Id m_activeId = 0; + Event::Id m_statusId = 0; + Event::Id m_finishedId = 0; + Event::Id m_busyId = 0; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index cf1d74c..68df0a4 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -19,9 +19,9 @@ namespace px::modrim { namespace { -const QUrl workshop_url(QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); +const QUrl kWorkshopUrl(QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); -QString json_argument(const QJsonValue& value) { +QString jsonArgument(const QJsonValue& value) { const auto json = QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); return QString::fromUtf8(json.constData() + 1, json.size() - 2); } @@ -35,7 +35,7 @@ QString injection() { QFile css(QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.css")); QString styles; if (css.open(QIODevice::ReadOnly)) styles = QString::fromUtf8(css.readAll()); - return QStringLiteral("var CSS_STYLES = %1;\n%2").arg(json_argument(styles), QString::fromUtf8(js.readAll())); + return QStringLiteral("var CSS_STYLES = %1;\n%2").arg(jsonArgument(styles), QString::fromUtf8(js.readAll())); } } @@ -45,14 +45,14 @@ SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* eng auto* layout = new QHBoxLayout(content); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - sidebar_ = new DownloadSidebar(engine, content); - layout->addWidget(sidebar_); - qml_ = new QQuickWidget(engine, content); - qml_->setObjectName(QStringLiteral("workshopView")); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); + m_sidebar = new DownloadSidebar(engine, content); + layout->addWidget(m_sidebar); + m_qml = new QQuickWidget(engine, content); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); #ifdef PX_HAS_WEBENGINE - layout->addWidget(qml_, 1); + layout->addWidget(m_qml, 1); #else qml_->hide(); auto* unavailable = new QLabel(QStringLiteral("This build does not include the Steam Workshop web browser."), content); @@ -60,68 +60,68 @@ SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* eng unavailable->setWordWrap(true); layout->addWidget(unavailable, 1); #endif - root_->addWidget(content, 1); + m_root->addWidget(content, 1); } SteamWorkshopViewPanel::~SteamWorkshopViewPanel() { teardown(); } -void SteamWorkshopViewPanel::ensure_initialized() { - if (initialized_) return; - initialized_ = true; - plugin_ = static_cast(app_.plugins().get(QStringLiteral("steamworkshop"))); - if (plugin_) { - connect(sidebar_, &DownloadSidebar::download_requested, this, [this] { plugin_->request_download(); }); - connect(sidebar_, &DownloadSidebar::stop_requested, this, [this] { plugin_->stop_download(); }); - connect(sidebar_, &DownloadSidebar::item_removed, this, [this](const QString& id) { plugin_->remove_item(id); }); - connect(sidebar_, &DownloadSidebar::clear_requested, this, [this] { plugin_->clear_queue(); }); - badges_id_ = plugin_->badges_refresh_requested.connect([this](const QStringList& ids) { - run_js(QStringLiteral("if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)").arg(json_argument(QJsonArray::fromStringList(ids)))); +void SteamWorkshopViewPanel::ensureInitialized() { + if (m_initialized) return; + m_initialized = true; + m_plugin = static_cast(m_app.plugins().get(QStringLiteral("steamworkshop"))); + if (m_plugin) { + connect(m_sidebar, &DownloadSidebar::downloadTriggered, this, [this] { m_plugin->requestDownload(); }); + connect(m_sidebar, &DownloadSidebar::stopTriggered, this, [this] { m_plugin->stopDownload(); }); + connect(m_sidebar, &DownloadSidebar::itemRemoved, this, [this](const QString& id) { m_plugin->removeItem(id); }); + connect(m_sidebar, &DownloadSidebar::clearRequested, this, [this] { m_plugin->clearQueue(); }); + m_badgesId = m_plugin->badgesRefreshRequested.connect([this](const QStringList& ids) { + runJs(QStringLiteral("if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)").arg(jsonArgument(QJsonArray::fromStringList(ids)))); }); - active_id_ = plugin_->active_refresh_requested.connect([this](const QStringList& ids) { - run_js(QStringLiteral("if (window.__pxmSetActive) window.__pxmSetActive(%1)").arg(json_argument(QJsonArray::fromStringList(ids)))); + m_activeId = m_plugin->activeRefreshRequested.connect([this](const QStringList& ids) { + runJs(QStringLiteral("if (window.__pxmSetActive) window.__pxmSetActive(%1)").arg(jsonArgument(QJsonArray::fromStringList(ids)))); }); - sidebar_id_ = plugin_->sidebar_sync_requested.connect([this](const SidebarSync& sync) { sidebar_->sync_from(sync.checked_ids, sync.statuses); }); - status_id_ = plugin_->item_status_changed.connect([this](const DownloadItemStatus& status) { - sidebar_->update_status(status.mod_id, status.status, status.bytes_done, status.bytes_total); + m_sidebarId = m_plugin->sidebarSyncRequested.connect([this](const SidebarSync& sync) { m_sidebar->syncFrom(sync.checkedIds, sync.statuses); }); + m_statusId = m_plugin->itemStatusChanged.connect([this](const DownloadItemStatus& status) { + m_sidebar->updateStatus(status.modId, status.status, status.bytesDone, status.bytesTotal); }); - busy_id_ = plugin_->download_busy_changed.connect([this](bool busy) { sidebar_->set_download_enabled(!busy); }); - uncheck_id_ = plugin_->uncheck_mod_requested.connect([this](const QString& id) { - run_js(QStringLiteral("if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);").arg(json_argument(id))); + m_busyId = m_plugin->downloadBusyChanged.connect([this](bool busy) { m_sidebar->setDownloadEnabled(!busy); }); + m_uncheckId = m_plugin->uncheckModRequested.connect([this](const QString& id) { + runJs(QStringLiteral("if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);").arg(jsonArgument(id))); }); - clear_id_ = plugin_->clear_checked_requested.connect([this](const std::monostate&) { - run_js(QStringLiteral("if (window.__pxmClearChecked) window.__pxmClearChecked();")); + m_clearId = m_plugin->clearCheckedRequested.connect([this](const std::monostate&) { + runJs(QStringLiteral("if (window.__pxmClearChecked) window.__pxmClearChecked();")); }); - sidebar_->set_download_enabled(!plugin_->is_downloading()); - plugin_->sync_all(); + m_sidebar->setDownloadEnabled(!m_plugin->isDownloading()); + m_plugin->syncAll(); } #ifdef PX_HAS_WEBENGINE - auto* bridge = plugin_ ? new PxModRimBridge(*plugin_, qml_) : nullptr; + auto* bridge = m_plugin ? new PxModRimBridge(*m_plugin, m_qml) : nullptr; if (bridge) bridge->setObjectName(QStringLiteral("workshopBridge")); - auto* context = qml_->rootContext(); + auto* context = m_qml->rootContext(); context->setContextProperty(QStringLiteral("steamWorkshopPanel"), this); context->setContextProperty(QStringLiteral("_bridge"), bridge); context->setContextProperty(QStringLiteral("_injectCode"), injection()); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); #endif } QObject* SteamWorkshopViewPanel::web() const { #ifdef PX_HAS_WEBENGINE - if (initialized_ && qml_->rootObject()) return qml_->rootObject()->findChild(QStringLiteral("workshopWeb")); + if (m_initialized && m_qml->rootObject()) return m_qml->rootObject()->findChild(QStringLiteral("workshopWeb")); #endif return nullptr; } -void SteamWorkshopViewPanel::run_js(const QString& code) { +void SteamWorkshopViewPanel::runJs(const QString& code) { #ifdef PX_HAS_WEBENGINE - if (auto* root = qml_->rootObject()) QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); + if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); #else Q_UNUSED(code); #endif } void SteamWorkshopViewPanel::navigateHome() { - if (auto* browser = web()) browser->setProperty("url", workshop_url); + if (auto* browser = web()) browser->setProperty("url", kWorkshopUrl); } void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { @@ -132,32 +132,32 @@ void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { if (auto* browser = web()) browser->setProperty("url", url); } -void SteamWorkshopViewPanel::preload() { ensure_initialized(); } +void SteamWorkshopViewPanel::preload() { ensureInitialized(); } void SteamWorkshopViewPanel::showEvent(QShowEvent* event) { BaseViewPanel::showEvent(event); - ensure_initialized(); + ensureInitialized(); } void SteamWorkshopViewPanel::teardown() { - if (!initialized_) return; + if (!m_initialized) return; if (auto* browser = web()) { QMetaObject::invokeMethod(browser, "stop"); browser->setProperty("url", QUrl(QStringLiteral("about:blank"))); } - qml_->setSource({}); - if (auto* bridge = qml_->findChild(QStringLiteral("workshopBridge"))) delete bridge; - disconnect(sidebar_, nullptr, this, nullptr); - if (plugin_) { - plugin_->badges_refresh_requested.disconnect(badges_id_); - plugin_->active_refresh_requested.disconnect(active_id_); - plugin_->sidebar_sync_requested.disconnect(sidebar_id_); - plugin_->item_status_changed.disconnect(status_id_); - plugin_->download_busy_changed.disconnect(busy_id_); - plugin_->uncheck_mod_requested.disconnect(uncheck_id_); - plugin_->clear_checked_requested.disconnect(clear_id_); + m_qml->setSource({}); + if (auto* bridge = m_qml->findChild(QStringLiteral("workshopBridge"))) delete bridge; + disconnect(m_sidebar, nullptr, this, nullptr); + if (m_plugin) { + m_plugin->badgesRefreshRequested.disconnect(m_badgesId); + m_plugin->activeRefreshRequested.disconnect(m_activeId); + m_plugin->sidebarSyncRequested.disconnect(m_sidebarId); + m_plugin->itemStatusChanged.disconnect(m_statusId); + m_plugin->downloadBusyChanged.disconnect(m_busyId); + m_plugin->uncheckModRequested.disconnect(m_uncheckId); + m_plugin->clearCheckedRequested.disconnect(m_clearId); } - plugin_ = nullptr; - initialized_ = false; + m_plugin = nullptr; + m_initialized = false; } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/view.hpp b/src/ui/plugins/steam_workshop/view.hpp index 8075ffb..806e537 100644 --- a/src/ui/plugins/steam_workshop/view.hpp +++ b/src/ui/plugins/steam_workshop/view.hpp @@ -25,20 +25,20 @@ class SteamWorkshopViewPanel final : public BaseViewPanel { protected: void showEvent(QShowEvent* event) override; private: - void ensure_initialized(); + void ensureInitialized(); QObject* web() const; - void run_js(const QString& code); - DownloadSidebar* sidebar_; - QQuickWidget* qml_; - SteamWorkshopUiPlugin* plugin_ = nullptr; - bool initialized_ = false; - Event::Id badges_id_ = 0; - Event::Id active_id_ = 0; - Event::Id sidebar_id_ = 0; - Event::Id status_id_ = 0; - Event::Id busy_id_ = 0; - Event::Id uncheck_id_ = 0; - Event::Id clear_id_ = 0; + void runJs(const QString& code); + DownloadSidebar* m_sidebar; + QQuickWidget* m_qml; + SteamWorkshopUiPlugin* m_plugin = nullptr; + bool m_initialized = false; + Event::Id m_badgesId = 0; + Event::Id m_activeId = 0; + Event::Id m_sidebarId = 0; + Event::Id m_statusId = 0; + Event::Id m_busyId = 0; + Event::Id m_uncheckId = 0; + Event::Id m_clearId = 0; }; } // namespace px::modrim diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp index 8cf2235..aeb072d 100644 --- a/src/ui/plugins/workshop/details.cpp +++ b/src/ui/plugins/workshop/details.cpp @@ -10,11 +10,11 @@ QString joined(const std::vector& ids) { } } -QCoro::Task> fetch_detail( - workshop::WorkshopCatalog& catalog, QString item_id, QString kind) { +QCoro::Task> fetchDetail( + workshop::WorkshopCatalog& catalog, QString itemId, QString kind) { using namespace workshop; if (kind == QStringLiteral("collection")) { - auto result = co_await catalog.collection(item_id); + auto result = co_await catalog.collection(itemId); if (!result) co_return std::unexpected(result.error()); if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This collection is no longer available."), 404}); auto& value = **result; @@ -24,7 +24,7 @@ QCoro::Task> fetch_detail( if (!value.unavailable_ids.empty()) warning += QStringLiteral(" Unavailable: ") + joined(value.unavailable_ids); co_return Detail{std::move(value.collection), std::move(value.members), warning.toStdString(), value.is_complete}; } - auto result = co_await catalog.mod(item_id); + auto result = co_await catalog.mod(itemId); if (!result) co_return std::unexpected(result.error()); if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This mod is no longer available."), 404}); auto mod = std::move(**result); @@ -44,14 +44,14 @@ QCoro::Task> fetch_detail( co_return Detail{std::move(mod), std::move(members), warning.toStdString(), missing.isEmpty()}; } -QString incomplete_plan_notice(const workshop::DownloadPlan& plan) { +QString incompletePlanNotice(const workshop::DownloadPlan& plan) { QStringList parts{QStringLiteral("The download plan is incomplete.")}; if (!plan.unavailable_ids.isEmpty()) parts.append(QStringLiteral("Unavailable: ") + plan.unavailable_ids.join(QStringLiteral(", "))); if (!plan.incomplete_collection_ids.isEmpty()) parts.append(QStringLiteral("Incomplete collections: ") + plan.incomplete_collection_ids.join(QStringLiteral(", "))); return parts.join(QLatin1Char(' ')); } -QString queued_notice(qsizetype count) { +QString queuedNotice(qsizetype count) { return QStringLiteral("Queued %1 mod%2. Progress is shown in the header and results in Downloads. Mods are not activated.") .arg(count).arg(count == 1 ? QString{} : QStringLiteral("s")); } diff --git a/src/ui/plugins/workshop/details.hpp b/src/ui/plugins/workshop/details.hpp index 32d302c..ddacc0d 100644 --- a/src/ui/plugins/workshop/details.hpp +++ b/src/ui/plugins/workshop/details.hpp @@ -13,9 +13,9 @@ struct Detail { bool operator==(const Detail&) const = default; }; -QCoro::Task> fetch_detail( - workshop::WorkshopCatalog& catalog, QString item_id, QString kind); -QString incomplete_plan_notice(const workshop::DownloadPlan& plan); -QString queued_notice(qsizetype count); +QCoro::Task> fetchDetail( + workshop::WorkshopCatalog& catalog, QString itemId, QString kind); +QString incompletePlanNotice(const workshop::DownloadPlan& plan); +QString queuedNotice(qsizetype count); } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp index 2f0b803..4e10bb0 100644 --- a/src/ui/plugins/workshop/installed.cpp +++ b/src/ui/plugins/workshop/installed.cpp @@ -8,52 +8,52 @@ namespace px::modrim::workshop { void InstalledList::replace(std::vector mods) { - mods_ = std::move(mods); + m_mods = std::move(mods); rematch(); } void InstalledList::search(QString query) { - query_ = std::move(query); - reset_paging(); + m_query = std::move(query); + resetPaging(); rematch(); } -void InstalledList::reset_paging() { - shown_ = PAGE; +void InstalledList::resetPaging() { + m_shown = kPage; } std::vector InstalledList::visible() const { - return slice(0, std::min(shown_, matches_.size())); + return slice(0, std::min(m_shown, m_matches.size())); } -std::vector InstalledList::next_page() { - const auto start = shown_; - shown_ = std::min(start + PAGE, matches_.size()); - return slice(start, shown_); +std::vector InstalledList::nextPage() { + const auto start = m_shown; + m_shown = std::min(start + kPage, m_matches.size()); + return slice(start, m_shown); } std::vector InstalledList::slice(std::size_t start, std::size_t end) const { std::vector result; if (start >= end) return result; result.reserve(end - start); - for (auto i = start; i < end; ++i) result.push_back(mods_[matches_[i]]); + for (auto i = start; i < end; ++i) result.push_back(m_mods[m_matches[i]]); return result; } void InstalledList::rematch() { - matches_.clear(); - const QString needle = query_.toCaseFolded(); - for (std::size_t i = 0; i < mods_.size(); ++i) { - const auto& mod = mods_[i]; + m_matches.clear(); + const QString needle = m_query.toCaseFolded(); + for (std::size_t i = 0; i < m_mods.size(); ++i) { + const auto& mod = m_mods[i]; if (needle.isEmpty()) { - matches_.push_back(i); + m_matches.push_back(i); continue; } QStringList fields{QString::fromStdString(mod.title), mod.author.name ? QString::fromStdString(*mod.author.name) : QString{}, QString::fromStdString(mod.id)}; for (const auto& tag : mod.tags) fields.append(QString::fromStdString(tag)); - if (fields.join(' ').toCaseFolded().contains(needle)) matches_.push_back(i); + if (fields.join(' ').toCaseFolded().contains(needle)) m_matches.push_back(i); } } diff --git a/src/ui/plugins/workshop/installed.hpp b/src/ui/plugins/workshop/installed.hpp index bedf52f..1c62a98 100644 --- a/src/ui/plugins/workshop/installed.hpp +++ b/src/ui/plugins/workshop/installed.hpp @@ -9,29 +9,29 @@ namespace px::modrim::workshop { -inline constexpr int PAGE = 48; +inline constexpr int kPage = 48; class InstalledList { public: - const std::vector& mods() const { return mods_; } - bool loaded() const { return !mods_.empty(); } - int total() const { return static_cast(matches_.size()); } - bool has_more() const { return shown_ < matches_.size(); } + const std::vector& mods() const { return m_mods; } + bool loaded() const { return !m_mods.empty(); } + int total() const { return static_cast(m_matches.size()); } + bool hasMore() const { return m_shown < m_matches.size(); } void replace(std::vector mods); void search(QString query); - void reset_paging(); + void resetPaging(); std::vector visible() const; - std::vector next_page(); + std::vector nextPage(); private: void rematch(); std::vector slice(std::size_t start, std::size_t end) const; - std::vector mods_; - std::vector matches_; - QString query_; - std::size_t shown_ = PAGE; + std::vector m_mods; + std::vector m_matches; + QString m_query; + std::size_t m_shown = kPage; }; } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp index 92cbdb3..1a13634 100644 --- a/src/ui/plugins/workshop/models.cpp +++ b/src/ui/plugins/workshop/models.cpp @@ -16,26 +16,26 @@ namespace px::modrim::workshop { namespace { using namespace workshop; -constexpr std::array roles = { +constexpr std::array kRoles = { "itemId", "kind", "title", "author", "previewUrl", "collage", "sourceLabel", "versions", "tags", "state", "active", "stateLabel", "actionLabel", "incompatible", "memberCount", "description", "workshopUrl", "fileSize", "votes", "votesUp", "votesDown", "queued", "favourite", "summary", "compatLabel"}; -QString size_label(const std::optional& bytes) { +QString sizeLabel(const std::optional& bytes) { return bytes ? QString::number(QString::fromStdString(*bytes).toDouble() / 1048576.0, 'f', 1) + " MB" : QStringLiteral("Unknown"); } -QString compat_label(const QSet& versions, const QString& game, bool incompatible, bool no_common) { +QString compatLabel(const QSet& versions, const QString& game, bool incompatible, bool noCommon) { if (game.isEmpty()) return {}; - if (no_common) return QStringLiteral("No common version"); + if (noCommon) return QStringLiteral("No common version"); if (incompatible) return "Not for " + game; if (versions.isEmpty()) return QStringLiteral("Version not stated"); return "Works with " + game; } } // namespace -QString safe_url(const QString& value) { +QString safeUrl(const QString& value) { for (const QChar ch : value) { if (ch.isSpace() || ch.unicode() < 32) return {}; } @@ -61,52 +61,52 @@ QString summarize(const QString& text, const QString& format) { return result + QChar(0x2026); } -QVariantMap item_row(const CatalogEntry& entry, - const std::function& install_state, - const QString& game_version, bool detail) { +QVariantMap itemRow(const CatalogEntry& entry, + const std::function& installState, + const QString& gameVersion, bool detail) { return std::visit([&](const auto& item) { using Item = std::decay_t; - constexpr bool collection = std::is_same_v; + constexpr bool kCollection = std::is_same_v; QSet versions; for (const auto& version : item.supported_versions) versions.insert(QString::fromStdString(version)); - if constexpr (collection) { - static const QRegularExpression version_tag(QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); + if constexpr (kCollection) { + static const QRegularExpression versionTag(QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); for (const auto& tag : item.tags) { const QString value = QString::fromStdString(tag); - if (version_tag.match(value).hasMatch()) versions.insert(value); + if (versionTag.match(value).hasMatch()) versions.insert(value); } } - bool incompatible = !game_version.isEmpty() && !versions.isEmpty() && !versions.contains(game_version); - bool no_common = false; - InstallState state = InstallState::missing; + bool incompatible = !gameVersion.isEmpty() && !versions.isEmpty() && !versions.contains(gameVersion); + bool noCommon = false; + InstallState state = InstallState::kMissing; QString source, action, size, votes = QStringLiteral("Unrated"), up = "0", down = "0"; - QString workshop_url, format = QStringLiteral("bbcode"); + QString workshopUrl, format = QStringLiteral("bbcode"); QStringList collage; - qlonglong member_count = 0; - if constexpr (collection) { - source = item.source == CollectionSource::picked ? "PxModRim pick" : "Steam collection"; + qlonglong memberCount = 0; + if constexpr (kCollection) { + source = item.source == CollectionSource::kPicked ? "PxModRim pick" : "Steam collection"; action = QStringLiteral("Download all"); - member_count = item.member_count; - size = size_label(item.total_size); - no_common = item.no_common_version && versions.isEmpty(); - incompatible = incompatible || no_common; - if (item.workshop_url) workshop_url = QString::fromStdString(*item.workshop_url); - if (item.description_format == CollectionDescriptionFormat::text) format = QStringLiteral("text"); + memberCount = item.member_count; + size = sizeLabel(item.total_size); + noCommon = item.no_common_version && versions.isEmpty(); + incompatible = incompatible || noCommon; + if (item.workshop_url) workshopUrl = QString::fromStdString(*item.workshop_url); + if (item.description_format == CollectionDescriptionFormat::kText) format = QStringLiteral("text"); for (const auto& preview : item.member_previews) { - const QString url = safe_url(QString::fromStdString(preview)); + const QString url = safeUrl(QString::fromStdString(preview)); if (!url.isEmpty()) collage.append(url); } } else { - state = install_state(item); + state = installState(item); incompatible = incompatible || item.incompatible; source = QStringLiteral("Steam Workshop"); switch (state) { - case InstallState::missing: action = QStringLiteral("Download"); break; - case InstallState::installed: action = QStringLiteral("Installed"); break; - case InstallState::outdated: action = QStringLiteral("Update"); break; + case InstallState::kMissing: action = QStringLiteral("Download"); break; + case InstallState::kInstalled: action = QStringLiteral("Installed"); break; + case InstallState::kOutdated: action = QStringLiteral("Update"); break; } - size = size_label(item.file_size); - workshop_url = QString::fromStdString(item.workshop_url); + size = sizeLabel(item.file_size); + workshopUrl = QString::fromStdString(item.workshop_url); if (item.votes) { const QLocale locale(QLocale::English, QLocale::UnitedStates); up = locale.toString(static_cast(item.votes->up)); @@ -115,16 +115,16 @@ QVariantMap item_row(const CatalogEntry& entry, if (total != 0) votes = QString::number(std::nearbyint(100.0 * item.votes->up / total), 'f', 0) + '%'; } } - QString state_name, state_label; + QString stateName, stateLabel; switch (state) { - case InstallState::missing: state_name = "missing"; state_label = "Not installed"; break; - case InstallState::installed: state_name = "installed"; state_label = "Installed"; break; - case InstallState::outdated: state_name = "outdated"; state_label = "Update available"; break; + case InstallState::kMissing: stateName = "missing"; stateLabel = "Not installed"; break; + case InstallState::kInstalled: stateName = "installed"; stateLabel = "Installed"; break; + case InstallState::kOutdated: stateName = "outdated"; stateLabel = "Update available"; break; } QString preview = item.preview_url ? QString::fromStdString(*item.preview_url) : QString{}; if (preview.isEmpty()) { for (const auto& image : item.previews) { - if (image.type == PreviewType::image) { + if (image.type == PreviewType::kImage) { preview = QString::fromStdString(image.url); break; } @@ -142,34 +142,34 @@ QVariantMap item_row(const CatalogEntry& entry, sorted.emplace_back(std::move(parts), version); } std::sort(sorted.begin(), sorted.end()); - QStringList version_labels, tag_labels; - for (const auto& version : sorted) version_labels.append(version.second); + QStringList versionLabels, tagLabels; + for (const auto& version : sorted) versionLabels.append(version.second); for (const auto& tag : item.tags) { const QString value = QString::fromStdString(tag); - if (!versions.contains(value)) tag_labels.append(value); + if (!versions.contains(value)) tagLabels.append(value); } const QString description = QString::fromStdString(item.description); return QVariantMap{ - {"itemId", QString::fromStdString(item.id)}, {"kind", collection ? "collection" : "mod"}, - {"title", QString::fromStdString(item.title)}, {"author", author}, {"previewUrl", safe_url(preview)}, + {"itemId", QString::fromStdString(item.id)}, {"kind", kCollection ? "collection" : "mod"}, + {"title", QString::fromStdString(item.title)}, {"author", author}, {"previewUrl", safeUrl(preview)}, {"collage", collage}, {"sourceLabel", source}, - {"versions", version_labels.isEmpty() ? QStringLiteral("Not specified") : version_labels.join(", ")}, - {"tags", tag_labels.join(QStringLiteral(" · "))}, {"state", state_name}, {"active", false}, - {"queued", false}, {"favourite", false}, {"stateLabel", state_label}, {"actionLabel", action}, - {"incompatible", incompatible}, {"memberCount", member_count}, - {"description", detail ? px::modrim::description_to_html(description, format) : QString{}}, - {"workshopUrl", safe_url(workshop_url)}, {"fileSize", size}, {"votes", votes}, + {"versions", versionLabels.isEmpty() ? QStringLiteral("Not specified") : versionLabels.join(", ")}, + {"tags", tagLabels.join(QStringLiteral(" · "))}, {"state", stateName}, {"active", false}, + {"queued", false}, {"favourite", false}, {"stateLabel", stateLabel}, {"actionLabel", action}, + {"incompatible", incompatible}, {"memberCount", memberCount}, + {"description", detail ? px::modrim::descriptionToHtml(description, format) : QString{}}, + {"workshopUrl", safeUrl(workshopUrl)}, {"fileSize", size}, {"votes", votes}, {"votesUp", up}, {"votesDown", down}, {"summary", summarize(description, format)}, - {"compatLabel", compat_label(versions, game_version, incompatible, no_common)}}; + {"compatLabel", compatLabel(versions, gameVersion, incompatible, noCommon)}}; }, entry); } CatalogListModel::CatalogListModel(QObject* parent) : QAbstractListModel(parent) {} -void CatalogListModel::set_page(QList rows, int total, std::optional cursor, bool append) { +void CatalogListModel::setPage(QList rows, int total, std::optional cursor, bool append) { if (append) { QSet known; - for (const auto& row : rows_) known.insert(row.value("itemId").toString()); + for (const auto& row : m_rows) known.insert(row.value("itemId").toString()); QList additions; for (auto& row : rows) { const QString id = row.value("itemId").toString(); @@ -180,30 +180,30 @@ void CatalogListModel::set_page(QList rows, int total, std::optiona } if (!additions.isEmpty()) { beginInsertRows({}, count(), count() + static_cast(additions.size()) - 1); - rows_.append(std::move(additions)); + m_rows.append(std::move(additions)); endInsertRows(); } - } else if (rows.size() == rows_.size() && std::equal(rows.cbegin(), rows.cend(), rows_.cbegin(), + } else if (rows.size() == m_rows.size() && std::equal(rows.cbegin(), rows.cend(), m_rows.cbegin(), [](const auto& a, const auto& b) { return a.value("itemId") == b.value("itemId"); })) { for (qsizetype i = 0; i < rows.size(); ++i) { - if (rows.at(i) != rows_.at(i)) { - rows_[i] = std::move(rows[i]); + if (rows.at(i) != m_rows.at(i)) { + m_rows[i] = std::move(rows[i]); emit dataChanged(index(static_cast(i)), index(static_cast(i))); } } } else { beginResetModel(); - rows_ = std::move(rows); + m_rows = std::move(rows); endResetModel(); } - total_ = total; - cursor_ = std::move(cursor); + m_total = total; + m_cursor = std::move(cursor); emit changed(); } -void CatalogListModel::refresh_rows(const std::function& convert) { - for (auto& row : rows_) row = convert(row); - if (!rows_.isEmpty()) emit dataChanged(index(0), index(count() - 1)); +void CatalogListModel::refreshRows(const std::function& convert) { + for (auto& row : m_rows) row = convert(row); + if (!m_rows.isEmpty()) emit dataChanged(index(0), index(count() - 1)); } int CatalogListModel::rowCount(const QModelIndex& parent) const { @@ -212,13 +212,13 @@ int CatalogListModel::rowCount(const QModelIndex& parent) const { QVariant CatalogListModel::data(const QModelIndex& index, int role) const { const int offset = role - Qt::UserRole - 1; - if (!index.isValid() || index.row() < 0 || index.row() >= count() || offset < 0 || offset >= static_cast(roles.size())) return {}; - return rows_.at(index.row()).value(QString::fromLatin1(roles[offset])); + if (!index.isValid() || index.row() < 0 || index.row() >= count() || offset < 0 || offset >= static_cast(kRoles.size())) return {}; + return m_rows.at(index.row()).value(QString::fromLatin1(kRoles[offset])); } QHash CatalogListModel::roleNames() const { QHash names; - for (std::size_t i = 0; i < roles.size(); ++i) names.insert(Qt::UserRole + static_cast(i) + 1, roles[i]); + for (std::size_t i = 0; i < kRoles.size(); ++i) names.insert(Qt::UserRole + static_cast(i) + 1, kRoles[i]); return names; } diff --git a/src/ui/plugins/workshop/models.hpp b/src/ui/plugins/workshop/models.hpp index 759af8c..6e42c59 100644 --- a/src/ui/plugins/workshop/models.hpp +++ b/src/ui/plugins/workshop/models.hpp @@ -12,11 +12,11 @@ namespace px::modrim::workshop { using workshop::CatalogEntry; -QString safe_url(const QString& value); +QString safeUrl(const QString& value); QString summarize(const QString& text, const QString& format = QStringLiteral("bbcode")); -QVariantMap item_row(const CatalogEntry& item, - const std::function& install_state, - const QString& game_version, bool detail = false); +QVariantMap itemRow(const CatalogEntry& item, + const std::function& installState, + const QString& gameVersion, bool detail = false); class CatalogListModel : public QAbstractListModel { Q_OBJECT @@ -27,25 +27,25 @@ class CatalogListModel : public QAbstractListModel { public: explicit CatalogListModel(QObject* parent = nullptr); - void set_page(QList rows, int total, std::optional cursor, bool append = false); - void refresh_rows(const std::function& convert); + void setPage(QList rows, int total, std::optional cursor, bool append = false); + void refreshRows(const std::function& convert); int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - const std::optional& cursor() const { return cursor_; } - bool hasMore() const { return cursor_.has_value(); } - int count() const { return static_cast(rows_.size()); } - int total() const { return total_; } - const QList& rows() const { return rows_; } + const std::optional& cursor() const { return m_cursor; } + bool hasMore() const { return m_cursor.has_value(); } + int count() const { return static_cast(m_rows.size()); } + int total() const { return m_total; } + const QList& rows() const { return m_rows; } signals: void changed(); private: - QList rows_; - std::optional cursor_; - int total_ = 0; + QList m_rows; + std::optional m_cursor; + int m_total = 0; }; } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp index 8c7724f..caaf0bd 100644 --- a/src/ui/plugins/workshop/plugin.cpp +++ b/src/ui/plugins/workshop/plugin.cpp @@ -5,11 +5,11 @@ namespace px::modrim::workshop { CatalogSettingsSection::CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent) - : PluginConfigSectionBase({{QStringLiteral("url"), QString::fromStdString(catalog.settings().url)}}, parent), catalog_(catalog) {} + : PluginConfigSectionBase({{QStringLiteral("url"), QString::fromStdString(catalog.settings().url)}}, parent), m_catalog(catalog) {} QString CatalogSettingsSection::title() const { return QStringLiteral("Workshop catalog"); } QUrl CatalogSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); } Result CatalogSettingsSection::commit(const QJsonObject& merged) { - catalog_.set_base_url(merged.value(QStringLiteral("url")).toString()); + m_catalog.setBaseUrl(merged.value(QStringLiteral("url")).toString()); return {}; } diff --git a/src/ui/plugins/workshop/plugin.hpp b/src/ui/plugins/workshop/plugin.hpp index 8657928..4580acb 100644 --- a/src/ui/plugins/workshop/plugin.hpp +++ b/src/ui/plugins/workshop/plugin.hpp @@ -16,7 +16,7 @@ class CatalogSettingsSection : public PluginConfigSectionBase { Result commit(const QJsonObject& merged) override; private: - workshop::WorkshopCatalog& catalog_; + workshop::WorkshopCatalog& m_catalog; }; class WorkshopUiPlugin : public UiPlugin { diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index 5dd2016..6206a74 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -17,125 +17,125 @@ namespace px::modrim::workshop { using namespace workshop; namespace { -QString id_of(const CatalogEntry& item) { +QString idOf(const CatalogEntry& item) { return std::visit([](const auto& value) { return QString::fromStdString(value.id); }, item); } -QSet ids_of(const std::vector& ids) { +QSet idsOf(const std::vector& ids) { QSet result; for (const auto& id : ids) result.insert(QString::fromStdString(id)); return result; } -std::optional cursor_of(const std::optional& cursor) { +std::optional cursorOf(const std::optional& cursor) { return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; } -WorkshopCatalog& catalog_of(CoreContext& ctx) { +WorkshopCatalog& catalogOf(CoreContext& ctx) { return *static_cast(ctx.plugins().get(QStringLiteral("workshop_catalog"))); } } WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : WorkshopViewPanel(ctx, engine, parent, app, catalog_of(ctx)) {} + : WorkshopViewPanel(ctx, engine, parent, app, catalogOf(ctx)) {} WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, WorkshopCatalog& catalog) - : BaseViewPanel(ctx, engine, parent, app), mods_model(new CatalogListModel(this)), - collections_model(new CatalogListModel(this)), members_model(new CatalogListModel(this)), - catalog_(catalog), downloads_(ctx.downloads()), favourites_(catalog.favourites()) { - if (!gameVersion().isEmpty()) criteria_.version = gameVersion(); - refresh_mod_ids(); - qml_ = new QQuickWidget(engine, this); - qml_->setObjectName(QStringLiteral("workshopView")); - qml_->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml_->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); - qml_->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); - qml_->rootContext()->setContextProperty(QStringLiteral("catalogMods"), mods_model); - qml_->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collections_model); - qml_->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), members_model); - busy_id_ = downloads_.busy_changed.connect([this](bool) { refresh_rows(); emit changed(); }); - queue_id_ = catalog_.queue_changed.connect([this](const std::monostate&) { refresh_rows(); emit changed(); }); - url_id_ = catalog_.catalog_url_changed.connect([this](const QString&) { url_changed(); }); - installed_id_ = catalog_.installed_changed.connect([this](const std::monostate&) { installed_changed(); }); - favourites_id_ = favourites_.changed().connect([this](const FavouritesState&) { - if (tab_ == QStringLiteral("Favourites") && !detail_data_) show_favourites(); - else { refresh_rows(); emit changed(); } + : BaseViewPanel(ctx, engine, parent, app), modsModel(new CatalogListModel(this)), + collectionsModel(new CatalogListModel(this)), membersModel(new CatalogListModel(this)), + m_catalog(catalog), m_downloads(ctx.downloads()), m_favourites(catalog.favourites()) { + if (!gameVersion().isEmpty()) m_criteria.version = gameVersion(); + refreshModIds(); + m_qml = new QQuickWidget(engine, this); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); + m_qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), modsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collectionsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), membersModel); + m_busyId = m_downloads.busyChanged.connect([this](bool) { refreshRows(); emit changed(); }); + m_queueId = m_catalog.queueChanged.connect([this](const std::monostate&) { refreshRows(); emit changed(); }); + m_urlId = m_catalog.catalogUrlChanged.connect([this](const QString&) { urlChanged(); }); + m_installedId = m_catalog.installedChanged.connect([this](const std::monostate&) { installedChanged(); }); + m_favouritesId = m_favourites.changed().connect([this](const FavouritesState&) { + if (m_tab == QStringLiteral("Favourites") && !m_detailData) showFavourites(); + else { refreshRows(); emit changed(); } }); - active_id_ = ctx_.activeStateChanged().connect([this](const QStringList&) { - refresh_mod_ids(); refresh_rows(); emit changed(); + m_activeId = m_ctx.activeStateChanged().connect([this](const QStringList&) { + refreshModIds(); refreshRows(); emit changed(); }); - qml_->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); - root_->addWidget(qml_, 1); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + m_root->addWidget(m_qml, 1); } WorkshopViewPanel::~WorkshopViewPanel() { teardown(); // Workshop.qml bindings read workshopPanel; the view must die while this panel is still alive. - delete qml_; + delete m_qml; } void WorkshopViewPanel::teardown() { - if (torn_down_) return; - torn_down_ = true; - ++generation_; - retry_ = {}; - downloads_.busy_changed.disconnect(busy_id_); - catalog_.queue_changed.disconnect(queue_id_); - catalog_.catalog_url_changed.disconnect(url_id_); - catalog_.installed_changed.disconnect(installed_id_); - favourites_.changed().disconnect(favourites_id_); - ctx_.activeStateChanged().disconnect(active_id_); + if (m_tornDown) return; + m_tornDown = true; + ++m_generation; + m_retry = {}; + m_downloads.busyChanged.disconnect(m_busyId); + m_catalog.queueChanged.disconnect(m_queueId); + m_catalog.catalogUrlChanged.disconnect(m_urlId); + m_catalog.installedChanged.disconnect(m_installedId); + m_favourites.changed().disconnect(m_favouritesId); + m_ctx.activeStateChanged().disconnect(m_activeId); } QString WorkshopViewPanel::gameVersion() const { - const auto version = ctx_.targetVersion(); + const auto version = m_ctx.targetVersion(); return version == QStringLiteral("Unknown") ? QString{} : version; } -void WorkshopViewPanel::refresh_mod_ids() { - installed_ids_.clear(); active_ids_.clear(); - const auto& mods = ctx_.allMods(); - for (const auto& mod : mods) if (auto id = mod->published_file_id()) installed_ids_.insert(*id); - for (const auto& uuid : ctx_.activeUuids()) { +void WorkshopViewPanel::refreshModIds() { + m_installedIds.clear(); m_activeIds.clear(); + const auto& mods = m_ctx.allMods(); + for (const auto& mod : mods) if (auto id = mod->publishedFileId()) m_installedIds.insert(*id); + for (const auto& uuid : m_ctx.activeUuids()) { auto mod = mods.value(uuid); - if (mod) if (auto id = mod->published_file_id()) active_ids_.insert(*id); + if (mod) if (auto id = mod->publishedFileId()) m_activeIds.insert(*id); } } QVariantMap WorkshopViewPanel::row(const CatalogEntry& item, bool detail) { - const auto id = id_of(item); - items_.insert(id, item); + const auto id = idOf(item); + m_items.insert(id, item); std::visit([this](const auto& value) { - auto tags = ids_of(value.tags); - tags.subtract(ids_of(value.supported_versions)); - known_tags_.unite(tags); + auto tags = idsOf(value.tags); + tags.subtract(idsOf(value.supported_versions)); + m_knownTags.unite(tags); }, item); - auto result = item_row(item, [this](const CatalogMod& mod) { return catalog_.install_state(mod); }, gameVersion(), detail); + auto result = itemRow(item, [this](const CatalogMod& mod) { return m_catalog.installState(mod); }, gameVersion(), detail); const auto* mod = std::get_if(&item); const auto* collection = std::get_if(&item); - result[QStringLiteral("active")] = mod && active_ids_.contains(id); + result[QStringLiteral("active")] = mod && m_activeIds.contains(id); if (collection) { if (!detail) { qsizetype count = 0; - for (const auto& member : collection->member_ids) count += installed_ids_.contains(QString::fromStdString(member)); + for (const auto& member : collection->member_ids) count += m_installedIds.contains(QString::fromStdString(member)); if (!collection->member_ids.empty() && count == static_cast(collection->member_ids.size())) { result[QStringLiteral("state")] = QStringLiteral("installed"); result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); } else if (count) result[QStringLiteral("actionLabel")] = QStringLiteral("Complete download"); } - if (favourites_.contains(collection->id)) { + if (m_favourites.contains(collection->id)) { result[QStringLiteral("favourite")] = true; - favourites_.remember(*collection); + m_favourites.remember(*collection); } } if (collection || result.value(QStringLiteral("state")).toString() != QStringLiteral("installed")) { - if (preparing_.contains(id)) { + if (m_preparing.contains(id)) { result[QStringLiteral("queued")] = true; result[QStringLiteral("actionLabel")] = QStringLiteral("Preparing…"); } else { - QSet ids = mod ? QSet{id} : ids_of(collection->member_ids) | collection_mods_.value(id); - if (collection && detail_data_ && id_of(detail_data_->item) == id) ids.unite(pack_ids_); - if (ids.intersects(downloads_.active_ids())) { + QSet ids = mod ? QSet{id} : idsOf(collection->member_ids) | m_collectionMods.value(id); + if (collection && m_detailData && idOf(m_detailData->item) == id) ids.unite(m_packIds); + if (ids.intersects(m_downloads.activeIds())) { result[QStringLiteral("queued")] = true; result[QStringLiteral("actionLabel")] = QStringLiteral("Downloading"); - } else if (ids.intersects(catalog_.queued_ids())) { + } else if (ids.intersects(m_catalog.queuedIds())) { result[QStringLiteral("queued")] = true; result[QStringLiteral("actionLabel")] = QStringLiteral("Queued"); } @@ -144,7 +144,7 @@ QVariantMap WorkshopViewPanel::row(const CatalogEntry& item, bool detail) { return result; } -QVariantMap WorkshopViewPanel::detail_row(const Detail& detail) { +QVariantMap WorkshopViewPanel::detailRow(const Detail& detail) { auto result = row(detail.item, true); result[QStringLiteral("complete")] = detail.complete; result[QStringLiteral("warning")] = QString::fromStdString(detail.warning); @@ -156,9 +156,9 @@ QVariantMap WorkshopViewPanel::detail_row(const Detail& detail) { if (!mod) continue; QString preview = QString::fromStdString(mod->preview_url.value_or("")); if (preview.isEmpty()) for (const auto& image : mod->previews) { - if (image.type == PreviewType::image) { preview = QString::fromStdString(image.url); break; } + if (image.type == PreviewType::kImage) { preview = QString::fromStdString(image.url); break; } } - preview = safe_url(preview); + preview = safeUrl(preview); if (!preview.isEmpty()) collage.append(preview); if (collage.size() == 4) break; } @@ -166,288 +166,288 @@ QVariantMap WorkshopViewPanel::detail_row(const Detail& detail) { } bool started = false; for (const auto& member : detail.members) if (auto* mod = std::get_if(&member)) - started |= catalog_.install_state(*mod) != InstallState::missing; + started |= m_catalog.installState(*mod) != InstallState::kMissing; if (!result.value(QStringLiteral("queued")).toBool()) result[QStringLiteral("actionLabel")] = started ? QStringLiteral("Complete download") : QStringLiteral("Download all"); - if (auto copies = pack_copies()) { + if (auto copies = packCopies()) { result[QStringLiteral("state")] = QStringLiteral("installed"); result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); if (!result.value(QStringLiteral("queued")).toBool()) result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); - result[QStringLiteral("active")] = std::ranges::all_of(*copies, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); + result[QStringLiteral("active")] = std::ranges::all_of(*copies, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); } } return result; } -void WorkshopViewPanel::refresh_rows() { - for (auto* model : {mods_model, collections_model, members_model}) model->refresh_rows([this](const QVariantMap& value) { - return row(items_.value(value.value(QStringLiteral("itemId")).toString())); +void WorkshopViewPanel::refreshRows() { + for (auto* model : {modsModel, collectionsModel, membersModel}) model->refreshRows([this](const QVariantMap& value) { + return row(m_items.value(value.value(QStringLiteral("itemId")).toString())); }); - if (detail_data_) detail_ = detail_row(*detail_data_); + if (m_detailData) m_detail = detailRow(*m_detailData); } -void WorkshopViewPanel::clear_detail() { - detail_.clear(); detail_data_.reset(); current_.reset(); trail_.clear(); pack_ids_.clear(); +void WorkshopViewPanel::clearDetail() { + m_detail.clear(); m_detailData.reset(); m_current.reset(); m_trail.clear(); m_packIds.clear(); } -std::optional WorkshopViewPanel::pack_copies() const { - if (torn_down_ || pack_ids_.isEmpty()) return std::nullopt; +std::optional WorkshopViewPanel::packCopies() const { + if (m_tornDown || m_packIds.isEmpty()) return std::nullopt; QHash installed; - for (auto it = ctx_.allMods().cbegin(); it != ctx_.allMods().cend(); ++it) - if (auto id = it.value()->published_file_id(); id && pack_ids_.contains(*id)) installed[*id].append(it.key()); - if (installed.size() < pack_ids_.size()) return std::nullopt; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) + if (auto id = it.value()->publishedFileId(); id && m_packIds.contains(*id)) installed[*id].append(it.key()); + if (installed.size() < m_packIds.size()) return std::nullopt; QStringList copies; for (const auto& uuids : installed) { - auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); + auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); copies.append(active == uuids.cend() ? uuids.front() : *active); } return copies; } -QCoro::Task WorkshopViewPanel::load_pack() { - if (!detail_data_ || torn_down_) co_return; +QCoro::Task WorkshopViewPanel::loadPack() { + if (!m_detailData || m_tornDown) co_return; QPointer self(this); - const int generation = generation_; - auto result = co_await catalog_.plan({}, {id_of(detail_data_->item)}); - if (!self || self->torn_down_ || generation != self->generation_) co_return; + const int generation = m_generation; + auto result = co_await m_catalog.plan({}, {idOf(m_detailData->item)}); + if (!self || self->m_tornDown || generation != self->m_generation) co_return; if (!result) { spdlog::warn("[workshop] cannot resolve collection mods: {}", result.error().message.toStdString()); co_return; } - self->pack_ids_.clear(); + self->m_packIds.clear(); if (result->is_complete) { - self->pack_ids_ = QSet(result->to_download.cbegin(), result->to_download.cend()); - self->pack_ids_.unite(QSet(result->already_current.cbegin(), result->already_current.cend())); + self->m_packIds = QSet(result->to_download.cbegin(), result->to_download.cend()); + self->m_packIds.unite(QSet(result->already_current.cbegin(), result->already_current.cend())); } - self->refresh_rows(); emit self->changed(); + self->refreshRows(); emit self->changed(); } QCoro::Task WorkshopViewPanel::load(bool append) { - if (torn_down_) co_return; - if (!configured()) { ++generation_; busy_ = false; emit changed(); co_return; } - clear_detail(); - auto* catalog = &catalog_; - if (tab_ == QStringLiteral("Discover")) { - co_await run_cached(QStringLiteral("discover"), [catalog] { return catalog->discover(); }, [this](const Discover& value) { - mods_model->set_page(rows(value.mods.items), static_cast(value.mods.total), std::nullopt); - collections_model->set_page(rows(value.collections.items), static_cast(value.collections.total), std::nullopt); + if (m_tornDown) co_return; + if (!configured()) { ++m_generation; m_busy = false; emit changed(); co_return; } + clearDetail(); + auto* catalog = &m_catalog; + if (m_tab == QStringLiteral("Discover")) { + co_await runCached(QStringLiteral("discover"), [catalog] { return catalog->discover(); }, [this](const Discover& value) { + modsModel->setPage(rows(value.mods.items), static_cast(value.mods.total), std::nullopt); + collectionsModel->setPage(rows(value.collections.items), static_cast(value.collections.total), std::nullopt); }); - } else if (tab_ == QStringLiteral("Favourites")) { - ++generation_; busy_ = false; error_.clear(); show_favourites(); - } else if (tab_ == QStringLiteral("Installed")) { + } else if (m_tab == QStringLiteral("Favourites")) { + ++m_generation; m_busy = false; m_error.clear(); showFavourites(); + } else if (m_tab == QStringLiteral("Installed")) { if (append) { - mods_model->set_page(rows(installed_.next_page()), installed_.total(), installed_cursor(), true); + modsModel->setPage(rows(m_installed.nextPage()), m_installed.total(), installedCursor(), true); co_return; } - installed_dirty_ = false; - auto ids = installed_ids_.values(); + m_installedDirty = false; + auto ids = m_installedIds.values(); std::ranges::sort(ids); const auto fingerprint = QCryptographicHash::hash(ids.join(QLatin1Char(',')).toUtf8(), QCryptographicHash::Sha256).toHex(); - co_await run_cached>(QStringLiteral("installed|") + QString::fromLatin1(fingerprint), - [catalog] { return catalog->installed_with_updates(); }, [this](const std::vector& value) { - installed_.replace(value); recount(); show_installed(); + co_await runCached>(QStringLiteral("installed|") + QString::fromLatin1(fingerprint), + [catalog] { return catalog->installedWithUpdates(); }, [this](const std::vector& value) { + m_installed.replace(value); recount(); showInstalled(); }); } else { - const bool collections = tab_ == QStringLiteral("Collections"); - auto* model = collections ? collections_model : mods_model; - auto query = criteria_; + const bool collections = m_tab == QStringLiteral("Collections"); + auto* model = collections ? collectionsModel : modsModel; + auto query = m_criteria; query.cursor = append ? model->cursor() : std::nullopt; - const QString key = tab_ + QLatin1Char('|') + QString::fromLatin1(QCryptographicHash::hash( + const QString key = m_tab + QLatin1Char('|') + QString::fromLatin1(QCryptographicHash::hash( QJsonDocument(QJsonArray{query.query, query.tag.value_or(QString{}), query.version.value_or(QString{}), query.sort, query.limit, query.source}).toJson(QJsonDocument::Compact), QCryptographicHash::Sha256).toHex()); if (collections) { - co_await run_cached>(key, [catalog, query] { return catalog->collections(query); }, + co_await runCached>(key, [catalog, query] { return catalog->collections(query); }, [this, model, append](const CatalogPage& value) { - model->set_page(rows(value.items), static_cast(value.total), cursor_of(value.next_cursor), append); + model->setPage(rows(value.items), static_cast(value.total), cursorOf(value.next_cursor), append); }, !append); } else { - co_await run_cached>(key, [catalog, query] { return catalog->mods(query); }, + co_await runCached>(key, [catalog, query] { return catalog->mods(query); }, [this, model, append](const CatalogPage& value) { - model->set_page(rows(value.items), static_cast(value.total), cursor_of(value.next_cursor), append); + model->setPage(rows(value.items), static_cast(value.total), cursorOf(value.next_cursor), append); }, !append); } } } -void WorkshopViewPanel::show_favourites() { +void WorkshopViewPanel::showFavourites() { std::vector matches; - for (const auto& item : favourites_.items()) - if (QString::fromStdString(item.title).toCaseFolded().contains(criteria_.query.toCaseFolded())) matches.push_back(item); - collections_model->set_page(rows(matches), static_cast(matches.size()), std::nullopt); + for (const auto& item : m_favourites.items()) + if (QString::fromStdString(item.title).toCaseFolded().contains(m_criteria.query.toCaseFolded())) matches.push_back(item); + collectionsModel->setPage(rows(matches), static_cast(matches.size()), std::nullopt); emit changed(); } -std::optional WorkshopViewPanel::installed_cursor() const { - return installed_.has_more() ? std::optional(QStringLiteral("more")) : std::nullopt; +std::optional WorkshopViewPanel::installedCursor() const { + return m_installed.hasMore() ? std::optional(QStringLiteral("more")) : std::nullopt; } -void WorkshopViewPanel::show_installed() { - clear_detail(); - mods_model->set_page(rows(installed_.visible()), installed_.total(), installed_cursor()); +void WorkshopViewPanel::showInstalled() { + clearDetail(); + modsModel->setPage(rows(m_installed.visible()), m_installed.total(), installedCursor()); emit changed(); } void WorkshopViewPanel::recount() { - update_count_ = 0; - for (const auto& mod : installed_.mods()) update_count_ += catalog_.install_state(mod) == InstallState::outdated; + m_updateCount = 0; + for (const auto& mod : m_installed.mods()) m_updateCount += m_catalog.installState(mod) == InstallState::kOutdated; } -QCoro::Task WorkshopViewPanel::select_tab(QString tab) { - tab_ = std::move(tab); notice_.clear(); pending_.reset(); clear_detail(); installed_.reset_paging(); +QCoro::Task WorkshopViewPanel::selectTabAsync(QString tab) { + m_tab = std::move(tab); m_notice.clear(); m_pending.reset(); clearDetail(); m_installed.resetPaging(); co_await load(); } -QCoro::Task WorkshopViewPanel::filter_items(QString query, QString version, QString source, QString sort, QString tag) { - criteria_ = CatalogQuery{.query = query, .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), +QCoro::Task WorkshopViewPanel::filterItems(QString query, QString version, QString source, QString sort, QString tag) { + m_criteria = CatalogQuery{.query = query, .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), .version = version.isEmpty() ? std::nullopt : std::optional(version), .sort = sort, .source = source}; - installed_.search(query); - if (tab_ == QStringLiteral("Installed") && installed_.loaded()) { show_installed(); co_return; } - if (tab_ == QStringLiteral("Discover")) tab_ = QStringLiteral("Mods"); + m_installed.search(query); + if (m_tab == QStringLiteral("Installed") && m_installed.loaded()) { showInstalled(); co_return; } + if (m_tab == QStringLiteral("Discover")) m_tab = QStringLiteral("Mods"); co_await load(); } -QCoro::Task WorkshopViewPanel::retry_load() { - if (retry_ && !torn_down_) { - auto action = retry_; +QCoro::Task WorkshopViewPanel::retryLoad() { + if (m_retry && !m_tornDown) { + auto action = m_retry; co_await action(); } co_return; } -void WorkshopViewPanel::go(const QStringList& path) { app_.navigate(buildRoute(QStringLiteral("workshop"), path)); } +void WorkshopViewPanel::go(const QStringList& path) { m_app.navigate(buildRoute(QStringLiteral("workshop"), path)); } void WorkshopViewPanel::selectTab(const QString& tab) { go({tab.toLower()}); } -void WorkshopViewPanel::openItem(const QString& item_id, const QString& kind) { go({kind, item_id}); } -void WorkshopViewPanel::openRoute(const QStringList& path) { follow_route(path); } -QCoro::Task WorkshopViewPanel::follow_route(QStringList path) { - if (torn_down_ || path.isEmpty()) co_return; +void WorkshopViewPanel::openItem(const QString& itemId, const QString& kind) { go({kind, itemId}); } +void WorkshopViewPanel::openRoute(const QStringList& path) { followRoute(path); } +QCoro::Task WorkshopViewPanel::followRoute(QStringList path) { + if (m_tornDown || path.isEmpty()) co_return; const QStringList tabs{QStringLiteral("Discover"), QStringLiteral("Mods"), QStringLiteral("Collections"), QStringLiteral("Favourites"), QStringLiteral("Installed")}; if (path.size() == 1) { for (const auto& tab : tabs) if (tab.compare(path.front(), Qt::CaseInsensitive) == 0) { - co_await select_tab(tab); co_return; + co_await selectTabAsync(tab); co_return; } } else if (path.size() == 2 && (path.front() == QStringLiteral("mod") || path.front() == QStringLiteral("collection"))) { - co_await open_item(path[1], path[0]); co_return; + co_await openItemAsync(path[1], path[0]); co_return; } spdlog::warn("[workshop] unknown route {}", buildRoute(QStringLiteral("workshop"), path).toStdString()); } -QCoro::Task WorkshopViewPanel::open_item(QString item_id, QString kind, bool record) { - pack_ids_.clear(); +QCoro::Task WorkshopViewPanel::openItemAsync(QString itemId, QString kind, bool record) { + m_packIds.clear(); QPointer self(this); - auto* catalog = &catalog_; - co_await run_cached(QStringLiteral("detail|") + kind + QLatin1Char('|') + item_id, - [catalog, item_id, kind] { return fetch_detail(*catalog, item_id, kind); }, - [this, item_id, kind, record](const Detail& value) { - const auto ref = std::pair(kind, item_id); - if (current_ != ref) { - if (detail_.isEmpty()) trail_.clear(); - else if (record && current_) trail_.emplace_back(current_->first, current_->second, detail_.value(QStringLiteral("title")).toString()); + auto* catalog = &m_catalog; + co_await runCached(QStringLiteral("detail|") + kind + QLatin1Char('|') + itemId, + [catalog, itemId, kind] { return fetchDetail(*catalog, itemId, kind); }, + [this, itemId, kind, record](const Detail& value) { + const auto ref = std::pair(kind, itemId); + if (m_current != ref) { + if (m_detail.isEmpty()) m_trail.clear(); + else if (record && m_current) m_trail.emplace_back(m_current->first, m_current->second, m_detail.value(QStringLiteral("title")).toString()); } - current_ = ref; + m_current = ref; auto members = rows(value.members); - detail_data_ = value; - detail_ = detail_row(value); - members_model->set_page(std::move(members), static_cast(value.members.size()), std::nullopt); + m_detailData = value; + m_detail = detailRow(value); + membersModel->setPage(std::move(members), static_cast(value.members.size()), std::nullopt); }); - if (self && !self->torn_down_ && kind == QStringLiteral("collection") && self->current_ == std::pair(kind, item_id)) - co_await self->load_pack(); + if (self && !self->m_tornDown && kind == QStringLiteral("collection") && self->m_current == std::pair(kind, itemId)) + co_await self->loadPack(); } -QCoro::Task WorkshopViewPanel::go_back() { - if (torn_down_) co_return; - if (!trail_.empty()) { - auto [kind, item_id, title] = trail_.back(); +QCoro::Task WorkshopViewPanel::goBack() { + if (m_tornDown) co_return; + if (!m_trail.empty()) { + auto [kind, item_id, title] = m_trail.back(); QPointer self(this); - co_await open_item(item_id, kind, false); - if (self && !self->torn_down_ && self->current_ == std::pair(kind, item_id) && !self->trail_.empty()) { - self->trail_.pop_back(); emit self->changed(); + co_await openItemAsync(item_id, kind, false); + if (self && !self->m_tornDown && self->m_current == std::pair(kind, item_id) && !self->m_trail.empty()) { + self->m_trail.pop_back(); emit self->changed(); } co_return; } - ++generation_; clear_detail(); error_.clear(); busy_ = false; emit changed(); - if (tab_ == QStringLiteral("Favourites")) show_favourites(); - else if (tab_ == QStringLiteral("Installed") && installed_dirty_) co_await load(); + ++m_generation; clearDetail(); m_error.clear(); m_busy = false; emit changed(); + if (m_tab == QStringLiteral("Favourites")) showFavourites(); + else if (m_tab == QStringLiteral("Installed") && m_installedDirty) co_await load(); } -QCoro::Task WorkshopViewPanel::toggle_activation(QString item_id) { - if (torn_down_) co_return; +QCoro::Task WorkshopViewPanel::toggleActivationAsync(QString itemId) { + if (m_tornDown) co_return; QStringList copies, active; - for (auto it = ctx_.allMods().cbegin(); it != ctx_.allMods().cend(); ++it) { - if (it.value()->published_file_id() != item_id) continue; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) { + if (it.value()->publishedFileId() != itemId) continue; copies.append(it.key()); - if (ctx_.activeUuids().contains(it.key())) active.append(it.key()); + if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); } if (copies.isEmpty()) co_return; - if (active.isEmpty()) co_await apply_activation(ctx_, this, {copies.front()}, {}); - else co_await apply_activation(ctx_, this, {}, active); + if (active.isEmpty()) co_await applyActivation(m_ctx, this, {copies.front()}, {}); + else co_await applyActivation(m_ctx, this, {}, active); } -QCoro::Task WorkshopViewPanel::toggle_collection(bool only) { - auto copies = pack_copies(); +QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { + auto copies = packCopies(); if (!copies) co_return; QStringList disable; - const bool all_active = std::ranges::all_of(*copies, [this](const auto& uuid) { return ctx_.activeUuids().contains(uuid); }); - for (const auto& uuid : ctx_.activeUuids()) { - auto mod = ctx_.allMods().value(uuid); + const bool allActive = std::ranges::all_of(*copies, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); + for (const auto& uuid : m_ctx.activeUuids()) { + auto mod = m_ctx.allMods().value(uuid); if (!mod) continue; - auto id = mod->published_file_id(); - if (id && ((only && !copies->contains(uuid)) || (!only && all_active && pack_ids_.contains(*id)))) disable.append(uuid); + auto id = mod->publishedFileId(); + if (id && ((only && !copies->contains(uuid)) || (!only && allActive && m_packIds.contains(*id)))) disable.append(uuid); } - if (!only && all_active) co_await apply_activation(ctx_, this, {}, disable); - else co_await apply_activation(ctx_, this, *copies, disable); + if (!only && allActive) co_await applyActivation(m_ctx, this, {}, disable); + else co_await applyActivation(m_ctx, this, *copies, disable); } -QCoro::Task WorkshopViewPanel::download_item(QString item_id, QString kind) { - co_await download(kind == QStringLiteral("mod") ? QStringList{item_id} : QStringList{}, - kind == QStringLiteral("collection") ? QStringList{item_id} : QStringList{}); +QCoro::Task WorkshopViewPanel::downloadItemAsync(QString itemId, QString kind) { + co_await download(kind == QStringLiteral("mod") ? QStringList{itemId} : QStringList{}, + kind == QStringLiteral("collection") ? QStringList{itemId} : QStringList{}); } -QCoro::Task WorkshopViewPanel::update_all() { +QCoro::Task WorkshopViewPanel::updateAllAsync() { QStringList ids; - for (const auto& mod : installed_.mods()) if (catalog_.install_state(mod) == InstallState::outdated) ids.append(QString::fromStdString(mod.id)); + for (const auto& mod : m_installed.mods()) if (m_catalog.installState(mod) == InstallState::kOutdated) ids.append(QString::fromStdString(mod.id)); if (!ids.isEmpty()) co_await download(ids, {}); } -void WorkshopViewPanel::queue_plan(const DownloadPlan& plan) { catalog_.enqueue(plan); notice_ = queued_notice(plan.to_download.size()); } +void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { m_catalog.enqueue(plan); m_notice = queuedNotice(plan.to_download.size()); } QCoro::Task WorkshopViewPanel::download(QStringList ids, QStringList collections) { - if (torn_down_) co_return; + if (m_tornDown) co_return; QPointer self(this); - notice_ = QStringLiteral("Preparing download…"); pending_.reset(); ++planning_; + m_notice = QStringLiteral("Preparing download…"); m_pending.reset(); ++m_planning; auto requested = QSet(ids.cbegin(), ids.cend()); requested.unite(QSet(collections.cbegin(), collections.cend())); - preparing_.unite(requested); refresh_rows(); emit changed(); - auto result = co_await catalog_.plan(ids, collections); - if (!self || self->torn_down_) co_return; - --self->planning_; self->preparing_.subtract(requested); self->refresh_rows(); emit self->changed(); - if (!result) { self->notice_ = QStringLiteral("Could not prepare the download: ") + result.error().message; emit self->changed(); co_return; } + m_preparing.unite(requested); refreshRows(); emit changed(); + auto result = co_await m_catalog.plan(ids, collections); + if (!self || self->m_tornDown) co_return; + --self->m_planning; self->m_preparing.subtract(requested); self->refreshRows(); emit self->changed(); + if (!result) { self->m_notice = QStringLiteral("Could not prepare the download: ") + result.error().message; emit self->changed(); co_return; } for (const auto& collection : collections) - self->collection_mods_[collection] = QSet(result->to_download.cbegin(), result->to_download.cend()); - if (!result->is_complete) { self->pending_ = *result; self->notice_ = incomplete_plan_notice(*result); } - else if (result->to_download.isEmpty()) self->notice_ = QStringLiteral("All available mods are already installed and up to date."); - else self->queue_plan(*result); + self->m_collectionMods[collection] = QSet(result->to_download.cbegin(), result->to_download.cend()); + if (!result->is_complete) { self->m_pending = *result; self->m_notice = incompletePlanNotice(*result); } + else if (result->to_download.isEmpty()) self->m_notice = QStringLiteral("All available mods are already installed and up to date."); + else self->queuePlan(*result); emit self->changed(); } void WorkshopViewPanel::downloadAvailable() { - if (torn_down_) return; - auto plan = std::exchange(pending_, std::nullopt); - if (plan && !plan->to_download.isEmpty()) queue_plan(*plan); + if (m_tornDown) return; + auto plan = std::exchange(m_pending, std::nullopt); + if (plan && !plan->to_download.isEmpty()) queuePlan(*plan); emit changed(); } -void WorkshopViewPanel::dismissPlan() { pending_.reset(); notice_.clear(); emit changed(); } -void WorkshopViewPanel::openLink(const QString& url) { if (!safe_url(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); } -void WorkshopViewPanel::openSettings() { app_.navigate(buildRoute(QString(kSettingsViewId))); } -void WorkshopViewPanel::toggleFavourite(const QString& item_id) { - const auto found = items_.constFind(item_id); - if (found != items_.cend()) if (const auto* collection = std::get_if(&*found)) - favourites_.set(*collection, !favourites_.contains(collection->id)); -} -QCoro::Task WorkshopViewPanel::url_changed() { - if (torn_down_) co_return; - clear_detail(); error_.clear(); notice_.clear(); pending_.reset(); items_.clear(); known_tags_.clear(); - for (auto* model : {mods_model, collections_model, members_model}) model->set_page({}, 0, std::nullopt); +void WorkshopViewPanel::dismissPlan() { m_pending.reset(); m_notice.clear(); emit changed(); } +void WorkshopViewPanel::openLink(const QString& url) { if (!safeUrl(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); } +void WorkshopViewPanel::openSettings() { m_app.navigate(buildRoute(QString(kSettingsViewId))); } +void WorkshopViewPanel::toggleFavourite(const QString& itemId) { + const auto found = m_items.constFind(itemId); + if (found != m_items.cend()) if (const auto* collection = std::get_if(&*found)) + m_favourites.set(*collection, !m_favourites.contains(collection->id)); +} +QCoro::Task WorkshopViewPanel::urlChanged() { + if (m_tornDown) co_return; + clearDetail(); m_error.clear(); m_notice.clear(); m_pending.reset(); m_items.clear(); m_knownTags.clear(); + for (auto* model : {modsModel, collectionsModel, membersModel}) model->setPage({}, 0, std::nullopt); co_await load(); } -QCoro::Task WorkshopViewPanel::installed_changed() { - if (torn_down_) co_return; - installed_dirty_ = true; refresh_mod_ids(); recount(); refresh_rows(); emit changed(); - if (tab_ == QStringLiteral("Installed") && !busy_ && !detail_data_) co_await load(); +QCoro::Task WorkshopViewPanel::installedChanged() { + if (m_tornDown) co_return; + m_installedDirty = true; refreshModIds(); recount(); refreshRows(); emit changed(); + if (m_tab == QStringLiteral("Installed") && !m_busy && !m_detailData) co_await load(); } QString WorkshopViewPanel::url() const { - return buildRoute(QStringLiteral("workshop"), current_ ? QStringList{current_->first, current_->second} : QStringList{tab_.toLower()}); + return buildRoute(QStringLiteral("workshop"), m_current ? QStringList{m_current->first, m_current->second} : QStringList{m_tab.toLower()}); } QString WorkshopViewPanel::backLabel() const { - return QStringLiteral("Back to ") + (trail_.empty() ? tab_ : std::get<2>(trail_.back())); + return QStringLiteral("Back to ") + (m_trail.empty() ? m_tab : std::get<2>(m_trail.back())); } QStringList WorkshopViewPanel::tagOptions() const { - auto tags = known_tags_.values(); std::ranges::sort(tags); tags.prepend(QStringLiteral("All tags")); return tags; + auto tags = m_knownTags.values(); std::ranges::sort(tags); tags.prepend(QStringLiteral("All tags")); return tags; } QStringList WorkshopViewPanel::versionOptions() const { QStringList versions{QStringLiteral("All versions")}; - if (criteria_.version) versions.append(*criteria_.version); + if (m_criteria.version) versions.append(*m_criteria.version); for (const auto& version : {QStringLiteral("1.6"), QStringLiteral("1.5"), QStringLiteral("1.4"), QStringLiteral("1.3")}) if (!versions.contains(version)) versions.append(version); return versions; diff --git a/src/ui/plugins/workshop/view.hpp b/src/ui/plugins/workshop/view.hpp index 181692c..7f009bc 100644 --- a/src/ui/plugins/workshop/view.hpp +++ b/src/ui/plugins/workshop/view.hpp @@ -46,156 +46,156 @@ class WorkshopViewPanel : public BaseViewPanel { ~WorkshopViewPanel() override; void openRoute(const QStringList& path) override; Q_INVOKABLE void teardown() override; - QCoro::Task follow_route(QStringList path); + QCoro::Task followRoute(QStringList path); QCoro::Task load(bool append = false); - QCoro::Task select_tab(QString tab); - QCoro::Task open_item(QString item_id, QString kind, bool record = true); - QCoro::Task filter_items(QString query, QString version, QString source, QString sort, QString tag); - QCoro::Task go_back(); - QCoro::Task retry_load(); - QCoro::Task download_item(QString item_id, QString kind); - QCoro::Task toggle_activation(QString item_id); - QCoro::Task toggle_collection(bool only = false); - QCoro::Task update_all(); + QCoro::Task selectTabAsync(QString tab); + QCoro::Task openItemAsync(QString itemId, QString kind, bool record = true); + QCoro::Task filterItems(QString query, QString version, QString source, QString sort, QString tag); + QCoro::Task goBack(); + QCoro::Task retryLoad(); + QCoro::Task downloadItemAsync(QString itemId, QString kind); + QCoro::Task toggleActivationAsync(QString itemId); + QCoro::Task toggleCollectionAsync(bool only = false); + QCoro::Task updateAllAsync(); Q_INVOKABLE QCoro::QmlTask refresh() { return load(); } Q_INVOKABLE void selectTab(const QString& tab); Q_INVOKABLE QCoro::QmlTask filter(QString query, QString version, QString source, QString sort, QString tag) { - return filter_items(std::move(query), std::move(version), std::move(source), std::move(sort), std::move(tag)); + return filterItems(std::move(query), std::move(version), std::move(source), std::move(sort), std::move(tag)); } Q_INVOKABLE QCoro::QmlTask loadMore() { return load(true); } - Q_INVOKABLE QCoro::QmlTask retry() { return retry_load(); } - Q_INVOKABLE void openItem(const QString& item_id, const QString& kind); - Q_INVOKABLE QCoro::QmlTask back() { return go_back(); } - Q_INVOKABLE QCoro::QmlTask downloadItem(QString item_id, QString kind) { return download_item(std::move(item_id), std::move(kind)); } - Q_INVOKABLE QCoro::QmlTask toggleActivation(QString item_id) { return toggle_activation(std::move(item_id)); } - Q_INVOKABLE QCoro::QmlTask toggleCollection() { return toggle_collection(); } - Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { return toggle_collection(true); } - Q_INVOKABLE QCoro::QmlTask updateAll() { return update_all(); } + Q_INVOKABLE QCoro::QmlTask retry() { return retryLoad(); } + Q_INVOKABLE void openItem(const QString& itemId, const QString& kind); + Q_INVOKABLE QCoro::QmlTask back() { return goBack(); } + Q_INVOKABLE QCoro::QmlTask downloadItem(QString itemId, QString kind) { return downloadItemAsync(std::move(itemId), std::move(kind)); } + Q_INVOKABLE QCoro::QmlTask toggleActivation(QString itemId) { return toggleActivationAsync(std::move(itemId)); } + Q_INVOKABLE QCoro::QmlTask toggleCollection() { return toggleCollectionAsync(); } + Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { return toggleCollectionAsync(true); } + Q_INVOKABLE QCoro::QmlTask updateAll() { return updateAllAsync(); } Q_INVOKABLE void downloadAvailable(); Q_INVOKABLE void dismissPlan(); Q_INVOKABLE void openLink(const QString& url); Q_INVOKABLE void openSettings(); - Q_INVOKABLE void toggleFavourite(const QString& item_id); + Q_INVOKABLE void toggleFavourite(const QString& itemId); - QString tab() const { return tab_; } + QString tab() const { return m_tab; } QString url() const; QString backLabel() const; - bool configured() const { return catalog_.configured(); } - bool busy() const { return busy_; } - QString error() const { return error_; } - QString notice() const { return notice_; } - QVariantMap detail() const { return detail_; } - bool hasDetail() const { return !detail_.isEmpty(); } - bool pendingPlan() const { return pending_.has_value(); } - bool canDownloadAvailable() const { return pending_ && !pending_->to_download.isEmpty(); } - QString searchQuery() const { return criteria_.query; } + bool configured() const { return m_catalog.configured(); } + bool busy() const { return m_busy; } + QString error() const { return m_error; } + QString notice() const { return m_notice; } + QVariantMap detail() const { return m_detail; } + bool hasDetail() const { return !m_detail.isEmpty(); } + bool pendingPlan() const { return m_pending.has_value(); } + bool canDownloadAvailable() const { return m_pending && !m_pending->to_download.isEmpty(); } + QString searchQuery() const { return m_criteria.query; } QStringList tagOptions() const; QString gameVersion() const; QStringList versionOptions() const; - int updateCount() const { return update_count_; } - bool planning() const { return planning_ > 0; } + int updateCount() const { return m_updateCount; } + bool planning() const { return m_planning > 0; } - CatalogListModel* mods_model; - CatalogListModel* collections_model; - CatalogListModel* members_model; + CatalogListModel* modsModel; + CatalogListModel* collectionsModel; + CatalogListModel* membersModel; signals: void changed(); private: template - QCoro::Task run_cached(QString key, + QCoro::Task runCached(QString key, std::function>()> produce, std::function apply, bool cached = true); - QCoro::Task load_pack(); + QCoro::Task loadPack(); QCoro::Task download(QStringList ids, QStringList collections); - QCoro::Task installed_changed(); - QCoro::Task url_changed(); - void refresh_mod_ids(); + QCoro::Task installedChanged(); + QCoro::Task urlChanged(); + void refreshModIds(); QVariantMap row(const workshop::CatalogEntry& item, bool detail = false); - QVariantMap detail_row(const Detail& detail); + QVariantMap detailRow(const Detail& detail); template QList rows(const std::vector& items) { - refresh_mod_ids(); + refreshModIds(); QList result; result.reserve(static_cast(items.size())); for (const auto& item : items) result.append(row(item)); return result; } - void refresh_rows(); - void clear_detail(); - std::optional pack_copies() const; - void show_favourites(); - void show_installed(); + void refreshRows(); + void clearDetail(); + std::optional packCopies() const; + void showFavourites(); + void showInstalled(); void recount(); - std::optional installed_cursor() const; - void queue_plan(const workshop::DownloadPlan& plan); + std::optional installedCursor() const; + void queuePlan(const workshop::DownloadPlan& plan); void go(const QStringList& path); - workshop::WorkshopCatalog& catalog_; - DownloadManager& downloads_; - workshop::FavouriteCollections& favourites_; - QQuickWidget* qml_ = nullptr; - QString tab_ = QStringLiteral("Discover"); - workshop::CatalogQuery criteria_; - bool busy_ = false; - bool torn_down_ = false; - bool installed_dirty_ = false; - int generation_ = 0; - int planning_ = 0; - int update_count_ = 0; - QString error_, notice_; - std::function()> retry_; - QVariantMap detail_; - std::optional detail_data_; - InstalledList installed_; - std::optional> current_; - std::vector> trail_; - std::optional pending_; - QSet preparing_, pack_ids_, installed_ids_, active_ids_, known_tags_; - QHash> collection_mods_; - QHash items_; - Event::Id busy_id_ = 0; - Event::Id queue_id_ = 0, installed_id_ = 0; - Event::Id url_id_ = 0; - Event::Id favourites_id_ = 0; - Event::Id active_id_ = 0; + workshop::WorkshopCatalog& m_catalog; + DownloadManager& m_downloads; + workshop::FavouriteCollections& m_favourites; + QQuickWidget* m_qml = nullptr; + QString m_tab = QStringLiteral("Discover"); + workshop::CatalogQuery m_criteria; + bool m_busy = false; + bool m_tornDown = false; + bool m_installedDirty = false; + int m_generation = 0; + int m_planning = 0; + int m_updateCount = 0; + QString m_error, m_notice; + std::function()> m_retry; + QVariantMap m_detail; + std::optional m_detailData; + InstalledList m_installed; + std::optional> m_current; + std::vector> m_trail; + std::optional m_pending; + QSet m_preparing, m_packIds, m_installedIds, m_activeIds, m_knownTags; + QHash> m_collectionMods; + QHash m_items; + Event::Id m_busyId = 0; + Event::Id m_queueId = 0, m_installedId = 0; + Event::Id m_urlId = 0; + Event::Id m_favouritesId = 0; + Event::Id m_activeId = 0; }; template -QCoro::Task WorkshopViewPanel::run_cached(QString key, +QCoro::Task WorkshopViewPanel::runCached(QString key, std::function>()> produce, std::function apply, bool cached) { - if (torn_down_) co_return; + if (m_tornDown) co_return; QPointer self(this); - const int generation = ++generation_; - busy_ = true; - error_.clear(); - retry_ = [self, key, produce, apply, cached] { - return self->run_cached(key, produce, apply, cached); + const int generation = ++m_generation; + m_busy = true; + m_error.clear(); + m_retry = [self, key, produce, apply, cached] { + return self->runCached(key, produce, apply, cached); }; emit changed(); bool shown = false; auto show = [self, generation, apply, &shown](const T& value) { - if (!self || self->torn_down_ || generation != self->generation_) return; + if (!self || self->m_tornDown || generation != self->m_generation) return; apply(value); shown = true; - self->busy_ = false; + self->m_busy = false; emit self->changed(); }; workshop::CatalogResult result; - if (cached) result = co_await catalog_.cached(key, std::move(produce), show, workshop::kFreshSeconds, true); + if (cached) result = co_await m_catalog.cached(key, std::move(produce), show, workshop::kFreshSeconds, true); else { auto fetched = co_await produce(); if (fetched) show(*fetched); else result = std::unexpected(fetched.error()); } - if (!self || self->torn_down_ || generation != self->generation_) co_return; + if (!self || self->m_tornDown || generation != self->m_generation) co_return; if (!result && shown) spdlog::warn("[workshop] background refresh failed: {}", result.error().message.toStdString()); - else if (!result) self->error_ = result.error().message; - self->busy_ = false; + else if (!result) self->m_error = result.error().message; + self->m_busy = false; emit self->changed(); - if (self->installed_dirty_ && self->tab_ == QStringLiteral("Installed") && !self->detail_data_) + if (self->m_installedDirty && self->m_tab == QStringLiteral("Installed") && !self->m_detailData) co_await self->load(); } diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp index 6ac9527..ea53c76 100644 --- a/src/ui/settings_section.cpp +++ b/src/ui/settings_section.cpp @@ -6,24 +6,24 @@ namespace px::modrim { PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, QObject* parent) - : SettingsSection(parent), initial_(std::move(initial)) {} + : SettingsSection(parent), m_initial(std::move(initial)) {} -QVariantMap PluginConfigSectionBase::initial() const { return initial_; } +QVariantMap PluginConfigSectionBase::initial() const { return m_initial; } bool PluginConfigSectionBase::set(const QString& key, const QVariant& value) { - if (!initial_.contains(key)) { + if (!m_initial.contains(key)) { return false; } - draft_.insert(key, value); + m_draft.insert(key, value); return true; } Result PluginConfigSectionBase::apply() { - if (draft_.isEmpty()) { + if (m_draft.isEmpty()) { return {}; } - QVariantMap merged = initial_; - for (auto it = draft_.cbegin(); it != draft_.cend(); ++it) { + QVariantMap merged = m_initial; + for (auto it = m_draft.cbegin(); it != m_draft.cend(); ++it) { merged.insert(it.key(), it.value()); } return commit(QJsonObject::fromVariantMap(merged)); diff --git a/src/ui/settings_section.hpp b/src/ui/settings_section.hpp index 0114017..8ad19f3 100644 --- a/src/ui/settings_section.hpp +++ b/src/ui/settings_section.hpp @@ -64,8 +64,8 @@ class PluginConfigSectionBase : public SettingsSection { virtual Result commit(const QJsonObject& merged) = 0; private: - QVariantMap initial_; - QVariantMap draft_; + QVariantMap m_initial; + QVariantMap m_draft; }; // Edits a PluginConfig. Keys and values follow T's JSON layout. @@ -73,7 +73,7 @@ template class PluginConfigSection : public PluginConfigSectionBase { public: PluginConfigSection(PluginConfig& config, QObject* parent) - : PluginConfigSectionBase(toVariantMap(config.value()), parent), config_(config) {} + : PluginConfigSectionBase(toVariantMap(config.value()), parent), m_config(config) {} protected: Result commit(const QJsonObject& merged) override { @@ -82,7 +82,7 @@ class PluginConfigSection : public PluginConfigSectionBase { if (auto error = glz::read(value, text)) { return std::unexpected(Error{QString::fromStdString(glz::format_error(error, text))}); } - config_.update(std::move(value)); + m_config.update(std::move(value)); return {}; } @@ -96,7 +96,7 @@ class PluginConfigSection : public PluginConfigSectionBase { return QJsonDocument::fromJson(QByteArray::fromStdString(*text)).object().toVariantMap(); } - PluginConfig& config_; + PluginConfig& m_config; }; } // namespace px::modrim diff --git a/src/ui/theme/constants.hpp b/src/ui/theme/constants.hpp index c4c2ccb..1c5a321 100644 --- a/src/ui/theme/constants.hpp +++ b/src/ui/theme/constants.hpp @@ -5,22 +5,22 @@ namespace px::modrim { // Animation -inline constexpr int ACCORDION_ANIMATION_DURATION_MS = 200; -inline constexpr QEasingCurve::Type ACCORDION_EASING = QEasingCurve::Type::OutCubic; +inline constexpr int kAccordionAnimationDurationMs = 200; +inline constexpr QEasingCurve::Type kAccordionEasing = QEasingCurve::Type::OutCubic; // Banner -inline constexpr int BANNER_MAX_HEIGHT = 260; -inline constexpr int BANNER_ASPECT_W = 16; -inline constexpr int BANNER_ASPECT_H = 9; -inline constexpr int BANNER_OVERLAY_HEIGHT = 120; +inline constexpr int kBannerMaxHeight = 260; +inline constexpr int kBannerAspectW = 16; +inline constexpr int kBannerAspectH = 9; +inline constexpr int kBannerOverlayHeight = 120; // Layout -inline constexpr int RAIL_MIN_WIDTH = 52; -inline constexpr int RAIL_MAX_WIDTH = 180; -inline constexpr int RAIL_COLLAPSE_WIDTH = 116; -inline constexpr int SIDEBAR_WIDTH = 240; +inline constexpr int kRailMinWidth = 52; +inline constexpr int kRailMaxWidth = 180; +inline constexpr int kRailCollapseWidth = 116; +inline constexpr int kSidebarWidth = 240; // Interaction -inline constexpr int TOOLTIP_DELAY_MS = 400; +inline constexpr int kTooltipDelayMs = 400; } // namespace px::modrim diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp index e91f649..f4805d6 100644 --- a/src/ui/theme/qml_theme.cpp +++ b/src/ui/theme/qml_theme.cpp @@ -32,10 +32,10 @@ QStringList loadLogoVariants() { } // namespace -Theme::Theme(QObject* parent) : QObject(parent), logo_variants_(loadLogoVariants()) { - if (!logo_variants_.isEmpty()) { - const int pick = QRandomGenerator::global()->bounded(static_cast(logo_variants_.size())); - logo_data_uri_ = logo_variants_.at(pick); +Theme::Theme(QObject* parent) : QObject(parent), m_logoVariants(loadLogoVariants()) { + if (!m_logoVariants.isEmpty()) { + const int pick = QRandomGenerator::global()->bounded(static_cast(m_logoVariants.size())); + m_logoDataUri = m_logoVariants.at(pick); } } @@ -77,15 +77,15 @@ int Theme::radiusMd() const { return 6; } int Theme::radiusLg() const { return 8; } int Theme::radiusPill() const { return 999; } int Theme::scrollbarWidth() const { return 6; } -int Theme::tooltipDelay() const { return TOOLTIP_DELAY_MS; } +int Theme::tooltipDelay() const { return kTooltipDelayMs; } double Theme::disabledOpacity() const { return 0.45; } QString Theme::overlay() const { return QStringLiteral("#8c000000"); } -int Theme::railMinWidth() const { return RAIL_MIN_WIDTH; } -int Theme::railMaxWidth() const { return RAIL_MAX_WIDTH; } -int Theme::railCollapseWidth() const { return RAIL_COLLAPSE_WIDTH; } -int Theme::sidebarWidth() const { return SIDEBAR_WIDTH; } +int Theme::railMinWidth() const { return kRailMinWidth; } +int Theme::railMaxWidth() const { return kRailMaxWidth; } +int Theme::railCollapseWidth() const { return kRailCollapseWidth; } +int Theme::sidebarWidth() const { return kSidebarWidth; } -QStringList Theme::logoVariants() const { return logo_variants_; } -QString Theme::logoFileDataUri() const { return logo_data_uri_; } +QStringList Theme::logoVariants() const { return m_logoVariants; } +QString Theme::logoFileDataUri() const { return m_logoDataUri; } } // namespace px::modrim diff --git a/src/ui/theme/qml_theme.hpp b/src/ui/theme/qml_theme.hpp index e5ddfe5..ae2afa0 100644 --- a/src/ui/theme/qml_theme.hpp +++ b/src/ui/theme/qml_theme.hpp @@ -115,8 +115,8 @@ class Theme : public QObject { QString logoFileDataUri() const; private: - QStringList logo_variants_; - QString logo_data_uri_; + QStringList m_logoVariants; + QString m_logoDataUri; }; } // namespace px::modrim diff --git a/src/ui/ui_prefs.hpp b/src/ui/ui_prefs.hpp index bcb7d5b..f8dbac3 100644 --- a/src/ui/ui_prefs.hpp +++ b/src/ui/ui_prefs.hpp @@ -7,13 +7,15 @@ namespace px::modrim { // Persisted UI state (ui_prefs.json). Field names are the JSON keys. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct UiPrefs { bool desc_expanded = false; - LaunchStrategy launch_strategy = LaunchStrategy::Direct; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; std::string skipped_update_tag; bool rail_collapsed = false; bool operator==(const UiPrefs&) const = default; }; +// NOLINTEND(readability-identifier-naming) } // namespace px::modrim diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp index b0c958a..72f391f 100644 --- a/src/ui/views/base.cpp +++ b/src/ui/views/base.cpp @@ -10,9 +10,9 @@ namespace px::modrim { BaseViewPanel::BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) : RailView(ctx, engine, parent, app) { setAutoFillBackground(true); - root_ = new QVBoxLayout(this); - root_->setContentsMargins(0, 0, 0, 0); - root_->setSpacing(0); + m_root = new QVBoxLayout(this); + m_root->setContentsMargins(0, 0, 0, 0); + m_root->setSpacing(0); setClearColor(paletteValue(u"ELEVATE_0")); } diff --git a/src/ui/views/base.hpp b/src/ui/views/base.hpp index ca389dd..37f74f9 100644 --- a/src/ui/views/base.hpp +++ b/src/ui/views/base.hpp @@ -18,7 +18,7 @@ class BaseViewPanel : public RailView { protected: void setClearColor(const QString& color); - QVBoxLayout* root_ = nullptr; + QVBoxLayout* m_root = nullptr; }; } // namespace px::modrim diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp index 5982593..89efcfa 100644 --- a/src/ui/views/mods_view.cpp +++ b/src/ui/views/mods_view.cpp @@ -6,21 +6,21 @@ ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* pare : BaseViewPanel(ctx,engine,parent,app) { auto* content = new QWidget(this); content->setObjectName(QStringLiteral("contentArea")); auto* layout = new QHBoxLayout(content); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); - sidebar = new SidebarPanel(ctx,engine,content); sidebar->setObjectName(QStringLiteral("sidebarPanel")); sidebar->setFixedWidth(SIDEBAR_WIDTH); - connect(sidebar,&SidebarPanel::entry_selected,this,&ModsViewPanel::entry_selected); layout->addWidget(sidebar); - mod_list = new ModListPanel(ctx,engine,content); mod_list->setObjectName(QStringLiteral("modListPanel")); - connect(mod_list,&ModListPanel::mod_selected,this,&ModsViewPanel::mod_selected); - connect(mod_list,&ModListPanel::active_mods_changed,this,&ModsViewPanel::active_mods_changed,Qt::QueuedConnection); - connect(mod_list,&ModListPanel::order_changed,this,&ModsViewPanel::order_changed,Qt::QueuedConnection); - layout->addWidget(mod_list,3); - mod_info = new ModInfoPanel(ctx,engine,app.uiPrefs(),content); mod_info->setObjectName(QStringLiteral("modInfoPanel")); mod_info->setMinimumWidth(300); - layout->addWidget(mod_info,2); root_->addWidget(content,1); + sidebar = new SidebarPanel(ctx,engine,content); sidebar->setObjectName(QStringLiteral("sidebarPanel")); sidebar->setFixedWidth(kSidebarWidth); + connect(sidebar,&SidebarPanel::entrySelected,this,&ModsViewPanel::entrySelected); layout->addWidget(sidebar); + modList = new ModListPanel(ctx,engine,content); modList->setObjectName(QStringLiteral("modListPanel")); + connect(modList,&ModListPanel::modPicked,this,&ModsViewPanel::modSelected); + connect(modList,&ModListPanel::activeModsChanged,this,&ModsViewPanel::activeModsChanged,Qt::QueuedConnection); + connect(modList,&ModListPanel::orderChanged,this,&ModsViewPanel::orderChanged,Qt::QueuedConnection); + layout->addWidget(modList,3); + modInfo = new ModInfoPanel(ctx,engine,app.uiPrefs(),content); modInfo->setObjectName(QStringLiteral("modInfoPanel")); modInfo->setMinimumWidth(300); + layout->addWidget(modInfo,2); m_root->addWidget(content,1); } -void ModsViewPanel::apply_sidebar_entry(const view::SidebarEntry& entry) { - mod_list->clearSelection(); - mod_list->set_sidebar_filter(entry.visible_uuids); +void ModsViewPanel::applySidebarEntry(const view::SidebarEntry& entry) { + modList->clearSelection(); + modList->setSidebarFilter(entry.visibleUuids); } -void ModsViewPanel::apply_current_sidebar_filter() { - if (const auto entry = sidebar->current_entry()) mod_list->set_sidebar_filter(entry->visible_uuids); +void ModsViewPanel::applyCurrentSidebarFilter() { + if (const auto entry = sidebar->currentEntry()) modList->setSidebarFilter(entry->visibleUuids); } } diff --git a/src/ui/views/mods_view.hpp b/src/ui/views/mods_view.hpp index 22c3d11..6ab6ac6 100644 --- a/src/ui/views/mods_view.hpp +++ b/src/ui/views/mods_view.hpp @@ -9,15 +9,15 @@ class ModsViewPanel : public BaseViewPanel { public: static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), QLatin1StringView("mods"), QLatin1StringView("Mods")}; ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - void apply_sidebar_entry(const view::SidebarEntry& entry); - void apply_current_sidebar_filter(); + void applySidebarEntry(const view::SidebarEntry& entry); + void applyCurrentSidebarFilter(); SidebarPanel* sidebar; - ModListPanel* mod_list; - ModInfoPanel* mod_info; + ModListPanel* modList; + ModInfoPanel* modInfo; signals: - void entry_selected(const px::modrim::view::SidebarEntry& entry); - void mod_selected(const QString& uuid); - void active_mods_changed(); - void order_changed(); + void entrySelected(const px::modrim::view::SidebarEntry& entry); + void modSelected(const QString& uuid); + void activeModsChanged(); + void orderChanged(); }; } diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp index 4e8e6ca..81f91b1 100644 --- a/src/ui/window/actions.cpp +++ b/src/ui/window/actions.cpp @@ -13,24 +13,24 @@ QString ActionSpec::tooltip() const { const std::array& actionSpecs() { static const std::array specs{{ - {ActionId::Save, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, - {ActionId::Restore, "restore", "&Restore Mod List…", "Restore mod list", {}}, - {ActionId::Settings, "settings", "&Settings…", "Settings", "Ctrl+,"}, - {ActionId::Quit, "quit", "&Quit", "Quit", "Ctrl+Q"}, - {ActionId::Refresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, - {ActionId::FullRescan, "full_rescan", "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R"}, - {ActionId::AutoSort, "auto_sort", "&Auto-Sort Mods", "Auto-sort mods", {}}, - {ActionId::UpdateMods, "update_mods", "&Update Mods", "Update downloaded mods", {}}, - {ActionId::FocusSearch, "focus_search", "Focus &Search", "Focus search", "Ctrl+F"}, - {ActionId::NextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, - {ActionId::PrevView, "prev_view", "&Previous View", "Previous view", "Ctrl+Shift+Tab"}, - {ActionId::Fullscreen, "fullscreen", "Toggle &Fullscreen", "Toggle fullscreen", "F11"}, - {ActionId::ReportIssue, "report_issue", "&Report Issue", "Report issue", {}}, - {ActionId::UploadLogs, "upload_logs", "Log && System &Info…", "Log & system info", {}}, - {ActionId::OpenLogs, "open_logs", "Open &Logs Folder", "Open logs folder", {}}, - {ActionId::Shortcuts, "shortcuts", "&Keyboard Shortcuts", "Keyboard shortcuts", {}}, - {ActionId::CheckUpdates, "check_updates", "Check for &Updates…", "Check for updates", {}}, - {ActionId::About, "about", "&About PxModRim", "About PxModRim", "F1"}, + {ActionId::kSave, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, + {ActionId::kRestore, "restore", "&Restore Mod List…", "Restore mod list", {}}, + {ActionId::kSettings, "settings", "&Settings…", "Settings", "Ctrl+,"}, + {ActionId::kQuit, "quit", "&Quit", "Quit", "Ctrl+Q"}, + {ActionId::kRefresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, + {ActionId::kFullRescan, "full_rescan", "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R"}, + {ActionId::kAutoSort, "auto_sort", "&Auto-Sort Mods", "Auto-sort mods", {}}, + {ActionId::kUpdateMods, "update_mods", "&Update Mods", "Update downloaded mods", {}}, + {ActionId::kFocusSearch, "focus_search", "Focus &Search", "Focus search", "Ctrl+F"}, + {ActionId::kNextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, + {ActionId::kPrevView, "prev_view", "&Previous View", "Previous view", "Ctrl+Shift+Tab"}, + {ActionId::kFullscreen, "fullscreen", "Toggle &Fullscreen", "Toggle fullscreen", "F11"}, + {ActionId::kReportIssue, "report_issue", "&Report Issue", "Report issue", {}}, + {ActionId::kUploadLogs, "upload_logs", "Log && System &Info…", "Log & system info", {}}, + {ActionId::kOpenLogs, "open_logs", "Open &Logs Folder", "Open logs folder", {}}, + {ActionId::kShortcuts, "shortcuts", "&Keyboard Shortcuts", "Keyboard shortcuts", {}}, + {ActionId::kCheckUpdates, "check_updates", "Check for &Updates…", "Check for updates", {}}, + {ActionId::kAbout, "about", "&About PxModRim", "About PxModRim", "F1"}, }}; return specs; } diff --git a/src/ui/window/actions.hpp b/src/ui/window/actions.hpp index aacb7dd..6b705ff 100644 --- a/src/ui/window/actions.hpp +++ b/src/ui/window/actions.hpp @@ -15,9 +15,9 @@ class QWidget; namespace px::modrim { enum class ActionId { - Save, Restore, Settings, Quit, Refresh, FullRescan, AutoSort, UpdateMods, - FocusSearch, NextView, PrevView, Fullscreen, ReportIssue, UploadLogs, - OpenLogs, Shortcuts, CheckUpdates, About, + kSave, kRestore, kSettings, kQuit, kRefresh, kFullRescan, kAutoSort, kUpdateMods, + kFocusSearch, kNextView, kPrevView, kFullscreen, kReportIssue, kUploadLogs, + kOpenLogs, kShortcuts, kCheckUpdates, kAbout, }; struct ActionSpec { diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index 79c37b5..8bee650 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -79,48 +79,48 @@ LaunchConfirmDialog::LaunchConfirmDialog(QWidget* parent, const QString& title, bool LaunchConfirmDialog::dontAskAgain() const { return checkBox()->isChecked(); } MainWindow::MainWindow(AppContext& app, QWidget* parent) - : QMainWindow(parent), app_(app), ctx_(app.coreContext()), - engine_(createQmlEngine(this)), saved_active_(ctx_.loadedActiveUuids()), - updates_(appVersion(), ctx_.network()) { + : QMainWindow(parent), m_app(app), m_ctx(app.coreContext()), + m_engine(createQmlEngine(this)), m_savedActive(m_ctx.loadedActiveUuids()), + m_updates(appVersion(), m_ctx.network()) { setWindowTitle(QStringLiteral("PxModRim[*]")); setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); resize(1400, 850); setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint | Qt::WindowCloseButtonHint); setFocusPolicy(Qt::ClickFocus); - engine_->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(engine_)); + m_engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(m_engine)); const QVariantMap tooltips{ - {"refresh", actionSpec(ActionId::Refresh).tooltip()}, - {"sort", actionSpec(ActionId::AutoSort).tooltip()}, - {"save", actionSpec(ActionId::Save).tooltip()}, - {"update_mods", actionSpec(ActionId::UpdateMods).tooltip()}, + {"refresh", actionSpec(ActionId::kRefresh).tooltip()}, + {"sort", actionSpec(ActionId::kAutoSort).tooltip()}, + {"save", actionSpec(ActionId::kSave).tooltip()}, + {"update_mods", actionSpec(ActionId::kUpdateMods).tooltip()}, }; - header_controller_ = new HeaderController(windowFlags().testFlag(Qt::FramelessWindowHint), - static_cast(app_.uiPrefs().launch_strategy), appVersion(), tooltips, - ctx_.downloads().available(), this); - header_ = new HeaderPanel(*header_controller_, engine_, this); + m_headerController = new HeaderController(windowFlags().testFlag(Qt::FramelessWindowHint), + static_cast(m_app.uiPrefs().launch_strategy), appVersion(), tooltips, + m_ctx.downloads().available(), this); + m_header = new HeaderPanel(*m_headerController, m_engine, this); setupActions(); - menu_ = new MenuBar(actions_, this); - menu_->hide(); - connect(header_controller_, &HeaderController::refreshRequested, this, [this] { reloadMods(); }); - connect(header_controller_, &HeaderController::sortRequested, this, [this] { autoSort(); }); - connect(header_controller_, &HeaderController::saveRequested, this, [this] { saveActiveMods(); }); - connect(header_controller_, &HeaderController::launchRequested, this, [this] { launchGame(); }); - connect(header_controller_, &HeaderController::updateModsRequested, this, [this] { updateMods(); }); - connect(header_controller_, &HeaderController::downloadsRequested, this, &MainWindow::openDownloads); - connect(header_controller_, &HeaderController::minimizeRequested, this, &QWidget::showMinimized); - connect(header_controller_, &HeaderController::maximizeRequested, this, [this] { + m_menu = new MenuBar(m_actions, this); + m_menu->hide(); + connect(m_headerController, &HeaderController::refreshRequested, this, [this] { reloadMods(); }); + connect(m_headerController, &HeaderController::sortRequested, this, [this] { autoSort(); }); + connect(m_headerController, &HeaderController::saveRequested, this, [this] { saveActiveMods(); }); + connect(m_headerController, &HeaderController::launchRequested, this, [this] { launchGame(); }); + connect(m_headerController, &HeaderController::updateModsRequested, this, [this] { updateMods(); }); + connect(m_headerController, &HeaderController::downloadsRequested, this, &MainWindow::openDownloads); + connect(m_headerController, &HeaderController::minimizeRequested, this, &QWidget::showMinimized); + connect(m_headerController, &HeaderController::maximizeRequested, this, [this] { isMaximized() ? showNormal() : showMaximized(); }); - connect(header_controller_, &HeaderController::closeRequested, this, &QWidget::close); - connect(header_controller_, &HeaderController::dragStartRequested, this, [this] { + connect(m_headerController, &HeaderController::closeRequested, this, &QWidget::close); + connect(m_headerController, &HeaderController::dragStartRequested, this, [this] { if (windowHandle()) windowHandle()->startSystemMove(); }); - connect(header_controller_, &HeaderController::strategyChanged, this, [this](int index) { + connect(m_headerController, &HeaderController::strategyChanged, this, [this](int index) { const auto strategy = static_cast(index); - if (app_.uiPrefs().launch_strategy != strategy) { - app_.uiPrefs().launch_strategy = strategy; + if (m_app.uiPrefs().launch_strategy != strategy) { + m_app.uiPrefs().launch_strategy = strategy; savePrefs(); } }); @@ -130,137 +130,137 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) auto* layout = new QVBoxLayout(outer); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - layout->addWidget(header_); - layout->addWidget(menu_); + layout->addWidget(m_header); + layout->addWidget(m_menu); QVariantList tabs; - for (const auto& view : app_.railViews()) { + for (const auto& view : m_app.railViews()) { tabs.append(QVariantMap{{"viewId", QString(view.meta.viewId)}, {"icon", QString(view.meta.iconName)}, {"label", QString(view.meta.label)}}); } QVariantList help; - for (const auto id : {ActionId::ReportIssue, ActionId::UploadLogs, ActionId::OpenLogs, - ActionId::Shortcuts, ActionId::CheckUpdates, ActionId::About}) { + for (const auto id : {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, + ActionId::kShortcuts, ActionId::kCheckUpdates, ActionId::kAbout}) { const auto& spec = actionSpec(id); help.append(QVariantMap{{"id", spec.key}, {"label", spec.label}}); } - splitter_ = new QSplitter(Qt::Horizontal, outer); - splitter_->setContentsMargins(0, 0, 0, 0); - splitter_->setHandleWidth(1); - rail_ = new ViewRailPanel(tabs, help, engine_, splitter_); - rail_->setObjectName(QStringLiteral("viewRail")); - rail_->setMinimumWidth(RAIL_MIN_WIDTH); - rail_->setMaximumWidth(RAIL_MAX_WIDTH); - stack_ = new QStackedWidget(splitter_); - stack_->setObjectName(QStringLiteral("viewStack")); - splitter_->addWidget(rail_); - splitter_->addWidget(stack_); - splitter_->setStretchFactor(0, 0); - splitter_->setStretchFactor(1, 1); - splitter_->setCollapsible(0, false); - splitter_->setCollapsible(1, false); - const int railWidth = app_.uiPrefs().rail_collapsed ? RAIL_MIN_WIDTH : RAIL_MAX_WIDTH; - splitter_->setSizes({railWidth, width() - railWidth}); - connect(rail_, &ViewRailPanel::currentChanged, this, &MainWindow::selectView); - connect(rail_, &ViewRailPanel::hovered, this, &MainWindow::preloadView); - connect(rail_, &ViewRailPanel::settingsRequested, this, [this] { app_.navigate(buildRoute(QString(kSettingsViewId))); }); - connect(rail_, &ViewRailPanel::helpActionRequested, this, [this](const QString& key) { - for (const auto& spec : actionSpecs()) if (spec.key == key) actions_.at(spec.id)->trigger(); + m_splitter = new QSplitter(Qt::Horizontal, outer); + m_splitter->setContentsMargins(0, 0, 0, 0); + m_splitter->setHandleWidth(1); + m_rail = new ViewRailPanel(tabs, help, m_engine, m_splitter); + m_rail->setObjectName(QStringLiteral("viewRail")); + m_rail->setMinimumWidth(kRailMinWidth); + m_rail->setMaximumWidth(kRailMaxWidth); + m_stack = new QStackedWidget(m_splitter); + m_stack->setObjectName(QStringLiteral("viewStack")); + m_splitter->addWidget(m_rail); + m_splitter->addWidget(m_stack); + m_splitter->setStretchFactor(0, 0); + m_splitter->setStretchFactor(1, 1); + m_splitter->setCollapsible(0, false); + m_splitter->setCollapsible(1, false); + const int railWidth = m_app.uiPrefs().rail_collapsed ? kRailMinWidth : kRailMaxWidth; + m_splitter->setSizes({railWidth, width() - railWidth}); + connect(m_rail, &ViewRailPanel::currentChanged, this, &MainWindow::selectView); + connect(m_rail, &ViewRailPanel::hovered, this, &MainWindow::preloadView); + connect(m_rail, &ViewRailPanel::settingsRequested, this, [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + connect(m_rail, &ViewRailPanel::helpActionRequested, this, [this](const QString& key) { + for (const auto& spec : actionSpecs()) if (spec.key == key) m_actions.at(spec.id)->trigger(); }); - connect(splitter_, &QSplitter::splitterMoved, this, [this] { snapRail(); }); - for (const auto& spec : app_.railViews()) { - auto* view = spec.create(ctx_, engine_, stack_, app_); - views_.push_back(view); - stack_->addWidget(view); - if (spec.meta.viewId == QLatin1StringView("mods")) mods_view_ = qobject_cast(view); + connect(m_splitter, &QSplitter::splitterMoved, this, [this] { snapRail(); }); + for (const auto& spec : m_app.railViews()) { + auto* view = spec.create(m_ctx, m_engine, m_stack, m_app); + m_views.push_back(view); + m_stack->addWidget(view); + if (spec.meta.viewId == QLatin1StringView("mods")) m_modsView = qobject_cast(view); } - if (mods_view_) { - selection_ = std::make_unique(ctx_, *mods_view_->mod_info); - connect(mods_view_, &ModsViewPanel::entry_selected, this, [this](const view::SidebarEntry& entry) { - mods_view_->apply_sidebar_entry(entry); + if (m_modsView) { + m_selection = std::make_unique(m_ctx, *m_modsView->modInfo); + connect(m_modsView, &ModsViewPanel::entrySelected, this, [this](const view::SidebarEntry& entry) { + m_modsView->applySidebarEntry(entry); }); - connect(mods_view_, &ModsViewPanel::mod_selected, this, [this](const QString& uuid) { selectMod(uuid); }); - connect(mods_view_->mod_info, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { requestMod(uuid); }); + connect(m_modsView, &ModsViewPanel::modSelected, this, [this](const QString& uuid) { selectMod(uuid); }); + connect(m_modsView->modInfo, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { requestMod(uuid); }); } - layout->addWidget(splitter_, 1); + layout->addWidget(m_splitter, 1); setCentralWidget(outer); - toasts_ = new ToastManager(outer); - toasts_->resizeToParent(); - app_.setToasts(toasts_); - app_.setNavigator([this](const QString& url) { openRoute(url); }); - active_connection_ = ctx_.activeStateChanged().connect([this](const auto&) { refreshUnsavedState(); }); - mods_connection_ = ctx_.modService().mods_changed.connect([this](const auto&) { onModsReloaded(); }); - auto& diagnostics = ctx_.diagnosticsService(); - diagnostics_connection_ = diagnostics.diagnostics_summary_changed.connect([this](const auto&) { - if (!mods_view_) return; - mods_view_->apply_current_sidebar_filter(); - if (!selected_uuid_.isEmpty()) mods_view_->mod_info->setIssues(ctx_.diagnosticsService().issues_for(selected_uuid_)); + m_toasts = new ToastManager(outer); + m_toasts->resizeToParent(); + m_app.setToasts(m_toasts); + m_app.setNavigator([this](const QString& url) { openRoute(url); }); + m_activeConnection = m_ctx.activeStateChanged().connect([this](const auto&) { refreshUnsavedState(); }); + m_modsConnection = m_ctx.modService().modsChanged.connect([this](const auto&) { onModsReloaded(); }); + auto& diagnostics = m_ctx.diagnosticsService(); + m_diagnosticsConnection = diagnostics.diagnosticsSummaryChanged.connect([this](const auto&) { + if (!m_modsView) return; + m_modsView->applyCurrentSidebarFilter(); + if (!m_selectedUuid.isEmpty()) m_modsView->modInfo->setIssues(m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); }); - status_connection_ = diagnostics.status_message_changed.connect([this](const QString& message) { toasts_->info(message); }); - task_connection_ = diagnostics.background_task_changed.connect([this](const QString& text) { header_controller_->setTaskText(text); }); - auto& downloads = ctx_.downloads(); + m_statusConnection = diagnostics.statusMessageChanged.connect([this](const QString& message) { m_toasts->info(message); }); + m_taskConnection = diagnostics.backgroundTaskChanged.connect([this](const QString& text) { m_headerController->setTaskText(text); }); + auto& downloads = m_ctx.downloads(); if (downloads.available()) { - download_busy_connection_ = downloads.busy_changed.connect([this](bool busy) { - header_controller_->setDownloadsBusy(busy); - actions_.at(ActionId::UpdateMods)->setEnabled(!busy); - if (busy) header_controller_->setDownloadsProgress(QStringLiteral("Updating mods…"), 0, 0); + m_downloadBusyConnection = downloads.busyChanged.connect([this](bool busy) { + m_headerController->setDownloadsBusy(busy); + m_actions.at(ActionId::kUpdateMods)->setEnabled(!busy); + if (busy) m_headerController->setDownloadsProgress(QStringLiteral("Updating mods…"), 0, 0); }); - download_progress_connection_ = downloads.download_progress.connect([this](const DownloadProgress& progress) { - header_controller_->setDownloadsProgress( + m_downloadProgressConnection = downloads.downloadProgress.connect([this](const DownloadProgress& progress) { + m_headerController->setDownloadsProgress( QStringLiteral("Updating mods: %1 / %2 · %3 MB").arg(progress.completed).arg(progress.total) - .arg(progress.bytes_done / 1048576.0, 0, 'f', 0), progress.completed, progress.total); + .arg(progress.bytesDone / 1048576.0, 0, 'f', 0), progress.completed, progress.total); }); - download_finished_connection_ = downloads.download_finished.connect([this](const auto& result) { onDownloadsFinished(result); }); - download_status_connection_ = downloads.status_message_changed.connect([this](const QString& message) { toasts_->info(message); }); + m_downloadFinishedConnection = downloads.downloadFinished.connect([this](const auto& result) { onDownloadsFinished(result); }); + m_downloadStatusConnection = downloads.statusMessageChanged.connect([this](const QString& message) { m_toasts->info(message); }); } qApp->installEventFilter(this); - if (!views_.empty()) showView(0); + if (!m_views.empty()) showView(0); refreshUnsavedState(); } MainWindow::~MainWindow() { qApp->removeEventFilter(this); - app_.setNavigator({}); - app_.setToasts(nullptr); - ctx_.activeStateChanged().disconnect(active_connection_); - ctx_.modService().mods_changed.disconnect(mods_connection_); - auto& diagnostics = ctx_.diagnosticsService(); - diagnostics.diagnostics_summary_changed.disconnect(diagnostics_connection_); - diagnostics.status_message_changed.disconnect(status_connection_); - diagnostics.background_task_changed.disconnect(task_connection_); - auto& downloads = ctx_.downloads(); - downloads.busy_changed.disconnect(download_busy_connection_); - downloads.download_progress.disconnect(download_progress_connection_); - downloads.download_finished.disconnect(download_finished_connection_); - downloads.status_message_changed.disconnect(download_status_connection_); + m_app.setNavigator({}); + m_app.setToasts(nullptr); + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + auto& diagnostics = m_ctx.diagnosticsService(); + diagnostics.diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); + diagnostics.statusMessageChanged.disconnect(m_statusConnection); + diagnostics.backgroundTaskChanged.disconnect(m_taskConnection); + auto& downloads = m_ctx.downloads(); + downloads.busyChanged.disconnect(m_downloadBusyConnection); + downloads.downloadProgress.disconnect(m_downloadProgressConnection); + downloads.downloadFinished.disconnect(m_downloadFinishedConnection); + downloads.statusMessageChanged.disconnect(m_downloadStatusConnection); const auto dialogs = findChildren(QString(), Qt::FindDirectChildrenOnly); for (auto* dialog : dialogs) delete dialog; // QuickWidgets must release their scenes before the shared engine is destroyed. delete takeCentralWidget(); - delete engine_; + delete m_engine; } void MainWindow::setupActions() { - actions_ = createActions(*this); - const auto bind = [this](ActionId id, auto handler) { connect(actions_.at(id), &QAction::triggered, this, handler); }; - bind(ActionId::Save, [this] { saveActiveMods(); }); - bind(ActionId::Restore, [this] { restoreSnapshot(); }); - bind(ActionId::Settings, [this] { app_.navigate(buildRoute(QString(kSettingsViewId))); }); - bind(ActionId::Quit, [this] { close(); }); - bind(ActionId::Refresh, [this] { reloadMods(); }); - bind(ActionId::FullRescan, [this] { reloadMods(true); }); - bind(ActionId::AutoSort, [this] { autoSort(); }); - bind(ActionId::UpdateMods, [this] { updateMods(); }); - bind(ActionId::FocusSearch, [this] { focusSearch(); }); - bind(ActionId::NextView, [this] { cycleView(1); }); - bind(ActionId::PrevView, [this] { cycleView(-1); }); - bind(ActionId::Fullscreen, [this] { isFullScreen() ? showNormal() : showFullScreen(); }); - bind(ActionId::ReportIssue, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); - bind(ActionId::OpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(config_dir()).filePath(QStringLiteral("logs")))); }); - bind(ActionId::UploadLogs, [this] { uploadReport(); }); - bind(ActionId::Shortcuts, [this] { showShortcuts(); }); - bind(ActionId::CheckUpdates, [this] { checkUpdates(true); }); - bind(ActionId::About, [this] { showAbout(); }); - actions_.at(ActionId::UpdateMods)->setEnabled(ctx_.downloads().available()); + m_actions = createActions(*this); + const auto bind = [this](ActionId id, auto handler) { connect(m_actions.at(id), &QAction::triggered, this, handler); }; + bind(ActionId::kSave, [this] { saveActiveMods(); }); + bind(ActionId::kRestore, [this] { restoreSnapshot(); }); + bind(ActionId::kSettings, [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + bind(ActionId::kQuit, [this] { close(); }); + bind(ActionId::kRefresh, [this] { reloadMods(); }); + bind(ActionId::kFullRescan, [this] { reloadMods(true); }); + bind(ActionId::kAutoSort, [this] { autoSort(); }); + bind(ActionId::kUpdateMods, [this] { updateMods(); }); + bind(ActionId::kFocusSearch, [this] { focusSearch(); }); + bind(ActionId::kNextView, [this] { cycleView(1); }); + bind(ActionId::kPrevView, [this] { cycleView(-1); }); + bind(ActionId::kFullscreen, [this] { isFullScreen() ? showNormal() : showFullScreen(); }); + bind(ActionId::kReportIssue, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); + bind(ActionId::kOpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(configDir()).filePath(QStringLiteral("logs")))); }); + bind(ActionId::kUploadLogs, [this] { uploadReport(); }); + bind(ActionId::kShortcuts, [this] { showShortcuts(); }); + bind(ActionId::kCheckUpdates, [this] { checkUpdates(true); }); + bind(ActionId::kAbout, [this] { showAbout(); }); + m_actions.at(ActionId::kUpdateMods)->setEnabled(m_ctx.downloads().available()); for (int index = 0; index < 9; ++index) { auto* action = new QAction(this); action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(index + 1))); @@ -269,76 +269,76 @@ void MainWindow::setupActions() { } } -void MainWindow::setAppQuitCallback(std::function callback) { quit_ = std::move(callback); } +void MainWindow::setAppQuitCallback(std::function callback) { m_quit = std::move(callback); } void MainWindow::openRoute(const QString& url) { const auto route = parseRoute(url); if (!route) return; - if (route->view_id == kSettingsViewId) { openSettings(); return; } - const auto& specs = app_.railViews(); - for (std::size_t i = 0; i < views_.size(); ++i) { - if (route->view_id == specs[i].meta.viewId) { + if (route->viewId == kSettingsViewId) { openSettings(); return; } + const auto& specs = m_app.railViews(); + for (std::size_t i = 0; i < m_views.size(); ++i) { + if (route->viewId == specs[i].meta.viewId) { showView(static_cast(i)); - views_[i]->openRoute(route->path); + m_views[i]->openRoute(route->path); return; } } } void MainWindow::selectView(int index) { - if (index >= 0 && index < stack_->count()) app_.navigate(buildRoute(QString(app_.railViews()[index].meta.viewId))); + if (index >= 0 && index < m_stack->count()) m_app.navigate(buildRoute(QString(m_app.railViews()[index].meta.viewId))); } void MainWindow::showView(int index) { - rail_->setCurrent(index); - stack_->setCurrentIndex(index); - header_controller_->setDownloadsProgressShown(!app_.railViews()[index].meta.listsDownloads); + m_rail->setCurrent(index); + m_stack->setCurrentIndex(index); + m_headerController->setDownloadsProgressShown(!m_app.railViews()[index].meta.listsDownloads); preloadView(index - 1); preloadView(index + 1); } void MainWindow::preloadView(int index) { - if (index >= 0 && index < static_cast(views_.size())) views_[index]->preload(); + if (index >= 0 && index < static_cast(m_views.size())) m_views[index]->preload(); } void MainWindow::cycleView(int direction) { - const int count = stack_->count(); - if (count) selectView(((stack_->currentIndex() + direction) % count + count) % count); + const int count = m_stack->count(); + if (count) selectView(((m_stack->currentIndex() + direction) % count + count) % count); } void MainWindow::openDownloads() { - for (const auto& view : app_.railViews()) { - if (view.meta.listsDownloads) { app_.navigate(buildRoute(QString(view.meta.viewId))); return; } + for (const auto& view : m_app.railViews()) { + if (view.meta.listsDownloads) { m_app.navigate(buildRoute(QString(view.meta.viewId))); return; } } } void MainWindow::focusSearch() { - auto* search = stack_->currentWidget() ? stack_->currentWidget()->findChild(QStringLiteral("searchInput")) : nullptr; + auto* search = m_stack->currentWidget() ? m_stack->currentWidget()->findChild(QStringLiteral("searchInput")) : nullptr; if (search) search->setFocus(); - else if (mods_view_) mods_view_->mod_list->search_input->setFocus(); + else if (m_modsView) m_modsView->modList->searchInput->setFocus(); } void MainWindow::savePrefs() { - if (ctx_.hasConfigService()) { - const auto result = saveUiPrefs(app_.uiPrefs(), ctx_.configService()); - if (!result) toasts_->error(result.error().message); + if (m_ctx.hasConfigService()) { + const auto result = saveUiPrefs(m_app.uiPrefs(), m_ctx.configService()); + if (!result) m_toasts->error(result.error().message); } } void MainWindow::snapRail() { - const int target = rail_->width() >= RAIL_COLLAPSE_WIDTH ? RAIL_MAX_WIDTH : RAIL_MIN_WIDTH; - splitter_->setSizes({target, splitter_->width() - target}); - const bool collapsed = target == RAIL_MIN_WIDTH; - if (app_.uiPrefs().rail_collapsed != collapsed) { app_.uiPrefs().rail_collapsed = collapsed; savePrefs(); } + const int target = m_rail->width() >= kRailCollapseWidth ? kRailMaxWidth : kRailMinWidth; + m_splitter->setSizes({target, m_splitter->width() - target}); + const bool collapsed = target == kRailMinWidth; + if (m_app.uiPrefs().rail_collapsed != collapsed) { m_app.uiPrefs().rail_collapsed = collapsed; savePrefs(); } } void MainWindow::refreshUnsavedState() { - unsaved_ = ctx_.activeUuids() != saved_active_; - header_controller_->setUnsavedChanges(unsaved_); - setWindowModified(unsaved_); + m_unsaved = m_ctx.activeUuids() != m_savedActive; + m_headerController->setUnsavedChanges(m_unsaved); + setWindowModified(m_unsaved); } -void MainWindow::onModsReloaded() { saved_active_ = ctx_.loadedActiveUuids(); refreshUnsavedState(); } +void MainWindow::onModsReloaded() { m_savedActive = m_ctx.loadedActiveUuids(); refreshUnsavedState(); } bool MainWindow::eventFilter(QObject* object, QEvent* event) { if (event->type() == QEvent::KeyPress) { @@ -346,158 +346,158 @@ bool MainWindow::eventFilter(QObject* object, QEvent* event) { if (widget && widget->window() == this) { const int key = static_cast(event)->key(); if (key == Qt::Key_Alt) { - menu_->setVisible(!menu_->isVisible()); - if (menu_->isVisible()) menu_->setFocus(); + m_menu->setVisible(!m_menu->isVisible()); + if (m_menu->isVisible()) m_menu->setFocus(); return true; } - if (key == Qt::Key_Escape && menu_->isVisible()) { menu_->hide(); return true; } + if (key == Qt::Key_Escape && m_menu->isVisible()) { m_menu->hide(); return true; } } } return QMainWindow::eventFilter(object, event); } void MainWindow::closeEvent(QCloseEvent* event) { - if (unsaved_ && !close_confirmed_) { + if (m_unsaved && !m_closeConfirmed) { event->ignore(); - if (!close_prompt_open_) { close_prompt_open_ = true; confirmClose(); } + if (!m_closePromptOpen) { m_closePromptOpen = true; confirmClose(); } return; } - closing_ = true; - for (auto* view : views_) view->teardown(); + m_closing = true; + for (auto* view : m_views) view->teardown(); event->accept(); - if (quit_) quit_(); + if (m_quit) m_quit(); } QCoro::Task MainWindow::confirmClose() { const auto [result, dialog] = co_await awaitDialog(this); - close_prompt_open_ = false; + m_closePromptOpen = false; if (result == QMessageBox::Save) { if (!co_await saveActiveMods()) co_return; } else if (result != QMessageBox::Discard) co_return; - close_confirmed_ = true; + m_closeConfirmed = true; close(); } void MainWindow::resizeEvent(QResizeEvent* event) { QMainWindow::resizeEvent(event); - if (toasts_) toasts_->resizeToParent(); + if (m_toasts) m_toasts->resizeToParent(); } void MainWindow::changeEvent(QEvent* event) { QMainWindow::changeEvent(event); - if (event->type() == QEvent::WindowStateChange && header_controller_) header_controller_->setMaximized(isMaximized()); + if (event->type() == QEvent::WindowStateChange && m_headerController) m_headerController->setMaximized(isMaximized()); } QCoro::Task MainWindow::reloadMods(bool full) { - toasts_->info(full ? QStringLiteral("Rescanning all mods…") : QStringLiteral("Refreshing mods…")); - const auto count = co_await app_.refreshMods(full); - if (!count) { toasts_->error(count.error().message); co_return; } - if (!*count) { toasts_->warning(QStringLiteral("No mods found")); co_return; } - toasts_->success((full ? QStringLiteral("Rescanned %1 mods") : QStringLiteral("Reloaded %1 mods")).arg(*count)); + m_toasts->info(full ? QStringLiteral("Rescanning all mods…") : QStringLiteral("Refreshing mods…")); + const auto count = co_await m_app.refreshMods(full); + if (!count) { m_toasts->error(count.error().message); co_return; } + if (!*count) { m_toasts->warning(QStringLiteral("No mods found")); co_return; } + m_toasts->success((full ? QStringLiteral("Rescanned %1 mods") : QStringLiteral("Reloaded %1 mods")).arg(*count)); } QCoro::Task MainWindow::autoSort() { - const auto [count, elapsed] = co_await ctx_.autoSort(); - toasts_->success(QStringLiteral("Sorted %1 mods in %2ms").arg(count).arg(elapsed, 0, 'f', 0)); + const auto [count, elapsed] = co_await m_ctx.autoSort(); + m_toasts->success(QStringLiteral("Sorted %1 mods in %2ms").arg(count).arg(elapsed, 0, 'f', 0)); } QCoro::Task MainWindow::updateMods() { - auto& downloads = ctx_.downloads(); + auto& downloads = m_ctx.downloads(); if (!downloads.available()) co_return; - if (downloads.is_downloading()) { downloads.cancel(); co_return; } - const auto ids = downloads.updatable_ids(ctx_.allMods().values()); - if (ids.isEmpty()) { toasts_->info(QStringLiteral("No downloaded mods to update")); co_return; } - if (!co_await confirm_update(ids.size(), this)) co_return; - toasts_->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); - const auto result = co_await downloads.download_mods(ids); - if (!result) toasts_->error(result.error().message); + if (downloads.isDownloading()) { downloads.cancel(); co_return; } + const auto ids = downloads.updatableIds(m_ctx.allMods().values()); + if (ids.isEmpty()) { m_toasts->info(QStringLiteral("No downloaded mods to update")); co_return; } + if (!co_await confirmUpdate(ids.size(), this)) co_return; + m_toasts->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); + const auto result = co_await downloads.downloadMods(ids); + if (!result) m_toasts->error(result.error().message); } void MainWindow::onDownloadsFinished(const DownloadResult& result) { const auto ok = result.succeeded.size(), failed = result.failed.size(), changed = result.changed.size(); - if (!ok && !failed) { toasts_->info(QStringLiteral("Download cancelled")); return; } + if (!ok && !failed) { m_toasts->info(QStringLiteral("Download cancelled")); return; } const QString summary = QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); if (failed) { - const auto names = ctx_.downloads().names_by_id(ctx_.allMods().values()); + const auto names = m_ctx.downloads().namesById(m_ctx.allMods().values()); QStringList shown; for (const auto& id : result.failed.mid(0, 3)) shown.append(names.value(id, id)); - toasts_->warning(QStringLiteral("%1, %2 failed: %3%4").arg(summary).arg(failed).arg(shown.join(QStringLiteral(", ")), failed > 3 ? QStringLiteral("…") : QString())); - } else toasts_->success(summary); - if (changed) app_.refreshMods(); + m_toasts->warning(QStringLiteral("%1, %2 failed: %3%4").arg(summary).arg(failed).arg(shown.join(QStringLiteral(", ")), failed > 3 ? QStringLiteral("…") : QString())); + } else m_toasts->success(summary); + if (changed) m_app.refreshMods(); } QCoro::Task MainWindow::openSettings() { - if (settings_open_) co_return; - settings_open_ = true; - const auto [result, dialog] = co_await awaitDialog(app_, engine_, this); - settings_open_ = false; + if (m_settingsOpen) co_return; + m_settingsOpen = true; + const auto [result, dialog] = co_await awaitDialog(m_app, m_engine, this); + m_settingsOpen = false; if (result != QDialog::Accepted) co_return; auto config = dialog->getConfig(); - const auto saved = ctx_.configService().save(QStringLiteral("config.json"), config); - if (!saved) { toasts_->error(saved.error().message); co_return; } - ctx_.updateConfig(config); - ctx_.resetProviders(config.paths); - const auto reloaded = co_await ctx_.modService().reload(); - if (!reloaded) { toasts_->error(reloaded.error().message); co_return; } - toasts_->success(QStringLiteral("Settings saved")); + const auto saved = m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { m_toasts->error(saved.error().message); co_return; } + m_ctx.updateConfig(config); + m_ctx.resetProviders(config.paths); + const auto reloaded = co_await m_ctx.modService().reload(); + if (!reloaded) { m_toasts->error(reloaded.error().message); co_return; } + m_toasts->success(QStringLiteral("Settings saved")); } -QCoro::Task MainWindow::showAbout() { co_await awaitDialog(this, ctx_, *toasts_); } -QCoro::Task MainWindow::showShortcuts() { co_await awaitDialog(shortcutRows() + QML_SHORTCUTS, this); } -QCoro::Task MainWindow::uploadReport() { co_await handle_upload_report(this, ctx_, *toasts_); } +QCoro::Task MainWindow::showAbout() { co_await awaitDialog(this, m_ctx, *m_toasts); } +QCoro::Task MainWindow::showShortcuts() { co_await awaitDialog(shortcutRows() + kQmlShortcuts, this); } +QCoro::Task MainWindow::uploadReport() { co_await handleUploadReport(this, m_ctx, *m_toasts); } QCoro::Task MainWindow::restoreSnapshot() { - const auto snapshots = ctx_.modService().get_snapshots(); - if (snapshots.isEmpty()) { toasts_->warning(QStringLiteral("No saved mod lists are available")); co_return; } + const auto snapshots = m_ctx.modService().getSnapshots(); + if (snapshots.isEmpty()) { m_toasts->warning(QStringLiteral("No saved mod lists are available")); co_return; } const auto [result, dialog] = co_await awaitDialog(snapshots, this); if (result != QDialog::Accepted) co_return; const auto chosen = dialog->selectedSnapshot(); if (!chosen) co_return; const auto [confirmed, confirmation] = co_await awaitDialog(QFileInfo(*chosen).fileName(), this); if (confirmed != QMessageBox::Yes) co_return; - if (co_await ctx_.modService().restore_snapshot(*chosen)) toasts_->success(QStringLiteral("Restored mod list from %1").arg(QFileInfo(*chosen).fileName())); - else toasts_->error(QStringLiteral("The saved mod list is invalid or cannot be read")); + if (co_await m_ctx.modService().restoreSnapshot(*chosen)) m_toasts->success(QStringLiteral("Restored mod list from %1").arg(QFileInfo(*chosen).fileName())); + else m_toasts->error(QStringLiteral("The saved mod list is invalid or cannot be read")); } void MainWindow::startStartupUpdateCheck() { checkUpdates(false); } QCoro::Task MainWindow::checkUpdates(bool manual) { - if (checking_updates_) co_return; - checking_updates_ = true; + if (m_checkingUpdates) co_return; + m_checkingUpdates = true; const QPointer guard(this); - const auto release = co_await updates_.check(); - if (!guard || closing_) co_return; + const auto release = co_await m_updates.check(); + if (!guard || m_closing) co_return; if (!release) { - checking_updates_ = false; - if (manual) toasts_->error(QStringLiteral("Could not check for updates")); + m_checkingUpdates = false; + if (manual) m_toasts->error(QStringLiteral("Could not check for updates")); co_return; } if (!release->has_value()) { - checking_updates_ = false; - if (manual) toasts_->success(QStringLiteral("PxModRim is up to date")); + m_checkingUpdates = false; + if (manual) m_toasts->success(QStringLiteral("PxModRim is up to date")); co_return; } const auto info = **release; - if (!manual && info.tag.toStdString() == app_.uiPrefs().skipped_update_tag) { checking_updates_ = false; co_return; } - const auto [result, dialog] = co_await awaitDialog(info, updates_.current_version(), this); - if (!guard || closing_) co_return; - checking_updates_ = false; + if (!manual && info.tag.toStdString() == m_app.uiPrefs().skipped_update_tag) { m_checkingUpdates = false; co_return; } + const auto [result, dialog] = co_await awaitDialog(info, m_updates.currentVersion(), this); + if (!guard || m_closing) co_return; + m_checkingUpdates = false; if (result == QDialog::Accepted) QDesktopServices::openUrl(QUrl(info.url)); - else if (result == SKIP_RESULT) { app_.uiPrefs().skipped_update_tag = info.tag.toStdString(); savePrefs(); } + else if (result == kSkipResult) { m_app.uiPrefs().skipped_update_tag = info.tag.toStdString(); savePrefs(); } } QCoro::Task> MainWindow::writeActiveLayout() { - const auto active = ctx_.activeUuids(); - if (!co_await ctx_.modService().save_active_layout(active)) co_return std::nullopt; - saved_active_ = active; + const auto active = m_ctx.activeUuids(); + if (!co_await m_ctx.modService().saveActiveLayout(active)) co_return std::nullopt; + m_savedActive = active; refreshUnsavedState(); co_return active; } QCoro::Task MainWindow::saveActiveMods() { const auto saved = co_await writeActiveLayout(); - if (!saved) { toasts_->warning(QStringLiteral("Config folder not set")); co_return false; } - toasts_->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); + if (!saved) { m_toasts->warning(QStringLiteral("Config folder not set")); co_return false; } + m_toasts->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); co_return true; } @@ -506,69 +506,69 @@ QCoro::Task MainWindow::confirmLaunch(bool AppConfig::* setting, const QSt const auto [result, dialog] = co_await awaitDialog(this, title, text, info, accept); if (result != QMessageBox::Ok) co_return false; if (dialog->dontAskAgain()) { - auto config = ctx_.config(); + auto config = m_ctx.config(); config.*setting = false; - const auto saved = ctx_.configService().save(QStringLiteral("config.json"), config); - if (!saved) { toasts_->error(saved.error().message); co_return false; } - ctx_.updateConfig(std::move(config)); + const auto saved = m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { m_toasts->error(saved.error().message); co_return false; } + m_ctx.updateConfig(std::move(config)); } co_return true; } QCoro::Task MainWindow::launchGame() { - if (header_controller_->launchState() != QStringLiteral("idle")) co_return; - header_controller_->setLaunchState(QStringLiteral("launching")); + if (m_headerController->launchState() != QStringLiteral("idle")) co_return; + m_headerController->setLaunchState(QStringLiteral("launching")); const QPointer guard(this); - auto& launcher = ctx_.gameLauncher(); - const auto config = ctx_.config(); - const bool running = config.launch_confirm_running && co_await QtConcurrent::run([&launcher] { return launcher.is_running(); }); - if (!guard || closing_) co_return; - const int errors = ctx_.diagnosticsService().active_error_count(); - const auto abort = [this] { header_controller_->setLaunchState(QStringLiteral("idle")); }; + auto& launcher = m_ctx.gameLauncher(); + const auto config = m_ctx.config(); + const bool running = config.launch_confirm_running && co_await QtConcurrent::run([&launcher] { return launcher.isRunning(); }); + if (!guard || m_closing) co_return; + const int errors = m_ctx.diagnosticsService().activeErrorCount(); + const auto abort = [this] { m_headerController->setLaunchState(QStringLiteral("idle")); }; if (running && !co_await confirmLaunch(&AppConfig::launch_confirm_running, QStringLiteral("Game Already Running"), QStringLiteral("RimWorld is already running."), QStringLiteral("A second copy can overwrite the first one's saves and settings."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } if (config.launch_confirm_errors && errors > 0 && !co_await confirmLaunch(&AppConfig::launch_confirm_errors, QStringLiteral("Mod List Has Errors"), QStringLiteral("%1 active mod(s) have errors.").arg(errors), QStringLiteral("Missing dependencies or wrong load order can crash the game."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } - if (config.launch_confirm_unsaved && unsaved_ && !co_await confirmLaunch(&AppConfig::launch_confirm_unsaved, + if (config.launch_confirm_unsaved && m_unsaved && !co_await confirmLaunch(&AppConfig::launch_confirm_unsaved, QStringLiteral("Unsaved Changes"), QStringLiteral("The active mod list has unsaved changes."), QStringLiteral("The list is saved to ModsConfig.xml before the game starts."), QStringLiteral("Save and Launch"))) { abort(); co_return; } - if (!co_await writeActiveLayout()) toasts_->warning(QStringLiteral("Config folder not set — mod list won't be saved")); - const auto result = co_await launcher.launch(app_.uiPrefs().launch_strategy); - if (!guard || closing_) co_return; - if (!result) { toasts_->warning(result.error().message); abort(); co_return; } - toasts_->success(*result); - const auto after = ctx_.config().after_launch; - if (!co_await launcher.wait_for_start()) { - if (guard && !closing_) { toasts_->warning(QStringLiteral("Game did not start")); abort(); } + if (!co_await writeActiveLayout()) m_toasts->warning(QStringLiteral("Config folder not set — mod list won't be saved")); + const auto result = co_await launcher.launch(m_app.uiPrefs().launch_strategy); + if (!guard || m_closing) co_return; + if (!result) { m_toasts->warning(result.error().message); abort(); co_return; } + m_toasts->success(*result); + const auto after = m_ctx.config().after_launch; + if (!co_await launcher.waitForStart()) { + if (guard && !m_closing) { m_toasts->warning(QStringLiteral("Game did not start")); abort(); } co_return; } - if (!guard || closing_) co_return; - header_controller_->setLaunchState(QStringLiteral("running")); - if (after == AfterLaunch::Close && !unsaved_) { close_confirmed_ = true; close(); co_return; } - if (after == AfterLaunch::Minimize) showMinimized(); - const auto code = co_await launcher.wait_for_exit(); - if (!guard || closing_) co_return; - if (code && *code) toasts_->warning(QStringLiteral("Game exited with code %1").arg(*code)); - else toasts_->info(QStringLiteral("Game closed")); - if (after == AfterLaunch::Minimize) { showNormal(); activateWindow(); } + if (!guard || m_closing) co_return; + m_headerController->setLaunchState(QStringLiteral("running")); + if (after == AfterLaunch::kClose && !m_unsaved) { m_closeConfirmed = true; close(); co_return; } + if (after == AfterLaunch::kMinimize) showMinimized(); + const auto code = co_await launcher.waitForExit(); + if (!guard || m_closing) co_return; + if (code && *code) m_toasts->warning(QStringLiteral("Game exited with code %1").arg(*code)); + else m_toasts->info(QStringLiteral("Game closed")); + if (after == AfterLaunch::kMinimize) { showNormal(); activateWindow(); } abort(); } QCoro::Task MainWindow::selectMod(QString uuid) { - selected_uuid_ = uuid; - if (!selection_) co_return; - if (uuid.isEmpty()) selection_->clear(); - else co_await selection_->show(std::move(uuid)); + m_selectedUuid = uuid; + if (!m_selection) co_return; + if (uuid.isEmpty()) m_selection->clear(); + else co_await m_selection->show(std::move(uuid)); } QCoro::Task MainWindow::requestMod(QString uuid) { - if (!mods_view_) co_return; - if (mods_view_->mod_list->select_uuid(uuid)) co_return; - mods_view_->mod_list->search_input->clear(); - mods_view_->sidebar->select_all_entry(); - if (!mods_view_->mod_list->select_uuid(uuid)) co_await selectMod(std::move(uuid)); + if (!m_modsView) co_return; + if (m_modsView->modList->selectUuid(uuid)) co_return; + m_modsView->modList->searchInput->clear(); + m_modsView->sidebar->selectAllEntry(); + if (!m_modsView->modList->selectUuid(uuid)) co_await selectMod(std::move(uuid)); } } // namespace px::modrim diff --git a/src/ui/window/main_window.hpp b/src/ui/window/main_window.hpp index 30bcbaf..920acd1 100644 --- a/src/ui/window/main_window.hpp +++ b/src/ui/window/main_window.hpp @@ -48,14 +48,14 @@ class MainWindow : public QMainWindow { explicit MainWindow(AppContext& app, QWidget* parent = nullptr); ~MainWindow() override; - QQmlEngine* qmlEngine() const noexcept { return engine_; } + QQmlEngine* qmlEngine() const noexcept { return m_engine; } void setAppQuitCallback(std::function callback); void startStartupUpdateCheck(); void openRoute(const QString& url); void selectView(int index); void cycleView(int direction); void refreshUnsavedState(); - bool hasUnsavedChanges() const noexcept { return unsaved_; } + bool hasUnsavedChanges() const noexcept { return m_unsaved; } QCoro::Task saveActiveMods(); protected: @@ -91,39 +91,39 @@ class MainWindow : public QMainWindow { QCoro::Task selectMod(QString uuid); QCoro::Task requestMod(QString uuid); - AppContext& app_; - CoreContext& ctx_; - QQmlEngine* engine_; - HeaderController* header_controller_ = nullptr; - HeaderPanel* header_ = nullptr; - MenuBar* menu_ = nullptr; - ViewRailPanel* rail_ = nullptr; - QStackedWidget* stack_ = nullptr; - QSplitter* splitter_ = nullptr; - ToastManager* toasts_ = nullptr; - ModsViewPanel* mods_view_ = nullptr; - std::vector views_; - std::unique_ptr selection_; - QString selected_uuid_; - QStringList saved_active_; - ActionMap actions_; - std::function quit_; - services::UpdateService updates_; - bool unsaved_ = false; - bool close_prompt_open_ = false; - bool close_confirmed_ = false; - bool closing_ = false; - bool settings_open_ = false; - bool checking_updates_ = false; - std::uint64_t active_connection_ = 0; - std::uint64_t mods_connection_ = 0; - std::uint64_t diagnostics_connection_ = 0; - std::uint64_t status_connection_ = 0; - std::uint64_t task_connection_ = 0; - std::uint64_t download_busy_connection_ = 0; - std::uint64_t download_progress_connection_ = 0; - std::uint64_t download_finished_connection_ = 0; - std::uint64_t download_status_connection_ = 0; + AppContext& m_app; + CoreContext& m_ctx; + QQmlEngine* m_engine; + HeaderController* m_headerController = nullptr; + HeaderPanel* m_header = nullptr; + MenuBar* m_menu = nullptr; + ViewRailPanel* m_rail = nullptr; + QStackedWidget* m_stack = nullptr; + QSplitter* m_splitter = nullptr; + ToastManager* m_toasts = nullptr; + ModsViewPanel* m_modsView = nullptr; + std::vector m_views; + std::unique_ptr m_selection; + QString m_selectedUuid; + QStringList m_savedActive; + ActionMap m_actions; + std::function m_quit; + services::UpdateService m_updates; + bool m_unsaved = false; + bool m_closePromptOpen = false; + bool m_closeConfirmed = false; + bool m_closing = false; + bool m_settingsOpen = false; + bool m_checkingUpdates = false; + std::uint64_t m_activeConnection = 0; + std::uint64_t m_modsConnection = 0; + std::uint64_t m_diagnosticsConnection = 0; + std::uint64_t m_statusConnection = 0; + std::uint64_t m_taskConnection = 0; + std::uint64_t m_downloadBusyConnection = 0; + std::uint64_t m_downloadProgressConnection = 0; + std::uint64_t m_downloadFinishedConnection = 0; + std::uint64_t m_downloadStatusConnection = 0; }; } // namespace px::modrim diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp index 005dbb2..8cc9a2f 100644 --- a/src/ui/window/menu_bar.cpp +++ b/src/ui/window/menu_bar.cpp @@ -6,11 +6,11 @@ namespace px::modrim { const std::array& menuLayout() { static const std::array layout{{ - {"&File", {ActionId::Save, ActionId::Restore, ActionId::Settings, std::nullopt, ActionId::Quit}}, - {"&Mods", {ActionId::Refresh, ActionId::FullRescan, ActionId::AutoSort, std::nullopt, ActionId::UpdateMods}}, - {"&View", {ActionId::FocusSearch, ActionId::NextView, ActionId::PrevView, std::nullopt, ActionId::Fullscreen}}, - {"&Help", {ActionId::ReportIssue, ActionId::UploadLogs, ActionId::OpenLogs, ActionId::Shortcuts, - ActionId::CheckUpdates, std::nullopt, ActionId::About}}, + {"&File", {ActionId::kSave, ActionId::kRestore, ActionId::kSettings, std::nullopt, ActionId::kQuit}}, + {"&Mods", {ActionId::kRefresh, ActionId::kFullRescan, ActionId::kAutoSort, std::nullopt, ActionId::kUpdateMods}}, + {"&View", {ActionId::kFocusSearch, ActionId::kNextView, ActionId::kPrevView, std::nullopt, ActionId::kFullscreen}}, + {"&Help", {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, ActionId::kShortcuts, + ActionId::kCheckUpdates, std::nullopt, ActionId::kAbout}}, }}; return layout; } diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp index 311269c..87258c3 100644 --- a/tests/core/checker/test_checker.cpp +++ b/tests/core/checker/test_checker.cpp @@ -22,10 +22,10 @@ using namespace px::modrim::checker; namespace { -std::shared_ptr make_mod(const QString& package_id, const QString& name = {}) { +std::shared_ptr makeMod(const QString& packageId, const QString& name = {}) { auto m = std::make_shared(); - m->package_id = CaseInsensitiveStr(package_id); - m->name = name.isEmpty() ? package_id : name; + m->packageId = CaseInsensitiveStr(packageId); + m->name = name.isEmpty() ? packageId : name; return m; } @@ -37,7 +37,7 @@ sort::SortSettings settings() { return s; } -QHash as_listed(const std::vector>& mods) { +QHash asListed(const std::vector>& mods) { QHash result; for (std::size_t i = 0; i < mods.size(); ++i) { result.insert(QString::number(i), mods[i]); @@ -45,7 +45,7 @@ QHash as_listed(const std::vector& mods, const QString& name) { +QString uuidFor(const QHash& mods, const QString& name) { for (auto it = mods.cbegin(); it != mods.cend(); ++it) { if (it.value()->name == name) { return it.key(); @@ -54,7 +54,7 @@ QString uuid_for(const QHash& mods, const QString& name) return {}; } -std::vector> default_checkers() { +std::vector> defaultCheckers() { std::vector> checkers; checkers.push_back(std::make_unique()); checkers.push_back(std::make_unique()); @@ -66,40 +66,40 @@ std::vector> default_checkers() { } // namespace TEST_CASE("ModChecker basic rebuild") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); DependencyMod dep; - dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.c")); - a->about_rules.dependencies.append(dep); + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.c")); + a->aboutRules.dependencies.append(dep); - const auto mods = as_listed({a, b}); - const QStringList active_uuids{ - uuid_for(mods, QStringLiteral("mod.a")), - uuid_for(mods, QStringLiteral("mod.b")), + const auto mods = asListed({a, b}); + const QStringList activeUuids{ + uuidFor(mods, QStringLiteral("mod.a")), + uuidFor(mods, QStringLiteral("mod.b")), }; DiagnosticsByUuid collected; - auto on_change = [&](const DiagnosticsByUuid& diags) { + auto onChange = [&](const DiagnosticsByUuid& diags) { for (auto it = diags.cbegin(); it != diags.cend(); ++it) { collected.insert(it.key(), it.value()); } }; - ModChecker checker(default_checkers(), settings(), QStringLiteral("1.5"), on_change); - checker.rebuild(mods, active_uuids); - - const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); - const auto* diag_a = checker.diagnostics_for(a_uuid); - REQUIRE(diag_a != nullptr); - CHECK(diag_a->has_errors()); - REQUIRE(diag_a->errors.size() == 1); - CHECK(diag_a->errors[0].category == QStringLiteral("missing_dependency")); - - const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); - const auto* diag_b = checker.diagnostics_for(b_uuid); - REQUIRE(diag_b != nullptr); - CHECK(!diag_b->has_errors()); - CHECK(!diag_b->has_warnings()); + ModChecker checker(defaultCheckers(), settings(), QStringLiteral("1.5"), onChange); + checker.rebuild(mods, activeUuids); + + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const auto* diagA = checker.diagnosticsFor(aUuid); + REQUIRE(diagA != nullptr); + CHECK(diagA->hasErrors()); + REQUIRE(diagA->errors.size() == 1); + CHECK(diagA->errors[0].category == QStringLiteral("missing_dependency")); + + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + const auto* diagB = checker.diagnosticsFor(bUuid); + REQUIRE(diagB != nullptr); + CHECK(!diagB->hasErrors()); + CHECK(!diagB->hasWarnings()); } TEST_CASE("ModChecker toggle mod rebuilds diagnostics and constraints") { @@ -107,81 +107,81 @@ TEST_CASE("ModChecker toggle mod rebuilds diagnostics and constraints") { checkers.push_back(std::make_unique()); checkers.push_back(std::make_unique()); - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); DependencyMod dep; - dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->about_rules.dependencies.append(dep); - b->about_rules.load_after.add(QStringLiteral("mod.a")); + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); - const auto mods = as_listed({a, b}); - const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); - const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); + const auto mods = asListed({a, b}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); ModChecker checker(std::move(checkers), settings()); - checker.rebuild(mods, {a_uuid, b_uuid}); + checker.rebuild(mods, {aUuid, bUuid}); - checker.toggle_mod(*mods[b_uuid], {a_uuid}); - const auto* diag_disabled = checker.diagnostics_for(a_uuid); - REQUIRE(diag_disabled != nullptr); - CHECK(diag_disabled->has_errors()); - CHECK(diag_disabled->errors[0].category == QStringLiteral("missing_dependency")); + checker.toggleMod(*mods[bUuid], {aUuid}); + const auto* diagDisabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagDisabled != nullptr); + CHECK(diagDisabled->hasErrors()); + CHECK(diagDisabled->errors[0].category == QStringLiteral("missing_dependency")); - checker.toggle_mod(*mods[b_uuid], {a_uuid, b_uuid}); - const auto* diag_enabled = checker.diagnostics_for(a_uuid); - REQUIRE(diag_enabled != nullptr); - CHECK(!diag_enabled->has_errors()); + checker.toggleMod(*mods[bUuid], {aUuid, bUuid}); + const auto* diagEnabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagEnabled != nullptr); + CHECK(!diagEnabled->hasErrors()); - QSet dependency_sources; + QSet dependencySources; for (const auto& edge : - checker.graph().incoming_of_type(PackageId(QStringLiteral("mod.b")), EdgeType::Dependency)) { - dependency_sources.insert(edge.source); + checker.graph().incomingOfType(PackageId(QStringLiteral("mod.b")), EdgeType::kDependency)) { + dependencySources.insert(edge.source); } - CHECK(dependency_sources == QSet{PackageId(QStringLiteral("mod.a"))}); + CHECK(dependencySources == QSet{PackageId(QStringLiteral("mod.a"))}); - QSet load_after_targets; + QSet loadAfterTargets; for (const auto& edge : - checker.graph().edges_of_type(PackageId(QStringLiteral("mod.b")), EdgeType::LoadAfter)) { - load_after_targets.insert(edge.target); + checker.graph().edgesOfType(PackageId(QStringLiteral("mod.b")), EdgeType::kLoadAfter)) { + loadAfterTargets.insert(edge.target); } - CHECK(load_after_targets == QSet{PackageId(QStringLiteral("mod.a"))}); + CHECK(loadAfterTargets == QSet{PackageId(QStringLiteral("mod.a"))}); } TEST_CASE("ModChecker move mod updates load order") { std::vector> checkers; checkers.push_back(std::make_unique()); - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); // b first, then a -> satisfies load_after - const auto mods = as_listed({b, a}); - const QString a_uuid = uuid_for(mods, QStringLiteral("mod.a")); - const QString b_uuid = uuid_for(mods, QStringLiteral("mod.b")); + const auto mods = asListed({b, a}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); ModChecker checker(std::move(checkers), settings()); - checker.rebuild(mods, {b_uuid, a_uuid}); + checker.rebuild(mods, {bUuid, aUuid}); - const auto* initial = checker.diagnostics_for(a_uuid); - CHECK((initial == nullptr || !initial->has_warnings())); + const auto* initial = checker.diagnosticsFor(aUuid); + CHECK((initial == nullptr || !initial->hasWarnings())); // Move a before b (old_index 1 -> new_index 0) - checker.move_mod(a_uuid, 1, 0); + checker.moveMod(aUuid, 1, 0); - const auto* after_move = checker.diagnostics_for(a_uuid); - REQUIRE(after_move != nullptr); - CHECK(after_move->has_warnings()); - CHECK(after_move->warnings[0].category == QStringLiteral("load_order")); + const auto* afterMove = checker.diagnosticsFor(aUuid); + REQUIRE(afterMove != nullptr); + CHECK(afterMove->hasWarnings()); + CHECK(afterMove->warnings[0].category == QStringLiteral("load_order")); } TEST_CASE("ModChecker reorder matches full rebuild") { std::mt19937 rng(0); constexpr int kModCount = 12; - std::vector> mods_list; - mods_list.reserve(kModCount); + std::vector> modsList; + modsList.reserve(kModCount); for (int i = 0; i < kModCount; ++i) { - mods_list.push_back(make_mod(QStringLiteral("mod.%1").arg(i))); + modsList.push_back(makeMod(QStringLiteral("mod.%1").arg(i))); } for (int i = 0; i < kModCount; ++i) { @@ -192,18 +192,18 @@ TEST_CASE("ModChecker reorder matches full rebuild") { } } std::ranges::shuffle(others, rng); - mods_list[i]->about_rules.load_after.add(QStringLiteral("mod.%1").arg(others[0])); - mods_list[i]->about_rules.load_after.add(QStringLiteral("mod.%1").arg(others[1])); - mods_list[i]->about_rules.load_before.add(QStringLiteral("mod.%1").arg(others[2])); + modsList[i]->aboutRules.loadAfter.add(QStringLiteral("mod.%1").arg(others[0])); + modsList[i]->aboutRules.loadAfter.add(QStringLiteral("mod.%1").arg(others[1])); + modsList[i]->aboutRules.loadBefore.add(QStringLiteral("mod.%1").arg(others[2])); } - const auto mods = as_listed(mods_list); + const auto mods = asListed(modsList); QStringList order; for (int i = 0; i < kModCount; ++i) { order.append(QString::number(i)); } - ModChecker incremental(default_checkers(), settings()); + ModChecker incremental(defaultCheckers(), settings()); incremental.rebuild(mods, order); for (int step = 0; step < 30; ++step) { @@ -220,12 +220,12 @@ TEST_CASE("ModChecker reorder matches full rebuild") { } incremental.reorder(order); - ModChecker full(default_checkers(), settings()); + ModChecker full(defaultCheckers(), settings()); full.rebuild(mods, order); for (const QString& uuid : order) { - const auto* got = incremental.diagnostics_for(uuid); - const auto* want = full.diagnostics_for(uuid); + const auto* got = incremental.diagnosticsFor(uuid); + const auto* want = full.diagnosticsFor(uuid); REQUIRE(got != nullptr); REQUIRE(want != nullptr); diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp index d6cc154..8fc7388 100644 --- a/tests/core/checker/test_databases.cpp +++ b/tests/core/checker/test_databases.cpp @@ -20,7 +20,7 @@ using namespace px::modrim::checker; namespace { -std::string gzip_compress(std::string_view data) { +std::string gzipCompress(std::string_view data) { z_stream stream{}; deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY); stream.next_in = reinterpret_cast(const_cast(data.data())); @@ -39,7 +39,7 @@ std::string gzip_compress(std::string_view data) { return out; } -void write_file(const QString& path, std::string_view content) { +void writeFile(const QString& path, std::string_view content) { QFile file(path); REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); file.write(content.data(), static_cast(content.size())); @@ -53,14 +53,14 @@ TEST_CASE("NoVersionWarningService load_if_exists on missing file returns empty" QNetworkAccessManager network; NoVersionWarningService service(config, network); - CHECK(!service.cache_age_s().has_value()); - CHECK(service.load_if_exists().isEmpty()); + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); } TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { QTemporaryDir dir; - const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - write_file(xml_path, + const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "" "
  • brrainz.harmony
  • " "
  • unlimitedhugs.hugslib
  • " @@ -70,10 +70,10 @@ TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { QNetworkAccessManager network; NoVersionWarningService service(config, network); - CHECK(service.cache_age_s().has_value()); - CHECK(*service.cache_age_s() >= 0.0); + CHECK(service.cacheAgeS().has_value()); + CHECK(*service.cacheAgeS() >= 0.0); - const auto pids = service.load_if_exists(); + const auto pids = service.loadIfExists(); CHECK(pids.size() == 2); CHECK(pids.contains(PackageId(QStringLiteral("brrainz.harmony")))); CHECK(pids.contains(PackageId(QStringLiteral("unlimitedhugs.hugslib")))); @@ -81,28 +81,28 @@ TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { TEST_CASE("NoVersionWarningService parses single-item li") { QTemporaryDir dir; - const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - write_file(xml_path, "
  • lone.mod
  • "); + const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "
  • lone.mod
  • "); ConfigService config(dir.path()); QNetworkAccessManager network; NoVersionWarningService service(config, network); - const auto pids = service.load_if_exists(); + const auto pids = service.loadIfExists(); CHECK(pids.size() == 1); CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); } TEST_CASE("NoVersionWarningService handles malformed XML gracefully") { QTemporaryDir dir; - const QString xml_path = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - write_file(xml_path, "not xml at all"); + const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "not xml at all"); ConfigService config(dir.path()); QNetworkAccessManager network; NoVersionWarningService service(config, network); - CHECK(service.load_if_exists().isEmpty()); + CHECK(service.loadIfExists().isEmpty()); } TEST_CASE("UseThisInsteadService load_if_exists on missing file returns empty") { @@ -111,13 +111,13 @@ TEST_CASE("UseThisInsteadService load_if_exists on missing file returns empty") QNetworkAccessManager network; UseThisInsteadService service(config, network); - CHECK(!service.cache_age_s().has_value()); - CHECK(service.load_if_exists().isEmpty()); + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); } TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { QTemporaryDir dir; - const QString gz_path = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + const QString gzPath = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); const std::string json = R"({ "rules": [ { @@ -130,14 +130,14 @@ TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { } ] })"; - write_file(gz_path, gzip_compress(json)); + writeFile(gzPath, gzipCompress(json)); ConfigService config(dir.path()); QNetworkAccessManager network; UseThisInsteadService service(config, network); - CHECK(service.cache_age_s().has_value()); - const auto entries = service.load_if_exists(); + CHECK(service.cacheAgeS().has_value()); + const auto entries = service.loadIfExists(); REQUIRE(entries.size() == 1); REQUIRE(entries.contains(QStringLiteral("123456"))); @@ -152,12 +152,12 @@ TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { TEST_CASE("UseThisInsteadService handles corrupt gzip gracefully") { QTemporaryDir dir; - const QString gz_path = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); - write_file(gz_path, "not gzip data"); + const QString gzPath = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + writeFile(gzPath, "not gzip data"); ConfigService config(dir.path()); QNetworkAccessManager network; UseThisInsteadService service(config, network); - CHECK(service.load_if_exists().isEmpty()); + CHECK(service.loadIfExists().isEmpty()); } diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp index 8e77814..560e4ec 100644 --- a/tests/core/checker/test_graph.cpp +++ b/tests/core/checker/test_graph.cpp @@ -16,17 +16,17 @@ using namespace px::modrim::checker; namespace { -std::shared_ptr make_mod(const QString& package_id) { +std::shared_ptr makeMod(const QString& packageId) { auto m = std::make_shared(); - m->package_id = CaseInsensitiveStr(package_id); - m->name = package_id; + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; return m; } -sort::SortSettings settings(bool use_alternative_package_ids = false) { +sort::SortSettings settings(bool useAlternativePackageIds = false) { sort::SortSettings s; s.use_community_rules = false; - s.use_alternative_package_ids = use_alternative_package_ids; + s.use_alternative_package_ids = useAlternativePackageIds; return s; } @@ -39,128 +39,128 @@ TEST_CASE("ConstraintGraph empty graph") { TEST_CASE("ConstraintGraph build no edges") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.has_node(PackageId(QStringLiteral("mod.a")))); - CHECK(g.has_node(PackageId(QStringLiteral("mod.b")))); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.a")))); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); CHECK(g.nodes().size() == 2); - CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 0); - CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 1); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); } TEST_CASE("ConstraintGraph dependency edge") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); DependencyMod dep; - dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->about_rules.dependencies.append(dep); + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::Dependency); + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kDependency); REQUIRE(edges.size() == 1); const ConstraintEdge edge = *edges.cbegin(); CHECK(edge.source == PackageId(QStringLiteral("mod.a"))); CHECK(edge.target == PackageId(QStringLiteral("mod.b"))); - CHECK(edge.type == EdgeType::Dependency); - CHECK(edge.origin == EdgeOrigin::AboutXml); + CHECK(edge.type == EdgeType::kDependency); + CHECK(edge.origin == EdgeOrigin::kAboutXml); } TEST_CASE("ConstraintGraph dependency alternative resolution respects setting") { ConstraintGraph g; - auto consumer = make_mod(QStringLiteral("mod.consumer")); - auto alternative = make_mod(QStringLiteral("mod.alternative")); + auto consumer = makeMod(QStringLiteral("mod.consumer")); + auto alternative = makeMod(QStringLiteral("mod.alternative")); DependencyMod dependency; - dependency.package_id = CaseInsensitiveStr(QStringLiteral("mod.dependency")); - dependency.alternative_package_ids.add(QStringLiteral("mod.alternative")); - consumer->about_rules.dependencies.append(dependency); + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.dependency")); + dependency.alternativePackageIds.add(QStringLiteral("mod.alternative")); + consumer->aboutRules.dependencies.append(dependency); QHash> mods{ {PackageId(QStringLiteral("mod.consumer")), consumer}, {PackageId(QStringLiteral("mod.alternative")), alternative}, }; - const QList ordered_pids{ + const QList orderedPids{ PackageId(QStringLiteral("mod.consumer")), PackageId(QStringLiteral("mod.alternative")), }; - g.build(mods, ordered_pids, settings(true)); - QSet enabled_targets; - for (const auto& edge : g.edges_of_type(PackageId(QStringLiteral("mod.consumer")), EdgeType::Dependency)) { - enabled_targets.insert(edge.target); + g.build(mods, orderedPids, settings(true)); + QSet enabledTargets; + for (const auto& edge : g.edgesOfType(PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + enabledTargets.insert(edge.target); } - CHECK(enabled_targets == QSet{PackageId(QStringLiteral("mod.alternative"))}); + CHECK(enabledTargets == QSet{PackageId(QStringLiteral("mod.alternative"))}); - g.build(mods, ordered_pids, settings(false)); - QSet disabled_targets; - for (const auto& edge : g.edges_of_type(PackageId(QStringLiteral("mod.consumer")), EdgeType::Dependency)) { - disabled_targets.insert(edge.target); + g.build(mods, orderedPids, settings(false)); + QSet disabledTargets; + for (const auto& edge : g.edgesOfType(PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + disabledTargets.insert(edge.target); } - CHECK(disabled_targets == QSet{PackageId(QStringLiteral("mod.dependency"))}); + CHECK(disabledTargets == QSet{PackageId(QStringLiteral("mod.dependency"))}); } TEST_CASE("ConstraintGraph incompatibility edge") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::Incompatibility); + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kIncompatibility); REQUIRE(edges.size() == 1); CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph load after edge") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::LoadAfter); + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadAfter); REQUIRE(edges.size() == 1); CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph load before edge") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_before.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto edges = g.edges_of_type(PackageId(QStringLiteral("mod.a")), EdgeType::LoadBefore); + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadBefore); REQUIRE(edges.size() == 1); CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph neighbors") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, @@ -169,15 +169,15 @@ TEST_CASE("ConstraintGraph neighbors") { auto nbrs = g.neighbors(PackageId(QStringLiteral("mod.a"))); CHECK(nbrs.contains(PackageId(QStringLiteral("mod.b")))); - auto nbrs_b = g.neighbors(PackageId(QStringLiteral("mod.b"))); - CHECK(nbrs_b.contains(PackageId(QStringLiteral("mod.a")))); + auto nbrsB = g.neighbors(PackageId(QStringLiteral("mod.b"))); + CHECK(nbrsB.contains(PackageId(QStringLiteral("mod.a")))); } TEST_CASE("ConstraintGraph incoming") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, @@ -191,46 +191,46 @@ TEST_CASE("ConstraintGraph incoming") { TEST_CASE("ConstraintGraph remove mod") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.has_node(PackageId(QStringLiteral("mod.b")))); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); - g.remove_mod(PackageId(QStringLiteral("mod.b"))); - CHECK(!g.has_node(PackageId(QStringLiteral("mod.b")))); + g.removeMod(PackageId(QStringLiteral("mod.b"))); + CHECK(!g.hasNode(PackageId(QStringLiteral("mod.b")))); CHECK(g.outgoing(PackageId(QStringLiteral("mod.a"))).isEmpty()); } TEST_CASE("ConstraintGraph update order") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 0); - CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 1); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); - g.update_order({PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); - CHECK(g.index_of(PackageId(QStringLiteral("mod.a"))) == 1); - CHECK(g.index_of(PackageId(QStringLiteral("mod.b"))) == 0); + g.updateOrder({PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 1); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 0); } TEST_CASE("ConstraintGraph cycle detection") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto c = make_mod(QStringLiteral("mod.c")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); - b->about_rules.load_after.add(QStringLiteral("mod.c")); - c->about_rules.load_after.add(QStringLiteral("mod.a")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.a")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, @@ -242,28 +242,28 @@ TEST_CASE("ConstraintGraph cycle detection") { PackageId(QStringLiteral("mod.c")), }; g.build(mods, order, settings()); - auto cycles = g.find_cycles(); + auto cycles = g.findCycles(); REQUIRE(cycles.size() >= 1); - QSet cycle_pids; + QSet cyclePids; for (const auto& cycle : cycles) { for (const auto& p : cycle) { - cycle_pids.insert(p); + cyclePids.insert(p); } } for (const auto& name : {QStringLiteral("mod.a"), QStringLiteral("mod.b"), QStringLiteral("mod.c")}) { - CHECK(cycle_pids.contains(PackageId(name))); + CHECK(cyclePids.contains(PackageId(name))); } } TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto c = make_mod(QStringLiteral("mod.c")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); - b->about_rules.load_after.add(QStringLiteral("mod.a")); - b->about_rules.load_after.add(QStringLiteral("mod.c")); - c->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, @@ -276,7 +276,7 @@ TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { }; g.build(mods, order, settings()); - auto cycles = g.find_cycles(); + auto cycles = g.findCycles(); REQUIRE(cycles.size() == 1); CHECK(QSet(cycles[0].cbegin(), cycles[0].cend()) == QSet(order.cbegin(), order.cend())); @@ -284,15 +284,15 @@ TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { TEST_CASE("ConstraintGraph no cycle") { ConstraintGraph g; - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); QHash> mods{ {PackageId(QStringLiteral("mod.a")), a}, {PackageId(QStringLiteral("mod.b")), b}, }; g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto cycles = g.find_cycles(); + auto cycles = g.findCycles(); CHECK(cycles.isEmpty()); } @@ -304,12 +304,12 @@ TEST_CASE("ConstraintGraph cycle detection handles long chain") { for (int i = 0; i < kCount; ++i) { PackageId pid(QStringLiteral("mod.%1").arg(i)); nodes.append(pid); - mods.insert(pid, make_mod(pid.str())); + mods.insert(pid, makeMod(pid.str())); } for (int i = 0; i < kCount - 1; ++i) { - mods[nodes[i]]->about_rules.load_after.add(nodes[i + 1].str()); + mods[nodes[i]]->aboutRules.loadAfter.add(nodes[i + 1].str()); } ConstraintGraph g; g.build(mods, nodes, settings()); - CHECK(g.find_cycles().isEmpty()); + CHECK(g.findCycles().isEmpty()); } diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp index d68d5ee..0cc8f95 100644 --- a/tests/core/checker/test_issues.cpp +++ b/tests/core/checker/test_issues.cpp @@ -19,14 +19,14 @@ using namespace px::modrim::checker; namespace { -std::shared_ptr make_mod(const QString& package_id) { +std::shared_ptr makeMod(const QString& packageId) { auto m = std::make_shared(); - m->package_id = CaseInsensitiveStr(package_id); - m->name = package_id; + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; return m; } -sort::SortSettings default_settings() { +sort::SortSettings defaultSettings() { sort::SortSettings s; s.use_community_rules = false; s.use_alternative_package_ids = true; @@ -36,126 +36,126 @@ sort::SortSettings default_settings() { // Holds the storage referenced by a CheckContext so tests can construct one simply. struct ContextStorage { - QHash> active_mods; - QList ordered_pids; - QHash pid_to_index; + QHash> activeMods; + QList orderedPids; + QHash pidToIndex; ConstraintGraph graph; - sort::SortSettings settings = default_settings(); - QString target_version = QStringLiteral("1.5"); - QSet no_version_warning; - QHash use_this_instead; + sort::SortSettings settings = defaultSettings(); + QString targetVersion = QStringLiteral("1.5"); + QSet noVersionWarning; + QHash useThisInstead; QList> cycles; CheckContext ctx() const { return CheckContext{ - .active_mods = active_mods, - .ordered_pids = ordered_pids, - .pid_to_index = pid_to_index, + .activeMods = activeMods, + .orderedPids = orderedPids, + .pidToIndex = pidToIndex, .graph = graph, .settings = settings, - .target_version = target_version, - .no_version_warning = no_version_warning, - .use_this_instead = use_this_instead, + .targetVersion = targetVersion, + .noVersionWarning = noVersionWarning, + .useThisInstead = useThisInstead, .cycles = cycles, }; } }; -ContextStorage make_storage(QHash> mods, +ContextStorage makeStorage(QHash> mods, std::optional> ordered = std::nullopt, const sort::SortSettings* settings = nullptr, - const sort::CommunityRules* community_rules = nullptr) { + const sort::CommunityRules* communityRules = nullptr) { ContextStorage storage; - storage.active_mods = std::move(mods); - storage.ordered_pids = ordered ? *ordered : storage.active_mods.keys(); + storage.activeMods = std::move(mods); + storage.orderedPids = ordered ? *ordered : storage.activeMods.keys(); if (settings != nullptr) { storage.settings = *settings; } - for (qsizetype i = 0; i < storage.ordered_pids.size(); ++i) { - storage.pid_to_index.insert(storage.ordered_pids[i], static_cast(i)); + for (qsizetype i = 0; i < storage.orderedPids.size(); ++i) { + storage.pidToIndex.insert(storage.orderedPids[i], static_cast(i)); } - storage.graph.build(storage.active_mods, storage.ordered_pids, storage.settings, community_rules); + storage.graph.build(storage.activeMods, storage.orderedPids, storage.settings, communityRules); return storage; } } // namespace TEST_CASE("DependencyIssueChecker no issues when mod has no dependencies") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); DependencyIssueChecker checker; - CHECK(!checker.should_check(*a, storage.ctx())); + CHECK(!checker.shouldCheck(*a, storage.ctx())); CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("DependencyIssueChecker reports missing dependency") { - auto a = make_mod(QStringLiteral("mod.a")); + auto a = makeMod(QStringLiteral("mod.a")); DependencyMod dep; - dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->about_rules.dependencies.append(dep); - auto storage = make_storage({{a->package_id, a}}); + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}}); DependencyIssueChecker checker; - CHECK(checker.should_check(*a, storage.ctx())); + CHECK(checker.shouldCheck(*a, storage.ctx())); auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("missing_dependency")); - CHECK(issues[0].severity == IssueSeverity::Error); + CHECK(issues[0].severity == IssueSeverity::kError); } TEST_CASE("DependencyIssueChecker satisfied dependency yields no issues") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); DependencyMod dep; - dep.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->about_rules.dependencies.append(dep); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); DependencyIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("IncompatibilityIssueChecker no incompatibilities") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); IncompatibilityIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("IncompatibilityIssueChecker self-declared incompatibility") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.incompatible_with.add(QStringLiteral("mod.b")); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); IncompatibilityIssueChecker checker; auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("incompatibility")); - CHECK(issues[0].severity == IssueSeverity::Error); + CHECK(issues[0].severity == IssueSeverity::kError); } TEST_CASE("IncompatibilityIssueChecker reverse-declared incompatibility") { - auto declarer = make_mod(QStringLiteral("mod.declarer")); - auto target = make_mod(QStringLiteral("mod.target")); - declarer->about_rules.incompatible_with.add(QStringLiteral("mod.target")); - auto storage = make_storage({{declarer->package_id, declarer}, {target->package_id, target}}); + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(QStringLiteral("mod.target")); + auto storage = makeStorage({{declarer->packageId, declarer}, {target->packageId, target}}); IncompatibilityIssueChecker checker; auto issues = checker.check(*target, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("incompatibility")); - CHECK(issues[0].related_package_ids == QList{PackageId(QStringLiteral("mod.declarer"))}); + CHECK(issues[0].relatedPackageIds == QList{PackageId(QStringLiteral("mod.declarer"))}); } TEST_CASE("IncompatibilityIssueChecker reverse-declared deduplicated across origins") { - auto declarer = make_mod(QStringLiteral("mod.declarer")); - auto target = make_mod(QStringLiteral("mod.target")); - const PackageId declarer_pid(QStringLiteral("mod.declarer")); - const PackageId target_pid(QStringLiteral("mod.target")); - declarer->about_rules.incompatible_with.add(target_pid.str()); + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + const PackageId declarerPid(QStringLiteral("mod.declarer")); + const PackageId targetPid(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(targetPid.str()); sort::SortSettings settings; settings.use_community_rules = true; @@ -163,92 +163,92 @@ TEST_CASE("IncompatibilityIssueChecker reverse-declared deduplicated across orig settings.check_missing_dependencies = true; sort::CommunityRule rule; - rule.package_id = declarer_pid; - rule.incompatible_with.add(target_pid.str()); - sort::CommunityRules cr{{declarer_pid, rule}}; + rule.packageId = declarerPid; + rule.incompatibleWith.add(targetPid.str()); + sort::CommunityRules cr{{declarerPid, rule}}; - auto storage = make_storage({{declarer_pid, declarer}, {target_pid, target}}, + auto storage = makeStorage({{declarerPid, declarer}, {targetPid, target}}, std::nullopt, &settings, &cr); - auto incoming_edges = storage.graph.incoming_of_type(target_pid, EdgeType::Incompatibility); + auto incomingEdges = storage.graph.incomingOfType(targetPid, EdgeType::kIncompatibility); QSet origins; - for (const auto& edge : incoming_edges) { + for (const auto& edge : incomingEdges) { origins.insert(edge.origin); } - CHECK(origins == QSet{EdgeOrigin::AboutXml, EdgeOrigin::CommunityRules}); + CHECK(origins == QSet{EdgeOrigin::kAboutXml, EdgeOrigin::kCommunityRules}); auto issues = IncompatibilityIssueChecker().check(*target, storage.ctx()); REQUIRE(issues.size() == 1); - CHECK(issues[0].related_package_ids == QList{declarer_pid}); + CHECK(issues[0].relatedPackageIds == QList{declarerPid}); } TEST_CASE("LoadOrderIssueChecker no violations when order satisfies rules") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); LoadOrderIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("LoadOrderIssueChecker load-after violation reported") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_after.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); const QList ordered{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}; - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); LoadOrderIssueChecker checker; auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("load_order")); - CHECK(issues[0].severity == IssueSeverity::Warning); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("LoadOrderIssueChecker load-before violation reported") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - a->about_rules.load_before.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); // A should load before B, but appears after const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}, ordered); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); LoadOrderIssueChecker checker; auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("load_order")); - CHECK(issues[0].severity == IssueSeverity::Warning); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("CycleIssueChecker no cycles") { - auto a = make_mod(QStringLiteral("mod.a")); - auto storage = make_storage({{a->package_id, a}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); CycleIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("CycleIssueChecker reports cycle membership") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; CycleIssueChecker checker; auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("cycle")); - CHECK(issues[0].severity == IssueSeverity::Warning); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("CycleIssueChecker non-member gets no issue") { - auto a = make_mod(QStringLiteral("mod.a")); - auto b = make_mod(QStringLiteral("mod.b")); - auto c = make_mod(QStringLiteral("mod.c")); - auto storage = make_storage({{a->package_id, a}, {b->package_id, b}, {c->package_id, c}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}, {c->packageId, c}}); storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; CycleIssueChecker checker; @@ -256,52 +256,52 @@ TEST_CASE("CycleIssueChecker non-member gets no issue") { } TEST_CASE("GameVersionIssueChecker no versions declared is not checked") { - auto a = make_mod(QStringLiteral("mod.a")); - a->supported_versions.clear(); - auto storage = make_storage({{a->package_id, a}}); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions.clear(); + auto storage = makeStorage({{a->packageId, a}}); GameVersionIssueChecker checker; - CHECK(!checker.should_check(*a, storage.ctx())); + CHECK(!checker.shouldCheck(*a, storage.ctx())); CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("GameVersionIssueChecker matching version yields no issues") { - auto a = make_mod(QStringLiteral("mod.a")); - a->supported_versions = {QStringLiteral("1.5")}; - auto storage = make_storage({{a->package_id, a}}); - storage.target_version = QStringLiteral("1.5"); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.5")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); GameVersionIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("GameVersionIssueChecker mismatch reported as warning") { - auto a = make_mod(QStringLiteral("mod.a")); - a->supported_versions = {QStringLiteral("1.4")}; - auto storage = make_storage({{a->package_id, a}}); - storage.target_version = QStringLiteral("1.5"); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); GameVersionIssueChecker checker; auto issues = checker.check(*a, storage.ctx()); REQUIRE(issues.size() == 1); CHECK(issues[0].category == QStringLiteral("version_mismatch")); - CHECK(issues[0].severity == IssueSeverity::Warning); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("GameVersionIssueChecker suppressed by no_version_warning") { - auto a = make_mod(QStringLiteral("mod.a")); - a->supported_versions = {QStringLiteral("1.4")}; - auto storage = make_storage({{a->package_id, a}}); - storage.target_version = QStringLiteral("1.5"); - storage.no_version_warning.insert(PackageId(QStringLiteral("mod.a"))); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + storage.noVersionWarning.insert(PackageId(QStringLiteral("mod.a"))); GameVersionIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("ReplacementIssueChecker no replacement without published file id") { - auto a = make_mod(QStringLiteral("mod.a")); - auto storage = make_storage({{a->package_id, a}}); + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); ReplacementIssueChecker checker; CHECK(checker.check(*a, storage.ctx()).isEmpty()); } diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp index 55660cb..1111ea0 100644 --- a/tests/core/downloads/test_manager.cpp +++ b/tests/core/downloads/test_manager.cpp @@ -26,20 +26,20 @@ using px::modrim::DownloadResult; using px::modrim::ListedMod; using px::modrim::ListedModPtr; using px::modrim::Result; -using test_support::run_sync; +using test_support::runSync; // Claims ids that start with `prefix`, and records each batch it was asked to download. class FakeDownloader : public Downloader { public: FakeDownloader(QString name, QString label, QString prefix, DownloadManager& manager) - : Downloader(std::move(name), std::move(label), manager), prefix_(std::move(prefix)) {} + : Downloader(std::move(name), std::move(label), manager), m_prefix(std::move(prefix)) {} - bool is_downloading() const override { return busy_; } - QSet active_ids() const override { return {}; } - bool accepts(const QString& mod_id) const override { return mod_id.startsWith(prefix_); } + bool isDownloading() const override { return m_busy; } + QSet activeIds() const override { return {}; } + bool accepts(const QString& modId) const override { return modId.startsWith(m_prefix); } - std::optional updatable_id(const ListedMod& mod) const override { - const auto& path = mod.mod_path(); + std::optional updatableId(const ListedMod& mod) const override { + const auto& path = mod.modPath(); if (!path) { return std::nullopt; } @@ -47,18 +47,18 @@ class FakeDownloader : public Downloader { return accepts(id) ? std::optional(id) : std::nullopt; } - std::optional last_synced(const ListedMod& mod) const override { - return updatable_id(mod) ? std::optional(1.0) : std::nullopt; + std::optional lastSynced(const ListedMod& mod) const override { + return updatableId(mod) ? std::optional(1.0) : std::nullopt; } - QCoro::Task> download_mods(QStringList mod_ids) override { - calls.push_back(mod_ids); - busy_ = true; - busy_changed.notify(true); - busy_ = false; - busy_changed.notify(false); - DownloadResult result{.succeeded = mod_ids, .failed = {}, .changed = {}}; - download_finished.notify(result); + QCoro::Task> downloadMods(QStringList modIds) override { + calls.push_back(modIds); + m_busy = true; + busyChanged.notify(true); + m_busy = false; + busyChanged.notify(false); + DownloadResult result{.succeeded = modIds, .failed = {}, .changed = {}}; + downloadFinished.notify(result); co_return result; } @@ -68,8 +68,8 @@ class FakeDownloader : public Downloader { bool cancelled = false; private: - QString prefix_; - bool busy_ = false; + QString m_prefix; + bool m_busy = false; }; class FakeSteam : public FakeDownloader { @@ -82,9 +82,9 @@ class FakeGithub : public FakeDownloader { explicit FakeGithub(DownloadManager& manager) : FakeDownloader("github", "Github", "g", manager) {} }; -ListedModPtr make_mod(const QString& folder) { +ListedModPtr makeMod(const QString& folder) { auto mod = std::make_shared(); - REQUIRE(mod->set_mod_path(QStringLiteral("/mods/%1").arg(folder)).has_value()); + REQUIRE(mod->setModPath(QStringLiteral("/mods/%1").arg(folder)).has_value()); return mod; } @@ -93,18 +93,18 @@ ListedModPtr make_mod(const QString& folder) { TEST_CASE("empty manager offers nothing", "[downloads][manager]") { DownloadManager manager; REQUIRE_FALSE(manager.available()); - REQUIRE(manager.updatable_ids({make_mod("s1")}).isEmpty()); - REQUIRE(manager.source_of(*make_mod("s1")) == nullptr); + REQUIRE(manager.updatableIds({makeMod("s1")}).isEmpty()); + REQUIRE(manager.sourceOf(*makeMod("s1")) == nullptr); } TEST_CASE("download routes ids to the accepting downloader", "[downloads][manager]") { DownloadManager manager; FakeSteam steam(manager); FakeGithub github(manager); - manager.register_downloader(steam); - manager.register_downloader(github); + manager.registerDownloader(steam); + manager.registerDownloader(github); - auto result = run_sync(manager.download_mods({"s1", "g1", "s2", "s1"})); + auto result = runSync(manager.downloadMods({"s1", "g1", "s2", "s1"})); REQUIRE(result.has_value()); REQUIRE(steam.calls == std::vector{{"s1", "s2"}}); @@ -117,9 +117,9 @@ TEST_CASE("download routes ids to the accepting downloader", "[downloads][manage TEST_CASE("unclaimed id is rejected before anything starts", "[downloads][manager]") { DownloadManager manager; FakeSteam steam(manager); - manager.register_downloader(steam); + manager.registerDownloader(steam); - auto result = run_sync(manager.download_mods({"s1", "x1"})); + auto result = runSync(manager.downloadMods({"s1", "x1"})); REQUIRE_FALSE(result.has_value()); REQUIRE(result.error().message.contains("x1")); @@ -130,30 +130,30 @@ TEST_CASE("source and ids come from the owning downloader", "[downloads][manager DownloadManager manager; FakeSteam steam(manager); FakeGithub github(manager); - manager.register_downloader(steam); - manager.register_downloader(github); - const auto g1 = make_mod("g1"); - const auto other = make_mod("other"); - const QList mods{make_mod("s1"), g1, other}; + manager.registerDownloader(steam); + manager.registerDownloader(github); + const auto g1 = makeMod("g1"); + const auto other = makeMod("other"); + const QList mods{makeMod("s1"), g1, other}; - const Downloader* source = manager.source_of(*g1); + const Downloader* source = manager.sourceOf(*g1); REQUIRE(source != nullptr); REQUIRE(source->label() == "Github"); - REQUIRE(manager.updatable_ids(mods) == QStringList{"s1", "g1"}); - REQUIRE_FALSE(manager.last_synced(*other).has_value()); + REQUIRE(manager.updatableIds(mods) == QStringList{"s1", "g1"}); + REQUIRE_FALSE(manager.lastSynced(*other).has_value()); } TEST_CASE("busy reflects any downloader and cancel stops the rest", "[downloads][manager]") { DownloadManager manager; FakeSteam steam(manager); FakeGithub github(manager); - manager.register_downloader(steam); - manager.register_downloader(github); + manager.registerDownloader(steam); + manager.registerDownloader(github); std::vector busy; - manager.busy_changed.connect([&busy](const bool& value) { busy.push_back(value); }); + manager.busyChanged.connect([&busy](const bool& value) { busy.push_back(value); }); - steam.download_finished.connect([&manager](const DownloadResult&) { manager.cancel(); }); - auto result = run_sync(manager.download_mods({"s1", "g1"})); + steam.downloadFinished.connect([&manager](const DownloadResult&) { manager.cancel(); }); + auto result = runSync(manager.downloadMods({"s1", "g1"})); REQUIRE(result.has_value()); REQUIRE(github.calls.empty()); @@ -163,11 +163,11 @@ TEST_CASE("busy reflects any downloader and cancel stops the rest", "[downloads] TEST_CASE("names are keyed by source id, not workshop id", "[downloads][manager]") { DownloadManager manager; FakeGithub github(manager); - manager.register_downloader(github); - const auto mod = make_mod("g1"); + manager.registerDownloader(github); + const auto mod = makeMod("g1"); mod->name = QStringLiteral("Git Mod"); - const QHash names = manager.names_by_id({mod, make_mod("s1")}); + const QHash names = manager.namesById({mod, makeMod("s1")}); REQUIRE(names == QHash{{"g1", "Git Mod"}}); } diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp index aeb24ee..68a78f1 100644 --- a/tests/core/downloads/test_steam.cpp +++ b/tests/core/downloads/test_steam.cpp @@ -60,31 +60,31 @@ class Flag { public: void set() { { - std::lock_guard lock(mutex_); - value_ = true; + std::lock_guard lock(m_mutex); + m_value = true; } - cv_.notify_all(); + m_cv.notify_all(); } void clear() { - std::lock_guard lock(mutex_); - value_ = false; + std::lock_guard lock(m_mutex); + m_value = false; } - bool is_set() const { - std::lock_guard lock(mutex_); - return value_; + bool isSet() const { + std::lock_guard lock(m_mutex); + return m_value; } - bool wait_for(std::chrono::milliseconds timeout) { - std::unique_lock lock(mutex_); - return cv_.wait_for(lock, timeout, [this] { return value_; }); + bool waitFor(std::chrono::milliseconds timeout) { + std::unique_lock lock(m_mutex); + return m_cv.wait_for(lock, timeout, [this] { return m_value; }); } private: - mutable std::mutex mutex_; - std::condition_variable cv_; - bool value_ = false; + mutable std::mutex m_mutex; + std::condition_variable m_cv; + bool m_value = false; }; // Drives an event loop until `task` co_returns. Fails if `timeout` expires. @@ -95,7 +95,7 @@ QCoro::Task drive(QCoro::Task task, std::optional& out, QEventLoop& } template -T run_loop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { +T runLoop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { std::optional out; QEventLoop loop; QTimer timer; @@ -111,13 +111,13 @@ T run_loop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { return std::move(*out); } -inline QCoro::Task drive_void(QCoro::Task task, bool& done, QEventLoop& loop) { +inline QCoro::Task driveVoid(QCoro::Task task, bool& done, QEventLoop& loop) { co_await std::move(task); done = true; loop.quit(); } -inline void run_loop_void(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { +inline void runLoopVoid(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { bool done = false; QEventLoop loop; QTimer timer; @@ -125,7 +125,7 @@ inline void run_loop_void(QCoro::Task task, std::chrono::milliseconds time QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); timer.start(timeout); - auto driver = drive_void(std::move(task), done, loop); + auto driver = driveVoid(std::move(task), done, loop); if (!done) { loop.exec(); } @@ -142,7 +142,7 @@ class FakeClient : public WorkshopClient { QHash errors; QHash> gates; - QSet network_failures; + QSet networkFailures; std::shared_ptr blocked = std::make_shared(); std::vector calls; @@ -173,22 +173,22 @@ class FakeClient : public WorkshopClient { } if (auto gate = gates.value(id)) { blocked->set(); - gate->wait_for(5s); + gate->waitFor(5s); } const bool cancelled = options.stop.stop_requested(); - const QString error_text = cancelled ? QStringLiteral("cancelled") : errors.value(id); - if (error_text.isEmpty()) { + const QString errorText = cancelled ? QStringLiteral("cancelled") : errors.value(id); + if (errorText.isEmpty()) { std::error_code ec; std::filesystem::create_directories(root / std::to_string(id), ec); } pxsteamdl::ErrorKind kind = pxsteamdl::ErrorKind::kNone; - if (network_failures.contains(id)) { + if (networkFailures.contains(id)) { kind = pxsteamdl::ErrorKind::kNetwork; } else if (cancelled) { kind = pxsteamdl::ErrorKind::kCancelled; - } else if (!error_text.isEmpty()) { + } else if (!errorText.isEmpty()) { kind = pxsteamdl::ErrorKind::kOther; } @@ -196,7 +196,7 @@ class FakeClient : public WorkshopClient { .item_id = id, .title = "Title " + std::to_string(id), .path = root / std::to_string(id), - .error = error_text.toStdString(), + .error = errorText.toStdString(), .error_kind = kind, .downloaded_bytes = 0, .unpacked_bytes = 0, @@ -216,58 +216,58 @@ class DyingClient : public WorkshopClient { struct Service { explicit Service(std::deque> clients, bool local = true) - : config_dir(), - config(config_dir.path()), + : configDir(), + config(configDir.path()), manager(), - local_path(local ? config_dir.path() + "/Mods" : QString()), - clients_(std::move(clients)) { + localPath(local ? configDir.path() + "/Mods" : QString()), + clients(std::move(clients)) { SteamDownloaderDeps deps{ .manager = manager, .config = config, - .local_mods_path = [this] { return local_path; }, - .installed_mods = [this] { return mods; }, - .client_factory = [this](LoginOptions opts) { return login(std::move(opts)); }, + .localModsPath = [this] { return localPath; }, + .installedMods = [this] { return mods; }, + .clientFactory = [this](LoginOptions opts) { return login(std::move(opts)); }, }; steam = std::make_unique(std::move(deps)); - manager.register_downloader(*steam); + manager.registerDownloader(*steam); } QCoro::Task>> login(LoginOptions opts) { logins.push_back(opts); - if (clients_.empty()) { + if (clients.empty()) { co_return std::unexpected(px::modrim::Error{QStringLiteral("logon denied")}); } - auto client = clients_.front(); - clients_.pop_front(); + auto client = clients.front(); + clients.pop_front(); co_return client; } - QTemporaryDir config_dir; + QTemporaryDir configDir; ConfigService config; DownloadManager manager; - QString local_path; + QString localPath; QList mods; std::vector logins; - std::deque> clients_; + std::deque> clients; std::unique_ptr steam; }; -ListedModPtr make_mod_at(const QString& path, const QString& pfid = {}) { +ListedModPtr makeModAt(const QString& path, const QString& pfid = {}) { QDir().mkpath(path); if (!pfid.isEmpty()) { QDir().mkpath(path + "/About"); - test_support::write_bytes(path + "/About/PublishedFileId.txt", pfid.toStdString()); + test_support::writeBytes(path + "/About/PublishedFileId.txt", pfid.toStdString()); } auto mod = std::make_shared(); - REQUIRE(mod->set_mod_path(path).has_value()); + REQUIRE(mod->setModPath(path).has_value()); return mod; } -std::optional synced_of(Service& svc, const QString& pid) { +std::optional syncedOf(Service& svc, const QString& pid) { auto mod = std::make_shared(); - REQUIRE(mod->set_mod_path(svc.local_path + "/" + pid).has_value()); - return svc.steam->last_synced(*mod); + REQUIRE(mod->setModPath(svc.localPath + "/" + pid).has_value()); + return svc.steam->lastSynced(*mod); } } // namespace @@ -277,7 +277,7 @@ TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client, client}); - REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); svc.steam->settings().update(SteamSettings{ .auto_update_hours = 0, .parallel_items = 8, @@ -286,11 +286,11 @@ TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { .connect_timeout = 3, .stall_timeout = 7, }); - REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); const std::vector expected{ - LoginOptions{.proxy = std::nullopt, .connect_timeout = 10, .stall_timeout = 30}, - LoginOptions{.proxy = "socks5h://host:1080", .connect_timeout = 3, .stall_timeout = 7}, + LoginOptions{.proxy = std::nullopt, .connectTimeout = 10, .stallTimeout = 30}, + LoginOptions{.proxy = "socks5h://host:1080", .connectTimeout = 3, .stallTimeout = 7}, }; REQUIRE(svc.logins == expected); } @@ -298,11 +298,11 @@ TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { TEST_CASE("network failure triggers new login", "[downloads][steam]") { TestApplication app; auto client = std::make_shared(); - client->network_failures.insert(111); + client->networkFailures.insert(111); Service svc({client, client}); - REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); - REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); REQUIRE(svc.logins.size() == 2); } @@ -315,39 +315,39 @@ TEST_CASE("downloads into local mods and splits results", "[downloads][steam]") std::vector statuses; std::vector finished; - svc.steam->download_item_status_changed.connect([&](const auto& s) { statuses.push_back(s); }); - svc.steam->download_finished.connect([&](const auto& r) { finished.push_back(r); }); + svc.steam->downloadItemStatusChanged.connect([&](const auto& s) { statuses.push_back(s); }); + svc.steam->downloadFinished.connect([&](const auto& r) { finished.push_back(r); }); - auto result = run_loop(svc.steam->download_mods({"111", "222", "111"})); + auto result = runLoop(svc.steam->downloadMods({"111", "222", "111"})); REQUIRE(result.has_value()); REQUIRE(result->succeeded == QStringList{"111"}); REQUIRE(result->failed == QStringList{"222"}); REQUIRE(finished.size() == 1); REQUIRE(finished[0] == *result); - REQUIRE(QFileInfo::exists(svc.local_path + "/111")); + REQUIRE(QFileInfo::exists(svc.localPath + "/111")); REQUIRE(client->calls.size() == 1); REQUIRE(client->calls[0].ids == std::vector{111, 222}); - REQUIRE(client->calls[0].root == svc.local_path); + REQUIRE(client->calls[0].root == svc.localPath); - QHash final_status; + QHash finalStatus; for (const auto& s : statuses) { if (s.status != "downloading") { - final_status.insert(s.mod_id, s); + finalStatus.insert(s.modId, s); } } - REQUIRE(final_status.value("111").status == "success"); - REQUIRE(final_status.value("222").status == "error"); - REQUIRE(final_status.value("222").error == "item not found"); + REQUIRE(finalStatus.value("111").status == "success"); + REQUIRE(finalStatus.value("222").status == "error"); + REQUIRE(finalStatus.value("222").error == "item not found"); } TEST_CASE("rejects invalid ids", "[downloads][steam]") { TestApplication app; - const std::vector bad_inputs{ + const std::vector badInputs{ {"12a"}, {"\u0661\u0662\u0663"}, {"1 2"}, {}, {"18446744073709551616"}}; - for (const auto& ids : bad_inputs) { + for (const auto& ids : badInputs) { Service svc({std::make_shared()}); - auto result = run_loop(svc.steam->download_mods(ids)); + auto result = runLoop(svc.steam->downloadMods(ids)); REQUIRE_FALSE(result.has_value()); REQUIRE(svc.logins.empty()); } @@ -357,7 +357,7 @@ TEST_CASE("requires local mods path", "[downloads][steam]") { TestApplication app; Service svc({std::make_shared()}, /*local=*/false); - auto result = run_loop(svc.steam->download_mods({"111"})); + auto result = runLoop(svc.steam->downloadMods({"111"})); REQUIRE_FALSE(result.has_value()); REQUIRE(result.error().message.contains("Local mods path")); @@ -369,7 +369,7 @@ TEST_CASE("accepts uint64 max id", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client}); - auto result = run_loop(svc.steam->download_mods({"18446744073709551615"})); + auto result = runLoop(svc.steam->downloadMods({"18446744073709551615"})); REQUIRE(result.has_value()); REQUIRE(result->succeeded == QStringList{"18446744073709551615"}); @@ -379,9 +379,9 @@ TEST_CASE("accepts uint64 max id", "[downloads][steam]") { TEST_CASE("unwritable local path fails every item", "[downloads][steam]") { TestApplication app; Service svc({std::make_shared()}); - test_support::write_bytes(svc.local_path, "not a directory"); + test_support::writeBytes(svc.localPath, "not a directory"); - auto result = run_loop(svc.steam->download_mods({"111", "222"})); + auto result = runLoop(svc.steam->downloadMods({"111", "222"})); REQUIRE(result.has_value()); QStringList failed = result->failed; @@ -394,10 +394,10 @@ TEST_CASE("login failure fails every item and retries next time", "[downloads][s TestApplication app; Service svc({}); std::vector messages; - svc.steam->status_message_changed.connect([&](const QString& m) { messages.push_back(m); }); + svc.steam->statusMessageChanged.connect([&](const QString& m) { messages.push_back(m); }); - auto first = run_loop(svc.steam->download_mods({"111", "222"})); - auto second = run_loop(svc.steam->download_mods({"111"})); + auto first = runLoop(svc.steam->downloadMods({"111", "222"})); + auto second = runLoop(svc.steam->downloadMods({"111"})); REQUIRE(first.has_value()); REQUIRE(first->succeeded.isEmpty()); @@ -405,8 +405,8 @@ TEST_CASE("login failure fails every item and retries next time", "[downloads][s failed.sort(); REQUIRE(failed == QStringList{"111", "222"}); - const bool has_denied = std::ranges::any_of(messages, [](const auto& m) { return m.contains("logon denied"); }); - REQUIRE(has_denied); + const bool hasDenied = std::ranges::any_of(messages, [](const auto& m) { return m.contains("logon denied"); }); + REQUIRE(hasDenied); REQUIRE(svc.logins.size() == 2); } @@ -415,8 +415,8 @@ TEST_CASE("client session is reused", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client}); - REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); - REQUIRE(run_loop(svc.steam->download_mods({"222"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); REQUIRE(svc.logins.size() == 1); } @@ -429,7 +429,7 @@ TEST_CASE("uses at most eight download workers", "[downloads][steam]") { ids.append(QString::number(i)); } - REQUIRE(run_loop(svc.steam->download_mods(ids)).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods(ids)).has_value()); REQUIRE(client->calls.size() == 1); REQUIRE(client->calls[0].workers <= 8); } @@ -443,7 +443,7 @@ TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { QTimer poll; QObject::connect(&poll, &QTimer::timeout, &poll, [&] { - if (client->blocked->is_set()) { + if (client->blocked->isSet()) { svc.steam->cancel(); gate->set(); poll.stop(); @@ -451,12 +451,12 @@ TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { }); poll.start(10ms); - auto result = run_loop(svc.steam->download_mods({"111", "222", "333"})); + auto result = runLoop(svc.steam->downloadMods({"111", "222", "333"})); REQUIRE(result.has_value()); REQUIRE(result->succeeded == QStringList{"111"}); REQUIRE(result->failed.isEmpty()); - REQUIRE_FALSE(svc.steam->is_downloading()); + REQUIRE_FALSE(svc.steam->isDownloading()); } TEST_CASE("second download while running is rejected", "[downloads][steam]") { @@ -466,17 +466,17 @@ TEST_CASE("second download while running is rejected", "[downloads][steam]") { client->gates.insert(111, gate); Service svc({client}); - std::optional> first_out; - std::optional> second_out; + std::optional> firstOut; + std::optional> secondOut; QEventLoop loop; - auto first_task = test_support::await_into(svc.steam->download_mods({"111"}), first_out); + auto firstTask = test_support::awaitInto(svc.steam->downloadMods({"111"}), firstOut); QTimer poll; QObject::connect(&poll, &QTimer::timeout, &poll, [&] { - if (svc.steam->is_downloading()) { + if (svc.steam->isDownloading()) { poll.stop(); - auto second_task = test_support::await_into(svc.steam->download_mods({"222"}), second_out); + auto secondTask = test_support::awaitInto(svc.steam->downloadMods({"222"}), secondOut); gate->set(); loop.quit(); } @@ -484,34 +484,34 @@ TEST_CASE("second download while running is rejected", "[downloads][steam]") { poll.start(10ms); loop.exec(); - REQUIRE(second_out.has_value()); - REQUIRE_FALSE(second_out->has_value()); - REQUIRE(second_out->error().message.contains("already running")); + REQUIRE(secondOut.has_value()); + REQUIRE_FALSE(secondOut->has_value()); + REQUIRE(secondOut->error().message.contains("already running")); } TEST_CASE("updatable_id only for downloads in local mods", "[downloads][steam]") { TestApplication app; Service svc({}); - const QString root = svc.local_path; - const auto m1 = make_mod_at(root + "/111", "111"); - const auto m2 = make_mod_at(root + "/Renamed", "222"); - const auto m3 = make_mod_at(root + "/333"); - const auto m4 = make_mod_at(svc.config_dir.path() + "/Workshop/444", "444"); + const QString root = svc.localPath; + const auto m1 = makeModAt(root + "/111", "111"); + const auto m2 = makeModAt(root + "/Renamed", "222"); + const auto m3 = makeModAt(root + "/333"); + const auto m4 = makeModAt(svc.configDir.path() + "/Workshop/444", "444"); svc.mods = {m1, m2, m3, m4}; - REQUIRE(svc.steam->updatable_id(*m1) == std::optional("111")); - REQUIRE(svc.steam->updatable_id(*m2) == std::nullopt); - REQUIRE(svc.steam->updatable_id(*m3) == std::optional("333")); - REQUIRE(svc.steam->updatable_id(*m4) == std::nullopt); + REQUIRE(svc.steam->updatableId(*m1) == std::optional("111")); + REQUIRE(svc.steam->updatableId(*m2) == std::nullopt); + REQUIRE(svc.steam->updatableId(*m3) == std::optional("333")); + REQUIRE(svc.steam->updatableId(*m4) == std::nullopt); - REQUIRE(svc.steam->updatable_ids(svc.mods) == QStringList{"111", "333"}); + REQUIRE(svc.steam->updatableIds(svc.mods) == QStringList{"111", "333"}); } TEST_CASE("updatable_id none without local path", "[downloads][steam]") { TestApplication app; Service svc({}, /*local=*/false); - const auto mod = make_mod_at(svc.config_dir.path() + "/Mods/111", "111"); - REQUIRE(svc.steam->updatable_id(*mod) == std::nullopt); + const auto mod = makeModAt(svc.configDir.path() + "/Mods/111", "111"); + REQUIRE(svc.steam->updatableId(*mod) == std::nullopt); } TEST_CASE("busy changed brackets download", "[downloads][steam]") { @@ -519,10 +519,10 @@ TEST_CASE("busy changed brackets download", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client}); std::vector events; - svc.steam->busy_changed.connect([&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); - svc.steam->download_finished.connect([&](const auto&) { events.push_back("finished"); }); + svc.steam->busyChanged.connect([&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); + svc.steam->downloadFinished.connect([&](const auto&) { events.push_back("finished"); }); - REQUIRE(run_loop(svc.steam->download_mods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); const std::vector expected{"busy:1", "busy:0", "finished"}; REQUIRE(events == expected); @@ -532,9 +532,9 @@ TEST_CASE("invalid ids do not toggle busy", "[downloads][steam]") { TestApplication app; Service svc({std::make_shared()}); std::vector busy; - svc.steam->busy_changed.connect([&](bool b) { busy.push_back(b); }); + svc.steam->busyChanged.connect([&](bool b) { busy.push_back(b); }); - auto result = run_loop(svc.steam->download_mods({"12a"})); + auto result = runLoop(svc.steam->downloadMods({"12a"})); REQUIRE_FALSE(result.has_value()); REQUIRE(busy.empty()); } @@ -553,11 +553,11 @@ TEST_CASE("progress counts complete and up to date items", "[downloads][steam]") TEST_CASE("updatable_ids limited to given mods and deduplicated", "[downloads][steam]") { TestApplication app; Service svc({}); - const auto a = make_mod_at(svc.local_path + "/111", "111"); - const auto b = make_mod_at(svc.local_path + "/222", "222"); - const auto other = make_mod_at(svc.config_dir.path() + "/Other/9", "9"); + const auto a = makeModAt(svc.localPath + "/111", "111"); + const auto b = makeModAt(svc.localPath + "/222", "222"); + const auto other = makeModAt(svc.configDir.path() + "/Other/9", "9"); - REQUIRE(svc.steam->updatable_ids({b, b, other}) == QStringList{"222"}); + REQUIRE(svc.steam->updatableIds({b, b, other}) == QStringList{"222"}); } TEST_CASE("result changed excludes up to date items", "[downloads][steam]") { @@ -565,11 +565,11 @@ TEST_CASE("result changed excludes up to date items", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client}); - auto result = run_loop(svc.steam->download_mods({"111"})); + auto result = runLoop(svc.steam->downloadMods({"111"})); REQUIRE(result.has_value()); REQUIRE(result->changed == QStringList{"111"}); - REQUIRE(svc.steam->active_ids().isEmpty()); + REQUIRE(svc.steam->activeIds().isEmpty()); } TEST_CASE("batch changed ignores zero byte items", "[downloads][steam]") { @@ -586,9 +586,9 @@ TEST_CASE("success records sync time and persists", "[downloads][steam]") { client->errors.insert(222, QStringLiteral("gone")); Service svc({client}); - REQUIRE(run_loop(svc.steam->download_mods({"111", "222"})).has_value()); - REQUIRE(synced_of(svc, "111").has_value()); - REQUIRE_FALSE(synced_of(svc, "222").has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); + REQUIRE(syncedOf(svc, "111").has_value()); + REQUIRE_FALSE(syncedOf(svc, "222").has_value()); auto reloaded = svc.config.load(px::modrim::kWorkshopSyncFile); REQUIRE(reloaded.has_value()); @@ -603,24 +603,24 @@ TEST_CASE("stale ids never synced first then oldest", "[downloads][steam]") { TestApplication app; Service svc({}); for (const char* name : {"1", "2", "3", "4"}) { - svc.mods.push_back(make_mod_at(svc.local_path + "/" + name, name)); + svc.mods.push_back(makeModAt(svc.localPath + "/" + name, name)); } WorkshopSyncState seed; seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; REQUIRE(svc.config.save(px::modrim::kWorkshopSyncFile, seed).has_value()); - run_loop_void(svc.steam->load_sync_state()); + runLoopVoid(svc.steam->loadSyncState()); - REQUIRE(svc.steam->stale_ids(3600.0, 10000.0) == QStringList{"4", "3", "1"}); + REQUIRE(svc.steam->staleIds(3600.0, 10000.0) == QStringList{"4", "3", "1"}); } TEST_CASE("auto update only syncs stale mods when enabled", "[downloads][steam]") { TestApplication app; auto client = std::make_shared(); Service svc({client}); - svc.mods.push_back(make_mod_at(svc.local_path + "/1", "1")); + svc.mods.push_back(makeModAt(svc.localPath + "/1", "1")); - run_loop_void(svc.steam->auto_update_once()); - REQUIRE_FALSE(synced_of(svc, "1").has_value()); + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE_FALSE(syncedOf(svc, "1").has_value()); svc.steam->settings().update(SteamSettings{ .auto_update_hours = 6, @@ -630,12 +630,12 @@ TEST_CASE("auto update only syncs stale mods when enabled", "[downloads][steam]" .connect_timeout = 10, .stall_timeout = 30, }); - run_loop_void(svc.steam->auto_update_once()); - REQUIRE(synced_of(svc, "1").has_value()); - const double first_synced = *synced_of(svc, "1"); + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(syncedOf(svc, "1").has_value()); + const double firstSynced = *syncedOf(svc, "1"); - run_loop_void(svc.steam->auto_update_once()); - REQUIRE(*synced_of(svc, "1") == first_synced); + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(*syncedOf(svc, "1") == firstSynced); } TEST_CASE("titles from steam are emitted", "[downloads][steam]") { @@ -643,11 +643,11 @@ TEST_CASE("titles from steam are emitted", "[downloads][steam]") { auto client = std::make_shared(); Service svc({client}); QHash titles; - svc.steam->download_item_titled.connect([&](const auto& t) { titles.insert(t.mod_id, t.title); }); + svc.steam->downloadItemTitled.connect([&](const auto& t) { titles.insert(t.modId, t.title); }); std::vector phases; - svc.steam->download_phase_changed.connect([&](const auto& p) { phases.push_back(p); }); + svc.steam->downloadPhaseChanged.connect([&](const auto& p) { phases.push_back(p); }); - REQUIRE(run_loop(svc.steam->download_mods({"111", "222"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); REQUIRE(phases == std::vector{"login", "query", "run"}); REQUIRE(titles.value("111") == "Title 111"); @@ -660,11 +660,11 @@ TEST_CASE("client is relogged after download raises", "[downloads][steam]") { auto fresh = std::make_shared(); Service svc({dying, fresh}); - auto first = run_loop(svc.steam->download_mods({"1"})); + auto first = runLoop(svc.steam->downloadMods({"1"})); REQUIRE(first.has_value()); REQUIRE(first->failed == QStringList{"1"}); - auto second = run_loop(svc.steam->download_mods({"2"})); + auto second = runLoop(svc.steam->downloadMods({"2"})); REQUIRE(second.has_value()); REQUIRE(second->succeeded == QStringList{"2"}); REQUIRE(svc.logins.size() == 2); @@ -683,7 +683,7 @@ TEST_CASE("download uses configured parallelism clamped", "[downloads][steam]") .connect_timeout = 10, .stall_timeout = 30, }); - REQUIRE(run_loop(svc.steam->download_mods({"1"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"1"})).has_value()); svc.steam->settings().update(SteamSettings{ .auto_update_hours = 0, @@ -693,7 +693,7 @@ TEST_CASE("download uses configured parallelism clamped", "[downloads][steam]") .connect_timeout = 10, .stall_timeout = 30, }); - REQUIRE(run_loop(svc.steam->download_mods({"2"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"2"})).has_value()); REQUIRE(client->calls.size() == 2); REQUIRE(client->calls[0].workers == 15); diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp index 58f66d9..003b6ff 100644 --- a/tests/core/models/metadata/test_parsing.cpp +++ b/tests/core/models/metadata/test_parsing.cpp @@ -46,21 +46,21 @@ TEST_CASE("basic About.xml fields are parsed") { CHECK(mod->valid); CHECK(mod->name == QStringLiteral("Test Mod")); CHECK(mod->authors == QStringList{QStringLiteral("Tester")}); - CHECK(mod->package_id.str() == QStringLiteral("test.mod")); + CHECK(mod->packageId.str() == QStringLiteral("test.mod")); CHECK(mod->description == QStringLiteral("A test mod.")); - CHECK(mod->supported_versions == QSet{QStringLiteral("1.5")}); + CHECK(mod->supportedVersions == QSet{QStringLiteral("1.5")}); } TEST_CASE("About.xml without packageId is invalid") { const auto mod = aboutFromXml("No ID Mod"); CHECK_FALSE(mod->valid); - CHECK(mod->package_id.str() == QStringLiteral("missing.packageid")); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); } TEST_CASE("About.xml with empty packageId is invalid") { const auto mod = aboutFromXml(""); CHECK_FALSE(mod->valid); - CHECK(mod->package_id.str() == QStringLiteral("missing.packageid")); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); } TEST_CASE("versioned entries must match the version key exactly") { @@ -73,13 +73,13 @@ TEST_CASE("versioned entries must match the version key exactly") {
  • wrong.dep
  • )"); - CHECK(wrong->about_rules.load_after.size() == 0); - CHECK(wrong->about_rules.dependencies.isEmpty()); + CHECK(wrong->aboutRules.loadAfter.size() == 0); + CHECK(wrong->aboutRules.dependencies.isEmpty()); const auto right = aboutFromXml(R"(x.y
  • right
  • )"); - CHECK(right->about_rules.load_after.contains(QStringLiteral("right"))); + CHECK(right->aboutRules.loadAfter.contains(QStringLiteral("right"))); } TEST_CASE("versioned dependencies replace the unversioned list") { @@ -90,8 +90,8 @@ TEST_CASE("versioned dependencies replace the unversioned list") { )"); - REQUIRE(mod->about_rules.dependencies.size() == 1); - CHECK(mod->about_rules.dependencies.first().package_id == + REQUIRE(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.dependencies.first().packageId == CaseInsensitiveStr(QStringLiteral("versioned.dep"))); } @@ -104,7 +104,7 @@ TEST_CASE("preferVersioned=false ignores versioned entries") { )")); const auto mod = createAboutModFromElement(doc.document_element(), QStringLiteral("1.5"), /*preferVersioned=*/false); - CHECK(mod->about_rules.dependencies.isEmpty()); + CHECK(mod->aboutRules.dependencies.isEmpty()); } TEST_CASE("dependencies keep declaration order, skip duplicates and null entries") { @@ -120,13 +120,13 @@ TEST_CASE("dependencies keep declaration order, skip duplicates and null entries )"); - const auto& deps = mod->about_rules.dependencies; + const auto& deps = mod->aboutRules.dependencies; REQUIRE(deps.size() == 2); - CHECK(deps[0].package_id == CaseInsensitiveStr(QStringLiteral("dep.one"))); + CHECK(deps[0].packageId == CaseInsensitiveStr(QStringLiteral("dep.one"))); CHECK(deps[0].name == QStringLiteral("Dep One")); - CHECK(deps[0].workshop_url == QStringLiteral("https://example.test/one")); - CHECK(deps[0].alternative_package_ids.contains(QStringLiteral("alt.one"))); - CHECK(deps[1].package_id == CaseInsensitiveStr(QStringLiteral("dep.two"))); + CHECK(deps[0].workshopUrl == QStringLiteral("https://example.test/one")); + CHECK(deps[0].alternativePackageIds.contains(QStringLiteral("alt.one"))); + CHECK(deps[1].packageId == CaseInsensitiveStr(QStringLiteral("dep.two"))); } TEST_CASE("force-load entries are merged into the load-order sets") { @@ -138,11 +138,11 @@ TEST_CASE("force-load entries are merged into the load-order sets") {
  • bad.one
  • )"); - CHECK(mod->about_rules.load_before.contains(QStringLiteral("before.a"))); - CHECK(mod->about_rules.load_before.contains(QStringLiteral("before.b"))); - CHECK(mod->about_rules.load_after.contains(QStringLiteral("after.a"))); - CHECK(mod->about_rules.load_after.contains(QStringLiteral("after.b"))); - CHECK(mod->about_rules.incompatible_with.contains(QStringLiteral("bad.one"))); + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.a"))); + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.b"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.a"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.b"))); + CHECK(mod->aboutRules.incompatibleWith.contains(QStringLiteral("bad.one"))); } TEST_CASE("descriptionsByVersion selects the description for the target version") { @@ -155,7 +155,7 @@ TEST_CASE("descriptionsByVersion selects the description for the target version" TEST_CASE("explicit steamAppId is read") { const auto mod = aboutFromXml(R"(x.y 1234)"); - CHECK(mod->steam_app_id == 1234); + CHECK(mod->steamAppId == 1234); } TEST_CASE("DLC package ids depend on the RimWorld base game and take the DLC name") { @@ -164,7 +164,7 @@ TEST_CASE("DLC package ids depend on the RimWorld base game and take the DLC nam CHECK(mod->valid); CHECK(mod->name == QStringLiteral("Royalty")); - const auto* base = mod->about_rules.find_dependency( + const auto* base = mod->aboutRules.findDependency( CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))); REQUIRE(base != nullptr); CHECK(base->name == QStringLiteral("RimWorld")); @@ -233,11 +233,11 @@ TEST_CASE("createListedModFromPath parses a valid mod directory") { REQUIRE(about != nullptr); CHECK(about->name == QStringLiteral("Test Mod")); CHECK(about->authors == QStringList{QStringLiteral("TestAuthor")}); - CHECK(about->package_id.str() == QStringLiteral("test.mod")); - CHECK(about->mod_version == QStringLiteral("1.0.0")); - CHECK(about->about_rules.load_after.contains(QStringLiteral("ludeon.rimworld"))); - CHECK(mod->mod_path() == modPath); - CHECK(mod->provider_id.isEmpty()); + CHECK(about->packageId.str() == QStringLiteral("test.mod")); + CHECK(about->modVersion == QStringLiteral("1.0.0")); + CHECK(about->aboutRules.loadAfter.contains(QStringLiteral("ludeon.rimworld"))); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId.isEmpty()); CHECK(about->mtime > 0.0); } @@ -262,7 +262,7 @@ TEST_CASE("malformed About.xml yields an invalid AboutXmlMod with the mod path") const auto& mod = *result; CHECK_FALSE(mod->valid); CHECK(dynamic_cast(mod.get()) != nullptr); - CHECK(mod->mod_path() == modPath); + CHECK(mod->modPath() == modPath); CHECK(mod->uuid() == modPath); } diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp index 5e546fb..dec66a7 100644 --- a/tests/core/models/metadata/test_structures.cpp +++ b/tests/core/models/metadata/test_structures.cpp @@ -57,14 +57,14 @@ TEST_CASE("ListedMod has the invalid-mod defaults") { const ListedMod mod; CHECK(mod.name == QStringLiteral("Unknown Mod Name")); CHECK(mod.valid); - CHECK(mod.provider_id.isEmpty()); - CHECK_FALSE(mod.mod_path().has_value()); + CHECK(mod.providerId.isEmpty()); + CHECK_FALSE(mod.modPath().has_value()); } TEST_CASE("ListedMod provider_id is assignable") { ListedMod mod; - mod.provider_id = QStringLiteral("local"); - CHECK(mod.provider_id == QStringLiteral("local")); + mod.providerId = QStringLiteral("local"); + CHECK(mod.providerId == QStringLiteral("local")); } TEST_CASE("pathless ListedMod uuids are unique") { @@ -75,28 +75,28 @@ TEST_CASE("pathless ListedMod uuids are unique") { TEST_CASE("ListedMod mod_path can be set only once") { ListedMod mod; - REQUIRE(mod.set_mod_path(QStringLiteral("/mods/a"))); + REQUIRE(mod.setModPath(QStringLiteral("/mods/a"))); CHECK(mod.uuid() == QStringLiteral("/mods/a")); - const auto second = mod.set_mod_path(QStringLiteral("/mods/b")); + const auto second = mod.setModPath(QStringLiteral("/mods/b")); REQUIRE_FALSE(second); CHECK(second.error().message == QStringLiteral("Mod path already set. Cannot override.")); - CHECK(mod.mod_path() == QStringLiteral("/mods/a")); + CHECK(mod.modPath() == QStringLiteral("/mods/a")); } TEST_CASE("AboutXmlMod has the About.xml defaults") { const AboutXmlMod mod; CHECK(mod.name == QStringLiteral("Unknown Mod Name")); CHECK(mod.authors.isEmpty()); - CHECK(mod.mod_version.isEmpty()); - CHECK(mod.package_id == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); + CHECK(mod.modVersion.isEmpty()); + CHECK(mod.packageId == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); } TEST_CASE("AboutXmlMod reports the DLC name for DLC package ids") { AboutXmlMod mod; - mod.package_id = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); - REQUIRE(mod.get_dlc_name().has_value()); - CHECK(*mod.get_dlc_name() == QStringLiteral("RimWorld - Royalty")); + mod.packageId = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); + REQUIRE(mod.getDlcName().has_value()); + CHECK(*mod.getDlcName() == QStringLiteral("RimWorld - Royalty")); } TEST_CASE("ModsConfig normalizes package ids to lowercase") { @@ -114,7 +114,7 @@ TEST_CASE("ModsConfig clear_active_mods keeps expansions") { .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, }; - config.clear_active_mods(); + config.clearActiveMods(); CHECK(config.activeMods.isEmpty()); CHECK(config.knownExpansions.size() == 1); } @@ -125,7 +125,7 @@ TEST_CASE("ModsConfig clear_all empties both lists") { .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, }; - config.clear_all(); + config.clearAll(); CHECK(config.activeMods.isEmpty()); CHECK(config.knownExpansions.isEmpty()); } @@ -137,7 +137,7 @@ TEST_CASE("ModsConfig detects duplicate active mods ignoring case") { CaseInsensitiveStr(QStringLiteral("Mod.A"))}, .knownExpansions = {}, }; - CHECK(duplicated.check_active_duplicates()); + CHECK(duplicated.checkActiveDuplicates()); const ModsConfig distinct{ .version = QStringLiteral("1.5"), @@ -145,7 +145,7 @@ TEST_CASE("ModsConfig detects duplicate active mods ignoring case") { CaseInsensitiveStr(QStringLiteral("mod.b"))}, .knownExpansions = {}, }; - CHECK_FALSE(distinct.check_active_duplicates()); + CHECK_FALSE(distinct.checkActiveDuplicates()); } TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { @@ -155,7 +155,7 @@ TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), CaseInsensitiveStr(QStringLiteral("Ludeon.RimWorld.Royalty"))}, }; - CHECK(duplicated.check_expansions_duplicates()); + CHECK(duplicated.checkExpansionsDuplicates()); const ModsConfig distinct{ .version = QStringLiteral("1.5"), @@ -163,5 +163,5 @@ TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.ideology"))}, }; - CHECK_FALSE(distinct.check_expansions_duplicates()); + CHECK_FALSE(distinct.checkExpansionsDuplicates()); } diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp index 845dad3..2ec8036 100644 --- a/tests/core/organizer/test_db.cpp +++ b/tests/core/organizer/test_db.cpp @@ -11,13 +11,13 @@ using namespace px::modrim::organizer; using organizer_test::expect; -using organizer_test::expect_error; +using organizer_test::expectError; using organizer_test::wait; using test_support::TestApplication; namespace { -QString organizer_file(const QTemporaryDir& dir) { +QString organizerFile(const QTemporaryDir& dir) { return dir.filePath(QStringLiteral("organizer.db")); } @@ -27,57 +27,57 @@ TEST_CASE("a fresh database holds only the root folder") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); const OrganizerState state = expect(wait(db.load())); REQUIRE(state.folders.size() == 1); - const Folder root = state.folders.value(ROOT_ID); - CHECK(root.id == ROOT_ID); - CHECK_FALSE(root.parent_id.has_value()); + const Folder root = state.folders.value(kRootId); + CHECK(root.id == kRootId); + CHECK_FALSE(root.parentId.has_value()); CHECK(root.name == QStringLiteral("Root")); CHECK_FALSE(root.collapsed); CHECK(state.placements.isEmpty()); CHECK(state.tags.isEmpty()); - CHECK(state.mod_tags.isEmpty()); + CHECK(state.modTags.isEmpty()); CHECK(state.rules.isEmpty()); - expect_error(wait(db.delete_folder(ROOT_ID))); - expect_error(wait(db.rename_folder(ROOT_ID, QStringLiteral("CustomRoot")))); - expect_error(wait(db.set_parent(ROOT_ID, 2))); + expectError(wait(db.deleteFolder(kRootId))); + expectError(wait(db.renameFolder(kRootId, QStringLiteral("CustomRoot")))); + expectError(wait(db.setParent(kRootId, 2))); } TEST_CASE("sibling names are unique case-insensitively, per parent") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder f_a = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("FolderA")))); - CHECK(f_a.id > ROOT_ID); - CHECK(f_a.parent_id == ROOT_ID); - CHECK(f_a.name == QStringLiteral("FolderA")); + const Folder fA = expect(wait(db.createFolder(kRootId, QStringLiteral("FolderA")))); + CHECK(fA.id > kRootId); + CHECK(fA.parentId == kRootId); + CHECK(fA.name == QStringLiteral("FolderA")); - expect_error(wait(db.create_folder(ROOT_ID, QStringLiteral("foldera")))); + expectError(wait(db.createFolder(kRootId, QStringLiteral("foldera")))); - const Folder f_b = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("FolderB")))); - expect_error(wait(db.rename_folder(f_b.id, QStringLiteral("foldera")))); + const Folder fB = expect(wait(db.createFolder(kRootId, QStringLiteral("FolderB")))); + expectError(wait(db.renameFolder(fB.id, QStringLiteral("foldera")))); // The same name is fine under a different parent. - const Folder f_sub = expect(wait(db.create_folder(f_a.id, QStringLiteral("FolderB")))); - CHECK(f_sub.parent_id == f_a.id); - CHECK(f_sub.name == QStringLiteral("FolderB")); + const Folder fSub = expect(wait(db.createFolder(fA.id, QStringLiteral("FolderB")))); + CHECK(fSub.parentId == fA.id); + CHECK(fSub.name == QStringLiteral("FolderB")); - expect_error(wait(db.set_parent(f_sub.id, ROOT_ID))); + expectError(wait(db.setParent(fSub.id, kRootId))); } TEST_CASE("placing lowercases package ids and upserts; unplacing removes rows") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder f1 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Folder1")))); - const Folder f2 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Folder2")))); + const Folder f1 = expect(wait(db.createFolder(kRootId, QStringLiteral("Folder1")))); + const Folder f2 = expect(wait(db.createFolder(kRootId, QStringLiteral("Folder2")))); expect(wait(db.place({}, 9999))); expect(wait(db.place({QStringLiteral("Package.One"), QStringLiteral("PACKAGE.TWO")}, f1.id))); @@ -96,21 +96,21 @@ TEST_CASE("placing lowercases package ids and upserts; unplacing removes rows") CHECK_FALSE(st.placements.contains(QStringLiteral("package.one"))); CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); - expect_error(wait(db.place({QStringLiteral("package.three")}, 9999))); + expectError(wait(db.place({QStringLiteral("package.three")}, 9999))); } TEST_CASE("create_folder_with places its mods and rolls back on a duplicate name") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder f = expect(wait(db.create_folder_with( - ROOT_ID, QStringLiteral("CoreMods"), + const Folder f = expect(wait(db.createFolderWith( + kRootId, QStringLiteral("CoreMods"), {QStringLiteral("Package.One"), QStringLiteral("package.two"), QStringLiteral("PACKAGE.THREE")}))); - CHECK(f.id > ROOT_ID); + CHECK(f.id > kRootId); CHECK(f.name == QStringLiteral("CoreMods")); - CHECK(f.parent_id == ROOT_ID); + CHECK(f.parentId == kRootId); const OrganizerState st = expect(wait(db.load())); CHECK(st.folders.contains(f.id)); @@ -118,7 +118,7 @@ TEST_CASE("create_folder_with places its mods and rolls back on a duplicate name CHECK(st.placements.value(QStringLiteral("package.two")) == f.id); CHECK(st.placements.value(QStringLiteral("package.three")) == f.id); - expect_error(wait(db.create_folder_with(ROOT_ID, QStringLiteral("coremods"), + expectError(wait(db.createFolderWith(kRootId, QStringLiteral("coremods"), {QStringLiteral("package.four"), QStringLiteral("package.five")}))); const OrganizerState after = expect(wait(db.load())); @@ -131,131 +131,131 @@ TEST_CASE("deleting a folder re-homes its subtree's placements and drops its des TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder f_parent = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Parent")))); - const Folder f_child = expect(wait(db.create_folder(f_parent.id, QStringLiteral("Child")))); - const Folder f_grandchild = expect(wait(db.create_folder(f_child.id, QStringLiteral("Grandchild")))); - const Folder f_other = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Other")))); + const Folder fParent = expect(wait(db.createFolder(kRootId, QStringLiteral("Parent")))); + const Folder fChild = expect(wait(db.createFolder(fParent.id, QStringLiteral("Child")))); + const Folder fGrandchild = expect(wait(db.createFolder(fChild.id, QStringLiteral("Grandchild")))); + const Folder fOther = expect(wait(db.createFolder(kRootId, QStringLiteral("Other")))); - expect(wait(db.place({QStringLiteral("mod.parent")}, f_parent.id))); - expect(wait(db.place({QStringLiteral("mod.child")}, f_child.id))); - expect(wait(db.place({QStringLiteral("mod.grandchild")}, f_grandchild.id))); - expect(wait(db.place({QStringLiteral("mod.other")}, f_other.id))); - expect(wait(db.place({QStringLiteral("mod.root")}, ROOT_ID))); + expect(wait(db.place({QStringLiteral("mod.parent")}, fParent.id))); + expect(wait(db.place({QStringLiteral("mod.child")}, fChild.id))); + expect(wait(db.place({QStringLiteral("mod.grandchild")}, fGrandchild.id))); + expect(wait(db.place({QStringLiteral("mod.other")}, fOther.id))); + expect(wait(db.place({QStringLiteral("mod.root")}, kRootId))); - expect(wait(db.delete_folder(f_parent.id))); + expect(wait(db.deleteFolder(fParent.id))); const OrganizerState st = expect(wait(db.load())); - CHECK_FALSE(st.folders.contains(f_parent.id)); - CHECK_FALSE(st.folders.contains(f_child.id)); - CHECK_FALSE(st.folders.contains(f_grandchild.id)); - CHECK(st.folders.contains(f_other.id)); - - CHECK(st.placements.value(QStringLiteral("mod.parent")) == ROOT_ID); - CHECK(st.placements.value(QStringLiteral("mod.child")) == ROOT_ID); - CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == ROOT_ID); - CHECK(st.placements.value(QStringLiteral("mod.other")) == f_other.id); - CHECK(st.placements.value(QStringLiteral("mod.root")) == ROOT_ID); - - expect_error(wait(db.delete_folder(9999))); + CHECK_FALSE(st.folders.contains(fParent.id)); + CHECK_FALSE(st.folders.contains(fChild.id)); + CHECK_FALSE(st.folders.contains(fGrandchild.id)); + CHECK(st.folders.contains(fOther.id)); + + CHECK(st.placements.value(QStringLiteral("mod.parent")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.child")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.other")) == fOther.id); + CHECK(st.placements.value(QStringLiteral("mod.root")) == kRootId); + + expectError(wait(db.deleteFolder(9999))); } TEST_CASE("tags: create, rename, unique names, mod tagging and cascade on delete") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Tag t1 = expect(wait(db.create_tag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); - const Tag t2 = expect(wait(db.create_tag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); + const Tag t1 = expect(wait(db.createTag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); + const Tag t2 = expect(wait(db.createTag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); CHECK(t1.name == QStringLiteral("Tag1")); CHECK(t1.color == QStringLiteral("#ff0000")); - expect_error(wait(db.create_tag(QStringLiteral("tag1"), QStringLiteral("#123456")))); + expectError(wait(db.createTag(QStringLiteral("tag1"), QStringLiteral("#123456")))); - expect(wait(db.set_mod_tags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, {t1.id, t2.id}, {}))); + expect(wait(db.setModTags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, {t1.id, t2.id}, {}))); OrganizerState st = expect(wait(db.load())); - CHECK(st.mod_tags.value(QStringLiteral("mod.alpha")) == QSet{t1.id, t2.id}); - CHECK(st.mod_tags.value(QStringLiteral("mod.beta")) == QSet{t1.id, t2.id}); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t1.id, t2.id}); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id, t2.id}); - expect(wait(db.set_mod_tags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); + expect(wait(db.setModTags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); st = expect(wait(db.load())); - CHECK(st.mod_tags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); - expect_error(wait(db.set_mod_tags({QStringLiteral("mod.alpha")}, {9999}, {}))); + expectError(wait(db.setModTags({QStringLiteral("mod.alpha")}, {9999}, {}))); - expect(wait(db.update_tag(t1.id, QStringLiteral("Tag1Updated"), QStringLiteral("#aaaaaa")))); + expect(wait(db.updateTag(t1.id, QStringLiteral("Tag1Updated"), QStringLiteral("#aaaaaa")))); st = expect(wait(db.load())); CHECK(st.tags.value(t1.id).name == QStringLiteral("Tag1Updated")); - expect_error(wait(db.update_tag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); + expectError(wait(db.updateTag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); - expect(wait(db.delete_tag(t2.id))); + expect(wait(db.deleteTag(t2.id))); st = expect(wait(db.load())); CHECK_FALSE(st.tags.contains(t2.id)); - CHECK((!st.mod_tags.contains(QStringLiteral("mod.alpha")) || - st.mod_tags.value(QStringLiteral("mod.alpha")).isEmpty())); - CHECK(st.mod_tags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); + CHECK((!st.modTags.contains(QStringLiteral("mod.alpha")) || + st.modTags.value(QStringLiteral("mod.alpha")).isEmpty())); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); - expect_error(wait(db.delete_tag(9999))); + expectError(wait(db.deleteTag(9999))); } TEST_CASE("replacing rules numbers them by position and deleting a folder drops its rules") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder f1 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Target1")))); - const Folder f2 = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Target2")))); + const Folder f1 = expect(wait(db.createFolder(kRootId, QStringLiteral("Target1")))); + const Folder f2 = expect(wait(db.createFolder(kRootId, QStringLiteral("Target2")))); const QList specs{ - RuleSpec{.field = RuleField::Name, .op = RuleOp::Prefix, .pattern = QStringLiteral("Vanilla"), .folder_id = f1.id}, - RuleSpec{.field = RuleField::Author, .op = RuleOp::Contains, .pattern = QStringLiteral("Oskar"), .folder_id = f2.id}, - RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Equals, .pattern = QStringLiteral("ludeon.rimworld"), .folder_id = f1.id}, + RuleSpec{.field = RuleField::kName, .op = RuleOp::kPrefix, .pattern = QStringLiteral("Vanilla"), .folderId = f1.id}, + RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kContains, .pattern = QStringLiteral("Oskar"), .folderId = f2.id}, + RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kEquals, .pattern = QStringLiteral("ludeon.rimworld"), .folderId = f1.id}, }; - const QList rules = expect(wait(db.replace_rules(specs))); + const QList rules = expect(wait(db.replaceRules(specs))); REQUIRE(rules.size() == 3); CHECK(rules[0].position == 0); CHECK(rules[1].position == 1); CHECK(rules[2].position == 2); - CHECK(rules[0].field == RuleField::Name); - CHECK(rules[1].field == RuleField::Author); - CHECK(rules[2].field == RuleField::PackageId); + CHECK(rules[0].field == RuleField::kName); + CHECK(rules[1].field == RuleField::kAuthor); + CHECK(rules[2].field == RuleField::kPackageId); const OrganizerState st = expect(wait(db.load())); CHECK(st.rules == rules); // A rule pointing at a missing folder fails the whole replacement. - expect_error(wait(db.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Prefix, - .pattern = QStringLiteral("x"), .folder_id = 9999}}))); + expectError(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kPrefix, + .pattern = QStringLiteral("x"), .folderId = 9999}}))); // Deleting Target1 drops its rules; the rule for Target2 remains. - expect(wait(db.delete_folder(f1.id))); + expect(wait(db.deleteFolder(f1.id))); const OrganizerState after = expect(wait(db.load())); REQUIRE(after.rules.size() == 1); CHECK(after.rules[0].id == rules[1].id); - CHECK(after.rules[0].folder_id == f2.id); + CHECK(after.rules[0].folderId == f2.id); } TEST_CASE("state survives closing and reopening the database") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString file = organizer_file(dir); + const QString file = organizerFile(dir); Folder folder; Tag tag; { OrganizerDb db1(file); - folder = expect(wait(db1.create_folder(ROOT_ID, QStringLiteral("PersistFolder")))); - expect(wait(db1.set_collapsed(folder.id, true))); + folder = expect(wait(db1.createFolder(kRootId, QStringLiteral("PersistFolder")))); + expect(wait(db1.setCollapsed(folder.id, true))); expect(wait(db1.place({QStringLiteral("persist.pkg")}, folder.id))); - tag = expect(wait(db1.create_tag(QStringLiteral("PersistTag"), QStringLiteral("#abcdef")))); - expect(wait(db1.set_mod_tags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); - expect(wait(db1.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, - .pattern = QStringLiteral("mod"), .folder_id = folder.id}}))); + tag = expect(wait(db1.createTag(QStringLiteral("PersistTag"), QStringLiteral("#abcdef")))); + expect(wait(db1.setModTags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); + expect(wait(db1.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, + .pattern = QStringLiteral("mod"), .folderId = folder.id}}))); wait(db1.close()); } @@ -267,56 +267,56 @@ TEST_CASE("state survives closing and reopening the database") { CHECK(st.placements.value(QStringLiteral("persist.pkg")) == folder.id); REQUIRE(st.tags.contains(tag.id)); CHECK(st.tags.value(tag.id).name == QStringLiteral("PersistTag")); - CHECK(st.mod_tags.value(QStringLiteral("persist.pkg")) == QSet{tag.id}); + CHECK(st.modTags.value(QStringLiteral("persist.pkg")) == QSet{tag.id}); REQUIRE(st.rules.size() == 1); CHECK(st.rules[0].pattern == QStringLiteral("mod")); - CHECK(st.rules[0].folder_id == folder.id); + CHECK(st.rules[0].folderId == folder.id); } TEST_CASE("append_named_rules is atomic and resolves folders case-insensitively") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - OrganizerDb db(organizer_file(dir)); + OrganizerDb db(organizerFile(dir)); - const Folder existing = expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Existing")))); - expect(wait(db.replace_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, - .pattern = QStringLiteral("a"), .folder_id = existing.id}}))); + const Folder existing = expect(wait(db.createFolder(kRootId, QStringLiteral("Existing")))); + expect(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, + .pattern = QStringLiteral("a"), .folderId = existing.id}}))); const OrganizerState before = expect(wait(db.load())); - expect_error(wait(db.append_named_rules( - ROOT_ID, {QStringLiteral("New")}, - {StandardRule{.folder = QStringLiteral("New"), .field = RuleField::Name, - .op = RuleOp::Contains, .pattern = QStringLiteral("b")}, - StandardRule{.folder = QStringLiteral("Missing"), .field = RuleField::Name, - .op = RuleOp::Contains, .pattern = QStringLiteral("c")}})), + expectError(wait(db.appendNamedRules( + kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("New"), .field = RuleField::kName, + .op = RuleOp::kContains, .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("Missing"), .field = RuleField::kName, + .op = RuleOp::kContains, .pattern = QStringLiteral("c")}})), QStringLiteral("Missing")); CHECK(expect(wait(db.load())) == before); - expect(wait(db.append_named_rules( - ROOT_ID, {QStringLiteral("New")}, - {StandardRule{.folder = QStringLiteral("existing"), .field = RuleField::Name, - .op = RuleOp::Contains, .pattern = QStringLiteral("b")}, - StandardRule{.folder = QStringLiteral("NEW"), .field = RuleField::Name, - .op = RuleOp::Contains, .pattern = QStringLiteral("c")}}))); + expect(wait(db.appendNamedRules( + kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("existing"), .field = RuleField::kName, + .op = RuleOp::kContains, .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("NEW"), .field = RuleField::kName, + .op = RuleOp::kContains, .pattern = QStringLiteral("c")}}))); const OrganizerState st = expect(wait(db.load())); - qint64 new_id = -1; + qint64 newId = -1; for (const Folder& folder : st.folders) { if (folder.name == QStringLiteral("New")) { - new_id = folder.id; + newId = folder.id; } } REQUIRE(st.rules.size() == 3); CHECK(st.rules[0].position == 0); CHECK(st.rules[0].pattern == QStringLiteral("a")); - CHECK(st.rules[0].folder_id == existing.id); + CHECK(st.rules[0].folderId == existing.id); CHECK(st.rules[1].position == 1); CHECK(st.rules[1].pattern == QStringLiteral("b")); - CHECK(st.rules[1].folder_id == existing.id); + CHECK(st.rules[1].folderId == existing.id); CHECK(st.rules[2].position == 2); CHECK(st.rules[2].pattern == QStringLiteral("c")); - CHECK(st.rules[2].folder_id == new_id); + CHECK(st.rules[2].folderId == newId); } TEST_CASE("the database file is created in a missing directory on first use") { @@ -326,6 +326,6 @@ TEST_CASE("the database file is created in a missing directory on first use") { const QString file = dir.filePath(QStringLiteral("nested/config/organizer.db")); OrganizerDb db(file); - expect(wait(db.create_folder(ROOT_ID, QStringLiteral("Made")))); + expect(wait(db.createFolder(kRootId, QStringLiteral("Made")))); CHECK(QFile::exists(file)); } diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp index 74611de..fe8283b 100644 --- a/tests/core/organizer/test_resolve.cpp +++ b/tests/core/organizer/test_resolve.cpp @@ -12,29 +12,29 @@ using namespace px::modrim::organizer; namespace { -ListedModPtr make_mod([[maybe_unused]] const QString& uuid, const QString& name, +ListedModPtr makeMod([[maybe_unused]] const QString& uuid, const QString& name, const std::optional& pid = std::nullopt, const QString& author = {}, const QString& provider = QStringLiteral("local")) { if (!pid) { auto mod = std::make_shared(); mod->name = name; - mod->provider_id = provider; + mod->providerId = provider; return mod; } auto mod = std::make_shared(); mod->name = name; - mod->provider_id = provider; - mod->package_id = CaseInsensitiveStr(QStringView(*pid)); + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(*pid)); if (!author.isEmpty()) { mod->authors.append(author); } return mod; } -Folder folder(qint64 id, std::optional parent_id, QString name) { +Folder folder(qint64 id, std::optional parentId, QString name) { Folder f; f.id = id; - f.parent_id = parent_id; + f.parentId = parentId; f.name = std::move(name); f.collapsed = false; return f; @@ -44,19 +44,19 @@ struct RuleSeed { RuleField field; RuleOp op; QString pattern; - qint64 folder_id; + qint64 folderId; }; -OrganizerState make_state(QList folders = {}, QHash placements = {}, - QList rules = {}, QHash> mod_tags = {}, +OrganizerState makeState(QList folders = {}, QHash placements = {}, + QList rules = {}, QHash> modTags = {}, QList tags = {}) { OrganizerState state; - state.folders.insert(ROOT_ID, folder(ROOT_ID, std::nullopt, QStringLiteral(""))); + state.folders.insert(kRootId, folder(kRootId, std::nullopt, QStringLiteral(""))); for (const Folder& f : folders) { state.folders.insert(f.id, f); } state.placements = std::move(placements); - state.mod_tags = std::move(mod_tags); + state.modTags = std::move(modTags); for (const Tag& tag : tags) { state.tags.insert(tag.id, tag); } @@ -67,14 +67,14 @@ OrganizerState make_state(QList folders = {}, QHash pla .field = fld, .op = op, .pattern = pat, - .folder_id = fid}); + .folderId = fid}); } return state; } -const FolderNode* find_node(const FolderNode& root, qint64 folder_id) { +const FolderNode* findNode(const FolderNode& root, qint64 folderId) { for (const FolderNode* node : root.walk()) { - if (node->folder.id == folder_id) { + if (node->folder.id == folderId) { return node; } } @@ -84,96 +84,96 @@ const FolderNode* find_node(const FolderNode& root, qint64 folder_id) { } // namespace TEST_CASE("preview_rule_matches counts first matches and assignable ungrouped mods") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, - {{QStringLiteral("manual.mod"), 3}, {QStringLiteral("root.mod"), ROOT_ID}}, - {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("old."), 2}}); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, + {{QStringLiteral("manual.mod"), 3}, {QStringLiteral("root.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("old."), 2}}); const ModsByUuid mods{ - {QStringLiteral("a"), make_mod(QStringLiteral("a"), QStringLiteral("Core Alpha"), QStringLiteral("new.alpha"), QStringLiteral("Author"))}, - {QStringLiteral("b"), make_mod(QStringLiteral("b"), QStringLiteral("Core Beta"), QStringLiteral("old.beta"), QStringLiteral("Other"))}, - {QStringLiteral("c"), make_mod(QStringLiteral("c"), QStringLiteral("Core Manual"), QStringLiteral("manual.mod"), QStringLiteral("Author"))}, - {QStringLiteral("d"), make_mod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), QStringLiteral("root.mod"), QStringLiteral("Author"))}, - {QStringLiteral("e"), make_mod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, + {QStringLiteral("a"), makeMod(QStringLiteral("a"), QStringLiteral("Core Alpha"), QStringLiteral("new.alpha"), QStringLiteral("Author"))}, + {QStringLiteral("b"), makeMod(QStringLiteral("b"), QStringLiteral("Core Beta"), QStringLiteral("old.beta"), QStringLiteral("Other"))}, + {QStringLiteral("c"), makeMod(QStringLiteral("c"), QStringLiteral("Core Manual"), QStringLiteral("manual.mod"), QStringLiteral("Author"))}, + {QStringLiteral("d"), makeMod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), QStringLiteral("root.mod"), QStringLiteral("Author"))}, + {QStringLiteral("e"), makeMod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, }; const QList draft{ - RuleSpec{.field = RuleField::Author, .op = RuleOp::Equals, .pattern = QStringLiteral("author"), .folder_id = 3}, - RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, .pattern = QStringLiteral("core"), .folder_id = 2}, + RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kEquals, .pattern = QStringLiteral("author"), .folderId = 3}, + RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, .pattern = QStringLiteral("core"), .folderId = 2}, }; - RulePreview preview = preview_rule_matches(state, mods, draft); + RulePreview preview = previewRuleMatches(state, mods, draft); CHECK(preview.counts == QList{3, 1}); CHECK(preview.assignable == 1); const QList reversed{draft[1], draft[0]}; - preview = preview_rule_matches(state, mods, reversed); + preview = previewRuleMatches(state, mods, reversed); CHECK(preview.counts == QList{4, 0}); CHECK(preview.assignable == 1); - QList with_blank{ - RuleSpec{.field = RuleField::Name, .op = RuleOp::Contains, .pattern = QStringLiteral(" "), .folder_id = 2}, + QList withBlank{ + RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, .pattern = QStringLiteral(" "), .folderId = 2}, draft[0], draft[1], }; - preview = preview_rule_matches(state, mods, with_blank); + preview = previewRuleMatches(state, mods, withBlank); CHECK(preview.counts == QList{0, 3, 1}); CHECK(preview.assignable == 1); } TEST_CASE("folder_for manual placement beats rules, and root placement blocks rules") { SECTION("manual beats rule") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, {{QStringLiteral("x.mod"), 3}}, - {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("x."), 2}}); - const Resolution res = folder_for(state, QStringLiteral("X.Mod"), QStringLiteral("Mod"), QStringLiteral("")); - CHECK(res.folder_id == 3); - CHECK(res.placement == Placement::Manual); + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("X.Mod"), QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == 3); + CHECK(res.placement == Placement::kManual); } SECTION("root placement blocks rules") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A"))}, - {{QStringLiteral("x.mod"), ROOT_ID}}, - {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("x."), 2}}); - const Resolution res = folder_for(state, QStringLiteral("x.mod"), QStringLiteral("Mod"), QStringLiteral("")); - CHECK(res.folder_id == ROOT_ID); - CHECK(res.placement == Placement::Manual); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, + {{QStringLiteral("x.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("x.mod"), QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == kRootId); + CHECK(res.placement == Placement::kManual); } SECTION("first rule by position wins") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A")), folder(3, ROOT_ID, QStringLiteral("B"))}, {}, - {{RuleField::Author, RuleOp::Equals, QStringLiteral("OSKAR"), 3}, - {RuleField::Name, RuleOp::Contains, QStringLiteral("core"), 2}}); - const Resolution res1 = folder_for(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), QStringLiteral("Oskar")); - CHECK(res1.folder_id == 3); - CHECK(res1.placement == Placement::Rule); - const Resolution res2 = folder_for(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), QStringLiteral("Someone")); - CHECK(res2.folder_id == 2); - CHECK(res2.placement == Placement::Rule); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, {}, + {{RuleField::kAuthor, RuleOp::kEquals, QStringLiteral("OSKAR"), 3}, + {RuleField::kName, RuleOp::kContains, QStringLiteral("core"), 2}}); + const Resolution res1 = folderFor(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), QStringLiteral("Oskar")); + CHECK(res1.folderId == 3); + CHECK(res1.placement == Placement::kRule); + const Resolution res2 = folderFor(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), QStringLiteral("Someone")); + CHECK(res2.folderId == 2); + CHECK(res2.placement == Placement::kRule); } SECTION("ops are case-insensitive") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, - {{RuleField::Name, RuleOp::Equals, QStringLiteral("hello world"), 2}}); - CHECK(folder_for(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), QStringLiteral("")).folder_id == 2); - CHECK(folder_for(state, QStringLiteral("a.b"), QStringLiteral("Hello World!"), QStringLiteral("")).folder_id == ROOT_ID); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kEquals, QStringLiteral("hello world"), 2}}); + CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), QStringLiteral("")).folderId == 2); + CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World!"), QStringLiteral("")).folderId == kRootId); } SECTION("no package id or no match is ungrouped") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, - {{RuleField::Name, RuleOp::Contains, QStringLiteral(""), 2}}); - CHECK(folder_for(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).folder_id == ROOT_ID); - CHECK(folder_for(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).placement == Placement::None); - - const OrganizerState state2 = make_state( - {folder(2, ROOT_ID, QStringLiteral("A"))}, {}, - {{RuleField::PackageId, RuleOp::Prefix, QStringLiteral("zzz"), 2}}); - CHECK(folder_for(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), QStringLiteral("")).folder_id == ROOT_ID); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kContains, QStringLiteral(""), 2}}); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).folderId == kRootId); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).placement == Placement::kNone); + + const OrganizerState state2 = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("zzz"), 2}}); + CHECK(folderFor(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), QStringLiteral("")).folderId == kRootId); } } @@ -184,20 +184,20 @@ struct NestedFixture { ModsByUuid mods; }; -NestedFixture make_nested() { - OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("Zeta")), folder(3, 2, QStringLiteral("inner")), - folder(4, ROOT_ID, QStringLiteral("alpha"))}, +NestedFixture makeNested() { + OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("Zeta")), folder(3, 2, QStringLiteral("inner")), + folder(4, kRootId, QStringLiteral("alpha"))}, {{QStringLiteral("a.one"), 2}, {QStringLiteral("a.two"), 3}, {QStringLiteral("a.three"), 3}, {QStringLiteral("b.one"), 4}}); ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("two"), QStringLiteral("a.two"))}, - {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"))}, - {QStringLiteral("u4"), make_mod(QStringLiteral("u4"), QStringLiteral("B One"), QStringLiteral("b.one"))}, - {QStringLiteral("u5"), make_mod(QStringLiteral("u5"), QStringLiteral("loose"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("two"), QStringLiteral("a.two"))}, + {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"))}, + {QStringLiteral("u4"), makeMod(QStringLiteral("u4"), QStringLiteral("B One"), QStringLiteral("b.one"))}, + {QStringLiteral("u5"), makeMod(QStringLiteral("u5"), QStringLiteral("loose"))}, }; return {std::move(state), std::move(mods)}; } @@ -205,71 +205,71 @@ NestedFixture make_nested() { } // namespace TEST_CASE("build_tree computes tri-state checks across nested folders") { - auto [state, mods] = make_nested(); - const FolderNode root = build_tree(state, mods, {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); - const FolderNode* zeta = find_node(root, 2); - const FolderNode* inner = find_node(root, 3); - const FolderNode* alpha = find_node(root, 4); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); + const FolderNode* alpha = findNode(root, 4); REQUIRE((zeta && inner && alpha)); CHECK(inner->total == 2); CHECK(inner->enabled == 2); - CHECK(inner->check == CheckState::On); + CHECK(inner->check == CheckState::kOn); CHECK(zeta->total == 3); CHECK(zeta->enabled == 3); - CHECK(zeta->check == CheckState::On); + CHECK(zeta->check == CheckState::kOn); CHECK(alpha->total == 1); CHECK(alpha->enabled == 0); - CHECK(alpha->check == CheckState::Off); + CHECK(alpha->check == CheckState::kOff); CHECK(root.total == 5); CHECK(root.enabled == 3); - CHECK(root.check == CheckState::Partial); + CHECK(root.check == CheckState::kPartial); - const FolderNode root2 = build_tree(state, mods, {QStringLiteral("u2")}); - CHECK(find_node(root2, 3)->check == CheckState::Partial); - CHECK(find_node(root2, 2)->check == CheckState::Partial); + const FolderNode root2 = buildTree(state, mods, {QStringLiteral("u2")}); + CHECK(findNode(root2, 3)->check == CheckState::kPartial); + CHECK(findNode(root2, 2)->check == CheckState::kPartial); } TEST_CASE("build_tree reports depths and places ungrouped mods at root") { - auto [state, mods] = make_nested(); - const FolderNode root = build_tree(state, mods, {}); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); CHECK(root.depth == 0); - CHECK(find_node(root, 2)->depth == 1); - CHECK(find_node(root, 3)->depth == 2); + CHECK(findNode(root, 2)->depth == 1); + CHECK(findNode(root, 3)->depth == 2); REQUIRE(root.mods.size() == 1); CHECK(root.mods[0].uuid == QStringLiteral("u5")); - CHECK(root.mods[0].placement == Placement::None); - CHECK_FALSE(root.mods[0].package_id.has_value()); + CHECK(root.mods[0].placement == Placement::kNone); + CHECK_FALSE(root.mods[0].packageId.has_value()); } TEST_CASE("build_tree sorts siblings and mods by name casefolded") { - auto [state, mods] = make_nested(); - const FolderNode root = build_tree(state, mods, {}); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); REQUIRE(root.children.size() == 2); CHECK(root.children[0].folder.name == QStringLiteral("alpha")); CHECK(root.children[1].folder.name == QStringLiteral("Zeta")); - const FolderNode* inner = find_node(root, 3); + const FolderNode* inner = findNode(root, 3); REQUIRE(inner->mods.size() == 2); CHECK(inner->mods[0].name == QStringLiteral("Three")); CHECK(inner->mods[1].name == QStringLiteral("two")); } TEST_CASE("build_tree query prunes nodes but counts cover all mods") { - auto [state, mods] = make_nested(); + auto [state, mods] = makeNested(); TreeQuery query; query.text = QStringLiteral("TWO"); - const FolderNode root = build_tree(state, mods, {QStringLiteral("u1")}, query); + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, query); REQUIRE(root.children.size() == 1); CHECK(root.children[0].folder.id == 2); - const FolderNode* zeta = find_node(root, 2); - const FolderNode* inner = find_node(root, 3); + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); REQUIRE((zeta && inner)); CHECK(zeta->mods.isEmpty()); REQUIRE(inner->mods.size() == 1); @@ -277,122 +277,122 @@ TEST_CASE("build_tree query prunes nodes but counts cover all mods") { CHECK(zeta->total == 3); CHECK(zeta->enabled == 1); - CHECK(zeta->visible_total == 1); + CHECK(zeta->visibleTotal == 1); CHECK(root.total == 5); - CHECK(root.visible_total == 1); + CHECK(root.visibleTotal == 1); CHECK(root.mods.isEmpty()); } TEST_CASE("an empty query does not prune empty folders") { - const OrganizerState state = make_state({folder(2, ROOT_ID, QStringLiteral("Empty"))}); - const FolderNode root = build_tree(state, {}, {}, TreeQuery{}); + const OrganizerState state = makeState({folder(2, kRootId, QStringLiteral("Empty"))}); + const FolderNode root = buildTree(state, {}, {}, TreeQuery{}); REQUIRE(root.children.size() == 1); CHECK(root.children[0].folder.id == 2); - CHECK(find_node(root, 2)->check == CheckState::Off); + CHECK(findNode(root, 2)->check == CheckState::kOff); } TEST_CASE("build_tree filters by status, tags, provider and text") { - const OrganizerState state = make_state( + const OrganizerState state = makeState( {}, {}, {}, {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}); const ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, - {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, + {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"))}, }; const auto visible = [&](const TreeQuery& q) { - const FolderNode root = build_tree(state, mods, {QStringLiteral("u1")}, q); + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, q); QSet uuids; - for (const ModLeaf& leaf : root.all_mods()) { + for (const ModLeaf& leaf : root.allMods()) { uuids.insert(leaf.uuid); } return uuids; }; - TreeQuery active_q; - active_q.status = StatusFilter::Active; - CHECK(visible(active_q) == QSet{QStringLiteral("u1")}); + TreeQuery activeQ; + activeQ.status = StatusFilter::kActive; + CHECK(visible(activeQ) == QSet{QStringLiteral("u1")}); - TreeQuery inactive_q; - inactive_q.status = StatusFilter::Inactive; - CHECK(visible(inactive_q) == QSet{QStringLiteral("u2"), QStringLiteral("u3")}); + TreeQuery inactiveQ; + inactiveQ.status = StatusFilter::kInactive; + CHECK(visible(inactiveQ) == QSet{QStringLiteral("u2"), QStringLiteral("u3")}); - TreeQuery tag1_q; - tag1_q.tag_ids = {1}; - CHECK(visible(tag1_q) == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + TreeQuery tag1Q; + tag1Q.tagIds = {1}; + CHECK(visible(tag1Q) == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); - TreeQuery tag12_q; - tag12_q.tag_ids = {1, 2}; - CHECK(visible(tag12_q) == QSet{QStringLiteral("u1")}); + TreeQuery tag12Q; + tag12Q.tagIds = {1, 2}; + CHECK(visible(tag12Q) == QSet{QStringLiteral("u1")}); - TreeQuery steam_q; - steam_q.provider_ids = {QStringLiteral("steam")}; - CHECK(visible(steam_q) == QSet{QStringLiteral("u1")}); + TreeQuery steamQ; + steamQ.providerIds = {QStringLiteral("steam")}; + CHECK(visible(steamQ) == QSet{QStringLiteral("u1")}); - TreeQuery text_q; - text_q.text = QStringLiteral("ludeon"); - CHECK(visible(text_q) == QSet{QStringLiteral("u2")}); + TreeQuery textQ; + textQ.text = QStringLiteral("ludeon"); + CHECK(visible(textQ) == QSet{QStringLiteral("u2")}); - TreeQuery pid_q; - pid_q.text = QStringLiteral("a.one"); - CHECK(visible(pid_q) == QSet{QStringLiteral("u1")}); + TreeQuery pidQ; + pidQ.text = QStringLiteral("a.one"); + CHECK(visible(pidQ) == QSet{QStringLiteral("u1")}); } TEST_CASE("mods sharing a package id share their placement and tags") { - const OrganizerState state = make_state( - {folder(2, ROOT_ID, QStringLiteral("A"))}, + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {{QStringLiteral("dup.mod"), 2}}, {}, {{QStringLiteral("dup.mod"), {7}}}); const ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Copy 1"), QStringLiteral("Dup.Mod"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Copy 2"), QStringLiteral("dup.mod"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Copy 1"), QStringLiteral("Dup.Mod"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Copy 2"), QStringLiteral("dup.mod"))}, }; - const FolderNode root = build_tree(state, mods, {}); - const FolderNode* a = find_node(root, 2); + const FolderNode root = buildTree(state, mods, {}); + const FolderNode* a = findNode(root, 2); REQUIRE(a != nullptr); REQUIRE(a->mods.size() == 2); CHECK(a->mods[0].uuid == QStringLiteral("u1")); CHECK(a->mods[1].uuid == QStringLiteral("u2")); - CHECK(a->mods[0].tag_ids == QSet{7}); - CHECK(a->mods[1].tag_ids == QSet{7}); + CHECK(a->mods[0].tagIds == QSet{7}); + CHECK(a->mods[1].tagIds == QSet{7}); } TEST_CASE("build_tree computes own counts and flags folders that have rules") { - auto [base_state, mods] = make_nested(); - QList nested_folders; + auto [base_state, mods] = makeNested(); + QList nestedFolders; for (const Folder& f : base_state.folders) { - if (f.id != ROOT_ID) { - nested_folders.append(f); + if (f.id != kRootId) { + nestedFolders.append(f); } } - const OrganizerState state = make_state( - nested_folders, base_state.placements, - {{RuleField::Name, RuleOp::Contains, QStringLiteral("zzz"), 3}}); + const OrganizerState state = makeState( + nestedFolders, base_state.placements, + {{RuleField::kName, RuleOp::kContains, QStringLiteral("zzz"), 3}}); TreeQuery query; query.text = QStringLiteral("nothing"); - const FolderNode root = build_tree(state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); - CHECK(root.own_total == 1); - CHECK(root.own_enabled == 1); - CHECK(root.own_check() == CheckState::On); + const FolderNode root = buildTree(state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); + CHECK(root.ownTotal == 1); + CHECK(root.ownEnabled == 1); + CHECK(root.ownCheck() == CheckState::kOn); CHECK(root.total == 5); - CHECK(root.check == CheckState::Partial); + CHECK(root.check == CheckState::kPartial); - const FolderNode full = build_tree(state, mods, {}); - QList ruled_ids; + const FolderNode full = buildTree(state, mods, {}); + QList ruledIds; for (const FolderNode* node : full.walk()) { - if (node->has_rules) { - ruled_ids.append(node->folder.id); + if (node->hasRules) { + ruledIds.append(node->folder.id); } } - CHECK(ruled_ids == QList{3}); - const FolderNode* zeta = find_node(full, 2); + CHECK(ruledIds == QList{3}); + const FolderNode* zeta = findNode(full, 2); REQUIRE(zeta != nullptr); - CHECK(zeta->own_total == 1); + CHECK(zeta->ownTotal == 1); } TEST_CASE("TreeFilter::query builds a query matching its preset") { @@ -400,14 +400,14 @@ TEST_CASE("TreeFilter::query builds a query matching its preset") { tf.key = QStringLiteral("tag:10"); tf.label = QStringLiteral("Medieval"); tf.count = 3; - tf.tag_id = 10; + tf.tagId = 10; const TreeQuery q = tf.query(QStringLiteral("sword")); - CHECK(q.tag_ids == QSet{10}); + CHECK(q.tagIds == QSet{10}); CHECK(q.text == QStringLiteral("sword")); - CHECK(q.status == StatusFilter::All); - CHECK(q.provider_ids.isEmpty()); - CHECK_FALSE(q.is_empty()); + CHECK(q.status == StatusFilter::kAll); + CHECK(q.providerIds.isEmpty()); + CHECK_FALSE(q.isEmpty()); } TEST_CASE("tree_filters preserves order, counts status and counts tags across mods") { @@ -415,18 +415,18 @@ TEST_CASE("tree_filters preserves order, counts status and counts tags across mo const Tag t2{.id = 2, .name = QStringLiteral("Combat"), .color = QStringLiteral("#fb923c")}; const Tag t3{.id = 3, .name = QStringLiteral("Magic"), .color = QStringLiteral("#a3e635")}; - const OrganizerState state = make_state( + const OrganizerState state = makeState( {}, {}, {}, {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}, {t1, t2, t3}); const ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), {}, QStringLiteral("local"))}, - {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, }; - const QList filters = tree_filters(mods, {QStringLiteral("u1")}, &state); + const QList filters = treeFilters(mods, {QStringLiteral("u1")}, &state); REQUIRE(filters.size() >= 3); CHECK(filters[0].key == QStringLiteral("all")); CHECK(filters[0].count == 3); @@ -437,7 +437,7 @@ TEST_CASE("tree_filters preserves order, counts status and counts tags across mo QList providers; for (const TreeFilter& f : filters) { - if (f.provider_id) { + if (f.providerId) { providers.append(f); } } @@ -449,7 +449,7 @@ TEST_CASE("tree_filters preserves order, counts status and counts tags across mo QList tags; for (const TreeFilter& f : filters) { - if (f.tag_id) { + if (f.tagId) { tags.append(f); } } @@ -469,76 +469,76 @@ TEST_CASE("tree_filters preserves order, counts status and counts tags across mo TEST_CASE("tree_filters counts all copies sharing a package id") { const Tag t5{.id = 5, .name = QStringLiteral("Overhaul"), .color = QStringLiteral("#5eead4")}; - const OrganizerState state = make_state({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); + const OrganizerState state = makeState({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); const ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, - {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, }; - const QList filters = tree_filters(mods, {}, &state); + const QList filters = treeFilters(mods, {}, &state); const auto it = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:5"); }); REQUIRE(it != filters.cend()); CHECK(it->count == 2); CHECK(it->label == QStringLiteral("Overhaul")); - CHECK(it->tag_id == 5); + CHECK(it->tagId == 5); } TEST_CASE("tree_filters without state or without tags only produces presets and providers") { - const ModsByUuid mods{{QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}}; + const ModsByUuid mods{{QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}}; - const QList without_state = tree_filters(mods, {}, nullptr); + const QList withoutState = treeFilters(mods, {}, nullptr); QStringList keys; - for (const TreeFilter& f : without_state) { + for (const TreeFilter& f : withoutState) { keys.append(f.key); } CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), QStringLiteral("inactive"), QStringLiteral("provider:local")}); - const OrganizerState empty_state = make_state(); - const QList with_empty_state = tree_filters(mods, {}, &empty_state); - QStringList empty_keys; - for (const TreeFilter& f : with_empty_state) { - empty_keys.append(f.key); + const OrganizerState emptyState = makeState(); + const QList withEmptyState = treeFilters(mods, {}, &emptyState); + QStringList emptyKeys; + for (const TreeFilter& f : withEmptyState) { + emptyKeys.append(f.key); } - CHECK(empty_keys == keys); + CHECK(emptyKeys == keys); } TEST_CASE("filtering tree with a tag query selects only tagged mods") { const Tag t1{.id = 1, .name = QStringLiteral("Tagged"), .color = QStringLiteral("#5eead4")}; const Tag t2{.id = 2, .name = QStringLiteral("Empty"), .color = QStringLiteral("#fb923c")}; - const OrganizerState state = make_state( + const OrganizerState state = makeState( {}, {}, {}, {{QStringLiteral("a.one"), {1}}, {QStringLiteral("a.two"), {1}}}, {t1, t2}); const ModsByUuid mods{ - {QStringLiteral("u1"), make_mod(QStringLiteral("u1"), QStringLiteral("Mod One"), QStringLiteral("a.one"))}, - {QStringLiteral("u2"), make_mod(QStringLiteral("u2"), QStringLiteral("Mod Two"), QStringLiteral("a.two"))}, - {QStringLiteral("u3"), make_mod(QStringLiteral("u3"), QStringLiteral("Mod Three"), QStringLiteral("a.three"))}, + {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Mod One"), QStringLiteral("a.one"))}, + {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Mod Two"), QStringLiteral("a.two"))}, + {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Mod Three"), QStringLiteral("a.three"))}, }; - const QList filters = tree_filters(mods, {}, &state); + const QList filters = treeFilters(mods, {}, &state); const auto it1 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:1"); }); REQUIRE(it1 != filters.cend()); - const FolderNode tree1 = build_tree(state, mods, {}, it1->query()); + const FolderNode tree1 = buildTree(state, mods, {}, it1->query()); QSet visible1; - for (const ModLeaf& leaf : tree1.all_mods()) { + for (const ModLeaf& leaf : tree1.allMods()) { visible1.insert(leaf.uuid); } CHECK(visible1 == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); const auto it2 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:2"); }); REQUIRE(it2 != filters.cend()); - const FolderNode empty_tree = build_tree(state, mods, {}, it2->query()); - CHECK(empty_tree.all_mods().isEmpty()); - CHECK(empty_tree.visible_total == 0); - - const FolderNode narrowed = build_tree(state, mods, {}, it1->query(QStringLiteral("Two"))); - QSet visible_narrowed; - for (const ModLeaf& leaf : narrowed.all_mods()) { - visible_narrowed.insert(leaf.uuid); + const FolderNode emptyTree = buildTree(state, mods, {}, it2->query()); + CHECK(emptyTree.allMods().isEmpty()); + CHECK(emptyTree.visibleTotal == 0); + + const FolderNode narrowed = buildTree(state, mods, {}, it1->query(QStringLiteral("Two"))); + QSet visibleNarrowed; + for (const ModLeaf& leaf : narrowed.allMods()) { + visibleNarrowed.insert(leaf.uuid); } - CHECK(visible_narrowed == QSet{QStringLiteral("u2")}); + CHECK(visibleNarrowed == QSet{QStringLiteral("u2")}); } diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp index 8ffedaf..6b18b00 100644 --- a/tests/core/organizer/test_service.cpp +++ b/tests/core/organizer/test_service.cpp @@ -17,23 +17,23 @@ using namespace px::modrim; using namespace px::modrim::organizer; using organizer_test::expect; -using organizer_test::expect_error; +using organizer_test::expectError; using organizer_test::wait; using test_support::TestApplication; namespace { struct FakeContext { - QHash all_mods; - QStringList active_uuids; + QHash allMods; + QStringList activeUuids; void add(const QString& uuid, const QString& name, const QString& pid, const QString& provider = QStringLiteral("local")) { auto mod = std::make_shared(); mod->name = name; - mod->provider_id = provider; - mod->package_id = CaseInsensitiveStr(QStringView(pid)); - all_mods.insert(uuid, mod); + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(pid)); + allMods.insert(uuid, mod); } }; @@ -53,8 +53,8 @@ struct TestHarness { explicit TestHarness() : dir(), db(dir.filePath(QStringLiteral("organizer.db"))), - svc([this]() -> const auto& { return ctx.all_mods; }, - [this]() -> const auto& { return ctx.active_uuids; }, db) { + svc([this]() -> const auto& { return ctx.allMods; }, + [this]() -> const auto& { return ctx.activeUuids; }, db) { REQUIRE(dir.isValid()); svc.changed.connect([this](const std::monostate& m) { changes.record(m); }); wait(svc.init()); @@ -71,18 +71,18 @@ struct TestHarness { TEST_CASE("bulk folder expansion and collapse persists") { TestHarness h; - const Folder parent = expect(wait(h.svc.create_folder(QStringLiteral("Parent")))); - const Folder child = expect(wait(h.svc.create_folder(QStringLiteral("Child"), parent.id))); - const Folder other = expect(wait(h.svc.create_folder(QStringLiteral("Other")))); + const Folder parent = expect(wait(h.svc.createFolder(QStringLiteral("Parent")))); + const Folder child = expect(wait(h.svc.createFolder(QStringLiteral("Child"), parent.id))); + const Folder other = expect(wait(h.svc.createFolder(QStringLiteral("Other")))); - expect(wait(h.svc.set_all_collapsed(true))); + expect(wait(h.svc.setAllCollapsed(true))); OrganizerState persisted = expect(wait(h.db.load())); CHECK(persisted.folders.value(parent.id).collapsed); CHECK(persisted.folders.value(child.id).collapsed); CHECK(persisted.folders.value(other.id).collapsed); - expect(wait(h.svc.set_collapsed(child.id, false))); - expect(wait(h.svc.set_all_collapsed(false))); + expect(wait(h.svc.setCollapsed(child.id, false))); + expect(wait(h.svc.setAllCollapsed(false))); persisted = expect(wait(h.db.load())); CHECK_FALSE(persisted.folders.value(parent.id).collapsed); CHECK_FALSE(persisted.folders.value(child.id).collapsed); @@ -91,171 +91,171 @@ TEST_CASE("bulk folder expansion and collapse persists") { TEST_CASE("folder depth cannot exceed maximum depth") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); - const Folder c = expect(wait(h.svc.create_folder(QStringLiteral("C"), b.id))); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); CHECK(h.changes.count == 3); - expect_error(wait(h.svc.create_folder(QStringLiteral("D"), c.id)), QStringLiteral("at most 3 levels")); + expectError(wait(h.svc.createFolder(QStringLiteral("D"), c.id)), QStringLiteral("at most 3 levels")); CHECK(h.changes.count == 3); CHECK(h.svc.state().folders.size() == 4); } TEST_CASE("moving a folder respects subtree height") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); - const Folder x = expect(wait(h.svc.create_folder(QStringLiteral("X")))); - const Folder y = expect(wait(h.svc.create_folder(QStringLiteral("Y"), x.id))); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); - expect_error(wait(h.svc.move_folder(x.id, b.id)), QStringLiteral("at most 3 levels")); + expectError(wait(h.svc.moveFolder(x.id, b.id)), QStringLiteral("at most 3 levels")); - expect(wait(h.svc.move_folder(x.id, a.id))); - CHECK(h.svc.state().folders.value(x.id).parent_id == a.id); - CHECK(h.svc.state().folders.value(y.id).parent_id == x.id); + expect(wait(h.svc.moveFolder(x.id, a.id))); + CHECK(h.svc.state().folders.value(x.id).parentId == a.id); + CHECK(h.svc.state().folders.value(y.id).parentId == x.id); CHECK(h.changes.count == 5); } TEST_CASE("moving a folder into its own subtree or moving root is rejected") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - expect_error(wait(h.svc.move_folder(a.id, a.id)), QStringLiteral("into itself")); - expect_error(wait(h.svc.move_folder(a.id, b.id)), QStringLiteral("into itself")); - expect_error(wait(h.svc.move_folder(ROOT_ID, a.id))); + expectError(wait(h.svc.moveFolder(a.id, a.id)), QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(a.id, b.id)), QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(kRootId, a.id))); CHECK(h.changes.count == 2); - CHECK(h.svc.state().folders.value(a.id).parent_id == ROOT_ID); + CHECK(h.svc.state().folders.value(a.id).parentId == kRootId); } TEST_CASE("folder creation and rename reject duplicate names case-insensitively") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("Graphics")))); - expect_error(wait(h.svc.create_folder(QStringLiteral(" graphics "))), QStringLiteral("already exists")); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("Graphics")))); + expectError(wait(h.svc.createFolder(QStringLiteral(" graphics "))), QStringLiteral("already exists")); - const Folder other = expect(wait(h.svc.create_folder(QStringLiteral("Other")))); - expect_error(wait(h.svc.rename_folder(other.id, QStringLiteral("GRAPHICS"))), QStringLiteral("already exists")); + const Folder other = expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + expectError(wait(h.svc.renameFolder(other.id, QStringLiteral("GRAPHICS"))), QStringLiteral("already exists")); - const Folder in_other = expect(wait(h.svc.create_folder(QStringLiteral("Graphics"), other.id))); - CHECK(in_other.parent_id == other.id); + const Folder inOther = expect(wait(h.svc.createFolder(QStringLiteral("Graphics"), other.id))); + CHECK(inOther.parentId == other.id); - expect(wait(h.svc.rename_folder(a.id, QStringLiteral("graphics")))); + expect(wait(h.svc.renameFolder(a.id, QStringLiteral("graphics")))); CHECK(h.svc.state().folders.value(a.id).name == QStringLiteral("graphics")); CHECK(h.changes.count == 4); } TEST_CASE("empty folder name is rejected") { TestHarness h; - expect_error(wait(h.svc.create_folder(QStringLiteral(" "))), QStringLiteral("cannot be empty")); + expectError(wait(h.svc.createFolder(QStringLiteral(" "))), QStringLiteral("cannot be empty")); CHECK(h.changes.count == 0); } TEST_CASE("create_folder_from emits once and rolls back on duplicate") { TestHarness h; - const Folder f = expect(wait(h.svc.create_folder_from( + const Folder f = expect(wait(h.svc.createFolderFrom( QStringLiteral("Core"), {QStringLiteral("mod.a"), QStringLiteral("mod.b")}))); CHECK(h.changes.count == 1); CHECK(f.name == QStringLiteral("Core")); CHECK(h.svc.state().placements.value(QStringLiteral("mod.a")) == f.id); CHECK(h.svc.state().placements.value(QStringLiteral("mod.b")) == f.id); - const OrganizerState state_before = h.svc.state(); - const int changes_before = h.changes.count; + const OrganizerState stateBefore = h.svc.state(); + const int changesBefore = h.changes.count; - expect_error(wait(h.svc.create_folder_from(QStringLiteral("core"), {QStringLiteral("mod.c")}))); - CHECK(h.changes.count == changes_before); - CHECK(h.svc.state() == state_before); + expectError(wait(h.svc.createFolderFrom(QStringLiteral("core"), {QStringLiteral("mod.c")}))); + CHECK(h.changes.count == changesBefore); + CHECK(h.svc.state() == stateBefore); - const OrganizerState db_state = expect(wait(h.db.load())); - CHECK_FALSE(db_state.placements.contains(QStringLiteral("mod.c"))); - CHECK(db_state.folders.size() == state_before.folders.size()); + const OrganizerState dbState = expect(wait(h.db.load())); + CHECK_FALSE(dbState.placements.contains(QStringLiteral("mod.c"))); + CHECK(dbState.folders.size() == stateBefore.folders.size()); } TEST_CASE("deleting a folder ungroups mods to root manual placement") { TestHarness h; h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.active_uuids = {QStringLiteral("u1")}; + h.ctx.activeUuids = {QStringLiteral("u1")}; - const Folder a = expect(wait(h.svc.create_folder_from(QStringLiteral("A"), {QStringLiteral("A.One")}))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder a = expect(wait(h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("A.One")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); - expect(wait(h.svc.set_rules({RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Prefix, - .pattern = QStringLiteral("a."), .folder_id = a.id}}))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kPrefix, + .pattern = QStringLiteral("a."), .folderId = a.id}}))); CHECK(h.changes.count == 4); - QStringList uuids = expect(h.svc.folder_mod_uuids(a.id)); + QStringList uuids = expect(h.svc.folderModUuids(a.id)); std::sort(uuids.begin(), uuids.end()); CHECK(uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - expect(wait(h.svc.delete_folder(a.id))); + expect(wait(h.svc.deleteFolder(a.id))); CHECK(h.changes.count == 5); - CHECK(h.svc.state().folders.keys() == QList{ROOT_ID}); + CHECK(h.svc.state().folders.keys() == QList{kRootId}); CHECK(h.svc.state().rules.isEmpty()); const FolderNode root = h.svc.tree(); - QStringList root_uuids; + QStringList rootUuids; for (const ModLeaf& leaf : root.mods) { - root_uuids.append(leaf.uuid); - CHECK(leaf.placement == Placement::Manual); + rootUuids.append(leaf.uuid); + CHECK(leaf.placement == Placement::kManual); } - std::sort(root_uuids.begin(), root_uuids.end()); - CHECK(root_uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + std::sort(rootUuids.begin(), rootUuids.end()); + CHECK(rootUuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); CHECK(root.total == 2); CHECK(root.enabled == 1); - CHECK(root.check == CheckState::Partial); + CHECK(root.check == CheckState::kPartial); } TEST_CASE("place, ungroup and reset_to_rules alter placement source") { TestHarness h; h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); - expect(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, - .pattern = QStringLiteral(" one "), .folder_id = a.id}}))); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, + .pattern = QStringLiteral(" one "), .folderId = a.id}}))); CHECK(h.svc.state().rules[0].pattern == QStringLiteral("one")); - CHECK(h.svc.tree().children[0].mods[0].placement == Placement::Rule); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); expect(wait(h.svc.ungroup({QStringLiteral("a.one")}))); - CHECK(h.svc.tree().mods[0].placement == Placement::Manual); + CHECK(h.svc.tree().mods[0].placement == Placement::kManual); - expect(wait(h.svc.reset_to_rules({QStringLiteral("a.one")}))); - CHECK(h.svc.tree().children[0].mods[0].placement == Placement::Rule); + expect(wait(h.svc.resetToRules({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); CHECK(h.changes.count == 4); } TEST_CASE("rules validation rejects root target, empty pattern or missing folder") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, - .pattern = QStringLiteral("x"), .folder_id = ROOT_ID}})), + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), .folderId = kRootId}})), QStringLiteral("root")); - expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, - .pattern = QStringLiteral(" "), .folder_id = a.id}})), + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, + .pattern = QStringLiteral(" "), .folderId = a.id}})), QStringLiteral("pattern cannot be empty")); - expect_error(wait(h.svc.set_rules({RuleSpec{.field = RuleField::Name, .op = RuleOp::Equals, - .pattern = QStringLiteral("x"), .folder_id = 999}}))); + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), .folderId = 999}}))); CHECK(h.changes.count == 1); } TEST_CASE("tags lifecycle, validation and removal cascade") { TestHarness h; - const Tag tag = expect(wait(h.svc.create_tag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); + const Tag tag = expect(wait(h.svc.createTag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); - expect_error(wait(h.svc.create_tag(QStringLiteral("qol"), QStringLiteral("#000000"))), QStringLiteral("already exists")); - expect_error(wait(h.svc.create_tag(QStringLiteral("Other"), QStringLiteral("red"))), QStringLiteral("not a valid color")); + expectError(wait(h.svc.createTag(QStringLiteral("qol"), QStringLiteral("#000000"))), QStringLiteral("already exists")); + expectError(wait(h.svc.createTag(QStringLiteral("Other"), QStringLiteral("red"))), QStringLiteral("not a valid color")); - expect(wait(h.svc.set_mod_tags({QStringLiteral("A.Mod")}, {tag.id}, {}))); - CHECK(h.svc.state().mod_tags.value(QStringLiteral("a.mod")) == QSet{tag.id}); + expect(wait(h.svc.setModTags({QStringLiteral("A.Mod")}, {tag.id}, {}))); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")) == QSet{tag.id}); - expect_error(wait(h.svc.set_mod_tags({QStringLiteral("a.mod")}, {999}, {}))); + expectError(wait(h.svc.setModTags({QStringLiteral("a.mod")}, {999}, {}))); - expect(wait(h.svc.delete_tag(tag.id))); + expect(wait(h.svc.deleteTag(tag.id))); CHECK(h.svc.state().tags.isEmpty()); - CHECK(h.svc.state().mod_tags.value(QStringLiteral("a.mod")).isEmpty()); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")).isEmpty()); CHECK(h.changes.count == 3); } @@ -263,23 +263,23 @@ TEST_CASE("service state persists across instances") { TestApplication app; QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString db_file = dir.filePath(QStringLiteral("organizer.db")); + const QString dbFile = dir.filePath(QStringLiteral("organizer.db")); FakeContext ctx; Folder folder; { - OrganizerDb db1(db_file); - OrganizerService svc1([&]() -> const auto& { return ctx.all_mods; }, - [&]() -> const auto& { return ctx.active_uuids; }, db1); + OrganizerDb db1(dbFile); + OrganizerService svc1([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, db1); wait(svc1.init()); - folder = expect(wait(svc1.create_folder(QStringLiteral("Kept")))); + folder = expect(wait(svc1.createFolder(QStringLiteral("Kept")))); wait(svc1.shutdown()); } { - OrganizerDb db2(db_file); - OrganizerService svc2([&]() -> const auto& { return ctx.all_mods; }, - [&]() -> const auto& { return ctx.active_uuids; }, db2); + OrganizerDb db2(dbFile); + OrganizerService svc2([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, db2); wait(svc2.init()); CHECK(svc2.state().folders.contains(folder.id)); CHECK(svc2.state().folders.value(folder.id).name == QStringLiteral("Kept")); @@ -293,8 +293,8 @@ TEST_CASE("init signals readiness and emits changed") { REQUIRE(dir.isValid()); FakeContext ctx; OrganizerDb db(dir.filePath(QStringLiteral("organizer.db"))); - OrganizerService svc([&]() -> const auto& { return ctx.all_mods; }, - [&]() -> const auto& { return ctx.active_uuids; }, db); + OrganizerService svc([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, db); ChangeCounter counter; svc.changed.connect([&](const std::monostate& m) { counter.record(m); }); @@ -311,58 +311,58 @@ TEST_CASE("folder_toggle enables partial/off and disables full") { h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); h.ctx.add(QStringLiteral("u3"), QStringLiteral("Loose"), QStringLiteral("loose.mod")); - const Folder a = expect(wait(h.svc.create_folder_from(QStringLiteral("A"), {QStringLiteral("a.one")}))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); + const Folder a = expect(wait(h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); - h.ctx.active_uuids = {QStringLiteral("u1")}; - FolderToggle toggle = expect(h.svc.folder_toggle(a.id)); + h.ctx.activeUuids = {QStringLiteral("u1")}; + FolderToggle toggle = expect(h.svc.folderToggle(a.id)); toggle.enable.sort(); CHECK(toggle.enable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); CHECK(toggle.disable.isEmpty()); - h.ctx.active_uuids = {QStringLiteral("u1"), QStringLiteral("u2")}; - toggle = expect(h.svc.folder_toggle(a.id)); + h.ctx.activeUuids = {QStringLiteral("u1"), QStringLiteral("u2")}; + toggle = expect(h.svc.folderToggle(a.id)); toggle.disable.sort(); CHECK(toggle.enable.isEmpty()); CHECK(toggle.disable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - toggle = expect(h.svc.folder_toggle(ROOT_ID, /*recursive=*/false)); + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); CHECK(toggle.enable == QStringList{QStringLiteral("u3")}); CHECK(toggle.disable.isEmpty()); - h.ctx.active_uuids = {QStringLiteral("u3")}; - toggle = expect(h.svc.folder_toggle(ROOT_ID, /*recursive=*/false)); + h.ctx.activeUuids = {QStringLiteral("u3")}; + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); CHECK(toggle.enable.isEmpty()); CHECK(toggle.disable == QStringList{QStringLiteral("u3")}); - CHECK(expect(h.svc.folder_mod_uuids(ROOT_ID, /*recursive=*/false)) == QStringList{QStringLiteral("u3")}); + CHECK(expect(h.svc.folderModUuids(kRootId, /*recursive=*/false)) == QStringList{QStringLiteral("u3")}); } TEST_CASE("folder_move_targets respects subtree depth and duplicate names") { TestHarness h; - const Folder a = expect(wait(h.svc.create_folder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.create_folder(QStringLiteral("B"), a.id))); - const Folder c = expect(wait(h.svc.create_folder(QStringLiteral("C"), b.id))); - const Folder x = expect(wait(h.svc.create_folder(QStringLiteral("X")))); - const Folder y = expect(wait(h.svc.create_folder(QStringLiteral("Y"), x.id))); - const Folder clash = expect(wait(h.svc.create_folder(QStringLiteral("Clash")))); - const Folder inner_x = expect(wait(h.svc.create_folder(QStringLiteral("X"), clash.id))); - - CHECK(expect(h.svc.folder_move_targets(b.id)) == QSet{x.id, clash.id, ROOT_ID}); - CHECK(expect(h.svc.folder_move_targets(x.id)) == QSet{a.id}); - CHECK(expect(h.svc.folder_move_targets(c.id)) == QSet{a.id, x.id, y.id, clash.id, inner_x.id, ROOT_ID}); - expect_error(h.svc.folder_move_targets(ROOT_ID)); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); + const Folder clash = expect(wait(h.svc.createFolder(QStringLiteral("Clash")))); + const Folder innerX = expect(wait(h.svc.createFolder(QStringLiteral("X"), clash.id))); + + CHECK(expect(h.svc.folderMoveTargets(b.id)) == QSet{x.id, clash.id, kRootId}); + CHECK(expect(h.svc.folderMoveTargets(x.id)) == QSet{a.id}); + CHECK(expect(h.svc.folderMoveTargets(c.id)) == QSet{a.id, x.id, y.id, clash.id, innerX.id, kRootId}); + expectError(h.svc.folderMoveTargets(kRootId)); } TEST_CASE("service tree_filters counts status and providers") { TestHarness h; h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.active_uuids = {QStringLiteral("u1")}; + h.ctx.activeUuids = {QStringLiteral("u1")}; QHash counts; - for (const TreeFilter& f : h.svc.tree_filters()) { + for (const TreeFilter& f : h.svc.treeFilters()) { counts[f.key] = f.count; } CHECK(counts.value(QStringLiteral("all")) == 2); @@ -377,41 +377,41 @@ TEST_CASE("service tree_filters with persisted tags and empty tag") { h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); h.ctx.add(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three")); - const Tag t1 = expect(wait(h.svc.create_tag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); - const Tag t2 = expect(wait(h.svc.create_tag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); - const Tag t3 = expect(wait(h.svc.create_tag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); + const Tag t1 = expect(wait(h.svc.createTag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); + const Tag t2 = expect(wait(h.svc.createTag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); + const Tag t3 = expect(wait(h.svc.createTag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); - expect(wait(h.svc.set_mod_tags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); - expect(wait(h.svc.set_mod_tags({QStringLiteral("a.two")}, {t1.id}, {}))); + expect(wait(h.svc.setModTags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); + expect(wait(h.svc.setModTags({QStringLiteral("a.two")}, {t1.id}, {}))); - QHash filter_map; - for (const TreeFilter& f : h.svc.tree_filters()) { - filter_map[f.key] = f; + QHash filterMap; + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; } const QString k1 = QStringLiteral("tag:%1").arg(t1.id); const QString k2 = QStringLiteral("tag:%1").arg(t2.id); const QString k3 = QStringLiteral("tag:%1").arg(t3.id); - REQUIRE(filter_map.contains(k1)); - CHECK(filter_map[k1].count == 2); - CHECK(filter_map[k1].label == QStringLiteral("Medieval")); + REQUIRE(filterMap.contains(k1)); + CHECK(filterMap[k1].count == 2); + CHECK(filterMap[k1].label == QStringLiteral("Medieval")); - REQUIRE(filter_map.contains(k2)); - CHECK(filter_map[k2].count == 1); - CHECK(filter_map[k2].label == QStringLiteral("Combat")); + REQUIRE(filterMap.contains(k2)); + CHECK(filterMap[k2].count == 1); + CHECK(filterMap[k2].label == QStringLiteral("Combat")); - REQUIRE(filter_map.contains(k3)); - CHECK(filter_map[k3].count == 0); - CHECK(filter_map[k3].label == QStringLiteral("Empty")); + REQUIRE(filterMap.contains(k3)); + CHECK(filterMap[k3].count == 0); + CHECK(filterMap[k3].label == QStringLiteral("Empty")); - expect(wait(h.svc.delete_tag(t2.id))); - filter_map.clear(); - for (const TreeFilter& f : h.svc.tree_filters()) { - filter_map[f.key] = f; + expect(wait(h.svc.deleteTag(t2.id))); + filterMap.clear(); + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; } - CHECK_FALSE(filter_map.contains(k2)); - CHECK(filter_map.contains(k1)); + CHECK_FALSE(filterMap.contains(k2)); + CHECK(filterMap.contains(k1)); } TEST_CASE("service tree_filters counts shared package ids across copies") { @@ -420,11 +420,11 @@ TEST_CASE("service tree_filters counts shared package ids across copies") { h.ctx.add(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.pkg"), QStringLiteral("local")); h.ctx.add(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.pkg"), QStringLiteral("local")); - const Tag tag = expect(wait(h.svc.create_tag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); - expect(wait(h.svc.set_mod_tags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); + const Tag tag = expect(wait(h.svc.createTag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); + expect(wait(h.svc.setModTags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); QHash counts; - for (const TreeFilter& f : h.svc.tree_filters()) { + for (const TreeFilter& f : h.svc.treeFilters()) { counts[f.key] = f.count; } CHECK(counts.value(QStringLiteral("tag:%1").arg(tag.id)) == 2); @@ -432,26 +432,26 @@ TEST_CASE("service tree_filters counts shared package ids across copies") { TEST_CASE("add_standard_rules is fresh and idempotent") { TestHarness h; - const int total = static_cast(standard_rules().size()); + const int total = static_cast(standardRules().size()); QSet names; - for (const StandardRule& r : standard_rules()) { + for (const StandardRule& r : standardRules()) { names.insert(r.folder); } - CHECK(expect(wait(h.svc.add_standard_rules())) == total); + CHECK(expect(wait(h.svc.addStandardRules())) == total); CHECK(h.changes.count == 1); - QSet top_folders; + QSet topFolders; for (const Folder& f : h.svc.state().folders) { - if (f.parent_id == ROOT_ID) { - top_folders.insert(f.name); + if (f.parentId == kRootId) { + topFolders.insert(f.name); } } - CHECK(top_folders == names); + CHECK(topFolders == names); CHECK(h.svc.state().folders.size() == names.size() + 1); CHECK(h.svc.state().rules.size() == total); - CHECK(expect(wait(h.svc.add_standard_rules())) == 0); + CHECK(expect(wait(h.svc.addStandardRules())) == 0); CHECK(h.changes.count == 1); CHECK(h.svc.state().rules.size() == total); CHECK(h.svc.state().folders.size() == names.size() + 1); @@ -459,16 +459,16 @@ TEST_CASE("add_standard_rules is fresh and idempotent") { TEST_CASE("add_standard_rules reuses existing folders and preserves user rules") { TestHarness h; - const Folder official = expect(wait(h.svc.create_folder(QStringLiteral("OFFICIAL")))); - const Folder mine = expect(wait(h.svc.create_folder(QStringLiteral("Mine")))); - expect(wait(h.svc.set_rules({ - RuleSpec{.field = RuleField::Author, .op = RuleOp::Contains, .pattern = QStringLiteral("me"), .folder_id = mine.id}, - RuleSpec{.field = RuleField::PackageId, .op = RuleOp::Prefix, .pattern = QStringLiteral("SARG."), .folder_id = mine.id}, + const Folder official = expect(wait(h.svc.createFolder(QStringLiteral("OFFICIAL")))); + const Folder mine = expect(wait(h.svc.createFolder(QStringLiteral("Mine")))); + expect(wait(h.svc.setRules({ + RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kContains, .pattern = QStringLiteral("me"), .folderId = mine.id}, + RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kPrefix, .pattern = QStringLiteral("SARG."), .folderId = mine.id}, }))); h.changes.count = 0; - const int total = static_cast(standard_rules().size()); - CHECK(expect(wait(h.svc.add_standard_rules())) == total - 1); + const int total = static_cast(standardRules().size()); + CHECK(expect(wait(h.svc.addStandardRules())) == total - 1); CHECK(h.changes.count == 1); QStringList names; @@ -481,56 +481,56 @@ TEST_CASE("add_standard_rules reuses existing folders and preserves user rules") const auto& rules = h.svc.state().rules; REQUIRE(rules.size() >= 3); - CHECK(h.svc.state().folders.value(rules[0].folder_id).name == QStringLiteral("Mine")); + CHECK(h.svc.state().folders.value(rules[0].folderId).name == QStringLiteral("Mine")); CHECK(rules[0].pattern == QStringLiteral("me")); - CHECK(h.svc.state().folders.value(rules[1].folder_id).name == QStringLiteral("Mine")); + CHECK(h.svc.state().folders.value(rules[1].folderId).name == QStringLiteral("Mine")); CHECK(rules[1].pattern == QStringLiteral("SARG.")); // The first standard rule for Official got re-targeted to the existing OFFICIAL folder. - CHECK(rules[2].folder_id == official.id); + CHECK(rules[2].folderId == official.id); } TEST_CASE("standard rules resolve mods correctly") { TestHarness h; - expect(wait(h.svc.add_standard_rules())); + expect(wait(h.svc.addStandardRules())); const OrganizerState& state = h.svc.state(); - const auto folder_name = [&](const QString& pid, const QString& name = QStringLiteral("Mod"), + const auto folderName = [&](const QString& pid, const QString& name = QStringLiteral("Mod"), const QString& author = {}) { - const auto [fid, _] = folder_for(state, pid, name, author); + const auto [fid, _] = folderFor(state, pid, name, author); return state.folders.value(fid).name; }; - CHECK(folder_name(QStringLiteral("oskarpotocki.vfe.core")) == QStringLiteral("Vanilla Expanded")); - CHECK(folder_name(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), QStringLiteral("Oskar Potocki, Taranchuk")) == + CHECK(folderName(QStringLiteral("oskarpotocki.vfe.core")) == QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), QStringLiteral("Oskar Potocki, Taranchuk")) == QStringLiteral("Vanilla Expanded")); - CHECK(folder_name(QStringLiteral("ludeon.rimworld.biotech"), QStringLiteral("Biotech")) == QStringLiteral("Official")); - CHECK(folder_name(QStringLiteral("brrainz.harmony"), QStringLiteral("Harmony")) == QStringLiteral("Frameworks & Libraries")); - CHECK(folder_name(QStringLiteral("ceteam.combatextended"), QStringLiteral("Combat Extended")) == QStringLiteral("Combat Extended")); - CHECK(folder_name(QStringLiteral("sarg.alphagenes")) == QStringLiteral("Alpha Mods")); - CHECK(folder_name(QStringLiteral("bs.performance"), QStringLiteral("Performance Fish")) == QStringLiteral("Performance")); - CHECK(folder_name(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == QStringLiteral("Frameworks & Libraries")); - CHECK(folder_name(QStringLiteral("vanillaracesexpanded.sanguophage")) == QStringLiteral("Vanilla Expanded")); - CHECK(folder_name(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == QStringLiteral("Performance")); - CHECK(folder_name(QStringLiteral("dubwise.rimatomics")) == QStringLiteral("Dubs Mods")); - CHECK(folder_name(QStringLiteral("brrainz.achtung"), QStringLiteral("Achtung!")) == QStringLiteral("Quality of Life")); - CHECK(folder_name(QStringLiteral("brrainz.jobsofopportunity")) == QStringLiteral("Root")); - CHECK(folder_name(QStringLiteral("smartkar.athena.framework"), QStringLiteral("Athena Framework")) == QStringLiteral("Root")); - CHECK(folder_name(QStringLiteral("someone.perf"), QStringLiteral("Performance Tweaks")) == QStringLiteral("Root")); - CHECK(folder_name(QStringLiteral("someone.other"), QStringLiteral("Other")) == QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("ludeon.rimworld.biotech"), QStringLiteral("Biotech")) == QStringLiteral("Official")); + CHECK(folderName(QStringLiteral("brrainz.harmony"), QStringLiteral("Harmony")) == QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("ceteam.combatextended"), QStringLiteral("Combat Extended")) == QStringLiteral("Combat Extended")); + CHECK(folderName(QStringLiteral("sarg.alphagenes")) == QStringLiteral("Alpha Mods")); + CHECK(folderName(QStringLiteral("bs.performance"), QStringLiteral("Performance Fish")) == QStringLiteral("Performance")); + CHECK(folderName(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("vanillaracesexpanded.sanguophage")) == QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == QStringLiteral("Performance")); + CHECK(folderName(QStringLiteral("dubwise.rimatomics")) == QStringLiteral("Dubs Mods")); + CHECK(folderName(QStringLiteral("brrainz.achtung"), QStringLiteral("Achtung!")) == QStringLiteral("Quality of Life")); + CHECK(folderName(QStringLiteral("brrainz.jobsofopportunity")) == QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("smartkar.athena.framework"), QStringLiteral("Athena Framework")) == QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.perf"), QStringLiteral("Performance Tweaks")) == QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.other"), QStringLiteral("Other")) == QStringLiteral("Root")); } TEST_CASE("standard package_id rules are not shadowed") { TestHarness h; - expect(wait(h.svc.add_standard_rules())); + expect(wait(h.svc.addStandardRules())); const OrganizerState& state = h.svc.state(); - for (const StandardRule& rule : standard_rules()) { - if (rule.field != RuleField::PackageId) { + for (const StandardRule& rule : standardRules()) { + if (rule.field != RuleField::kPackageId) { continue; } - const QString probe = rule.op == RuleOp::Equals ? rule.pattern : rule.pattern + QStringLiteral("x"); - const auto [fid, _] = folder_for(state, probe, QStringLiteral("Mod"), {}); + const QString probe = rule.op == RuleOp::kEquals ? rule.pattern : rule.pattern + QStringLiteral("x"); + const auto [fid, _] = folderFor(state, probe, QStringLiteral("Mod"), {}); CHECK(state.folders.value(fid).name == rule.folder); } } diff --git a/tests/core/organizer/test_util.hpp b/tests/core/organizer/test_util.hpp index 64e2b8a..a5a5ff3 100644 --- a/tests/core/organizer/test_util.hpp +++ b/tests/core/organizer/test_util.hpp @@ -28,7 +28,7 @@ inline void expect(const px::modrim::Result& result) { template -void expect_error(const px::modrim::Result& result, const QString& fragment = {}) { +void expectError(const px::modrim::Result& result, const QString& fragment = {}) { REQUIRE_FALSE(result.has_value()); if (!fragment.isEmpty()) { CHECK(result.error().message.contains(fragment)); diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp index 4788a91..d190510 100644 --- a/tests/core/providers/test_factory.cpp +++ b/tests/core/providers/test_factory.cpp @@ -8,11 +8,11 @@ #include "core/providers/local.hpp" using px::modrim::PathConfig; -using px::modrim::providers::create_providers; +using px::modrim::providers::createProviders; using px::modrim::providers::SteamWorkshopModProvider; TEST_CASE("no configured paths produce no providers") { - CHECK(create_providers(PathConfig{}).empty()); + CHECK(createProviders(PathConfig{}).empty()); } TEST_CASE("each configured path yields its providers in core, local, downloaded, steam order") { @@ -21,10 +21,10 @@ TEST_CASE("each configured path yields its providers in core, local, downloaded, paths.local = "/mods"; paths.workshop = "/steam/workshop"; - const auto providers = create_providers(paths); + const auto providers = createProviders(paths); QStringList ids; for (const auto& provider : providers) { - ids.append(provider->provider_id()); + ids.append(provider->providerId()); } CHECK(ids == QStringList{"core", "local", "downloaded", "steam"}); CHECK(providers[0]->path() == QStringLiteral("/games/rimworld")); @@ -37,8 +37,8 @@ TEST_CASE("each configured path yields its providers in core, local, downloaded, TEST_CASE("only the configured paths produce providers") { PathConfig paths; paths.local = "/mods"; - const auto providers = create_providers(paths); + const auto providers = createProviders(paths); REQUIRE(providers.size() == 2); - CHECK(providers[0]->provider_id() == QStringLiteral("local")); - CHECK(providers[1]->provider_id() == QStringLiteral("downloaded")); + CHECK(providers[0]->providerId() == QStringLiteral("local")); + CHECK(providers[1]->providerId() == QStringLiteral("downloaded")); } diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp index 2231233..982de5b 100644 --- a/tests/core/providers/test_providers.cpp +++ b/tests/core/providers/test_providers.cpp @@ -72,7 +72,7 @@ class FakeCache final : public MetadataCacheStore { int getCalls = 0; QList stored; - QCoro::Task get_mods( + QCoro::Task getMods( QList candidates, QString /*target_version*/) override { ++getCalls; px::modrim::providers::CacheLookup lookup; @@ -84,7 +84,7 @@ class FakeCache final : public MetadataCacheStore { co_return lookup; } - QCoro::Task put_mods(QList entries, QString /*target_version*/) override { + QCoro::Task putMods(QList entries, QString /*target_version*/) override { stored.append(entries); co_return; } @@ -100,15 +100,15 @@ TEST_CASE("workshop provider discovers mods that carry a published file id") { QStringLiteral("author.workshop"), true); SteamWorkshopModProvider provider(workshop); - CHECK(provider.provider_id() == QStringLiteral("steam")); + CHECK(provider.providerId() == QStringLiteral("steam")); CHECK(provider.path() == workshop); const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); REQUIRE(discovered.has_value()); REQUIRE(discovered->size() == 1); const ListedModPtr mod = discovered->constBegin().value(); - CHECK(mod->mod_path() == modPath); - CHECK(mod->provider_id == QStringLiteral("steam")); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId == QStringLiteral("steam")); } TEST_CASE("local and downloaded providers split mods on the published file id") { @@ -121,8 +121,8 @@ TEST_CASE("local and downloaded providers split mods on the published file id") LocalModProvider localProvider(local); DownloadedModProvider downloadedProvider(local); - CHECK(localProvider.provider_id() == QStringLiteral("local")); - CHECK(downloadedProvider.provider_id() == QStringLiteral("downloaded")); + CHECK(localProvider.providerId() == QStringLiteral("local")); + CHECK(downloadedProvider.providerId() == QStringLiteral("downloaded")); const auto localMods = runToCompletion(localProvider.discover(QStringLiteral("1.5"))); REQUIRE(localMods.has_value()); @@ -158,7 +158,7 @@ TEST_CASE("core provider reads mods from the game's Data directory") { QTemporaryDir game; REQUIRE(game.isValid()); CoreModProvider provider(game.path()); - CHECK(provider.provider_id() == QStringLiteral("core")); + CHECK(provider.providerId() == QStringLiteral("core")); CHECK(provider.color() == QStringLiteral("#e67e22")); // No Data/ directory: nothing to discover. @@ -183,14 +183,14 @@ TEST_CASE("cached metadata is returned without parsing") { FakeCache cache; cache.serveCached = true; cache.cachedMod = std::make_shared(); - REQUIRE(cache.cachedMod->set_mod_path(plain)); + REQUIRE(cache.cachedMod->setModPath(plain)); LocalModProvider provider(root.path(), nullptr, &cache); const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); REQUIRE(discovered.has_value()); REQUIRE(discovered->size() == 1); CHECK(discovered->constBegin().value() == cache.cachedMod); - CHECK(cache.cachedMod->provider_id == QStringLiteral("local")); + CHECK(cache.cachedMod->providerId == QStringLiteral("local")); CHECK(cache.stored.isEmpty()); } diff --git a/tests/core/services/services_support.hpp b/tests/core/services/services_support.hpp index d7711da..1043690 100644 --- a/tests/core/services/services_support.hpp +++ b/tests/core/services/services_support.hpp @@ -20,19 +20,19 @@ class EventLoop { public: EventLoop() { if (!QCoreApplication::instance()) { - app_.emplace(argc_, nullptr); + m_app.emplace(m_argc, nullptr); } } private: - int argc_ = 0; - std::optional app_; + int m_argc = 0; + std::optional m_app; }; // Runs a coroutine to completion on the event loop. Coroutines that never suspend finish // before the loop starts. template -T run_async(QCoro::Task task) { +T runAsync(QCoro::Task task) { // std::optional is ill-formed, so a void result is carried as std::monostate. using Outcome = std::conditional_t, std::monostate, T>; std::optional result; @@ -57,7 +57,7 @@ T run_async(QCoro::Task task) { } } -inline bool write_text(const QString& path, const QByteArray& bytes) { +inline bool writeText(const QString& path, const QByteArray& bytes) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp index 64d01ed..9a892f6 100644 --- a/tests/core/services/startup_impact_service/test_db.cpp +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -36,25 +36,25 @@ auto ok(QCoro::Task> task) { } } -StartupImpactMod mod_entry(const QString& name, const std::optional& pid, - double total_s, double off_s = 0.0) { +StartupImpactMod modEntry(const QString& name, const std::optional& pid, + double totalS, double offS = 0.0) { StartupImpactMod mod; - mod.mod_name = name; - mod.package_id = pid; - mod.total_impact_s = total_s; - mod.off_thread_total_impact_s = off_s; + mod.modName = name; + mod.packageId = pid; + mod.totalImpactS = totalS; + mod.offThreadTotalImpactS = offS; return mod; } -StartupImpactReport make_report(const QString& pid, double total_s, double off_thread_s = 0.0) { +StartupImpactReport makeReport(const QString& pid, double totalS, double offThreadS = 0.0) { StartupImpactReport report; - report.loading_time_s = 1.0; + report.loadingTimeS = 1.0; report.timestamp = QStringLiteral("now"); - report.mods.append(mod_entry(QStringLiteral("Mod ") + pid, pid, total_s, off_thread_s)); + report.mods.append(modEntry(QStringLiteral("Mod ") + pid, pid, totalS, offThreadS)); return report; } -QString db_file(const QTemporaryDir& dir) { return QDir(dir.path()).filePath(QStringLiteral("cache.db")); } +QString dbFile(const QTemporaryDir& dir) { return QDir(dir.path()).filePath(QStringLiteral("cache.db")); } } // namespace @@ -63,21 +63,21 @@ TEST_CASE("store and clear run in the order they were issued") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); // Both calls are issued before either is awaited. The single worker runs them in order. - const auto report = make_report(QStringLiteral("author.example"), 0.5); - auto stored = db.store_report(path, report); + const auto report = makeReport(QStringLiteral("author.example"), 0.5); + auto stored = db.storeReport(path, report); auto cleared = db.clear(path); REQUIRE(QCoro::waitFor(std::move(stored)).has_value()); REQUIRE(QCoro::waitFor(std::move(cleared)).has_value()); - CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); - ok(db.store_report(path, report)); - const auto latest = ok(db.get_latest_report(path)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); REQUIRE(latest.has_value()); REQUIRE(latest->mods.size() == 1); - CHECK(latest->mods[0].package_id == QStringLiteral("author.example")); + CHECK(latest->mods[0].packageId == QStringLiteral("author.example")); } TEST_CASE("base game keeps its sentinel package id after a round trip") { @@ -85,22 +85,22 @@ TEST_CASE("base game keeps its sentinel package id after a round trip") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); StartupImpactReport report; - report.loading_time_s = 1.0; + report.loadingTimeS = 1.0; report.timestamp = QStringLiteral("now"); - report.mods.append(mod_entry(QStringLiteral("Example"), QStringLiteral("author.example"), 0.5)); - report.mods.append(mod_entry(QString(BASE_GAME_PID), QString(BASE_GAME_PID), 2.0)); - ok(db.store_report(path, report)); - const auto latest = ok(db.get_latest_report(path)); + report.mods.append(modEntry(QStringLiteral("Example"), QStringLiteral("author.example"), 0.5)); + report.mods.append(modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); REQUIRE(latest.has_value()); - QHash> by_name; + QHash> byName; for (const StartupImpactMod& mod : latest->mods) { - by_name.insert(mod.mod_name, mod.package_id); + byName.insert(mod.modName, mod.packageId); } - REQUIRE(by_name.value(QString(BASE_GAME_PID)) == QString(BASE_GAME_PID)); + REQUIRE(byName.value(QString(kBaseGamePid)) == QString(kBaseGamePid)); } TEST_CASE("a mod stored without a package id reads back without one") { @@ -108,17 +108,17 @@ TEST_CASE("a mod stored without a package id reads back without one") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); StartupImpactReport report; - report.loading_time_s = 1.0; - report.mods.append(mod_entry(QStringLiteral("No Package"), std::nullopt, 0.2)); - ok(db.store_report(path, report)); - const auto latest = ok(db.get_latest_report(path)); + report.loadingTimeS = 1.0; + report.mods.append(modEntry(QStringLiteral("No Package"), std::nullopt, 0.2)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); REQUIRE(latest.has_value()); REQUIRE(latest->mods.size() == 1); - CHECK_FALSE(latest->mods[0].package_id.has_value()); - CHECK(latest->mods[0].mod_name == QStringLiteral("No Package")); + CHECK_FALSE(latest->mods[0].packageId.has_value()); + CHECK(latest->mods[0].modName == QStringLiteral("No Package")); } TEST_CASE("metrics survive a round trip") { @@ -126,25 +126,25 @@ TEST_CASE("metrics survive a round trip") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); StartupImpactReport report; - report.loading_time_s = 3.0; + report.loadingTimeS = 3.0; StartupImpactMod harmony = - mod_entry(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); + modEntry(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); harmony.metrics.insert(QStringLiteral("ModConstructor"), 1.0); - harmony.off_thread_metrics.insert(QStringLiteral("LoadPatches"), 0.3); + harmony.offThreadMetrics.insert(QStringLiteral("LoadPatches"), 0.3); report.mods.append(std::move(harmony)); - ok(db.store_report(path, report)); - const auto latest = ok(db.get_latest_report(path)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); REQUIRE(latest.has_value()); REQUIRE(latest->mods.size() == 1); const StartupImpactMod& mod = latest->mods[0]; - CHECK(mod.total_impact_s == Catch::Approx(1.25)); + CHECK(mod.totalImpactS == Catch::Approx(1.25)); CHECK(mod.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); - CHECK(mod.off_thread_metrics.value(QStringLiteral("LoadPatches")) == Catch::Approx(0.3)); - CHECK(mod.off_thread_total_impact_s == Catch::Approx(0.3)); - CHECK(latest->loading_time_s == Catch::Approx(3.0)); + CHECK(mod.offThreadMetrics.value(QStringLiteral("LoadPatches")) == Catch::Approx(0.3)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.3)); + CHECK(latest->loadingTimeS == Catch::Approx(3.0)); } TEST_CASE("averages weight the newest session most") { @@ -152,19 +152,19 @@ TEST_CASE("averages weight the newest session most") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); const QString pid = QStringLiteral("a.a"); - ok(db.store_report(path, make_report(pid, 1.0, 0.5))); - ok(db.store_report(path, make_report(pid, 3.0, 1.5))); + ok(db.storeReport(path, makeReport(pid, 1.0, 0.5))); + ok(db.storeReport(path, makeReport(pid, 3.0, 1.5))); // Older session weighs 9, newest weighs 10: (1.0*9 + 3.0*10) / 19 and (0.5*9 + 1.5*10) / 19. - CHECK_THAT(ok(db.get_average(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); - const auto totals = ok(db.get_totals_for(path, {pid})); + CHECK_THAT(ok(db.getAverage(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); + const auto totals = ok(db.getTotalsFor(path, {pid})); REQUIRE(totals.contains(pid)); - CHECK_THAT(totals.value(pid).on_thread, WithinAbs(39.0 / 19.0, 1e-9)); - CHECK_THAT(totals.value(pid).off_thread, WithinAbs(19.5 / 19.0, 1e-9)); - CHECK(ok(db.get_all_averages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); + CHECK_THAT(totals.value(pid).onThread, WithinAbs(39.0 / 19.0, 1e-9)); + CHECK_THAT(totals.value(pid).offThread, WithinAbs(19.5 / 19.0, 1e-9)); + CHECK(ok(db.getAllAverages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); } TEST_CASE("only the most recent sessions contribute to averages") { @@ -172,17 +172,17 @@ TEST_CASE("only the most recent sessions contribute to averages") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); - ok(db.store_report(path, make_report(QStringLiteral("old.mod"), 100.0))); + ok(db.storeReport(path, makeReport(QStringLiteral("old.mod"), 100.0))); for (int i = 0; i < 5; ++i) { - ok(db.store_report(path, make_report(QStringLiteral("new.mod"), 1.0))); + ok(db.storeReport(path, makeReport(QStringLiteral("new.mod"), 1.0))); } // Five sessions are kept, so the old one is gone from history entirely. - CHECK(ok(db.get_average(path, QStringLiteral("old.mod"))) == 0.0); - CHECK_THAT(ok(db.get_average(path, QStringLiteral("new.mod"))), WithinAbs(1.0, 1e-9)); - CHECK_FALSE(ok(db.get_all_averages(path)).contains(QStringLiteral("old.mod"))); + CHECK(ok(db.getAverage(path, QStringLiteral("old.mod"))) == 0.0); + CHECK_THAT(ok(db.getAverage(path, QStringLiteral("new.mod"))), WithinAbs(1.0, 1e-9)); + CHECK_FALSE(ok(db.getAllAverages(path)).contains(QStringLiteral("old.mod"))); } TEST_CASE("snapshot returns the report with averages for the requested mods") { @@ -190,23 +190,23 @@ TEST_CASE("snapshot returns the report with averages for the requested mods") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); - StartupImpactReport report = make_report(QStringLiteral("a.a"), 1.0, 0.2); - report.mods.append(mod_entry(QString(BASE_GAME_PID), QString(BASE_GAME_PID), 2.0)); - ok(db.store_report(path, report)); + StartupImpactReport report = makeReport(QStringLiteral("a.a"), 1.0, 0.2); + report.mods.append(modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); - const auto snap = ok(db.get_latest_with_averages( + const auto snap = ok(db.getLatestWithAverages( path, {QStringLiteral("a.a"), QStringLiteral("missing.mod")}, QStringLiteral("a.a"))); REQUIRE(snap.report.has_value()); CHECK(snap.report->mods.size() == 2); - CHECK_THAT(snap.base_game_avg, WithinAbs(2.0, 1e-9)); - CHECK_THAT(snap.selected_avg, WithinAbs(1.0, 1e-9)); + CHECK_THAT(snap.baseGameAvg, WithinAbs(2.0, 1e-9)); + CHECK_THAT(snap.selectedAvg, WithinAbs(1.0, 1e-9)); REQUIRE(snap.totals.contains(QStringLiteral("a.a"))); - CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).on_thread, WithinAbs(1.0, 1e-9)); - CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).off_thread, WithinAbs(0.2, 1e-9)); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).onThread, WithinAbs(1.0, 1e-9)); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).offThread, WithinAbs(0.2, 1e-9)); REQUIRE(snap.totals.contains(QStringLiteral("missing.mod"))); - CHECK(snap.totals.value(QStringLiteral("missing.mod")).on_thread == 0.0); + CHECK(snap.totals.value(QStringLiteral("missing.mod")).onThread == 0.0); } TEST_CASE("reads of a missing database return empty results and create no file") { @@ -214,15 +214,15 @@ TEST_CASE("reads of a missing database return empty results and create no file") QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); - CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); - CHECK(ok(db.get_average(path, QStringLiteral("a.a"))) == 0.0); - CHECK(ok(db.get_all_averages(path)).isEmpty()); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAverage(path, QStringLiteral("a.a"))) == 0.0); + CHECK(ok(db.getAllAverages(path)).isEmpty()); - const auto snap = ok(db.get_latest_with_averages(path, {QStringLiteral("a.a")}, {})); + const auto snap = ok(db.getLatestWithAverages(path, {QStringLiteral("a.a")}, {})); CHECK_FALSE(snap.report.has_value()); - CHECK(snap.totals.value(QStringLiteral("a.a")).on_thread == 0.0); + CHECK(snap.totals.value(QStringLiteral("a.a")).onThread == 0.0); CHECK_FALSE(QFileInfo::exists(path)); } @@ -234,7 +234,7 @@ TEST_CASE("store creates a missing parent directory") { const QString path = QDir(dir.path()).filePath(QStringLiteral("nested/config/cache.db")); - ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); CHECK(QFileInfo::exists(path)); } @@ -243,12 +243,12 @@ TEST_CASE("clear removes all history") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); + const QString path = dbFile(dir); - ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); ok(db.clear(path)); - CHECK_FALSE(ok(db.get_latest_report(path)).has_value()); - CHECK(ok(db.get_all_averages(path)).isEmpty()); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAllAverages(path)).isEmpty()); } TEST_CASE("the database uses the legacy schema and version") { @@ -256,8 +256,8 @@ TEST_CASE("the database uses the legacy schema and version") { QTemporaryDir dir; REQUIRE(dir.isValid()); StartupImpactDb db; - const QString path = db_file(dir); - ok(db.store_report(path, make_report(QStringLiteral("a.a"), 1.0))); + const QString path = dbFile(dir); + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); // A second connection reads what the first wrote, as the legacy app would. const QString name = QStringLiteral("startup_impact_schema_check"); diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp index edc02ed..04ffb5f 100644 --- a/tests/core/services/startup_impact_service/test_phases.cpp +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -22,26 +22,26 @@ TEST_CASE("metric_phase assigns each metric key to its phase") { Phase phase; }; const Case cases[] = { - {"ModContentPackReloadContentInt.Textures", Phase::Content}, - {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::Content}, - {"ModConstructor", Phase::ModConstructors}, - {"StaticConstructorOnStartupUtilityCallAll", Phase::StaticConstructors}, - {"ApplyPatches", Phase::Patches}, - {"ErrorCheckPatches", Phase::Patches}, - {"LoadDefs", Phase::Defs}, - {"ResolveAllWantedCrossReferences.NonImplied", Phase::Defs}, - {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", Phase::Deferred}, - {"LanguageDatabaseInitAllMetadata", Phase::Other}, - {"SomethingNew", Phase::Other}, + {"ModContentPackReloadContentInt.Textures", Phase::kContent}, + {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::kContent}, + {"ModConstructor", Phase::kModConstructors}, + {"StaticConstructorOnStartupUtilityCallAll", Phase::kStaticConstructors}, + {"ApplyPatches", Phase::kPatches}, + {"ErrorCheckPatches", Phase::kPatches}, + {"LoadDefs", Phase::kDefs}, + {"ResolveAllWantedCrossReferences.NonImplied", Phase::kDefs}, + {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", Phase::kDeferred}, + {"LanguageDatabaseInitAllMetadata", Phase::kOther}, + {"SomethingNew", Phase::kOther}, }; for (const Case& c : cases) { INFO(c.key); - CHECK(metric_phase(kPrefix + QString::fromLatin1(c.key)) == c.phase); + CHECK(metricPhase(kPrefix + QString::fromLatin1(c.key)) == c.phase); } } TEST_CASE("metric_phase also accepts keys without the prefix") { - CHECK(metric_phase(QStringLiteral("LoadDefs")) == Phase::Defs); + CHECK(metricPhase(QStringLiteral("LoadDefs")) == Phase::kDefs); } TEST_CASE("phase_totals sums per phase and zero-fills the rest") { @@ -50,33 +50,33 @@ TEST_CASE("phase_totals sums per phase and zero-fills the rest") { {kPrefix + QStringLiteral("LoadPatches"), 0.5}, {kPrefix + QStringLiteral("LoadDefs"), 2.0}, }; - const PhaseTotals totals = phase_totals(metrics); - CHECK_THAT(totals[phase_index(Phase::Patches)], WithinAbs(2.0, 1e-9)); - CHECK_THAT(totals[phase_index(Phase::Defs)], WithinAbs(2.0, 1e-9)); - CHECK(totals[phase_index(Phase::Content)] == 0.0); - CHECK(totals.size() == PHASE_COUNT); + const PhaseTotals totals = phaseTotals(metrics); + CHECK_THAT(totals[phaseIndex(Phase::kPatches)], WithinAbs(2.0, 1e-9)); + CHECK_THAT(totals[phaseIndex(Phase::kDefs)], WithinAbs(2.0, 1e-9)); + CHECK(totals[phaseIndex(Phase::kContent)] == 0.0); + CHECK(totals.size() == kPhaseCount); } TEST_CASE("every phase has a label") { - for (std::size_t i = 0; i < PHASE_COUNT; ++i) { - CHECK_FALSE(phase_label(static_cast(i)).isEmpty()); + for (std::size_t i = 0; i < kPhaseCount; ++i) { + CHECK_FALSE(phaseLabel(static_cast(i)).isEmpty()); } - CHECK(phase_label(Phase::Defs) == QStringLiteral("Defs & XML")); + CHECK(phaseLabel(Phase::kDefs) == QStringLiteral("Defs & XML")); } TEST_CASE("metric_label names a deferred task's target") { const QString key = kPrefix + QStringLiteral("ExecuteToExecuteWhenFinished|RimWorld.AbilityDef -> Void b__88_0()"); - CHECK(metric_label(key) == + CHECK(metricLabel(key) == QStringLiteral("Running delayed initialization task (RimWorld.AbilityDef)")); } TEST_CASE("metric_label falls back to the last key segment, then the key itself") { - CHECK(metric_label(kPrefix + QStringLiteral("ModContentPackReloadContentInt.Textures")) == + CHECK(metricLabel(kPrefix + QStringLiteral("ModContentPackReloadContentInt.Textures")) == QStringLiteral("Loading textures")); - CHECK(metric_label(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == + CHECK(metricLabel(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == QStringLiteral("Loading defs")); - CHECK(metric_label(kPrefix + QStringLiteral("Totally.New")) == QStringLiteral("Totally.New")); - CHECK(metric_label(QStringLiteral("ModConstructor")) == QStringLiteral("Running mod constructor")); + CHECK(metricLabel(kPrefix + QStringLiteral("Totally.New")) == QStringLiteral("Totally.New")); + CHECK(metricLabel(QStringLiteral("ModConstructor")) == QStringLiteral("Running mod constructor")); } diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp index 5cf9b9c..70e3da6 100644 --- a/tests/core/services/startup_impact_service/test_startup_impact.cpp +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -12,7 +12,7 @@ using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; -using test_support::write_bytes; +using test_support::writeBytes; namespace { @@ -71,152 +71,152 @@ const char* kSampleJson = R"({ )"; // Writes `json` to a file in `dir` and parses it. -std::optional parse_text(const QTemporaryDir& dir, const QString& name, +std::optional parseText(const QTemporaryDir& dir, const QString& name, const char* json) { const QString path = QDir(dir.path()).filePath(name); - REQUIRE(write_bytes(path, json)); - return parse_startup_impact(path); + REQUIRE(writeBytes(path, json)); + return parseStartupImpact(path); } } // namespace TEST_CASE("normalize_package_id lowercases and strips the steam suffix") { - CHECK(normalize_package_id(QStringLiteral("Author.Mod")) == QStringLiteral("author.mod")); - CHECK(normalize_package_id(QStringLiteral("author.mod_steam")) == + CHECK(normalizePackageId(QStringLiteral("Author.Mod")) == QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod_steam")) == QStringLiteral("author.mod")); - CHECK(normalize_package_id(QStringLiteral("author.mod")) == QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod")) == QStringLiteral("author.mod")); } TEST_CASE("format_impact rounds seconds to whole milliseconds") { - CHECK(format_impact(1.234) == QStringLiteral("1234ms")); - CHECK(format_impact(0.05) == QStringLiteral("50ms")); - CHECK(format_impact(0.0) == QStringLiteral("0ms")); + CHECK(formatImpact(1.234) == QStringLiteral("1234ms")); + CHECK(formatImpact(0.05) == QStringLiteral("50ms")); + CHECK(formatImpact(0.0) == QStringLiteral("0ms")); } TEST_CASE("format_impact rounds half to even like Python's round") { - CHECK(format_impact(0.0005) == QStringLiteral("0ms")); - CHECK(format_impact(0.0015) == QStringLiteral("2ms")); - CHECK(format_impact(0.0025) == QStringLiteral("2ms")); + CHECK(formatImpact(0.0005) == QStringLiteral("0ms")); + CHECK(formatImpact(0.0015) == QStringLiteral("2ms")); + CHECK(formatImpact(0.0025) == QStringLiteral("2ms")); } TEST_CASE("get_startup_impact_path sits beside the config folder") { - CHECK(get_startup_impact_path(QStringLiteral("/home/user/Config")) == + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config")) == QStringLiteral("/home/user/StartupImpactData.json")); - CHECK(get_startup_impact_path(QStringLiteral("/home/user/Config/")) == + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config/")) == QStringLiteral("/home/user/StartupImpactData.json")); - CHECK(get_startup_impact_path(QStringLiteral("/Config")) == + CHECK(getStartupImpactPath(QStringLiteral("/Config")) == QStringLiteral("/StartupImpactData.json")); } TEST_CASE("parse_startup_impact reads the full report") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); REQUIRE(report.has_value()); - CHECK(report->loading_time_s == Catch::Approx(42.5005)); + CHECK(report->loadingTimeS == Catch::Approx(42.5005)); CHECK(report->timestamp == QStringLiteral("2026-07-16T14:30:45.123Z")); REQUIRE(report->metrics.size() == 2); CHECK(report->metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(10.2)); CHECK(report->metrics.value(QStringLiteral("ResolveReferences")) == Catch::Approx(1.5)); - CHECK(report->total_impact == 0.0); + CHECK(report->totalImpact == 0.0); CHECK(report->mods.size() == 4); } TEST_CASE("base game time is the sum of the base-game metrics") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); REQUIRE(report.has_value()); // Not loading time minus mod impacts: loading time includes unaccounted time. - CHECK_THAT(report->base_game_time(), WithinAbs(11.7, kEps)); - CHECK(std::abs(report->loading_time_s - report->total_time()) > 1.0); + CHECK_THAT(report->baseGameTime(), WithinAbs(11.7, kEps)); + CHECK(std::abs(report->loadingTimeS - report->totalTime()) > 1.0); } TEST_CASE("mods_total_time includes off-thread impact") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); REQUIRE(report.has_value()); const StartupImpactMod& harmony = report->mods[0]; - CHECK(harmony.total_impact_s == Catch::Approx(1.2305)); - CHECK(harmony.off_thread_total_impact_s == Catch::Approx(0.3)); + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); + CHECK(harmony.offThreadTotalImpactS == Catch::Approx(0.3)); // total_time counts on-thread impact only; mods_total_time adds off-thread impact. - CHECK_THAT(report->total_time(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); - CHECK_THAT(report->off_thread_total_time(), WithinAbs(0.3, kEps)); - CHECK_THAT(report->mods_total_time(), WithinAbs(report->total_time() + 0.3, kEps)); + CHECK_THAT(report->totalTime(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); + CHECK_THAT(report->offThreadTotalTime(), WithinAbs(0.3, kEps)); + CHECK_THAT(report->modsTotalTime(), WithinAbs(report->totalTime() + 0.3, kEps)); } TEST_CASE("parse_startup_impact reads mod values") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); REQUIRE(report.has_value()); const StartupImpactMod& harmony = report->mods[0]; - CHECK(harmony.mod_name == QStringLiteral("Harmony")); - CHECK(harmony.package_id == QStringLiteral("brrainz.harmony")); - CHECK(harmony.total_impact_s == Catch::Approx(1.2305)); + CHECK(harmony.modName == QStringLiteral("Harmony")); + CHECK(harmony.packageId == QStringLiteral("brrainz.harmony")); + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); CHECK(harmony.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); - const StartupImpactMod& some_mod = report->mods[1]; - CHECK(some_mod.mod_name == QStringLiteral("Some Mod")); - CHECK(some_mod.package_id == QStringLiteral("author.somemod")); - CHECK(some_mod.total_impact_s == Catch::Approx(4.5)); - CHECK(some_mod.metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(4.5)); + const StartupImpactMod& someMod = report->mods[1]; + CHECK(someMod.modName == QStringLiteral("Some Mod")); + CHECK(someMod.packageId == QStringLiteral("author.somemod")); + CHECK(someMod.totalImpactS == Catch::Approx(4.5)); + CHECK(someMod.metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(4.5)); const StartupImpactMod& fast = report->mods[2]; - CHECK(fast.mod_name == QStringLiteral("Fast Mod")); - CHECK(fast.package_id == QStringLiteral("author.fast")); - CHECK(fast.total_impact_s == Catch::Approx(0.05)); + CHECK(fast.modName == QStringLiteral("Fast Mod")); + CHECK(fast.packageId == QStringLiteral("author.fast")); + CHECK(fast.totalImpactS == Catch::Approx(0.05)); CHECK(fast.metrics.isEmpty()); } TEST_CASE("a mod without a package id has none after parsing") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); REQUIRE(report.has_value()); - const StartupImpactMod& no_pid = report->mods[3]; - CHECK(no_pid.mod_name == QStringLiteral("No Package ID Mod")); - CHECK_FALSE(no_pid.package_id.has_value()); - CHECK(no_pid.total_impact_s == Catch::Approx(0.2)); - CHECK(no_pid.metrics.isEmpty()); + const StartupImpactMod& noPid = report->mods[3]; + CHECK(noPid.modName == QStringLiteral("No Package ID Mod")); + CHECK_FALSE(noPid.packageId.has_value()); + CHECK(noPid.totalImpactS == Catch::Approx(0.2)); + CHECK(noPid.metrics.isEmpty()); } TEST_CASE("parse_startup_impact returns nullopt for a missing file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - CHECK_FALSE(parse_startup_impact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) + CHECK_FALSE(parseStartupImpact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) .has_value()); } TEST_CASE("parse_startup_impact reads an empty mod list") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("empty_mods.json"), + const auto report = parseText(dir, QStringLiteral("empty_mods.json"), R"({"loadingTime": 1000.0, "mods": []})"); REQUIRE(report.has_value()); - CHECK(report->loading_time_s == Catch::Approx(1.0)); + CHECK(report->loadingTimeS == Catch::Approx(1.0)); CHECK(report->mods.isEmpty()); } TEST_CASE("parse_startup_impact rejects a top-level array") { QTemporaryDir dir; REQUIRE(dir.isValid()); - CHECK_FALSE(parse_text(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); + CHECK_FALSE(parseText(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); } TEST_CASE("parse_startup_impact rejects malformed JSON") { QTemporaryDir dir; REQUIRE(dir.isValid()); - CHECK_FALSE(parse_text(dir, QStringLiteral("bad.json"), "{invalid").has_value()); + CHECK_FALSE(parseText(dir, QStringLiteral("bad.json"), "{invalid").has_value()); } TEST_CASE("parse_startup_impact accepts an object without known keys") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("invalid.json"), + const auto report = parseText(dir, QStringLiteral("invalid.json"), R"({"wrongKey": "value"})"); REQUIRE(report.has_value()); CHECK(report->mods.isEmpty()); @@ -225,7 +225,7 @@ TEST_CASE("parse_startup_impact accepts an object without known keys") { TEST_CASE("total_time sums on-thread mod impact") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("total.json"), R"({ + const auto report = parseText(dir, QStringLiteral("total.json"), R"({ "loadingTime": 50000.0, "mods": [ {"modName": "A", "modPackageId": "a.a", @@ -237,22 +237,22 @@ TEST_CASE("total_time sums on-thread mod impact") { ] })"); REQUIRE(report.has_value()); - CHECK(report->total_time() == Catch::Approx(5.0)); + CHECK(report->totalTime() == Catch::Approx(5.0)); } TEST_CASE("total_time of an empty report is zero") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("total_empty.json"), + const auto report = parseText(dir, QStringLiteral("total_empty.json"), R"({"loadingTime": 1000.0, "mods": []})"); REQUIRE(report.has_value()); - CHECK(report->total_time() == 0.0); + CHECK(report->totalTime() == 0.0); } TEST_CASE("parse_startup_impact reads off-thread mod fields") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto report = parse_text(dir, QStringLiteral("offthread.json"), R"({ + const auto report = parseText(dir, QStringLiteral("offthread.json"), R"({ "loadingTime": 10000.0, "mods": [ { @@ -267,29 +267,29 @@ TEST_CASE("parse_startup_impact reads off-thread mod fields") { })"); REQUIRE(report.has_value()); const StartupImpactMod& mod = report->mods[0]; - REQUIRE(mod.off_thread_metrics.size() == 1); - CHECK(mod.off_thread_metrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); - CHECK(mod.off_thread_total_impact_s == Catch::Approx(0.5)); + REQUIRE(mod.offThreadMetrics.size() == 1); + CHECK(mod.offThreadMetrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.5)); } TEST_CASE("find matches by package id, then by mod name") { StartupImpactReport report; StartupImpactMod m1; - m1.mod_name = QStringLiteral("Harmony"); - m1.package_id = QStringLiteral("brrainz.harmony"); + m1.modName = QStringLiteral("Harmony"); + m1.packageId = QStringLiteral("brrainz.harmony"); report.mods.append(std::move(m1)); StartupImpactMod m2; - m2.mod_name = QStringLiteral("Other"); - m2.package_id = QStringLiteral("other.mod"); + m2.modName = QStringLiteral("Other"); + m2.packageId = QStringLiteral("other.mod"); report.mods.append(std::move(m2)); - const StartupImpactMod* by_id = report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); - REQUIRE(by_id != nullptr); - CHECK(by_id->mod_name == QStringLiteral("Harmony")); + const StartupImpactMod* byId = report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); + REQUIRE(byId != nullptr); + CHECK(byId->modName == QStringLiteral("Harmony")); - const StartupImpactMod* by_name = report.find({}, QStringLiteral("OTHER")); - REQUIRE(by_name != nullptr); - CHECK(by_name->package_id == QStringLiteral("other.mod")); + const StartupImpactMod* byName = report.find({}, QStringLiteral("OTHER")); + REQUIRE(byName != nullptr); + CHECK(byName->packageId == QStringLiteral("other.mod")); CHECK(report.find(QStringLiteral("missing.mod"), QStringLiteral("Missing")) == nullptr); CHECK(report.find(QString(), QString()) == nullptr); diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp index a25ae71..cedafe9 100644 --- a/tests/core/services/test_activation_service.cpp +++ b/tests/core/services/test_activation_service.cpp @@ -34,39 +34,39 @@ QStringList ids(std::initializer_list items) { } // About.xml mod with the given rules. Each dependency is a bare package id. -ListedModPtr make_mod(const char* name, +ListedModPtr makeMod(const char* name, const char* pid, std::initializer_list deps = {}, - std::initializer_list load_after = {}, - std::initializer_list load_before = {}) { + std::initializer_list loadAfter = {}, + std::initializer_list loadBefore = {}) { auto mod = std::make_shared(); mod->name = QString::fromLatin1(name); - mod->package_id = CaseInsensitiveStr(QString::fromLatin1(pid)); - mod->provider_id = QStringLiteral("stub"); + mod->packageId = CaseInsensitiveStr(QString::fromLatin1(pid)); + mod->providerId = QStringLiteral("stub"); mod->valid = true; - mod->about_rules.load_after = CaseInsensitiveSet(ids(load_after)); - mod->about_rules.load_before = CaseInsensitiveSet(ids(load_before)); + mod->aboutRules.loadAfter = CaseInsensitiveSet(ids(loadAfter)); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ids(loadBefore)); for (const char* dep : deps) { DependencyMod dependency; dependency.name = QString::fromLatin1(dep); - dependency.package_id = CaseInsensitiveStr(QString::fromLatin1(dep)); - mod->about_rules.set_dependency(dependency); + dependency.packageId = CaseInsensitiveStr(QString::fromLatin1(dep)); + mod->aboutRules.setDependency(dependency); } return mod; } -Mods base_mods() { +Mods baseMods() { Mods mods; - mods.insert("uuid-a", make_mod("Mod A", "mod.a")); - mods.insert("uuid-b", make_mod("Mod B", "mod.b", {"mod.a"})); - mods.insert("uuid-c", make_mod("Mod C", "mod.c", {"mod.b"})); - mods.insert("uuid-d", make_mod("Mod D", "mod.d")); - mods.insert("uuid-e", make_mod("Mod E", "mod.e", {"mod.a"})); - mods.insert("uuid-f", make_mod("Mod F", "mod.f")); - mods.insert("uuid-g", make_mod("Mod G", "mod.g", {"mod.f"})); - mods.insert("uuid-h", make_mod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); - mods.insert("uuid-i", make_mod("Mod I", "mod.i", {}, {"mod.d"})); - mods.insert("uuid-j", make_mod("Mod J", "mod.j", {}, {}, {"mod.a"})); + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-b", makeMod("Mod B", "mod.b", {"mod.a"})); + mods.insert("uuid-c", makeMod("Mod C", "mod.c", {"mod.b"})); + mods.insert("uuid-d", makeMod("Mod D", "mod.d")); + mods.insert("uuid-e", makeMod("Mod E", "mod.e", {"mod.a"})); + mods.insert("uuid-f", makeMod("Mod F", "mod.f")); + mods.insert("uuid-g", makeMod("Mod G", "mod.g", {"mod.f"})); + mods.insert("uuid-h", makeMod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); + mods.insert("uuid-i", makeMod("Mod I", "mod.i", {}, {"mod.d"})); + mods.insert("uuid-j", makeMod("Mod J", "mod.j", {}, {}, {"mod.a"})); return mods; } @@ -75,11 +75,11 @@ Mods base_mods() { struct Fixture { services_test::EventLoop loop; QTemporaryDir dir; - ConfigService config_service{dir.path()}; - std::unique_ptr ctx = CoreContext::create(AppConfig{}, &config_service); + ConfigService configService{dir.path()}; + std::unique_ptr ctx = CoreContext::create(AppConfig{}, &configService); Fixture() { - ctx->load(base_mods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + ctx->load(baseMods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); ctx->diagnosticsService().rebuild(); } @@ -132,7 +132,7 @@ TEST_CASE("enabling mod with load-before rule inserts before target") { TEST_CASE("enabling dependency before active dependent") { Fixture f; - f.reload(base_mods(), {"uuid-b", "uuid-c", "uuid-d"}); + f.reload(baseMods(), {"uuid-b", "uuid-c", "uuid-d"}); REQUIRE(f.ctx->activation().apply(ids({"uuid-a"}))); @@ -244,9 +244,9 @@ TEST_CASE("no-op returns false without emitting") { TEST_CASE("edges through an uninstalled mod add no constraint") { Fixture f; Mods mods; - mods.insert("uuid-a", make_mod("Mod A", "mod.a")); - mods.insert("uuid-w", make_mod("Mod W", "mod.w", {}, {"ce.absent"})); - mods.insert("uuid-y", make_mod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-w", makeMod("Mod W", "mod.w", {}, {"ce.absent"})); + mods.insert("uuid-y", makeMod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); f.reload(mods, {"uuid-a", "uuid-w"}); REQUIRE(f.ctx->activation().apply(ids({"uuid-y"}))); @@ -256,9 +256,9 @@ TEST_CASE("edges through an uninstalled mod add no constraint") { TEST_CASE("bounds ignore an inconsistent active order") { Fixture f; - Mods mods = base_mods(); + Mods mods = baseMods(); // Active c depends on b but sits before it; X must still land in d..b. - mods.insert("uuid-x", make_mod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); + mods.insert("uuid-x", makeMod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); REQUIRE(f.ctx->activation().apply(ids({"uuid-x"}))); @@ -268,11 +268,11 @@ TEST_CASE("bounds ignore an inconsistent active order") { TEST_CASE("batch cycle does not unplace downstream mods") { Fixture f; - Mods mods = base_mods(); - mods.insert("uuid-p", make_mod("Mod P", "mod.p", {"mod.q"})); - mods.insert("uuid-q", make_mod("Mod Q", "mod.q", {"mod.p"})); - mods.insert("uuid-z", make_mod("Mod Z", "mod.z", {"mod.p"})); - mods.insert("uuid-k", make_mod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); + Mods mods = baseMods(); + mods.insert("uuid-p", makeMod("Mod P", "mod.p", {"mod.q"})); + mods.insert("uuid-q", makeMod("Mod Q", "mod.q", {"mod.p"})); + mods.insert("uuid-z", makeMod("Mod Z", "mod.z", {"mod.p"})); + mods.insert("uuid-k", makeMod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); REQUIRE(f.ctx->activation().apply(ids({"uuid-z", "uuid-k", "uuid-q", "uuid-p"}))); @@ -284,54 +284,54 @@ TEST_CASE("batch cycle does not unplace downstream mods") { TEST_CASE("dependents_of lists transitive dependents in active order") { Fixture f; - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-c", "uuid-b"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-c", "uuid-b"})); } TEST_CASE("dependents_of excludes its inputs") { Fixture f; - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a", "uuid-b"})) == ids({"uuid-c"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a", "uuid-b"})) == ids({"uuid-c"})); } TEST_CASE("dependents_of excludes inactive dependents") { Fixture f; CHECK_FALSE(f.ctx->activeUuids().contains(QStringLiteral("uuid-e"))); - CHECK_FALSE(f.ctx->activation().dependents_of(ids({"uuid-a"})).contains(QStringLiteral("uuid-e"))); + CHECK_FALSE(f.ctx->activation().dependentsOf(ids({"uuid-a"})).contains(QStringLiteral("uuid-e"))); } TEST_CASE("dependents_of reflects the graph after a state change") { Fixture f; REQUIRE(f.ctx->activation().apply(ids({"uuid-e"}))); - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-c", "uuid-e", "uuid-b"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-c", "uuid-e", "uuid-b"})); } TEST_CASE("dependents_of is empty for a mod without dependents") { Fixture f; - CHECK(f.ctx->activation().dependents_of(ids({"uuid-d", "uuid-missing"})).isEmpty()); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-d", "uuid-missing"})).isEmpty()); } TEST_CASE("removing a duplicate copy keeps dependents") { Fixture f; - Mods mods = base_mods(); - mods.insert("uuid-a-copy", make_mod("Copy A", "mod.a")); + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); f.reload(mods, {"uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"}); - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a-copy"})).isEmpty()); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a-copy"})).isEmpty()); REQUIRE(f.ctx->activation().apply({}, ids({"uuid-a-copy"}))); CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c"})); - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"})) == ids({"uuid-b", "uuid-c"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-b", "uuid-c"})); } TEST_CASE("enabling a replacement copy keeps dependents") { Fixture f; - Mods mods = base_mods(); - mods.insert("uuid-a-copy", make_mod("Copy A", "mod.a")); + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); f.reload(mods, {"uuid-a", "uuid-b", "uuid-c"}); - CHECK(f.ctx->activation().dependents_of(ids({"uuid-a"}), ids({"uuid-a-copy"})).isEmpty()); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"}), ids({"uuid-a-copy"})).isEmpty()); REQUIRE(f.ctx->activation().apply(ids({"uuid-a-copy"}), ids({"uuid-a"}))); CHECK(f.ctx->activeUuids() == ids({"uuid-a-copy", "uuid-b", "uuid-c"})); } @@ -341,25 +341,25 @@ TEST_CASE("sidebar entries are published before the summary") { Fixture f; DiagnosticsService& diagnostics = f.ctx->diagnosticsService(); QSet inactive; - std::vector> seen_at_summary; + std::vector> seenAtSummary; - diagnostics.sidebar_entries_changed.connect([&inactive](const QList& entries) { + diagnostics.sidebarEntriesChanged.connect([&inactive](const QList& entries) { inactive.clear(); for (const view::SidebarEntry& entry : entries) { if (entry.label == QStringLiteral("Inactive")) { - inactive = entry.visible_uuids; + inactive = entry.visibleUuids; } } }); - diagnostics.diagnostics_summary_changed.connect( - [&inactive, &seen_at_summary](const QHash&) { - seen_at_summary.push_back(inactive); + diagnostics.diagnosticsSummaryChanged.connect( + [&inactive, &seenAtSummary](const QHash&) { + seenAtSummary.push_back(inactive); }); diagnostics.rebuild(f.ctx->activeUuids()); - seen_at_summary.clear(); + seenAtSummary.clear(); REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); - REQUIRE_FALSE(seen_at_summary.empty()); - CHECK_FALSE(seen_at_summary.back().contains(QStringLiteral("uuid-f"))); + REQUIRE_FALSE(seenAtSummary.empty()); + CHECK_FALSE(seenAtSummary.back().contains(QStringLiteral("uuid-f"))); } diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp index c6abaa9..324d75d 100644 --- a/tests/core/services/test_game_launcher.cpp +++ b/tests/core/services/test_game_launcher.cpp @@ -26,19 +26,19 @@ using px::modrim::CoreContext; using px::modrim::LaunchStrategy; using px::modrim::PathConfig; using px::modrim::Result; -using px::modrim::services::build_direct_command; -using px::modrim::services::find_executable; +using px::modrim::services::buildDirectCommand; +using px::modrim::services::findExecutable; using px::modrim::services::GameLauncher; using px::modrim::services::GameProcessMonitor; -using px::modrim::services::is_game_running; +using px::modrim::services::isGameRunning; using px::modrim::services::MonitorTiming; using px::modrim::services::Platform; using px::modrim::services::ProcessInfo; using px::modrim::services::ProcessLister; -using px::modrim::services::split_arguments; -using px::modrim::services::steam_run_url; +using px::modrim::services::splitArguments; +using px::modrim::services::steamRunUrl; using services_test::EventLoop; -using services_test::run_async; +using services_test::runAsync; namespace { @@ -67,7 +67,7 @@ ProcessLister replay(std::vector> snapshots) { const MonitorTiming kNoSleep{std::chrono::milliseconds(0), std::chrono::milliseconds(0)}; QCoro::Task capture(GameProcessMonitor& monitor) { - co_await monitor.capture_baseline(); + co_await monitor.captureBaseline(); co_return true; } @@ -76,41 +76,41 @@ void touch(const QString& path) { REQUIRE(file.open(QIODevice::WriteOnly)); } -void make_executable(const QString& path) { +void makeExecutable(const QString& path) { touch(path); REQUIRE(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); } -std::unique_ptr context_for(const QString& game_path) { +std::unique_ptr contextFor(const QString& gamePath) { static QTemporaryDir configDir; static px::modrim::ConfigService configService(configDir.path()); AppConfig cfg; - cfg.paths.game = game_path.toStdString(); + cfg.paths.game = gamePath.toStdString(); return CoreContext::create(cfg, &configService); } } // namespace TEST_CASE("command without wrapper is the executable followed by the arguments") { - auto command = build_direct_command(QStringLiteral("/g/RimWorldLinux"), {}, - QStringLiteral("-popupwindow -logfile"), Platform::Linux); + auto command = buildDirectCommand(QStringLiteral("/g/RimWorldLinux"), {}, + QStringLiteral("-popupwindow -logfile"), Platform::kLinux); REQUIRE(command.has_value()); CHECK(command->argv == QStringList{"/g/RimWorldLinux", "-popupwindow", "-logfile"}); CHECK(command->env.isEmpty()); } TEST_CASE("wrapper without placeholder is a prefix") { - auto command = build_direct_command(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), - QStringLiteral("-x"), Platform::Linux); + auto command = buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), + QStringLiteral("-x"), Platform::kLinux); REQUIRE(command.has_value()); CHECK(command->argv == QStringList{"gamemoderun", "/g/rw", "-x"}); } TEST_CASE("placeholder marks the game position and leading assignments become environment") { - auto command = build_direct_command( + auto command = buildDirectCommand( QStringLiteral("/g/rw"), QStringLiteral("MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail"), - QStringLiteral("-x"), Platform::Linux); + QStringLiteral("-x"), Platform::kLinux); REQUIRE(command.has_value()); CHECK(command->argv == QStringList{"mangohud", "/g/rw", "-x", "--tail"}); CHECK(command->env.value(QStringLiteral("MANGOHUD")) == QStringLiteral("1")); @@ -118,29 +118,29 @@ TEST_CASE("placeholder marks the game position and leading assignments become en } TEST_CASE("quoted POSIX arguments stay one token") { - auto command = build_direct_command(QStringLiteral("/g/rw"), {}, + auto command = buildDirectCommand(QStringLiteral("/g/rw"), {}, QStringLiteral(R"(-savedatafolder="/tmp/my saves")"), - Platform::Linux); + Platform::kLinux); REQUIRE(command.has_value()); CHECK(command->argv == QStringList{"/g/rw", "-savedatafolder=/tmp/my saves"}); } TEST_CASE("an option containing '=' is not an environment assignment") { - auto command = build_direct_command(QStringLiteral("/g/rw"), QStringLiteral("--cfg=/p wrap"), {}, - Platform::Linux); + auto command = buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("--cfg=/p wrap"), {}, + Platform::kLinux); REQUIRE(command.has_value()); CHECK(command->argv == QStringList{"--cfg=/p", "wrap", "/g/rw"}); CHECK(command->env.isEmpty()); } TEST_CASE("unbalanced quotes are rejected") { - CHECK_FALSE(build_direct_command(QStringLiteral("/g/rw"), {}, QStringLiteral("\"oops"), - Platform::Linux) + CHECK_FALSE(buildDirectCommand(QStringLiteral("/g/rw"), {}, QStringLiteral("\"oops"), + Platform::kLinux) .has_value()); } TEST_CASE("Windows splitting keeps quotes in the token, as shlex with posix=False does") { - auto tokens = split_arguments(QStringLiteral(R"(-a "b c" d)"), Platform::Windows); + auto tokens = splitArguments(QStringLiteral(R"(-a "b c" d)"), Platform::kWindows); REQUIRE(tokens.has_value()); CHECK(*tokens == QStringList{"-a", R"("b c")", "d"}); } @@ -150,9 +150,9 @@ TEST_CASE("executable lookup prefers the current binary over the legacy one") { REQUIRE(dir.isValid()); const QString root = dir.path(); - make_executable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); - make_executable(QDir(root).filePath(QStringLiteral("RimWorld"))); - auto found = find_executable(root, Platform::Linux); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorld"))); + auto found = findExecutable(root, Platform::kLinux); REQUIRE(found.has_value()); CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorld"))); } @@ -163,11 +163,11 @@ TEST_CASE("executable lookup skips a non-executable binary") { const QString root = dir.path(); touch(QDir(root).filePath(QStringLiteral("RimWorld"))); - make_executable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); REQUIRE(QFile::setPermissions(QDir(root).filePath(QStringLiteral("RimWorld")), QFileDevice::ReadOwner | QFileDevice::WriteOwner)); - auto found = find_executable(root, Platform::Linux); + auto found = findExecutable(root, Platform::kLinux); REQUIRE(found.has_value()); CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorldLinux"))); } @@ -178,23 +178,23 @@ TEST_CASE("executable lookup finds the Windows and macOS layouts") { const QString root = dir.path(); touch(QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); - auto windows = find_executable(root, Platform::Windows); + auto windows = findExecutable(root, Platform::kWindows); REQUIRE(windows.has_value()); CHECK(*windows == QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); REQUIRE(QDir(root).mkpath(QStringLiteral("RimWorldMac.app"))); - auto mac = find_executable(root, Platform::MacOS); + auto mac = findExecutable(root, Platform::kMacOs); REQUIRE(mac.has_value()); CHECK(*mac == QDir(root).filePath(QStringLiteral("RimWorldMac.app"))); } TEST_CASE("steam URL is the app id alone without launch arguments") { - CHECK(steam_run_url(QString()) == QStringLiteral("steam://rungameid/294100")); - CHECK(steam_run_url(QStringLiteral(" ")) == QStringLiteral("steam://rungameid/294100")); + CHECK(steamRunUrl(QString()) == QStringLiteral("steam://rungameid/294100")); + CHECK(steamRunUrl(QStringLiteral(" ")) == QStringLiteral("steam://rungameid/294100")); } TEST_CASE("steam URL percent-encodes launch arguments") { - CHECK(steam_run_url(QStringLiteral("-savedatafolder=/a b")) == + CHECK(steamRunUrl(QStringLiteral("-savedatafolder=/a b")) == QStringLiteral("steam://run/294100//-savedatafolder%3D%2Fa%20b/")); } @@ -202,13 +202,13 @@ TEST_CASE("legacy and current game process names are recognized") { for (const char* name : {"RimWorld", "RimWorld.exe", "RimWorldLinux", "RimWorldWin64.exe", "RimWorldWin.exe", "RimWorld by Ludeon Studios"}) { const auto lister = replay({{proc(QString::fromLatin1(name)), proc("bash", 2)}}); - CHECK(is_game_running(lister)); + CHECK(isGameRunning(lister)); } } TEST_CASE("zombie game process is not running") { const auto lister = replay({{proc("RimWorldLinux", 1, true), proc("bash", 2)}}); - CHECK_FALSE(is_game_running(lister)); + CHECK_FALSE(isGameRunning(lister)); } TEST_CASE("wait_for_exit returns once the game processes disappear") { @@ -216,8 +216,8 @@ TEST_CASE("wait_for_exit returns once the game processes disappear") { // The first snapshot is the baseline, which is empty; then the game runs, then exits. GameProcessMonitor monitor(replay({{}, {proc("RimWorldLinux")}, {proc("RimWorldLinux")}, {}}), kNoSleep); - REQUIRE(run_async(capture(monitor))); - CHECK(run_async(monitor.wait_for_exit()) == std::nullopt); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForExit()) == std::nullopt); } TEST_CASE("processes running before the launch are not counted as started") { @@ -227,15 +227,15 @@ TEST_CASE("processes running before the launch are not counted as started") { {proc("RimWorldLinux", 10)}, {proc("RimWorldLinux", 10), proc("RimWorldLinux", 20)}}), kNoSleep); - REQUIRE(run_async(capture(monitor))); - CHECK(run_async(monitor.wait_for_start(std::chrono::milliseconds(5000)))); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); } TEST_CASE("wait_for_start is false when the spawned process died before the game appeared") { EventLoop loop; GameProcessMonitor monitor(replay({{}}), kNoSleep); - monitor.attach_process(std::make_unique()); // never started: state NotRunning - CHECK_FALSE(run_async(monitor.wait_for_start(std::chrono::milliseconds(5000)))); + monitor.attachProcess(std::make_unique()); // never started: state NotRunning + CHECK_FALSE(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); } #if defined(Q_OS_UNIX) @@ -246,16 +246,16 @@ TEST_CASE("wait_for_exit reports the spawned process's exit code") { REQUIRE(spawned->waitForFinished(10'000)); GameProcessMonitor monitor(replay({{}}), kNoSleep); - monitor.attach_process(std::move(spawned)); - CHECK(run_async(monitor.wait_for_exit()) == std::optional(7)); + monitor.attachProcess(std::move(spawned)); + CHECK(runAsync(monitor.waitForExit()) == std::optional(7)); } #endif TEST_CASE("launch without a game path fails before anything runs") { EventLoop loop; - auto ctx = context_for({}); + auto ctx = contextFor({}); GameLauncher launcher(*ctx); - const Result result = run_async(launcher.launch(LaunchStrategy::Direct)); + const Result result = runAsync(launcher.launch(LaunchStrategy::kDirect)); REQUIRE_FALSE(result.has_value()); CHECK(result.error().message == QStringLiteral("Game path not configured")); } @@ -264,9 +264,9 @@ TEST_CASE("direct launch without an executable fails") { EventLoop loop; QTemporaryDir dir; REQUIRE(dir.isValid()); - auto ctx = context_for(dir.path()); + auto ctx = contextFor(dir.path()); GameLauncher launcher(*ctx); - const Result result = run_async(launcher.launch(LaunchStrategy::Direct)); + const Result result = runAsync(launcher.launch(LaunchStrategy::kDirect)); REQUIRE_FALSE(result.has_value()); CHECK(result.error().message == QStringLiteral("Game executable not found")); } diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp index d09dcb9..09e01b6 100644 --- a/tests/core/services/test_metadata_cache.cpp +++ b/tests/core/services/test_metadata_cache.cpp @@ -18,8 +18,8 @@ using namespace px::modrim; using namespace px::modrim::providers; using namespace px::modrim::services; using services_test::EventLoop; -using services_test::run_async; -using services_test::write_text; +using services_test::runAsync; +using services_test::writeText; namespace { @@ -81,18 +81,18 @@ constexpr auto kAboutTwo = R"( )"; // Writes About/About.xml (and optionally About/PublishedFileId.txt) under `modDir`. -QString create_mod(const QString& modDir, const char* xml, bool withPublishedFileId = false) { +QString createMod(const QString& modDir, const char* xml, bool withPublishedFileId = false) { REQUIRE(QDir().mkpath(QDir(modDir).filePath(QStringLiteral("About")))); - REQUIRE(write_text(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); + REQUIRE(writeText(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); if (withPublishedFileId) { - REQUIRE(write_text(QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), + REQUIRE(writeText(QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), "123456")); } return modDir; } // Stat data the way the providers take it: About.xml mtime in seconds, and its size. -ModCandidate candidate_for(const QString& modDir) { +ModCandidate candidateFor(const QString& modDir) { const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); const QFileInfo info(about); return ModCandidate{modDir, about, info.lastModified().toMSecsSinceEpoch() / 1000.0, @@ -100,7 +100,7 @@ ModCandidate candidate_for(const QString& modDir) { } // Parses the mod exactly as the providers do, so cache entries match real ones. -std::shared_ptr parse_about(const QString& modDir) { +std::shared_ptr parseAbout(const QString& modDir) { auto listed = createListedModFromPath(modDir, QStringLiteral("1.5"), true); REQUIRE(listed.has_value()); auto about = std::dynamic_pointer_cast(*listed); @@ -109,7 +109,7 @@ std::shared_ptr parse_about(const QString& modDir) { } // Moves About.xml's mtime, keeping the file's contents. -void set_about_mtime(const ModCandidate& candidate, double mtimeSeconds) { +void setAboutMtime(const ModCandidate& candidate, double mtimeSeconds) { QFile file(candidate.aboutXmlPath); REQUIRE(file.open(QIODevice::ReadWrite)); REQUIRE(file.setFileTime(QDateTime::fromMSecsSinceEpoch(std::llround(mtimeSeconds * 1000)), @@ -117,7 +117,7 @@ void set_about_mtime(const ModCandidate& candidate, double mtimeSeconds) { } // Reads mod_path values with an independent connection, to check what the cache stored. -QStringList stored_mod_paths(const QString& dbPath) { +QStringList storedModPaths(const QString& dbPath) { const QString name = QStringLiteral("metadata-cache-test-reader"); QStringList paths; { @@ -140,42 +140,42 @@ TEST_CASE("cache returns the parsed fields of a stored mod") { EventLoop loop; QTemporaryDir root; REQUIRE(root.isValid()); - const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidate_for(modDir); - const QString dbPath = metadata_cache_path(root.path()); + const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); MetadataCache cache(dbPath); - run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); - const CacheLookup lookup = run_async(cache.get_mods({candidate}, QStringLiteral("1.5"))); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); + const CacheLookup lookup = runAsync(cache.getMods({candidate}, QStringLiteral("1.5"))); REQUIRE(lookup.cached.size() == 1); REQUIRE(lookup.missing.isEmpty()); const auto mod = std::dynamic_pointer_cast(lookup.cached[0]); REQUIRE(mod != nullptr); CHECK(mod->name == QStringLiteral("Sample Mod One")); - CHECK(mod->package_id.str() == QStringLiteral("author.samplemodone")); + CHECK(mod->packageId.str() == QStringLiteral("author.samplemodone")); CHECK(mod->authors == QStringList{QStringLiteral("TestAuthor")}); - CHECK(mod->supported_versions == QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(mod->supportedVersions == QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); CHECK(mod->description == QStringLiteral("Initial description")); - CHECK(mod->mod_version == QStringLiteral("1.0.0")); + CHECK(mod->modVersion == QStringLiteral("1.0.0")); CHECK(mod->url == QStringLiteral("https://example.com/mod")); - REQUIRE(mod->mod_icon_path.has_value()); - CHECK(*mod->mod_icon_path == QStringLiteral("UI/Icon.png")); + REQUIRE(mod->modIconPath.has_value()); + CHECK(*mod->modIconPath == QStringLiteral("UI/Icon.png")); CHECK(std::abs(mod->mtime - candidate.mtime) < 1e-3); - CHECK(mod->about_rules.dependencies.size() == 1); - CHECK(mod->about_rules.has_dependency(CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); - REQUIRE(mod->mod_path().has_value()); - CHECK(*mod->mod_path() == modDir); + CHECK(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.hasDependency(CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); + REQUIRE(mod->modPath().has_value()); + CHECK(*mod->modPath() == modDir); } TEST_CASE("cache entries are misses when the file size, mtime or target version changes") { EventLoop loop; QTemporaryDir root; REQUIRE(root.isValid()); - const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidate_for(modDir); - MetadataCache cache(metadata_cache_path(root.path())); - run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); + const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); ModCandidate resized = candidate; resized.size += 1; @@ -183,11 +183,11 @@ TEST_CASE("cache entries are misses when the file size, mtime or target version touched.mtime += 5.0; for (const ModCandidate& stale : {resized, touched}) { - const CacheLookup lookup = run_async(cache.get_mods({stale}, QStringLiteral("1.5"))); + const CacheLookup lookup = runAsync(cache.getMods({stale}, QStringLiteral("1.5"))); CHECK(lookup.cached.isEmpty()); CHECK(lookup.missing.size() == 1); } - const CacheLookup otherVersion = run_async(cache.get_mods({candidate}, QStringLiteral("1.4"))); + const CacheLookup otherVersion = runAsync(cache.getMods({candidate}, QStringLiteral("1.4"))); CHECK(otherVersion.cached.isEmpty()); CHECK(otherVersion.missing.size() == 1); } @@ -196,21 +196,21 @@ TEST_CASE("pruning removes entries for mods that disappeared") { EventLoop loop; QTemporaryDir root; REQUIRE(root.isValid()); - const QString modA = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const QString modB = create_mod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); - const QString dbPath = metadata_cache_path(root.path()); + const QString modA = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const QString modB = createMod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); + const QString dbPath = metadataCachePath(root.path()); MetadataCache cache(dbPath); - run_async(cache.put_mods({CachedMod{parse_about(modA), candidate_for(modA)}, - CachedMod{parse_about(modB), candidate_for(modB)}}, + runAsync(cache.putMods({CachedMod{parseAbout(modA), candidateFor(modA)}, + CachedMod{parseAbout(modB), candidateFor(modB)}}, QStringLiteral("1.5"))); - QStringList expectedPaths{normalize_path(modA), normalize_path(modB)}; + QStringList expectedPaths{normalizePath(modA), normalizePath(modB)}; expectedPaths.sort(); - REQUIRE(stored_mod_paths(dbPath) == expectedPaths); + REQUIRE(storedModPaths(dbPath) == expectedPaths); - run_async(cache.prune_disappeared(QSet{normalize_path(modA)})); + runAsync(cache.pruneDisappeared(QSet{normalizePath(modA)})); - CHECK(stored_mod_paths(dbPath) == QStringList{normalize_path(modA)}); - const CacheLookup lookup = run_async(cache.get_mods({candidate_for(modB)}, QStringLiteral("1.5"))); + CHECK(storedModPaths(dbPath) == QStringList{normalizePath(modA)}); + const CacheLookup lookup = runAsync(cache.getMods({candidateFor(modB)}, QStringLiteral("1.5"))); CHECK(lookup.cached.isEmpty()); } @@ -218,27 +218,27 @@ TEST_CASE("corrupt cache database is recovered and keeps working") { EventLoop loop; QTemporaryDir root; REQUIRE(root.isValid()); - const QString modDir = create_mod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidate_for(modDir); - const QString dbPath = metadata_cache_path(root.path()); + const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); { MetadataCache cache(dbPath); - run_async(cache.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); - run_async(cache.close()); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); + runAsync(cache.close()); } QByteArray garbage; for (int i = 0; i < 50; ++i) { garbage += "corrupt non-sqlite data"; } - REQUIRE(write_text(dbPath, garbage)); + REQUIRE(writeText(dbPath, garbage)); MetadataCache recovered(dbPath); - const CacheLookup empty = run_async(recovered.get_mods({candidate}, QStringLiteral("1.5"))); + const CacheLookup empty = runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); CHECK(empty.cached.isEmpty()); REQUIRE(empty.missing.size() == 1); - run_async(recovered.put_mods({CachedMod{parse_about(modDir), candidate}}, QStringLiteral("1.5"))); - const CacheLookup warm = run_async(recovered.get_mods({candidate}, QStringLiteral("1.5"))); + runAsync(recovered.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); + const CacheLookup warm = runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); CHECK(warm.cached.size() == 1); } @@ -247,30 +247,30 @@ TEST_CASE("providers reuse cached metadata and reparse when the file changes") { QTemporaryDir root; REQUIRE(root.isValid()); const QString modsRoot = QDir(root.path()).filePath(QStringLiteral("mods")); - const QString modDir = create_mod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); + const QString modDir = createMod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); - const ModCandidate original = candidate_for(modDir); - MetadataCache cache(metadata_cache_path(root.path())); + const ModCandidate original = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); LocalModProvider provider(modsRoot, nullptr, &cache); - auto first = run_async(provider.discover(QStringLiteral("1.5"))); + auto first = runAsync(provider.discover(QStringLiteral("1.5"))); REQUIRE(first.has_value()); REQUIRE(first->contains(modDir)); CHECK((*first)[modDir]->name == QStringLiteral("Sample Mod One")); // Same size and mtime, different text: the cache still answers, a forced reparse does not. - REQUIRE(write_text(about, kAboutSameSize)); - set_about_mtime(original, original.mtime); - auto cached = run_async(provider.discover(QStringLiteral("1.5"))); + REQUIRE(writeText(about, kAboutSameSize)); + setAboutMtime(original, original.mtime); + auto cached = runAsync(provider.discover(QStringLiteral("1.5"))); REQUIRE(cached.has_value()); CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); - auto forced = run_async(provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); + auto forced = runAsync(provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); REQUIRE(forced.has_value()); CHECK((*forced)[modDir]->name == QStringLiteral("Sample Mod Two")); // A new mtime invalidates the entry even though the cache is consulted. - set_about_mtime(original, original.mtime + 5.0); - auto changed = run_async(provider.discover(QStringLiteral("1.5"))); + setAboutMtime(original, original.mtime + 5.0); + auto changed = runAsync(provider.discover(QStringLiteral("1.5"))); REQUIRE(changed.has_value()); CHECK((*changed)[modDir]->name == QStringLiteral("Sample Mod Two")); // mtime lives on AboutXmlMod; ListedMod has no such field. @@ -285,21 +285,21 @@ TEST_CASE("providers keep their identity when they share one cache") { REQUIRE(root.isValid()); const QString localRoot = QDir(root.path()).filePath(QStringLiteral("local")); const QString downloadedRoot = QDir(root.path()).filePath(QStringLiteral("downloaded")); - const QString localMod = create_mod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); - const QString steamMod = create_mod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), kAboutTwo, true); - MetadataCache cache(metadata_cache_path(root.path())); + const QString localMod = createMod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); + const QString steamMod = createMod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), kAboutTwo, true); + MetadataCache cache(metadataCachePath(root.path())); LocalModProvider local(localRoot, nullptr, &cache); DownloadedModProvider downloaded(downloadedRoot, nullptr, &cache); for (int pass = 0; pass < 2; ++pass) { - auto localMods = run_async(local.discover(QStringLiteral("1.5"))); - auto steamMods = run_async(downloaded.discover(QStringLiteral("1.5"))); + auto localMods = runAsync(local.discover(QStringLiteral("1.5"))); + auto steamMods = runAsync(downloaded.discover(QStringLiteral("1.5"))); REQUIRE(localMods.has_value()); REQUIRE(steamMods.has_value()); REQUIRE(localMods->contains(localMod)); REQUIRE(steamMods->contains(steamMod)); - CHECK((*localMods)[localMod]->provider_id == QStringLiteral("local")); - CHECK((*steamMods)[steamMod]->provider_id == QStringLiteral("downloaded")); + CHECK((*localMods)[localMod]->providerId == QStringLiteral("local")); + CHECK((*steamMods)[steamMod]->providerId == QStringLiteral("downloaded")); } } @@ -325,8 +325,8 @@ TEST_CASE("normalize_paths matches normalize_path, including symlinks") { QStringList expected; for (const QString& path : paths) { - expected.append(normalize_path(path)); + expected.append(normalizePath(path)); } - CHECK(normalize_paths(paths) == expected); - CHECK(normalize_path(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == normalize_path(other)); + CHECK(normalizePaths(paths) == expected); + CHECK(normalizePath(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == normalizePath(other)); } diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp index 7bf2787..69270ea 100644 --- a/tests/core/services/test_sort_service.cpp +++ b/tests/core/services/test_sort_service.cpp @@ -19,27 +19,27 @@ using px::modrim::sort::PackageId; namespace { -std::shared_ptr about(const QString& name, const QString& package_id) { +std::shared_ptr about(const QString& name, const QString& packageId) { auto mod = std::make_shared(); mod->name = name; - mod->package_id = CaseInsensitiveStr(package_id); - mod->provider_id = QStringLiteral("stub"); + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("stub"); mod->valid = true; return mod; } // `deps` maps a dependency package id to its alternative package ids. -std::shared_ptr about_with_deps(const QString& name, const QString& package_id, +std::shared_ptr aboutWithDeps(const QString& name, const QString& packageId, const QHash& deps) { - auto mod = about(name, package_id); + auto mod = about(name, packageId); for (auto it = deps.cbegin(); it != deps.cend(); ++it) { DependencyMod dep; dep.name = QStringLiteral("Dep of %1").arg(name); - dep.package_id = CaseInsensitiveStr(it.key()); + dep.packageId = CaseInsensitiveStr(it.key()); for (const auto& alt : it.value()) { - dep.alternative_package_ids.add(alt); + dep.alternativePackageIds.add(alt); } - mod->about_rules.dependencies.append(std::move(dep)); + mod->aboutRules.dependencies.append(std::move(dep)); } return mod; } @@ -47,7 +47,7 @@ std::shared_ptr about_with_deps(const QString& name, const QString& ListedModPtr listed(const QString& name) { auto mod = std::make_shared(); mod->name = name; - mod->provider_id = QStringLiteral("stub"); + mod->providerId = QStringLiteral("stub"); return mod; } @@ -67,7 +67,7 @@ TEST_CASE("duplicate inactive copy does not replace the active uuid") { {QStringLiteral("uuid-inactive"), about("Inactive Copy", "shared.package")}, }); - const auto ordered = SortService::order_uuids({PackageId(QStringLiteral("shared.package"))}, all, + const auto ordered = SortService::orderUuids({PackageId(QStringLiteral("shared.package"))}, all, {QStringLiteral("uuid-active")}); CHECK(ordered == QStringList{QStringLiteral("uuid-active")}); @@ -79,7 +79,7 @@ TEST_CASE("order_uuids follows the sorted package ids and drops unknown ones") { {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, }); - const auto ordered = SortService::order_uuids( + const auto ordered = SortService::orderUuids( {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("ghost")), PackageId(QStringLiteral("mod.a"))}, all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); @@ -93,7 +93,7 @@ TEST_CASE("order_uuids skips active mods that are not about.xml mods") { {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, }); - const auto ordered = SortService::order_uuids( + const auto ordered = SortService::orderUuids( {PackageId(QStringLiteral("mod.a"))}, all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}); @@ -106,36 +106,36 @@ TEST_CASE("missing dependencies: no dependencies yields nothing") { {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) .isEmpty()); } TEST_CASE("missing dependency that exists as an inactive mod is enabled") { const auto all = mods({ - {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, }); - CHECK(SortService::missing_dependency_uuids(all, {QStringLiteral("uuid-a")}) == + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) == QStringList{QStringLiteral("uuid-b")}); } TEST_CASE("missing dependency that is not among the known mods is ignored") { const auto all = mods({ - {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, }); - CHECK(SortService::missing_dependency_uuids(all, {QStringLiteral("uuid-a")}).isEmpty()); + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}).isEmpty()); } TEST_CASE("missing dependency that is already active is not enabled again") { const auto all = mods({ - {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) .isEmpty()); } @@ -143,11 +143,11 @@ TEST_CASE("missing dependency that is already active is not enabled again") { TEST_CASE("dependency satisfied by an active alternative package id is not missing") { const auto all = mods({ {QStringLiteral("uuid-a"), - about_with_deps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-c")}) .isEmpty()); } @@ -158,39 +158,39 @@ TEST_CASE("non about.xml mods in the active set contribute no dependencies") { {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}) .isEmpty()); } TEST_CASE("a dependency shared by two active mods is listed once") { const auto all = mods({ - {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.c", {}}})}, - {QStringLiteral("uuid-b"), about_with_deps("Mod B", "mod.b", {{"mod.c", {}}})}, + {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.c", {}}})}, + {QStringLiteral("uuid-b"), aboutWithDeps("Mod B", "mod.b", {{"mod.c", {}}})}, {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) == QStringList{QStringLiteral("uuid-c")}); } TEST_CASE("an empty active set needs nothing") { const auto all = mods({ - {QStringLiteral("uuid-a"), about_with_deps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, }); - CHECK(SortService::missing_dependency_uuids(all, {}).isEmpty()); + CHECK(SortService::missingDependencyUuids(all, {}).isEmpty()); } TEST_CASE("a mod without dependencies adds nothing to the result") { const auto all = mods({ {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - {QStringLiteral("uuid-b"), about_with_deps("Mod B", "mod.b", {{"mod.a", {}}})}, + {QStringLiteral("uuid-b"), aboutWithDeps("Mod B", "mod.b", {{"mod.a", {}}})}, }); - CHECK(SortService::missing_dependency_uuids( + CHECK(SortService::missingDependencyUuids( all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) .isEmpty()); } diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index 07053cd..021af37 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -16,7 +16,7 @@ using namespace px::modrim; using namespace px::modrim::services; using services_test::EventLoop; -using services_test::run_async; +using services_test::runAsync; namespace { @@ -24,43 +24,43 @@ namespace { class CannedHttpServer : public QObject { public: CannedHttpServer(int statusCode, QByteArray body, QByteArray contentType = "application/json") - : statusCode_(statusCode), body_(std::move(body)), contentType_(std::move(contentType)) { - connect(&server_, &QTcpServer::newConnection, this, &CannedHttpServer::onNewConnection); - REQUIRE(server_.listen(QHostAddress::LocalHost, 0)); + : m_statusCode(statusCode), m_body(std::move(body)), m_contentType(std::move(contentType)) { + connect(&m_server, &QTcpServer::newConnection, this, &CannedHttpServer::onNewConnection); + REQUIRE(m_server.listen(QHostAddress::LocalHost, 0)); } QString url() const { - return QStringLiteral("http://127.0.0.1:%1/latest").arg(server_.serverPort()); + return QStringLiteral("http://127.0.0.1:%1/latest").arg(m_server.serverPort()); } private: void onNewConnection() { - while (QTcpSocket* socket = server_.nextPendingConnection()) { + while (QTcpSocket* socket = m_server.nextPendingConnection()) { connect(socket, &QTcpSocket::readyRead, this, [this, socket] { if (socket->canReadLine()) { // Drain the headers. while (!socket->readLine().trimmed().isEmpty()) { } const QByteArray header = - QByteArray("HTTP/1.1 ") + QByteArray::number(statusCode_) + " OK\r\n" + - "Content-Type: " + contentType_ + "\r\n" + - "Content-Length: " + QByteArray::number(body_.size()) + "\r\n" + + QByteArray("HTTP/1.1 ") + QByteArray::number(m_statusCode) + " OK\r\n" + + "Content-Type: " + m_contentType + "\r\n" + + "Content-Length: " + QByteArray::number(m_body.size()) + "\r\n" + "Connection: close\r\n\r\n"; - socket->write(header + body_); + socket->write(header + m_body); socket->disconnectFromHost(); } }); } } - QTcpServer server_; - int statusCode_; - QByteArray body_; - QByteArray contentType_; + QTcpServer m_server; + int m_statusCode; + QByteArray m_body; + QByteArray m_contentType; }; // Returns a port on localhost that is not currently listening. -quint16 closed_port() { +quint16 closedPort() { QTcpServer server; REQUIRE(server.listen(QHostAddress::LocalHost, 0)); const quint16 port = server.serverPort(); @@ -75,13 +75,13 @@ constexpr auto kReleaseJson = R"({ "body": "notes" })"; -Result> check_payload(const QByteArray& payload, int status = 200, +Result> checkPayload(const QByteArray& payload, int status = 200, const QString& current = QStringLiteral("0.1.0")) { EventLoop loop; QNetworkAccessManager network; CannedHttpServer server(status, payload); UpdateService service(current, network, server.url()); - return run_async(service.check()); + return runAsync(service.check()); } } // namespace @@ -112,19 +112,19 @@ TEST_CASE("is_newer compares versions with padding, pre-release suffixes and dev }; for (const auto& [tag, current, expected] : cases) { CAPTURE(tag, current); - CHECK(is_newer(QString::fromLatin1(tag), QString::fromLatin1(current)) == expected); + CHECK(isNewer(QString::fromLatin1(tag), QString::fromLatin1(current)) == expected); } } TEST_CASE("parse_version extracts leading numeric parts and rejects non-numeric tags") { - CHECK(parse_version(QStringLiteral("latest")) == std::nullopt); - const auto parsed = parse_version(QStringLiteral(" v1.2.3 ")); + CHECK(parseVersion(QStringLiteral("latest")) == std::nullopt); + const auto parsed = parseVersion(QStringLiteral(" v1.2.3 ")); REQUIRE(parsed.has_value()); CHECK(*parsed == std::vector{1, 2, 3}); } TEST_CASE("UpdateService returns a newer release") { - const auto result = check_payload(kReleaseJson); + const auto result = checkPayload(kReleaseJson); REQUIRE(result.has_value()); REQUIRE(result->has_value()); const ReleaseInfo& release = **result; @@ -135,34 +135,34 @@ TEST_CASE("UpdateService returns a newer release") { } TEST_CASE("UpdateService returns nullopt when the latest release is not newer") { - const auto result = check_payload(kReleaseJson, 200, QStringLiteral("0.2.0")); + const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0")); REQUIRE(result.has_value()); CHECK(!result->has_value()); } TEST_CASE("UpdateService offers a plain release over its own rc") { - const auto result = check_payload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); + const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); REQUIRE(result.has_value()); REQUIRE(result->has_value()); CHECK((**result).tag == QStringLiteral("v0.2.0")); } TEST_CASE("UpdateService wraps HTTP status errors") { - const auto result = check_payload("{}", 500); + const auto result = checkPayload("{}", 500); CHECK(!result.has_value()); } TEST_CASE("UpdateService wraps transport failures") { EventLoop loop; QNetworkAccessManager network; - const QString deadUrl = QStringLiteral("http://127.0.0.1:%1/latest").arg(closed_port()); + const QString deadUrl = QStringLiteral("http://127.0.0.1:%1/latest").arg(closedPort()); UpdateService service(QStringLiteral("0.1.0"), network, deadUrl); - const auto result = run_async(service.check()); + const auto result = runAsync(service.check()); CHECK(!result.has_value()); } TEST_CASE("UpdateService wraps non-JSON bodies") { - const auto result = check_payload("", 200); + const auto result = checkPayload("", 200); CHECK(!result.has_value()); } @@ -170,7 +170,7 @@ TEST_CASE("UpdateService rejects non-object JSON payloads") { const std::initializer_list payloads{"[]", "null", "\"tag\"", "3"}; for (const QByteArray& payload : payloads) { CAPTURE(payload); - const auto result = check_payload(payload); + const auto result = checkPayload(payload); CHECK(!result.has_value()); } } @@ -183,7 +183,7 @@ TEST_CASE("UpdateService rejects responses lacking tag_name or html_url") { QByteArray(R"({"tag_name": "", "html_url": "https://example.test/r"})"), }) { CAPTURE(payload); - const auto result = check_payload(payload); + const auto result = checkPayload(payload); CHECK(!result.has_value()); } } diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp index 7c9ff0f..20c7881 100644 --- a/tests/core/sort/test_community.cpp +++ b/tests/core/sort/test_community.cpp @@ -6,14 +6,14 @@ #include "core/sort/community.hpp" #include "../test_support.hpp" -using px::modrim::sort::load_community_rules; +using px::modrim::sort::loadCommunityRules; using px::modrim::sort::PackageId; TEST_CASE("a missing community rules file means no rules") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto rules = load_community_rules(dir.filePath(QStringLiteral("communityRules.json"))); + const auto rules = loadCommunityRules(dir.filePath(QStringLiteral("communityRules.json"))); REQUIRE(rules.has_value()); CHECK(rules->isEmpty()); @@ -23,7 +23,7 @@ TEST_CASE("community rules are keyed by lowercased package id") { QTemporaryDir dir; REQUIRE(dir.isValid()); const QString path = dir.filePath(QStringLiteral("communityRules.json")); - REQUIRE(test_support::write_bytes(path, R"({ + REQUIRE(test_support::writeBytes(path, R"({ "timestamp": 1, "rules": { "Some.Mod": { @@ -35,26 +35,26 @@ TEST_CASE("community rules are keyed by lowercased package id") { } })")); - const auto loaded = load_community_rules(path); + const auto loaded = loadCommunityRules(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->contains(PackageId(QStringLiteral("some.mod")))); const auto& rule = loaded->value(PackageId(QStringLiteral("some.mod"))); - CHECK(rule.package_id == PackageId(QStringLiteral("some.mod"))); - CHECK(rule.load_after.contains(QStringLiteral("base.mod"))); - CHECK(rule.load_before.contains(QStringLiteral("late.mod"))); - CHECK(rule.load_first); - CHECK_FALSE(rule.load_last); - CHECK(rule.incompatible_with.empty()); + CHECK(rule.packageId == PackageId(QStringLiteral("some.mod"))); + CHECK(rule.loadAfter.contains(QStringLiteral("base.mod"))); + CHECK(rule.loadBefore.contains(QStringLiteral("late.mod"))); + CHECK(rule.loadFirst); + CHECK_FALSE(rule.loadLast); + CHECK(rule.incompatibleWith.empty()); } TEST_CASE("a malformed community rules file is an error") { QTemporaryDir dir; REQUIRE(dir.isValid()); const QString path = dir.filePath(QStringLiteral("communityRules.json")); - REQUIRE(test_support::write_bytes(path, R"({"rules": [1, 2)")); + REQUIRE(test_support::writeBytes(path, R"({"rules": [1, 2)")); - const auto loaded = load_community_rules(path); + const auto loaded = loadCommunityRules(path); CHECK_FALSE(loaded.has_value()); } diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp index b662e0e..cac2e52 100644 --- a/tests/core/sort/test_tier_order.cpp +++ b/tests/core/sort/test_tier_order.cpp @@ -17,7 +17,7 @@ using px::modrim::AboutXmlMod; using px::modrim::CaseInsensitiveSet; using px::modrim::CaseInsensitiveStr; using px::modrim::checker::ConstraintGraph; -using px::modrim::checker::topological_sort; +using px::modrim::checker::topologicalSort; using px::modrim::sort::PackageId; using px::modrim::sort::SortSettings; using px::modrim::sort::Tier; @@ -32,60 +32,60 @@ PackageId pid(const QString& value) { return PackageId(value); } -ModPtr make_mod(const QString& package_id) { +ModPtr makeMod(const QString& packageId) { auto mod = std::make_shared(); - mod->package_id = CaseInsensitiveStr(package_id); - mod->name = package_id; + mod->packageId = CaseInsensitiveStr(packageId); + mod->name = packageId; return mod; } -ModMap active_dict(std::initializer_list mods) { +ModMap activeDict(std::initializer_list mods) { ModMap result; for (const auto& mod : mods) { - result.insert(mod->package_id, mod); + result.insert(mod->packageId, mod); } return result; } // Community rules are off and alternative ids are ignored, so only the tier config and explicit // load-order rules shape the result. -QList sort_mods(const ModMap& mods, const TierConfig& tier_config = TierConfig::defaults()) { +QList sortMods(const ModMap& mods, const TierConfig& tierConfig = TierConfig::defaults()) { SortSettings settings; settings.use_community_rules = false; settings.use_alternative_package_ids = false; - settings.tier_config = tier_config; + settings.tier_config = tierConfig; ConstraintGraph graph; graph.build(mods, mods.keys(), settings); - return topological_sort(mods, graph, settings); + return topologicalSort(mods, graph, settings); } // Checks only the entries of `result` that belong to `expected`, in their order. -void assert_order(const QList& result, const QStringList& expected) { - QStringList in_tier; +void assertOrder(const QList& result, const QStringList& expected) { + QStringList inTier; for (const auto& entry : result) { if (expected.contains(entry.str())) { - in_tier.append(entry.str()); + inTier.append(entry.str()); } } - CHECK(in_tier == expected); + CHECK(inTier == expected); } } // namespace TEST_CASE("tier 0 mods follow the config order") { - const auto mods = active_dict({ - make_mod(QStringLiteral("brrainz.harmony")), - make_mod(QStringLiteral("zetrith.prepatcher")), - make_mod(QStringLiteral("ludeon.rimworld")), - make_mod(QStringLiteral("ludeon.rimworld.royalty")), - make_mod(QStringLiteral("ludeon.rimworld.ideology")), - make_mod(QStringLiteral("ludeon.rimworld.biotech")), - make_mod(QStringLiteral("ludeon.rimworld.anomaly")), - make_mod(QStringLiteral("ludeon.rimworld.odyssey")), + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), + makeMod(QStringLiteral("ludeon.rimworld.ideology")), + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("ludeon.rimworld.anomaly")), + makeMod(QStringLiteral("ludeon.rimworld.odyssey")), }); - assert_order(sort_mods(mods), + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony"), QStringLiteral("ludeon.rimworld"), QStringLiteral("ludeon.rimworld.royalty"), QStringLiteral("ludeon.rimworld.ideology"), QStringLiteral("ludeon.rimworld.biotech"), @@ -93,79 +93,79 @@ TEST_CASE("tier 0 mods follow the config order") { } TEST_CASE("tier 0 partial active set keeps config order") { - const auto mods = active_dict({ - make_mod(QStringLiteral("ludeon.rimworld.biotech")), - make_mod(QStringLiteral("zetrith.prepatcher")), - make_mod(QStringLiteral("ludeon.rimworld.royalty")), + const auto mods = activeDict({ + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), }); - assert_order(sort_mods(mods), + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("ludeon.rimworld.royalty"), QStringLiteral("ludeon.rimworld.biotech")}); } TEST_CASE("tier 0 load-after rule orders mods within the tier") { - const auto mods = active_dict({ - make_mod(QStringLiteral("brrainz.harmony")), - make_mod(QStringLiteral("zetrith.prepatcher")), + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), }); - mods.value(pid(QStringLiteral("brrainz.harmony")))->about_rules.load_after = + mods.value(pid(QStringLiteral("brrainz.harmony")))->aboutRules.loadAfter = CaseInsensitiveSet(QStringList{QStringLiteral("zetrith.prepatcher")}); - assert_order(sort_mods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); } TEST_CASE("tier 1 mods follow the config order") { - const auto mods = active_dict({ - make_mod(QStringLiteral("unlimitedhugs.hugslib")), - make_mod(QStringLiteral("adaptive.storage.framework")), - make_mod(QStringLiteral("aoba.framework")), - make_mod(QStringLiteral("smashphil.vehicleframework")), - make_mod(QStringLiteral("vanillaexpanded.backgrounds")), + const auto mods = activeDict({ + makeMod(QStringLiteral("unlimitedhugs.hugslib")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("aoba.framework")), + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), }); - assert_order(sort_mods(mods), + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), QStringLiteral("aoba.framework"), QStringLiteral("smashphil.vehicleframework"), QStringLiteral("unlimitedhugs.hugslib"), QStringLiteral("vanillaexpanded.backgrounds")}); } TEST_CASE("tier 1 partial active set keeps config order") { - const auto mods = active_dict({ - make_mod(QStringLiteral("smashphil.vehicleframework")), - make_mod(QStringLiteral("adaptive.storage.framework")), - make_mod(QStringLiteral("vanillaexpanded.backgrounds")), + const auto mods = activeDict({ + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), }); - assert_order(sort_mods(mods), + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), QStringLiteral("smashphil.vehicleframework"), QStringLiteral("vanillaexpanded.backgrounds")}); } TEST_CASE("tier 1 load-after rule orders mods within the tier") { - const auto mods = active_dict({ - make_mod(QStringLiteral("aoba.exosuit.framework")), - make_mod(QStringLiteral("aoba.framework")), + const auto mods = activeDict({ + makeMod(QStringLiteral("aoba.exosuit.framework")), + makeMod(QStringLiteral("aoba.framework")), }); - mods.value(pid(QStringLiteral("aoba.exosuit.framework")))->about_rules.load_after = + mods.value(pid(QStringLiteral("aoba.exosuit.framework")))->aboutRules.loadAfter = CaseInsensitiveSet(QStringList{QStringLiteral("aoba.framework")}); - assert_order(sort_mods(mods), {QStringLiteral("aoba.framework"), QStringLiteral("aoba.exosuit.framework")}); + assertOrder(sortMods(mods), {QStringLiteral("aoba.framework"), QStringLiteral("aoba.exosuit.framework")}); } TEST_CASE("tiers are ordered: 0 before 1 before 2 before 3") { - const auto mods = active_dict({ - make_mod(QStringLiteral("some.two.mod")), - make_mod(QStringLiteral("brrainz.harmony")), - make_mod(QStringLiteral("adaptive.storage.framework")), - make_mod(QStringLiteral("some.three.mod")), + const auto mods = activeDict({ + makeMod(QStringLiteral("some.two.mod")), + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("some.three.mod")), }); - TierConfig tier_config; - tier_config.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; - tier_config.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; - tier_config.tier_3 = {pid(QStringLiteral("some.three.mod"))}; + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; + tierConfig.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; + tierConfig.tier_3 = {pid(QStringLiteral("some.three.mod"))}; - const auto result = sort_mods(mods, tier_config); + const auto result = sortMods(mods, tierConfig); CHECK(result.indexOf(pid(QStringLiteral("brrainz.harmony"))) < result.indexOf(pid(QStringLiteral("adaptive.storage.framework")))); @@ -176,15 +176,15 @@ TEST_CASE("tiers are ordered: 0 before 1 before 2 before 3") { } TEST_CASE("mods outside every configured tier come after configured mods") { - const auto mods = active_dict({ - make_mod(QStringLiteral("unknown.first")), - make_mod(QStringLiteral("zetrith.prepatcher")), - make_mod(QStringLiteral("unknown.second")), - make_mod(QStringLiteral("brrainz.harmony")), + const auto mods = activeDict({ + makeMod(QStringLiteral("unknown.first")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("unknown.second")), + makeMod(QStringLiteral("brrainz.harmony")), }); - TierConfig tier_config; - tier_config.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), pid(QStringLiteral("brrainz.harmony"))}; + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), pid(QStringLiteral("brrainz.harmony"))}; - assert_order(sort_mods(mods, tier_config), + assertOrder(sortMods(mods, tierConfig), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); } diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp index 0185ba3..ea838a8 100644 --- a/tests/core/sort/test_tiers.cpp +++ b/tests/core/sort/test_tiers.cpp @@ -6,10 +6,10 @@ #include "core/sort/tiers.hpp" -using px::modrim::sort::assign_tiers; -using px::modrim::sort::find_cycle; -using px::modrim::sort::get_deps_recursive; -using px::modrim::sort::get_reverse_deps_recursive; +using px::modrim::sort::assignTiers; +using px::modrim::sort::findCycle; +using px::modrim::sort::getDepsRecursive; +using px::modrim::sort::getReverseDepsRecursive; using px::modrim::sort::PackageGraph; using px::modrim::sort::PackageId; using px::modrim::sort::Tier; @@ -21,7 +21,7 @@ PackageId pid(const QString& value) { return PackageId(value); } -void add_edge(PackageGraph& graph, const QString& from, const QString& to) { +void addEdge(PackageGraph& graph, const QString& from, const QString& to) { graph[pid(from)].insert(pid(to)); } @@ -36,20 +36,20 @@ TEST_CASE("tier graph algorithms handle long chains") { const QSet all(nodes.cbegin(), nodes.cend()); PackageGraph deps; - PackageGraph reverse_deps; + PackageGraph reverseDeps; for (int index = 0; index + 1 < kCount; ++index) { deps[nodes[index]].insert(nodes[index + 1]); - reverse_deps[nodes[index + 1]].insert(nodes[index]); + reverseDeps[nodes[index + 1]].insert(nodes[index]); } const QSet tail(nodes.cbegin() + 1, nodes.cend()); const QSet head(nodes.cbegin(), nodes.cend() - 1); - CHECK(get_deps_recursive(nodes.front(), deps) == tail); - CHECK(get_reverse_deps_recursive(nodes.back(), reverse_deps) == head); - CHECK_FALSE(find_cycle(all, deps).has_value()); + CHECK(getDepsRecursive(nodes.front(), deps) == tail); + CHECK(getReverseDepsRecursive(nodes.back(), reverseDeps) == head); + CHECK_FALSE(findCycle(all, deps).has_value()); deps[nodes.back()].insert(nodes.front()); - const auto cycle = find_cycle(all, deps); + const auto cycle = findCycle(all, deps); REQUIRE(cycle.has_value()); CHECK(cycle->size() == kCount + 1); CHECK(cycle->front() == cycle->back()); @@ -58,14 +58,14 @@ TEST_CASE("tier graph algorithms handle long chains") { TEST_CASE("find_cycle returns a closed path whose steps are dependency edges") { PackageGraph deps; - add_edge(deps, QStringLiteral("a"), QStringLiteral("b")); - add_edge(deps, QStringLiteral("b"), QStringLiteral("c")); - add_edge(deps, QStringLiteral("c"), QStringLiteral("a")); - add_edge(deps, QStringLiteral("c"), QStringLiteral("d")); + addEdge(deps, QStringLiteral("a"), QStringLiteral("b")); + addEdge(deps, QStringLiteral("b"), QStringLiteral("c")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("a")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("d")); const QSet nodes{pid(QStringLiteral("a")), pid(QStringLiteral("b")), pid(QStringLiteral("c")), pid(QStringLiteral("d"))}; - const auto cycle = find_cycle(nodes, deps); + const auto cycle = findCycle(nodes, deps); REQUIRE(cycle.has_value()); REQUIRE(cycle->size() == 4); CHECK(cycle->front() == cycle->back()); @@ -76,40 +76,40 @@ TEST_CASE("find_cycle returns a closed path whose steps are dependency edges") { TEST_CASE("find_cycle ignores edges that leave the node set") { PackageGraph deps; - add_edge(deps, QStringLiteral("a"), QStringLiteral("outside")); - add_edge(deps, QStringLiteral("outside"), QStringLiteral("a")); + addEdge(deps, QStringLiteral("a"), QStringLiteral("outside")); + addEdge(deps, QStringLiteral("outside"), QStringLiteral("a")); const QSet nodes{pid(QStringLiteral("a"))}; - CHECK_FALSE(find_cycle(nodes, deps).has_value()); + CHECK_FALSE(findCycle(nodes, deps).has_value()); } TEST_CASE("assign_tiers places tier 0 mods and their dependencies in tier 0") { PackageGraph deps; - add_edge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); + addEdge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); const QSet canonical{pid(QStringLiteral("zetrith")), pid(QStringLiteral("bar")), pid(QStringLiteral("other"))}; TierConfig config; config.tier_0 = {pid(QStringLiteral("zetrith"))}; - const auto tiers = assign_tiers(canonical, deps, {}, {}, {}, config); + const auto tiers = assignTiers(canonical, deps, {}, {}, {}, config); - CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::Tier0); - CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::Tier0); - CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::Tier2); + CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::kTier2); } TEST_CASE("assign_tiers sinks load-last mods and their dependents to tier 3") { - PackageGraph rev_deps; - add_edge(rev_deps, QStringLiteral("late"), QStringLiteral("dependent")); + PackageGraph revDeps; + addEdge(revDeps, QStringLiteral("late"), QStringLiteral("dependent")); const QSet canonical{pid(QStringLiteral("late")), pid(QStringLiteral("dependent")), pid(QStringLiteral("mid"))}; - const auto tiers = assign_tiers(canonical, {}, rev_deps, {}, + const auto tiers = assignTiers(canonical, {}, revDeps, {}, QSet{pid(QStringLiteral("late"))}, TierConfig{}); - CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::Tier3); - CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::Tier3); - CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::Tier2); + CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::kTier2); } TEST_CASE("tier 0 overrides tier 3 on overlap") { @@ -117,21 +117,21 @@ TEST_CASE("tier 0 overrides tier 3 on overlap") { TierConfig config; config.tier_0 = {pid(QStringLiteral("x"))}; - const auto tiers = assign_tiers(canonical, {}, {}, {}, + const auto tiers = assignTiers(canonical, {}, {}, {}, QSet{pid(QStringLiteral("x"))}, config); - CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::Tier0); + CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::kTier0); } TEST_CASE("tier 1 only promotes tier 2 mods") { const QSet canonical{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; - const QSet load_first{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; - const QSet load_last{pid(QStringLiteral("last"))}; + const QSet loadFirst{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; + const QSet loadLast{pid(QStringLiteral("last"))}; - const auto tiers = assign_tiers(canonical, {}, {}, load_first, load_last, TierConfig{}); + const auto tiers = assignTiers(canonical, {}, {}, loadFirst, loadLast, TierConfig{}); - CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::Tier1); - CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::Tier3); + CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::kTier1); + CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::kTier3); } TEST_CASE("assign_tiers never returns ids outside the canonical set") { @@ -140,9 +140,9 @@ TEST_CASE("assign_tiers never returns ids outside the canonical set") { config.tier_0 = {pid(QStringLiteral("ghost"))}; config.tier_3 = {pid(QStringLiteral("ghost"))}; - const auto tiers = assign_tiers(canonical, {}, {}, {}, {}, config); + const auto tiers = assignTiers(canonical, {}, {}, {}, {}, config); CHECK(tiers.size() == 1); CHECK_FALSE(tiers.contains(pid(QStringLiteral("ghost")))); - CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::Tier2); + CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::kTier2); } diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp index 990d70a..fb74ffa 100644 --- a/tests/core/support/test_report.cpp +++ b/tests/core/support/test_report.cpp @@ -22,7 +22,7 @@ using namespace px::modrim; using namespace px::modrim::support; using px::modrim::Result; using test_support::TestApplication; -using test_support::write_bytes; +using test_support::writeBytes; namespace { @@ -32,7 +32,7 @@ T wait(QCoro::Task task) { return QCoro::waitFor(std::move(task)); } -void require_error(const Result& result, const QString& fragment) { +void requireError(const Result& result, const QString& fragment) { REQUIRE_FALSE(result.has_value()); CHECK(result.error().message.contains(fragment)); } @@ -41,38 +41,38 @@ void require_error(const Result& result, const QString& fragment) { // hit the client's timeout. The request is recorded for inspection. class CannedServer { public: - explicit CannedServer(QByteArray response) : response_(std::move(response)) { - REQUIRE(server_.listen(QHostAddress::LocalHost)); - QObject::connect(&server_, &QTcpServer::newConnection, [this] { accept(); }); + explicit CannedServer(QByteArray response) : m_response(std::move(response)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, [this] { accept(); }); } QString url() const { - return QStringLiteral("http://127.0.0.1:%1/").arg(server_.serverPort()); + return QStringLiteral("http://127.0.0.1:%1/").arg(m_server.serverPort()); } - const QByteArray& request() const { return request_; } + const QByteArray& request() const { return m_request; } private: void accept() { - QTcpSocket* socket = server_.nextPendingConnection(); - socket->setParent(&server_); + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket] { - request_ += socket->readAll(); - if (response_.isEmpty() || !complete(request_)) { + m_request += socket->readAll(); + if (m_response.isEmpty() || !complete(m_request)) { return; } - socket->write(response_); + socket->write(m_response); socket->disconnectFromHost(); }); } // True once the headers have arrived and the Content-Length body is complete. static bool complete(const QByteArray& raw) { - const qsizetype headers_end = raw.indexOf("\r\n\r\n"); - if (headers_end < 0) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { return false; } - const QByteArray headers = raw.left(headers_end).toLower(); + const QByteArray headers = raw.left(headersEnd).toLower(); const qsizetype key = headers.indexOf("content-length:"); if (key < 0) { return true; @@ -82,23 +82,23 @@ class CannedServer { eol = headers.size(); } const qint64 length = headers.mid(key + 15, eol - key - 15).trimmed().toLongLong(); - return raw.size() - (headers_end + 4) >= length; + return raw.size() - (headersEnd + 4) >= length; } - QTcpServer server_; - QByteArray response_; - QByteArray request_; + QTcpServer m_server; + QByteArray m_response; + QByteArray m_request; }; -QByteArray http_response(const QByteArray& status_line, const QByteArray& body) { - return "HTTP/1.1 " + status_line + "\r\nContent-Type: text/plain\r\nContent-Length: " + +QByteArray httpResponse(const QByteArray& statusLine, const QByteArray& body) { + return "HTTP/1.1 " + statusLine + "\r\nContent-Type: text/plain\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body; } -ListedModPtr about_mod(const QString& name, const QString& package_id) { +ListedModPtr aboutMod(const QString& name, const QString& packageId) { auto mod = std::make_shared(); mod->name = name; - mod->package_id = CaseInsensitiveStr(package_id); + mod->packageId = CaseInsensitiveStr(packageId); return mod; } @@ -154,7 +154,7 @@ TEST_CASE("read_log_tail reports a missing file") { QTemporaryDir dir; REQUIRE(dir.isValid()); const QString content = - read_log_tail(QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); + readLogTail(QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); CHECK(content.contains(QStringLiteral(""))); } @@ -162,8 +162,8 @@ TEST_CASE("read_log_tail reports an empty file") { QTemporaryDir dir; REQUIRE(dir.isValid()); const QString path = QDir(dir.path()).filePath(QStringLiteral("empty.log")); - REQUIRE(write_bytes(path, "")); - CHECK(read_log_tail(path).contains(QStringLiteral(""))); + REQUIRE(writeBytes(path, "")); + CHECK(readLogTail(path).contains(QStringLiteral(""))); } TEST_CASE("read_log_tail returns a small file unchanged") { @@ -171,21 +171,21 @@ TEST_CASE("read_log_tail returns a small file unchanged") { REQUIRE(dir.isValid()); const QString path = QDir(dir.path()).filePath(QStringLiteral("app.log")); const char* text = "Line 1\nLine 2\nLine 3\n"; - REQUIRE(write_bytes(path, text)); - CHECK(read_log_tail(path) == QString::fromLatin1(text)); + REQUIRE(writeBytes(path, text)); + CHECK(readLogTail(path) == QString::fromLatin1(text)); } TEST_CASE("read_log_tail unpacks loguru JSON records") { QTemporaryDir dir; REQUIRE(dir.isValid()); const QString path = QDir(dir.path()).filePath(QStringLiteral("json.log")); - REQUIRE(write_bytes(path, + REQUIRE(writeBytes(path, R"({"text": "2026-09-28 | INFO | Loaded mods\n", "record": {"id": 1}})" "\n" "Plain log line\n" R"({"text": "2026-09-28 | INFO | Ready\n", "record": {"id": 2}})" "\n")); - const QString content = read_log_tail(path); + const QString content = readLogTail(path); CHECK(content == QStringLiteral("2026-09-28 | INFO | Loaded mods\n" "Plain log line\n" "2026-09-28 | INFO | Ready\n")); @@ -200,9 +200,9 @@ TEST_CASE("read_log_tail keeps the end of a large file and adds a banner") { for (int i = 0; i < 200; ++i) { lines.append(QStringLiteral("Log record number %1 with some extra padding").arg(i, 4, 10, QLatin1Char('0'))); } - REQUIRE(write_bytes(path, lines.join(QLatin1Char('\n')).toStdString())); + REQUIRE(writeBytes(path, lines.join(QLatin1Char('\n')).toStdString())); - const QString content = read_log_tail(path, 200); + const QString content = readLogTail(path, 200); CHECK(content.startsWith(QStringLiteral("[... truncated, showing last "))); CHECK(content.contains(QStringLiteral(" of "))); CHECK(content.contains(QStringLiteral("Log record number 0199"))); @@ -211,31 +211,31 @@ TEST_CASE("read_log_tail keeps the end of a large file and adds a banner") { TEST_CASE("build_report contains every section with active mods in load order") { TestApplication app; - QTemporaryDir home_dir; - REQUIRE(home_dir.isValid()); - QTemporaryDir log_dir; - REQUIRE(log_dir.isValid()); + QTemporaryDir homeDir; + REQUIRE(homeDir.isValid()); + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); - const QString home = QDir(home_dir.path()).filePath(QStringLiteral("home/testuser")); + const QString home = QDir(homeDir.path()).filePath(QStringLiteral("home/testuser")); REQUIRE(QDir().mkpath(home)); ReportSource source; - source.game_version = QStringLiteral("1.5.4243"); + source.gameVersion = QStringLiteral("1.5.4243"); source.paths.game = QDir(home).filePath(QStringLiteral("Games/RimWorld")).toStdString(); source.paths.config_folder = QDir(home).filePath(QStringLiteral(".config/unity3d")).toStdString(); source.paths.local = QDir(home).filePath(QStringLiteral("Games/RimWorld/Mods")).toStdString(); - source.all_mods = { - {QStringLiteral("u1"), about_mod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, - {QStringLiteral("u2"), about_mod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, - {QStringLiteral("u3"), about_mod(QStringLiteral("Royalty"), QStringLiteral("ludeon.rimworld.royalty"))}, + source.allMods = { + {QStringLiteral("u1"), aboutMod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, + {QStringLiteral("u2"), aboutMod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, + {QStringLiteral("u3"), aboutMod(QStringLiteral("Royalty"), QStringLiteral("ludeon.rimworld.royalty"))}, }; - source.active_uuids = {QStringLiteral("u2"), QStringLiteral("u1"), QStringLiteral("u3")}; + source.activeUuids = {QStringLiteral("u2"), QStringLiteral("u1"), QStringLiteral("u3")}; - const QString log_path = QDir(log_dir.path()).filePath(QStringLiteral("app.log")); - REQUIRE(write_bytes(log_path, "INFO | Loaded /home/testuser/mods for testuser\n")); + const QString logPath = QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(writeBytes(logPath, "INFO | Loaded /home/testuser/mods for testuser\n")); const QString report = - build_report(source, log_path, home, QStringLiteral("testuser")); + buildReport(source, logPath, home, QStringLiteral("testuser")); CHECK(report.contains(QStringLiteral("=== PxModRim System Report ==="))); CHECK(report.contains(QStringLiteral("=== RimWorld Configuration ==="))); @@ -248,9 +248,9 @@ TEST_CASE("build_report contains every section with active mods in load order") CHECK(report.contains(QStringLiteral("OS: "))); CHECK(report.contains(QStringLiteral("Game Version: 1.5.4243"))); - const QString slashed_report = QString(report).replace(u'\\', u'/'); - CHECK(slashed_report.contains(QStringLiteral("~/Games/RimWorld"))); - CHECK(slashed_report.contains(QStringLiteral("~/.config/unity3d"))); + const QString slashedReport = QString(report).replace(u'\\', u'/'); + CHECK(slashedReport.contains(QStringLiteral("~/Games/RimWorld"))); + CHECK(slashedReport.contains(QStringLiteral("~/.config/unity3d"))); CHECK_FALSE(report.contains(home)); CHECK(report.contains(QStringLiteral("Total Mods: 3"))); @@ -265,21 +265,21 @@ TEST_CASE("build_report contains every section with active mods in load order") TEST_CASE("build_report names active uuids it cannot resolve") { TestApplication app; - QTemporaryDir log_dir; - REQUIRE(log_dir.isValid()); + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); ReportSource source; - source.active_uuids = {QStringLiteral("/mods/gone")}; + source.activeUuids = {QStringLiteral("/mods/gone")}; - const QString log_path = QDir(log_dir.path()).filePath(QStringLiteral("app.log")); - REQUIRE(write_bytes(log_path, "x\n")); - const QString report = build_report(source, log_path); + const QString logPath = QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(writeBytes(logPath, "x\n")); + const QString report = buildReport(source, logPath); CHECK(report.contains(QStringLiteral(" 1. Unknown Mod (/mods/gone)"))); CHECK(report.contains(QStringLiteral("Total Mods: 0"))); } TEST_CASE("PasteUploader posts the report and returns the URL") { TestApplication app; - CannedServer server(http_response("201 Created", "https://paste.rs/abc123\n")); + CannedServer server(httpResponse("201 Created", "https://paste.rs/abc123\n")); QNetworkAccessManager network; PasteUploader uploader(network, server.url()); @@ -297,33 +297,33 @@ TEST_CASE("PasteUploader rejects empty text without sending it") { TestApplication app; QNetworkAccessManager network; PasteUploader uploader(network, QStringLiteral("http://127.0.0.1:9/")); - require_error(wait(uploader.upload(QStringLiteral(" "))), QStringLiteral("Report text is empty")); + requireError(wait(uploader.upload(QStringLiteral(" "))), QStringLiteral("Report text is empty")); } TEST_CASE("PasteUploader reports an HTTP error status") { TestApplication app; - CannedServer server(http_response("403 Forbidden", "nope")); + CannedServer server(httpResponse("403 Forbidden", "nope")); QNetworkAccessManager network; PasteUploader uploader(network, server.url()); - require_error(wait(uploader.upload(QStringLiteral("Report content"))), + requireError(wait(uploader.upload(QStringLiteral("Report content"))), QStringLiteral("HTTP 403 Forbidden")); } TEST_CASE("PasteUploader reports a server error status") { TestApplication app; - CannedServer server(http_response("500 Internal Server Error", "boom")); + CannedServer server(httpResponse("500 Internal Server Error", "boom")); QNetworkAccessManager network; PasteUploader uploader(network, server.url()); - require_error(wait(uploader.upload(QStringLiteral("Report content"))), + requireError(wait(uploader.upload(QStringLiteral("Report content"))), QStringLiteral("HTTP 500 Internal Server Error")); } TEST_CASE("PasteUploader rejects a success body that is not a URL") { TestApplication app; - CannedServer server(http_response("200 OK", "Something unexpected went wrong")); + CannedServer server(httpResponse("200 OK", "Something unexpected went wrong")); QNetworkAccessManager network; PasteUploader uploader(network, server.url()); - require_error(wait(uploader.upload(QStringLiteral("Report content"))), + requireError(wait(uploader.upload(QStringLiteral("Report content"))), QStringLiteral("valid URL")); } @@ -337,7 +337,7 @@ TEST_CASE("PasteUploader reports a refused connection as a network error") { QNetworkAccessManager network; PasteUploader uploader(network, url); - require_error(wait(uploader.upload(QStringLiteral("Report content"))), + requireError(wait(uploader.upload(QStringLiteral("Report content"))), QStringLiteral("network error")); } @@ -346,6 +346,6 @@ TEST_CASE("PasteUploader reports a request that gets no answer as a timeout") { CannedServer server{QByteArray{}}; QNetworkAccessManager network; PasteUploader uploader(network, server.url(), std::chrono::milliseconds(200)); - require_error(wait(uploader.upload(QStringLiteral("Report content"))), + requireError(wait(uploader.upload(QStringLiteral("Report content"))), QStringLiteral("timed out")); } diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index f012fb9..5574b1a 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -18,36 +18,38 @@ namespace { using namespace px::modrim; -using test_support::read_bytes; -using test_support::run_sync; -using test_support::write_bytes; +using test_support::readBytes; +using test_support::runSync; +using test_support::writeBytes; // Stands in for ui_prefs.json's struct: these tests exercise ConfigService's // versioning, not the UI settings themselves. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire struct TestPrefs { bool desc_expanded = false; - LaunchStrategy launch_strategy = LaunchStrategy::Direct; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; std::string skipped_update_tag; bool operator==(const TestPrefs&) const = default; }; +// NOLINTEND(readability-identifier-naming) -QString path_in(const QTemporaryDir& dir, const char* name) { return dir.filePath(QString::fromLatin1(name)); } +QString pathIn(const QTemporaryDir& dir, const char* name) { return dir.filePath(QString::fromLatin1(name)); } -QStringList backups_of(const QTemporaryDir& dir, const char* name) { +QStringList backupsOf(const QTemporaryDir& dir, const char* name) { return QDir(dir.path()).entryList({QString::fromLatin1(name) + QStringLiteral(".bak.*")}, QDir::Files); } -int schema_version_of(const QString& path) { +int schemaVersionOf(const QString& path) { glz::json_t document; - REQUIRE_FALSE(glz::read_json(document, read_bytes(path))); + REQUIRE_FALSE(glz::read_json(document, readBytes(path))); const auto& object = document.get_object(); const auto marker = object.find(std::string(kSchemaVersionKey)); REQUIRE(marker != object.end()); return static_cast(marker->second.get()); } -AppConfig legacy_expected_config() { +AppConfig legacyExpectedConfig() { AppConfig cfg; cfg.paths = PathConfig{ .game = "/legacy/game", @@ -66,60 +68,60 @@ AppConfig legacy_expected_config() { TEST_CASE("read_game_version reads and trims Version.txt") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE(write_bytes(path_in(dir, "Version.txt"), "1.6.4871 rev598\n")); - REQUIRE(read_game_version(dir.path()) == QStringLiteral("1.6.4871 rev598")); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); - REQUIRE(write_bytes(path_in(dir, "Version.txt"), " 1.6.4871 rev598 \n")); - REQUIRE(read_game_version(dir.path()) == QStringLiteral("1.6.4871 rev598")); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), " 1.6.4871 rev598 \n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); } TEST_CASE("read_game_version returns nullopt when Version.txt is missing or unreadable") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE_FALSE(read_game_version(dir.path()).has_value()); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); - REQUIRE(write_bytes(path_in(dir, "Version.txt"), "1.6.4871 rev598\n")); - REQUIRE(QFile(path_in(dir, "Version.txt")).setPermissions(QFileDevice::Permissions{})); - REQUIRE_FALSE(read_game_version(dir.path()).has_value()); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(QFile(pathIn(dir, "Version.txt")).setPermissions(QFileDevice::Permissions{})); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); } TEST_CASE("legacy app config migrates with a backup") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "config.json"); + const QString path = pathIn(dir, "config.json"); const std::string legacy = R"({"paths":{"game":"/legacy/game","local":"/legacy/mods","workshop":"/legacy/workshop",)" R"("config_folder":"/legacy/config","community_rules_file":"/legacy/rules",)" R"("no_version_warning_file":"/legacy/no-warning","use_this_instead_file":"/legacy/use-instead",)" R"("steamcmd_prefix":"/legacy/steamcmd"}})"; - REQUIRE(write_bytes(path, legacy)); + REQUIRE(writeBytes(path, legacy)); ConfigService service(dir.path()); AppConfigService app(service); - REQUIRE(run_sync(app.setup()).has_value()); + REQUIRE(runSync(app.setup()).has_value()); - REQUIRE(app.cfg() == legacy_expected_config()); - REQUIRE(schema_version_of(path) == 1); - const auto backups = backups_of(dir, "config.json"); + REQUIRE(app.cfg() == legacyExpectedConfig()); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "config.json"); REQUIRE(backups.size() == 1); - REQUIRE(read_bytes(dir.filePath(backups.first())) == legacy); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); } TEST_CASE("legacy ui prefs migrate with a backup") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "ui_prefs.json"); + const QString path = pathIn(dir, "ui_prefs.json"); const std::string legacy = R"({"validate_downloads":true,"desc_expanded":true,"launch_strategy":1})"; - REQUIRE(write_bytes(path, legacy)); + REQUIRE(writeBytes(path, legacy)); ConfigService service(dir.path()); const auto prefs = service.load("ui_prefs.json"); REQUIRE(prefs.has_value()); - REQUIRE(*prefs == TestPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::Steam, .skipped_update_tag = ""}); - REQUIRE(schema_version_of(path) == 1); - const auto backups = backups_of(dir, "ui_prefs.json"); + REQUIRE(*prefs == TestPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::kSteam, .skipped_update_tag = ""}); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "ui_prefs.json"); REQUIRE(backups.size() == 1); - REQUIRE(read_bytes(dir.filePath(backups.first())) == legacy); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); } TEST_CASE("fresh services return defaults without creating files") { @@ -127,73 +129,73 @@ TEST_CASE("fresh services return defaults without creating files") { REQUIRE(dir.isValid()); ConfigService service(dir.path()); AppConfigService app(service); - REQUIRE(run_sync(app.setup()).has_value()); + REQUIRE(runSync(app.setup()).has_value()); REQUIRE(app.cfg() == AppConfig{}); REQUIRE(service.load("ui_prefs.json") == TestPrefs{}); - REQUIRE_FALSE(QFileInfo::exists(path_in(dir, "config.json"))); - REQUIRE_FALSE(QFileInfo::exists(path_in(dir, "ui_prefs.json"))); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "config.json"))); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "ui_prefs.json"))); } TEST_CASE("managed saves stamp schema_version and round-trip values") { QTemporaryDir dir; REQUIRE(dir.isValid()); ConfigService service(dir.path()); - AppConfig expected_config; - expected_config.paths.game = "/game"; - expected_config.paths.local = "/mods"; - const TestPrefs expected_prefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::Steam, .skipped_update_tag = ""}; + AppConfig expectedConfig; + expectedConfig.paths.game = "/game"; + expectedConfig.paths.local = "/mods"; + const TestPrefs expectedPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::kSteam, .skipped_update_tag = ""}; - REQUIRE(service.save("config.json", expected_config).has_value()); - REQUIRE(read_bytes(path_in(dir, "config.json")).find("\"schema_version\": 1\n}") != std::string::npos); - REQUIRE(service.save("ui_prefs.json", expected_prefs).has_value()); + REQUIRE(service.save("config.json", expectedConfig).has_value()); + REQUIRE(readBytes(pathIn(dir, "config.json")).find("\"schema_version\": 1\n}") != std::string::npos); + REQUIRE(service.save("ui_prefs.json", expectedPrefs).has_value()); - REQUIRE(schema_version_of(path_in(dir, "config.json")) == 1); - REQUIRE(schema_version_of(path_in(dir, "ui_prefs.json")) == 1); - REQUIRE(service.load("config.json") == expected_config); - REQUIRE(service.load("ui_prefs.json") == expected_prefs); + REQUIRE(schemaVersionOf(pathIn(dir, "config.json")) == 1); + REQUIRE(schemaVersionOf(pathIn(dir, "ui_prefs.json")) == 1); + REQUIRE(service.load("config.json") == expectedConfig); + REQUIRE(service.load("ui_prefs.json") == expectedPrefs); } TEST_CASE("current config load does not rewrite the file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "config.json"); - REQUIRE(write_bytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); - const std::string before = read_bytes(path); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); + const std::string before = readBytes(path); const auto cfg = ConfigService(dir.path()).load("config.json"); REQUIRE(cfg.has_value()); REQUIRE(cfg->paths.game == "/current"); - REQUIRE(read_bytes(path) == before); - REQUIRE(backups_of(dir, "config.json").isEmpty()); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); } TEST_CASE("current ui prefs load does not rewrite the file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "ui_prefs.json"); - REQUIRE(write_bytes(path, R"({"schema_version":1,"desc_expanded":true})")); - const std::string before = read_bytes(path); + const QString path = pathIn(dir, "ui_prefs.json"); + REQUIRE(writeBytes(path, R"({"schema_version":1,"desc_expanded":true})")); + const std::string before = readBytes(path); const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); REQUIRE(prefs.has_value()); REQUIRE(prefs->desc_expanded); - REQUIRE(read_bytes(path) == before); - REQUIRE(backups_of(dir, "ui_prefs.json").isEmpty()); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "ui_prefs.json").isEmpty()); } TEST_CASE("corrupt config returns safe defaults") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE(write_bytes(path_in(dir, "config.json"), "{invalid")); + REQUIRE(writeBytes(pathIn(dir, "config.json"), "{invalid")); REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); } TEST_CASE("unreadable config returns safe defaults") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "config.json"); - REQUIRE(write_bytes(path, "{}")); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, "{}")); REQUIRE(QFile(path).setPermissions(QFileDevice::Permissions{})); REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); } @@ -201,43 +203,43 @@ TEST_CASE("unreadable config returns safe defaults") { TEST_CASE("missing JSON migration step fails without rewrite") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "config.json"); - REQUIRE(write_bytes(path, R"({"paths":{"game":"/legacy"}})")); - const std::string before = read_bytes(path); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"paths":{"game":"/legacy"}})")); + const std::string before = readBytes(path); const ConfigService service(dir.path(), JsonMigrations{}); const auto loaded = service.load("config.json"); REQUIRE_FALSE(loaded.has_value()); REQUIRE(loaded.error().message.contains("Missing JSON migration step")); - REQUIRE(read_bytes(path) == before); - REQUIRE(backups_of(dir, "config.json").isEmpty()); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); } TEST_CASE("unsupported config schema fails without mutation") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString path = path_in(dir, "config.json"); - REQUIRE(write_bytes(path, R"({"schema_version":2})")); - const std::string before = read_bytes(path); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"schema_version":2})")); + const std::string before = readBytes(path); const auto loaded = ConfigService(dir.path()).load("config.json"); REQUIRE_FALSE(loaded.has_value()); REQUIRE(loaded.error().message.contains("Unsupported JSON schema version")); - REQUIRE(read_bytes(path) == before); + REQUIRE(readBytes(path) == before); } TEST_CASE("ui prefs skipped update tag round-trips") { QTemporaryDir dir; REQUIRE(dir.isValid()); ConfigService service(dir.path()); - REQUIRE(service.save("ui_prefs.json", TestPrefs{.desc_expanded = false, .launch_strategy = LaunchStrategy::Direct, .skipped_update_tag = "v0.2.0"}).has_value()); + REQUIRE(service.save("ui_prefs.json", TestPrefs{.desc_expanded = false, .launch_strategy = LaunchStrategy::kDirect, .skipped_update_tag = "v0.2.0"}).has_value()); REQUIRE(service.load("ui_prefs.json").value().skipped_update_tag == "v0.2.0"); } TEST_CASE("ui prefs with removed keys still load") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE(write_bytes(path_in(dir, "ui_prefs.json"), + REQUIRE(writeBytes(pathIn(dir, "ui_prefs.json"), R"({"schema_version":1,"deps_expanded":true,"desc_expanded":true,)" R"("skipped_update_tag":"v0.3.0"})")); diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp index 6abe3c1..dfc6c50 100644 --- a/tests/core/test_context.cpp +++ b/tests/core/test_context.cpp @@ -23,29 +23,29 @@ using px::modrim::CoreContext; using px::modrim::DependencyMod; using px::modrim::ListedModPtr; using services_test::EventLoop; -using services_test::run_async; +using services_test::runAsync; namespace { -std::shared_ptr about(const QString& package_id, bool valid = true) { +std::shared_ptr about(const QString& packageId, bool valid = true) { auto mod = std::make_shared(); - mod->name = package_id; - mod->package_id = CaseInsensitiveStr(package_id); - mod->provider_id = QStringLiteral("local"); + mod->name = packageId; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("local"); mod->valid = valid; return mod; } -std::unique_ptr make_context(const QString& game_path = {}) { +std::unique_ptr makeContext(const QString& gamePath = {}) { static QTemporaryDir configDir; static px::modrim::ConfigService configService(configDir.path()); AppConfig cfg; - cfg.paths.game = game_path.toStdString(); + cfg.paths.game = gamePath.toStdString(); return CoreContext::create(cfg, &configService); } -void write_version(const QString& game_dir, const QString& version) { - QFile file(QDir(game_dir).filePath(QStringLiteral("Version.txt"))); +void writeVersion(const QString& gameDir, const QString& version) { + QFile file(QDir(gameDir).filePath(QStringLiteral("Version.txt"))); REQUIRE(file.open(QIODevice::WriteOnly)); file.write(version.toUtf8()); } @@ -56,11 +56,11 @@ TEST_CASE("target version is the major.minor of the game version") { QTemporaryDir game; REQUIRE(game.isValid()); - auto ctx = make_context(); + auto ctx = makeContext(); CHECK(ctx->gameVersion() == QStringLiteral("Unknown")); CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); - write_version(game.path(), QStringLiteral("1.5.4297 rev123")); + writeVersion(game.path(), QStringLiteral("1.5.4297 rev123")); ctx->updateConfig([&] { AppConfig cfg = ctx->config(); cfg.paths.game = game.path().toStdString(); @@ -73,16 +73,16 @@ TEST_CASE("target version is the major.minor of the game version") { TEST_CASE("a game version without a minor part has no target version") { QTemporaryDir game; REQUIRE(game.isValid()); - write_version(game.path(), QStringLiteral("1")); + writeVersion(game.path(), QStringLiteral("1")); - auto ctx = make_context(game.path()); + auto ctx = makeContext(game.path()); CHECK(ctx->gameVersion() == QStringLiteral("1")); CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); } TEST_CASE("setActive emits on every change and keeps the loaded list") { EventLoop loop; - auto ctx = make_context(); + auto ctx = makeContext(); ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, {QStringLiteral("b"), about(QStringLiteral("b.mod"))}}, {QStringLiteral("a")}); @@ -108,7 +108,7 @@ TEST_CASE("setActive emits on every change and keeps the loaded list") { TEST_CASE("computeStats counts total, active, inactive and invalid mods") { EventLoop loop; - auto ctx = make_context(); + auto ctx = makeContext(); ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, {QStringLiteral("b"), about(QStringLiteral("b.mod"))}, {QStringLiteral("c"), about(QStringLiteral("c.mod"), false)}}, @@ -120,9 +120,9 @@ TEST_CASE("computeStats counts total, active, inactive and invalid mods") { CHECK(stats.inactive == 2); CHECK(stats.errors == 1); - const auto override_stats = ctx->computeStats(QStringList{"a", "b"}); - CHECK(override_stats.active == 2); - CHECK(override_stats.inactive == 1); + const auto overrideStats = ctx->computeStats(QStringList{"a", "b"}); + CHECK(overrideStats.active == 2); + CHECK(overrideStats.inactive == 1); } TEST_CASE("autoSort enables a missing dependency and orders it first") { @@ -130,15 +130,15 @@ TEST_CASE("autoSort enables a missing dependency and orders it first") { auto dependent = about(QStringLiteral("mod.a")); DependencyMod dependency; dependency.name = QStringLiteral("Mod B"); - dependency.package_id = CaseInsensitiveStr(QStringLiteral("mod.b")); - dependent->about_rules.dependencies.append(std::move(dependency)); + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + dependent->aboutRules.dependencies.append(std::move(dependency)); - auto ctx = make_context(); + auto ctx = makeContext(); ctx->load({{QStringLiteral("uuid-a"), dependent}, {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, {QStringLiteral("uuid-a")}); - const auto [count, elapsed_ms] = run_async(ctx->autoSort()); + const auto [count, elapsed_ms] = runAsync(ctx->autoSort()); CHECK(count == 2); CHECK(elapsed_ms >= 0.0); REQUIRE(ctx->activeUuids().size() == 2); diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp index 8445b62..54f622d 100644 --- a/tests/core/test_loading.cpp +++ b/tests/core/test_loading.cpp @@ -33,7 +33,7 @@ TEST_CASE("loading state reports the innermost frame and pops back to its parent { auto outer = state.task(QStringLiteral("outer"), 4); REQUIRE(state.statuses() == QStringList{"outer"}); - state.set_progress(1); + state.setProgress(1); { auto inner = state.task(QStringLiteral("inner")); REQUIRE(state.statuses() == QStringList{"outer", "inner"}); @@ -56,7 +56,7 @@ TEST_CASE("percent truncates the float quotient, as the legacy progress did") { emissions.append(Emission{status, pct, current, total}); }); state.push(QStringLiteral("p"), 100); - state.set_progress(29); + state.setProgress(29); REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); } @@ -68,11 +68,11 @@ TEST_CASE("set_total clamps to at least one step and progress stays within bound emissions.append(Emission{status, pct, current, total}); }); state.push(QStringLiteral("t"), 10); - state.set_total(0); + state.setTotal(0); REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); - state.set_progress(50); + state.setProgress(50); REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 100, 1, 1}); - state.set_progress(-5); + state.setProgress(-5); REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); } diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp index d475645..2c688c7 100644 --- a/tests/core/test_migrator.cpp +++ b/tests/core/test_migrator.cpp @@ -16,38 +16,38 @@ namespace { -using px::modrim::ensure_schema; +using px::modrim::ensureSchema; using px::modrim::MigrationSteps; using px::modrim::Result; -using test_support::read_bytes; +using test_support::readBytes; // Owns one named SQLite connection and closes it when it goes out of scope. class Database { public: - Database(const QString& path, QString name) : name_(std::move(name)) { - db_ = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name_); - db_.setDatabaseName(path); - REQUIRE(db_.open()); + Database(const QString& path, QString name) : m_name(std::move(name)) { + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_name); + m_db.setDatabaseName(path); + REQUIRE(m_db.open()); } ~Database() { - db_.close(); - db_ = QSqlDatabase(); - QSqlDatabase::removeDatabase(name_); + m_db.close(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(m_name); } Database(const Database&) = delete; Database& operator=(const Database&) = delete; - QSqlDatabase& get() { return db_; } + QSqlDatabase& get() { return m_db; } void exec(const QString& sql) { - QSqlQuery query(db_); + QSqlQuery query(m_db); REQUIRE(query.exec(sql)); } QStringList column(const QString& sql) { - QSqlQuery query(db_); + QSqlQuery query(m_db); REQUIRE(query.exec(sql)); QStringList result; while (query.next()) { @@ -56,20 +56,20 @@ class Database { return result; } - int user_version() { - QSqlQuery query(db_); + int userVersion() { + QSqlQuery query(m_db); REQUIRE(query.exec(QStringLiteral("PRAGMA user_version"))); REQUIRE(query.next()); return query.value(0).toInt(); } private: - QSqlDatabase db_; - QString name_; + QSqlDatabase m_db; + QString m_name; }; -QStringList backups_of(const QString& dir, const QString& db_name) { - return QDir(dir).entryList({db_name + QStringLiteral(".bak.*")}, QDir::Files); +QStringList backupsOf(const QString& dir, const QString& dbName) { + return QDir(dir).entryList({dbName + QStringLiteral(".bak.*")}, QDir::Files); } Result noop() { return {}; } @@ -91,19 +91,19 @@ TEST_CASE("fresh database is initialised and stamped without running steps") { }; { Database db(path, QStringLiteral("fresh")); - REQUIRE(ensure_schema(db.get(), + REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE state (value TEXT DEFAULT 'a;b'); -- note; ignored\n" "CREATE TABLE other (x INTEGER);"), 2, steps, path) .has_value()); - REQUIRE(db.user_version() == 2); + REQUIRE(db.userVersion() == 2); REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'")) == QStringList{"state"}); REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'other'")) == QStringList{"other"}); } REQUIRE_FALSE(stepped); - REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } TEST_CASE("migrations checkpoint the WAL before backing up") { @@ -133,24 +133,24 @@ TEST_CASE("migrations checkpoint the WAL before backing up") { return {}; }}, }; - REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, steps, + REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, steps, path) .has_value()); - REQUIRE(db.user_version() == 3); + REQUIRE(db.userVersion() == 3); REQUIRE(db.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == QStringList{"value", "second", "third"}); REQUIRE(events == QStringList{"two", "three"}); } - const auto found = backups_of(dir.path(), QStringLiteral("cache.db")); + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); REQUIRE(found.size() == 1); const QString backup = dir.filePath(found.first()); { - Database backup_db(backup, QStringLiteral("wal-backup")); - REQUIRE(backup_db.user_version() == 1); - REQUIRE(backup_db.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == + Database backupDb(backup, QStringLiteral("wal-backup")); + REQUIRE(backupDb.userVersion() == 1); + REQUIRE(backupDb.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == QStringList{"value"}); - REQUIRE(backup_db.column(QStringLiteral("SELECT value FROM state")) == QStringList{"before"}); + REQUIRE(backupDb.column(QStringLiteral("SELECT value FROM state")) == QStringList{"before"}); } for (const char* suffix : {"-wal", "-shm"}) { QFile::remove(backup + QString::fromLatin1(suffix)); @@ -167,19 +167,19 @@ TEST_CASE("missing migration step fails without advancing the version") { Database db(path, QStringLiteral("missing")); db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); db.exec(QStringLiteral("PRAGMA user_version = 1")); - before = read_bytes(path); - const auto result = ensure_schema(db.get(), + before = readBytes(path); + const auto result = ensureSchema(db.get(), QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, MigrationSteps{{2, noop}}, path); REQUIRE_FALSE(result.has_value()); REQUIRE(result.error().message.contains("Missing migration step")); - REQUIRE(db.user_version() == 1); + REQUIRE(db.userVersion() == 1); } - REQUIRE(read_bytes(path) == before); - const auto found = backups_of(dir.path(), QStringLiteral("cache.db")); + REQUIRE(readBytes(path) == before); + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); REQUIRE(found.size() == 1); - REQUIRE(read_bytes(dir.filePath(found.first())) == before); + REQUIRE(readBytes(dir.filePath(found.first())) == before); } TEST_CASE("current schema is a no-op") { @@ -193,19 +193,19 @@ TEST_CASE("current schema is a no-op") { Database db(path, QStringLiteral("current")); db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); db.exec(QStringLiteral("PRAGMA user_version = 1")); - before = read_bytes(path); + before = readBytes(path); const MigrationSteps steps{{1, [&]() -> Result { called = true; return {}; }}}; - REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, + REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, steps, path) .has_value()); - REQUIRE(db.user_version() == 1); + REQUIRE(db.userVersion() == 1); } REQUIRE_FALSE(called); - REQUIRE(read_bytes(path) == before); - REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } TEST_CASE("newer schema is a no-op") { @@ -219,17 +219,17 @@ TEST_CASE("newer schema is a no-op") { Database db(path, QStringLiteral("newer")); db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); db.exec(QStringLiteral("PRAGMA user_version = 2")); - before = read_bytes(path); + before = readBytes(path); const MigrationSteps steps{{1, [&]() -> Result { called = true; return {}; }}}; - REQUIRE(ensure_schema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, + REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, steps, path) .has_value()); - REQUIRE(db.user_version() == 2); + REQUIRE(db.userVersion() == 2); } REQUIRE_FALSE(called); - REQUIRE(read_bytes(path) == before); - REQUIRE(backups_of(dir.path(), QStringLiteral("cache.db")).isEmpty()); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp index acce815..62768f4 100644 --- a/tests/core/test_mods_config.cpp +++ b/tests/core/test_mods_config.cpp @@ -16,8 +16,8 @@ namespace { using namespace px::modrim; -using test_support::read_bytes; -using test_support::write_bytes; +using test_support::readBytes; +using test_support::writeBytes; constexpr auto kSampleXml = R"( @@ -42,25 +42,25 @@ constexpr auto kSampleXml = R"( CaseInsensitiveStr pid(const char* text) { return CaseInsensitiveStr(QString::fromLatin1(text)); } -std::optional parse_text(const std::string& content, const QTemporaryDir& dir) { +std::optional parseText(const std::string& content, const QTemporaryDir& dir) { const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_bytes(path, content)); - return parse_mods_config(path); + REQUIRE(writeBytes(path, content)); + return parseModsConfig(path); } -ModsConfig mods_config(const char* package_id) { +ModsConfig modsConfig(const char* packageId) { return ModsConfig{ .version = QStringLiteral("1.5"), - .activeMods = {pid(package_id)}, + .activeMods = {pid(packageId)}, .knownExpansions = {}, }; } // Package ids of the snapshots in `dir`, newest first. -QList retained_ids(const QString& dir) { +QList retainedIds(const QString& dir) { QList ids; - for (const auto& snapshot : get_snapshots(dir)) { - const auto parsed = parse_mods_config(snapshot); + for (const auto& snapshot : getSnapshots(dir)) { + const auto parsed = parseModsConfig(snapshot); REQUIRE(parsed.has_value()); ids.append(parsed->activeMods.first()); } @@ -72,7 +72,7 @@ QList retained_ids(const QString& dir) { TEST_CASE("parse_mods_config reads version, active mods and expansions") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto config = parse_text(kSampleXml, dir); + const auto config = parseText(kSampleXml, dir); REQUIRE(config.has_value()); REQUIRE(config->version == QStringLiteral("1.6.4871 rev600")); REQUIRE(config->activeMods == QList{ @@ -95,7 +95,7 @@ TEST_CASE("parse_mods_config reads version, active mods and expansions") { TEST_CASE("parse_mods_config accepts a single active mod") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto config = parse_text(R"( + const auto config = parseText(R"( 1.5 @@ -114,7 +114,7 @@ TEST_CASE("parse_mods_config accepts a single active mod") { TEST_CASE("parse_mods_config falls back to the DLC list when no expansions are declared") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const auto config = parse_text(R"( + const auto config = parseText(R"( 1.5 @@ -126,7 +126,7 @@ TEST_CASE("parse_mods_config falls back to the DLC list when no expansions are d } TEST_CASE("parse_mods_config returns nullopt for a missing file") { - REQUIRE_FALSE(parse_mods_config(QStringLiteral("/nonexistent/ModsConfig.xml")).has_value()); + REQUIRE_FALSE(parseModsConfig(QStringLiteral("/nonexistent/ModsConfig.xml")).has_value()); } TEST_CASE("write then parse round-trips the mod lists") { @@ -149,9 +149,9 @@ TEST_CASE("write then parse round-trips the mod lists") { }, }; const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(path, original, dir.filePath(QStringLiteral("snapshots"))).has_value()); + REQUIRE(writeModsConfig(path, original, dir.filePath(QStringLiteral("snapshots"))).has_value()); - const auto loaded = parse_mods_config(path); + const auto loaded = parseModsConfig(path); REQUIRE(loaded.has_value()); REQUIRE(loaded->version == original.version); REQUIRE(loaded->activeMods == original.activeMods); @@ -165,12 +165,12 @@ TEST_CASE("write keeps unknown XML in an existing ModsConfigData document") { std::string text = kSampleXml; text.replace(text.find(""), std::string("").size(), R"(42)"); - REQUIRE(write_bytes(path, text)); + REQUIRE(writeBytes(path, text)); - auto loaded = parse_mods_config(path); + auto loaded = parseModsConfig(path); REQUIRE(loaded.has_value()); loaded->activeMods = {pid("author.updated")}; - REQUIRE(write_mods_config(path, *loaded, dir.filePath(QStringLiteral("snapshots"))).has_value()); + REQUIRE(writeModsConfig(path, *loaded, dir.filePath(QStringLiteral("snapshots"))).has_value()); pugi::xml_document doc; REQUIRE(doc.load_file(path.toStdString().c_str())); @@ -184,18 +184,18 @@ TEST_CASE("write keeps unknown XML in an existing ModsConfigData document") { TEST_CASE("replacing a config keeps the previous contents as a snapshot") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.alpha"), snapshots_dir).has_value()); - REQUIRE(get_snapshots(snapshots_dir).isEmpty()); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.alpha"), snapshotsDir).has_value()); + REQUIRE(getSnapshots(snapshotsDir).isEmpty()); - REQUIRE(write_mods_config(config_path, mods_config("mod.beta"), snapshots_dir).has_value()); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.beta"), snapshotsDir).has_value()); - const auto snapshots = get_snapshots(snapshots_dir); + const auto snapshots = getSnapshots(snapshotsDir); REQUIRE(snapshots.size() == 1); - const auto previous = parse_mods_config(snapshots.first()); - const auto current = parse_mods_config(config_path); + const auto previous = parseModsConfig(snapshots.first()); + const auto current = parseModsConfig(configPath); REQUIRE(previous.has_value()); REQUIRE(previous->activeMods == QList{pid("mod.alpha")}); REQUIRE(current.has_value()); @@ -205,102 +205,102 @@ TEST_CASE("replacing a config keeps the previous contents as a snapshot") { TEST_CASE("replacement retains only the newest ten snapshots") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir).has_value()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir).has_value()); for (int index = 1; index < 12; ++index) { const std::string id = "mod." + std::to_string(index); - REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir).has_value()); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir).has_value()); } QList expected; for (int index = 10; index >= 1; --index) { expected.append(pid(("mod." + std::to_string(index)).c_str())); } - REQUIRE(get_snapshots(snapshots_dir).size() == 10); - REQUIRE(retained_ids(snapshots_dir) == expected); + REQUIRE(getSnapshots(snapshotsDir).size() == 10); + REQUIRE(retainedIds(snapshotsDir) == expected); } TEST_CASE("configurable retention limit is honored") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir, 3).has_value()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, 3).has_value()); for (int index = 1; index < 6; ++index) { const std::string id = "mod." + std::to_string(index); - REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir, 3).has_value()); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, 3).has_value()); } - const auto snapshots = get_snapshots(snapshots_dir); + const auto snapshots = getSnapshots(snapshotsDir); REQUIRE(snapshots.size() == 3); - REQUIRE(retained_ids(snapshots_dir) == + REQUIRE(retainedIds(snapshotsDir) == QList{pid("mod.4"), pid("mod.3"), pid("mod.2")}); // Lowering the limit on duplicate content prunes the excess before returning. - REQUIRE(write_bytes(config_path, read_bytes(snapshots.first()))); - const auto duplicate = create_snapshot(config_path, snapshots_dir, 2); + REQUIRE(writeBytes(configPath, readBytes(snapshots.first()))); + const auto duplicate = createSnapshot(configPath, snapshotsDir, 2); REQUIRE(duplicate.has_value()); REQUIRE(duplicate->has_value()); REQUIRE(**duplicate == snapshots.first()); - REQUIRE(get_snapshots(snapshots_dir).size() == 2); + REQUIRE(getSnapshots(snapshotsDir).size() == 2); } TEST_CASE("restoring the oldest snapshot at full retention keeps it and backs up the current file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - constexpr int max_snapshots = 3; - REQUIRE(write_mods_config(config_path, mods_config("mod.0"), snapshots_dir, max_snapshots).has_value()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + constexpr int kMaxSnapshots = 3; + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, kMaxSnapshots).has_value()); for (int index = 1; index < 4; ++index) { const std::string id = "mod." + std::to_string(index); - REQUIRE(write_mods_config(config_path, mods_config(id.c_str()), snapshots_dir, max_snapshots).has_value()); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, kMaxSnapshots).has_value()); } - const QString selected = get_snapshots(snapshots_dir).last(); - const std::string selected_data = read_bytes(selected); + const QString selected = getSnapshots(snapshotsDir).last(); + const std::string selectedData = readBytes(selected); - REQUIRE(restore_snapshot(selected, config_path, snapshots_dir, max_snapshots).has_value()); + REQUIRE(restoreSnapshot(selected, configPath, snapshotsDir, kMaxSnapshots).has_value()); - const auto restored = parse_mods_config(config_path); + const auto restored = parseModsConfig(configPath); REQUIRE(restored.has_value()); REQUIRE(restored->activeMods == QList{pid("mod.0")}); REQUIRE(QFileInfo::exists(selected)); - REQUIRE(read_bytes(selected) == selected_data); - REQUIRE(get_snapshots(snapshots_dir).size() == max_snapshots); - REQUIRE(retained_ids(snapshots_dir) == + REQUIRE(readBytes(selected) == selectedData); + REQUIRE(getSnapshots(snapshotsDir).size() == kMaxSnapshots); + REQUIRE(retainedIds(snapshotsDir) == QList{pid("mod.3"), pid("mod.2"), pid("mod.0")}); } TEST_CASE("restoring a snapshot writes its exact bytes and backs up the current file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.current"), snapshots_dir).has_value()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.current"), snapshotsDir).has_value()); - const QString snapshot_path = dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); - const std::string snapshot_data = + const QString snapshotPath = dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); + const std::string snapshotData = "\n" "1.5" "
  • mod.saved
  • \n"; REQUIRE(QDir().mkpath(dir.filePath(QStringLiteral("snapshots")))); - REQUIRE(write_bytes(snapshot_path, snapshot_data)); + REQUIRE(writeBytes(snapshotPath, snapshotData)); - REQUIRE(restore_snapshot(snapshot_path, config_path, snapshots_dir).has_value()); - REQUIRE(read_bytes(config_path) == snapshot_data); + REQUIRE(restoreSnapshot(snapshotPath, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == snapshotData); QStringList backups; - for (const auto& snapshot : get_snapshots(snapshots_dir)) { - if (snapshot != snapshot_path) { + for (const auto& snapshot : getSnapshots(snapshotsDir)) { + if (snapshot != snapshotPath) { backups.append(snapshot); } } REQUIRE(backups.size() == 1); - const auto backup = parse_mods_config(backups.first()); + const auto backup = parseModsConfig(backups.first()); REQUIRE(backup.has_value()); REQUIRE(backup->activeMods == QList{pid("mod.current")}); } @@ -308,38 +308,38 @@ TEST_CASE("restoring a snapshot writes its exact bytes and backs up the current TEST_CASE("invalid snapshots never change the current file") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.safe"), snapshots_dir).has_value()); - const std::string original = read_bytes(config_path); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir).has_value()); + const std::string original = readBytes(configPath); const QString missing = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing.xml")); const QString corrupt = dir.filePath(QStringLiteral("snapshots/ModsConfig_corrupt.xml")); - const QString wrong_root = dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); - const QString missing_active = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); - REQUIRE(QDir().mkpath(snapshots_dir)); - REQUIRE(write_bytes(corrupt, "not XML")); - REQUIRE(write_bytes(wrong_root, "")); - REQUIRE(write_bytes(missing_active, "1.5")); - - for (const QString& snapshot : {missing, corrupt, wrong_root, missing_active}) { - const QStringList before = get_snapshots(snapshots_dir); - REQUIRE_FALSE(restore_snapshot(snapshot, config_path, snapshots_dir).has_value()); - REQUIRE(read_bytes(config_path) == original); - REQUIRE(get_snapshots(snapshots_dir) == before); + const QString wrongRoot = dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); + const QString missingActive = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); + REQUIRE(QDir().mkpath(snapshotsDir)); + REQUIRE(writeBytes(corrupt, "not XML")); + REQUIRE(writeBytes(wrongRoot, "")); + REQUIRE(writeBytes(missingActive, "1.5")); + + for (const QString& snapshot : {missing, corrupt, wrongRoot, missingActive}) { + const QStringList before = getSnapshots(snapshotsDir); + REQUIRE_FALSE(restoreSnapshot(snapshot, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == original); + REQUIRE(getSnapshots(snapshotsDir) == before); } } TEST_CASE("a failed replacement reports an error and leaves no temporary files") { QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString snapshots_dir = dir.filePath(QStringLiteral("snapshots")); - const QString config_path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(write_mods_config(config_path, mods_config("mod.safe"), snapshots_dir).has_value()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir).has_value()); // A directory at the target makes the final rename fail after the temporary file is written. const QString blocked = dir.filePath(QStringLiteral("Blocked.xml")); REQUIRE(QDir().mkpath(blocked)); - REQUIRE_FALSE(write_mods_config(blocked, mods_config("mod.new"), snapshots_dir).has_value()); + REQUIRE_FALSE(writeModsConfig(blocked, modsConfig("mod.new"), snapshotsDir).has_value()); REQUIRE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden) == QStringList{QStringLiteral("ModsConfig.xml")}); } diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp index 1638fd1..e557fbe 100644 --- a/tests/core/test_plugin.cpp +++ b/tests/core/test_plugin.cpp @@ -20,12 +20,12 @@ struct TestContext { using Plugin = px::modrim::BasicPlugin; using PluginRegistry = px::modrim::BasicPluginRegistry; -using test_support::run_sync; +using test_support::runSync; class RecorderPlugin : public Plugin { public: - RecorderPlugin(QString name, QStringList deps = {}, QStringList* shutdown_log = nullptr) - : Plugin(std::move(name), std::move(deps)), shutdown_log_(shutdown_log) {} + RecorderPlugin(QString name, QStringList deps = {}, QStringList* shutdownLog = nullptr) + : Plugin(std::move(name), std::move(deps)), m_shutdownLog(shutdownLog) {} void setup(TestContext& ctx) override { ctx.log.append(name()); } @@ -35,14 +35,14 @@ class RecorderPlugin : public Plugin { } QCoro::Task shutdown() override { - if (shutdown_log_) { - shutdown_log_->append(name()); + if (m_shutdownLog) { + m_shutdownLog->append(name()); } co_return; } private: - QStringList* shutdown_log_; + QStringList* m_shutdownLog; }; } // namespace @@ -50,49 +50,49 @@ class RecorderPlugin : public Plugin { TEST_CASE("empty registry sets up nothing") { PluginRegistry reg; TestContext ctx; - REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(reg.setupAll(ctx).has_value()); REQUIRE(ctx.log.isEmpty()); } TEST_CASE("plugins without dependencies set up in name order") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a")); - reg.register_plugin(std::make_unique("b")); - reg.register_plugin(std::make_unique("c")); + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b")); + reg.registerPlugin(std::make_unique("c")); TestContext ctx; - REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(reg.setupAll(ctx).has_value()); REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("linear dependency chain sets up in order") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a")); - reg.register_plugin(std::make_unique("b", QStringList{"a"})); - reg.register_plugin(std::make_unique("c", QStringList{"b"})); + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); TestContext ctx; - REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(reg.setupAll(ctx).has_value()); REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("complex DAG sets up level by level, names sorted within a level") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a")); - reg.register_plugin(std::make_unique("b", QStringList{"a"})); - reg.register_plugin(std::make_unique("c", QStringList{"a"})); - reg.register_plugin(std::make_unique("d", QStringList{"b", "c"})); - reg.register_plugin(std::make_unique("e", QStringList{"d"})); - reg.register_plugin(std::make_unique("f")); - reg.register_plugin(std::make_unique("g", QStringList{"e", "f"})); + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"a"})); + reg.registerPlugin(std::make_unique("d", QStringList{"b", "c"})); + reg.registerPlugin(std::make_unique("e", QStringList{"d"})); + reg.registerPlugin(std::make_unique("f")); + reg.registerPlugin(std::make_unique("g", QStringList{"e", "f"})); TestContext ctx; - REQUIRE(reg.setup_all(ctx).has_value()); + REQUIRE(reg.setupAll(ctx).has_value()); REQUIRE(ctx.log == QStringList{"a", "f", "b", "c", "d", "e", "g"}); } TEST_CASE("missing dependency is reported before anything is set up") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a", QStringList{"nonexistent"})); + reg.registerPlugin(std::make_unique("a", QStringList{"nonexistent"})); TestContext ctx; - const auto result = reg.setup_all(ctx); + const auto result = reg.setupAll(ctx); REQUIRE_FALSE(result.has_value()); REQUIRE(result.error().message.contains("depends on 'nonexistent'")); REQUIRE(ctx.log.isEmpty()); @@ -100,18 +100,18 @@ TEST_CASE("missing dependency is reported before anything is set up") { TEST_CASE("extra dependencies satisfy missing plugins") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a", QStringList{"external_dep"})); + reg.registerPlugin(std::make_unique("a", QStringList{"external_dep"})); TestContext ctx; - REQUIRE(reg.setup_all(ctx, QSet{"external_dep"}).has_value()); + REQUIRE(reg.setupAll(ctx, QSet{"external_dep"}).has_value()); REQUIRE(ctx.log == QStringList{"a"}); } TEST_CASE("cyclic dependencies are reported as an error") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a", QStringList{"b"})); - reg.register_plugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("a", QStringList{"b"})); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); TestContext ctx; - const auto result = reg.setup_all(ctx); + const auto result = reg.setupAll(ctx); REQUIRE_FALSE(result.has_value()); REQUIRE(result.error().message.contains("Circular dependencies")); REQUIRE(ctx.log.isEmpty()); @@ -119,19 +119,19 @@ TEST_CASE("cyclic dependencies are reported as an error") { TEST_CASE("init runs in dependency order") { PluginRegistry reg; - reg.register_plugin(std::make_unique("a")); - reg.register_plugin(std::make_unique("b", QStringList{"a"})); - reg.register_plugin(std::make_unique("c", QStringList{"b"})); + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); TestContext ctx; - REQUIRE(run_sync(reg.init_all(ctx)).has_value()); + REQUIRE(runSync(reg.initAll(ctx)).has_value()); REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("shutdown runs in reverse dependency order") { PluginRegistry reg; - QStringList shutdown_order; - reg.register_plugin(std::make_unique("dependent", QStringList{"dependency"}, &shutdown_order)); - reg.register_plugin(std::make_unique("dependency", QStringList{}, &shutdown_order)); - REQUIRE(run_sync(reg.shutdown_all()).has_value()); - REQUIRE(shutdown_order == QStringList{"dependent", "dependency"}); + QStringList shutdownOrder; + reg.registerPlugin(std::make_unique("dependent", QStringList{"dependency"}, &shutdownOrder)); + reg.registerPlugin(std::make_unique("dependency", QStringList{}, &shutdownOrder)); + REQUIRE(runSync(reg.shutdownAll()).has_value()); + REQUIRE(shutdownOrder == QStringList{"dependent", "dependency"}); } diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp index 3cd3060..0a375ad 100644 --- a/tests/core/test_plugin_config.cpp +++ b/tests/core/test_plugin_config.cpp @@ -14,30 +14,32 @@ namespace { using namespace px::modrim; -using test_support::read_bytes; -using test_support::write_bytes; +using test_support::readBytes; +using test_support::writeBytes; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct DemoSettings { bool show_badges = false; int level = 0; bool operator==(const DemoSettings&) const = default; }; +// NOLINTEND(readability-identifier-naming) } // namespace TEST_CASE("plugin config seeds from legacy config.json keys and persists changes") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE(write_bytes(dir.filePath(QStringLiteral("config.json")), R"({"old_badges":true,"paths":{}})")); - const QString settings_path = dir.filePath(QStringLiteral("plugins/demo.json")); + REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), R"({"old_badges":true,"paths":{}})")); + const QString settingsPath = dir.filePath(QStringLiteral("plugins/demo.json")); ConfigService service(dir.path()); PluginConfig settings(&service, QStringLiteral("demo"), QHash{{QStringLiteral("old_badges"), QStringLiteral("show_badges")}}); REQUIRE(settings.value().show_badges); - REQUIRE(QFileInfo::exists(settings_path)); + REQUIRE(QFileInfo::exists(settingsPath)); int emitted = 0; DemoSettings last; diff --git a/tests/core/test_support.hpp b/tests/core/test_support.hpp index 489d076..5052466 100644 --- a/tests/core/test_support.hpp +++ b/tests/core/test_support.hpp @@ -18,29 +18,29 @@ namespace test_support { // that open databases create one for their scope (one at a time, as test_version does). class TestApplication { public: - TestApplication() : app_(argc_, nullptr) {} + TestApplication() : m_app(m_argc, nullptr) {} private: - int argc_ = 0; - QCoreApplication app_; + int m_argc = 0; + QCoreApplication m_app; }; // Drives a coroutine whose awaited work finishes without suspending, which holds for // every coroutine these tests start. The REQUIRE fails if it suspended instead. template -QCoro::Task await_into(QCoro::Task task, std::optional& out) { +QCoro::Task awaitInto(QCoro::Task task, std::optional& out) { out.emplace(co_await std::move(task)); } template -T run_sync(QCoro::Task task) { +T runSync(QCoro::Task task) { std::optional out; - [[maybe_unused]] auto pending = await_into(std::move(task), out); + [[maybe_unused]] auto pending = awaitInto(std::move(task), out); REQUIRE(out.has_value()); return std::move(*out); } -inline bool write_bytes(const QString& path, std::string_view bytes) { +inline bool writeBytes(const QString& path, std::string_view bytes) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; @@ -49,7 +49,7 @@ inline bool write_bytes(const QString& path, std::string_view bytes) { return file.write(bytes.data(), size) == size; } -inline std::string read_bytes(const QString& path) { +inline std::string readBytes(const QString& path) { QFile file(path); REQUIRE(file.open(QIODevice::ReadOnly)); const QByteArray data = file.readAll(); diff --git a/tests/core/workshop/support.hpp b/tests/core/workshop/support.hpp index 8396505..ebe1ef8 100644 --- a/tests/core/workshop/support.hpp +++ b/tests/core/workshop/support.hpp @@ -83,13 +83,13 @@ inline constexpr std::string_view kCollectionJson = R"JSON({ "no_common_version": false })JSON"; -inline px::modrim::workshop::CatalogMod decode_mod(std::string_view json) { +inline px::modrim::workshop::CatalogMod decodeMod(std::string_view json) { px::modrim::workshop::CatalogMod mod; REQUIRE_FALSE(glz::read(mod, std::string(json))); return mod; } -inline px::modrim::workshop::CatalogCollection decode_collection(std::string_view json) { +inline px::modrim::workshop::CatalogCollection decodeCollection(std::string_view json) { px::modrim::workshop::CatalogCollection collection; REQUIRE_FALSE(glz::read(collection, std::string(json))); return collection; @@ -124,19 +124,19 @@ using Handler = std::function; // A loopback catalog: answers each request with `handler`, one request per connection. class FakeCatalog { public: - explicit FakeCatalog(Handler handler) : handler_(std::move(handler)) { - REQUIRE(server_.listen(QHostAddress::LocalHost)); - QObject::connect(&server_, &QTcpServer::newConnection, &server_, [this] { accept(); }); + explicit FakeCatalog(Handler handler) : m_handler(std::move(handler)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, &m_server, [this] { accept(); }); } - QString url() const { return QStringLiteral("http://127.0.0.1:%1").arg(server_.serverPort()); } + QString url() const { return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); } std::vector requests; private: void accept() { - QTcpSocket* socket = server_.nextPendingConnection(); - socket->setParent(&server_); + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); auto raw = std::make_shared(); auto answered = std::make_shared(false); @@ -148,21 +148,21 @@ class FakeCatalog { *answered = true; HttpRequest request = parse(*raw); requests.push_back(request); - socket->write(serialize(handler_(request))); + socket->write(serialize(m_handler(request))); socket->disconnectFromHost(); }); } // True once the headers and the Content-Length body have arrived. static bool complete(const QByteArray& raw) { - const qsizetype headers_end = raw.indexOf("\r\n\r\n"); - if (headers_end < 0) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { return false; } - return raw.size() - (headers_end + 4) >= content_length(raw.left(headers_end)); + return raw.size() - (headersEnd + 4) >= contentLength(raw.left(headersEnd)); } - static qint64 content_length(const QByteArray& headers) { + static qint64 contentLength(const QByteArray& headers) { const QByteArray lower = headers.toLower(); const qsizetype key = lower.indexOf("content-length:"); if (key < 0) { @@ -176,13 +176,13 @@ class FakeCatalog { } static HttpRequest parse(const QByteArray& raw) { - const qsizetype headers_end = raw.indexOf("\r\n\r\n"); - const QByteArray request_line = raw.left(raw.indexOf("\r\n")); - const QList parts = request_line.split(' '); + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + const QByteArray requestLine = raw.left(raw.indexOf("\r\n")); + const QList parts = requestLine.split(' '); HttpRequest request; request.method = parts.value(0).toStdString(); request.target = parts.value(1).toStdString(); - request.body = raw.mid(headers_end + 4, content_length(raw.left(headers_end))).toStdString(); + request.body = raw.mid(headersEnd + 4, contentLength(raw.left(headersEnd))).toStdString(); return request; } @@ -206,12 +206,12 @@ class FakeCatalog { } } - QTcpServer server_; - Handler handler_; + QTcpServer m_server; + Handler m_handler; }; // A URL that refuses connections: the port was bound, then released. -inline QString unreachable_url() { +inline QString unreachableUrl() { QTcpServer probe; REQUIRE(probe.listen(QHostAddress::LocalHost)); const quint16 port = probe.serverPort(); diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index 1ebb248..dcf7796 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -33,19 +33,21 @@ using namespace px::modrim; using namespace px::modrim::workshop; using test_support::TestApplication; -using test_support::write_bytes; +using test_support::writeBytes; using workshop_test::FakeCatalog; using workshop_test::HttpRequest; using workshop_test::HttpResponse; -using workshop_test::decode_mod; +using workshop_test::decodeMod; using workshop_test::run; using workshop_test::wire; namespace { +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct BatchWire { std::vector items; std::vector unavailable_ids; }; +// NOLINTEND(readability-identifier-naming) struct IdsBatchPayload { std::vector ids; @@ -53,43 +55,43 @@ struct IdsBatchPayload { struct FakeDownloader : public Downloader { FakeDownloader(DownloadManager& manager, std::map synced) - : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), manager), synced_(std::move(synced)) {} + : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), manager), synced(std::move(synced)) {} - bool is_downloading() const override { return is_busy_; } - QSet active_ids() const override { return active_; } + bool isDownloading() const override { return isBusy; } + QSet activeIds() const override { return active; } bool accepts(const QString&) const override { return true; } - std::optional updatable_id(const ListedMod& mod) const override { return mod.published_file_id(); } - std::optional last_synced(const ListedMod& mod) const override { - const auto id = mod.published_file_id(); + std::optional updatableId(const ListedMod& mod) const override { return mod.publishedFileId(); } + std::optional lastSynced(const ListedMod& mod) const override { + const auto id = mod.publishedFileId(); if (!id) { return std::nullopt; } - const auto found = synced_.find(*id); - return found == synced_.end() ? std::nullopt : std::optional(found->second); + const auto found = synced.find(*id); + return found == synced.end() ? std::nullopt : std::optional(found->second); } - QCoro::Task> download_mods(QStringList ids) override { - recorded_calls.push_back(ids); + QCoro::Task> downloadMods(QStringList ids) override { + recordedCalls.push_back(ids); co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; } void cancel() override {} - std::map synced_; - bool is_busy_ = false; - QSet active_; - std::vector recorded_calls; + std::map synced; + bool isBusy = false; + QSet active; + std::vector recordedCalls; }; -ListedModPtr make_mod(const QString& path, const QString& provider = QStringLiteral("local")) { +ListedModPtr makeMod(const QString& path, const QString& provider = QStringLiteral("local")) { auto mod = std::make_shared(); - REQUIRE(mod->set_mod_path(path).has_value()); - mod->provider_id = provider; + REQUIRE(mod->setModPath(path).has_value()); + mod->providerId = provider; return mod; } -CatalogMod mod_with(std::string id, std::optional updated) { - CatalogMod mod = decode_mod(workshop_test::kModJson); +CatalogMod modWith(std::string id, std::optional updated) { + CatalogMod mod = decodeMod(workshop_test::kModJson); mod.id = std::move(id); mod.updated_at = updated; return mod; @@ -109,21 +111,23 @@ struct ContextFixture { {QStringLiteral("1"), 200.0}, {QStringLiteral("2"), 50.0}, }); - downloads.register_downloader(*downloader); + downloads.registerDownloader(*downloader); // Mods 1, 2, 4 are local; 3 is workshop; NotWorkshop has no numeric folder name. - mods.append(make_mod(dir.filePath(QStringLiteral("Mods/1")))); - mods.append(make_mod(dir.filePath(QStringLiteral("Mods/2")))); - mods.append(make_mod(dir.filePath(QStringLiteral("Workshop/3")), QStringLiteral("workshop"))); - mods.append(make_mod(dir.filePath(QStringLiteral("Mods/4")))); - mods.append(make_mod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/1")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/2")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Workshop/3")), QStringLiteral("workshop"))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/4")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); } }; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolvePayload { std::vector ids; std::vector collection_ids; }; +// NOLINTEND(readability-identifier-naming) } // namespace @@ -131,16 +135,16 @@ TEST_CASE("workshop install state uses sync timestamp, not directory mtime") { ContextFixture fixture; WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - CHECK(catalog.install_state(mod_with("1", 100)) == InstallState::installed); - CHECK(catalog.install_state(mod_with("2", 100)) == InstallState::outdated); + CHECK(catalog.installState(modWith("1", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 100)) == InstallState::kOutdated); // 3 has no sync record, so it cannot be outdated. - CHECK(catalog.install_state(mod_with("3", 100)) == InstallState::installed); - CHECK(catalog.install_state(mod_with("4", 100)) == InstallState::installed); + CHECK(catalog.installState(modWith("3", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("4", 100)) == InstallState::kInstalled); // 5 is not installed at all. - CHECK(catalog.install_state(mod_with("5", 100)) == InstallState::missing); + CHECK(catalog.installState(modWith("5", 100)) == InstallState::kMissing); // When updated_at is null or equal to sync, the mod is installed. - CHECK(catalog.install_state(mod_with("2", std::nullopt)) == InstallState::installed); - CHECK(catalog.install_state(mod_with("2", 50)) == InstallState::installed); + CHECK(catalog.installState(modWith("2", std::nullopt)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 50)) == InstallState::kInstalled); } TEST_CASE("workshop plan keeps dependency order and incomplete flags") { @@ -151,11 +155,11 @@ TEST_CASE("workshop plan keeps dependency order and incomplete flags") { ResolvedDownload answer; answer.roots = {"5", "picked:starter"}; for (const char* id : {"1", "2", "3", "4", "5"}) { - CatalogMod m = mod_with(id, 100); + CatalogMod m = modWith(id, 100); m.title = std::string("Title ") + id; answer.items.emplace(id, std::move(m)); } - answer.items.emplace("picked:starter", workshop_test::decode_collection(workshop_test::kCollectionJson)); + answer.items.emplace("picked:starter", workshop_test::decodeCollection(workshop_test::kCollectionJson)); answer.mod_ids = {"1", "2", "3", "4", "5"}; answer.unavailable_ids = {"99"}; answer.incomplete_collection_ids = {"picked:starter"}; @@ -164,7 +168,7 @@ TEST_CASE("workshop plan keeps dependency order and incomplete flags") { }); WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.set_base_url(server.url()); + catalog.setBaseUrl(server.url()); const auto plan = run(catalog.plan({QStringLiteral("5")}, {QStringLiteral("picked:starter")})); REQUIRE(plan.has_value()); @@ -185,26 +189,26 @@ TEST_CASE("workshop installed metadata uses batch") { TestApplication app; ContextFixture fixture; - std::vector requested_ids; + std::vector requestedIds; FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { const auto body = glz::read_json(request.body); REQUIRE(body.has_value()); - requested_ids = body->ids; + requestedIds = body->ids; BatchWire answer{ - .items = {mod_with("2", 100)}, + .items = {modWith("2", 100)}, .unavailable_ids = {"1", "3", "4"}, }; return {200, wire(answer)}; }); WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.set_base_url(server.url()); + catalog.setBaseUrl(server.url()); - const auto installed = run(catalog.installed_with_updates()); + const auto installed = run(catalog.installedWithUpdates()); REQUIRE(installed.has_value()); - CHECK(requested_ids == std::vector{"1", "2", "3", "4"}); + CHECK(requestedIds == std::vector{"1", "2", "3", "4"}); REQUIRE(installed->size() == 1); - CHECK(catalog.install_state(installed->front()) == InstallState::outdated); + CHECK(catalog.installState(installed->front()) == InstallState::kOutdated); } TEST_CASE("workshop download delegates without side effects") { @@ -221,22 +225,22 @@ TEST_CASE("workshop download delegates without side effects") { const auto result = run(catalog.download(plan)); REQUIRE(result.has_value()); CHECK(result->succeeded == QStringList{QStringLiteral("2")}); - REQUIRE(fixture.downloader->recorded_calls.size() == 1); - CHECK(fixture.downloader->recorded_calls[0] == QStringList{QStringLiteral("2")}); + REQUIRE(fixture.downloader->recordedCalls.size() == 1); + CHECK(fixture.downloader->recordedCalls[0] == QStringList{QStringLiteral("2")}); } TEST_CASE("workshop empty URL is disabled without creating a client") { TestApplication app; ContextFixture fixture; WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.set_base_url(QString()); + catalog.setBaseUrl(QString()); CHECK_FALSE(catalog.configured()); const auto discover = run(catalog.discover()); REQUIRE_FALSE(discover.has_value()); CHECK(discover.error().message.contains(QStringLiteral("disabled"))); - const auto installed = run(catalog.installed_with_updates()); + const auto installed = run(catalog.installedWithUpdates()); REQUIRE_FALSE(installed.has_value()); CHECK(installed.error().message.contains(QStringLiteral("disabled"))); @@ -250,26 +254,26 @@ TEST_CASE("workshop configuration and installed events") { ContextFixture fixture; WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - std::vector url_changes; - int installed_invalidated = 0; - catalog.catalog_url_changed.connect([&](const QString& url) { url_changes.push_back(url); }); - catalog.installed_changed.connect([&](std::monostate) { ++installed_invalidated; }); + std::vector urlChanges; + int installedInvalidated = 0; + catalog.catalogUrlChanged.connect([&](const QString& url) { urlChanges.push_back(url); }); + catalog.installedChanged.connect([&](std::monostate) { ++installedInvalidated; }); - catalog.set_base_url(QStringLiteral(" https://other.example/ ")); + catalog.setBaseUrl(QStringLiteral(" https://other.example/ ")); CHECK(catalog.settings().url == "https://other.example"); - const auto stored = fixture.config->load_existing(QStringLiteral("plugins/workshop_catalog.json")); + const auto stored = fixture.config->loadExisting(QStringLiteral("plugins/workshop_catalog.json")); REQUIRE(stored.has_value()); REQUIRE(stored->has_value()); CHECK((*stored)->url == "https://other.example"); - catalog.set_base_url(QString()); + catalog.setBaseUrl(QString()); CHECK_FALSE(catalog.configured()); // Firing download_finished and manual invalidation both emit installed_changed. - catalog.invalidate_installed(); - fixture.downloads.download_finished.notify(DownloadResult{}); - CHECK(installed_invalidated == 2); - CHECK(url_changes == std::vector{QStringLiteral("https://other.example"), QString()}); + catalog.invalidateInstalled(); + fixture.downloads.downloadFinished.notify(DownloadResult{}); + CHECK(installedInvalidated == 2); + CHECK(urlChanges == std::vector{QStringLiteral("https://other.example"), QString()}); } TEST_CASE("workshop cached serves remembered value before refreshing") { @@ -278,18 +282,18 @@ TEST_CASE("workshop cached serves remembered value before refreshing") { WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); std::vector sequence{"first", "second", "second"}; - std::size_t next_idx = 0; + std::size_t nextIdx = 0; int calls = 0; auto produce = [&]() -> QCoro::Task> { ++calls; - co_return sequence.at(next_idx++); + co_return sequence.at(nextIdx++); }; - auto seen = [&](double fresh_for) { + auto seen = [&](double freshFor) { std::vector out; const auto done = run(catalog.cached( - QStringLiteral("k"), produce, [&](const std::string& v) { out.push_back(v); }, fresh_for)); + QStringLiteral("k"), produce, [&](const std::string& v) { out.push_back(v); }, freshFor)); REQUIRE(done.has_value()); return out; }; @@ -308,23 +312,23 @@ TEST_CASE("workshop enqueue runs batches in order after the running download") { ContextFixture fixture; WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - fixture.downloader->is_busy_ = true; + fixture.downloader->isBusy = true; DownloadPlan first{.to_download = {QStringLiteral("5"), QStringLiteral("6")}}; DownloadPlan second{.to_download = {QStringLiteral("6"), QStringLiteral("7")}}; catalog.enqueue(first); catalog.enqueue(second); - CHECK(catalog.queued_ids() == QSet{QStringLiteral("5"), QStringLiteral("6"), QStringLiteral("7")}); - CHECK(fixture.downloader->recorded_calls.empty()); + CHECK(catalog.queuedIds() == QSet{QStringLiteral("5"), QStringLiteral("6"), QStringLiteral("7")}); + CHECK(fixture.downloader->recordedCalls.empty()); // Once the downloads report idle, the queue drains both batches in order. - fixture.downloader->is_busy_ = false; - fixture.downloads.busy_changed.notify(false); + fixture.downloader->isBusy = false; + fixture.downloads.busyChanged.notify(false); - CHECK(fixture.downloader->recorded_calls.size() == 2); - CHECK(fixture.downloader->recorded_calls[0] == QStringList{QStringLiteral("5"), QStringLiteral("6")}); - CHECK(fixture.downloader->recorded_calls[1] == QStringList{QStringLiteral("7")}); - CHECK(catalog.queued_ids().isEmpty()); + CHECK(fixture.downloader->recordedCalls.size() == 2); + CHECK(fixture.downloader->recordedCalls[0] == QStringList{QStringLiteral("5"), QStringLiteral("6")}); + CHECK(fixture.downloader->recordedCalls[1] == QStringList{QStringLiteral("7")}); + CHECK(catalog.queuedIds().isEmpty()); } TEST_CASE("workshop cached values survive a restart and corrupt files are ignored") { @@ -334,7 +338,7 @@ TEST_CASE("workshop cached values survive a restart and corrupt files are ignore auto produce = []() -> QCoro::Task>> { co_return std::vector{1, 2}; }; - auto must_not_run = []() -> QCoro::Task>> { + auto mustNotRun = []() -> QCoro::Task>> { FAIL("A fresh value on disk must not hit the catalog"); co_return std::vector{}; }; @@ -354,15 +358,15 @@ TEST_CASE("workshop cached values survive a restart and corrupt files are ignore WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); std::vector out; const auto done = run(second.cached>( - QStringLiteral("k"), must_not_run, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); + QStringLiteral("k"), mustNotRun, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); REQUIRE(done.has_value()); CHECK(out == std::vector{1, 2}); } // Corrupting the file causes a miss: produce is invoked again. - const QString cache_dir = fixture.dir.filePath(QStringLiteral("workshop-cache")); - for (const QFileInfo& file : QDir(cache_dir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { - REQUIRE(write_bytes(file.absoluteFilePath(), "{not json")); + const QString cacheDir = fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); } { WorkshopCatalog third(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); @@ -382,7 +386,7 @@ TEST_CASE("workshop equal cache refresh keeps the displayed mods and refreshes a int calls = 0; auto produce = [&]() -> QCoro::Task>> { ++calls; - co_return std::vector{mod_with("1", 100)}; + co_return std::vector{modWith("1", 100)}; }; std::vector> initial; @@ -416,9 +420,9 @@ TEST_CASE("workshop disk cache is reused in memory after first load") { ContextFixture fixture; auto produce = []() -> QCoro::Task>> { - co_return std::vector{mod_with("1", 100)}; + co_return std::vector{modWith("1", 100)}; }; - auto must_not_run = []() -> QCoro::Task>> { + auto mustNotRun = []() -> QCoro::Task>> { FAIL("A fresh cached response must not hit the catalog"); co_return std::vector{}; }; @@ -432,28 +436,28 @@ TEST_CASE("workshop disk cache is reused in memory after first load") { WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); std::vector loaded; - REQUIRE(run(second.cached>(QStringLiteral("installed"), must_not_run, + REQUIRE(run(second.cached>(QStringLiteral("installed"), mustNotRun, [&](const auto& v) { loaded = v; }, 300.0, /*persist=*/true)) .has_value()); REQUIRE(loaded.size() == 1); // Corrupt the disk file now: second catalog must still serve its in-memory copy. - const QString cache_dir = fixture.dir.filePath(QStringLiteral("workshop-cache")); - for (const QFileInfo& file : QDir(cache_dir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { - REQUIRE(write_bytes(file.absoluteFilePath(), "{not json")); + const QString cacheDir = fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); } - std::vector from_memory; - REQUIRE(run(second.cached>(QStringLiteral("installed"), must_not_run, - [&](const auto& v) { from_memory = v; }, 300.0, /*persist=*/true)) + std::vector fromMemory; + REQUIRE(run(second.cached>(QStringLiteral("installed"), mustNotRun, + [&](const auto& v) { fromMemory = v; }, 300.0, /*persist=*/true)) .has_value()); - CHECK(from_memory == loaded); + CHECK(fromMemory == loaded); } TEST_CASE("workshop catalog URL moves out of the old shared config") { QTemporaryDir dir; REQUIRE(dir.isValid()); - REQUIRE(write_bytes(dir.filePath(QStringLiteral("config.json")), + REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), R"({"schema_version":1,"workshop_catalog_url":"http://localhost:8787"})")); ConfigService service(dir.path()); @@ -461,7 +465,7 @@ TEST_CASE("workshop catalog URL moves out of the old shared config") { WorkshopCatalog catalog(&service, downloads, [] { return QList{}; }); CHECK(catalog.settings().url == "http://localhost:8787"); - const auto stored = service.load_existing(QStringLiteral("plugins/workshop_catalog.json")); + const auto stored = service.loadExisting(QStringLiteral("plugins/workshop_catalog.json")); REQUIRE(stored.has_value()); REQUIRE(stored->has_value()); CHECK((*stored)->url == "http://localhost:8787"); diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp index a63bcc0..b421431 100644 --- a/tests/core/workshop/test_client.cpp +++ b/tests/core/workshop/test_client.cpp @@ -18,47 +18,49 @@ using test_support::TestApplication; using workshop_test::FakeCatalog; using workshop_test::HttpRequest; using workshop_test::HttpResponse; -using workshop_test::decode_collection; -using workshop_test::decode_mod; +using workshop_test::decodeCollection; +using workshop_test::decodeMod; using workshop_test::run; -using workshop_test::unreachable_url; +using workshop_test::unreachableUrl; using workshop_test::wire; namespace { +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolveRequest { std::vector ids; std::vector collection_ids; }; +// NOLINTEND(readability-identifier-naming) struct IdsRequest { std::vector ids; }; // The `{"items":[...],"total":N,"next_cursor":...}` page shape. -std::string page(const std::string& items, int total, const std::string& cursor_json) { - return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + R"(,"next_cursor":)" + cursor_json + "}"; +std::string page(const std::string& items, int total, const std::string& cursorJson) { + return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + R"(,"next_cursor":)" + cursorJson + "}"; } -std::string error_body(const std::string& message) { +std::string errorBody(const std::string& message) { return R"({"error":{"message":")" + message + R"("}})"; } -QString query_value(const HttpRequest& request, const char* key) { +QString queryValue(const HttpRequest& request, const char* key) { return request.query().queryItemValue(QString::fromLatin1(key)); } -CatalogMod with_id(std::string id) { - CatalogMod mod = decode_mod(workshop_test::kModJson); +CatalogMod withId(std::string id) { + CatalogMod mod = decodeMod(workshop_test::kModJson); mod.id = std::move(id); return mod; } -CatalogCollection steam_collection(std::string id, std::string steam_id, std::vector members) { - CatalogCollection collection = decode_collection(workshop_test::kCollectionJson); +CatalogCollection steamCollection(std::string id, std::string steamId, std::vector members) { + CatalogCollection collection = decodeCollection(workshop_test::kCollectionJson); collection.id = std::move(id); - collection.source = CollectionSource::steam; - collection.steam_id = std::move(steam_id); + collection.source = CollectionSource::kSteam; + collection.steam_id = std::move(steamId); collection.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + *collection.steam_id; collection.member_count = static_cast(members.size()); collection.member_ids = std::move(members); @@ -71,12 +73,12 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { TestApplication app; const std::string mod = std::string(workshop_test::kModJson); const std::string collection = std::string(workshop_test::kCollectionJson); - const std::string nested = wire(steam_collection("steam:456", "456", {"2009463077"})); - const std::string steam = wire(steam_collection("steam:123", "123", {"2009463077", "steam:456", "99"})); + const std::string nested = wire(steamCollection("steam:456", "456", {"2009463077"})); + const std::string steam = wire(steamCollection("steam:123", "123", {"2009463077", "steam:456", "99"})); - const std::string detail_picked = R"({"collection":)" + collection + R"(,"members":[)" + mod + + const std::string detailPicked = R"({"collection":)" + collection + R"(,"members":[)" + mod + R"(],"unavailable_ids":["99"],"is_complete":false})"; - const std::string detail_steam = R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + + const std::string detailSteam = R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + R"(],"unavailable_ids":["99"],"is_complete":false})"; const std::string resolution = R"({"roots":["picked:starter"],"items":{"2009463077":)" + mod + R"(,"picked:starter":)" + collection + @@ -99,15 +101,15 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { return {200, mod}; } if (path == "/catalog/collections/picked/starter") { - return {200, detail_picked}; + return {200, detailPicked}; } if (path == "/catalog/collections/steam/123") { - return {200, detail_steam}; + return {200, detailSteam}; } if (path == "/catalog/resolve") { return {200, resolution}; } - return {404, error_body("unknown")}; + return {404, errorBody("unknown")}; }); CatalogClient client(server.url()); @@ -115,8 +117,8 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { REQUIRE(discover.has_value()); CHECK(discover->mods.items[0].file_size == std::string("18446744073709551615")); CHECK(discover->mods.items[0].votes->positive_percent == 90.0); - CHECK(discover->mods.items[0].previews[1].type == PreviewType::video); - CHECK(discover->collections.items[0].description_format == CollectionDescriptionFormat::text); + CHECK(discover->mods.items[0].previews[1].type == PreviewType::kVideo); + CHECK(discover->collections.items[0].description_format == CollectionDescriptionFormat::kText); CatalogQuery query; query.query = QStringLiteral("quality"); @@ -130,13 +132,13 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { REQUIRE(mods.has_value()); CHECK(mods->next_cursor == std::optional("next-page")); const HttpRequest& sent = server.requests.back(); - CHECK(query_value(sent, "q") == QStringLiteral("quality")); - CHECK(query_value(sent, "tag") == QStringLiteral("Libraries")); - CHECK(query_value(sent, "version") == QStringLiteral("1.6")); - CHECK(query_value(sent, "sort") == QStringLiteral("updated")); - CHECK(query_value(sent, "limit") == QStringLiteral("50")); - CHECK(query_value(sent, "cursor") == QStringLiteral("next")); - CHECK(query_value(sent, "source") == QStringLiteral("picked")); + CHECK(queryValue(sent, "q") == QStringLiteral("quality")); + CHECK(queryValue(sent, "tag") == QStringLiteral("Libraries")); + CHECK(queryValue(sent, "version") == QStringLiteral("1.6")); + CHECK(queryValue(sent, "sort") == QStringLiteral("updated")); + CHECK(queryValue(sent, "limit") == QStringLiteral("50")); + CHECK(queryValue(sent, "cursor") == QStringLiteral("next")); + CHECK(queryValue(sent, "source") == QStringLiteral("picked")); CHECK(sent.query().queryItems().size() == 7); const auto collections = run(client.collections(query)); @@ -146,20 +148,20 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { const auto single = run(client.mod("2009463077")); REQUIRE(single.has_value()); REQUIRE(single->has_value()); - CHECK((*single)->description == decode_mod(mod).description); - CHECK((*single)->kind == ModKind::mod); + CHECK((*single)->description == decodeMod(mod).description); + CHECK((*single)->kind == ModKind::kMod); for (const char* id : {"picked:starter", "steam:123"}) { const auto detail = run(client.collection(id)); REQUIRE(detail.has_value()); REQUIRE(detail->has_value()); const CollectionDetail& value = **detail; - const bool is_steam = std::string(id) == "steam:123"; - REQUIRE(value.members.size() == (is_steam ? 2 : 1)); + const bool isSteam = std::string(id) == "steam:123"; + REQUIRE(value.members.size() == (isSteam ? 2 : 1)); CHECK(std::holds_alternative(value.members[0])); CHECK(value.unavailable_ids == std::vector{"99"}); CHECK_FALSE(value.is_complete); - if (is_steam) { + if (isSteam) { CHECK(std::holds_alternative(value.members[1])); } } @@ -180,16 +182,16 @@ TEST_CASE("workshop client decodes every endpoint and sends the query") { TEST_CASE("workshop client reports error statuses as errors, never as empty results") { TestApplication app; for (int status : {400, 401, 500, 502, 503}) { - for (bool json_body : {true, false}) { + for (bool jsonBody : {true, false}) { FakeCatalog server([&](const HttpRequest&) -> HttpResponse { - return json_body ? HttpResponse{status, error_body("Catalog unavailable")} + return jsonBody ? HttpResponse{status, errorBody("Catalog unavailable")} : HttpResponse{status, "Upstream failure"}; }); CatalogClient client(server.url()); const auto result = run(client.mod("123")); REQUIRE_FALSE(result.has_value()); CHECK(result.error().status == status); - if (json_body) { + if (jsonBody) { CHECK(result.error().message == QStringLiteral("Catalog unavailable")); } else { CHECK(result.error().message.contains(QStringLiteral("HTTP %1").arg(status))); @@ -201,9 +203,9 @@ TEST_CASE("workshop client reports error statuses as errors, never as empty resu TEST_CASE("workshop optional details return nothing only for a 404") { TestApplication app; - for (bool json_body : {true, false}) { + for (bool jsonBody : {true, false}) { FakeCatalog server([&](const HttpRequest&) -> HttpResponse { - return json_body ? HttpResponse{404, error_body("Item unavailable")} : HttpResponse{404, "Not found"}; + return jsonBody ? HttpResponse{404, errorBody("Item unavailable")} : HttpResponse{404, "Not found"}; }); CatalogClient client(server.url()); const auto mod = run(client.mod("123")); @@ -218,22 +220,22 @@ TEST_CASE("workshop optional details return nothing only for a 404") { TEST_CASE("workshop optional details keep server, network and decoding failures") { TestApplication app; FakeCatalog failing([](const HttpRequest&) -> HttpResponse { return {500, "Server error"}; }); - CatalogClient failing_client(failing.url()); - const auto server_error = run(failing_client.mod("123")); - REQUIRE_FALSE(server_error.has_value()); - CHECK(server_error.error().status == 500); - const auto collection_error = run(failing_client.collection("steam:123")); - REQUIRE_FALSE(collection_error.has_value()); - CHECK(collection_error.error().status == 500); - - CatalogClient offline(unreachable_url()); + CatalogClient failingClient(failing.url()); + const auto serverError = run(failingClient.mod("123")); + REQUIRE_FALSE(serverError.has_value()); + CHECK(serverError.error().status == 500); + const auto collectionError = run(failingClient.collection("steam:123")); + REQUIRE_FALSE(collectionError.has_value()); + CHECK(collectionError.error().status == 500); + + CatalogClient offline(unreachableUrl()); const auto network = run(offline.mod("123")); REQUIRE_FALSE(network.has_value()); CHECK_FALSE(network.error().status.has_value()); FakeCatalog garbage([](const HttpRequest&) -> HttpResponse { return {200, "not-json"}; }); - CatalogClient garbage_client(garbage.url()); - const auto decoding = run(garbage_client.collection("steam:123")); + CatalogClient garbageClient(garbage.url()); + const auto decoding = run(garbageClient.collection("steam:123")); REQUIRE_FALSE(decoding.has_value()); CHECK(decoding.error().status == 200); } @@ -250,7 +252,7 @@ TEST_CASE("workshop non-optional endpoints keep 404 errors") { const auto collections = run(client.collections(query)); REQUIRE_FALSE(collections.has_value()); CHECK(collections.error().status == 404); - const auto batch = run(client.mods_batch({"123"})); + const auto batch = run(client.modsBatch({"123"})); REQUIRE_FALSE(batch.has_value()); CHECK(batch.error().status == 404); const auto resolved = run(client.resolve({"123"}, {})); @@ -272,15 +274,15 @@ TEST_CASE("workshop client rejects invalid collection ids before sending") { TEST_CASE("workshop client treats responses that violate the wire contract as invalid") { TestApplication app; - const std::string valid_mod(workshop_test::kModJson); - std::string numeric_id = valid_mod; - numeric_id.replace(numeric_id.find("\"2009463077\""), 12, "123"); - std::string bad_kind = valid_mod; - bad_kind.replace(bad_kind.find("\"mod\""), 5, "\"collection\""); - std::string missing_title = valid_mod; - missing_title.replace(missing_title.find("\"title\": \"Harmony\","), 21, ""); - - for (const std::string& body : {numeric_id, bad_kind, missing_title, std::string("not-json")}) { + const std::string validMod(workshop_test::kModJson); + std::string numericId = validMod; + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + std::string badKind = validMod; + badKind.replace(badKind.find("\"mod\""), 5, "\"collection\""); + std::string missingTitle = validMod; + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + + for (const std::string& body : {numericId, badKind, missingTitle, std::string("not-json")}) { FakeCatalog server([&](const HttpRequest&) -> HttpResponse { return {200, body}; }); CatalogClient client(server.url()); const auto result = run(client.mod("123")); @@ -302,13 +304,13 @@ TEST_CASE("workshop batch requests split ids into chunks of 100") { unavailable.push_back(id); continue; } - items += (items.empty() ? "" : ",") + wire(with_id(id)); + items += (items.empty() ? "" : ",") + wire(withId(id)); } - std::string unavailable_json = "["; + std::string unavailableJson = "["; for (const std::string& id : unavailable) { - unavailable_json += "\"" + id + "\""; + unavailableJson += "\"" + id + "\""; } - return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + unavailable_json + "]}"}; + return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + unavailableJson + "]}"}; }); CatalogClient client(server.url()); @@ -320,7 +322,7 @@ TEST_CASE("workshop batch requests split ids into chunks of 100") { for (const std::string& id : ids) { qids.append(QString::fromStdString(id)); } - const auto result = run(client.mods_batch(qids)); + const auto result = run(client.modsBatch(qids)); REQUIRE(result.has_value()); REQUIRE(server.requests.size() == 3); @@ -329,18 +331,18 @@ TEST_CASE("workshop batch requests split ids into chunks of 100") { CHECK(glz::read_json(server.requests[2].body)->ids.size() == 5); CHECK(result->items.size() == 204); CHECK(result->items.front().id == "1"); - CHECK(result->unavailable_ids == std::vector{"105"}); + CHECK(result->unavailableIds == std::vector{"105"}); - const auto empty = run(client.mods_batch({})); + const auto empty = run(client.modsBatch({})); REQUIRE(empty.has_value()); CHECK(empty->items.empty()); - CHECK(empty->unavailable_ids.empty()); + CHECK(empty->unavailableIds.empty()); CHECK(server.requests.size() == 3); } TEST_CASE("workshop transport failures are catalog errors without a status") { TestApplication app; - CatalogClient client(unreachable_url()); + CatalogClient client(unreachableUrl()); const auto result = run(client.discover()); REQUIRE_FALSE(result.has_value()); CHECK(result.error().message.startsWith(QStringLiteral("Cannot reach the Workshop catalog"))); @@ -366,29 +368,29 @@ TEST_CASE("workshop resolve splits requests and merges their answers") { const bool second = sent.size() == 2; // Every answer repeats the dependency 999, which must be reported once. - std::vector mod_ids{"999"}; - mod_ids.insert(mod_ids.end(), body->ids.begin(), body->ids.end()); + std::vector modIds{"999"}; + modIds.insert(modIds.end(), body->ids.begin(), body->ids.end()); std::string items = "{"; - for (const std::string& id : mod_ids) { - items += "\"" + id + "\":" + wire(with_id(id)); - items += id == mod_ids.back() ? "" : ","; + for (const std::string& id : modIds) { + items += "\"" + id + "\":" + wire(withId(id)); + items += id == modIds.back() ? "" : ","; } items += "}"; - std::string mods_json = "["; - for (const std::string& id : mod_ids) { - mods_json += "\"" + id + "\","; + std::string modsJson = "["; + for (const std::string& id : modIds) { + modsJson += "\"" + id + "\","; } - mods_json.back() = ']'; - std::string roots_json = "["; + modsJson.back() = ']'; + std::string rootsJson = "["; for (const std::string& id : body->ids) { - roots_json += "\"" + id + "\","; + rootsJson += "\"" + id + "\","; } for (const std::string& id : body->collection_ids) { - roots_json += "\"" + id + "\","; + rootsJson += "\"" + id + "\","; } - roots_json.back() = ']'; + rootsJson.back() = ']'; - return {200, R"({"roots":)" + roots_json + R"(,"items":)" + items + R"(,"mod_ids":)" + mods_json + + return {200, R"({"roots":)" + rootsJson + R"(,"items":)" + items + R"(,"mod_ids":)" + modsJson + R"(,"unavailable_ids":)" + (second ? R"(["404"])" : "[]") + R"(,"incomplete_collection_ids":)" + (second ? R"(["picked:pack-10"])" : "[]") + R"(,"is_complete":)" + (second ? "false" : "true") + "}"}; diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp index bfa3938..60acd38 100644 --- a/tests/core/workshop/test_favourites.cpp +++ b/tests/core/workshop/test_favourites.cpp @@ -12,12 +12,12 @@ using namespace px::modrim; using namespace px::modrim::workshop; -using workshop_test::decode_collection; +using workshop_test::decodeCollection; namespace { -CatalogCollection make_collection(std::string id, std::string title = "Pack") { - CatalogCollection collection = decode_collection(workshop_test::kCollectionJson); +CatalogCollection makeCollection(std::string id, std::string title = "Pack") { + CatalogCollection collection = decodeCollection(workshop_test::kCollectionJson); collection.id = std::move(id); collection.title = std::move(title); return collection; @@ -31,8 +31,8 @@ TEST_CASE("workshop favourites persist newest first and survive a restart") { ConfigService service(dir.path()); FavouriteCollections favourites(&service); - const CatalogCollection first = make_collection("steam:1"); - const CatalogCollection second = make_collection("steam:2"); + const CatalogCollection first = makeCollection("steam:1"); + const CatalogCollection second = makeCollection("steam:2"); favourites.set(first, true); favourites.set(second, true); // Setting an already-starred collection again is a no-op: it does not jump to the front. @@ -45,10 +45,10 @@ TEST_CASE("workshop favourites persist newest first and survive a restart") { CHECK(reloaded.contains("steam:1")); reloaded.set(first, false); - FavouriteCollections after_removal(&service); - REQUIRE(after_removal.items().size() == 1); - CHECK(after_removal.items()[0].id == "steam:2"); - CHECK_FALSE(after_removal.contains("steam:1")); + FavouriteCollections afterRemoval(&service); + REQUIRE(afterRemoval.items().size() == 1); + CHECK(afterRemoval.items()[0].id == "steam:2"); + CHECK_FALSE(afterRemoval.contains("steam:1")); } TEST_CASE("workshop favourites remember refreshes only starred snapshots") { @@ -57,18 +57,18 @@ TEST_CASE("workshop favourites remember refreshes only starred snapshots") { ConfigService service(dir.path()); FavouriteCollections favourites(&service); - favourites.set(make_collection("steam:1", "Old title"), true); + favourites.set(makeCollection("steam:1", "Old title"), true); int saves = 0; favourites.changed().connect([&](const FavouritesState&) { ++saves; }); // An equal collection does not save. - favourites.remember(make_collection("steam:1", "Old title")); + favourites.remember(makeCollection("steam:1", "Old title")); // A collection that was never starred does not save. - favourites.remember(make_collection("steam:2", "Not starred")); + favourites.remember(makeCollection("steam:2", "Not starred")); CHECK(saves == 0); - favourites.remember(make_collection("steam:1", "New title")); + favourites.remember(makeCollection("steam:1", "New title")); CHECK(saves == 1); FavouriteCollections reloaded(&service); diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp index 4496682..a940228 100644 --- a/tests/core/workshop/test_types.cpp +++ b/tests/core/workshop/test_types.cpp @@ -8,8 +8,8 @@ #include "support.hpp" using namespace px::modrim::workshop; -using workshop_test::decode_collection; -using workshop_test::decode_mod; +using workshop_test::decodeCollection; +using workshop_test::decodeMod; using workshop_test::wire; namespace { @@ -24,12 +24,12 @@ bool accepts(const std::string& json) { } // namespace TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form") { - CatalogMod mod = decode_mod(workshop_test::kModJson); + CatalogMod mod = decodeMod(workshop_test::kModJson); REQUIRE(mod.id == "2009463077"); REQUIRE(mod.title == "Harmony"); REQUIRE(mod.votes.has_value()); REQUIRE(mod.votes->positive_percent == 90.0); - REQUIRE(mod.previews[1].type == PreviewType::video); + REQUIRE(mod.previews[1].type == PreviewType::kVideo); REQUIRE(mod.file_size == std::string("18446744073709551615")); // Absent values stay absent instead of being invented. @@ -41,7 +41,7 @@ TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form "created_at": null, "updated_at": null, "file_size": null, "subscriptions": null, "votes": null, "dependencies": [], "package_id": null, "incompatible": false })"; - CatalogMod sparse = decode_mod(nulled); + CatalogMod sparse = decodeMod(nulled); CHECK_FALSE(sparse.author.name.has_value()); CHECK_FALSE(sparse.votes.has_value()); CHECK_FALSE(sparse.updated_at.has_value()); @@ -49,12 +49,12 @@ TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form const std::string encoded = wire(sparse); CHECK(encoded.find(R"("kind":"mod")") != std::string::npos); - CHECK(decode_mod(encoded) == sparse); - CHECK(decode_mod(wire(mod)) == mod); + CHECK(decodeMod(encoded) == sparse); + CHECK(decodeMod(wire(mod)) == mod); } TEST_CASE("workshop steam collection and empty page decode") { - CatalogCollection collection = decode_collection(R"({ + CatalogCollection collection = decodeCollection(R"({ "id": "steam:123", "kind": "collection", "source": "steam", "steam_id": "123", "title": "Pack", "author": {"id": null, "name": "Curator", "profile_url": null}, "description": "[b]x[/b]", "description_format": "bbcode", @@ -66,9 +66,9 @@ TEST_CASE("workshop steam collection and empty page decode") { "total_size": "10", "no_common_version": true })"); CHECK(collection.member_ids == std::vector{"2009463077", "steam:456"}); - CHECK(collection.source == CollectionSource::steam); + CHECK(collection.source == CollectionSource::kSteam); CHECK(collection.no_common_version); - CHECK(decode_collection(wire(collection)) == collection); + CHECK(decodeCollection(wire(collection)) == collection); CatalogPage page{}; REQUIRE(accepts>(R"({"items":[],"total":0,"next_cursor":null})")); @@ -80,9 +80,9 @@ TEST_CASE("workshop steam collection and empty page decode") { TEST_CASE("workshop wire decoding rejects payloads outside the catalog contract") { // A numeric id is not a string. - std::string numeric_id(workshop_test::kModJson); - numeric_id.replace(numeric_id.find("\"2009463077\""), 12, "123"); - CHECK_FALSE(accepts(numeric_id)); + std::string numericId(workshop_test::kModJson); + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + CHECK_FALSE(accepts(numericId)); // A collection payload is not a mod. CHECK_FALSE(accepts(std::string(workshop_test::kCollectionJson))); @@ -91,14 +91,14 @@ TEST_CASE("workshop wire decoding rejects payloads outside the catalog contract" CHECK_FALSE(accepts(std::string(workshop_test::kModJson))); // A missing required key fails. - std::string missing_title(workshop_test::kModJson); - missing_title.replace(missing_title.find("\"title\": \"Harmony\","), 21, ""); - CHECK_FALSE(accepts(missing_title)); + std::string missingTitle(workshop_test::kModJson); + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + CHECK_FALSE(accepts(missingTitle)); // A preview type outside image|video fails. - std::string bad_preview(workshop_test::kModJson); - bad_preview.replace(bad_preview.find("\"image\""), 7, "\"audio\""); - CHECK_FALSE(accepts(bad_preview)); + std::string badPreview(workshop_test::kModJson); + badPreview.replace(badPreview.find("\"image\""), 7, "\"audio\""); + CHECK_FALSE(accepts(badPreview)); CHECK_FALSE(accepts("not-json")); } diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp index 82b4a6c..e4ef9d2 100644 --- a/tests/ui/test_downloads_model.cpp +++ b/tests/ui/test_downloads_model.cpp @@ -9,8 +9,8 @@ QHash states(const px::modrim::DownloadsModel& model) { QHash result; for (int row = 0; row < model.rowCount(); ++row) { const auto index = model.index(row); - result.insert(model.data(index, px::modrim::DownloadsModel::IdRole).toString(), - model.data(index, px::modrim::DownloadsModel::StateRole).toString()); + result.insert(model.data(index, px::modrim::DownloadsModel::kIdRole).toString(), + model.data(index, px::modrim::DownloadsModel::kStateRole).toString()); } return result; } @@ -33,7 +33,7 @@ TEST_CASE("download item events resolve states and cancel leftover rows") { CHECK(model.unchanged() == 1); CHECK(model.failed() == 3); CHECK(model.active() == 0); - CHECK(model.failed_ids() == QStringList{"2", "4", "5"}); + CHECK(model.failedIds() == QStringList{"2", "4", "5"}); } TEST_CASE("download filters limit visible rows and keep fallback titles") { @@ -43,7 +43,7 @@ TEST_CASE("download filters limit visible rows and keep fallback titles") { model.flush(); model.setFilter(QStringLiteral("failed")); REQUIRE(model.rowCount() == 1); - CHECK(model.data(model.index(0), px::modrim::DownloadsModel::TitleRole).toString() == "2"); + CHECK(model.data(model.index(0), px::modrim::DownloadsModel::kTitleRole).toString() == "2"); model.setFilter(QStringLiteral("all")); CHECK(model.rowCount() == 2); } diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index 01d5db3..ede5f66 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -63,9 +63,9 @@ struct Fixture { core = px::modrim::CoreContext::create(config, &configService); core->registerPlugin(std::make_unique()); auto a = std::make_shared(); - a->package_id = px::modrim::CaseInsensitiveStr(u"test.a"); + a->packageId = px::modrim::CaseInsensitiveStr(u"test.a"); auto b = std::make_shared(); - b->package_id = px::modrim::CaseInsensitiveStr(u"test.b"); + b->packageId = px::modrim::CaseInsensitiveStr(u"test.b"); core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, {QStringLiteral("a"), QStringLiteral("b")}); app = std::make_unique(*core); app->addRailView(); @@ -135,7 +135,7 @@ TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui] fixture.core->setActive({QStringLiteral("a"), QStringLiteral("b")}); CHECK_FALSE(window.hasUnsavedChanges()); fixture.core->setActive({QStringLiteral("a")}); - fixture.core->modService().mods_changed.notify(std::monostate{}); + fixture.core->modService().modsChanged.notify(std::monostate{}); CHECK(window.hasUnsavedChanges()); } diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp index cac9480..d283f2d 100644 --- a/tests/ui/test_menu_bar.cpp +++ b/tests/ui/test_menu_bar.cpp @@ -56,11 +56,11 @@ TEST_CASE("Hidden menu actions retain their window shortcuts", "[ui][window]") { px::modrim::MenuBar menu(actions, &window); menu.hide(); bool triggered = false; - QObject::connect(actions.at(px::modrim::ActionId::Save), &QAction::triggered, &window, [&] { triggered = true; }); + QObject::connect(actions.at(px::modrim::ActionId::kSave), &QAction::triggered, &window, [&] { triggered = true; }); QMenu* file = nullptr; for (auto* item : menu.findChildren()) if (item->title() == QStringLiteral("&File")) file = item; REQUIRE(file != nullptr); - auto* save = actions.at(px::modrim::ActionId::Save); + auto* save = actions.at(px::modrim::ActionId::kSave); CHECK(file->actions().contains(save)); CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); CHECK(window.actions().contains(save)); diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp index 1c731c4..a927bb4 100644 --- a/tests/ui/test_mod_info_data.cpp +++ b/tests/ui/test_mod_info_data.cpp @@ -15,21 +15,21 @@ namespace px::modrim { namespace { -std::shared_ptr make_mod(const QString& name, const QString& pid) { +std::shared_ptr makeMod(const QString& name, const QString& pid) { auto mod = std::make_shared(); mod->name = name; - mod->package_id = CaseInsensitiveStr(pid); + mod->packageId = CaseInsensitiveStr(pid); mod->description.clear(); return mod; } -void add_dependency(AboutXmlMod& mod, const QString& pid, +void addDependency(AboutXmlMod& mod, const QString& pid, const QStringList& alternatives = {}) { DependencyMod dependency; dependency.name = pid; - dependency.package_id = CaseInsensitiveStr(pid); - dependency.alternative_package_ids = CaseInsensitiveSet(alternatives); - mod.about_rules.dependencies.append(std::move(dependency)); + dependency.packageId = CaseInsensitiveStr(pid); + dependency.alternativePackageIds = CaseInsensitiveSet(alternatives); + mod.aboutRules.dependencies.append(std::move(dependency)); } struct ModInfoContext { @@ -74,53 +74,53 @@ TEST_CASE("Mod info autolinks bare URLs without changing markup", "[ui][mod-info } TEST_CASE("Mod info converts BBCode images and linked images", "[ui][mod-info]") { - CHECK(bbcode_images("[img]https://i.imgur.com/a.png[/img]\nhi") == - "\nhi"); - CHECK_FALSE(autolink(bbcode_images("[img]https://i.imgur.com/a.png[/img]")).contains("\nhi"); + CHECK_FALSE(autolink(bbcodeImages("[img]https://i.imgur.com/a.png[/img]")).contains("")); } TEST_CASE("Mod info converts BBCode links and formatting", "[ui][mod-info]") { - CHECK(bbcode_markup("[url=https://a.com/w]the wiki[/url]") == + CHECK(bbcodeMarkup("[url=https://a.com/w]the wiki[/url]") == "the wiki"); const QString unsafe = "[url=javascript:alert(1)]x[/url]"; - CHECK(bbcode_markup(unsafe) == unsafe); - CHECK(bbcode_markup("[b]a[/b] [i]b[/i]") == "a b"); + CHECK(bbcodeMarkup(unsafe) == unsafe); + CHECK(bbcodeMarkup("[b]a[/b] [i]b[/i]") == "a b"); } TEST_CASE("Mod info converts Steam structural tags without stray breaks", "[ui][mod-info]") { - CHECK(bbcode_markup("[list]
    [*]one
    [*]two
    [/list]
    after") == + CHECK(bbcodeMarkup("[list]
    [*]one
    [*]two
    [/list]
    after") == "
    • one
    • two
    after"); - CHECK(bbcode_markup("[h2]Title[/h2]
    body") == "

    Title

    body"); - CHECK(bbcode_markup("[quote=Bob]hi[/quote]") == + CHECK(bbcodeMarkup("[h2]Title[/h2]
    body") == "

    Title

    body"); + CHECK(bbcodeMarkup("[quote=Bob]hi[/quote]") == "
    Bob wrote:
    hi
    "); - CHECK(bbcode_markup("[table noborder=1][tr][td]x[/td][/tr][/table]") == + CHECK(bbcodeMarkup("[table noborder=1][tr][td]x[/td][/tr][/table]") == "
    x
    "); } TEST_CASE("Mod info restores entities once and excludes URL delimiters", "[ui][mod-info]") { - CHECK(restore_entities("&#8226; item") == "• item"); - CHECK(restore_entities("a & b") == "a & b"); - CHECK(description_to_html("see \"https://a.com/x\" ok") == + CHECK(restoreEntities("&#8226; item") == "• item"); + CHECK(restoreEntities("a & b") == "a & b"); + CHECK(descriptionToHtml("see \"https://a.com/x\" ok") == "see "https://a.com/x" ok"); - CHECK(description_to_html("") == + CHECK(descriptionToHtml("") == "<https://a.com/x>"); } TEST_CASE("Mod info rich descriptions preserve formatting entities and images", "[ui][mod-info]") { - CHECK(description_to_html( + CHECK(descriptionToHtml( "[h2]Title[/h2]\nBold •\n" "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki") == "

    Title

    Bold •
    " - "
    " + "
    " "https://a.com/wiki"); } TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", "[ui][mod-info]") { - CHECK(description_to_html( + CHECK(descriptionToHtml( "Literal & text\n[img]https://a.com/image.png[/img]\n" "[url=https://a.com]wiki[/url]", "plaintext") == "<b>Literal & text</b>
    " @@ -128,22 +128,22 @@ TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", "[u } TEST_CASE("Mod info only embeds HTTPS images", "[ui][mod-info][security]") { - CHECK_FALSE(description_to_html("[img]file:///etc/passwd[/img]").contains("")); - CHECK_FALSE(description_to_html("[url=javascript:alert(1)]x[/url]").contains("x y"); + const auto html = descriptionToHtml("x y"); CHECK_FALSE(html.contains("") == + CHECK(descriptionToHtml("") == "<img src="file:///etc/passwd">"); } @@ -152,7 +152,7 @@ TEST_CASE("Mod info rejects unsafe schemes in every rich link form", "[ui][mod-i "file:///etc/passwd", "qrc:/private.png", "steam://run/294100"}; for (const auto& url : urls) { CAPTURE(url); - const auto html = description_to_html( + const auto html = descriptionToHtml( "[url=" + url + "]link[/url] [url]" + url + "[/url] [img]" + url + "[/img] Unity link"); CHECK_FALSE(html.contains("uuid()}); - const auto needs = build_mod_info(*context.ctx, *main, {}).value("needs").toList(); + const auto needs = buildModInfo(*context.ctx, *main, {}).value("needs").toList(); const QVariantList expected{ QVariantMap{{"name", "a.active"}, {"state", "active"}, {"uuid", active->uuid()}}, QVariantMap{{"name", "A.Inactive"}, {"state", "inactive"}, {"uuid", inactive->uuid()}}, @@ -180,42 +180,42 @@ TEST_CASE("Mod info reports dependency states and case insensitive alternatives" } TEST_CASE("Mod info conflicts include only active mods sorted by name", "[ui][mod-info]") { - auto beta = make_mod("beta", "x.b"); - auto alpha = make_mod("Alpha", "x.a"); - auto off = make_mod("Off", "x.off"); - auto main = make_mod("Main", "x.main"); - main->about_rules.incompatible_with = CaseInsensitiveSet( + auto beta = makeMod("beta", "x.b"); + auto alpha = makeMod("Alpha", "x.a"); + auto off = makeMod("Off", "x.off"); + auto main = makeMod("Main", "x.main"); + main->aboutRules.incompatibleWith = CaseInsensitiveSet( QStringList{"X.B", "x.a", "x.off", "x.no"}); ModInfoContext context({main, alpha, beta, off}, {alpha->uuid(), beta->uuid()}); - CHECK(build_mod_info(*context.ctx, *main, {}).value("conflicts").toStringList() == + CHECK(buildModInfo(*context.ctx, *main, {}).value("conflicts").toStringList() == QStringList{"Alpha", "beta"}); } TEST_CASE("Mod info needed by matches alternatives and sorts dependents", "[ui][mod-info]") { - auto target = make_mod("Target", "t.target"); - auto zed = make_mod("zed", "t.z"); - auto apple = make_mod("Apple", "t.a"); - auto unrelated = make_mod("Unrelated", "t.u"); - add_dependency(*zed, "t.target"); - add_dependency(*apple, "t.other", {"T.Target"}); - add_dependency(*unrelated, "t.other"); + auto target = makeMod("Target", "t.target"); + auto zed = makeMod("zed", "t.z"); + auto apple = makeMod("Apple", "t.a"); + auto unrelated = makeMod("Unrelated", "t.u"); + addDependency(*zed, "t.target"); + addDependency(*apple, "t.other", {"T.Target"}); + addDependency(*unrelated, "t.other"); ModInfoContext context({target, zed, apple, unrelated}, {}); const QVariantList expected{ QVariantMap{{"name", "Apple"}, {"uuid", apple->uuid()}}, QVariantMap{{"name", "zed"}, {"uuid", zed->uuid()}}}; - CHECK(build_mod_info(*context.ctx, *target, {}).value("neededBy").toList() == expected); + CHECK(buildModInfo(*context.ctx, *target, {}).value("neededBy").toList() == expected); } TEST_CASE("Mod info builds active mod status issues dependencies and description", "[ui][mod-info]") { - auto dep = make_mod("Dep", "d.dep"); - auto main = make_mod("Main", "m.main"); - add_dependency(*main, "d.dep"); + auto dep = makeMod("Dep", "d.dep"); + auto main = makeMod("Main", "m.main"); + addDependency(*main, "d.dep"); main->description = "hello x https://a.com/x"; main->authors = {"Ann"}; - auto other = make_mod("Other", "o.other"); + auto other = makeMod("Other", "o.other"); ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); const view::ModIssueView issue{"c", "Cat", std::nullopt, false}; - const auto info = build_mod_info(*context.ctx, *main, {issue}); + const auto info = buildModInfo(*context.ctx, *main, {issue}); CHECK(info.value("name").toString() == "Main"); CHECK(info.value("author").toString() == "Ann"); const auto status = info.value("status").toMap(); @@ -224,21 +224,21 @@ TEST_CASE("Mod info builds active mod status issues dependencies and description CHECK(status.value("active").toBool()); CHECK(status.value("position").toInt() == 2); CHECK(status.value("total").toInt() == 2); - const QVariantList expected_issues{ + const QVariantList expectedIssues{ QVariantMap{{"title", "Cat"}, {"detail", ""}, {"isError", false}}}; - CHECK(status.value("issues").toList() == expected_issues); - const QVariantList expected_needs{ + CHECK(status.value("issues").toList() == expectedIssues); + const QVariantList expectedNeeds{ QVariantMap{{"name", "d.dep"}, {"state", "inactive"}, {"uuid", dep->uuid()}}}; - CHECK(info.value("needs").toList() == expected_needs); + CHECK(info.value("needs").toList() == expectedNeeds); CHECK(info.value("description").toString().contains("")); - const QVariantMap expected_version{{"known", false}, {"ok", false}, {"target", "1.5"}}; - CHECK(info.value("version").toMap() == expected_version); + const QVariantMap expectedVersion{{"known", false}, {"ok", false}, {"target", "1.5"}}; + CHECK(info.value("version").toMap() == expectedVersion); } TEST_CASE("Mod info builds inactive mod without description or dependents", "[ui][mod-info]") { - auto mod = make_mod("Solo", "s.solo"); + auto mod = makeMod("Solo", "s.solo"); ModInfoContext context({mod}, {}); - const auto info = build_mod_info(*context.ctx, *mod, {}); + const auto info = buildModInfo(*context.ctx, *mod, {}); const auto status = info.value("status").toMap(); CHECK(status.value("level").toString() == "ok"); CHECK_FALSE(status.value("active").toBool()); @@ -248,12 +248,12 @@ TEST_CASE("Mod info builds inactive mod without description or dependents", "[ui } TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", "[ui][mod-info]") { - constexpr double now = 2'000'000'000; - CHECK(format_age(0, now).isEmpty()); - CHECK(format_age(-1, now).isEmpty()); - CHECK(format_age(now + 1, now) == "just now"); - CHECK(format_age(now, now) == "just now"); - CHECK(format_age(now - 3599, now) == "just now"); + constexpr double kNow = 2'000'000'000; + CHECK(formatAge(0, kNow).isEmpty()); + CHECK(formatAge(-1, kNow).isEmpty()); + CHECK(formatAge(kNow + 1, kNow) == "just now"); + CHECK(formatAge(kNow, kNow) == "just now"); + CHECK(formatAge(kNow - 3599, kNow) == "just now"); struct AgeCase { double elapsed; const char* expected; }; const AgeCase cases[]{ {3600, "1 hour ago"}, {7200, "2 hours ago"}, {86399, "23 hours ago"}, @@ -264,7 +264,7 @@ TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", "[ {365 * 86400, "1 year ago"}, {730 * 86400, "2 years ago"}}; for (const auto& age : cases) { CAPTURE(age.elapsed); - CHECK(format_age(now - age.elapsed, now) == age.expected); + CHECK(formatAge(kNow - age.elapsed, kNow) == age.expected); } } diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp index edef203..3c26232 100644 --- a/tests/ui/test_mod_list_model.cpp +++ b/tests/ui/test_mod_list_model.cpp @@ -13,20 +13,20 @@ struct ListFixture { for (int i = 0; i < 5; ++i) { auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(i); - mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); - mod->provider_id = QStringLiteral("stub"); + mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->providerId = QStringLiteral("stub"); mods.insert(QStringLiteral("uuid-%1").arg(i),mod); } - model.load_mods(mods,{"uuid-0","uuid-2"}); + model.loadMods(mods,{"uuid-0","uuid-2"}); } QStringList order() const { QStringList result; - for (int row = 0; row < model.rowCount(); ++row) result.append(model.get_item(row)->uuid); + for (int row = 0; row < model.rowCount(); ++row) result.append(model.getItem(row)->uuid); return result; } QList indices() const { QList result; - for (int row = 0; row < model.rowCount(); ++row) result.append(model.data(model.index(row),ModListModel::LoadIndexRole).toInt()); + for (int row = 0; row < model.rowCount(); ++row) result.append(model.data(model.index(row),ModListModel::kLoadIndexRole).toInt()); return result; } }; @@ -34,29 +34,29 @@ struct ListFixture { TEST_CASE("mod list load skips missing active UUIDs and sorts inactive mods") { ListFixture f; CHECK(f.order() == QStringList{"uuid-0","uuid-2","uuid-1","uuid-3","uuid-4"}); - f.model.load_mods(f.mods,{"ghost","uuid-2"}); - CHECK(f.model.active_uuids() == QStringList{"uuid-2"}); + f.model.loadMods(f.mods,{"ghost","uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-2"}); CHECK(f.model.rowCount() == 5); CHECK(f.model.rowCount(f.model.index(0)) == 0); CHECK_FALSE(f.model.data({}).isValid()); - CHECK_FALSE(f.model.setData({},Qt::Checked,ModListModel::CheckStateRole)); + CHECK_FALSE(f.model.setData({},Qt::Checked,ModListModel::kCheckStateRole)); } TEST_CASE("mod list check state emits role updates without resetting") { ListFixture f; QSignalSpy reset(&f.model,&QAbstractItemModel::modelReset); QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); - QSignalSpy active(&f.model,&ModListModel::active_mods_changed); - REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::CheckStateRole)); - CHECK(f.model.get_item(2)->checked); + QSignalSpy active(&f.model,&ModListModel::activeModsChanged); + REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::kCheckStateRole)); + CHECK(f.model.getItem(2)->checked); CHECK(f.indices() == QList{1,2,3,-1,-1}); CHECK(reset.isEmpty()); REQUIRE(changed.size() == 1); - CHECK(changed.first()[2].value>().contains(ModListModel::LoadIndexRole)); + CHECK(changed.first()[2].value>().contains(ModListModel::kLoadIndexRole)); CHECK(active.size() == 1); changed.clear(); - REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::CheckStateRole)); + REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::kCheckStateRole)); CHECK(changed.isEmpty()); - f.model.set_check_states({0},false); + f.model.setCheckStates({0},false); CHECK(f.indices() == QList{-1,1,2,-1,-1}); } TEST_CASE("mod list commits subset order preserving persistent UUID identities") { @@ -67,57 +67,57 @@ TEST_CASE("mod list commits subset order preserving persistent UUID identities") f.model.commitOrder({"uuid-2","uuid-0"}); CHECK(f.order() == QStringList{"uuid-2","uuid-0","uuid-1","uuid-3","uuid-4"}); CHECK(persistent.row() == 1); - CHECK(persistent.data(ModListModel::UuidRole).toString() == "uuid-0"); + CHECK(persistent.data(ModListModel::kUuidRole).toString() == "uuid-0"); CHECK(about.size() == 1); CHECK(changed.size() == 1); f.model.commitOrder(f.order()); CHECK(changed.size() == 1); f.model.commitOrder({"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); CHECK(f.order() == QStringList{"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); - CHECK(f.model.active_uuids() == QStringList{"uuid-0","uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-0","uuid-2"}); } TEST_CASE("mod list move updates load indices and rejects invalid moves") { ListFixture f; QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); CHECK(f.indices() == QList{1,2,-1,-1,-1}); - REQUIRE(f.model.move_row(0,1)); + REQUIRE(f.model.moveRow(0,1)); CHECK(f.order().first() == "uuid-2"); CHECK(f.indices() == QList{1,2,-1,-1,-1}); REQUIRE_FALSE(changed.isEmpty()); - CHECK(changed.first()[2].value>().contains(ModListModel::LoadIndexRole)); - CHECK_FALSE(f.model.move_row(0,0)); - CHECK_FALSE(f.model.move_row(-1,1)); - CHECK_FALSE(f.model.move_row(0,5)); + CHECK(changed.first()[2].value>().contains(ModListModel::kLoadIndexRole)); + CHECK_FALSE(f.model.moveRow(0,0)); + CHECK_FALSE(f.model.moveRow(-1,1)); + CHECK_FALSE(f.model.moveRow(0,5)); } TEST_CASE("mod list provider labels and QML roles match the reference") { - CHECK(provider_label("steam") == "Workshop"); - CHECK(provider_label("downloaded") == "Workshop"); - CHECK(provider_label("local") == "Local"); - CHECK(provider_label("core","ludeon.rimworld") == "Core"); - CHECK(provider_label("core","ludeon.rimworld.royalty") == "DLC"); - CHECK(provider_label("unknown","ludeon.rimworld.ideology") == "DLC"); + CHECK(providerLabel("steam") == "Workshop"); + CHECK(providerLabel("downloaded") == "Workshop"); + CHECK(providerLabel("local") == "Local"); + CHECK(providerLabel("core","ludeon.rimworld") == "Core"); + CHECK(providerLabel("core","ludeon.rimworld.royalty") == "DLC"); + CHECK(providerLabel("unknown","ludeon.rimworld.ideology") == "DLC"); ListFixture f; const auto roles = f.model.roleNames(); - CHECK(roles.value(ModListModel::CheckStateRole) == "checkState"); - CHECK(roles.value(ModListModel::SectionNameRole) == "sectionName"); - CHECK(roles.value(ModListModel::StartupImpactTextRole) == "startupImpactText"); - CHECK(f.model.data(f.model.index(0),ModListModel::ProviderColorRole).toString() == "#abcdef"); - CHECK(f.model.data(f.model.index(2),ModListModel::SectionNameRole).toString() == "Inactive (3)"); + CHECK(roles.value(ModListModel::kCheckStateRole) == "checkState"); + CHECK(roles.value(ModListModel::kSectionNameRole) == "sectionName"); + CHECK(roles.value(ModListModel::kStartupImpactTextRole) == "startupImpactText"); + CHECK(f.model.data(f.model.index(0),ModListModel::kProviderColorRole).toString() == "#abcdef"); + CHECK(f.model.data(f.model.index(2),ModListModel::kSectionNameRole).toString() == "Inactive (3)"); } TEST_CASE("mod list startup impacts include presentation roles and empty input is silent") { ModListModel empty; - QSignalSpy no_changes(&empty,&QAbstractItemModel::dataChanged); - empty.set_startup_impact({{"mod.0",1.5}}); - CHECK(no_changes.isEmpty()); + QSignalSpy noChanges(&empty,&QAbstractItemModel::dataChanged); + empty.setStartupImpact({{"mod.0",1.5}}); + CHECK(noChanges.isEmpty()); ListFixture f; - f.model.set_startup_impact({{"mod.0",1.5}}); - CHECK(f.model.data(f.model.index(0),ModListModel::StartupImpactRole).toDouble() == 1.5); - CHECK(f.model.data(f.model.index(0),ModListModel::StartupImpactTextRole).toString() == "1.50 s"); - CHECK(f.model.data(f.model.index(1),ModListModel::StartupImpactRole).toDouble() == 0); - CHECK(format_duration(0.0001) == "0 ms"); - CHECK(format_duration(0.5) == "500 ms"); - CHECK(format_duration(60) == "1m 00s"); - CHECK(impact_color(0.5) == paletteValue(u"SUCCESS")); - CHECK(impact_color(1) == paletteValue(u"WARNING")); - CHECK(impact_color(5) == paletteValue(u"DANGER")); + f.model.setStartupImpact({{"mod.0",1.5}}); + CHECK(f.model.data(f.model.index(0),ModListModel::kStartupImpactRole).toDouble() == 1.5); + CHECK(f.model.data(f.model.index(0),ModListModel::kStartupImpactTextRole).toString() == "1.50 s"); + CHECK(f.model.data(f.model.index(1),ModListModel::kStartupImpactRole).toDouble() == 0); + CHECK(formatDuration(0.0001) == "0 ms"); + CHECK(formatDuration(0.5) == "500 ms"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(impactColor(0.5) == paletteValue(u"SUCCESS")); + CHECK(impactColor(1) == paletteValue(u"WARNING")); + CHECK(impactColor(5) == paletteValue(u"DANGER")); } diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp index 9741141..799181e 100644 --- a/tests/ui/test_mod_list_panel.cpp +++ b/tests/ui/test_mod_list_panel.cpp @@ -33,12 +33,12 @@ struct PanelFixture { const auto letter = QChar('a'+i); auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(QChar('A'+i)); - mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); - mod->provider_id = QStringLiteral("stub"); + mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); + mod->providerId = QStringLiteral("stub"); if (dependencies && i > 0) { px::modrim::DependencyMod dependency; - dependency.package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); - mod->about_rules.dependencies.append(dependency); + dependency.packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); + mod->aboutRules.dependencies.append(dependency); } mods.insert(QStringLiteral("uuid-%1").arg(letter),mod); } @@ -47,9 +47,9 @@ struct PanelFixture { engine = px::modrim::createQmlEngine(&parent); engine->rootContext()->setContextProperty(QStringLiteral("Theme"),new px::modrim::Theme(engine)); panel = std::make_unique(*ctx,engine,&parent); - panel->load_mods(mods,ctx->activeUuids()); + panel->loadMods(mods,ctx->activeUuids()); } - QObject* root() const { return panel->qml_widget()->rootObject(); } + QObject* root() const { return panel->qmlWidget()->rootObject(); } QObject* view() const { return root() ? root()->findChild(QStringLiteral("listView")) : nullptr; } void select(int row, const QString& uuid) const { REQUIRE(root()); @@ -58,15 +58,15 @@ struct PanelFixture { } void show() const { panel->resize(420,320); panel->show(); QTest::qWait(20); - REQUIRE(panel->qml_widget()->status() == QQuickWidget::Ready); + REQUIRE(panel->qmlWidget()->status() == QQuickWidget::Ready); REQUIRE(root()); REQUIRE(view()); } }; -QVariant qml_value(QObject* object,const char* name) { +QVariant qmlValue(QObject* object,const char* name) { const auto value = object->property(name); return value.metaType() == QMetaType::fromType() ? value.value().toVariant() : value; } -void toggle_with_answer(PanelFixture& f, const QList& rows, QMessageBox::StandardButton answer, QString* prompt = nullptr) { +void toggleWithAnswer(PanelFixture& f, const QList& rows, QMessageBox::StandardButton answer, QString* prompt = nullptr) { QTimer click(&f.parent); click.setInterval(1); QObject::connect(&click,&QTimer::timeout,&f.parent,[&] { for (auto* box : f.panel->findChildren()) if (box->isVisible()) { @@ -74,55 +74,55 @@ void toggle_with_answer(PanelFixture& f, const QList& rows, QMessageBox::St click.stop(); box->button(answer)->click(); return; } }); - click.start(); services_test::run_async(f.panel->toggle_rows(rows)); + click.start(); services_test::runAsync(f.panel->toggleRows(rows)); } } TEST_CASE("disabling dependencies supports selected only all dependents and cancellation") { SECTION("selected only") { PanelFixture f(true,true); QString prompt; - toggle_with_answer(f,{0},QMessageBox::No,&prompt); - CHECK(f.panel->active_uuids() == QStringList{"uuid-b","uuid-c"}); + toggleWithAnswer(f,{0},QMessageBox::No,&prompt); + CHECK(f.panel->activeUuids() == QStringList{"uuid-b","uuid-c"}); CHECK(prompt.contains("Mod B (mod.b)")); CHECK(prompt.contains("Mod C (mod.c)")); } SECTION("selected plus transitive dependents") { PanelFixture f(true,true); - toggle_with_answer(f,{0,1},QMessageBox::Yes); - CHECK(f.panel->active_uuids().isEmpty()); + toggleWithAnswer(f,{0,1},QMessageBox::Yes); + CHECK(f.panel->activeUuids().isEmpty()); } SECTION("cancel") { PanelFixture f(true,true); - toggle_with_answer(f,{0},QMessageBox::Cancel); - CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); + toggleWithAnswer(f,{0},QMessageBox::Cancel); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); } } TEST_CASE("disabled mods leave the active block and reenabling restores dependency position") { PanelFixture f(true,true); - toggle_with_answer(f,{1},QMessageBox::No); - CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-c"}); - const auto* inactive = f.panel->model()->get_item(2); - REQUIRE(inactive); CHECK(inactive->uuid == "uuid-b"); CHECK_FALSE(inactive->checked); CHECK(inactive->load_index == -1); - services_test::run_async(f.panel->toggle_rows({f.panel->rowForUuid("uuid-b")})); - CHECK(f.panel->active_uuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); - CHECK(f.ctx->activeUuids() == f.panel->active_uuids()); + toggleWithAnswer(f,{1},QMessageBox::No); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-c"}); + const auto* inactive = f.panel->model()->getItem(2); + REQUIRE(inactive); CHECK(inactive->uuid == "uuid-b"); CHECK_FALSE(inactive->checked); CHECK(inactive->loadIndex == -1); + services_test::runAsync(f.panel->toggleRows({f.panel->rowForUuid("uuid-b")})); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); + CHECK(f.ctx->activeUuids() == f.panel->activeUuids()); } TEST_CASE("hover and tiny drag handle jitter never display the drag proxy") { PanelFixture f; f.show(); - auto* drag_proxy = f.root()->findChild(QStringLiteral("dragProxy")); REQUIRE(drag_proxy); - auto* widget = f.panel->qml_widget(); + auto* dragProxy = f.root()->findChild(QStringLiteral("dragProxy")); REQUIRE(dragProxy); + auto* widget = f.panel->qmlWidget(); QTest::mouseMove(widget,QPoint(4,26)); QTest::mouseMove(widget,QPoint(14,26)); - CHECK_FALSE(drag_proxy->property("visible").toBool()); + CHECK_FALSE(dragProxy->property("visible").toBool()); QTest::mousePress(widget,Qt::LeftButton,Qt::NoModifier,QPoint(20,26)); QTest::mouseMove(widget,QPoint(21,26)); - CHECK_FALSE(drag_proxy->property("visible").toBool()); + CHECK_FALSE(dragProxy->property("visible").toBool()); QTest::mouseRelease(widget,Qt::LeftButton,Qt::NoModifier,QPoint(21,26)); } TEST_CASE("highlights preserve user selection across filter changes") { PanelFixture f; f.select(0,"uuid-a"); - f.panel->highlight_rows({"uuid-c"}); - f.panel->set_search_filter("Mod"); f.panel->set_search_filter(""); + f.panel->highlightRows({"uuid-c"}); + f.panel->setSearchFilter("Mod"); f.panel->setSearchFilter(""); CHECK(f.panel->isHighlighted("uuid-c")); CHECK_FALSE(f.panel->isHighlighted("uuid-a")); REQUIRE(f.view()); - CHECK(qml_value(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-a")}); + CHECK(qmlValue(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-a")}); CHECK(f.view()->property("currentUuid").toString() == "uuid-a"); } TEST_CASE("highlight also changes the selected delegate tint") { @@ -135,18 +135,18 @@ TEST_CASE("highlight also changes the selected delegate tint") { return result; }; const auto before = colors(); - f.panel->highlight_rows({"uuid-a"}); QTest::qWait(1); + f.panel->highlightRows({"uuid-a"}); QTest::qWait(1); CHECK(colors() != before); } TEST_CASE("QML selection clears when filters hide current UUID and empty state appears") { PanelFixture f(false); REQUIRE(f.view()); CHECK(f.view()->property("currentIndex").toInt() == -1); - f.select(2,"uuid-c"); f.panel->set_search_filter("Mod A"); + f.select(2,"uuid-c"); f.panel->setSearchFilter("Mod A"); CHECK(f.view()->property("count").toInt() == 1); CHECK(f.view()->property("currentIndex").toInt() == -1); CHECK(f.view()->property("anchorIndex").toInt() == -1); - CHECK(qml_value(f.view(),"selectedIndices").toList().isEmpty()); - f.panel->set_search_filter("not present"); + CHECK(qmlValue(f.view(),"selectedIndices").toList().isEmpty()); + f.panel->setSearchFilter("not present"); CHECK(f.view()->property("count").toInt() == 0); auto* empty = f.root()->findChild(QStringLiteral("emptyStateText")); REQUIRE(empty); CHECK(empty->property("visible").toBool()); @@ -156,21 +156,21 @@ TEST_CASE("QML selection follows UUID during a row move and reveal respects filt f.select(2,"uuid-c"); f.panel->moveRow(2,0); CHECK(f.panel->uuidAt(0) == "uuid-c"); REQUIRE(f.view()); CHECK(f.view()->property("currentIndex").toInt() == 0); - CHECK(qml_value(f.view(),"selectedIndices").toList() == QVariantList{0}); - CHECK(qml_value(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-c")}); - CHECK(f.panel->select_uuid("uuid-b")); - f.panel->set_search_filter("Mod A"); CHECK_FALSE(f.panel->select_uuid("uuid-b")); + CHECK(qmlValue(f.view(),"selectedIndices").toList() == QVariantList{0}); + CHECK(qmlValue(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-c")}); + CHECK(f.panel->selectUuid("uuid-b")); + f.panel->setSearchFilter("Mod A"); CHECK_FALSE(f.panel->selectUuid("uuid-b")); } TEST_CASE("search focus disables list keyboard actions and Space remains input text") { PanelFixture f(false); f.show(); f.parent.show(); REQUIRE(QTest::qWaitForWindowActive(&f.parent)); - QTest::mouseClick(f.panel->qml_widget(),Qt::LeftButton,Qt::NoModifier,QPoint(100,26)); + QTest::mouseClick(f.panel->qmlWidget(),Qt::LeftButton,Qt::NoModifier,QPoint(100,26)); QTest::qWait(1); CHECK(f.root()->property("keyboardActive").toBool()); - f.select(0,"uuid-a"); f.panel->search_input->setFocus(); QTest::qWait(1); + f.select(0,"uuid-a"); f.panel->searchInput->setFocus(); QTest::qWait(1); CHECK_FALSE(f.root()->property("keyboardActive").toBool()); - QTest::keyClick(f.panel->search_input,Qt::Key_Return); - CHECK(f.panel->active_uuids().isEmpty()); - QTest::keyClick(f.panel->search_input,Qt::Key_Space); - CHECK(f.panel->search_input->text() == " "); - CHECK(f.panel->active_uuids().isEmpty()); + QTest::keyClick(f.panel->searchInput,Qt::Key_Return); + CHECK(f.panel->activeUuids().isEmpty()); + QTest::keyClick(f.panel->searchInput,Qt::Key_Space); + CHECK(f.panel->searchInput->text() == " "); + CHECK(f.panel->activeUuids().isEmpty()); } diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp index ce4e912..cf715c6 100644 --- a/tests/ui/test_mod_list_proxy_model.cpp +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -12,21 +12,21 @@ struct ProxyFixture { for (int i = 0; i < 5; ++i) { auto mod = std::make_shared(); mod->name = QStringLiteral("Mod %1").arg(i); - mod->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); mods.insert(QStringLiteral("uuid-%1").arg(i),mod); } - source.load_mods(mods,{"uuid-0","uuid-2"}); + source.loadMods(mods,{"uuid-0","uuid-2"}); } - QList source_rows() const { + QList sourceRows() const { QList rows; for (int row = 0; row < proxy.rowCount(); ++row) rows.append(proxy.mapToSource(proxy.index(row)).row()); return rows; } - void check_round_trip() const { + void checkRoundTrip() const { for (int row = 0; row < proxy.rowCount(); ++row) { - const auto source_index = proxy.mapToSource(proxy.index(row)); - REQUIRE(source_index.isValid()); - CHECK(proxy.mapFromSource(source_index).row() == row); + const auto sourceIndex = proxy.mapToSource(proxy.index(row)); + REQUIRE(sourceIndex.isValid()); + CHECK(proxy.mapFromSource(sourceIndex).row() == row); } } }; @@ -34,79 +34,79 @@ struct ProxyFixture { TEST_CASE("mod proxy sidebar filtering maps subset empty and cleared states") { ProxyFixture f; CHECK(f.proxy.rowCount() == 5); - f.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-2","uuid-4"}); - CHECK(f.source_rows() == QList{0,1,4}); - f.check_round_trip(); - f.proxy.set_sidebar_filter(QSet{}); + f.proxy.setSidebarFilter(QSet{"uuid-0","uuid-2","uuid-4"}); + CHECK(f.sourceRows() == QList{0,1,4}); + f.checkRoundTrip(); + f.proxy.setSidebarFilter(QSet{}); CHECK(f.proxy.rowCount() == 0); - f.proxy.set_sidebar_filter(std::nullopt); + f.proxy.setSidebarFilter(std::nullopt); CHECK(f.proxy.rowCount() == 5); } TEST_CASE("mod proxy search normalizes case whitespace package IDs and Unicode") { ProxyFixture f; - f.proxy.set_search_filter("Mod 1"); CHECK(f.source_rows() == QList{2}); - f.proxy.set_search_filter("mod.3"); CHECK(f.source_rows() == QList{3}); - f.proxy.set_search_filter("MOD 2"); CHECK(f.source_rows() == QList{1}); - f.proxy.set_search_filter("zzzz"); CHECK(f.proxy.rowCount() == 0); - f.proxy.set_search_filter(" \t \n "); CHECK(f.proxy.rowCount() == 5); + f.proxy.setSearchFilter("Mod 1"); CHECK(f.sourceRows() == QList{2}); + f.proxy.setSearchFilter("mod.3"); CHECK(f.sourceRows() == QList{3}); + f.proxy.setSearchFilter("MOD 2"); CHECK(f.sourceRows() == QList{1}); + f.proxy.setSearchFilter("zzzz"); CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(" \t \n "); CHECK(f.proxy.rowCount() == 5); auto unicode = std::make_shared(); unicode->name = QStringLiteral("Große Mod"); - unicode->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); + unicode->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); auto padded = std::make_shared(); padded->name = QStringLiteral(" Padded Mod "); - padded->package_id = px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); - f.source.load_mods({{"u",unicode},{"p",padded}},{"u","p"}); - f.proxy.set_search_filter(QStringLiteral(" GROßE ")); CHECK(f.source_rows() == QList{0}); - f.proxy.set_search_filter(" padded.pkg "); CHECK(f.source_rows() == QList{1}); + padded->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); + f.source.loadMods({{"u",unicode},{"p",padded}},{"u","p"}); + f.proxy.setSearchFilter(QStringLiteral(" GROßE ")); CHECK(f.sourceRows() == QList{0}); + f.proxy.setSearchFilter(" padded.pkg "); CHECK(f.sourceRows() == QList{1}); } TEST_CASE("mod proxy combines search and sidebar filters and exposes transitions") { ProxyFixture f; - QSignalSpy filtered(&f.proxy,&ModListProxyModel::is_filtered_changed); - f.proxy.set_sidebar_filter(QSet{"uuid-1","uuid-2","uuid-3"}); - f.proxy.set_search_filter("Mod 2"); CHECK(f.source_rows() == QList{1}); - CHECK(f.proxy.is_filtered()); CHECK(filtered.size() == 1); - f.proxy.set_search_filter("Mod 0"); CHECK(f.proxy.rowCount() == 0); - f.proxy.set_search_filter(""); CHECK(f.proxy.rowCount() == 3); + QSignalSpy filtered(&f.proxy,&ModListProxyModel::isFilteredChanged); + f.proxy.setSidebarFilter(QSet{"uuid-1","uuid-2","uuid-3"}); + f.proxy.setSearchFilter("Mod 2"); CHECK(f.sourceRows() == QList{1}); + CHECK(f.proxy.isFiltered()); CHECK(filtered.size() == 1); + f.proxy.setSearchFilter("Mod 0"); CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(""); CHECK(f.proxy.rowCount() == 3); CHECK(filtered.size() == 1); - f.proxy.set_sidebar_filter(std::nullopt); CHECK_FALSE(f.proxy.is_filtered()); CHECK(filtered.size() == 2); - f.proxy.set_search_filter("mod"); CHECK(filtered.size() == 3); - f.proxy.set_search_filter(""); CHECK(filtered.size() == 4); + f.proxy.setSidebarFilter(std::nullopt); CHECK_FALSE(f.proxy.isFiltered()); CHECK(filtered.size() == 2); + f.proxy.setSearchFilter("mod"); CHECK(filtered.size() == 3); + f.proxy.setSearchFilter(""); CHECK(filtered.size() == 4); } TEST_CASE("mod proxy preserves persistent UUIDs that survive filtering") { ProxyFixture f; QPersistentModelIndex kept(f.proxy.index(2)); QPersistentModelIndex hidden(f.proxy.index(1)); - f.proxy.set_sidebar_filter(QSet{"uuid-1","uuid-3"}); + f.proxy.setSidebarFilter(QSet{"uuid-1","uuid-3"}); CHECK(kept.isValid()); CHECK(kept.row() == 0); - CHECK(kept.data(ModListModel::UuidRole).toString() == "uuid-1"); + CHECK(kept.data(ModListModel::kUuidRole).toString() == "uuid-1"); CHECK_FALSE(hidden.isValid()); CHECK_FALSE(f.proxy.mapFromSource(f.source.index(0)).isValid()); - f.proxy.set_sidebar_filter(std::nullopt); - f.proxy.set_search_filter("Mod 4"); CHECK(f.source_rows() == QList{4}); - f.proxy.set_search_filter(""); f.check_round_trip(); + f.proxy.setSidebarFilter(std::nullopt); + f.proxy.setSearchFilter("Mod 4"); CHECK(f.sourceRows() == QList{4}); + f.proxy.setSearchFilter(""); f.checkRoundTrip(); } TEST_CASE("mod proxy moves retain source mappings with and without filters") { ProxyFixture f; - REQUIRE(f.proxy.move_row(0,2)); f.check_round_trip(); + REQUIRE(f.proxy.moveRow(0,2)); f.checkRoundTrip(); ProxyFixture filtered; - filtered.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-1","uuid-4"}); - REQUIRE(filtered.proxy.move_row(0,1)); filtered.check_round_trip(); + filtered.proxy.setSidebarFilter(QSet{"uuid-0","uuid-1","uuid-4"}); + REQUIRE(filtered.proxy.moveRow(0,1)); filtered.checkRoundTrip(); CHECK_FALSE(filtered.proxy.mapFromSource(filtered.source.index(0)).isValid()); } TEST_CASE("mod proxy clamped move stays inside the unfiltered active block") { ProxyFixture f; - REQUIRE(f.proxy.active_count() == 2); - REQUIRE(f.proxy.move_row(0,4,true)); - CHECK(f.proxy.data(f.proxy.index(0),ModListModel::UuidRole).toString() == "uuid-2"); - CHECK(f.proxy.data(f.proxy.index(1),ModListModel::UuidRole).toString() == "uuid-0"); - CHECK(f.proxy.data(f.proxy.index(2),ModListModel::UuidRole).toString() == "uuid-1"); - CHECK_FALSE(f.proxy.move_row(2,0,true)); - f.proxy.set_search_filter("mod"); CHECK_FALSE(f.proxy.move_row(0,1,true)); - f.check_round_trip(); + REQUIRE(f.proxy.activeCount() == 2); + REQUIRE(f.proxy.moveRow(0,4,true)); + CHECK(f.proxy.data(f.proxy.index(0),ModListModel::kUuidRole).toString() == "uuid-2"); + CHECK(f.proxy.data(f.proxy.index(1),ModListModel::kUuidRole).toString() == "uuid-0"); + CHECK(f.proxy.data(f.proxy.index(2),ModListModel::kUuidRole).toString() == "uuid-1"); + CHECK_FALSE(f.proxy.moveRow(2,0,true)); + f.proxy.setSearchFilter("mod"); CHECK_FALSE(f.proxy.moveRow(0,1,true)); + f.checkRoundTrip(); } TEST_CASE("mod proxy emits data changes only after move completion") { ProxyFixture f; - f.source.load_mods({{"u0",std::make_shared()}, + f.source.loadMods({{"u0",std::make_shared()}, {"u1",std::make_shared()}, {"u2",std::make_shared()}},{"u0","u1","u2"}); QStringList events; @@ -115,15 +115,15 @@ TEST_CASE("mod proxy emits data changes only after move completion") { QObject::connect(&f.proxy,&QAbstractItemModel::dataChanged,&f.proxy,[&](const QModelIndex& top,const QModelIndex& bottom) { events.append(QStringLiteral("data:%1:%2").arg(top.row()).arg(bottom.row())); }); - REQUIRE(f.proxy.move_row(0,2)); + REQUIRE(f.proxy.moveRow(0,2)); CHECK(events == QStringList{"begin","end","data:0:2"}); - for (int row = 0; row < 3; ++row) CHECK(f.proxy.data(f.proxy.index(row),ModListModel::LoadIndexRole).toInt() == row+1); + for (int row = 0; row < 3; ++row) CHECK(f.proxy.data(f.proxy.index(row),ModListModel::kLoadIndexRole).toInt() == row+1); } TEST_CASE("mod proxy section counts reflect visible rows rather than the whole collection") { ProxyFixture f; - f.proxy.set_sidebar_filter(QSet{"uuid-0","uuid-1"}); - CHECK(f.proxy.active_count() == 1); - CHECK(f.proxy.data(f.proxy.index(1),ModListModel::SectionNameRole).toString() == "Inactive (1)"); - f.proxy.set_sidebar_filter(QSet{"uuid-1"}); - CHECK(f.proxy.data(f.proxy.index(0),ModListModel::SectionNameRole).toString().isEmpty()); + f.proxy.setSidebarFilter(QSet{"uuid-0","uuid-1"}); + CHECK(f.proxy.activeCount() == 1); + CHECK(f.proxy.data(f.proxy.index(1),ModListModel::kSectionNameRole).toString() == "Inactive (1)"); + f.proxy.setSidebarFilter(QSet{"uuid-1"}); + CHECK(f.proxy.data(f.proxy.index(0),ModListModel::kSectionNameRole).toString().isEmpty()); } diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp index 1a3ecb7..2feaaa3 100644 --- a/tests/ui/test_mod_tree_model.cpp +++ b/tests/ui/test_mod_tree_model.cpp @@ -18,12 +18,12 @@ struct OrganizerFixture { QSet active{QStringLiteral("u1")}; OrganizerFixture() { - state.folders.insert(ROOT_ID, {ROOT_ID, std::nullopt, QStringLiteral("Root")}); + state.folders.insert(kRootId, {kRootId, std::nullopt, QStringLiteral("Root")}); auto add = [&](const QString& uuid, const QString& name, const QString& pid, const QString& provider) { auto mod = std::make_shared(); mod->name = name; - mod->package_id = CaseInsensitiveStr(pid); - mod->provider_id = provider; + mod->packageId = CaseInsensitiveStr(pid); + mod->providerId = provider; mods.insert(uuid, mod); }; add("u1", "One", "a.one", "local"); @@ -32,18 +32,18 @@ struct OrganizerFixture { } void nested() { - state.folders.insert(2, {2, ROOT_ID, QStringLiteral("Top")}); + state.folders.insert(2, {2, kRootId, QStringLiteral("Top")}); state.folders.insert(3, {3, 2, QStringLiteral("Sub")}); state.placements.insert("a.one", 2); state.placements.insert("a.two", 3); } FolderNode tree(const std::optional& query = std::nullopt) const { - return build_tree(state, mods, active, query); + return buildTree(state, mods, active, query); } }; -ModTreeModel::SummaryFor no_diagnostics = [](const QString&) -> std::optional { +ModTreeModel::SummaryFor noDiagnostics = [](const QString&) -> std::optional { return std::nullopt; }; } @@ -51,8 +51,8 @@ ModTreeModel::SummaryFor no_diagnostics = [](const QString&) -> std::optionalfolder_id() == 2); - CHECK(model.node_at(model.index_for_uuid("u2"))->folder_id() == 3); - CHECK(model.node_at(ungrouped)->folder_id() == ROOT_ID); - CHECK(model.node_at({}) == nullptr); + CHECK(model.data(ungrouped, ModTreeModel::kTotalCountRole).toInt() == 1); + CHECK(model.data(top, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.nodeAt(top)->folderId() == 2); + CHECK(model.nodeAt(model.indexForUuid("u2"))->folderId() == 3); + CHECK(model.nodeAt(ungrouped)->folderId() == kRootId); + CHECK(model.nodeAt({}) == nullptr); CHECK_FALSE(model.index(-1, 0).isValid()); CHECK_FALSE(model.index(0, 1).isValid()); - CHECK_FALSE(model.index_for_key("missing").isValid()); - CHECK_FALSE(model.index_for_uuid("missing").isValid()); + CHECK_FALSE(model.indexForKey("missing").isValid()); + CHECK_FALSE(model.indexForUuid("missing").isValid()); CHECK_FALSE(model.data({}).isValid()); CHECK(model.columnCount() == 1); } TEST_CASE("organizer check states retain total counts while filtering") { OrganizerFixture f; - f.state.folders.insert(2, {2, ROOT_ID, QStringLiteral("Group")}); + f.state.folders.insert(2, {2, kRootId, QStringLiteral("Group")}); f.state.placements.insert("a.one", 2); f.state.placements.insert("a.two", 2); - ModTreeModel model(no_diagnostics); - model.set_tree(f.tree(), f.mods, {}, false); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); auto group = model.index(0, 0); - CHECK(model.data(group, ModTreeModel::CheckStateRole).toInt() == Qt::PartiallyChecked); - CHECK(model.data(model.index(0, 0, group), ModTreeModel::CheckStateRole).toInt() == Qt::Checked); - CHECK(model.data(model.index(1, 0, group), ModTreeModel::CheckStateRole).toInt() == Qt::Unchecked); - CHECK(model.data(model.index(1, 0), ModTreeModel::CheckStateRole).toInt() == Qt::Unchecked); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == Qt::PartiallyChecked); + CHECK(model.data(model.index(0, 0, group), ModTreeModel::kCheckStateRole).toInt() == Qt::Checked); + CHECK(model.data(model.index(1, 0, group), ModTreeModel::kCheckStateRole).toInt() == Qt::Unchecked); + CHECK(model.data(model.index(1, 0), ModTreeModel::kCheckStateRole).toInt() == Qt::Unchecked); TreeQuery query; query.text = QStringLiteral("One"); - model.set_tree(f.tree(query), f.mods, {}, true); + model.setTree(f.tree(query), f.mods, {}, true); group = model.index(0, 0); CHECK(model.rowCount() == 1); CHECK(model.filtering()); - CHECK(model.data(group, ModTreeModel::FilteringRole).toBool()); - CHECK(model.data(group, ModTreeModel::VisibleCountRole).toInt() == 1); - CHECK(model.data(group, ModTreeModel::TotalCountRole).toInt() == 2); - CHECK(model.data(group, ModTreeModel::EnabledCountRole).toInt() == 1); - CHECK(model.data(group, ModTreeModel::CheckStateRole).toInt() == Qt::PartiallyChecked); - CHECK(model.index_for_key("f:2").isValid()); + CHECK(model.data(group, ModTreeModel::kFilteringRole).toBool()); + CHECK(model.data(group, ModTreeModel::kVisibleCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.data(group, ModTreeModel::kEnabledCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == Qt::PartiallyChecked); + CHECK(model.indexForKey("f:2").isValid()); } TEST_CASE("organizer issue markers only show on enabled mods") { @@ -109,75 +109,75 @@ TEST_CASE("organizer issue markers only show on enabled mods") { view::ModDiagnosticsView summary{true, true, QStringLiteral("Error"), QStringLiteral("Incompatible")}; int calls = 0; ModTreeModel model([&](const QString&) -> std::optional { ++calls; return summary; }); - model.set_tree(f.tree(), f.mods, {}, false); - const auto enabled = model.index_for_uuid("u1"); - const auto inactive = model.index_for_uuid("u2"); + model.setTree(f.tree(), f.mods, {}, false); + const auto enabled = model.indexForUuid("u1"); + const auto inactive = model.indexForUuid("u2"); CHECK(model.parent(enabled) == model.index(0, 0)); - CHECK(model.data(enabled, ModTreeModel::HasErrorRole).toBool()); - CHECK(model.data(enabled, ModTreeModel::HasWarningRole).toBool()); - CHECK(model.data(enabled, ModTreeModel::ErrorTooltipRole).toString() == "Error"); - CHECK(model.data(enabled, ModTreeModel::WarningTooltipRole).toString() == "Incompatible"); + CHECK(model.data(enabled, ModTreeModel::kHasErrorRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kErrorTooltipRole).toString() == "Error"); + CHECK(model.data(enabled, ModTreeModel::kWarningTooltipRole).toString() == "Incompatible"); calls = 0; - CHECK_FALSE(model.data(inactive, ModTreeModel::HasErrorRole).toBool()); - CHECK_FALSE(model.data(inactive, ModTreeModel::HasWarningRole).toBool()); - CHECK(model.data(inactive, ModTreeModel::ErrorTooltipRole).toString().isEmpty()); - CHECK(model.data(inactive, ModTreeModel::WarningTooltipRole).toString().isEmpty()); + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasErrorRole).toBool()); + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(inactive, ModTreeModel::kErrorTooltipRole).toString().isEmpty()); + CHECK(model.data(inactive, ModTreeModel::kWarningTooltipRole).toString().isEmpty()); CHECK(calls == 0); f.active.insert("u2"); - model.set_tree(f.tree(), f.mods, {}, false); - CHECK(model.data(model.index_for_uuid("u2"), ModTreeModel::HasWarningRole).toBool()); + model.setTree(f.tree(), f.mods, {}, false); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kHasWarningRole).toBool()); QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); - model.refresh_diagnostics(); + model.refreshDiagnostics(); REQUIRE(changed.count() == 2); - CHECK(changed.first()[2].value>() == QList{ModTreeModel::HasErrorRole, - ModTreeModel::ErrorTooltipRole, ModTreeModel::HasWarningRole, ModTreeModel::WarningTooltipRole}); + CHECK(changed.first()[2].value>() == QList{ModTreeModel::kHasErrorRole, + ModTreeModel::kErrorTooltipRole, ModTreeModel::kHasWarningRole, ModTreeModel::kWarningTooltipRole}); } TEST_CASE("organizer same-shape updates preserve persistent indexes and selection") { OrganizerFixture f; f.nested(); - ModTreeModel model(no_diagnostics); - model.set_tree(f.tree(), f.mods, {}, false); - QPersistentModelIndex one(model.index_for_uuid("u1")); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + QPersistentModelIndex one(model.indexForUuid("u1")); model.select({one}); - model.select({model.index_for_uuid("u2")}, "extend"); - REQUIRE(model.selected_nodes().size() == 2); - model.select({model.index_for_uuid("u2")}, "toggle"); - CHECK_FALSE(model.is_selected(model.index_for_uuid("u2"))); - CHECK(model.is_selected(one)); + model.select({model.indexForUuid("u2")}, "extend"); + REQUIRE(model.selectedNodes().size() == 2); + model.select({model.indexForUuid("u2")}, "toggle"); + CHECK_FALSE(model.isSelected(model.indexForUuid("u2"))); + CHECK(model.isSelected(one)); QSignalSpy reset(&model, &QAbstractItemModel::modelReset); f.active.insert("u2"); - model.set_tree(f.tree(), f.mods, {}, false); + model.setTree(f.tree(), f.mods, {}, false); CHECK(reset.isEmpty()); CHECK(one.isValid()); CHECK(model.data(one).toString() == "One"); - CHECK(model.data(one, ModTreeModel::SelectedRole).toBool()); - model.clear_selection(); - CHECK(model.selected_nodes().isEmpty()); - model.select({model.index_for_uuid("u3")}); + CHECK(model.data(one, ModTreeModel::kSelectedRole).toBool()); + model.clearSelection(); + CHECK(model.selectedNodes().isEmpty()); + model.select({model.indexForUuid("u3")}); TreeQuery query; query.text = QStringLiteral("One"); - model.set_tree(f.tree(query), f.mods, {}, true); + model.setTree(f.tree(query), f.mods, {}, true); CHECK(reset.count() == 1); CHECK_FALSE(one.isValid()); - CHECK(model.selected_nodes().isEmpty()); + CHECK(model.selectedNodes().isEmpty()); } TEST_CASE("organizer expansion respects collapsed ancestors except when filtering") { OrganizerFixture f; f.nested(); f.state.folders[2].collapsed = true; - ModTreeModel model(no_diagnostics); - model.set_tree(f.tree(), f.mods, {}, false); - auto expanded = model.expanded_indexes(); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + auto expanded = model.expandedIndexes(); REQUIRE(expanded.size() == 1); - CHECK(model.data(expanded.first(), ModTreeModel::KeyRole).toString() == "ungrouped"); - model.set_tree(f.tree(), f.mods, {}, true); - expanded = model.expanded_indexes(); + CHECK(model.data(expanded.first(), ModTreeModel::kKeyRole).toString() == "ungrouped"); + model.setTree(f.tree(), f.mods, {}, true); + expanded = model.expandedIndexes(); REQUIRE(expanded.size() == 3); - CHECK(model.data(expanded[0], ModTreeModel::KeyRole).toString() == "f:2"); - CHECK(model.data(expanded[1], ModTreeModel::KeyRole).toString() == "f:3"); - CHECK(model.expanded_indexes(model.index_for_key("f:2")).size() == 1); + CHECK(model.data(expanded[0], ModTreeModel::kKeyRole).toString() == "f:2"); + CHECK(model.data(expanded[1], ModTreeModel::kKeyRole).toString() == "f:3"); + CHECK(model.expandedIndexes(model.indexForKey("f:2")).size() == 1); } TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { @@ -185,74 +185,74 @@ TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { f.nested(); f.state.tags.insert(7, {7, QStringLiteral("Later"), QStringLiteral("blue")}); f.state.tags.insert(2, {2, QStringLiteral("First"), QStringLiteral("red")}); - f.state.mod_tags.insert("a.one", {7, 2, 99}); + f.state.modTags.insert("a.one", {7, 2, 99}); auto about = std::dynamic_pointer_cast(f.mods["u1"]); - about->mod_version = QStringLiteral("1.2.3"); + about->modVersion = QStringLiteral("1.2.3"); auto tree = f.tree(); - tree.children[0].has_rules = true; - tree.has_rules = true; - ModTreeModel model(no_diagnostics); - model.set_tree(tree, f.mods, {{QStringLiteral("local"), QStringLiteral("green")}}, false, f.state.tags); - const auto one = model.index_for_uuid("u1"); - CHECK(model.data(one, ModTreeModel::UuidRole).toString() == "u1"); - CHECK(model.data(one, ModTreeModel::PackageIdRole).toString() == "a.one"); - CHECK(model.data(one, ModTreeModel::CanPlaceRole).toBool()); - CHECK(model.data(one, ModTreeModel::ProviderLabelRole).toString() == "Local"); - CHECK(model.data(one, ModTreeModel::ProviderIconRole).toString() == "folder"); - CHECK(model.data(one, ModTreeModel::ProviderColorRole).toString() == "green"); - CHECK(model.data(one, ModTreeModel::ModVersionRole).toString() == "1.2.3"); - CHECK(model.data(model.index_for_uuid("u2"), ModTreeModel::ProviderIconRole).toString() == "steam"); - const auto tags = model.data(one, ModTreeModel::TagsRole).toList(); + tree.children[0].hasRules = true; + tree.hasRules = true; + ModTreeModel model(noDiagnostics); + model.setTree(tree, f.mods, {{QStringLiteral("local"), QStringLiteral("green")}}, false, f.state.tags); + const auto one = model.indexForUuid("u1"); + CHECK(model.data(one, ModTreeModel::kUuidRole).toString() == "u1"); + CHECK(model.data(one, ModTreeModel::kPackageIdRole).toString() == "a.one"); + CHECK(model.data(one, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(one, ModTreeModel::kProviderLabelRole).toString() == "Local"); + CHECK(model.data(one, ModTreeModel::kProviderIconRole).toString() == "folder"); + CHECK(model.data(one, ModTreeModel::kProviderColorRole).toString() == "green"); + CHECK(model.data(one, ModTreeModel::kModVersionRole).toString() == "1.2.3"); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kProviderIconRole).toString() == "steam"); + const auto tags = model.data(one, ModTreeModel::kTagsRole).toList(); REQUIRE(tags.size() == 2); CHECK(tags[0].toMap()["name"].toString() == "First"); CHECK(tags[1].toMap()["color"].toString() == "blue"); - CHECK(model.data(model.index_for_key("f:2"), ModTreeModel::HasRuleRole).toBool()); - CHECK_FALSE(model.data(model.index_for_key("ungrouped"), ModTreeModel::HasRuleRole).toBool()); - CHECK(model.roleNames()[ModTreeModel::ProviderLabelRole] == "providerLabel"); - CHECK(model.roleNames()[ModTreeModel::TagsRole] == "tags"); + CHECK(model.data(model.indexForKey("f:2"), ModTreeModel::kHasRuleRole).toBool()); + CHECK_FALSE(model.data(model.indexForKey("ungrouped"), ModTreeModel::kHasRuleRole).toBool()); + CHECK(model.roleNames()[ModTreeModel::kProviderLabelRole] == "providerLabel"); + CHECK(model.roleNames()[ModTreeModel::kTagsRole] == "tags"); auto invalid = std::make_shared(); - invalid->provider_id = QStringLiteral("git"); + invalid->providerId = QStringLiteral("git"); f.mods.insert("invalid", invalid); - model.set_tree(f.tree(), f.mods, {}, false); - const auto idx = model.index_for_uuid("invalid"); + model.setTree(f.tree(), f.mods, {}, false); + const auto idx = model.indexForUuid("invalid"); REQUIRE(idx.isValid()); - CHECK_FALSE(model.data(idx, ModTreeModel::CanPlaceRole).toBool()); - CHECK(model.data(idx, ModTreeModel::PackageIdRole).toString().isEmpty()); - CHECK(model.data(idx, ModTreeModel::ProviderIconRole).toString() == "git"); - CHECK(model.data(idx, ModTreeModel::ModVersionRole).toString().isEmpty()); + CHECK_FALSE(model.data(idx, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(idx, ModTreeModel::kPackageIdRole).toString().isEmpty()); + CHECK(model.data(idx, ModTreeModel::kProviderIconRole).toString() == "git"); + CHECK(model.data(idx, ModTreeModel::kModVersionRole).toString().isEmpty()); } TEST_CASE("organizer filter sections icons and fallbacks match sidebar entries") { OrganizerFilterModel model; CHECK(model.rowCount() == 0); - CHECK(model.for_key("missing").key == "all"); - CHECK_FALSE(model.key_at(0)); + CHECK(model.forKey("missing").key == "all"); + CHECK_FALSE(model.keyAt(0)); QList entries{ {QStringLiteral("all"), QStringLiteral("All"), 10}, - {QStringLiteral("active"), QStringLiteral("Active"), 6, StatusFilter::Active}, - {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, StatusFilter::Inactive}, - {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, StatusFilter::All, QStringLiteral("steam")}, - {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, StatusFilter::All, std::nullopt, 1}, - {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::All, std::nullopt, 2}}; + {QStringLiteral("active"), QStringLiteral("Active"), 6, StatusFilter::kActive}, + {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, StatusFilter::kInactive}, + {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, StatusFilter::kAll, QStringLiteral("steam")}, + {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, StatusFilter::kAll, std::nullopt, 1}, + {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::kAll, std::nullopt, 2}}; model.update(entries); REQUIRE(model.rowCount() == 6); const QStringList sections{"Status", "Status", "Status", "Providers", "Tags", "Tags"}; const QStringList icons{"grid", "check-circle", "ban", "steam", "tag", "tag"}; for (int row = 0; row < 6; ++row) { - CHECK(model.data(model.index(row), OrganizerFilterModel::SectionRole).toString() == sections[row]); - CHECK(model.data(model.index(row), OrganizerFilterModel::IconRole).toString() == icons[row]); + CHECK(model.data(model.index(row), OrganizerFilterModel::kSectionRole).toString() == sections[row]); + CHECK(model.data(model.index(row), OrganizerFilterModel::kIconRole).toString() == icons[row]); } - CHECK(model.data(model.index(4), OrganizerFilterModel::KeyRole).toString() == "tag:1"); + CHECK(model.data(model.index(4), OrganizerFilterModel::kKeyRole).toString() == "tag:1"); CHECK(model.data(model.index(4)).toString() == "Medieval"); - CHECK(model.data(model.index(4), OrganizerFilterModel::LabelRole).toString() == "Medieval"); - CHECK(model.data(model.index(4), OrganizerFilterModel::CountRole).toInt() == 5); - CHECK(model.data(model.index(5), OrganizerFilterModel::CountRole).toInt() == 0); - CHECK(model.for_key("tag:1").label == "Medieval"); - CHECK(model.for_key("tag:999").key == "all"); - CHECK(model.key_at(4) == QStringLiteral("tag:1")); - CHECK_FALSE(model.key_at(-1)); - CHECK_FALSE(model.key_at(6)); + CHECK(model.data(model.index(4), OrganizerFilterModel::kLabelRole).toString() == "Medieval"); + CHECK(model.data(model.index(4), OrganizerFilterModel::kCountRole).toInt() == 5); + CHECK(model.data(model.index(5), OrganizerFilterModel::kCountRole).toInt() == 0); + CHECK(model.forKey("tag:1").label == "Medieval"); + CHECK(model.forKey("tag:999").key == "all"); + CHECK(model.keyAt(4) == QStringLiteral("tag:1")); + CHECK_FALSE(model.keyAt(-1)); + CHECK_FALSE(model.keyAt(6)); CHECK(model.rowCount(model.index(0)) == 0); CHECK_FALSE(model.data({}).isValid()); CHECK(model.roleNames()[Qt::DisplayRole] == "filterLabel"); @@ -272,8 +272,8 @@ TEST_CASE("organizer filter infers sections from keys and unknown providers use OrganizerFilterModel model; model.update({{QStringLiteral("provider:custom"), QStringLiteral("Custom"), 1}, {QStringLiteral("tag:9"), QStringLiteral("Tag"), 1}}); - CHECK(model.data(model.index(0), OrganizerFilterModel::SectionRole).toString() == "Providers"); - CHECK(model.data(model.index(0), OrganizerFilterModel::IconRole).toString() == "folder"); - CHECK(model.data(model.index(1), OrganizerFilterModel::SectionRole).toString() == "Tags"); - CHECK(model.data(model.index(1), OrganizerFilterModel::IconRole).toString() == "tag"); + CHECK(model.data(model.index(0), OrganizerFilterModel::kSectionRole).toString() == "Providers"); + CHECK(model.data(model.index(0), OrganizerFilterModel::kIconRole).toString() == "folder"); + CHECK(model.data(model.index(1), OrganizerFilterModel::kSectionRole).toString() == "Tags"); + CHECK(model.data(model.index(1), OrganizerFilterModel::kIconRole).toString() == "tag"); } diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp index ba310a1..b676c33 100644 --- a/tests/ui/test_mod_updates.cpp +++ b/tests/ui/test_mod_updates.cpp @@ -8,7 +8,7 @@ TEST_CASE("small updates skip confirmation") { QWidget parent; - CHECK(services_test::run_async(px::modrim::confirm_update(px::modrim::CONFIRM_THRESHOLD-1,&parent))); + CHECK(services_test::runAsync(px::modrim::confirmUpdate(px::modrim::kConfirmThreshold-1,&parent))); CHECK(parent.findChildren().isEmpty()); } TEST_CASE("large updates follow the actual confirmation dialog answer") { @@ -22,6 +22,6 @@ TEST_CASE("large updates follow the actual confirmation dialog answer") { } }); click.start(); - CHECK(services_test::run_async(px::modrim::confirm_update(px::modrim::CONFIRM_THRESHOLD,&parent)) == (answer == QMessageBox::Yes)); + CHECK(services_test::runAsync(px::modrim::confirmUpdate(px::modrim::kConfirmThreshold,&parent)) == (answer == QMessageBox::Yes)); } } diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp index a382832..c855df5 100644 --- a/tests/ui/test_organizer_dialogs.cpp +++ b/tests/ui/test_organizer_dialogs.cpp @@ -13,12 +13,12 @@ using px::modrim::DeleteFolderDialog; namespace { // The shared QApplication is created by tests/ui/main.cpp. -QApplication& widget_app() { return *static_cast(QApplication::instance()); } +QApplication& widgetApp() { return *static_cast(QApplication::instance()); } } // namespace TEST_CASE("delete folder dialog confirms with yes button") { - widget_app(); + widgetApp(); QWidget parent; DeleteFolderDialog dialog(QStringLiteral("Fixes"), &parent); auto* yes = qobject_cast(dialog.button(QMessageBox::Yes)); diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp index fcb0348..c603b2a 100644 --- a/tests/ui/test_organizer_rule_editor.cpp +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -33,14 +33,14 @@ using organizer_test::wait; class Application { public: Application() { - if (!QCoreApplication::instance()) app_ = std::make_unique(argc_, argv_); + if (!QCoreApplication::instance()) m_app = std::make_unique(m_argc, m_argv); REQUIRE(qobject_cast(QCoreApplication::instance())); } private: - int argc_ = 1; - char name_[16] = "organizer-test"; - char* argv_[2] = {name_, nullptr}; - std::unique_ptr app_; + int m_argc = 1; + char m_name[16] = "organizer-test"; + char* m_argv[2] = {m_name, nullptr}; + std::unique_ptr m_app; }; template @@ -75,8 +75,8 @@ struct Harness { REQUIRE(dir.isValid()); auto mod = std::make_shared(); mod->name = QStringLiteral("Example"); - mod->provider_id = QStringLiteral("local"); - mod->package_id = CaseInsensitiveStr(QStringView(u"example.mod")); + mod->providerId = QStringLiteral("local"); + mod->packageId = CaseInsensitiveStr(QStringView(u"example.mod")); ctx->load({{QStringLiteral("example"), mod}}, {}); auto plugin = std::make_unique(*ctx, db); service = plugin.get(); @@ -149,17 +149,17 @@ struct Harness { }; void checkRule(const Rule& rule, RuleOp op, const QString& pattern, qint64 folder) { - CHECK(rule.field == RuleField::PackageId); + CHECK(rule.field == RuleField::kPackageId); CHECK(rule.op == op); CHECK(rule.pattern == pattern); - CHECK(rule.folder_id == folder); + CHECK(rule.folderId == folder); } } // namespace TEST_CASE("organizer rule editor preserves order and retains an invalid draft", "[ui][organizer]") { Harness h; - const auto first = expect(wait(h.service->create_folder(QStringLiteral("First")))); - const auto second = expect(wait(h.service->create_folder(QStringLiteral("Second")))); + const auto first = expect(wait(h.service->createFolder(QStringLiteral("First")))); + const auto second = expect(wait(h.service->createFolder(QStringLiteral("Second")))); h.view->rebuild(); h.open(0); h.call(QStringLiteral("addRule()")); @@ -167,8 +167,8 @@ TEST_CASE("organizer rule editor preserves order and retains an invalid draft", h.save(); auto state = expect(wait(h.db.load())); REQUIRE(state.rules.size() == 1); - checkRule(state.rules[0], RuleOp::Prefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folder_mod_uuids(first.id)) == QStringList{QStringLiteral("example")}); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == QStringList{QStringLiteral("example")}); h.open(1); h.call(QStringLiteral("addRule()")); @@ -185,9 +185,9 @@ TEST_CASE("organizer rule editor preserves order and retains an invalid draft", h.save(); state = expect(wait(h.db.load())); REQUIRE(state.rules.size() == 2); - checkRule(state.rules[0], RuleOp::Equals, QStringLiteral("example.mod"), second.id); - checkRule(state.rules[1], RuleOp::Prefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folder_mod_uuids(second.id)) == QStringList{QStringLiteral("example")}); + checkRule(state.rules[0], RuleOp::kEquals, QStringLiteral("example.mod"), second.id); + checkRule(state.rules[1], RuleOp::kPrefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folderModUuids(second.id)) == QStringList{QStringLiteral("example")}); const auto saved = state.rules; h.open(2); @@ -202,29 +202,29 @@ TEST_CASE("organizer rule editor preserves order and retains an invalid draft", h.save(); state = expect(wait(h.db.load())); REQUIRE(state.rules.size() == 1); - checkRule(state.rules[0], RuleOp::Prefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folder_mod_uuids(first.id)) == QStringList{QStringLiteral("example")}); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == QStringList{QStringLiteral("example")}); } TEST_CASE("organizer standard rules refresh only a clean editor draft", "[ui][organizer]") { Harness h; - expect(wait(h.service->create_folder(QStringLiteral("Mine")))); + expect(wait(h.service->createFolder(QStringLiteral("Mine")))); h.view->rebuild(); h.open(0); auto* standard = item(h.editor, QStringLiteral("organizerAddStandardRules")); REQUIRE(standard); REQUIRE(standard->property("enabled").toBool()); REQUIRE(QMetaObject::invokeMethod(standard, "clicked")); - until([&] { return h.draft().size() == standard_rules().size(); }); + until([&] { return h.draft().size() == standardRules().size(); }); CHECK(h.editor->property("opened").toBool()); CHECK(h.editor->property("errorMessage").toString().isEmpty()); CHECK_FALSE(h.editor->property("draftDirty").toBool()); const auto state = expect(wait(h.db.load())); - REQUIRE(state.rules.size() == standard_rules().size()); + REQUIRE(state.rules.size() == standardRules().size()); for (qsizetype i = 0; i < state.rules.size(); ++i) { const auto& actual = state.rules[i]; - const auto& expected = standard_rules()[i]; - CHECK(state.folders.value(actual.folder_id).name == expected.folder); + const auto& expected = standardRules()[i]; + CHECK(state.folders.value(actual.folderId).name == expected.folder); CHECK(actual.field == expected.field); CHECK(actual.op == expected.op); CHECK(actual.pattern == expected.pattern); @@ -241,8 +241,8 @@ TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui] Harness h; auto second = std::make_shared(); second->name = QStringLiteral("Second"); - second->provider_id = QStringLiteral("local"); - second->package_id = CaseInsensitiveStr(QStringView(u"second.mod")); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); auto mods = h.ctx->allMods(); mods.insert(QStringLiteral("second"), second); h.ctx->load(std::move(mods), {}); @@ -250,11 +250,11 @@ TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui] auto* model = h.view->findChild(); REQUIRE(model); const auto select = [&](bool both) { - const auto first = model->index_for_uuid(QStringLiteral("example")); + const auto first = model->indexForUuid(QStringLiteral("example")); REQUIRE(first.isValid()); h.view->selectSingle(first); if (both) { - const auto other = model->index_for_uuid(QStringLiteral("second")); + const auto other = model->indexForUuid(QStringLiteral("second")); REQUIRE(other.isValid()); h.view->selectRow(other, static_cast(Qt::ControlModifier), {}); } @@ -286,20 +286,20 @@ TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui] }; CHECK(checkState() == 0); assign(true); - until([&] { return h.service->state().mod_tags.value(QStringLiteral("example.mod")).contains(id); }); + until([&] { return h.service->state().modTags.value(QStringLiteral("example.mod")).contains(id); }); CHECK(checkState() == 1); select(true); CHECK(checkState() == 2); assign(true); - until([&] { return h.service->state().mod_tags.value(QStringLiteral("second.mod")).contains(id); }); + until([&] { return h.service->state().modTags.value(QStringLiteral("second.mod")).contains(id); }); CHECK(checkState() == 1); auto persisted = expect(wait(h.db.load())); - CHECK(persisted.mod_tags.value(QStringLiteral("example.mod")) == QSet{id}); - CHECK(persisted.mod_tags.value(QStringLiteral("second.mod")) == QSet{id}); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")) == QSet{id}); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")) == QSet{id}); assign(false); until([&] { - return h.service->state().mod_tags.value(QStringLiteral("example.mod")).isEmpty() - && h.service->state().mod_tags.value(QStringLiteral("second.mod")).isEmpty(); + return h.service->state().modTags.value(QStringLiteral("example.mod")).isEmpty() + && h.service->state().modTags.value(QStringLiteral("second.mod")).isEmpty(); }); CHECK(checkState() == 0); action(QStringLiteral("updateTag(%1, 'Best', 'bad')").arg(id)); @@ -312,8 +312,8 @@ TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui] persisted = expect(wait(h.db.load())); CHECK(persisted.tags.value(id).name == QStringLiteral("Best")); CHECK(persisted.tags.value(id).color == QStringLiteral("#123456")); - CHECK(persisted.mod_tags.value(QStringLiteral("example.mod")).isEmpty()); - CHECK(persisted.mod_tags.value(QStringLiteral("second.mod")).isEmpty()); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")).isEmpty()); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")).isEmpty()); action(QStringLiteral("deleteTag(%1)").arg(id)); until([&] { return h.service->state().tags.isEmpty(); }); CHECK(expect(wait(h.db.load())).tags.isEmpty()); @@ -323,13 +323,13 @@ TEST_CASE("organizer folder checkbox toggles descendants with one active-state c Harness h; auto second = std::make_shared(); second->name = QStringLiteral("Second"); - second->provider_id = QStringLiteral("local"); - second->package_id = CaseInsensitiveStr(QStringView(u"second.mod")); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); auto mods = h.ctx->allMods(); mods.insert(QStringLiteral("second"), second); h.ctx->load(std::move(mods), {}); - const auto parent = expect(wait(h.service->create_folder(QStringLiteral("Parent")))); - const auto child = expect(wait(h.service->create_folder(QStringLiteral("Child"), parent.id))); + const auto parent = expect(wait(h.service->createFolder(QStringLiteral("Parent")))); + const auto child = expect(wait(h.service->createFolder(QStringLiteral("Child"), parent.id))); expect(wait(h.service->place({QStringLiteral("example.mod")}, child.id))); expect(wait(h.service->place({QStringLiteral("second.mod")}, parent.id))); h.view->rebuild(); @@ -338,7 +338,7 @@ TEST_CASE("organizer folder checkbox toggles descendants with one active-state c int changes = 0; const auto connection = h.ctx->activeStateChanged().connect([&](const QStringList&) { ++changes; }); const auto toggle = [&] { - const auto index = model->index_for_key(QStringLiteral("f:%1").arg(parent.id)); + const auto index = model->indexForKey(QStringLiteral("f:%1").arg(parent.id)); REQUIRE(index.isValid()); h.view->toggleCheck(index); }; diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp index 1cfa36d..fc59dab 100644 --- a/tests/ui/test_procedural_preview.cpp +++ b/tests/ui/test_procedural_preview.cpp @@ -52,7 +52,7 @@ TEST_CASE("initials abbreviates titles") { TEST_CASE("generate preview returns a non-null image of the requested size") { application(); - const QImage image = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage image = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); REQUIRE(!image.isNull()); CHECK(image.width() == 120); CHECK(image.height() == 60); @@ -60,9 +60,9 @@ TEST_CASE("generate preview returns a non-null image of the requested size") { TEST_CASE("generate preview returns the cached image for the same request") { application(); - const QImage image = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage image = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); REQUIRE(!image.isNull()); // QImage shares its data implicitly, so a cache hit is identified by the shared data's cacheKey. - const QImage again = px::modrim::generate_preview(QStringLiteral("[AV] Framework"), 120, 60); + const QImage again = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); CHECK(again.cacheKey() == image.cacheKey()); } diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp index a836354..f307593 100644 --- a/tests/ui/test_restore_snapshot_dialog.cpp +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -27,7 +27,7 @@ using px::modrim::ConfirmRestoreDialog; using px::modrim::RestoreSnapshotDialog; // Widgets need a QApplication for the whole run; the first test that asks for it creates it. -QApplication& ensure_app() { +QApplication& ensureApp() { if (auto* existing = qobject_cast(QCoreApplication::instance())) return *existing; if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); static int argc = 1; @@ -37,26 +37,26 @@ QApplication& ensure_app() { return app; } -void write_text(const QString& path, const QByteArray& bytes) { +void writeText(const QString& path, const QByteArray& bytes) { QFile file(path); REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); REQUIRE(file.write(bytes) == bytes.size()); } -void write_snapshot(const QString& path, const QStringList& package_ids) { +void writeSnapshot(const QString& path, const QStringList& packageIds) { ModsConfig config; config.version = QStringLiteral("1.5"); - for (const QString& id : package_ids) { + for (const QString& id : packageIds) { config.activeMods.append(CaseInsensitiveStr(id)); } - REQUIRE(px::modrim::write_mods_config(path, config, QFileInfo(path).absolutePath()).has_value()); + REQUIRE(px::modrim::writeModsConfig(path, config, QFileInfo(path).absolutePath()).has_value()); } -QListWidget* list_of(RestoreSnapshotDialog& dialog) { +QListWidget* listOf(RestoreSnapshotDialog& dialog) { return dialog.findChild(); } -QPushButton* button_labelled(RestoreSnapshotDialog& dialog, const QString& text) { +QPushButton* buttonLabelled(RestoreSnapshotDialog& dialog, const QString& text) { for (QPushButton* button : dialog.findChildren()) { if (button->text() == text) { return button; @@ -66,7 +66,7 @@ QPushButton* button_labelled(RestoreSnapshotDialog& dialog, const QString& text) } // The details label is the only label carrying markup; the instruction line has none. -QString details_text(RestoreSnapshotDialog& dialog) { +QString detailsText(RestoreSnapshotDialog& dialog) { for (QLabel* label : dialog.findChildren()) { if (label->text().contains(QStringLiteral(""))) { return label->text(); @@ -79,74 +79,74 @@ QString details_text(RestoreSnapshotDialog& dialog) { TEST_CASE("Restore dialog selects readable snapshots and disables Restore for unreadable ones", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - write_snapshot(valid_snap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - write_text(corrupt_snap, "<<>>"); + const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); - RestoreSnapshotDialog dialog(QStringList{valid_snap, corrupt_snap}); - QListWidget* list = list_of(dialog); + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); REQUIRE(list->count() == 2); // The first readable snapshot is selected initially. - REQUIRE(dialog.selectedSnapshot() == valid_snap); - QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); - QPushButton* cancel_btn = button_labelled(dialog, QStringLiteral("Cancel")); - REQUIRE(restore_btn != nullptr); - REQUIRE(cancel_btn != nullptr); - REQUIRE(restore_btn->isEnabled()); - REQUIRE(restore_btn->parentWidget() == &dialog); + REQUIRE(dialog.selectedSnapshot() == validSnap); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + QPushButton* cancelBtn = buttonLabelled(dialog, QStringLiteral("Cancel")); + REQUIRE(restoreBtn != nullptr); + REQUIRE(cancelBtn != nullptr); + REQUIRE(restoreBtn->isEnabled()); + REQUIRE(restoreBtn->parentWidget() == &dialog); list->setCurrentRow(1); REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - REQUIRE_FALSE(restore_btn->isEnabled()); + REQUIRE_FALSE(restoreBtn->isEnabled()); list->setCurrentRow(0); - REQUIRE(dialog.selectedSnapshot() == valid_snap); - REQUIRE(restore_btn->isEnabled()); + REQUIRE(dialog.selectedSnapshot() == validSnap); + REQUIRE(restoreBtn->isEnabled()); - cancel_btn->click(); + cancelBtn->click(); REQUIRE(dialog.result() == QDialog::Rejected); - RestoreSnapshotDialog accept_dialog(QStringList{valid_snap}); - QListWidget* accept_list = list_of(accept_dialog); - REQUIRE(accept_list != nullptr); - emit accept_list->itemDoubleClicked(accept_list->item(0)); - REQUIRE(accept_dialog.result() == QDialog::Accepted); - REQUIRE(accept_dialog.selectedSnapshot() == valid_snap); + RestoreSnapshotDialog acceptDialog(QStringList{validSnap}); + QListWidget* acceptList = listOf(acceptDialog); + REQUIRE(acceptList != nullptr); + emit acceptList->itemDoubleClicked(acceptList->item(0)); + REQUIRE(acceptDialog.result() == QDialog::Accepted); + REQUIRE(acceptDialog.selectedSnapshot() == validSnap); } TEST_CASE("Restore dialog Restore button accepts the selected snapshot", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - write_snapshot(valid_snap, {QStringLiteral("mod.foo")}); + const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, {QStringLiteral("mod.foo")}); - RestoreSnapshotDialog dialog(QStringList{valid_snap}); - QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); - REQUIRE(restore_btn != nullptr); - restore_btn->click(); + RestoreSnapshotDialog dialog(QStringList{validSnap}); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + restoreBtn->click(); REQUIRE(dialog.result() == QDialog::Accepted); - REQUIRE(dialog.selectedSnapshot() == valid_snap); + REQUIRE(dialog.selectedSnapshot() == validSnap); } TEST_CASE("Restore dialog double-click on an unreadable snapshot does not accept", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - write_text(corrupt_snap, "<<>>"); + const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); - RestoreSnapshotDialog dialog(QStringList{corrupt_snap}); - QListWidget* list = list_of(dialog); + RestoreSnapshotDialog dialog(QStringList{corruptSnap}); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); emit list->itemDoubleClicked(list->item(0)); REQUIRE(dialog.result() == QDialog::Rejected); @@ -154,21 +154,21 @@ TEST_CASE("Restore dialog double-click on an unreadable snapshot does not accept } TEST_CASE("Restore dialog lists snapshot timestamps and mod counts in given order", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString two_mods = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - write_snapshot(two_mods, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString one_mod = dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); - write_snapshot(one_mod, {QStringLiteral("mod.foo")}); - const QString no_mods = dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); - write_snapshot(no_mods, {}); + const QString twoMods = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(twoMods, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString oneMod = dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); + writeSnapshot(oneMod, {QStringLiteral("mod.foo")}); + const QString noMods = dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); + writeSnapshot(noMods, {}); const QString corrupt = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - write_text(corrupt, "<<>>"); + writeText(corrupt, "<<>>"); - RestoreSnapshotDialog dialog(QStringList{two_mods, one_mod, no_mods, corrupt}); - QListWidget* list = list_of(dialog); + RestoreSnapshotDialog dialog(QStringList{twoMods, oneMod, noMods, corrupt}); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); REQUIRE(list->count() == 4); @@ -186,17 +186,17 @@ TEST_CASE("Restore dialog lists snapshot timestamps and mod counts in given orde TEST_CASE("Restore dialog falls back to file modification time for unnamed or invalid snapshot names", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); const QString unnamed = dir.filePath(QStringLiteral("custom.xml")); - write_snapshot(unnamed, {QStringLiteral("mod.foo")}); - const QString bad_date = dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); - write_snapshot(bad_date, {QStringLiteral("mod.foo")}); + writeSnapshot(unnamed, {QStringLiteral("mod.foo")}); + const QString badDate = dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); + writeSnapshot(badDate, {QStringLiteral("mod.foo")}); - RestoreSnapshotDialog dialog(QStringList{unnamed, bad_date}); - QListWidget* list = list_of(dialog); + RestoreSnapshotDialog dialog(QStringList{unnamed, badDate}); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); REQUIRE(list->count() == 2); @@ -207,48 +207,48 @@ TEST_CASE("Restore dialog falls back to file modification time for unnamed or in } TEST_CASE("Restore dialog details describe the selected snapshot", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); QTemporaryDir dir; REQUIRE(dir.isValid()); - const QString valid_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - write_snapshot(valid_snap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString corrupt_snap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - write_text(corrupt_snap, "<<>>"); + const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); - RestoreSnapshotDialog dialog(QStringList{valid_snap, corrupt_snap}); - QListWidget* list = list_of(dialog); + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); - REQUIRE(details_text(dialog) == + REQUIRE(detailsText(dialog) == QStringLiteral("File: ModsConfig_20260922_120000.xml
    Active mods: 2 | " "Game version: 1.5")); list->setCurrentRow(1); - REQUIRE(details_text(dialog) == + REQUIRE(detailsText(dialog) == QStringLiteral("This saved mod list cannot be read. ModsConfig_20260922_130000.xml " "will not be restored.")); list->setCurrentItem(nullptr); REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - REQUIRE(details_text(dialog).isEmpty()); + REQUIRE(detailsText(dialog).isEmpty()); } TEST_CASE("Restore dialog with no snapshots has nothing to select", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); RestoreSnapshotDialog dialog(QStringList{}); - QListWidget* list = list_of(dialog); + QListWidget* list = listOf(dialog); REQUIRE(list != nullptr); REQUIRE(list->count() == 0); REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - QPushButton* restore_btn = button_labelled(dialog, QStringLiteral("Restore")); - REQUIRE(restore_btn != nullptr); - REQUIRE_FALSE(restore_btn->isEnabled()); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + REQUIRE_FALSE(restoreBtn->isEnabled()); } TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensure_app(); + [[maybe_unused]] auto& app = ensureApp(); ConfirmRestoreDialog confirm(QStringLiteral("ModsConfig_20260922_120000.xml")); REQUIRE(confirm.windowTitle() == QStringLiteral("Restore Mod List")); @@ -257,11 +257,11 @@ TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", "[ REQUIRE(confirm.icon() == QMessageBox::Question); REQUIRE(confirm.defaultButton() == confirm.button(QMessageBox::Cancel)); - auto* restore_btn = qobject_cast(confirm.button(QMessageBox::Yes)); - REQUIRE(restore_btn != nullptr); - REQUIRE(restore_btn->text() == QStringLiteral("Restore")); - REQUIRE(restore_btn->objectName() == QStringLiteral("primaryAction")); - auto* cancel_btn = qobject_cast(confirm.button(QMessageBox::Cancel)); - REQUIRE(cancel_btn != nullptr); - REQUIRE(cancel_btn->text() == QStringLiteral("Cancel")); + auto* restoreBtn = qobject_cast(confirm.button(QMessageBox::Yes)); + REQUIRE(restoreBtn != nullptr); + REQUIRE(restoreBtn->text() == QStringLiteral("Restore")); + REQUIRE(restoreBtn->objectName() == QStringLiteral("primaryAction")); + auto* cancelBtn = qobject_cast(confirm.button(QMessageBox::Cancel)); + REQUIRE(cancelBtn != nullptr); + REQUIRE(cancelBtn->text() == QStringLiteral("Cancel")); } diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp index fa72a14..a976e8c 100644 --- a/tests/ui/test_sidebar_model.cpp +++ b/tests/ui/test_sidebar_model.cpp @@ -5,29 +5,29 @@ TEST_CASE("sidebar entry updates refresh provider icons without resetting") { using px::modrim::view::SidebarEntry; px::modrim::SidebarModel model; - model.set_entries({{SidebarEntry::Kind::Provider,"Steam",{"a","b"},"steam"}}); + model.setEntries({{SidebarEntry::Kind::kProvider,"Steam",{"a","b"},"steam"}}); QSignalSpy changed(&model,&QAbstractItemModel::dataChanged); QSignalSpy reset(&model,&QAbstractItemModel::modelReset); - model.update_entries({{SidebarEntry::Kind::Provider,"Local",{"a","b"},"local"}}); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::IconRole).toString() == "folder"); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::IconColorRole).toString() == px::modrim::paletteValue(u"TEXT_DIM")); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::CountRole).toInt() == 2); + model.updateEntries({{SidebarEntry::Kind::kProvider,"Local",{"a","b"},"local"}}); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::kIconRole).toString() == "folder"); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::kIconColorRole).toString() == px::modrim::paletteValue(u"TEXT_DIM")); + CHECK(model.data(model.index(0),px::modrim::SidebarModel::kCountRole).toInt() == 2); CHECK(reset.isEmpty()); REQUIRE(changed.size() == 1); - CHECK(changed.first()[2].value>().contains(px::modrim::SidebarModel::IconRole)); - CHECK(model.get_entry(0)->provider_id == "local"); + CHECK(changed.first()[2].value>().contains(px::modrim::SidebarModel::kIconRole)); + CHECK(model.getEntry(0)->providerId == "local"); } TEST_CASE("sidebar models status sections and resets when its entry shape changes") { using px::modrim::view::SidebarEntry; using px::modrim::SidebarModel; SidebarModel model; - model.set_entries({{SidebarEntry::Kind::All,"All",{"a"},{}}, - {SidebarEntry::Kind::WithErrors,"Errors",{"a"},{}}}); - CHECK(model.data(model.index(0),SidebarModel::SectionRole).toString() == "Status"); - CHECK(model.data(model.index(1),SidebarModel::BadgeFgRole).toString() == px::modrim::paletteValue(u"DANGER")); - CHECK_FALSE(model.get_entry(-1).has_value()); + model.setEntries({{SidebarEntry::Kind::kAll,"All",{"a"},{}}, + {SidebarEntry::Kind::kWithErrors,"Errors",{"a"},{}}}); + CHECK(model.data(model.index(0),SidebarModel::kSectionRole).toString() == "Status"); + CHECK(model.data(model.index(1),SidebarModel::kBadgeFgRole).toString() == px::modrim::paletteValue(u"DANGER")); + CHECK_FALSE(model.getEntry(-1).has_value()); QSignalSpy reset(&model,&QAbstractItemModel::modelReset); - model.update_entries({}); + model.updateEntries({}); CHECK(reset.size() == 1); CHECK(model.rowCount() == 0); } diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp index 7b89ee9..0d94791 100644 --- a/tests/ui/test_startup_impact_data.cpp +++ b/tests/ui/test_startup_impact_data.cpp @@ -10,12 +10,12 @@ StartupImpactReport report() { {"Small", QStringLiteral("a.small"), 1, {{"LoadingProgress.StartupImpact.ApplyPatches", 1}}}, {"Big", QStringLiteral("b.big"), 4, {{"LoadingProgress.StartupImpact.ModContentPackReloadContentInt.Textures", 3}, {"LoadingProgress.StartupImpact.ApplyPatches", 1}}, {}, 0.5}, - {QString(BASE_GAME_PID), QString(BASE_GAME_PID), 10, + {QString(kBaseGamePid), QString(kBaseGamePid), 10, {{"LoadingProgress.StartupImpact.GlobalTextureAtlasManagerBakeStaticAtlases", 10}}}}; return result; } QVariantMap build(QStringList active = {}, std::optional selected = {}, double estimate = 0) { - return build_startup_impact(report(), active, selected, estimate); + return buildStartupImpact(report(), active, selected, estimate); } } TEST_CASE("Startup impact excludes base and ranks mods") { @@ -82,16 +82,16 @@ TEST_CASE("Startup impact phases toggle base contribution") { for (const auto& mod : phase.toMap().value("mods").toList()) CHECK_FALSE(mod.toMap().value("base").toBool()); } TEST_CASE("Startup impact duration formatting") { - CHECK(format_duration(0.0004) == "0 ms"); - CHECK(format_duration(0.25) == "250 ms"); - CHECK(format_duration(59.5) == "59.50 s"); - CHECK(format_duration(60) == "1m 00s"); - CHECK(format_duration(268.7) == "4m 29s"); + CHECK(formatDuration(0.0004) == "0 ms"); + CHECK(formatDuration(0.25) == "250 ms"); + CHECK(formatDuration(59.5) == "59.50 s"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(formatDuration(268.7) == "4m 29s"); } TEST_CASE("Startup impact tooltip combines metrics sharing labels") { auto data = report(); data.mods[0].metrics = {{"ApplyPatches|Foo -> a", 1}, {"ApplyPatches|Foo -> b", 2}}; - const auto rows = build_startup_impact(data, {}, {}, 0).value("mods").toList(); + const auto rows = buildStartupImpact(data, {}, {}, 0).value("mods").toList(); const auto tooltip = rows[1].toMap().value("phases").toList()[0].toMap().value("tooltip").toString(); CHECK(tooltip.count('\n') == 0); CHECK(tooltip.endsWith("3.00 s")); diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp index 4729902..e44e3de 100644 --- a/tests/ui/test_toast_and_progress.cpp +++ b/tests/ui/test_toast_and_progress.cpp @@ -44,7 +44,7 @@ TEST_CASE("close button removes a persistent toast") { application(); QWidget parent; ToastManager manager(&parent); - manager.showToast(QStringLiteral("stays"), ToastLevel::Info, 0); + manager.showToast(QStringLiteral("stays"), ToastLevel::kInfo, 0); auto* toast = manager.findChild(); REQUIRE(toast != nullptr); diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp index c857b69..85b68a9 100644 --- a/tests/ui/test_unity_rich_text.cpp +++ b/tests/ui/test_unity_rich_text.cpp @@ -18,7 +18,7 @@ struct Case { void checkCases(std::initializer_list cases) { for (const Case& c : cases) { INFO("input: " << c.input); - CHECK(px::modrim::unity_rich_text_to_html(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); + CHECK(px::modrim::unityRichTextToHtml(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); } } @@ -98,21 +98,21 @@ TEST_CASE("unity rich text drops links with unsafe urls and keeps their text") { }) { INFO("url: " << url); const QString input = QStringLiteral("link").arg(QString::fromUtf8(url)); - CHECK(px::modrim::unity_rich_text_to_html(input) == QStringLiteral("link")); + CHECK(px::modrim::unityRichTextToHtml(input) == QStringLiteral("link")); } } TEST_CASE("unity rich text uses the default size for a non-finite size") { // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::modrim::unity_rich_text_to_html(QStringLiteral("large").arg(huge)); + const QString html = px::modrim::unityRichTextToHtml(QStringLiteral("large").arg(huge)); CHECK(html == QStringLiteral("large")); } TEST_CASE("unity rich text uses the default indent for a non-finite indent") { // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::modrim::unity_rich_text_to_html(QStringLiteral("wide").arg(huge)); + const QString html = px::modrim::unityRichTextToHtml(QStringLiteral("wide").arg(huge)); CHECK(html == QStringLiteral("
    wide
    ")); } diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp index 6789bb5..2fd735b 100644 --- a/tests/ui/test_views.cpp +++ b/tests/ui/test_views.cpp @@ -19,7 +19,7 @@ namespace { // The embedded browser exists only in WebEngine builds; without it there is no "workshopWeb" object. -QObject* web_of(const px::modrim::SteamWorkshopViewPanel& view) { +QObject* webOf(const px::modrim::SteamWorkshopViewPanel& view) { for (auto* quick : view.findChildren()) { if (auto* root = quick->rootObject()) { if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) return web; @@ -46,6 +46,6 @@ TEST_CASE("steam workshop view has no web view before initialization") { auto core = px::ui_test::makeCore(); px::modrim::AppContext app(*core); px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); - CHECK(web_of(view) == nullptr); + CHECK(webOf(view) == nullptr); } #endif diff --git a/tests/ui/test_workshop_images.cpp b/tests/ui/test_workshop_images.cpp index e4ccc67..05db17a 100644 --- a/tests/ui/test_workshop_images.cpp +++ b/tests/ui/test_workshop_images.cpp @@ -39,19 +39,19 @@ class Thumbs { const QString pragma = QStringLiteral(".pragma library"); if (script.startsWith(pragma)) script.remove(0, pragma.size()); - const QJSValue result = engine_.evaluate(script, path); + const QJSValue result = m_engine.evaluate(script, path); REQUIRE(!result.isError()); - sized_ = engine_.globalObject().property(QStringLiteral("sized")); - REQUIRE(sized_.isCallable()); + m_sized = m_engine.globalObject().property(QStringLiteral("sized")); + REQUIRE(m_sized.isCallable()); } QString sized(const QString& url, int width, int height) { - return sized_.call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}).toString(); + return m_sized.call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}).toString(); } private: - QJSEngine engine_; - QJSValue sized_; + QJSEngine m_engine; + QJSValue m_sized; }; // Spins the event loop until `predicate` holds or kWaitMs elapses. diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp index 0683d2f..61a4244 100644 --- a/tests/ui/test_workshop_installed.cpp +++ b/tests/ui/test_workshop_installed.cpp @@ -8,10 +8,10 @@ using px::modrim::workshop::CatalogMod; using px::modrim::workshop::InstalledList; -using px::modrim::workshop::PAGE; +using px::modrim::workshop::kPage; namespace { -CatalogMod installed_mod(std::string id, std::string title, std::string author = "Someone", +CatalogMod installedMod(std::string id, std::string title, std::string author = "Someone", std::vector tags = {}) { CatalogMod mod; mod.id = std::move(id); @@ -33,10 +33,10 @@ TEST_CASE("workshop installed search matches title author id and tags case insen REQUIRE_FALSE(installed.loaded()); REQUIRE(installed.total() == 0); REQUIRE(installed.visible().empty()); - REQUIRE(installed.next_page().empty()); + REQUIRE(installed.nextPage().empty()); installed.search(""); - installed.replace({installed_mod("11", "Harmony", "Brrainz"), installed_mod("12", "HugsLib", "Someone", {"Library"}), - installed_mod("999", "Other")}); + installed.replace({installedMod("11", "Harmony", "Brrainz"), installedMod("12", "HugsLib", "Someone", {"Library"}), + installedMod("999", "Other")}); REQUIRE(installed.loaded()); REQUIRE(installed.mods().size() == 3); const std::vector> cases{{"harm", {"11"}}, {"BRRAINZ", {"11"}}, @@ -52,39 +52,39 @@ TEST_CASE("workshop installed search matches title author id and tags case insen TEST_CASE("workshop installed paging reveals one page and search restarts it") { InstalledList installed; std::vector mods; - for (int i = 0; i < PAGE * 2 + 5; ++i) mods.push_back(installed_mod(std::to_string(i), "Mod")); + for (int i = 0; i < kPage * 2 + 5; ++i) mods.push_back(installedMod(std::to_string(i), "Mod")); installed.replace(mods); - REQUIRE(installed.visible().size() == PAGE); - REQUIRE(installed.has_more()); - const auto second = installed.next_page(); - REQUIRE(second.size() == PAGE); - REQUIRE(second.front().id == std::to_string(PAGE)); - REQUIRE(installed.next_page().size() == 5); - REQUIRE_FALSE(installed.has_more()); - REQUIRE(installed.next_page().empty()); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); + const auto second = installed.nextPage(); + REQUIRE(second.size() == kPage); + REQUIRE(second.front().id == std::to_string(kPage)); + REQUIRE(installed.nextPage().size() == 5); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); installed.search(""); - REQUIRE(installed.visible().size() == PAGE); - REQUIRE(installed.total() == PAGE * 2 + 5); - installed.next_page(); - installed.reset_paging(); - REQUIRE(installed.visible().size() == PAGE); - REQUIRE(installed.has_more()); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.total() == kPage * 2 + 5); + installed.nextPage(); + installed.resetPaging(); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); } TEST_CASE("workshop replacing installed mods preserves query and current paging") { InstalledList installed; installed.search("mod"); std::vector mods; - for (int i = 0; i < PAGE * 3; ++i) mods.push_back(installed_mod(std::to_string(i), "Mod")); + for (int i = 0; i < kPage * 3; ++i) mods.push_back(installedMod(std::to_string(i), "Mod")); installed.replace(mods); - installed.next_page(); + installed.nextPage(); installed.replace(mods); - REQUIRE(installed.visible().size() == PAGE * 2); - installed.replace({installed_mod("match", "Mod"), installed_mod("other", "Other")}); + REQUIRE(installed.visible().size() == kPage * 2); + installed.replace({installedMod("match", "Mod"), installedMod("other", "Other")}); REQUIRE(ids(installed.visible()) == QStringList{"match"}); REQUIRE(installed.total() == 1); - REQUIRE_FALSE(installed.has_more()); - REQUIRE(installed.next_page().empty()); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); installed.replace({}); REQUIRE_FALSE(installed.loaded()); REQUIRE(installed.visible().empty()); diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp index ecdbbd8..66219c6 100644 --- a/tests/ui/test_workshop_models.cpp +++ b/tests/ui/test_workshop_models.cpp @@ -26,8 +26,8 @@ CatalogMod mod(std::string id = "1") { return value; } -InstallState missing(const CatalogMod&) { return InstallState::missing; } -InstallState outdated(const CatalogMod&) { return InstallState::outdated; } +InstallState missing(const CatalogMod&) { return InstallState::kMissing; } +InstallState outdated(const CatalogMod&) { return InstallState::kOutdated; } int role(const CatalogListModel& model, const QByteArray& name) { const auto names = model.roleNames(); @@ -41,21 +41,21 @@ TEST_CASE("workshop model rejects unsafe URLs and preserves web URLs") { for (const auto* url : {"javascript:alert(1)", "file:///tmp/test", "data:text/plain,hi", "//example.com", "https:///path", "https://example.com/\nsecret", "https://[bad", "https://example.com/a b"}) { CAPTURE(url); - REQUIRE(safe_url(QString::fromUtf8(url)).isEmpty()); + REQUIRE(safeUrl(QString::fromUtf8(url)).isEmpty()); } for (const auto* url : {"https://example.com/path", "http://example.com", "HTTPS://example.com"}) { - REQUIRE(safe_url(QString::fromUtf8(url)) == QString::fromUtf8(url)); + REQUIRE(safeUrl(QString::fromUtf8(url)) == QString::fromUtf8(url)); } } TEST_CASE("workshop catalog roles paging append deduplication and reset") { QObject owner; CatalogListModel model(&owner); - const QList rows{item_row(mod("1"), outdated, "1.6"), item_row(mod("2"), outdated, "1.6"), - item_row(mod("3"), outdated, "1.6")}; + const QList rows{itemRow(mod("1"), outdated, "1.6"), itemRow(mod("2"), outdated, "1.6"), + itemRow(mod("3"), outdated, "1.6")}; int notifications = 0; QObject::connect(&model, &CatalogListModel::changed, [&] { ++notifications; }); - model.set_page({rows[0], rows[1]}, 10, QStringLiteral("page2")); + model.setPage({rows[0], rows[1]}, 10, QStringLiteral("page2")); REQUIRE(model.hasMore()); REQUIRE(model.cursor() == std::optional(QStringLiteral("page2"))); REQUIRE(model.count() == 2); @@ -65,18 +65,18 @@ TEST_CASE("workshop catalog roles paging append deduplication and reset") { REQUIRE(names == QSet(rows[0].keyBegin(), rows[0].keyEnd())); REQUIRE(role(model, "itemId") == Qt::UserRole + 1); REQUIRE(role(model, "compatLabel") == Qt::UserRole + 25); - const int id_role = role(model, "itemId"); - REQUIRE(model.data(model.index(0), id_role).toString() == "1"); - model.set_page({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); + const int idRole = role(model, "itemId"); + REQUIRE(model.data(model.index(0), idRole).toString() == "1"); + model.setPage({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); REQUIRE(model.count() == 3); REQUIRE_FALSE(model.hasMore()); - REQUIRE(model.data(model.index(2), id_role).toString() == "3"); - REQUIRE_FALSE(model.data({}, id_role).isValid()); + REQUIRE(model.data(model.index(2), idRole).toString() == "3"); + REQUIRE_FALSE(model.data({}, idRole).isValid()); REQUIRE_FALSE(model.data(model.index(0), Qt::DisplayRole).isValid()); REQUIRE_FALSE(model.data(model.index(0), Qt::UserRole + 26).isValid()); REQUIRE(model.rowCount(model.index(0)) == 0); REQUIRE(model.rowCount() == 3); - model.set_page({}, 0, std::nullopt); + model.setPage({}, 0, std::nullopt); REQUIRE(model.count() == 0); REQUIRE(model.total() == 0); REQUIRE(notifications == 3); @@ -85,14 +85,14 @@ TEST_CASE("workshop catalog roles paging append deduplication and reset") { TEST_CASE("workshop refreshing a page changes only changed rows without resetting") { CatalogListModel model; auto first = mod("1"), second = mod("2"), third = mod("3"); - auto show = [&] { model.set_page({item_row(first, missing, "1.5"), item_row(second, missing, "1.5"), - item_row(third, missing, "1.5")}, 3, std::nullopt); }; + auto show = [&] { model.setPage({itemRow(first, missing, "1.5"), itemRow(second, missing, "1.5"), + itemRow(third, missing, "1.5")}, 3, std::nullopt); }; show(); int resets = 0; QList changed; QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); QObject::connect(&model, &QAbstractItemModel::dataChanged, - [&](const QModelIndex& first_index, const QModelIndex&) { changed.append(first_index.row()); }); + [&](const QModelIndex& firstIndex, const QModelIndex&) { changed.append(firstIndex.row()); }); first.subscriptions = 500; second.title = "Renamed"; show(); @@ -101,45 +101,45 @@ TEST_CASE("workshop refreshing a page changes only changed rows without resettin REQUIRE(model.data(model.index(1), role(model, "title")).toString() == "Renamed"); auto reversed = model.rows(); std::reverse(reversed.begin(), reversed.end()); - model.set_page(reversed, 3, std::nullopt); + model.setPage(reversed, 3, std::nullopt); REQUIRE(resets == 1); - model.refresh_rows([](const QVariantMap& row) { auto copy = row; copy["queued"] = true; return copy; }); + model.refreshRows([](const QVariantMap& row) { auto copy = row; copy["queued"] = true; return copy; }); REQUIRE(model.rows().front().value("queued").toBool()); REQUIRE(changed.back() == 0); - model.set_page({}, 0, std::nullopt); + model.setPage({}, 0, std::nullopt); changed.clear(); - model.refresh_rows([](const QVariantMap& row) { return row; }); + model.refreshRows([](const QVariantMap& row) { return row; }); REQUIRE(changed.isEmpty()); } TEST_CASE("workshop mod rows preserve state sizes versions descriptions and compatibility") { auto value = mod(); - auto row = item_row(value, outdated, "1.6"); + auto row = itemRow(value, outdated, "1.6"); REQUIRE(row.value("actionLabel").toString() == "Update"); REQUIRE(row.value("stateLabel").toString() == "Update available"); REQUIRE(row.value("incompatible").toBool()); REQUIRE(row.value("fileSize").toString() == "1.0 MB"); REQUIRE(row.value("description").toString().isEmpty()); - REQUIRE(item_row(value, missing, "1.5", true).value("description").toString() - == px::modrim::description_to_html(QString::fromStdString(value.description), "bbcode")); + REQUIRE(itemRow(value, missing, "1.5", true).value("description").toString() + == px::modrim::descriptionToHtml(QString::fromStdString(value.description), "bbcode")); value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; - row = item_row(value, missing, "1.5"); + row = itemRow(value, missing, "1.5"); REQUIRE(row.value("versions").toString() == "1.2, 1.2.1, 1.5, 1.10"); REQUIRE(row.value("tags").toString() == QStringLiteral("Mod · Utility")); struct Compatibility { std::vector versions; QString game; bool incompatible; QString label; }; - const std::vector compatibility_cases{ + const std::vector compatibilityCases{ {{"1.5"}, "1.5", false, "Works with 1.5"}, {{"1.4"}, "1.5", true, "Not for 1.5"}, {{}, "1.5", false, "Version not stated"}, {{"1.5"}, "", false, ""}}; - for (const auto& scenario : compatibility_cases) { + for (const auto& scenario : compatibilityCases) { value.supported_versions = scenario.versions; value.incompatible = scenario.incompatible; - REQUIRE(item_row(value, missing, scenario.game).value("compatLabel").toString() == scenario.label); + REQUIRE(itemRow(value, missing, scenario.game).value("compatLabel").toString() == scenario.label); } value.author = {}; value.file_size.reset(); - row = item_row(value, missing, ""); + row = itemRow(value, missing, ""); REQUIRE(row.value("author").toString() == "Unknown author"); REQUIRE(row.value("fileSize").toString() == "Unknown"); } @@ -153,7 +153,7 @@ TEST_CASE("workshop vote approval uses counts and formats thousands") { for (const auto& scenario : cases) { auto value = mod(); value.votes = scenario.votes; - const auto row = item_row(value, [](const CatalogMod&) { return InstallState::installed; }, "1.5"); + const auto row = itemRow(value, [](const CatalogMod&) { return InstallState::kInstalled; }, "1.5"); REQUIRE(row.value("votes").toString() == scenario.approval); REQUIRE(row.value("votesUp").toString() == scenario.up); REQUIRE(row.value("votesDown").toString() == scenario.down); @@ -173,16 +173,16 @@ TEST_CASE("workshop summaries strip bbcode media and truncate at a word boundary TEST_CASE("workshop collection rows select previews filter collage and display server size") { CatalogCollection value; value.id = "picked:x"; - value.source = CollectionSource::picked; + value.source = CollectionSource::kPicked; value.title = "C"; value.author.name = "Curator"; value.description = "[b]Description[/b]"; - value.previews = {{PreviewType::video, "https://youtube.test/v"}, {PreviewType::image, "https://images.test/own.png"}}; + value.previews = {{PreviewType::kVideo, "https://youtube.test/v"}, {PreviewType::kImage, "https://images.test/own.png"}}; value.member_ids = {"10"}; value.member_count = 1; value.member_previews = {"https://images.test/m.png", "javascript:alert(1)"}; - const auto never_install = [](const CatalogMod&) { FAIL("Collections must not request install state"); return InstallState::missing; }; - auto row = item_row(value, never_install, "1.6", true); + const auto neverInstall = [](const CatalogMod&) { FAIL("Collections must not request install state"); return InstallState::kMissing; }; + auto row = itemRow(value, neverInstall, "1.6", true); REQUIRE(row.value("previewUrl").toString() == "https://images.test/own.png"); REQUIRE(row.value("collage").toStringList() == QStringList{"https://images.test/m.png"}); REQUIRE(row.value("votes").toString() == "Unrated"); @@ -193,11 +193,11 @@ TEST_CASE("workshop collection rows select previews filter collage and display s REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); REQUIRE(row.value("actionLabel").toString() == "Download all"); REQUIRE(row.value("memberCount").toInt() == 1); - REQUIRE(row.value("description").toString() == px::modrim::description_to_html("[b]Description[/b]", "text")); + REQUIRE(row.value("description").toString() == px::modrim::descriptionToHtml("[b]Description[/b]", "text")); value.total_size = "3145728"; value.preview_url = "https://images.test/explicit.png"; - value.source = CollectionSource::steam; - row = item_row(value, never_install, "1.6"); + value.source = CollectionSource::kSteam; + row = itemRow(value, neverInstall, "1.6"); REQUIRE(row.value("fileSize").toString() == "3.0 MB"); REQUIRE(row.value("previewUrl").toString() == "https://images.test/explicit.png"); REQUIRE(row.value("sourceLabel").toString() == "Steam collection"); @@ -206,11 +206,11 @@ TEST_CASE("workshop collection rows select previews filter collage and display s TEST_CASE("workshop collection version declarations override conflicting member summary") { CatalogCollection value; value.no_common_version = true; - auto row = item_row(value, missing, "1.6"); + auto row = itemRow(value, missing, "1.6"); REQUIRE(row.value("compatLabel").toString() == "No common version"); REQUIRE(row.value("incompatible").toBool()); value.no_common_version = false; - row = item_row(value, missing, "1.6"); + row = itemRow(value, missing, "1.6"); REQUIRE(row.value("compatLabel").toString() == "Version not stated"); REQUIRE_FALSE(row.value("incompatible").toBool()); value.no_common_version = true; @@ -219,7 +219,7 @@ TEST_CASE("workshop collection version declarations override conflicting member for (const auto& [versions, tags] : cases) { value.supported_versions = versions; value.tags = tags; - row = item_row(value, missing, "1.6"); + row = itemRow(value, missing, "1.6"); REQUIRE_FALSE(row.value("incompatible").toBool()); REQUIRE(row.value("compatLabel").toString() == "Works with 1.6"); REQUIRE(row.value("versions").toString().contains("1.6")); diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index 3e3d2c3..cc0277f 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -30,21 +30,23 @@ using workshop_test::HttpResponse; using workshop_test::run; using workshop_test::wire; +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct Batch { std::vector items; std::vector unavailable_ids; }; +// NOLINTEND(readability-identifier-naming) class DownloaderFixture : public Downloader { public: explicit DownloaderFixture(DownloadManager& manager) : Downloader(QStringLiteral("test_steam"), QStringLiteral("Steam"), manager) {} - bool is_downloading() const override { return busy; } - QSet active_ids() const override { return active; } + bool isDownloading() const override { return busy; } + QSet activeIds() const override { return active; } bool accepts(const QString&) const override { return true; } - std::optional updatable_id(const ListedMod& mod) const override { return mod.published_file_id(); } - std::optional last_synced(const ListedMod&) const override { return 50.; } - QCoro::Task> download_mods(QStringList ids) override { + std::optional updatableId(const ListedMod& mod) const override { return mod.publishedFileId(); } + std::optional lastSynced(const ListedMod&) const override { return 50.; } + QCoro::Task> downloadMods(QStringList ids) override { calls.append(ids); co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; } @@ -66,7 +68,7 @@ struct PanelFixture { CatalogCollection collection; bool fail = false; bool complete = false; - std::function on_request; + std::function onRequest; workshop_test::FakeCatalog server{[this](const HttpRequest& request) { return answer(request); }}; QQmlEngine* engine = nullptr; std::unique_ptr view; @@ -74,18 +76,18 @@ struct PanelFixture { PanelFixture() { REQUIRE(QApplication::instance()); REQUIRE(dir.isValid()); - core->downloads().register_downloader(downloader); + core->downloads().registerDownloader(downloader); mod.id = "1"; mod.title = "Untrusted title"; mod.author.name = "Author"; mod.description = "[b]Safe description[/b]"; mod.tags = {"Utility"}; mod.supported_versions = {"1.5"}; mod.updated_at = 100; mod.file_size = "1024"; mod.dependencies = {"2"}; mod.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=1"; dependency = mod; dependency.id = "2"; dependency.title = "Dependency"; dependency.dependencies.clear(); - collection.id = "picked:test"; collection.source = CollectionSource::picked; + collection.id = "picked:test"; collection.source = CollectionSource::kPicked; collection.title = "Test collection"; collection.author.name = "Curator"; collection.description = "Plain text"; collection.supported_versions = {"1.6"}; collection.member_ids = {"1", "missing"}; collection.member_count = 2; - catalog.set_base_url(server.url()); + catalog.setBaseUrl(server.url()); engine = createQmlEngine(&owner); engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); view = std::make_unique(*core, engine, &owner, app, catalog); @@ -97,7 +99,7 @@ struct PanelFixture { run(catalog.shutdown()); } HttpResponse answer(const HttpRequest& request) { - if (on_request) on_request(request); + if (onRequest) onRequest(request); if (fail) return {503, R"({"error":{"message":"Catalog unavailable"}})"}; const auto path = request.path(); if (path == QStringLiteral("/catalog/discover")) return {200, wire(Discover{{{mod}, 1, {}}, {{collection}, 1, {}}})}; @@ -115,8 +117,8 @@ struct PanelFixture { } if (path == QStringLiteral("/catalog/mods/batch")) return {200, wire(Batch{{mod}, {}})}; if (path == QStringLiteral("/catalog/resolve")) { - const bool collection_request = request.body.find("picked:test") != std::string::npos; - const bool incomplete = collection_request && !complete; + const bool collectionRequest = request.body.find("picked:test") != std::string::npos; + const bool incomplete = collectionRequest && !complete; return {200, wire(ResolvedDownload{{"1"}, {{"1", mod}, {"2", dependency}}, {"2", "1"}, incomplete ? std::vector{"missing"} : std::vector{}, incomplete ? std::vector{"picked:test"} : std::vector{}, !incomplete})}; @@ -125,14 +127,14 @@ struct PanelFixture { } QString install(const QString& id, const QString& provider = QStringLiteral("steam")) { auto installed = std::make_shared(); - REQUIRE(installed->set_mod_path(dir.filePath(provider + QLatin1Char('/') + id)).has_value()); - installed->provider_id = provider; + REQUIRE(installed->setModPath(dir.filePath(provider + QLatin1Char('/') + id)).has_value()); + installed->providerId = provider; installed->name = id; auto mods = core->allMods(); const auto uuid = installed->uuid(); mods.insert(uuid, installed); core->load(std::move(mods), core->activeUuids()); - catalog.invalidate_installed(); + catalog.invalidateInstalled(); return uuid; } qsizetype requests(const QString& path) const { @@ -144,31 +146,31 @@ struct PanelFixture { TEST_CASE("workshop view loads discovery and paginates filtered catalog results") { PanelFixture f; CHECK(f.view->findChild()->status() == QQuickWidget::Ready); - CHECK(f.view->mods_model->count() == 1); - CHECK(f.view->collections_model->count() == 1); - run(f.view->select_tab(QStringLiteral("Mods"))); - run(f.view->filter_items({}, QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 1); + CHECK(f.view->collectionsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems({}, QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); run(f.view->load(true)); - CHECK(f.view->mods_model->count() == 2); - CHECK_FALSE(f.view->mods_model->hasMore()); + CHECK(f.view->modsModel->count() == 2); + CHECK_FALSE(f.view->modsModel->hasMore()); CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("cursor")) == QStringLiteral("second")); CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("version")) == QStringLiteral("1.6")); f.fail = true; - run(f.view->filter_items(QStringLiteral("uncached"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + run(f.view->filterItems(QStringLiteral("uncached"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); CHECK(f.view->error() == QStringLiteral("Catalog unavailable")); f.fail = false; - run(f.view->retry_load()); + run(f.view->retryLoad()); CHECK(f.view->error().isEmpty()); - CHECK(f.view->mods_model->count() == 1); + CHECK(f.view->modsModel->count() == 1); } TEST_CASE("workshop detail escapes descriptions and lists dependencies") { PanelFixture f; - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); CHECK(f.view->hasDetail()); - CHECK(f.view->members_model->count() == 1); + CHECK(f.view->membersModel->count() == 1); CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("Safe description"))); - run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("<b>Plain text</b>"))); CHECK(f.view->detail().value(QStringLiteral("warning")).toString().contains(QStringLiteral("missing"))); } @@ -176,7 +178,7 @@ TEST_CASE("workshop detail escapes descriptions and lists dependencies") { TEST_CASE("incomplete workshop downloads require explicit partial approval and never activate") { PanelFixture f; const auto uuid = f.install(QStringLiteral("1")); - run(f.view->download_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); CHECK(f.view->pendingPlan()); CHECK(f.view->canDownloadAvailable()); CHECK(f.downloader.calls.isEmpty()); @@ -190,7 +192,7 @@ TEST_CASE("incomplete workshop downloads require explicit partial approval and n TEST_CASE("workshop partial plan cancellation clears notice without queuing") { PanelFixture f; - run(f.view->download_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); f.view->dismissPlan(); CHECK_FALSE(f.view->pendingPlan()); CHECK(f.view->notice().isEmpty()); @@ -200,7 +202,7 @@ TEST_CASE("workshop partial plan cancellation clears notice without queuing") { TEST_CASE("workshop collection detail falls back to member preview collage") { PanelFixture f; f.mod.preview_url = "https://example.com/preview.png"; - run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); CHECK(f.view->detail().value(QStringLiteral("collage")).toStringList() == QStringList{QStringLiteral("https://example.com/preview.png")}); } @@ -208,24 +210,24 @@ TEST_CASE("workshop collection listing detects partial and fully installed membe PanelFixture f; f.collection.member_ids = {"1", "2"}; f.collection.member_count = 2; f.install(QStringLiteral("1")); - run(f.view->select_tab(QStringLiteral("Collections"))); - auto row = f.view->collections_model->rows().front(); + run(f.view->selectTabAsync(QStringLiteral("Collections"))); + auto row = f.view->collectionsModel->rows().front(); CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Complete download")); CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); f.install(QStringLiteral("2")); - row = f.view->collections_model->rows().front(); + row = f.view->collectionsModel->rows().front(); CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Installed")); CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); f.downloader.active = {QStringLiteral("1")}; - f.core->downloads().busy_changed.notify(true); - row = f.view->collections_model->rows().front(); + f.core->downloads().busyChanged.notify(true); + row = f.view->collectionsModel->rows().front(); CHECK(row.value(QStringLiteral("queued")).toBool()); CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Downloading")); } TEST_CASE("disabled workshop catalog does not issue requests") { PanelFixture f; - f.catalog.set_base_url({}); + f.catalog.setBaseUrl({}); const auto before = f.server.requests.size(); run(f.view->load()); CHECK_FALSE(f.view->configured()); @@ -246,15 +248,15 @@ TEST_CASE("workshop settings apply catalog URL only on save") { TEST_CASE("installed workshop search is local and cached across revisits") { PanelFixture f; f.install(QStringLiteral("1")); - run(f.view->select_tab(QStringLiteral("Installed"))); - REQUIRE(f.view->mods_model->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + REQUIRE(f.view->modsModel->count() == 1); const auto fetched = f.requests(QStringLiteral("/catalog/mods/batch")); - run(f.view->filter_items(QStringLiteral("absent"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->mods_model->count() == 0); - run(f.view->filter_items(QStringLiteral("Author"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->mods_model->count() == 1); - run(f.view->select_tab(QStringLiteral("Mods"))); - run(f.view->select_tab(QStringLiteral("Installed"))); + run(f.view->filterItems(QStringLiteral("absent"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 0); + run(f.view->filterItems(QStringLiteral("Author"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == fetched); } @@ -262,13 +264,13 @@ TEST_CASE("workshop activation toggles one copy and removes every active duplica PanelFixture f; const auto first = f.install(QStringLiteral("1")); const auto second = f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); - run(f.view->toggle_activation(QStringLiteral("1"))); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); REQUIRE(f.core->activeUuids().size() == 1); CHECK((f.core->activeUuids().contains(first) || f.core->activeUuids().contains(second))); f.core->setActive({first, second}); - run(f.view->toggle_activation(QStringLiteral("1"))); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); CHECK(f.core->activeUuids().isEmpty()); - run(f.view->toggle_activation(QStringLiteral("absent"))); + run(f.view->toggleActivationAsync(QStringLiteral("absent"))); CHECK(f.core->activeUuids().isEmpty()); } @@ -276,32 +278,32 @@ TEST_CASE("external workshop activation refreshes listing detail and dependencie PanelFixture f; const auto first = f.install(QStringLiteral("1")); const auto second = f.install(QStringLiteral("2")); - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); f.core->setActive({first, second}); CHECK(f.view->detail().value(QStringLiteral("active")).toBool()); - CHECK(f.view->mods_model->rows().front().value(QStringLiteral("active")).toBool()); - CHECK(f.view->members_model->rows().front().value(QStringLiteral("active")).toBool()); + CHECK(f.view->modsModel->rows().front().value(QStringLiteral("active")).toBool()); + CHECK(f.view->membersModel->rows().front().value(QStringLiteral("active")).toBool()); } TEST_CASE("workshop tags accumulate excluding supported version tags") { PanelFixture f; f.mod.tags = {"1.5", "Utility", "Animals"}; - run(f.view->select_tab(QStringLiteral("Mods"))); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); f.dependency.tags = {"Library"}; - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); CHECK(f.view->tagOptions() == QStringList{QStringLiteral("All tags"), QStringLiteral("Animals"), QStringLiteral("Library"), QStringLiteral("Utility")}); } TEST_CASE("workshop detail back walks history before restoring listing filters") { PanelFixture f; - run(f.view->select_tab(QStringLiteral("Mods"))); - run(f.view->filter_items(QStringLiteral("query"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); - run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems(QStringLiteral("query"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); CHECK(f.view->backLabel() == QStringLiteral("Back to Test collection")); - run(f.view->go_back()); + run(f.view->goBack()); CHECK(f.view->detail().value(QStringLiteral("itemId")) == QStringLiteral("picked:test")); - run(f.view->go_back()); + run(f.view->goBack()); CHECK_FALSE(f.view->hasDetail()); CHECK(f.view->tab() == QStringLiteral("Mods")); CHECK(f.view->searchQuery() == QStringLiteral("query")); @@ -314,27 +316,27 @@ TEST_CASE("workshop routes tabs items and settings through app navigator") { f.view->openItem(QStringLiteral("picked:test"), QStringLiteral("collection")); REQUIRE(parseRoute(route)); CHECK(parseRoute(route)->path == QStringList{QStringLiteral("collection"), QStringLiteral("picked:test")}); - run(f.view->follow_route(parseRoute(route)->path)); + run(f.view->followRoute(parseRoute(route)->path)); CHECK(f.view->hasDetail()); - run(f.view->follow_route({QStringLiteral("mods")})); + run(f.view->followRoute({QStringLiteral("mods")})); CHECK(f.view->tab() == QStringLiteral("Mods")); CHECK_FALSE(f.view->hasDetail()); f.view->openSettings(); REQUIRE(parseRoute(route)); - CHECK(parseRoute(route)->view_id == QStringLiteral("settings")); + CHECK(parseRoute(route)->viewId == QStringLiteral("settings")); } TEST_CASE("workshop starred collections stay in favourites until unstarred") { PanelFixture f; f.view->toggleFavourite(QStringLiteral("picked:test")); - run(f.view->select_tab(QStringLiteral("Favourites"))); - REQUIRE(f.view->collections_model->count() == 1); - CHECK(f.view->collections_model->rows().front().value(QStringLiteral("favourite")).toBool()); - run(f.view->filter_items(QStringLiteral("not present"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->collections_model->count() == 0); - run(f.view->filter_items({}, {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + run(f.view->selectTabAsync(QStringLiteral("Favourites"))); + REQUIRE(f.view->collectionsModel->count() == 1); + CHECK(f.view->collectionsModel->rows().front().value(QStringLiteral("favourite")).toBool()); + run(f.view->filterItems(QStringLiteral("not present"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); + CHECK(f.view->collectionsModel->count() == 0); + run(f.view->filterItems({}, {}, QStringLiteral("all"), QStringLiteral("popular"), {})); f.view->toggleFavourite(QStringLiteral("picked:test")); - CHECK(f.view->collections_model->count() == 0); + CHECK(f.view->collectionsModel->count() == 0); } TEST_CASE("fully installed workshop pack activates all or only its members and dependencies") { @@ -345,17 +347,17 @@ TEST_CASE("fully installed workshop pack activates all or only its members and d const auto dependency = f.install(QStringLiteral("2")); const auto other = f.install(QStringLiteral("3")); f.core->setActive({duplicate, other}); - run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); CHECK(f.view->detail().value(QStringLiteral("state")) == QStringLiteral("installed")); - run(f.view->toggle_collection()); + run(f.view->toggleCollectionAsync()); CHECK(f.core->activeUuids().contains(duplicate)); CHECK(f.core->activeUuids().contains(dependency)); CHECK(f.core->activeUuids().contains(other)); CHECK_FALSE(f.core->activeUuids().contains(first)); - run(f.view->toggle_collection(true)); + run(f.view->toggleCollectionAsync(true)); CHECK_FALSE(f.core->activeUuids().contains(other)); f.core->setActive({first, duplicate, dependency}); - run(f.view->toggle_collection()); + run(f.view->toggleCollectionAsync()); CHECK(f.core->activeUuids().isEmpty()); } @@ -364,9 +366,9 @@ TEST_CASE("partly installed and incomplete workshop collections cannot activate" f.install(QStringLiteral("1")); for (bool complete : {false, true}) { f.complete = complete; - f.catalog.set_base_url(f.server.url() + (complete ? QStringLiteral("/") : QString{})); - run(f.view->open_item(QStringLiteral("picked:test"), QStringLiteral("collection"))); - run(f.view->toggle_collection(true)); + f.catalog.setBaseUrl(f.server.url() + (complete ? QStringLiteral("/") : QString{})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); + run(f.view->toggleCollectionAsync(true)); CHECK(f.core->activeUuids().isEmpty()); } } @@ -374,13 +376,13 @@ TEST_CASE("partly installed and incomplete workshop collections cannot activate" TEST_CASE("workshop installed events preserve open details until back reloads the list") { PanelFixture f; f.install(QStringLiteral("1")); - run(f.view->select_tab(QStringLiteral("Installed"))); - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); const auto before = f.requests(QStringLiteral("/catalog/mods/batch")); f.install(QStringLiteral("2")); CHECK(f.view->hasDetail()); CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before); - run(f.view->go_back()); + run(f.view->goBack()); CHECK_FALSE(f.view->hasDetail()); CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before + 1); } @@ -388,25 +390,25 @@ TEST_CASE("workshop installed events preserve open details until back reloads th TEST_CASE("workshop teardown disconnects events and blocks late fetch and plan completion") { PanelFixture f; const auto uuid = f.install(QStringLiteral("1")); - f.on_request = [&f](const HttpRequest& request) { + f.onRequest = [&f](const HttpRequest& request) { if (request.path() == QStringLiteral("/catalog/mods")) f.view->teardown(); }; - run(f.view->select_tab(QStringLiteral("Mods"))); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); f.view->teardown(); - run(f.view->toggle_activation(QStringLiteral("1"))); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); CHECK_FALSE(f.core->activeUuids().contains(uuid)); - f.catalog.queue_changed.notify({}); - f.catalog.installed_changed.notify({}); - f.core->downloads().busy_changed.notify(false); - CHECK(f.view->mods_model->count() == 1); + f.catalog.queueChanged.notify({}); + f.catalog.installedChanged.notify({}); + f.core->downloads().busyChanged.notify(false); + CHECK(f.view->modsModel->count() == 1); } TEST_CASE("workshop discards download plan completing after view destruction") { PanelFixture f; - f.on_request = [&f](const HttpRequest& request) { + f.onRequest = [&f](const HttpRequest& request) { if (request.path() == QStringLiteral("/catalog/resolve")) f.view.reset(); }; - run(f.view->download_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->downloadItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); CHECK_FALSE(f.view); CHECK(f.downloader.calls.isEmpty()); } @@ -415,7 +417,7 @@ TEST_CASE("workshop detail metadata retains all tags and numerically sorted vers PanelFixture f; f.mod.tags = {"1.6", "Utility", "Animals"}; f.mod.supported_versions = {"1.10", "1.6", "1.5"}; - run(f.view->open_item(QStringLiteral("1"), QStringLiteral("mod"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); CHECK(f.view->detail().value(QStringLiteral("versions")) == QStringLiteral("1.5, 1.6, 1.10")); CHECK(f.view->detail().value(QStringLiteral("tags")) == QStringLiteral("Utility · Animals")); } From 029a1fc58ba20981a0a4be5d2824662da2949a7e Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 04:19:57 +0300 Subject: [PATCH 10/50] style: Google-based clang-format, naming lint, docs and tooling for the new conventions --- .clang-tidy | 1 + .github/workflows/ci.yml | 12 + AGENTS.md | 43 +- justfile | 10 +- src/app/main.cpp | 347 ++--- src/app/plugins.cpp | 130 +- src/app/plugins.hpp | 43 +- src/core/checker/checker.cpp | 419 +++--- src/core/checker/checker.hpp | 163 +-- src/core/checker/databases.cpp | 650 ++++----- src/core/checker/databases.hpp | 100 +- src/core/checker/graph.cpp | 492 +++---- src/core/checker/graph.hpp | 162 +-- src/core/checker/issues.cpp | 447 +++--- src/core/checker/issues.hpp | 140 +- src/core/checker/models.cpp | 28 +- src/core/checker/models.hpp | 59 +- src/core/checker/sort.cpp | 384 +++-- src/core/checker/sort.hpp | 19 +- src/core/config.cpp | 711 +++++----- src/core/config.hpp | 244 ++-- src/core/constants.hpp | 67 +- src/core/context.cpp | 222 +-- src/core/context.hpp | 180 +-- src/core/downloads/downloader.cpp | 18 +- src/core/downloads/downloader.hpp | 74 +- src/core/downloads/manager.cpp | 222 +-- src/core/downloads/manager.hpp | 78 +- src/core/downloads/steam.cpp | 948 +++++++------ src/core/downloads/steam.hpp | 281 ++-- src/core/downloads/types.hpp | 33 +- src/core/error.hpp | 5 +- src/core/events.hpp | 59 +- src/core/file_io.cpp | 57 +- src/core/file_io.hpp | 3 +- src/core/loading.cpp | 99 +- src/core/loading.hpp | 80 +- src/core/migrator.cpp | 235 ++-- src/core/migrator.hpp | 21 +- src/core/mod_service.cpp | 402 +++--- src/core/mod_service.hpp | 112 +- src/core/models/metadata/parsing.cpp | 674 ++++----- src/core/models/metadata/parsing.hpp | 27 +- src/core/models/metadata/structures.cpp | 288 ++-- src/core/models/metadata/structures.hpp | 413 +++--- src/core/models/view/diagnostics.hpp | 23 +- src/core/models/view/sidebar.cpp | 14 +- src/core/models/view/sidebar.hpp | 32 +- src/core/mods_config.cpp | 464 +++--- src/core/mods_config.hpp | 39 +- src/core/organizer/db.cpp | 1018 ++++++++------ src/core/organizer/db.hpp | 103 +- src/core/organizer/defaults.cpp | 138 +- src/core/organizer/defaults.hpp | 8 +- src/core/organizer/models.hpp | 129 +- src/core/organizer/resolve.cpp | 621 +++++---- src/core/organizer/resolve.hpp | 115 +- src/core/organizer/service.cpp | 973 +++++++------ src/core/organizer/service.hpp | 187 +-- src/core/plugin.hpp | 226 +-- src/core/plugin_config.hpp | 135 +- src/core/profiler.cpp | 54 +- src/core/profiler.hpp | 106 +- src/core/providers/base.cpp | 245 ++-- src/core/providers/base.hpp | 159 ++- src/core/providers/core.cpp | 60 +- src/core/providers/core.hpp | 14 +- src/core/providers/factory.cpp | 29 +- src/core/providers/factory.hpp | 12 +- src/core/providers/labels.hpp | 48 +- src/core/providers/local.cpp | 177 +-- src/core/providers/local.hpp | 53 +- src/core/services/activation_service.cpp | 816 +++++------ src/core/services/activation_service.hpp | 30 +- src/core/services/diagnostics_service.cpp | 489 +++---- src/core/services/diagnostics_service.hpp | 152 +- src/core/services/game_launcher.cpp | 180 +-- src/core/services/game_launcher.hpp | 44 +- src/core/services/game_process.cpp | 658 ++++----- src/core/services/game_process.hpp | 144 +- src/core/services/metadata_cache.cpp | 1002 ++++++------- src/core/services/metadata_cache.hpp | 73 +- src/core/services/mod_discovery.cpp | 77 +- src/core/services/mod_discovery.hpp | 9 +- src/core/services/sort_service.cpp | 153 +- src/core/services/sort_service.hpp | 59 +- .../services/startup_impact_service/db.cpp | 934 +++++++------ .../services/startup_impact_service/db.hpp | 93 +- .../startup_impact_service/labels.cpp | 207 +-- .../startup_impact_service/labels.hpp | 7 +- .../startup_impact_service/models.cpp | 87 +- .../startup_impact_service/models.hpp | 62 +- .../startup_impact_service/parser.cpp | 205 +-- .../startup_impact_service/parser.hpp | 8 +- .../startup_impact_service/phases.cpp | 116 +- .../startup_impact_service/phases.hpp | 26 +- .../startup_impact_service/service.cpp | 130 +- .../startup_impact_service/service.hpp | 59 +- src/core/services/update_service.cpp | 235 ++-- src/core/services/update_service.hpp | 55 +- src/core/sort/community.cpp | 68 +- src/core/sort/community.hpp | 7 +- src/core/sort/community_service.cpp | 355 ++--- src/core/sort/community_service.hpp | 43 +- src/core/sort/config.cpp | 63 +- src/core/sort/config.hpp | 26 +- src/core/sort/models.hpp | 19 +- src/core/sort/tiers.cpp | 261 ++-- src/core/sort/tiers.hpp | 42 +- src/core/structures.hpp | 8 +- src/core/support/report.cpp | 573 ++++---- src/core/support/report.hpp | 68 +- src/core/toposort.hpp | 78 +- src/core/utils.cpp | 69 +- src/core/utils.hpp | 6 +- src/core/version.cpp | 13 +- src/core/version.hpp | 1 + src/core/workshop/catalog.cpp | 488 ++++--- src/core/workshop/catalog.hpp | 420 +++--- src/core/workshop/client.cpp | 443 +++--- src/core/workshop/client.hpp | 86 +- src/core/workshop/favourites.cpp | 75 +- src/core/workshop/favourites.hpp | 49 +- src/core/workshop/types.hpp | 282 ++-- src/core/xml.cpp | 310 ++-- src/core/xml.hpp | 25 +- src/ui/Header.qml | 4 +- src/ui/components/banner_widget.cpp | 155 +- src/ui/components/banner_widget.hpp | 59 +- src/ui/components/button.cpp | 7 +- src/ui/components/button.hpp | 7 +- src/ui/components/dialog_chrome.cpp | 246 ++-- src/ui/components/dialog_chrome.hpp | 8 +- src/ui/components/dialogs.hpp | 78 +- src/ui/components/filter_sidebar.cpp | 30 +- src/ui/components/filter_sidebar.hpp | 15 +- src/ui/components/header_controller.cpp | 100 +- src/ui/components/header_controller.hpp | 193 +-- src/ui/components/header_panel.cpp | 19 +- src/ui/components/header_panel.hpp | 10 +- src/ui/components/icon_button.cpp | 51 +- src/ui/components/icon_button.hpp | 17 +- src/ui/components/icons.cpp | 100 +- src/ui/components/icons.hpp | 6 +- src/ui/components/mod_activation.cpp | 87 +- src/ui/components/mod_activation.hpp | 20 +- src/ui/components/mod_updates.cpp | 105 +- src/ui/components/mod_updates.hpp | 26 +- src/ui/components/popup_parent.cpp | 14 +- src/ui/components/popup_parent.hpp | 10 +- src/ui/components/procedural_preview.cpp | 169 ++- src/ui/components/procedural_preview.hpp | 2 +- src/ui/components/progress_dialog.cpp | 188 +-- src/ui/components/progress_dialog.hpp | 48 +- src/ui/components/svg_provider.cpp | 71 +- src/ui/components/svg_provider.hpp | 7 +- src/ui/components/toast.cpp | 176 +-- src/ui/components/toast.hpp | 58 +- src/ui/components/unity_rich_text.cpp | 247 ++-- src/ui/components/unity_rich_text.hpp | 2 +- src/ui/components/view_rail_panel.cpp | 50 +- src/ui/components/view_rail_panel.hpp | 27 +- src/ui/config.cpp | 26 +- src/ui/config.hpp | 18 +- src/ui/context.cpp | 47 +- src/ui/context.hpp | 230 +-- src/ui/mod_selection.cpp | 74 +- src/ui/mod_selection.hpp | 49 +- src/ui/models/impact.cpp | 28 +- src/ui/models/impact.hpp | 2 +- src/ui/models/mod_list_model.cpp | 417 +++--- src/ui/models/mod_list_model.hpp | 123 +- src/ui/models/mod_list_proxy_model.cpp | 274 ++-- src/ui/models/mod_list_proxy_model.hpp | 81 +- src/ui/models/sidebar_model.cpp | 149 +- src/ui/models/sidebar_model.hpp | 45 +- src/ui/navigation.cpp | 40 +- src/ui/navigation.hpp | 17 +- src/ui/panels/about_panel.cpp | 405 +++--- src/ui/panels/about_panel.hpp | 27 +- src/ui/panels/keyboard_shortcuts_dialog.cpp | 56 +- src/ui/panels/keyboard_shortcuts_dialog.hpp | 9 +- src/ui/panels/mod_info_data.cpp | 378 +++-- src/ui/panels/mod_info_data.hpp | 10 +- src/ui/panels/mod_info_panel.cpp | 478 ++++--- src/ui/panels/mod_info_panel.hpp | 84 +- src/ui/panels/mod_list_panel.cpp | 302 ++-- src/ui/panels/mod_list_panel.hpp | 129 +- src/ui/panels/restore_snapshot_dialog.cpp | 301 ++-- src/ui/panels/restore_snapshot_dialog.hpp | 56 +- src/ui/panels/settings_panel.cpp | 294 ++-- src/ui/panels/settings_panel.hpp | 116 +- src/ui/panels/sidebar_panel.cpp | 58 +- src/ui/panels/sidebar_panel.hpp | 42 +- src/ui/panels/startup_impact_data.cpp | 311 +++-- src/ui/panels/startup_impact_data.hpp | 12 +- src/ui/panels/time_analytics_panel.cpp | 84 +- src/ui/panels/time_analytics_panel.hpp | 37 +- src/ui/panels/update_dialog.cpp | 83 +- src/ui/panels/update_dialog.hpp | 7 +- src/ui/panels/upload_report_dialog.cpp | 249 ++-- src/ui/panels/upload_report_dialog.hpp | 27 +- src/ui/plugins/downloads/model.cpp | 291 ++-- src/ui/plugins/downloads/model.hpp | 168 ++- src/ui/plugins/downloads/plugin.cpp | 7 +- src/ui/plugins/downloads/plugin.hpp | 6 +- src/ui/plugins/downloads/view.cpp | 77 +- src/ui/plugins/downloads/view.hpp | 39 +- src/ui/plugins/organizer/dialogs.cpp | 204 +-- src/ui/plugins/organizer/dialogs.hpp | 54 +- src/ui/plugins/organizer/filter_model.cpp | 102 +- src/ui/plugins/organizer/filter_model.hpp | 33 +- src/ui/plugins/organizer/plugin.hpp | 12 +- src/ui/plugins/organizer/tree_model.cpp | 517 ++++--- src/ui/plugins/organizer/tree_model.hpp | 148 +- src/ui/plugins/organizer/view.cpp | 1241 ++++++++++------- src/ui/plugins/organizer/view.hpp | 212 +-- src/ui/plugins/steam_downloader/plugin.cpp | 21 +- src/ui/plugins/steam_downloader/plugin.hpp | 14 +- src/ui/plugins/steam_workshop/bridge_obj.cpp | 161 ++- src/ui/plugins/steam_workshop/bridge_obj.hpp | 27 +- .../steam_workshop/download_queue_model.cpp | 80 +- .../steam_workshop/download_queue_model.hpp | 45 +- .../steam_workshop/download_sidebar.cpp | 42 +- .../steam_workshop/download_sidebar.hpp | 39 +- src/ui/plugins/steam_workshop/plugin.cpp | 359 ++--- src/ui/plugins/steam_workshop/plugin.hpp | 95 +- src/ui/plugins/steam_workshop/ts/bridge.ts | 2 +- src/ui/plugins/steam_workshop/view.cpp | 227 +-- src/ui/plugins/steam_workshop/view.hpp | 57 +- src/ui/plugins/workshop/details.cpp | 103 +- src/ui/plugins/workshop/details.hpp | 10 +- src/ui/plugins/workshop/installed.cpp | 66 +- src/ui/plugins/workshop/installed.hpp | 44 +- src/ui/plugins/workshop/models.cpp | 376 +++-- src/ui/plugins/workshop/models.hpp | 73 +- src/ui/plugins/workshop/plugin.cpp | 34 +- src/ui/plugins/workshop/plugin.hpp | 22 +- src/ui/plugins/workshop/view.cpp | 901 +++++++----- src/ui/plugins/workshop/view.hpp | 365 ++--- src/ui/settings_section.cpp | 29 +- src/ui/settings_section.hpp | 112 +- src/ui/theme/constants.hpp | 3 +- src/ui/theme/palette.cpp | 128 +- src/ui/theme/palette.hpp | 3 +- src/ui/theme/qml_theme.cpp | 43 +- src/ui/theme/qml_theme.hpp | 214 +-- src/ui/ui_prefs.hpp | 13 +- src/ui/views/base.cpp | 19 +- src/ui/views/base.hpp | 18 +- src/ui/views/mods_view.cpp | 53 +- src/ui/views/mods_view.hpp | 39 +- src/ui/views/views.hpp | 4 +- src/ui/window/actions.cpp | 124 +- src/ui/window/actions.hpp | 40 +- src/ui/window/main_window.cpp | 1039 ++++++++------ src/ui/window/main_window.hpp | 174 +-- src/ui/window/menu_bar.cpp | 38 +- src/ui/window/menu_bar.hpp | 10 +- tests/core/checker/test_checker.cpp | 368 ++--- tests/core/checker/test_databases.cpp | 219 +-- tests/core/checker/test_graph.cpp | 538 +++---- tests/core/checker/test_issues.cpp | 458 +++--- tests/core/downloads/test_manager.cpp | 255 ++-- tests/core/downloads/test_steam.cpp | 1089 ++++++++------- tests/core/models/metadata/test_parsing.cpp | 306 ++-- tests/core/models/metadata/test_schemas.cpp | 80 +- .../core/models/metadata/test_structures.cpp | 212 +-- tests/core/organizer/test_db.cpp | 629 +++++---- tests/core/organizer/test_resolve.cpp | 1033 ++++++++------ tests/core/organizer/test_service.cpp | 973 +++++++------ tests/core/organizer/test_util.hpp | 30 +- tests/core/providers/test_factory.cpp | 52 +- tests/core/providers/test_providers.cpp | 377 ++--- tests/core/services/services_support.hpp | 88 +- .../startup_impact_service/test_db.cpp | 472 ++++--- .../startup_impact_service/test_phases.cpp | 108 +- .../test_startup_impact.cpp | 317 +++-- .../core/services/test_activation_service.cpp | 470 ++++--- tests/core/services/test_game_launcher.cpp | 342 ++--- tests/core/services/test_metadata_cache.cpp | 471 ++++--- tests/core/services/test_sort_service.cpp | 250 ++-- tests/core/services/test_update_service.cpp | 257 ++-- tests/core/sort/test_community.cpp | 59 +- tests/core/sort/test_tier_order.cpp | 261 ++-- tests/core/sort/test_tiers.cpp | 195 +-- tests/core/support/test_report.cpp | 530 +++---- tests/core/test_config.cpp | 366 ++--- tests/core/test_context.cpp | 188 +-- tests/core/test_loading.cpp | 118 +- tests/core/test_migrator.cpp | 396 +++--- tests/core/test_mods_config.cpp | 545 ++++---- tests/core/test_plugin.cpp | 173 +-- tests/core/test_plugin_config.cpp | 73 +- tests/core/test_support.hpp | 61 +- tests/core/test_utils.cpp | 71 +- tests/core/test_version.cpp | 25 +- tests/core/test_xml.cpp | 133 +- tests/core/workshop/support.hpp | 259 ++-- tests/core/workshop/test_catalog.cpp | 824 ++++++----- tests/core/workshop/test_client.cpp | 731 +++++----- tests/core/workshop/test_favourites.cpp | 97 +- tests/core/workshop/test_types.cpp | 136 +- tests/ui/main.cpp | 14 +- tests/ui/test_dialog_chrome.cpp | 42 +- tests/ui/test_downloads_model.cpp | 78 +- tests/ui/test_main_window.cpp | 291 ++-- tests/ui/test_menu_bar.cpp | 103 +- tests/ui/test_mod_info_data.cpp | 479 ++++--- tests/ui/test_mod_list_model.cpp | 230 +-- tests/ui/test_mod_list_panel.cpp | 347 +++-- tests/ui/test_mod_list_proxy_model.cpp | 259 ++-- tests/ui/test_mod_tree_model.cpp | 535 +++---- tests/ui/test_mod_updates.cpp | 42 +- tests/ui/test_navigation.cpp | 32 +- tests/ui/test_organizer_dialogs.cpp | 24 +- tests/ui/test_organizer_rule_editor.cpp | 668 +++++---- tests/ui/test_procedural_preview.cpp | 98 +- tests/ui/test_restore_snapshot_dialog.cpp | 460 +++--- tests/ui/test_settings_panel.cpp | 94 +- tests/ui/test_sidebar_model.cpp | 65 +- tests/ui/test_startup_impact_data.cpp | 181 +-- tests/ui/test_support.hpp | 17 +- tests/ui/test_toast_and_progress.cpp | 97 +- tests/ui/test_unity_rich_text.cpp | 142 +- tests/ui/test_views.cpp | 50 +- tests/ui/test_workshop_images.cpp | 251 ++-- tests/ui/test_workshop_installed.cpp | 146 +- tests/ui/test_workshop_models.cpp | 429 +++--- tests/ui/test_workshop_view.cpp | 784 ++++++----- 330 files changed, 33102 insertions(+), 27827 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index d254cc5..54cb825 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -2,6 +2,7 @@ # Naming only. The formatter handles layout; this check enforces (and, with --fix, applies) # the identifier style. Checks: '-*,readability-identifier-naming' +WarningsAsErrors: 'readability-identifier-naming' HeaderFilterRegex: '.*/(src|tests)/.*' CheckOptions: readability-identifier-naming.NamespaceCase: lower_case diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da46daa..bb824cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,18 @@ jobs: -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" \ "${extra[@]}" + - name: Install clang tools (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + run: sudo apt-get install -y clang-format clang-tidy + + - name: Check formatting (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + run: just format-check + + - name: Lint (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + run: just lint + - name: Build run: cmake --build --preset default diff --git a/AGENTS.md b/AGENTS.md index 909e035..af9a6a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ ## Workflow 1. `just format` — clang-format the C++ tree (fastest cycle) -2. `just check` — `format-check` → `build` → `build-js` +2. `just check` — `format-check` → `lint` → `build` (`build` runs `build-js` first) 3. `just test` — build, then run the full ctest suite offscreen 4. `just ci` — `check` then `test` 5. Always make commits on the go — atomic, conventional commits as milestones are reached; never leave uncommitted work. -CI runs configure, build and test on every OS (see CI matrix); it does not run `format-check`, so run `just check` before pushing. +CI runs configure, build and test on every OS (see CI matrix). Only the Linux SteamWorkshop job also runs `just format-check` and `just lint`, so run `just check` before pushing. ## Environment - Task runner: **just** (`just` lists the recipes). Node.js 22 for `build-js` (`npx esbuild`). @@ -22,7 +22,7 @@ CI runs configure, build and test on every OS (see CI matrix); it does not run ` ## Entrypoint - `just run` (sets `PX_LOG_LEVEL=debug`), or `build/default/src/app/pxmodrim`. - Chain: `src/app/main.cpp` `main()` → `runApp()`, the composition root: - config services → `CoreContext::create` → `px::app::registerCorePlugins` → `px::ui::AppContext` → `px::app::registerUiPlugins` → `setupAll()` → `MainWindow` → `refreshMods()` → `initAll()`. + config services → `CoreContext::create` → `px::modrim::registerCorePlugins` → `px::modrim::AppContext` → `px::modrim::registerUiPlugins` → `setupAll()` → `MainWindow` → `refreshMods()` → `initAll()`. - `src/app/plugins.cpp` is the only list of concrete plugins. ## Module layout @@ -42,38 +42,48 @@ legacy/ read-only Python reference of the previous implementation **Core must never know about UI.** `pxcore` links only non-GUI Qt modules (Core, Concurrent, Network, Sql) and must not include `ui/` headers. Check both with `grep -rn '#include "ui/' src/core tests/core` and `cmake --preset default --graphviz=build/graph.dot` (the target graph must show no `pxcore` → `Qt6::Gui`/`Quick`/`Widgets` edge). Plugins must not know about layers; layers must not know about plugins. ## Download plugins -Downloads are plugins; the UI only talks to `core/downloads/manager.h` (`DownloadManager`, always present, plugin name `downloads`, `kDownloadsManagerName`). -- Sources subclass `px::core::Downloader` (`core/downloads/downloader.h`). `SteamDownloader` (`core/downloads/steam.h`, plugin name `steam_downloader`) is the only one today. The constructor hands the source to the manager, and `setup()` registers it. Third-party includes stay inside the source's own `.cpp`, behind `PX_HAS_STEAM`. `registerCorePlugins` (`src/app/plugins.cpp`) adds it only when it is not disabled. -- Catalog: `WorkshopCatalog` (`core/workshop/catalog.h`, plugin `workshop_catalog`, depends on `downloads`) consumes the read-only catalog API through `core/workshop/client.h` with an injected configuration and client. It resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `CatalogSettings::url` (legacy `config.json` key `workshop_catalog_url`) defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted settings fields load from older files without a schema migration. +Downloads are plugins; the UI only talks to `core/downloads/manager.hpp` (`DownloadManager`, always present, plugin name `downloads`, `kDownloadsManagerName`). +- Sources subclass `px::modrim::Downloader` (`core/downloads/downloader.hpp`). `SteamDownloader` (`core/downloads/steam.hpp`, plugin name `steam_downloader`) is the only one today. The constructor hands the source to the manager, and `setup()` registers it. Third-party includes stay inside the source's own `.cpp`, behind `PX_HAS_STEAM`. `registerCorePlugins` (`src/app/plugins.cpp`) adds it only when it is not disabled. +- Catalog: `WorkshopCatalog` (`core/workshop/catalog.hpp`, plugin `workshop_catalog`, depends on `downloads`) consumes the read-only catalog API through `core/workshop/client.hpp` with an injected configuration and client. It resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `CatalogSettings::url` (legacy `config.json` key `workshop_catalog_url`) defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted settings fields load from older files without a schema migration. - UI plugins: `DownloadsUiPlugin` (`ui/plugins/downloads/`, Downloads tab, source-agnostic, shown when any downloader exists), `SteamWorkshopUiPlugin` (`ui/plugins/steam_workshop/`, Steam WebView, needs `steam_downloader`), `workshop::WorkshopUiPlugin` (`ui/plugins/workshop/`, native QML Workshop browser, needs `workshop_catalog` and `downloads`). - Disable at runtime: `PX_DISABLED_PLUGINS=steam_downloader,steamworkshop,downloads_ui,workshop_catalog,workshop_ui` (comma-separated). Disabling the catalog also omits its UI. -- Installed catalog state uses discovered mods' `published_file_id` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. +- Installed catalog state uses discovered mods' `publishedFileId()` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. - Catalog requests batch installed metadata by 100 IDs (`kIdsPerBatch`) and resolution by 50 mod roots / 10 collection roots (`kModIdsPerResolve`, `kCollectionIdsPerResolve`); large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. - New source: subclass `Downloader`, register it in `registerCorePlugins` (`src/app/plugins.cpp`), and add its name to the `downloaders` list; the UI needs no change. ## Plugin API Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `src/app/` does. -- Base classes: `px::core::Plugin` (`BasicPlugin`) and `px::ui::UiPlugin` (`BasicPlugin`), from `core/plugin.h` and `ui/context.h`. Override `setup(Ctx&)` (synchronous, dependency order), `QCoro::Task init(Ctx&)` (awaited right after setup) and `shutdown()` (reverse order). Dependencies are passed to the constructor; the registry rejects unknown ones. `registerPlugin` with an existing name replaces the plugin in place. -- Settings: `core::PluginConfig(&ctx.configService(), "name", {{"old_config_key", "field"}})` in `setup()` → `plugins/.json`. Read `value()`; write with `update(T)`; subscribe to `changed`. The legacy map seeds the file the first time it is created. -- Settings UI: a UI plugin calls `app.addSettingsSection(factory)` (one Settings tab each). Subclass `ui::PluginConfigSection` for a typed settings struct, or `PluginConfigSectionBase` (`ui/settings_section.h`) with `title()` and `source()`. The QML root has `required property QtObject section`, reads `section.initial` and calls `section.set(key, value)`. Edits apply on Save only. +- Base classes: `px::modrim::Plugin` (`BasicPlugin`) and `px::modrim::UiPlugin` (`BasicPlugin`), from `core/plugin.hpp` and `ui/context.hpp`. Override `setup(Ctx&)` (synchronous, dependency order), `QCoro::Task init(Ctx&)` (awaited right after setup) and `shutdown()` (reverse order). Dependencies are passed to the constructor; the registry rejects unknown ones. `registerPlugin` with an existing name replaces the plugin in place. +- Settings: `PluginConfig(&ctx.configService(), "name", {{"old_config_key", "field"}})` in `setup()` → `plugins/.json`. Read `value()`; write with `update(T)`; subscribe to `changed`. The legacy map seeds the file the first time it is created. +- Settings UI: a UI plugin calls `app.addSettingsSection(factory)` (one Settings tab each). Subclass `PluginConfigSection` for a typed settings struct, or `PluginConfigSectionBase` (`ui/settings_section.hpp`) with `title()` and `source()`. The QML root has `required property QtObject section`, reads `section.initial` and calls `section.set(key, value)`. Edits apply on Save only. - Views: `RailView` subclasses declare `static constexpr RailViewMeta kRailMeta{viewId, iconName, label}` (optionally `listsDownloads = true` for the download queue) and are registered with `app.addRailView()` in `registerUiPlugins`, before the plugins that read the rail. `openRoute(const QStringList&)` handles `modrim:///…`. - `SteamDownloaderUiPlugin` (`ui/plugins/steam_downloader/`) holds the Steam download settings section. ## Build variants - `PX_WORKSHOP_VARIANT` (CMake cache, `NoWorkshop | SteamWorkshop | NativeWorkshop`, default `SteamWorkshop`): `cmake --preset default -DPX_WORKSHOP_VARIANT=NativeWorkshop`. -- Every build writes `workshop-variant.txt` beside the executable (build tree via a POST_BUILD copy; install via `install(FILES)`). `px::app::parseDisabledPlugins` (`src/app/plugins.cpp`) turns it into disabled plugins, on top of `PX_DISABLED_PLUGINS`. A missing file bakes nothing. +- Every build writes `workshop-variant.txt` beside the executable (build tree via a POST_BUILD copy; install via `install(FILES)`). `px::modrim::parseDisabledPlugins` (`src/app/plugins.cpp`) turns it into disabled plugins, on top of `PX_DISABLED_PLUGINS`. A missing file bakes nothing. - `NoWorkshop` skips PxSteamDL (no `PX_HAS_STEAM`, no Steam downloader), omits the Downloads tab and both browsers, and never looks for WebEngine. - `SteamWorkshop` adds the WebEngine browser and omits the native browser. - `NativeWorkshop` omits the web browser and WebEngine. - Code uses the compile-time gates, not variant names: `PX_HAS_STEAM` (public define of `pxcore` when PxSteamDL is found) and `PX_HAS_WEBENGINE` (public define of `pxui` when `Qt6::WebEngineQuick` is found, which only happens for SteamWorkshop). - CI builds and tests all three variants on every OS; the release workflow packages all three. +## Code style +- All code lives in `px::modrim`, with sub-namespaces such as `px::modrim::workshop` and `px::modrim::providers`. `px::core`, `px::ui` and `px::app` no longer exist. +- Headers are `.hpp` with `#pragma once`; sources are `.cpp`. Namespaces are `lower_case`. +- Names are enforced by `.clang-tidy` (`readability-identifier-naming`): types, enums and concepts `PascalCase`; functions, methods, variables and parameters `camelCase`; private and protected data members `m_camelCase`; public struct fields `camelCase`; constants and enumerators `kPascalCase`; macros `UPPER_CASE`. +- Wire structs (JSON through glaze) keep `snake_case` fields, because those fields are the JSON keys. Wrap them in `// NOLINTBEGIN(readability-identifier-naming)` and `// NOLINTEND(readability-identifier-naming)`. +- Names that a framework fixes keep the spelling it requires: coroutine awaiter methods (`await_ready`, `await_suspend`, `await_resume`), glaze metadata (`glz::meta` with `static constexpr auto value`), and Qt virtuals and macros. +- QML-facing `Q_INVOKABLE` and `Q_PROPERTY` names are camelCase. A QML signal handler is `on` plus the C++ signal name (`void changed();` is handled by `onChanged`), so the two spellings must match. +- `just format` applies `.clang-format` (Google base, 2-space indent, 80 columns, includes grouped as std, Qt, other libraries, project); `just format-check` verifies without rewriting. +- `just lint` runs `run-clang-tidy` over the `src` and `tests` sources in `build/default/compile_commands.json` (configuring first if needed); `just lint-fix` applies the renames. + ## Key conventions -- C++23, `.clang-format` (Google base, 4-space indent, 100 columns). The root sets `-Wall -Wextra -Wpedantic`. +- C++23, `.clang-format` (Google base, 2-space indent, 80 columns; see Code style). The root sets `-Wall -Wextra -Wpedantic`. - No comments unless explaining _why_. _What_ comments allowed for long blocks. - Never global singletons — constructor DI everywhere. - Never orphan Qt objects — always pass a parent. -- Errors: functions return `core::Result` (`std::expected`); use `std::unexpected(core::Error{...})`. +- Errors: functions return `Result` (`std::expected`); use `std::unexpected(Error{...})`. - Logging: spdlog. Log file `logs/pxmodrim.log` under the config directory; `PX_LOG_LEVEL` sets the level. - Settings are JSON through glaze (`ConfigService`, `PluginConfig`): `config.json` and `plugins/.json` in the config directory. - Git renames: `git mv`. @@ -84,10 +94,10 @@ Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only - Async functions return `QCoro::Task`. Wait with `co_await`: - network replies: `co_await qCoro(reply).waitForFinished()` - signals: `co_await qCoro(obj, &Class::signal)` - - modal dialogs: `co_await awaitDialog(args...)` (`ui/components/dialogs.h`), never `exec()` + - modal dialogs: `co_await awaitDialog(args...)` (`ui/components/dialogs.hpp`), never `exec()` - delays: `co_await QCoro::sleepFor(duration)` - QML-invokable coroutines return `QCoro::QmlTask` -- Long blocking work runs on a pool (`px::core::providers::runOnPool` or `QtConcurrent::run`), and the coroutine `co_await`s the future. +- Long blocking work runs on a pool (`px::modrim::providers::runOnPool` or `QtConcurrent::run`), and the coroutine `co_await`s the future. - Never `QApplication::processEvents()`, `QDialog::exec()`, `QThread`, `sleep`/`sleep_for`, or `QTimer::singleShot(0, …)`. Wait for the event that matters instead of spinning or deferring by zero. - `QCoro::waitFor` belongs only at the top of `main()`. - GCC 16 ICE (`gimple-low.cc`, `lower_stmt`) was hit by a coroutine with `co_await` in both arms of a `?:`; write it as `if`/`else` assigning a local. @@ -112,7 +122,7 @@ npx --yes esbuild ts/main.ts --bundle --format=iife --outfile=inject.js --target ## QML / SVG quirks - QML files sit directly in `src/ui/` (the module root, URI `PxModRim`). Qt does not reliably register types from subfolders, and plain type names resolve only within one flat module. - Resource paths: `:/qt/qml/PxModRim/theme/style.qss`, `:/qt/qml/PxModRim/assets/*`, `:/qt/qml/PxModRim/plugins//inject.*`. -- Icons come from `image://icons/?color=`, served by `SvgIconProvider` (`ui/components/svg_provider.h`) on the shared `QQmlEngine`. +- Icons come from `image://icons/?color=`, served by `SvgIconProvider` (`ui/components/svg_provider.hpp`) on the shared `QQmlEngine`. - **Color URL encoding**: QML must `encodeURIComponent(color)`. The provider decodes with `QUrl::fromPercentEncoding`. - `main.cpp` keeps `Q_IMPORT_QML_PLUGIN(PxModRimPlugin)` and sets `QT_QUICK_CONTROLS_STYLE=Basic` by default. @@ -127,6 +137,7 @@ C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): ## CI matrix - `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.11.0 (`jurplel/install-qt-action`, WebEngine modules only for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. - Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md`. +- The Linux SteamWorkshop job alone runs `just format-check` and `just lint` after configure, installing `clang-format` and `clang-tidy` from apt first. - Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. ## Packaging diff --git a/justfile b/justfile index 3c978eb..916afba 100644 --- a/justfile +++ b/justfile @@ -31,7 +31,15 @@ format: format-check: find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format --dry-run --Werror -check: format-check build build-js +lint: + [ -f build/default/compile_commands.json ] || cmake --preset default + run-clang-tidy -p build/default -quiet -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + +lint-fix: + [ -f build/default/compile_commands.json ] || cmake --preset default + run-clang-tidy -p build/default -quiet -fix -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + +check: format-check lint build ci: check test diff --git a/src/app/main.cpp b/src/app/main.cpp index 08486f1..c9acc92 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,18 +1,19 @@ -#include -#include -#include -#include - #include #include -#include -#include #include #include #include #include #include +#include +#include +#include +#include + +#include +#include #include + #include #include #include @@ -36,186 +37,212 @@ constexpr int kWheelScrollLines = 8; constexpr qint64 kLogMaxBytes = 10 * 1024 * 1024; constexpr std::size_t kLogRetention = 5; -// Qt6 defaults to PassThrough, which makes QtWebEngine render at integer buffer scale -// while the compositor upscales fractional scales, which blurs. RoundPreferFloor avoids -// the blur at the cost of slightly smaller UI (the QTBUG-113574 workaround). +// Qt6 defaults to PassThrough, which makes QtWebEngine render at integer buffer +// scale while the compositor upscales fractional scales, which blurs. +// RoundPreferFloor avoids the blur at the cost of slightly smaller UI (the +// QTBUG-113574 workaround). void setEnvDefaults() { - const std::pair defaults[] = { - {"QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor"}, - {"QT_ENABLE_HIGHDPI_SCALING", "1"}, - {"QT_QUICK_CONTROLS_STYLE", "Basic"}, - }; - for (const auto& [name, value] : defaults) { - if (!qEnvironmentVariableIsSet(name)) { - qputenv(name, value); - } + const std::pair defaults[] = { + {"QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor"}, + {"QT_ENABLE_HIGHDPI_SCALING", "1"}, + {"QT_QUICK_CONTROLS_STYLE", "Basic"}, + }; + for (const auto& [name, value] : defaults) { + if (!qEnvironmentVariableIsSet(name)) { + qputenv(name, value); } + } } -// PX_LOG_LEVEL takes precedence over LOGURU_LEVEL (the legacy name). Unknown values -// and loguru's SUCCESS level fall back to info. +// PX_LOG_LEVEL takes precedence over LOGURU_LEVEL (the legacy name). Unknown +// values and loguru's SUCCESS level fall back to info. spdlog::level::level_enum logLevelFromEnv() { - for (const char* name : {"PX_LOG_LEVEL", "LOGURU_LEVEL"}) { - const std::string value = qEnvironmentVariable(name).trimmed().toLower().toStdString(); - if (value.empty()) { - continue; - } - const spdlog::level::level_enum level = spdlog::level::from_str(value); - if (level != spdlog::level::off || value == "off") { - return level; - } + for (const char* name : {"PX_LOG_LEVEL", "LOGURU_LEVEL"}) { + const std::string value = + qEnvironmentVariable(name).trimmed().toLower().toStdString(); + if (value.empty()) { + continue; } - return spdlog::level::info; + const spdlog::level::level_enum level = spdlog::level::from_str(value); + if (level != spdlog::level::off || value == "off") { + return level; + } + } + return spdlog::level::info; } -// Logs to stderr, and to a rotating file under the config directory when it can be opened. +// Logs to stderr, and to a rotating file under the config directory when it can +// be opened. void configureLogging(const QString& logsDir) { - std::vector sinks{std::make_shared()}; - auto logger = std::make_shared("pxmodrim", sinks.begin(), sinks.end()); - logger->set_pattern("%Y-%m-%d %H:%M:%S.%e | %^%-8l%$ | %v"); - logger->set_level(logLevelFromEnv()); - spdlog::set_default_logger(logger); - - QDir().mkpath(logsDir); - const std::string logFile = QDir(logsDir).filePath(QStringLiteral("pxmodrim.log")).toStdString(); - try { - // spdlog reports an unopenable file by throwing; that is the only way it reports it. - logger->sinks().push_back(std::make_shared( + std::vector sinks{ + std::make_shared()}; + auto logger = + std::make_shared("pxmodrim", sinks.begin(), sinks.end()); + logger->set_pattern("%Y-%m-%d %H:%M:%S.%e | %^%-8l%$ | %v"); + logger->set_level(logLevelFromEnv()); + spdlog::set_default_logger(logger); + + QDir().mkpath(logsDir); + const std::string logFile = + QDir(logsDir).filePath(QStringLiteral("pxmodrim.log")).toStdString(); + try { + // spdlog reports an unopenable file by throwing; that is the only way it + // reports it. + logger->sinks().push_back( + std::make_shared( logFile, kLogMaxBytes, kLogRetention)); - } catch (const spdlog::spdlog_ex& ex) { - spdlog::warn("Logging to {} is disabled: {}", logFile, ex.what()); - } + } catch (const spdlog::spdlog_ex& ex) { + spdlog::warn("Logging to {} is disabled: {}", logFile, ex.what()); + } } int smokeExitMs() { - bool ok = false; - const int ms = qEnvironmentVariableIntValue("PX_SMOKE_EXIT_MS", &ok); - return ok ? ms : 0; + bool ok = false; + const int ms = qEnvironmentVariableIntValue("PX_SMOKE_EXIT_MS", &ok); + return ok ? ms : 0; } // Shuts UI plugins down, then the core context. Used once the app has started. -QCoro::Task teardown(px::modrim::AppContext& appContext, px::modrim::CoreContext& ctx) { - if (auto shut = co_await appContext.shutdownAll(); !shut) { - spdlog::error("Shutdown failed: {}", shut.error().message.toStdString()); - } - co_await ctx.close(); +QCoro::Task teardown(px::modrim::AppContext& appContext, + px::modrim::CoreContext& ctx) { + if (auto shut = co_await appContext.shutdownAll(); !shut) { + spdlog::error("Shutdown failed: {}", shut.error().message.toStdString()); + } + co_await ctx.close(); } -// Startup, then waiting for the application to quit. Eager: it runs until the first -// suspension, which is the quit wait, so main can tell an early failure from a -// running app (see main()). +// Startup, then waiting for the application to quit. Eager: it runs until the +// first suspension, which is the quit wait, so main can tell an early failure +// from a running app (see main()). QCoro::Task runApp(QApplication& qtApp, QSet disabled) { - spdlog::info("Starting PxModRim"); - - px::modrim::ConfigService configService(px::modrim::configDir()); - px::modrim::AppConfigService appConfigService(configService); - px::modrim::UiPrefsService uiPrefsService(configService); - - if (auto setup = co_await appConfigService.setup(); !setup) { - spdlog::error("Failed to initialize configuration: {}", setup.error().message.toStdString()); - co_return 1; + spdlog::info("Starting PxModRim"); + + px::modrim::ConfigService configService(px::modrim::configDir()); + px::modrim::AppConfigService appConfigService(configService); + px::modrim::UiPrefsService uiPrefsService(configService); + + if (auto setup = co_await appConfigService.setup(); !setup) { + spdlog::error("Failed to initialize configuration: {}", + setup.error().message.toStdString()); + co_return 1; + } + if (auto setup = co_await uiPrefsService.setup(); !setup) { + spdlog::error("Failed to initialize UI preferences: {}", + setup.error().message.toStdString()); + co_return 1; + } + + px::modrim::AppConfig cfg = appConfigService.cfg(); + spdlog::info("Configuration loaded: game={}, local={}, workshop={}", + cfg.paths.game, cfg.paths.local, cfg.paths.workshop); + + if (cfg.paths.game.empty()) { + spdlog::info("No game path in config, attempting auto-detect"); + if (auto detected = px::modrim::detectGamePaths(); !detected.game.empty()) { + cfg.paths = detected; + if (auto saved = configService.save(QStringLiteral("config.json"), cfg); + !saved) { + spdlog::warn("Cannot save detected game paths: {}", + saved.error().message.toStdString()); + } } - if (auto setup = co_await uiPrefsService.setup(); !setup) { - spdlog::error("Failed to initialize UI preferences: {}", setup.error().message.toStdString()); - co_return 1; + } + + // Declared before the context, so it outlives the organizer service that + // holds it. + px::modrim::organizer::OrganizerDb organizerDb( + px::modrim::organizer::dbPath(configService.directory())); + auto ctx = px::modrim::CoreContext::create(cfg, &configService); + const QStringList downloaders = px::modrim::registerCorePlugins( + *ctx, configService, organizerDb, disabled); + + px::modrim::AppContext appContext(*ctx, uiPrefsService.prefs()); + px::modrim::PluginSelection selection{std::move(disabled), downloaders}; + px::modrim::registerUiPlugins(appContext, selection); + + if (auto setup = appContext.setupAll(); !setup) { + spdlog::error("Plugin setup failed: {}", + setup.error().message.toStdString()); + ctx->closeSync(); + co_return 1; + } + spdlog::info("Services initialized for game version {}", + ctx->gameVersion().toStdString()); + + // The window opens before mods load, as in the legacy app. + auto window = std::make_unique(appContext); + window->setAppQuitCallback([] { QCoreApplication::quit(); }); + window->show(); + + if (ctx->config().paths.game.empty()) { + spdlog::warn( + "No game path configured; mods are not loaded until one is set"); + } else { + auto refreshed = co_await appContext.refreshMods(); + if (!refreshed) { + spdlog::error("Mod load failed: {}", + refreshed.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; } + spdlog::info("Initial mod load complete: {} mods", *refreshed); - px::modrim::AppConfig cfg = appConfigService.cfg(); - spdlog::info("Configuration loaded: game={}, local={}, workshop={}", cfg.paths.game, cfg.paths.local, - cfg.paths.workshop); - - if (cfg.paths.game.empty()) { - spdlog::info("No game path in config, attempting auto-detect"); - if (auto detected = px::modrim::detectGamePaths(); !detected.game.empty()) { - cfg.paths = detected; - if (auto saved = configService.save(QStringLiteral("config.json"), cfg); !saved) { - spdlog::warn("Cannot save detected game paths: {}", saved.error().message.toStdString()); - } - } + if (auto init = co_await appContext.initAll(); !init) { + spdlog::error("Plugin init failed: {}", + init.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; } + window->startStartupUpdateCheck(); + } - // Declared before the context, so it outlives the organizer service that holds it. - px::modrim::organizer::OrganizerDb organizerDb(px::modrim::organizer::dbPath(configService.directory())); - auto ctx = px::modrim::CoreContext::create(cfg, &configService); - const QStringList downloaders = px::modrim::registerCorePlugins(*ctx, configService, organizerDb, disabled); - - px::modrim::AppContext appContext(*ctx, uiPrefsService.prefs()); - px::modrim::PluginSelection selection{std::move(disabled), downloaders}; - px::modrim::registerUiPlugins(appContext, selection); - - if (auto setup = appContext.setupAll(); !setup) { - spdlog::error("Plugin setup failed: {}", setup.error().message.toStdString()); - ctx->closeSync(); - co_return 1; - } - spdlog::info("Services initialized for game version {}", ctx->gameVersion().toStdString()); - - // The window opens before mods load, as in the legacy app. - auto window = std::make_unique(appContext); - window->setAppQuitCallback([] { QCoreApplication::quit(); }); - window->show(); - - if (ctx->config().paths.game.empty()) { - spdlog::warn("No game path configured; mods are not loaded until one is set"); - } else { - auto refreshed = co_await appContext.refreshMods(); - if (!refreshed) { - spdlog::error("Mod load failed: {}", refreshed.error().message.toStdString()); - co_await teardown(appContext, *ctx); - co_return 1; - } - spdlog::info("Initial mod load complete: {} mods", *refreshed); - - if (auto init = co_await appContext.initAll(); !init) { - spdlog::error("Plugin init failed: {}", init.error().message.toStdString()); - co_await teardown(appContext, *ctx); - co_return 1; - } - window->startStartupUpdateCheck(); - } - - // Runs until the application quits (window close, or PX_SMOKE_EXIT_MS). - co_await qCoro(&qtApp, &QCoreApplication::aboutToQuit); - co_await teardown(appContext, *ctx); - co_return 0; + // Runs until the application quits (window close, or PX_SMOKE_EXIT_MS). + co_await qCoro(&qtApp, &QCoreApplication::aboutToQuit); + co_await teardown(appContext, *ctx); + co_return 0; } } // namespace int main(int argc, char* argv[]) { - setEnvDefaults(); - - QApplication qtApp(argc, argv); - qtApp.setApplicationName(QStringLiteral("PxModRim")); - qtApp.setApplicationVersion(px::modrim::appVersion()); - qtApp.setOrganizationName(QStringLiteral("PxModRim")); - qtApp.setApplicationDisplayName(QStringLiteral("PxModRim")); - qtApp.setDesktopFileName(QStringLiteral("pxmodrim")); - qtApp.setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); - // The app quits when its run finishes, not when the last window closes. - qtApp.setQuitOnLastWindowClosed(false); - qtApp.styleHints()->setWheelScrollLines(kWheelScrollLines); - px::modrim::applyAppTheme(qtApp); - qtApp.installEventFilter(new px::modrim::QuickPopupParenting(&qtApp)); - - configureLogging(QDir(px::modrim::configDir()).filePath(QStringLiteral("logs"))); - - if (const int smokeMs = smokeExitMs(); smokeMs > 0) { - QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); - } - - const QString manifest = - QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("workshop-variant.txt")); - auto disabled = px::modrim::parseDisabledPlugins(qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); - if (!disabled) { - spdlog::critical("{}", disabled.error().message.toStdString()); - return 1; - } - - auto run = runApp(qtApp, std::move(*disabled)); - // An early failure has already finished the run; otherwise run the event loop until quit. - if (!run.isReady()) { - qtApp.exec(); - } - return QCoro::waitFor(std::move(run)); + setEnvDefaults(); + + QApplication qtApp(argc, argv); + qtApp.setApplicationName(QStringLiteral("PxModRim")); + qtApp.setApplicationVersion(px::modrim::appVersion()); + qtApp.setOrganizationName(QStringLiteral("PxModRim")); + qtApp.setApplicationDisplayName(QStringLiteral("PxModRim")); + qtApp.setDesktopFileName(QStringLiteral("pxmodrim")); + qtApp.setWindowIcon( + QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + // The app quits when its run finishes, not when the last window closes. + qtApp.setQuitOnLastWindowClosed(false); + qtApp.styleHints()->setWheelScrollLines(kWheelScrollLines); + px::modrim::applyAppTheme(qtApp); + qtApp.installEventFilter(new px::modrim::QuickPopupParenting(&qtApp)); + + configureLogging( + QDir(px::modrim::configDir()).filePath(QStringLiteral("logs"))); + + if (const int smokeMs = smokeExitMs(); smokeMs > 0) { + QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); + } + + const QString manifest = + QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("workshop-variant.txt")); + auto disabled = px::modrim::parseDisabledPlugins( + qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); + if (!disabled) { + spdlog::critical("{}", disabled.error().message.toStdString()); + return 1; + } + + auto run = runApp(qtApp, std::move(*disabled)); + // An early failure has already finished the run; otherwise run the event loop + // until quit. + if (!run.isReady()) { + qtApp.exec(); + } + return QCoro::waitFor(std::move(run)); } diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp index 05eda45..b1cb09c 100644 --- a/src/app/plugins.cpp +++ b/src/app/plugins.cpp @@ -1,10 +1,10 @@ #include "app/plugins.hpp" -#include - #include #include #include +#include + #include #include "core/config.hpp" @@ -13,10 +13,10 @@ #include "core/organizer/service.hpp" #include "core/workshop/catalog.hpp" #include "ui/plugins/downloads/plugin.hpp" +#include "ui/plugins/organizer/plugin.hpp" #include "ui/plugins/steam_downloader/plugin.hpp" #include "ui/plugins/steam_workshop/plugin.hpp" #include "ui/plugins/workshop/plugin.hpp" -#include "ui/plugins/organizer/plugin.hpp" #include "ui/views/mods_view.hpp" #ifdef PX_HAS_STEAM @@ -25,81 +25,103 @@ namespace px::modrim { -std::expected, px::modrim::Error> parseDisabledPlugins(const QString& envValue, - const QString& manifestPath) { - QSet disabled; - for (const QString& part : envValue.split(QLatin1Char(','))) { - const QString name = part.trimmed(); - if (!name.isEmpty()) { - disabled.insert(name); - } +std::expected, px::modrim::Error> parseDisabledPlugins( + const QString& envValue, const QString& manifestPath) { + QSet disabled; + for (const QString& part : envValue.split(QLatin1Char(','))) { + const QString name = part.trimmed(); + if (!name.isEmpty()) { + disabled.insert(name); } + } - if (!QFileInfo(manifestPath).isFile()) { - return disabled; - } - QFile manifest(manifestPath); - if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { - return std::unexpected(px::modrim::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); - } - - const QString variant = QString::fromUtf8(manifest.readAll()).trimmed(); - if (variant == QLatin1StringView("NoWorkshop")) { - disabled.unite(QSet{QStringLiteral("steam_downloader"), QStringLiteral("steamworkshop"), - QStringLiteral("downloads_ui"), QStringLiteral("workshop_catalog"), - QStringLiteral("workshop_ui")}); - } else if (variant == QLatin1StringView("NativeWorkshop")) { - disabled.insert(QStringLiteral("steamworkshop")); - } else if (variant == QLatin1StringView("SteamWorkshop")) { - disabled.unite(QSet{QStringLiteral("workshop_catalog"), QStringLiteral("workshop_ui")}); - } else { - return std::unexpected(px::modrim::Error{QStringLiteral("Unknown workshop variant: %1").arg(variant)}); - } + if (!QFileInfo(manifestPath).isFile()) { return disabled; + } + QFile manifest(manifestPath); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + return std::unexpected( + px::modrim::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); + } + + const QString variant = QString::fromUtf8(manifest.readAll()).trimmed(); + if (variant == QLatin1StringView("NoWorkshop")) { + disabled.unite(QSet{ + QStringLiteral("steam_downloader"), QStringLiteral("steamworkshop"), + QStringLiteral("downloads_ui"), QStringLiteral("workshop_catalog"), + QStringLiteral("workshop_ui")}); + } else if (variant == QLatin1StringView("NativeWorkshop")) { + disabled.insert(QStringLiteral("steamworkshop")); + } else if (variant == QLatin1StringView("SteamWorkshop")) { + disabled.unite(QSet{QStringLiteral("workshop_catalog"), + QStringLiteral("workshop_ui")}); + } else { + return std::unexpected(px::modrim::Error{ + QStringLiteral("Unknown workshop variant: %1").arg(variant)}); + } + return disabled; } -QStringList registerCorePlugins(px::modrim::CoreContext& ctx, px::modrim::ConfigService& config, - px::modrim::organizer::OrganizerDb& organizerDb, const QSet& disabled) { - ctx.registerPlugin(std::make_unique(ctx, organizerDb)); +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, + px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, + const QSet& disabled) { + ctx.registerPlugin(std::make_unique( + ctx, organizerDb)); - QStringList downloaders; - if (!disabled.contains(QStringLiteral("steam_downloader"))) { + QStringList downloaders; + if (!disabled.contains(QStringLiteral("steam_downloader"))) { #ifdef PX_HAS_STEAM - ctx.registerPlugin(std::make_unique(px::modrim::SteamDownloaderDeps{ + ctx.registerPlugin(std::make_unique( + px::modrim::SteamDownloaderDeps{ ctx.downloads(), config, [&ctx] { return QString::fromStdString(ctx.config().paths.local); }, [&ctx] { return ctx.allMods().values(); }, })); - downloaders.append(QStringLiteral("steam")); + downloaders.append(QStringLiteral("steam")); #else - spdlog::info("PxSteamDL is not built in; Steam downloads disabled"); + spdlog::info("PxSteamDL is not built in; Steam downloads disabled"); #endif - } + } - // The catalog is only useful with a downloader to fetch items. - if (!downloaders.isEmpty() && !disabled.contains(QStringLiteral("workshop_catalog"))) { - ctx.registerPlugin(std::make_unique()); - } - return downloaders; + // The catalog is only useful with a downloader to fetch items. + if (!downloaders.isEmpty() && + !disabled.contains(QStringLiteral("workshop_catalog"))) { + ctx.registerPlugin( + std::make_unique()); + } + return downloaders; } // How to add a UI plugin (mirrors the legacy _app.py `_setup`): // - Guard it exactly as _app.py did, e.g. -// if (!selection.disabled.contains(QStringLiteral("workshop_ui")) && selection.downloaders.contains("steam")) +// if (!selection.disabled.contains(QStringLiteral("workshop_ui")) && +// selection.downloaders.contains("steam")) // app.registerPlugin(std::make_unique()); -// - Rail views go through app.addRailView(), and must come before the plugins +// - Rail views go through app.addRailView(), and must come before the +// plugins // that read the rail. // Each port adds one line here; no other file changes. -void registerUiPlugins(px::modrim::AppContext& ctx, const PluginSelection& selection) { - ctx.addRailView(); - ctx.registerPlugin(std::make_unique()); +void registerUiPlugins(px::modrim::AppContext& ctx, + const PluginSelection& selection) { + ctx.addRailView(); + ctx.registerPlugin(std::make_unique()); #ifdef PX_HAS_STEAM - if (selection.downloaders.contains(QStringLiteral("steam"))) ctx.registerPlugin(std::make_unique()); - if (selection.downloaders.contains(QStringLiteral("steam")) && !selection.disabled.contains(QStringLiteral("steamworkshop"))) ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam"))) + ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam")) && + !selection.disabled.contains(QStringLiteral("steamworkshop"))) + ctx.registerPlugin(std::make_unique()); #endif - if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("downloads_ui"))) ctx.registerPlugin(std::make_unique()); - if (!selection.downloaders.isEmpty() && !selection.disabled.contains(QStringLiteral("workshop_catalog")) && !selection.disabled.contains(QStringLiteral("workshop_ui"))) ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && + !selection.disabled.contains(QStringLiteral("downloads_ui"))) + ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && + !selection.disabled.contains(QStringLiteral("workshop_catalog")) && + !selection.disabled.contains(QStringLiteral("workshop_ui"))) + ctx.registerPlugin( + std::make_unique()); } } // namespace px::modrim diff --git a/src/app/plugins.hpp b/src/app/plugins.hpp index 26c5158..fdb775c 100644 --- a/src/app/plugins.hpp +++ b/src/app/plugins.hpp @@ -1,10 +1,9 @@ #pragma once -#include - #include #include #include +#include #include "core/error.hpp" @@ -22,27 +21,31 @@ class AppContext; namespace px::modrim { -// Which plugins this run keeps, and which downloaders came up. Core registration -// produces `downloaders`; UI registration reads both. +// Which plugins this run keeps, and which downloaders came up. Core +// registration produces `downloaders`; UI registration reads both. struct PluginSelection { - QSet disabled; - // Downloader names, e.g. "steam". Empty when no downloader is available. - QStringList downloaders; + QSet disabled; + // Downloader names, e.g. "steam". Empty when no downloader is available. + QStringList downloaders; }; // Names from PX_DISABLED_PLUGINS (comma-separated) plus the plugins the baked -// workshop variant omits. `manifestPath` is the workshop-variant.txt file; a missing -// file bakes nothing. Fails on an unknown variant name. -std::expected, px::modrim::Error> parseDisabledPlugins(const QString& envValue, - const QString& manifestPath); - -// Registers core plugins in legacy _app.py order and returns the downloaders that came -// up. `organizerDb` is not owned and must outlive `ctx`. -QStringList registerCorePlugins(px::modrim::CoreContext& ctx, px::modrim::ConfigService& config, - px::modrim::organizer::OrganizerDb& organizerDb, const QSet& disabled); - -// Registers the UI plugins and rail views. Add one line per plugin here, guarded the -// same way the legacy _app.py guarded it; see plugins.cpp for the pattern. -void registerUiPlugins(px::modrim::AppContext& app, const PluginSelection& selection); +// workshop variant omits. `manifestPath` is the workshop-variant.txt file; a +// missing file bakes nothing. Fails on an unknown variant name. +std::expected, px::modrim::Error> parseDisabledPlugins( + const QString& envValue, const QString& manifestPath); + +// Registers core plugins in legacy _app.py order and returns the downloaders +// that came up. `organizerDb` is not owned and must outlive `ctx`. +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, + px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, + const QSet& disabled); + +// Registers the UI plugins and rail views. Add one line per plugin here, +// guarded the same way the legacy _app.py guarded it; see plugins.cpp for the +// pattern. +void registerUiPlugins(px::modrim::AppContext& app, + const PluginSelection& selection); } // namespace px::modrim diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp index 0951375..cf5694d 100644 --- a/src/core/checker/checker.cpp +++ b/src/core/checker/checker.cpp @@ -9,39 +9,39 @@ namespace px::modrim::checker { namespace { ModDiagnostics compose(const std::vector>& perChecker) { - ModDiagnostics diagnostics; - for (const auto& issues : perChecker) { - for (const ModIssue& issue : issues) { - if (issue.severity == IssueSeverity::kError) { - diagnostics.errors.append(issue); - } else { - diagnostics.warnings.append(issue); - } - } + ModDiagnostics diagnostics; + for (const auto& issues : perChecker) { + for (const ModIssue& issue : issues) { + if (issue.severity == IssueSeverity::kError) { + diagnostics.errors.append(issue); + } else { + diagnostics.warnings.append(issue); + } } - return diagnostics; + } + return diagnostics; } -// Same key set, and every entry is the same mod object (identity, not equality). +// Same key set, and every entry is the same mod object (identity, not +// equality). bool sameMods(const QHash& a, - const QHash& b) { - if (a.size() != b.size()) { - return false; - } - for (auto it = a.cbegin(); it != a.cend(); ++it) { - const auto other = b.constFind(it.key()); - if (other == b.cend() || other.value() != it.value()) { - return false; - } + const QHash& b) { + if (a.size() != b.size()) { + return false; + } + for (auto it = a.cbegin(); it != a.cend(); ++it) { + const auto other = b.constFind(it.key()); + if (other == b.cend() || other.value() != it.value()) { + return false; } - return true; + } + return true; } } // namespace ModChecker::ModChecker(std::vector> checkers, - sort::SortSettings settings, - QString targetVersion, + sort::SortSettings settings, QString targetVersion, DiagnosticsCallback onDiagnosticsChanged) : m_checkers(std::move(checkers)), m_settings(std::move(settings)), @@ -49,242 +49,245 @@ ModChecker::ModChecker(std::vector> checkers, m_onDiagnosticsChanged(std::move(onDiagnosticsChanged)) {} void ModChecker::setNoVersionWarning(QSet pids) { - m_noVersionWarning = std::move(pids); - m_checkerResults.reset(); + m_noVersionWarning = std::move(pids); + m_checkerResults.reset(); } void ModChecker::setUseThisInstead(QHash db) { - m_useThisInstead = std::move(db); - m_checkerResults.reset(); + m_useThisInstead = std::move(db); + m_checkerResults.reset(); } void ModChecker::setCommunityRules(sort::CommunityRules rules) { - m_communityRules = std::move(rules); + m_communityRules = std::move(rules); } void ModChecker::rebuild(const QHash& mods, - const QStringList& orderedUuids, - Profiler* timer) { - Profiler local; - Profiler& t = timer != nullptr ? *timer : local; - - { - auto scope = t("collect_active"); - collectActive(mods, orderedUuids); - } - { - auto scope = t("graph.build"); - m_graph.build(m_activeMods, m_orderedPids, m_settings, &m_communityRules); - } - { - auto scope = t("find_cycles"); - m_cachedCycles = m_graph.findCycles(); - } - const CheckContext ctx = buildContext(m_cachedCycles); - { - auto scope = t("check_mod"); - checkAll(ctx); - } - - emitDiagnostics(); + const QStringList& orderedUuids, Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + { + auto scope = t("collect_active"); + collectActive(mods, orderedUuids); + } + { + auto scope = t("graph.build"); + m_graph.build(m_activeMods, m_orderedPids, m_settings, &m_communityRules); + } + { + auto scope = t("find_cycles"); + m_cachedCycles = m_graph.findCycles(); + } + const CheckContext ctx = buildContext(m_cachedCycles); + { + auto scope = t("check_mod"); + checkAll(ctx); + } + + emitDiagnostics(); } -void ModChecker::toggleMod(const ListedMod& mod, const QStringList& orderedUuids) { - if (dynamic_cast(&mod) == nullptr) { - emitDiagnostics(); - return; - } - rebuild(m_allMods, orderedUuids); +void ModChecker::toggleMod(const ListedMod& mod, + const QStringList& orderedUuids) { + if (dynamic_cast(&mod) == nullptr) { + emitDiagnostics(); + return; + } + rebuild(m_allMods, orderedUuids); } void ModChecker::moveMod(const QString& uuid, int oldIndex, int newIndex) { - if (m_orderedPids.isEmpty()) { - return; - } - const auto uuidIt = m_uuidToPid.constFind(uuid); - if (uuidIt == m_uuidToPid.cend()) { - return; + if (m_orderedPids.isEmpty()) { + return; + } + const auto uuidIt = m_uuidToPid.constFind(uuid); + if (uuidIt == m_uuidToPid.cend()) { + return; + } + const PackageId pid = uuidIt.value(); + + m_orderedPids.insert(newIndex, m_orderedPids.takeAt(oldIndex)); + m_graph.updateOrder(m_orderedPids); + + const CheckContext ctx = buildContext(m_cachedCycles); + // Only neighbours are rechecked, so cached results no longer match the order. + m_checkerResults.reset(); + + QSet affected{pid}; + affected.unite(m_graph.neighbors(pid)); + for (const PackageId& currentPid : affected) { + if (const auto mod = m_activeMods.constFind(currentPid); + mod != m_activeMods.cend()) { + m_diagnostics.insert(currentPid, checkMod(*mod.value(), ctx)); } - const PackageId pid = uuidIt.value(); - - m_orderedPids.insert(newIndex, m_orderedPids.takeAt(oldIndex)); - m_graph.updateOrder(m_orderedPids); + } - const CheckContext ctx = buildContext(m_cachedCycles); - // Only neighbours are rechecked, so cached results no longer match the order. - m_checkerResults.reset(); - - QSet affected{pid}; - affected.unite(m_graph.neighbors(pid)); - for (const PackageId& currentPid : affected) { - if (const auto mod = m_activeMods.constFind(currentPid); mod != m_activeMods.cend()) { - m_diagnostics.insert(currentPid, checkMod(*mod.value(), ctx)); - } - } - - emitDiagnostics(); + emitDiagnostics(); } void ModChecker::reorder(const QStringList& orderedUuids) { - const QHash previous = m_activeMods; - const QHash previousIndex = m_graph.pidToIndex(); - collectActive(m_allMods, orderedUuids); - m_graph.updateOrder(m_orderedPids); - - const QList> cycles = m_graph.findCycles(); - const CheckContext ctx = buildContext(cycles); - - if (!m_checkerResults.has_value() || !sameMods(previous, m_activeMods)) { - checkAll(ctx); - } else { - QSet moved; - for (auto it = ctx.pidToIndex.cbegin(); it != ctx.pidToIndex.cend(); ++it) { - if (previousIndex.value(it.key(), -1) != it.value()) { - moved.insert(it.key()); - } - } + const QHash previous = m_activeMods; + const QHash previousIndex = m_graph.pidToIndex(); + collectActive(m_allMods, orderedUuids); + m_graph.updateOrder(m_orderedPids); + + const QList> cycles = m_graph.findCycles(); + const CheckContext ctx = buildContext(cycles); + + if (!m_checkerResults.has_value() || !sameMods(previous, m_activeMods)) { + checkAll(ctx); + } else { + QSet moved; + for (auto it = ctx.pidToIndex.cbegin(); it != ctx.pidToIndex.cend(); ++it) { + if (previousIndex.value(it.key(), -1) != it.value()) { + moved.insert(it.key()); + } + } - CheckerResults& results = *m_checkerResults; - QSet changed; - for (std::size_t i = 0; i < m_checkers.size(); ++i) { - const ModIssueChecker& checker = *m_checkers[i]; - if (!checker.orderDependent()) { - continue; - } - const auto affected = checker.affectedByMoves(ctx, moved); - for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { - if (affected && !affected->contains(it.key())) { - continue; - } - QList issues = runChecker(checker, *it.value(), ctx); - auto& stored = results[it.key()][i]; - if (issues != stored) { - stored = std::move(issues); - changed.insert(it.key()); - } - } + CheckerResults& results = *m_checkerResults; + QSet changed; + for (std::size_t i = 0; i < m_checkers.size(); ++i) { + const ModIssueChecker& checker = *m_checkers[i]; + if (!checker.orderDependent()) { + continue; + } + const auto affected = checker.affectedByMoves(ctx, moved); + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { + if (affected && !affected->contains(it.key())) { + continue; } - for (const PackageId& pid : changed) { - m_diagnostics.insert(pid, compose(results[pid])); + QList issues = runChecker(checker, *it.value(), ctx); + auto& stored = results[it.key()][i]; + if (issues != stored) { + stored = std::move(issues); + changed.insert(it.key()); } + } + } + for (const PackageId& pid : changed) { + m_diagnostics.insert(pid, compose(results[pid])); } + } - emitDiagnostics(); + emitDiagnostics(); } const ModDiagnostics* ModChecker::diagnosticsFor(const QString& uuid) const { - const auto pid = m_uuidToPid.constFind(uuid); - if (pid == m_uuidToPid.cend()) { - return nullptr; - } - const auto diagnostics = m_diagnostics.constFind(pid.value()); - return diagnostics == m_diagnostics.cend() ? nullptr : &diagnostics.value(); + const auto pid = m_uuidToPid.constFind(uuid); + if (pid == m_uuidToPid.cend()) { + return nullptr; + } + const auto diagnostics = m_diagnostics.constFind(pid.value()); + return diagnostics == m_diagnostics.cend() ? nullptr : &diagnostics.value(); } DiagnosticsByUuid ModChecker::activeModDiagnostics() const { - DiagnosticsByUuid result; - for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { - if (const auto diagnostics = m_diagnostics.constFind(it.value()); - diagnostics != m_diagnostics.cend()) { - result.insert(it.key(), diagnostics.value()); - } + DiagnosticsByUuid result; + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + if (const auto diagnostics = m_diagnostics.constFind(it.value()); + diagnostics != m_diagnostics.cend()) { + result.insert(it.key(), diagnostics.value()); } - return result; + } + return result; } void ModChecker::collectActive(const QHash& mods, - const QStringList& orderedUuids) { - // `mods` may alias all_mods_ (toggle_mod and reorder pass it back in), so copy first. - m_allMods = mods; - m_activeMods.clear(); - m_uuidToPid.clear(); - m_orderedPids.clear(); - - for (const QString& uuid : orderedUuids) { - const auto listed = m_allMods.constFind(uuid); - if (listed == m_allMods.cend()) { - continue; - } - auto about = std::dynamic_pointer_cast(listed.value()); - if (!about) { - continue; - } - const PackageId pid = about->packageId; - m_activeMods.insert(pid, about); - m_uuidToPid.insert(uuid, pid); - m_orderedPids.append(pid); + const QStringList& orderedUuids) { + // `mods` may alias all_mods_ (toggle_mod and reorder pass it back in), so + // copy first. + m_allMods = mods; + m_activeMods.clear(); + m_uuidToPid.clear(); + m_orderedPids.clear(); + + for (const QString& uuid : orderedUuids) { + const auto listed = m_allMods.constFind(uuid); + if (listed == m_allMods.cend()) { + continue; } + auto about = std::dynamic_pointer_cast(listed.value()); + if (!about) { + continue; + } + const PackageId pid = about->packageId; + m_activeMods.insert(pid, about); + m_uuidToPid.insert(uuid, pid); + m_orderedPids.append(pid); + } } -CheckContext ModChecker::buildContext(const QList>& cycles) const { - return CheckContext{ - .activeMods = m_activeMods, - .orderedPids = m_orderedPids, - .pidToIndex = m_graph.pidToIndex(), - .graph = m_graph, - .settings = m_settings, - .targetVersion = m_targetVersion, - .noVersionWarning = m_noVersionWarning, - .useThisInstead = m_useThisInstead, - .cycles = cycles, - }; +CheckContext ModChecker::buildContext( + const QList>& cycles) const { + return CheckContext{ + .activeMods = m_activeMods, + .orderedPids = m_orderedPids, + .pidToIndex = m_graph.pidToIndex(), + .graph = m_graph, + .settings = m_settings, + .targetVersion = m_targetVersion, + .noVersionWarning = m_noVersionWarning, + .useThisInstead = m_useThisInstead, + .cycles = cycles, + }; } void ModChecker::checkAll(const CheckContext& ctx) { - CheckerResults results; - QHash diagnostics; - for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { - std::vector> perChecker; - perChecker.reserve(m_checkers.size()); - for (const auto& checker : m_checkers) { - perChecker.push_back(runChecker(*checker, *it.value(), ctx)); - } - diagnostics.insert(it.key(), compose(perChecker)); - results.insert(it.key(), std::move(perChecker)); - } - m_checkerResults = std::move(results); - m_diagnostics = std::move(diagnostics); -} - -ModDiagnostics ModChecker::checkMod(const AboutXmlMod& mod, const CheckContext& ctx) const { + CheckerResults results; + QHash diagnostics; + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { std::vector> perChecker; perChecker.reserve(m_checkers.size()); for (const auto& checker : m_checkers) { - perChecker.push_back(runChecker(*checker, mod, ctx)); + perChecker.push_back(runChecker(*checker, *it.value(), ctx)); } - return compose(perChecker); + diagnostics.insert(it.key(), compose(perChecker)); + results.insert(it.key(), std::move(perChecker)); + } + m_checkerResults = std::move(results); + m_diagnostics = std::move(diagnostics); +} + +ModDiagnostics ModChecker::checkMod(const AboutXmlMod& mod, + const CheckContext& ctx) const { + std::vector> perChecker; + perChecker.reserve(m_checkers.size()); + for (const auto& checker : m_checkers) { + perChecker.push_back(runChecker(*checker, mod, ctx)); + } + return compose(perChecker); } QList ModChecker::runChecker(const ModIssueChecker& checker, - const AboutXmlMod& mod, - const CheckContext& ctx) const { - if (!checker.shouldCheck(mod, ctx)) { - return {}; - } - // One checker must not abort diagnostics for every other mod and checker. - try { - return checker.check(mod, ctx); - } catch (const std::exception& error) { - spdlog::error("Checker failed on {}: {}", - mod.packageId.str().toStdString(), - error.what()); - return {}; - } + const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (!checker.shouldCheck(mod, ctx)) { + return {}; + } + // One checker must not abort diagnostics for every other mod and checker. + try { + return checker.check(mod, ctx); + } catch (const std::exception& error) { + spdlog::error("Checker failed on {}: {}", mod.packageId.str().toStdString(), + error.what()); + return {}; + } } void ModChecker::emitDiagnostics() const { - if (!m_onDiagnosticsChanged) { - return; - } - DiagnosticsByUuid byUuid; - for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { - const auto diagnostics = m_diagnostics.constFind(it.value()); - if (diagnostics != m_diagnostics.cend() && - (diagnostics->hasErrors() || diagnostics->hasWarnings())) { - byUuid.insert(it.key(), diagnostics.value()); - } + if (!m_onDiagnosticsChanged) { + return; + } + DiagnosticsByUuid byUuid; + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + const auto diagnostics = m_diagnostics.constFind(it.value()); + if (diagnostics != m_diagnostics.cend() && + (diagnostics->hasErrors() || diagnostics->hasWarnings())) { + byUuid.insert(it.key(), diagnostics.value()); } - m_onDiagnosticsChanged(byUuid); + } + m_onDiagnosticsChanged(byUuid); } } // namespace px::modrim::checker diff --git a/src/core/checker/checker.hpp b/src/core/checker/checker.hpp index 09f2a60..c81da74 100644 --- a/src/core/checker/checker.hpp +++ b/src/core/checker/checker.hpp @@ -1,14 +1,13 @@ #pragma once -#include -#include -#include -#include - #include #include #include #include +#include +#include +#include +#include #include "core/checker/graph.hpp" #include "core/checker/issues.hpp" @@ -24,81 +23,87 @@ namespace px::modrim::checker { using DiagnosticsByUuid = QHash; using DiagnosticsCallback = std::function; -// Runs every checker over the active mods and keeps their diagnostics up to date as the mod -// list, load order and databases change. +// Runs every checker over the active mods and keeps their diagnostics up to +// date as the mod list, load order and databases change. class ModChecker { -public: - ModChecker(std::vector> checkers, - sort::SortSettings settings, - QString targetVersion = QStringLiteral("1.5"), - DiagnosticsCallback onDiagnosticsChanged = {}); - - // ── Database config ─────────────────────────────────────── - - void setNoVersionWarning(QSet pids); - void setUseThisInstead(QHash db); - void setCommunityRules(sort::CommunityRules rules); - - // ── Rebuild ─────────────────────────────────────────────── - - // Re-scans all mods and regenerates diagnostics from scratch. - void rebuild(const QHash& mods, - const QStringList& orderedUuids, - Profiler* timer = nullptr); - - // ── Incremental updates ─────────────────────────────────── - - // Rebuilds constraints and diagnostics after toggling `mod`. Non-About.xml mods only emit. - void toggleMod(const ListedMod& mod, const QStringList& orderedUuids); - - // Moves a mod to a new position and rechecks it and its graph neighbours. - void moveMod(const QString& uuid, int oldIndex, int newIndex); - - // Reapplies the full active-mod order and regenerates the affected diagnostics. - void reorder(const QStringList& orderedUuids); - - // ── Query ───────────────────────────────────────────────── - - QHash activeMods() const { return m_activeMods; } - const ConstraintGraph& graph() const noexcept { return m_graph; } - QList orderedPids() const { return m_orderedPids; } - const sort::CommunityRules& communityRules() const noexcept { return m_communityRules; } - - // Null when `uuid` is not an active mod. The pointer is valid until the next mutation. - const ModDiagnostics* diagnosticsFor(const QString& uuid) const; - DiagnosticsByUuid activeModDiagnostics() const; - -private: - using CheckerResults = QHash>>; - - void collectActive(const QHash& mods, const QStringList& orderedUuids); - CheckContext buildContext(const QList>& cycles) const; - void checkAll(const CheckContext& ctx); - ModDiagnostics checkMod(const AboutXmlMod& mod, const CheckContext& ctx) const; - QList runChecker(const ModIssueChecker& checker, - const AboutXmlMod& mod, - const CheckContext& ctx) const; - void emitDiagnostics() const; - - std::vector> m_checkers; - sort::SortSettings m_settings; - QString m_targetVersion; - DiagnosticsCallback m_onDiagnosticsChanged; - - ConstraintGraph m_graph; - QHash m_allMods; - QHash m_diagnostics; - QHash m_activeMods; - QHash m_uuidToPid; - QList m_orderedPids; - - QSet m_noVersionWarning; - QHash m_useThisInstead; - sort::CommunityRules m_communityRules; - QList> m_cachedCycles; - // Per-mod issues from each checker (checker order), reused by reorder() for - // order-independent checkers; nullopt when stale. - std::optional m_checkerResults; + public: + ModChecker(std::vector> checkers, + sort::SortSettings settings, + QString targetVersion = QStringLiteral("1.5"), + DiagnosticsCallback onDiagnosticsChanged = {}); + + // ── Database config ─────────────────────────────────────── + + void setNoVersionWarning(QSet pids); + void setUseThisInstead(QHash db); + void setCommunityRules(sort::CommunityRules rules); + + // ── Rebuild ─────────────────────────────────────────────── + + // Re-scans all mods and regenerates diagnostics from scratch. + void rebuild(const QHash& mods, + const QStringList& orderedUuids, Profiler* timer = nullptr); + + // ── Incremental updates ─────────────────────────────────── + + // Rebuilds constraints and diagnostics after toggling `mod`. Non-About.xml + // mods only emit. + void toggleMod(const ListedMod& mod, const QStringList& orderedUuids); + + // Moves a mod to a new position and rechecks it and its graph neighbours. + void moveMod(const QString& uuid, int oldIndex, int newIndex); + + // Reapplies the full active-mod order and regenerates the affected + // diagnostics. + void reorder(const QStringList& orderedUuids); + + // ── Query ───────────────────────────────────────────────── + + QHash activeMods() const { return m_activeMods; } + const ConstraintGraph& graph() const noexcept { return m_graph; } + QList orderedPids() const { return m_orderedPids; } + const sort::CommunityRules& communityRules() const noexcept { + return m_communityRules; + } + + // Null when `uuid` is not an active mod. The pointer is valid until the next + // mutation. + const ModDiagnostics* diagnosticsFor(const QString& uuid) const; + DiagnosticsByUuid activeModDiagnostics() const; + + private: + using CheckerResults = QHash>>; + + void collectActive(const QHash& mods, + const QStringList& orderedUuids); + CheckContext buildContext(const QList>& cycles) const; + void checkAll(const CheckContext& ctx); + ModDiagnostics checkMod(const AboutXmlMod& mod, + const CheckContext& ctx) const; + QList runChecker(const ModIssueChecker& checker, + const AboutXmlMod& mod, + const CheckContext& ctx) const; + void emitDiagnostics() const; + + std::vector> m_checkers; + sort::SortSettings m_settings; + QString m_targetVersion; + DiagnosticsCallback m_onDiagnosticsChanged; + + ConstraintGraph m_graph; + QHash m_allMods; + QHash m_diagnostics; + QHash m_activeMods; + QHash m_uuidToPid; + QList m_orderedPids; + + QSet m_noVersionWarning; + QHash m_useThisInstead; + sort::CommunityRules m_communityRules; + QList> m_cachedCycles; + // Per-mod issues from each checker (checker order), reused by reorder() for + // order-independent checkers; nullopt when stale. + std::optional m_checkerResults; }; } // namespace px::modrim::checker diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp index ba43e67..e039cd3 100644 --- a/src/core/checker/databases.cpp +++ b/src/core/checker/databases.cpp @@ -1,5 +1,12 @@ #include "core/checker/databases.hpp" +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -7,20 +14,13 @@ #include #include #include - -#include #include +#include #include #include -#include -#include -#include -#include -#include -#include #include -#include + #include #include @@ -32,10 +32,10 @@ namespace px::modrim::checker { namespace { -const QString kNoVersionWarningUrl = - QStringLiteral("https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip"); -const QString kUseThisInsteadUrl = - QStringLiteral("https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip"); +const QString kNoVersionWarningUrl = QStringLiteral( + "https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip"); +const QString kUseThisInsteadUrl = QStringLiteral( + "https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip"); const QString kNoVersionWarningXml = QStringLiteral("ModIdsToFix.xml"); const QString kReplacementsGz = QStringLiteral("replacements.json.gz"); @@ -43,326 +43,341 @@ const QString kReplacementsGz = QStringLiteral("replacements.json.gz"); constexpr int kDownloadTimeoutMs = 30000; std::optional fileStamp(const QString& path) { - const QFileInfo info(path); - if (!info.exists()) { - return std::nullopt; - } - return FileStamp{info.lastModified().toMSecsSinceEpoch(), info.size()}; + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + return FileStamp{info.lastModified().toMSecsSinceEpoch(), info.size()}; } std::optional fileAgeS(const QString& path) { - const QFileInfo info(path); - if (!info.exists()) { - return std::nullopt; - } - const qint64 ageMs = info.lastModified().msecsTo(QDateTime::currentDateTime()); - return std::max(0.0, static_cast(ageMs) / 1000.0); + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + const qint64 ageMs = + info.lastModified().msecsTo(QDateTime::currentDateTime()); + return std::max(0.0, static_cast(ageMs) / 1000.0); } -// Downloads `url`, following redirects. Any transport or HTTP error fails the download. -QCoro::Task> fetchArchive(QNetworkAccessManager& network, const QString& url) { - QNetworkRequest request{QUrl(url)}; - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, - QNetworkRequest::NoLessSafeRedirectPolicy); - request.setTransferTimeout(kDownloadTimeoutMs); - QNetworkReply* reply = network.get(request); - // Released however the coroutine leaves, so a finished reply is never kept around. - struct Release { - QNetworkReply* reply; - ~Release() { reply->deleteLater(); } - } release{reply}; - - co_await qCoro(reply).waitForFinished(); - if (reply->error() != QNetworkReply::NoError) { - co_return std::unexpected(Error{reply->errorString()}); - } - co_return reply->readAll(); +// Downloads `url`, following redirects. Any transport or HTTP error fails the +// download. +QCoro::Task> fetchArchive(QNetworkAccessManager& network, + const QString& url) { + QNetworkRequest request{QUrl(url)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kDownloadTimeoutMs); + QNetworkReply* reply = network.get(request); + // Released however the coroutine leaves, so a finished reply is never kept + // around. + struct Release { + QNetworkReply* reply; + ~Release() { reply->deleteLater(); } + } release{reply}; + + co_await qCoro(reply).waitForFinished(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + co_return reply->readAll(); } Error zipFailure(const char* message) { - return Error{QString::fromUtf8(message)}; + return Error{QString::fromUtf8(message)}; } -// Reads the first archive entry whose file name is `entry_name`, at any depth. Yields nullopt -// when no entry has that name. +// Reads the first archive entry whose file name is `entry_name`, at any depth. +// Yields nullopt when no entry has that name. Result> readZipEntry(const QByteArray& archive, - const QString& entryName) { - zip_error_t error; - zip_error_init(&error); - zip_source_t* source = zip_source_buffer_create(archive.constData(), - static_cast(archive.size()), - 0, - &error); - if (source == nullptr) { - return std::unexpected(Error{QString::fromUtf8(zip_error_strerror(&error))}); - } - zip_t* zip = zip_open_from_source(source, ZIP_RDONLY, &error); - if (zip == nullptr) { - zip_source_free(source); - return std::unexpected(Error{QString::fromUtf8(zip_error_strerror(&error))}); - } - struct Close { - zip_t* zip; - ~Close() { zip_close(zip); } - } close{zip}; - - const zip_int64_t count = zip_get_num_entries(zip, 0); - for (zip_int64_t index = 0; index < count; ++index) { - const auto position = static_cast(index); - const char* name = zip_get_name(zip, position, 0); - if (name == nullptr || - QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entryName) { - continue; - } - - zip_file_t* file = zip_fopen_index(zip, position, 0); - if (file == nullptr) { - return std::unexpected(zipFailure(zip_strerror(zip))); - } - QByteArray bytes; - QByteArray chunk(64 * 1024, Qt::Uninitialized); - zip_int64_t read = 0; - while ((read = zip_fread(file, chunk.data(), static_cast(chunk.size()))) > 0) { - bytes.append(chunk.constData(), static_cast(read)); - } - const bool failed = read < 0; - zip_fclose(file); - if (failed) { - return std::unexpected(zipFailure(zip_strerror(zip))); - } - return std::optional(std::move(bytes)); - } - return std::optional{}; + const QString& entryName) { + zip_error_t error; + zip_error_init(&error); + zip_source_t* source = zip_source_buffer_create( + archive.constData(), static_cast(archive.size()), 0, + &error); + if (source == nullptr) { + return std::unexpected( + Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + zip_t* zip = zip_open_from_source(source, ZIP_RDONLY, &error); + if (zip == nullptr) { + zip_source_free(source); + return std::unexpected( + Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + struct Close { + zip_t* zip; + ~Close() { zip_close(zip); } + } close{zip}; + + const zip_int64_t count = zip_get_num_entries(zip, 0); + for (zip_int64_t index = 0; index < count; ++index) { + const auto position = static_cast(index); + const char* name = zip_get_name(zip, position, 0); + if (name == nullptr || + QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entryName) { + continue; + } + + zip_file_t* file = zip_fopen_index(zip, position, 0); + if (file == nullptr) { + return std::unexpected(zipFailure(zip_strerror(zip))); + } + QByteArray bytes; + QByteArray chunk(64 * 1024, Qt::Uninitialized); + zip_int64_t read = 0; + while ((read = zip_fread(file, chunk.data(), + static_cast(chunk.size()))) > 0) { + bytes.append(chunk.constData(), static_cast(read)); + } + const bool failed = read < 0; + zip_fclose(file); + if (failed) { + return std::unexpected(zipFailure(zip_strerror(zip))); + } + return std::optional(std::move(bytes)); + } + return std::optional{}; } -// Blocking: writes the archive entry `entry_name` to `target`. Yields false when the archive has -// no such entry, leaving `target` untouched. +// Blocking: writes the archive entry `entry_name` to `target`. Yields false +// when the archive has no such entry, leaving `target` untouched. Result storeArchiveEntry(const QByteArray& archive, - const QString& entryName, - const QString& target) { - auto entry = readZipEntry(archive, entryName); - if (!entry) { - return std::unexpected(entry.error()); - } - if (!entry->has_value()) { - return false; - } - const QByteArray& bytes = **entry; - auto written = writeFileAtomically( - target, std::string_view(bytes.constData(), static_cast(bytes.size()))); - if (!written) { - return std::unexpected(written.error()); - } - return true; + const QString& entryName, + const QString& target) { + auto entry = readZipEntry(archive, entryName); + if (!entry) { + return std::unexpected(entry.error()); + } + if (!entry->has_value()) { + return false; + } + const QByteArray& bytes = **entry; + auto written = writeFileAtomically( + target, std::string_view(bytes.constData(), + static_cast(bytes.size()))); + if (!written) { + return std::unexpected(written.error()); + } + return true; } // Blocking: inflates a gzip stream in memory. Result gunzip(const std::string& data) { - z_stream stream{}; - // 16 + MAX_WBITS selects the gzip wrapper, header and CRC included. - if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { - return std::unexpected(Error{QStringLiteral("Cannot start gzip decoding")}); - } - stream.next_in = reinterpret_cast(const_cast(data.data())); - stream.avail_in = static_cast(data.size()); - - std::string out; - std::array buffer{}; - int status = Z_OK; - do { - stream.next_out = reinterpret_cast(buffer.data()); - stream.avail_out = static_cast(buffer.size()); - status = inflate(&stream, Z_NO_FLUSH); - if (status != Z_OK && status != Z_STREAM_END) { - // Z_BUF_ERROR here means the input ended before the stream did. - inflateEnd(&stream); - return std::unexpected(Error{QStringLiteral("Corrupt or truncated gzip data")}); - } - out.append(buffer.data(), buffer.size() - stream.avail_out); - } while (status != Z_STREAM_END); - - inflateEnd(&stream); - return out; + z_stream stream{}; + // 16 + MAX_WBITS selects the gzip wrapper, header and CRC included. + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + return std::unexpected(Error{QStringLiteral("Cannot start gzip decoding")}); + } + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::array buffer{}; + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = inflate(&stream, Z_NO_FLUSH); + if (status != Z_OK && status != Z_STREAM_END) { + // Z_BUF_ERROR here means the input ended before the stream did. + inflateEnd(&stream); + return std::unexpected( + Error{QStringLiteral("Corrupt or truncated gzip data")}); + } + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + + inflateEnd(&stream); + return out; } -// The ids under ModIdsToFix/li: a lone
  • is a string, several are an array. Nullopt when the -// document has another shape. +// The ids under ModIdsToFix/li: a lone
  • is a string, several are an array. +// Nullopt when the document has another shape. std::optional> noVersionWarningIds(const glz::json_t& root) { - QSet ids; - if (!root.contains("ModIdsToFix")) { - return ids; - } - const glz::json_t& section = root["ModIdsToFix"]; - if (!section.is_object()) { - return std::nullopt; - } - if (!section.contains("li")) { - return ids; - } - const glz::json_t& items = section["li"]; - if (items.is_string()) { - ids.insert(PackageId(QString::fromStdString(items.get_string()))); - return ids; - } - if (!items.is_array()) { - return std::nullopt; - } - for (const glz::json_t& item : items.get_array()) { - if (!item.is_string()) { - return std::nullopt; - } - ids.insert(PackageId(QString::fromStdString(item.get_string()))); - } + QSet ids; + if (!root.contains("ModIdsToFix")) { + return ids; + } + const glz::json_t& section = root["ModIdsToFix"]; + if (!section.is_object()) { + return std::nullopt; + } + if (!section.contains("li")) { return ids; + } + const glz::json_t& items = section["li"]; + if (items.is_string()) { + ids.insert(PackageId(QString::fromStdString(items.get_string()))); + return ids; + } + if (!items.is_array()) { + return std::nullopt; + } + for (const glz::json_t& item : items.get_array()) { + if (!item.is_string()) { + return std::nullopt; + } + ids.insert(PackageId(QString::fromStdString(item.get_string()))); + } + return ids; } -// Blocking: parses the cached NoVersionWarning XML. A broken file yields an empty set. +// Blocking: parses the cached NoVersionWarning XML. A broken file yields an +// empty set. QSet parseNoVersionWarning(const QString& xmlPath) { - const auto data = xmlFileToJson(xmlPath); - if (!data) { - spdlog::error("Failed to load No Version Warning DB: {}", data.error().message.toStdString()); - return {}; - } - const auto ids = noVersionWarningIds(*data); - if (!ids) { - spdlog::error("Failed to load No Version Warning DB: unexpected ModIdsToFix layout"); - return {}; - } - spdlog::info("Loaded {} No Version Warning entries", ids->size()); - return *ids; + const auto data = xmlFileToJson(xmlPath); + if (!data) { + spdlog::error("Failed to load No Version Warning DB: {}", + data.error().message.toStdString()); + return {}; + } + const auto ids = noVersionWarningIds(*data); + if (!ids) { + spdlog::error( + "Failed to load No Version Warning DB: unexpected ModIdsToFix layout"); + return {}; + } + spdlog::info("Loaded {} No Version Warning entries", ids->size()); + return *ids; } -// Text of a JSON scalar as Python's str() would show it. Other kinds yield an empty string. +// Text of a JSON scalar as Python's str() would show it. Other kinds yield an +// empty string. QString jsonText(const glz::json_t& object, const char* key) { - if (!object.contains(key)) { - return {}; - } - const glz::json_t& value = object[key]; - if (value.is_string()) { - return QString::fromStdString(value.get_string()); - } - if (value.is_number()) { - const double number = value.get_number(); - if (std::floor(number) == number && std::abs(number) < 1e15) { - return QString::number(static_cast(number)); - } - return QString::number(number, 'g', 17); - } + if (!object.contains(key)) { return {}; + } + const glz::json_t& value = object[key]; + if (value.is_string()) { + return QString::fromStdString(value.get_string()); + } + if (value.is_number()) { + const double number = value.get_number(); + if (std::floor(number) == number && std::abs(number) < 1e15) { + return QString::number(static_cast(number)); + } + return QString::number(number, 'g', 17); + } + return {}; } QStringList jsonStringList(const glz::json_t& object, const char* key) { - QStringList values; - if (!object.contains(key) || !object[key].is_array()) { - return values; - } - for (const glz::json_t& item : object[key].get_array()) { - if (item.is_string()) { - values.append(QString::fromStdString(item.get_string())); - } - } + QStringList values; + if (!object.contains(key) || !object[key].is_array()) { return values; + } + for (const glz::json_t& item : object[key].get_array()) { + if (item.is_string()) { + values.append(QString::fromStdString(item.get_string())); + } + } + return values; } -// Blocking: parses the cached gzipped UseThisInstead JSON. A broken file yields no entries. +// Blocking: parses the cached gzipped UseThisInstead JSON. A broken file yields +// no entries. QHash parseReplacements(const QString& jsonPath) { - const auto bytes = readFileBytes(jsonPath); - if (!bytes) { - spdlog::error("Failed to load Use This Instead DB: {}", bytes.error().message.toStdString()); - return {}; - } - const auto text = gunzip(*bytes); - if (!text) { - spdlog::error("Failed to load Use This Instead DB: {}", text.error().message.toStdString()); - return {}; - } - - // Written by a Windows editor in some releases; the UTF-8 BOM is not part of the JSON. - std::string body = *text; - if (body.starts_with("\xEF\xBB\xBF")) { - body.erase(0, 3); - } - glz::json_t raw; - if (auto error = glz::read_json(raw, body)) { - spdlog::error("Failed to load Use This Instead DB: {}", glz::format_error(error, body)); - return {}; - } - - QHash entries; - if (raw.is_object() && raw.contains("rules") && raw["rules"].is_array()) { - for (const glz::json_t& rule : raw["rules"].get_array()) { - if (!rule.is_object() || !rule.contains("oldWorkshopId")) { - continue; - } - ReplacementInfo info; - info.name = jsonText(rule, "newName"); - info.author = jsonText(rule, "newAuthor"); - info.packageid = jsonText(rule, "newPackageId"); - info.pfid = jsonText(rule, "newWorkshopId"); - info.supportedversions = jsonStringList(rule, "newVersions"); - entries.insert(jsonText(rule, "oldWorkshopId"), std::move(info)); - } - } - spdlog::info("Loaded {} Use This Instead entries", entries.size()); - return entries; + const auto bytes = readFileBytes(jsonPath); + if (!bytes) { + spdlog::error("Failed to load Use This Instead DB: {}", + bytes.error().message.toStdString()); + return {}; + } + const auto text = gunzip(*bytes); + if (!text) { + spdlog::error("Failed to load Use This Instead DB: {}", + text.error().message.toStdString()); + return {}; + } + + // Written by a Windows editor in some releases; the UTF-8 BOM is not part of + // the JSON. + std::string body = *text; + if (body.starts_with("\xEF\xBB\xBF")) { + body.erase(0, 3); + } + glz::json_t raw; + if (auto error = glz::read_json(raw, body)) { + spdlog::error("Failed to load Use This Instead DB: {}", + glz::format_error(error, body)); + return {}; + } + + QHash entries; + if (raw.is_object() && raw.contains("rules") && raw["rules"].is_array()) { + for (const glz::json_t& rule : raw["rules"].get_array()) { + if (!rule.is_object() || !rule.contains("oldWorkshopId")) { + continue; + } + ReplacementInfo info; + info.name = jsonText(rule, "newName"); + info.author = jsonText(rule, "newAuthor"); + info.packageid = jsonText(rule, "newPackageId"); + info.pfid = jsonText(rule, "newWorkshopId"); + info.supportedversions = jsonStringList(rule, "newVersions"); + entries.insert(jsonText(rule, "oldWorkshopId"), std::move(info)); + } + } + spdlog::info("Loaded {} Use This Instead entries", entries.size()); + return entries; } } // namespace -NoVersionWarningService::NoVersionWarningService(const ConfigService& configService, - QNetworkAccessManager& network) +NoVersionWarningService::NoVersionWarningService( + const ConfigService& configService, QNetworkAccessManager& network) : m_network(network), m_cacheDir(configService.directory()), m_xmlPath(QDir(m_cacheDir).filePath(kNoVersionWarningXml)) {} QCoro::Task> NoVersionWarningService::ensure(bool force) { - if (!force) { - if (const auto stamp = fileStamp(m_xmlPath)) { - if (stamp != m_loadedStamp) { - m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); - m_loadedStamp = stamp; - } - co_return m_pids; - } - } - co_return co_await download(); + if (!force) { + if (const auto stamp = fileStamp(m_xmlPath)) { + if (stamp != m_loadedStamp) { + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + m_loadedStamp = stamp; + } + co_return m_pids; + } + } + co_return co_await download(); } std::optional NoVersionWarningService::cacheAgeS() const { - return fileAgeS(m_xmlPath); + return fileAgeS(m_xmlPath); } QSet NoVersionWarningService::loadIfExists() { - if (QFileInfo::exists(m_xmlPath)) { - m_pids = parseNoVersionWarning(m_xmlPath); - } - return m_pids; + if (QFileInfo::exists(m_xmlPath)) { + m_pids = parseNoVersionWarning(m_xmlPath); + } + return m_pids; } QCoro::Task> NoVersionWarningService::download() { - QDir().mkpath(m_cacheDir); - - auto archive = co_await fetchArchive(m_network, kNoVersionWarningUrl); - if (!archive) { - spdlog::warn("Failed to download No Version Warning DB: {}", - archive.error().message.toStdString()); - co_return QSet{}; - } - auto stored = co_await QtConcurrent::run(storeArchiveEntry, - std::move(*archive), - kNoVersionWarningXml, - m_xmlPath); - if (!stored) { - spdlog::warn("Failed to download No Version Warning DB: {}", - stored.error().message.toStdString()); - co_return QSet{}; - } - if (!*stored) { - co_return QSet{}; - } - - m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); - co_return m_pids; + QDir().mkpath(m_cacheDir); + + auto archive = co_await fetchArchive(m_network, kNoVersionWarningUrl); + if (!archive) { + spdlog::warn("Failed to download No Version Warning DB: {}", + archive.error().message.toStdString()); + co_return QSet{}; + } + auto stored = co_await QtConcurrent::run( + storeArchiveEntry, std::move(*archive), kNoVersionWarningXml, m_xmlPath); + if (!stored) { + spdlog::warn("Failed to download No Version Warning DB: {}", + stored.error().message.toStdString()); + co_return QSet{}; + } + if (!*stored) { + co_return QSet{}; + } + + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + co_return m_pids; } UseThisInsteadService::UseThisInsteadService(const ConfigService& configService, @@ -371,54 +386,53 @@ UseThisInsteadService::UseThisInsteadService(const ConfigService& configService, m_cacheDir(configService.directory()), m_jsonPath(QDir(m_cacheDir).filePath(kReplacementsGz)) {} -QCoro::Task> UseThisInsteadService::ensure(bool force) { - if (!force) { - if (const auto stamp = fileStamp(m_jsonPath)) { - if (stamp != m_loadedStamp) { - m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); - m_loadedStamp = stamp; - } - co_return m_entries; - } - } - co_return co_await download(); +QCoro::Task> UseThisInsteadService::ensure( + bool force) { + if (!force) { + if (const auto stamp = fileStamp(m_jsonPath)) { + if (stamp != m_loadedStamp) { + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + m_loadedStamp = stamp; + } + co_return m_entries; + } + } + co_return co_await download(); } std::optional UseThisInsteadService::cacheAgeS() const { - return fileAgeS(m_jsonPath); + return fileAgeS(m_jsonPath); } QHash UseThisInsteadService::loadIfExists() { - if (QFileInfo::exists(m_jsonPath)) { - m_entries = parseReplacements(m_jsonPath); - } - return m_entries; + if (QFileInfo::exists(m_jsonPath)) { + m_entries = parseReplacements(m_jsonPath); + } + return m_entries; } QCoro::Task> UseThisInsteadService::download() { - QDir().mkpath(m_cacheDir); - - auto archive = co_await fetchArchive(m_network, kUseThisInsteadUrl); - if (!archive) { - spdlog::warn("Failed to download Use This Instead DB: {}", - archive.error().message.toStdString()); - co_return QHash{}; - } - auto stored = co_await QtConcurrent::run(storeArchiveEntry, - std::move(*archive), - kReplacementsGz, - m_jsonPath); - if (!stored) { - spdlog::warn("Failed to download Use This Instead DB: {}", - stored.error().message.toStdString()); - co_return QHash{}; - } - if (!*stored) { - co_return QHash{}; - } - - m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); - co_return m_entries; + QDir().mkpath(m_cacheDir); + + auto archive = co_await fetchArchive(m_network, kUseThisInsteadUrl); + if (!archive) { + spdlog::warn("Failed to download Use This Instead DB: {}", + archive.error().message.toStdString()); + co_return QHash{}; + } + auto stored = co_await QtConcurrent::run( + storeArchiveEntry, std::move(*archive), kReplacementsGz, m_jsonPath); + if (!stored) { + spdlog::warn("Failed to download Use This Instead DB: {}", + stored.error().message.toStdString()); + co_return QHash{}; + } + if (!*stored) { + co_return QHash{}; + } + + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + co_return m_entries; } } // namespace px::modrim::checker diff --git a/src/core/checker/databases.hpp b/src/core/checker/databases.hpp index b022b8b..4a742e7 100644 --- a/src/core/checker/databases.hpp +++ b/src/core/checker/databases.hpp @@ -1,11 +1,11 @@ #pragma once -#include - -#include #include #include #include +#include + +#include #include "core/checker/models.hpp" #include "core/models/metadata/structures.hpp" @@ -20,62 +20,68 @@ namespace checker { // Identity of a cache file: changes whenever the file is rewritten. struct FileStamp { - qint64 modifiedMs = 0; - qint64 size = 0; + qint64 modifiedMs = 0; + qint64 size = 0; - bool operator==(const FileStamp&) const = default; + bool operator==(const FileStamp&) const = default; }; -// Caches the NoVersionWarning package-id list under the config directory and refreshes it from -// GitHub when the cache is missing or a refresh is forced. +// Caches the NoVersionWarning package-id list under the config directory and +// refreshes it from GitHub when the cache is missing or a refresh is forced. class NoVersionWarningService { -public: - NoVersionWarningService(const ConfigService& configService, QNetworkAccessManager& network); - - // Cached ids, reloaded when the file changed on disk. Downloads when the cache is missing - // or `force` is set. A failed download yields an empty set and keeps the previous ids. - QCoro::Task> ensure(bool force = false); - - // Seconds since the cache file was written, or nullopt when there is no cache. - std::optional cacheAgeS() const; - - // Loads the cache synchronously when it exists; otherwise returns the current ids. - QSet loadIfExists(); - -private: - QCoro::Task> download(); - - QNetworkAccessManager& m_network; - QString m_cacheDir; - QString m_xmlPath; - QSet m_pids; - // Stamp of the file the current `pids_` were parsed from. - std::optional m_loadedStamp; + public: + NoVersionWarningService(const ConfigService& configService, + QNetworkAccessManager& network); + + // Cached ids, reloaded when the file changed on disk. Downloads when the + // cache is missing or `force` is set. A failed download yields an empty set + // and keeps the previous ids. + QCoro::Task> ensure(bool force = false); + + // Seconds since the cache file was written, or nullopt when there is no + // cache. + std::optional cacheAgeS() const; + + // Loads the cache synchronously when it exists; otherwise returns the current + // ids. + QSet loadIfExists(); + + private: + QCoro::Task> download(); + + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_xmlPath; + QSet m_pids; + // Stamp of the file the current `pids_` were parsed from. + std::optional m_loadedStamp; }; -// Caches the UseThisInstead replacement database (gzipped JSON) under the config directory and -// refreshes it from GitHub when the cache is missing or a refresh is forced. +// Caches the UseThisInstead replacement database (gzipped JSON) under the +// config directory and refreshes it from GitHub when the cache is missing or a +// refresh is forced. class UseThisInsteadService { -public: - UseThisInsteadService(const ConfigService& configService, QNetworkAccessManager& network); + public: + UseThisInsteadService(const ConfigService& configService, + QNetworkAccessManager& network); - // Cached entries, reloaded when the file changed on disk. Downloads when the cache is - // missing or `force` is set. A failed download yields an empty map and keeps the previous - // entries. - QCoro::Task> ensure(bool force = false); + // Cached entries, reloaded when the file changed on disk. Downloads when the + // cache is missing or `force` is set. A failed download yields an empty map + // and keeps the previous entries. + QCoro::Task> ensure(bool force = false); - std::optional cacheAgeS() const; + std::optional cacheAgeS() const; - QHash loadIfExists(); + QHash loadIfExists(); -private: - QCoro::Task> download(); + private: + QCoro::Task> download(); - QNetworkAccessManager& m_network; - QString m_cacheDir; - QString m_jsonPath; - QHash m_entries; - std::optional m_loadedStamp; + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_jsonPath; + QHash m_entries; + std::optional m_loadedStamp; }; } // namespace checker diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index 5b305e4..12eae3e 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -8,323 +8,327 @@ namespace px::modrim::checker { namespace { constexpr std::size_t indexOfType(EdgeType type) { - return static_cast(type); + return static_cast(type); } bool strLess(const PackageId& a, const PackageId& b) { - return a.str() < b.str(); + return a.str() < b.str(); } -// Qt hashes are randomised per process, so the Tarjan traversal visits roots and neighbours -// in name order to keep the reported cycles stable from run to run. +// Qt hashes are randomised per process, so the Tarjan traversal visits roots +// and neighbours in name order to keep the reported cycles stable from run to +// run. QList sortedPids(const QSet& pids) { - QList sorted(pids.cbegin(), pids.cend()); - std::ranges::sort(sorted, strLess); - return sorted; + QList sorted(pids.cbegin(), pids.cend()); + std::ranges::sort(sorted, strLess); + return sorted; } -QHash buildAltMap(const QHash& activeMods) { - QHash altMap; - for (const auto& mod : activeMods) { - for (const DependencyMod& dep : mod->aboutRules.dependencies) { - if (activeMods.contains(dep.packageId)) { - continue; - } - for (const PackageId& alternativePid : dep.alternativePackageIds) { - if (activeMods.contains(alternativePid)) { - altMap.insert(dep.packageId, alternativePid); - break; - } - } +QHash buildAltMap( + const QHash& activeMods) { + QHash altMap; + for (const auto& mod : activeMods) { + for (const DependencyMod& dep : mod->aboutRules.dependencies) { + if (activeMods.contains(dep.packageId)) { + continue; + } + for (const PackageId& alternativePid : dep.alternativePackageIds) { + if (activeMods.contains(alternativePid)) { + altMap.insert(dep.packageId, alternativePid); + break; } + } } - return altMap; + } + return altMap; } } // namespace QSet ConstraintGraph::outgoing(const PackageId& pid) const { - QSet result; - if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { - for (const auto& edges : it.value()) { - result.unite(edges); - } + QSet result; + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); } - return result; + } + return result; } QSet ConstraintGraph::incoming(const PackageId& pid) const { - QSet result; - if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { - for (const auto& edges : it.value()) { - result.unite(edges); - } + QSet result; + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); } - return result; + } + return result; } QSet ConstraintGraph::neighbors(const PackageId& pid) const { - QSet result; - if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { - for (const auto& edges : it.value()) { - for (const auto& edge : edges) { - result.insert(edge.target); - } - } + QSet result; + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.target); + } } - if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { - for (const auto& edges : it.value()) { - for (const auto& edge : edges) { - result.insert(edge.source); - } - } + } + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.source); + } } - return result; + } + return result; } -QSet ConstraintGraph::edgesOfType(const PackageId& pid, EdgeType type) const { - if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { - return it.value()[indexOfType(type)]; - } - return {}; +QSet ConstraintGraph::edgesOfType(const PackageId& pid, + EdgeType type) const { + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + return it.value()[indexOfType(type)]; + } + return {}; } -QSet ConstraintGraph::incomingOfType(const PackageId& pid, EdgeType type) const { - if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { - return it.value()[indexOfType(type)]; - } - return {}; +QSet ConstraintGraph::incomingOfType(const PackageId& pid, + EdgeType type) const { + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + return it.value()[indexOfType(type)]; + } + return {}; } void ConstraintGraph::build(const QHash& activeMods, const QList& orderedPids, const sort::SortSettings& settings, const sort::CommunityRules* communityRules) { - m_outgoing.clear(); - m_incoming.clear(); - m_cycleComponents.reset(); - m_orderedPids = orderedPids; - rebuildIndex(); - - // Pre-populate nodes so mods without any edges are still tracked. - m_nodes = QSet(orderedPids.cbegin(), orderedPids.cend()); - - const Alternatives alternatives = - settings.use_alternative_package_ids ? buildAltMap(activeMods) : Alternatives{}; - + m_outgoing.clear(); + m_incoming.clear(); + m_cycleComponents.reset(); + m_orderedPids = orderedPids; + rebuildIndex(); + + // Pre-populate nodes so mods without any edges are still tracked. + m_nodes = QSet(orderedPids.cbegin(), orderedPids.cend()); + + const Alternatives alternatives = settings.use_alternative_package_ids + ? buildAltMap(activeMods) + : Alternatives{}; + + for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { + addRules(it.key(), it.value()->aboutRules, EdgeOrigin::kAboutXml, + alternatives); + } + + if (settings.use_community_rules && communityRules != nullptr && + !communityRules->isEmpty()) { for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { - addRules(it.key(), it.value()->aboutRules, EdgeOrigin::kAboutXml, alternatives); - } - - if (settings.use_community_rules && communityRules != nullptr && !communityRules->isEmpty()) { - for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { - const auto rule = communityRules->constFind(it.key()); - if (rule == communityRules->cend()) { - continue; - } - addLoadRules(it.key(), - rule->loadBefore, - rule->loadAfter, - rule->incompatibleWith, - EdgeOrigin::kCommunityRules, - alternatives); - } + const auto rule = communityRules->constFind(it.key()); + if (rule == communityRules->cend()) { + continue; + } + addLoadRules(it.key(), rule->loadBefore, rule->loadAfter, + rule->incompatibleWith, EdgeOrigin::kCommunityRules, + alternatives); } + } } void ConstraintGraph::removeMod(const PackageId& pid) { - m_cycleComponents.reset(); - const EdgesByType outgoingEdges = m_outgoing.take(pid); - for (const auto& edges : outgoingEdges) { - for (const auto& edge : edges) { - m_incoming[edge.target][indexOfType(edge.type)].remove(edge); - } + m_cycleComponents.reset(); + const EdgesByType outgoingEdges = m_outgoing.take(pid); + for (const auto& edges : outgoingEdges) { + for (const auto& edge : edges) { + m_incoming[edge.target][indexOfType(edge.type)].remove(edge); } - const EdgesByType incomingEdges = m_incoming.take(pid); - for (const auto& edges : incomingEdges) { - for (const auto& edge : edges) { - m_outgoing[edge.source][indexOfType(edge.type)].remove(edge); - } + } + const EdgesByType incomingEdges = m_incoming.take(pid); + for (const auto& edges : incomingEdges) { + for (const auto& edge : edges) { + m_outgoing[edge.source][indexOfType(edge.type)].remove(edge); } - m_nodes.remove(pid); - m_orderedPids.removeAll(pid); - rebuildIndex(); + } + m_nodes.remove(pid); + m_orderedPids.removeAll(pid); + rebuildIndex(); } void ConstraintGraph::updateOrder(const QList& orderedPids) { - m_orderedPids = orderedPids; - rebuildIndex(); + m_orderedPids = orderedPids; + rebuildIndex(); } void ConstraintGraph::rebuildIndex() { - m_pidToIndex.clear(); - for (qsizetype i = 0; i < m_orderedPids.size(); ++i) { - m_pidToIndex.insert(m_orderedPids[i], static_cast(i)); - } + m_pidToIndex.clear(); + for (qsizetype i = 0; i < m_orderedPids.size(); ++i) { + m_pidToIndex.insert(m_orderedPids[i], static_cast(i)); + } } QList> ConstraintGraph::findCycles() { - if (!m_cycleComponents) { - m_cycleComponents = findCycleComponents(); - } - const auto missing = static_cast(m_pidToIndex.size()); - QList> result; - result.reserve(m_cycleComponents->size()); - for (QList component : *m_cycleComponents) { - std::ranges::stable_sort(component, {}, [&](const PackageId& member) { - return m_pidToIndex.value(member, missing); - }); - result.append(std::move(component)); - } - return result; + if (!m_cycleComponents) { + m_cycleComponents = findCycleComponents(); + } + const auto missing = static_cast(m_pidToIndex.size()); + QList> result; + result.reserve(m_cycleComponents->size()); + for (QList component : *m_cycleComponents) { + std::ranges::stable_sort(component, {}, [&](const PackageId& member) { + return m_pidToIndex.value(member, missing); + }); + result.append(std::move(component)); + } + return result; } QList> ConstraintGraph::findCycleComponents() const { - // Nodes without dependency or load-after edges are trivial components that can never be - // part of a cycle, so they are left out of the search. - QHash> adjacency; - for (auto it = m_outgoing.cbegin(); it != m_outgoing.cend(); ++it) { - QSet targets; - for (const EdgeType type : {EdgeType::kDependency, EdgeType::kLoadAfter}) { - for (const auto& edge : it.value()[indexOfType(type)]) { - targets.insert(edge.target); - } - } - if (!targets.isEmpty()) { - adjacency.insert(it.key(), sortedPids(targets)); - } + // Nodes without dependency or load-after edges are trivial components that + // can never be part of a cycle, so they are left out of the search. + QHash> adjacency; + for (auto it = m_outgoing.cbegin(); it != m_outgoing.cend(); ++it) { + QSet targets; + for (const EdgeType type : {EdgeType::kDependency, EdgeType::kLoadAfter}) { + for (const auto& edge : it.value()[indexOfType(type)]) { + targets.insert(edge.target); + } + } + if (!targets.isEmpty()) { + adjacency.insert(it.key(), sortedPids(targets)); } - for (auto& neighbors : adjacency) { - neighbors.removeIf([&](const PackageId& target) { return !adjacency.contains(target); }); + } + for (auto& neighbors : adjacency) { + neighbors.removeIf( + [&](const PackageId& target) { return !adjacency.contains(target); }); + } + + struct Frame { + PackageId pid; + const QList* neighbors; + qsizetype next; + }; + + QHash indices; + QHash lowlinks; + std::vector activeStack; + QSet onStack; + QList> components; + int nextIndex = 0; + + for (const PackageId& root : + sortedPids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { + if (indices.contains(root)) { + continue; } - struct Frame { - PackageId pid; - const QList* neighbors; - qsizetype next; - }; - - QHash indices; - QHash lowlinks; - std::vector activeStack; - QSet onStack; - QList> components; - int nextIndex = 0; - - for (const PackageId& root : sortedPids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { - if (indices.contains(root)) { - continue; + indices.insert(root, nextIndex); + lowlinks.insert(root, nextIndex); + ++nextIndex; + activeStack.push_back(root); + onStack.insert(root); + std::vector dfsStack{Frame{root, &adjacency[root], 0}}; + + while (!dfsStack.empty()) { + Frame& top = dfsStack.back(); + const PackageId pid = top.pid; + const QList& neighbors = *top.neighbors; + + if (top.next == neighbors.size()) { + dfsStack.pop_back(); + if (lowlinks[pid] == indices[pid]) { + QList component; + while (true) { + PackageId member = activeStack.back(); + activeStack.pop_back(); + onStack.remove(member); + const bool isRoot = member == pid; + component.append(std::move(member)); + if (isRoot) { + break; + } + } + if (component.size() > 1 || adjacency[pid].contains(pid)) { + components.append(std::move(component)); + } } + if (!dfsStack.empty()) { + const PackageId& parent = dfsStack.back().pid; + lowlinks[parent] = std::min(lowlinks[parent], lowlinks[pid]); + } + continue; + } - indices.insert(root, nextIndex); - lowlinks.insert(root, nextIndex); + const PackageId neighbor = neighbors[top.next++]; + if (!indices.contains(neighbor)) { + indices.insert(neighbor, nextIndex); + lowlinks.insert(neighbor, nextIndex); ++nextIndex; - activeStack.push_back(root); - onStack.insert(root); - std::vector dfsStack{Frame{root, &adjacency[root], 0}}; - - while (!dfsStack.empty()) { - Frame& top = dfsStack.back(); - const PackageId pid = top.pid; - const QList& neighbors = *top.neighbors; - - if (top.next == neighbors.size()) { - dfsStack.pop_back(); - if (lowlinks[pid] == indices[pid]) { - QList component; - while (true) { - PackageId member = activeStack.back(); - activeStack.pop_back(); - onStack.remove(member); - const bool isRoot = member == pid; - component.append(std::move(member)); - if (isRoot) { - break; - } - } - if (component.size() > 1 || adjacency[pid].contains(pid)) { - components.append(std::move(component)); - } - } - if (!dfsStack.empty()) { - const PackageId& parent = dfsStack.back().pid; - lowlinks[parent] = std::min(lowlinks[parent], lowlinks[pid]); - } - continue; - } - - const PackageId neighbor = neighbors[top.next++]; - if (!indices.contains(neighbor)) { - indices.insert(neighbor, nextIndex); - lowlinks.insert(neighbor, nextIndex); - ++nextIndex; - activeStack.push_back(neighbor); - onStack.insert(neighbor); - dfsStack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); - } else if (onStack.contains(neighbor) && indices[neighbor] < lowlinks[pid]) { - lowlinks[pid] = indices[neighbor]; - } - } + activeStack.push_back(neighbor); + onStack.insert(neighbor); + dfsStack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); + } else if (onStack.contains(neighbor) && + indices[neighbor] < lowlinks[pid]) { + lowlinks[pid] = indices[neighbor]; + } } + } - return components; + return components; } template -void ConstraintGraph::addEdges(const PackageId& source, - const Targets& targets, - EdgeType type, - EdgeOrigin origin, - const Alternatives& alternatives) { - // Hot path of build(): the per-source edge set is looked up once, not per target. - QSet* outEdges = nullptr; - for (const PackageId& rawTarget : targets) { - const PackageId target = alternatives.value(rawTarget, rawTarget); - if (outEdges == nullptr) { - m_nodes.insert(source); - outEdges = &m_outgoing[source][indexOfType(type)]; - } - m_nodes.insert(target); - const ConstraintEdge edge{source, target, type, origin}; - outEdges->insert(edge); - m_incoming[target][indexOfType(type)].insert(edge); +void ConstraintGraph::addEdges(const PackageId& source, const Targets& targets, + EdgeType type, EdgeOrigin origin, + const Alternatives& alternatives) { + // Hot path of build(): the per-source edge set is looked up once, not per + // target. + QSet* outEdges = nullptr; + for (const PackageId& rawTarget : targets) { + const PackageId target = alternatives.value(rawTarget, rawTarget); + if (outEdges == nullptr) { + m_nodes.insert(source); + outEdges = &m_outgoing[source][indexOfType(type)]; } + m_nodes.insert(target); + const ConstraintEdge edge{source, target, type, origin}; + outEdges->insert(edge); + m_incoming[target][indexOfType(type)].insert(edge); + } } -void ConstraintGraph::addRules(const PackageId& pid, - const BaseRules& rules, - EdgeOrigin origin, - const Alternatives& alternatives) { - for (const DependencyMod& dep : rules.dependencies) { - addEdges(pid, std::array{dep.packageId}, EdgeType::kDependency, origin, alternatives); - if (!dep.alternativePackageIds.empty()) { - addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, {}); - } +void ConstraintGraph::addRules(const PackageId& pid, const BaseRules& rules, + EdgeOrigin origin, + const Alternatives& alternatives) { + for (const DependencyMod& dep : rules.dependencies) { + addEdges(pid, std::array{dep.packageId}, EdgeType::kDependency, origin, + alternatives); + if (!dep.alternativePackageIds.empty()) { + addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, + {}); } + } - addLoadRules(pid, - rules.loadBefore, - rules.loadAfter, - rules.incompatibleWith, - origin, - alternatives); + addLoadRules(pid, rules.loadBefore, rules.loadAfter, rules.incompatibleWith, + origin, alternatives); } void ConstraintGraph::addLoadRules(const PackageId& pid, - const CaseInsensitiveSet& loadBefore, - const CaseInsensitiveSet& loadAfter, - const CaseInsensitiveSet& incompatibleWith, - EdgeOrigin origin, - const Alternatives& alternatives) { - if (!loadAfter.empty()) { - addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); - } - if (!loadBefore.empty()) { - addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); - } - if (!incompatibleWith.empty()) { - addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, alternatives); - } + const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, + EdgeOrigin origin, + const Alternatives& alternatives) { + if (!loadAfter.empty()) { + addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); + } + if (!loadBefore.empty()) { + addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); + } + if (!incompatibleWith.empty()) { + addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, + alternatives); + } } } // namespace px::modrim::checker diff --git a/src/core/checker/graph.hpp b/src/core/checker/graph.hpp index e118f0f..d1bab9a 100644 --- a/src/core/checker/graph.hpp +++ b/src/core/checker/graph.hpp @@ -1,12 +1,11 @@ #pragma once -#include -#include -#include - #include #include #include +#include +#include +#include #include "core/checker/models.hpp" #include "core/models/metadata/structures.hpp" @@ -16,96 +15,97 @@ namespace px::modrim::checker { enum class EdgeType : std::uint8_t { - kDependency, - kLoadAfter, - kLoadBefore, - kIncompatibility, - kAlternative, + kDependency, + kLoadAfter, + kLoadBefore, + kIncompatibility, + kAlternative, }; inline constexpr std::size_t kEdgeTypeCount = 5; enum class EdgeOrigin : std::uint8_t { - kAboutXml, - kCommunityRules, + kAboutXml, + kCommunityRules, }; struct ConstraintEdge { - PackageId source; - PackageId target; - EdgeType type; - EdgeOrigin origin; + PackageId source; + PackageId target; + EdgeType type; + EdgeOrigin origin; - bool operator==(const ConstraintEdge&) const = default; + bool operator==(const ConstraintEdge&) const = default; - friend size_t qHash(const ConstraintEdge& edge, size_t seed = 0) noexcept { - return qHashMulti(seed, edge.source, edge.target, edge.type, edge.origin); - } + friend size_t qHash(const ConstraintEdge& edge, size_t seed = 0) noexcept { + return qHashMulti(seed, edge.source, edge.target, edge.type, edge.origin); + } }; -// Directed graph of mod dependencies, load-order rules and incompatibilities. Nodes are the -// ordered mods plus any package id a rule points at, whether or not it is active. +// Directed graph of mod dependencies, load-order rules and incompatibilities. +// Nodes are the ordered mods plus any package id a rule points at, whether or +// not it is active. class ConstraintGraph { -public: - using Alternatives = QHash; - - const QSet& nodes() const noexcept { return m_nodes; } - bool hasNode(const PackageId& pid) const { return m_nodes.contains(pid); } - - // Union over all edge types. Returned by value: Qt containers are implicitly shared. - QSet outgoing(const PackageId& pid) const; - QSet incoming(const PackageId& pid) const; - QSet neighbors(const PackageId& pid) const; - QSet edgesOfType(const PackageId& pid, EdgeType type) const; - QSet incomingOfType(const PackageId& pid, EdgeType type) const; - - int indexOf(const PackageId& pid) const { return m_pidToIndex.value(pid, -1); } - const QHash& pidToIndex() const noexcept { return m_pidToIndex; } - - // Replaces all edges. A null or empty `community_rules` means no community rules. - void build(const QHash& activeMods, - const QList& orderedPids, - const sort::SortSettings& settings, - const sort::CommunityRules* communityRules = nullptr); - - void removeMod(const PackageId& pid); - - // Replaces the mod ordering without touching edges. - void updateOrder(const QList& orderedPids); - - // Strongly connected components that contain a dependency or load-after cycle. Members are - // sorted by load index. Cached until the edges change. - QList> findCycles(); - -private: - using EdgesByType = std::array, kEdgeTypeCount>; - - template - void addEdges(const PackageId& source, - const Targets& targets, - EdgeType type, - EdgeOrigin origin, - const Alternatives& alternatives); - void addRules(const PackageId& pid, - const BaseRules& rules, - EdgeOrigin origin, - const Alternatives& alternatives); - void addLoadRules(const PackageId& pid, - const CaseInsensitiveSet& loadBefore, - const CaseInsensitiveSet& loadAfter, - const CaseInsensitiveSet& incompatibleWith, - EdgeOrigin origin, - const Alternatives& alternatives); - void rebuildIndex(); - QList> findCycleComponents() const; - - QHash m_outgoing; - QHash m_incoming; - QSet m_nodes; - QHash m_pidToIndex; - QList m_orderedPids; - // Cycle membership depends only on edges, so it survives reorders. - std::optional>> m_cycleComponents; + public: + using Alternatives = QHash; + + const QSet& nodes() const noexcept { return m_nodes; } + bool hasNode(const PackageId& pid) const { return m_nodes.contains(pid); } + + // Union over all edge types. Returned by value: Qt containers are implicitly + // shared. + QSet outgoing(const PackageId& pid) const; + QSet incoming(const PackageId& pid) const; + QSet neighbors(const PackageId& pid) const; + QSet edgesOfType(const PackageId& pid, EdgeType type) const; + QSet incomingOfType(const PackageId& pid, + EdgeType type) const; + + int indexOf(const PackageId& pid) const { + return m_pidToIndex.value(pid, -1); + } + const QHash& pidToIndex() const noexcept { + return m_pidToIndex; + } + + // Replaces all edges. A null or empty `community_rules` means no community + // rules. + void build(const QHash& activeMods, + const QList& orderedPids, + const sort::SortSettings& settings, + const sort::CommunityRules* communityRules = nullptr); + + void removeMod(const PackageId& pid); + + // Replaces the mod ordering without touching edges. + void updateOrder(const QList& orderedPids); + + // Strongly connected components that contain a dependency or load-after + // cycle. Members are sorted by load index. Cached until the edges change. + QList> findCycles(); + + private: + using EdgesByType = std::array, kEdgeTypeCount>; + + template + void addEdges(const PackageId& source, const Targets& targets, EdgeType type, + EdgeOrigin origin, const Alternatives& alternatives); + void addRules(const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, + const Alternatives& alternatives); + void addLoadRules(const PackageId& pid, const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, + EdgeOrigin origin, const Alternatives& alternatives); + void rebuildIndex(); + QList> findCycleComponents() const; + + QHash m_outgoing; + QHash m_incoming; + QSet m_nodes; + QHash m_pidToIndex; + QList m_orderedPids; + // Cycle membership depends only on edges, so it survives reorders. + std::optional>> m_cycleComponents; }; } // namespace px::modrim::checker diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp index 54e80b2..cfbbfdd 100644 --- a/src/core/checker/issues.cpp +++ b/src/core/checker/issues.cpp @@ -6,264 +6,281 @@ namespace px::modrim::checker { -bool DependencyIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext&) const { - return !mod.aboutRules.dependencies.isEmpty(); +bool DependencyIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext&) const { + return !mod.aboutRules.dependencies.isEmpty(); } QList DependencyIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - if (!ctx.settings.check_missing_dependencies) { - return {}; + if (!ctx.settings.check_missing_dependencies) { + return {}; + } + + QList issues; + const bool considerAlternatives = ctx.settings.use_alternative_package_ids; + + for (const DependencyMod& dep : mod.aboutRules.dependencies) { + bool satisfied = ctx.activeMods.contains(dep.packageId); + if (!satisfied && considerAlternatives) { + satisfied = std::ranges::any_of( + dep.alternativePackageIds, + [&](const PackageId& alt) { return ctx.activeMods.contains(alt); }); + } + if (satisfied) { + continue; } - QList issues; - const bool considerAlternatives = ctx.settings.use_alternative_package_ids; - - for (const DependencyMod& dep : mod.aboutRules.dependencies) { - bool satisfied = ctx.activeMods.contains(dep.packageId); - if (!satisfied && considerAlternatives) { - satisfied = std::ranges::any_of(dep.alternativePackageIds, [&](const PackageId& alt) { - return ctx.activeMods.contains(alt); - }); - } - if (satisfied) { - continue; - } - - QString altNote; - if (considerAlternatives && !dep.alternativePackageIds.empty()) { - QStringList candidates; - for (const PackageId& alt : dep.alternativePackageIds) { - candidates.append(alt.str()); - } - altNote = QStringLiteral(" (alternatives: %1)").arg(candidates.join(QStringLiteral(", "))); - } - - issues.append(ModIssue{ - .category = QStringLiteral("missing_dependency"), - .severity = IssueSeverity::kError, - .shortMessage = QStringLiteral("Missing dependencies"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = - QStringLiteral("Dependency not satisfied: %1%2").arg(dep.packageId.str(), altNote), - .relatedPackageIds = {dep.packageId}, - }); + QString altNote; + if (considerAlternatives && !dep.alternativePackageIds.empty()) { + QStringList candidates; + for (const PackageId& alt : dep.alternativePackageIds) { + candidates.append(alt.str()); + } + altNote = QStringLiteral(" (alternatives: %1)") + .arg(candidates.join(QStringLiteral(", "))); } - return issues; + issues.append(ModIssue{ + .category = QStringLiteral("missing_dependency"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Missing dependencies"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Dependency not satisfied: %1%2") + .arg(dep.packageId.str(), altNote), + .relatedPackageIds = {dep.packageId}, + }); + } + + return issues; } bool IncompatibilityIssueChecker::shouldCheck(const AboutXmlMod& mod, - const CheckContext& ctx) const { - const PackageId& pid = mod.packageId; - return !mod.aboutRules.incompatibleWith.empty() || - !ctx.graph.edgesOfType(pid, EdgeType::kIncompatibility).isEmpty() || - !ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility).isEmpty(); + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + return !mod.aboutRules.incompatibleWith.empty() || + !ctx.graph.edgesOfType(pid, EdgeType::kIncompatibility).isEmpty() || + !ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility).isEmpty(); } -QList IncompatibilityIssueChecker::check(const AboutXmlMod& mod, - const CheckContext& ctx) const { - QList issues; - const PackageId& pid = mod.packageId; - - for (const PackageId& incomp : mod.aboutRules.incompatibleWith) { - if (!ctx.activeMods.contains(incomp)) { - continue; - } - const AboutXmlModPtr other = ctx.activeMods.value(incomp); - issues.append(ModIssue{ - .category = QStringLiteral("incompatibility"), - .severity = IssueSeverity::kError, - .shortMessage = QStringLiteral("Incompatibilities detected"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Mod declares incompatibility with: %1 (%2)") - .arg(other->name, incomp.str()), - .relatedPackageIds = {incomp}, - }); - } +QList IncompatibilityIssueChecker::check( + const AboutXmlMod& mod, const CheckContext& ctx) const { + QList issues; + const PackageId& pid = mod.packageId; - // Each declaring mod is reported once, even when it is seen both in our own list and as an - // incoming graph edge (about.xml and community rules can both declare the same pair). - QSet reportedSources(mod.aboutRules.incompatibleWith.begin(), - mod.aboutRules.incompatibleWith.end()); - for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility)) { - if (!ctx.activeMods.contains(edge.source) || edge.source == pid || - reportedSources.contains(edge.source)) { - continue; - } - reportedSources.insert(edge.source); - const AboutXmlModPtr other = ctx.activeMods.value(edge.source); - issues.append(ModIssue{ - .category = QStringLiteral("incompatibility"), - .severity = IssueSeverity::kError, - .shortMessage = QStringLiteral("Incompatibilities detected"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Declared incompatible by: %1 (%2)") - .arg(other->name, edge.source.str()), - .relatedPackageIds = {edge.source}, - }); + for (const PackageId& incomp : mod.aboutRules.incompatibleWith) { + if (!ctx.activeMods.contains(incomp)) { + continue; + } + const AboutXmlModPtr other = ctx.activeMods.value(incomp); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Mod declares incompatibility with: %1 (%2)") + .arg(other->name, incomp.str()), + .relatedPackageIds = {incomp}, + }); + } + + // Each declaring mod is reported once, even when it is seen both in our own + // list and as an incoming graph edge (about.xml and community rules can both + // declare the same pair). + QSet reportedSources(mod.aboutRules.incompatibleWith.begin(), + mod.aboutRules.incompatibleWith.end()); + for (const ConstraintEdge& edge : + ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility)) { + if (!ctx.activeMods.contains(edge.source) || edge.source == pid || + reportedSources.contains(edge.source)) { + continue; } + reportedSources.insert(edge.source); + const AboutXmlModPtr other = ctx.activeMods.value(edge.source); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Declared incompatible by: %1 (%2)") + .arg(other->name, edge.source.str()), + .relatedPackageIds = {edge.source}, + }); + } - return issues; + return issues; } -bool LoadOrderIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const { - const PackageId& pid = mod.packageId; - return !ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore).isEmpty() || - !ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter).isEmpty(); +bool LoadOrderIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + return !ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore).isEmpty() || + !ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter).isEmpty(); } std::optional> LoadOrderIssueChecker::affectedByMoves( const CheckContext& ctx, const QSet& moved) const { - // Results only read the indices of the mod itself and its edge targets. - QSet affected = moved; - for (const PackageId& pid : moved) { - for (const EdgeType type : {EdgeType::kLoadBefore, EdgeType::kLoadAfter}) { - for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, type)) { - affected.insert(edge.source); - } - } + // Results only read the indices of the mod itself and its edge targets. + QSet affected = moved; + for (const PackageId& pid : moved) { + for (const EdgeType type : {EdgeType::kLoadBefore, EdgeType::kLoadAfter}) { + for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, type)) { + affected.insert(edge.source); + } } - return affected; + } + return affected; } QList LoadOrderIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - QList issues; - const PackageId& pid = mod.packageId; - const int idx = ctx.pidToIndex.value(pid, -1); - if (idx < 0) { - return issues; + QList issues; + const PackageId& pid = mod.packageId; + const int idx = ctx.pidToIndex.value(pid, -1); + if (idx < 0) { + return issues; + } + + for (const ConstraintEdge& edge : + ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx >= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Should load before: %1 (%2), but appears after " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(otherIdx), + .relatedPackageIds = {edge.target}, + }); } - - for (const ConstraintEdge& edge : ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore)) { - const int otherIdx = ctx.pidToIndex.value(edge.target, -1); - if (otherIdx >= 0 && idx >= otherIdx) { - const AboutXmlModPtr other = ctx.activeMods.value(edge.target); - const QString name = other ? other->name : edge.target.str(); - issues.append(ModIssue{ - .category = QStringLiteral("load_order"), - .severity = IssueSeverity::kWarning, - .shortMessage = QStringLiteral("Load order violated"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Should load before: %1 (%2), but appears after " - "(index %3 vs %4)") - .arg(name, edge.target.str()) - .arg(idx) - .arg(otherIdx), - .relatedPackageIds = {edge.target}, - }); - } + } + + for (const ConstraintEdge& edge : + ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx <= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Should load after: %1 (%2), but appears before " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(otherIdx), + .relatedPackageIds = {edge.target}, + }); } + } - for (const ConstraintEdge& edge : ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter)) { - const int otherIdx = ctx.pidToIndex.value(edge.target, -1); - if (otherIdx >= 0 && idx <= otherIdx) { - const AboutXmlModPtr other = ctx.activeMods.value(edge.target); - const QString name = other ? other->name : edge.target.str(); - issues.append(ModIssue{ - .category = QStringLiteral("load_order"), - .severity = IssueSeverity::kWarning, - .shortMessage = QStringLiteral("Load order violated"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Should load after: %1 (%2), but appears before " - "(index %3 vs %4)") - .arg(name, edge.target.str()) - .arg(idx) - .arg(otherIdx), - .relatedPackageIds = {edge.target}, - }); - } - } - - return issues; + return issues; } -QList CycleIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - const PackageId& pid = mod.packageId; - QList issues; - - for (const QList& cycle : ctx.cycles) { - if (!cycle.contains(pid)) { - continue; - } - QStringList members; - QList related; - for (const PackageId& member : cycle) { - members.append(member.str()); - if (member != pid) { - related.append(member); - } - } - issues.append(ModIssue{ - .category = QStringLiteral("cycle"), - .severity = IssueSeverity::kWarning, - .shortMessage = QStringLiteral("Dependency cycle detected"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Participates in a dependency cycle involving: ") + - members.join(QStringLiteral(", ")), - .relatedPackageIds = std::move(related), - }); - } +QList CycleIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + QList issues; - return issues; + for (const QList& cycle : ctx.cycles) { + if (!cycle.contains(pid)) { + continue; + } + QStringList members; + QList related; + for (const PackageId& member : cycle) { + members.append(member.str()); + if (member != pid) { + related.append(member); + } + } + issues.append(ModIssue{ + .category = QStringLiteral("cycle"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Dependency cycle detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Participates in a dependency cycle involving: ") + + members.join(QStringLiteral(", ")), + .relatedPackageIds = std::move(related), + }); + } + + return issues; } -bool GameVersionIssueChecker::shouldCheck(const AboutXmlMod& mod, const CheckContext&) const { - return !mod.supportedVersions.isEmpty(); +bool GameVersionIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext&) const { + return !mod.supportedVersions.isEmpty(); } QList GameVersionIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - if (mod.supportedVersions.isEmpty()) { - return {}; - } - if (ctx.noVersionWarning.contains(mod.packageId)) { - return {}; - } - - // Only major.minor is compared, so a patch-level target still matches "1.5" support. - const QStringList targetParts = ctx.targetVersion.split(QLatin1Char('.')).mid(0, 2); - const QString targetMajorMinor = targetParts.join(QLatin1Char('.')); - if (mod.supportedVersions.contains(targetMajorMinor)) { - return {}; - } - - QStringList supported(mod.supportedVersions.cbegin(), mod.supportedVersions.cend()); - std::ranges::sort(supported); - return {ModIssue{ - .category = QStringLiteral("version_mismatch"), - .severity = IssueSeverity::kWarning, - .shortMessage = QStringLiteral("Version mismatch"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Mod supports %1, but game is on %2") - .arg(supported.join(QStringLiteral(", ")), targetMajorMinor), - .relatedPackageIds = {}, - }}; + if (mod.supportedVersions.isEmpty()) { + return {}; + } + if (ctx.noVersionWarning.contains(mod.packageId)) { + return {}; + } + + // Only major.minor is compared, so a patch-level target still matches "1.5" + // support. + const QStringList targetParts = + ctx.targetVersion.split(QLatin1Char('.')).mid(0, 2); + const QString targetMajorMinor = targetParts.join(QLatin1Char('.')); + if (mod.supportedVersions.contains(targetMajorMinor)) { + return {}; + } + + QStringList supported(mod.supportedVersions.cbegin(), + mod.supportedVersions.cend()); + std::ranges::sort(supported); + return {ModIssue{ + .category = QStringLiteral("version_mismatch"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Version mismatch"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Mod supports %1, but game is on %2") + .arg(supported.join(QStringLiteral(", ")), targetMajorMinor), + .relatedPackageIds = {}, + }}; } QList ReplacementIssueChecker::check(const AboutXmlMod& mod, const CheckContext& ctx) const { - const std::optional pfid = mod.publishedFileId(); - if (!pfid || pfid->isEmpty()) { - return {}; - } - - const auto replacement = ctx.useThisInstead.constFind(*pfid); - if (replacement == ctx.useThisInstead.cend()) { - return {}; - } - const ReplacementInfo& info = replacement.value(); - - return {ModIssue{ - .category = QStringLiteral("replacement_available"), - .severity = IssueSeverity::kWarning, - .shortMessage = QStringLiteral("Replacement available"), - .categoryDisplayName = categoryDisplayName(), - .detailMessage = QStringLiteral("Recommended replacement: %1 (%2) by %3") - .arg(info.name, info.packageid, info.author), - .relatedPackageIds = {PackageId(info.packageid)}, - }}; + const std::optional pfid = mod.publishedFileId(); + if (!pfid || pfid->isEmpty()) { + return {}; + } + + const auto replacement = ctx.useThisInstead.constFind(*pfid); + if (replacement == ctx.useThisInstead.cend()) { + return {}; + } + const ReplacementInfo& info = replacement.value(); + + return {ModIssue{ + .category = QStringLiteral("replacement_available"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Replacement available"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Recommended replacement: %1 (%2) by %3") + .arg(info.name, info.packageid, info.author), + .relatedPackageIds = {PackageId(info.packageid)}, + }}; } } // namespace px::modrim::checker diff --git a/src/core/checker/issues.hpp b/src/core/checker/issues.hpp index 48be787..d8ab99b 100644 --- a/src/core/checker/issues.hpp +++ b/src/core/checker/issues.hpp @@ -1,97 +1,119 @@ #pragma once -#include -#include - #include #include #include +#include +#include #include "core/checker/models.hpp" namespace px::modrim::checker { class ModIssueChecker { -public: - virtual ~ModIssueChecker() = default; - - const QString& categoryDisplayName() const noexcept { return m_categoryDisplayName; } - - // Order-independent results are reused when only the load order changes. - bool orderDependent() const noexcept { return m_orderDependent; } - - virtual bool shouldCheck(const AboutXmlMod&, const CheckContext&) const { return true; } - - // Mods whose results may change when `moved` changed index; nullopt means all mods. - virtual std::optional> affectedByMoves(const CheckContext&, - const QSet&) const { - return std::nullopt; - } - - virtual QList check(const AboutXmlMod& mod, const CheckContext& ctx) const = 0; - -protected: - ModIssueChecker(QString categoryDisplayName, bool orderDependent) - : m_categoryDisplayName(std::move(categoryDisplayName)), - m_orderDependent(orderDependent) {} - -private: - QString m_categoryDisplayName; - bool m_orderDependent; + public: + virtual ~ModIssueChecker() = default; + + const QString& categoryDisplayName() const noexcept { + return m_categoryDisplayName; + } + + // Order-independent results are reused when only the load order changes. + bool orderDependent() const noexcept { return m_orderDependent; } + + virtual bool shouldCheck(const AboutXmlMod&, const CheckContext&) const { + return true; + } + + // Mods whose results may change when `moved` changed index; nullopt means all + // mods. + virtual std::optional> affectedByMoves( + const CheckContext&, const QSet&) const { + return std::nullopt; + } + + virtual QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const = 0; + + protected: + ModIssueChecker(QString categoryDisplayName, bool orderDependent) + : m_categoryDisplayName(std::move(categoryDisplayName)), + m_orderDependent(orderDependent) {} + + private: + QString m_categoryDisplayName; + bool m_orderDependent; }; -// Missing or unsatisfied mod dependencies, honouring alternative package ids when enabled. +// Missing or unsatisfied mod dependencies, honouring alternative package ids +// when enabled. class DependencyIssueChecker final : public ModIssueChecker { -public: - DependencyIssueChecker() : ModIssueChecker(QStringLiteral("Missing Dependency"), false) {} - - bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + public: + DependencyIssueChecker() + : ModIssueChecker(QStringLiteral("Missing Dependency"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; -// Incompatibilities declared by the mod itself, and declared by other active mods against it. +// Incompatibilities declared by the mod itself, and declared by other active +// mods against it. class IncompatibilityIssueChecker final : public ModIssueChecker { -public: - IncompatibilityIssueChecker() : ModIssueChecker(QStringLiteral("Incompatibility"), false) {} - - bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + public: + IncompatibilityIssueChecker() + : ModIssueChecker(QStringLiteral("Incompatibility"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; // Load-before and load-after constraints that the current order violates. class LoadOrderIssueChecker final : public ModIssueChecker { -public: - LoadOrderIssueChecker() : ModIssueChecker(QStringLiteral("Load Order"), true) {} - - bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; - std::optional> affectedByMoves(const CheckContext& ctx, - const QSet& moved) const override; - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + public: + LoadOrderIssueChecker() + : ModIssueChecker(QStringLiteral("Load Order"), true) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + std::optional> affectedByMoves( + const CheckContext& ctx, const QSet& moved) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; // Membership in a dependency cycle reported by the graph. class CycleIssueChecker final : public ModIssueChecker { -public: - CycleIssueChecker() : ModIssueChecker(QStringLiteral("Cycle"), true) {} + public: + CycleIssueChecker() : ModIssueChecker(QStringLiteral("Cycle"), true) {} - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; // Supported game versions that lack the target version. class GameVersionIssueChecker final : public ModIssueChecker { -public: - GameVersionIssueChecker() : ModIssueChecker(QStringLiteral("Version Mismatch"), false) {} - - bool shouldCheck(const AboutXmlMod& mod, const CheckContext& ctx) const override; - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + public: + GameVersionIssueChecker() + : ModIssueChecker(QStringLiteral("Version Mismatch"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; // A known replacement for the mod's Workshop file id. class ReplacementIssueChecker final : public ModIssueChecker { -public: - ReplacementIssueChecker() : ModIssueChecker(QStringLiteral("Replacement Available"), false) {} + public: + ReplacementIssueChecker() + : ModIssueChecker(QStringLiteral("Replacement Available"), false) {} - QList check(const AboutXmlMod& mod, const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; }; } // namespace px::modrim::checker diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp index 0d988db..51ae744 100644 --- a/src/core/checker/models.cpp +++ b/src/core/checker/models.cpp @@ -3,23 +3,23 @@ namespace px::modrim::checker { QString ModDiagnostics::errorTooltip() const { - if (errors.isEmpty()) { - return {}; - } - if (errors.size() == 1) { - return errors.front().shortMessage; - } - return QStringLiteral("%1 errors").arg(errors.size()); + if (errors.isEmpty()) { + return {}; + } + if (errors.size() == 1) { + return errors.front().shortMessage; + } + return QStringLiteral("%1 errors").arg(errors.size()); } QString ModDiagnostics::warningTooltip() const { - if (warnings.isEmpty()) { - return {}; - } - if (warnings.size() == 1) { - return warnings.front().shortMessage; - } - return QStringLiteral("%1 warnings").arg(warnings.size()); + if (warnings.isEmpty()) { + return {}; + } + if (warnings.size() == 1) { + return warnings.front().shortMessage; + } + return QStringLiteral("%1 warnings").arg(warnings.size()); } } // namespace px::modrim::checker diff --git a/src/core/checker/models.hpp b/src/core/checker/models.hpp index 74fd25f..df48762 100644 --- a/src/core/checker/models.hpp +++ b/src/core/checker/models.hpp @@ -1,12 +1,11 @@ #pragma once -#include -#include - #include #include #include #include +#include +#include #include "core/models/metadata/structures.hpp" #include "core/sort/config.hpp" @@ -21,41 +20,41 @@ class ConstraintGraph; enum class IssueSeverity : std::uint8_t { kError, kWarning }; struct ModIssue { - QString category; - IssueSeverity severity; - QString shortMessage; - QString categoryDisplayName; - QString detailMessage; - QList relatedPackageIds; - - bool operator==(const ModIssue&) const = default; + QString category; + IssueSeverity severity; + QString shortMessage; + QString categoryDisplayName; + QString detailMessage; + QList relatedPackageIds; + + bool operator==(const ModIssue&) const = default; }; struct ModDiagnostics { - QList errors; - QList warnings; + QList errors; + QList warnings; - bool hasErrors() const noexcept { return !errors.isEmpty(); } - bool hasWarnings() const noexcept { return !warnings.isEmpty(); } - // The single issue's message, or a count once there is more than one. - QString errorTooltip() const; - QString warningTooltip() const; + bool hasErrors() const noexcept { return !errors.isEmpty(); } + bool hasWarnings() const noexcept { return !warnings.isEmpty(); } + // The single issue's message, or a count once there is more than one. + QString errorTooltip() const; + QString warningTooltip() const; - bool operator==(const ModDiagnostics&) const = default; + bool operator==(const ModDiagnostics&) const = default; }; -// Everything a checker may read while checking one mod. Callers own the referenced data and -// keep it alive for the duration of the check. +// Everything a checker may read while checking one mod. Callers own the +// referenced data and keep it alive for the duration of the check. struct CheckContext { - const QHash& activeMods; - const QList& orderedPids; - const QHash& pidToIndex; - const ConstraintGraph& graph; - const sort::SortSettings& settings; - QString targetVersion = QStringLiteral("1.5"); - const QSet& noVersionWarning; - const QHash& useThisInstead; - const QList>& cycles; + const QHash& activeMods; + const QList& orderedPids; + const QHash& pidToIndex; + const ConstraintGraph& graph; + const sort::SortSettings& settings; + QString targetVersion = QStringLiteral("1.5"); + const QSet& noVersionWarning; + const QHash& useThisInstead; + const QList>& cycles; }; } // namespace px::modrim::checker diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp index 72c6bdf..c8e6b42 100644 --- a/src/core/checker/sort.cpp +++ b/src/core/checker/sort.cpp @@ -14,235 +14,229 @@ namespace { using sort::PackageGraph; using sort::Tier; -// Dependency edges point from a mod to the mods that must load before it; load-before edges are -// reversed so both shapes end up in the same map. -std::pair buildDepsAndRevDeps(const ConstraintGraph& graph, - const QSet& allPids) { - PackageGraph deps; - PackageGraph revDeps; - for (const PackageId& pid : allPids) { - deps.insert(pid, {}); - revDeps.insert(pid, {}); +// Dependency edges point from a mod to the mods that must load before it; +// load-before edges are reversed so both shapes end up in the same map. +std::pair buildDepsAndRevDeps( + const ConstraintGraph& graph, const QSet& allPids) { + PackageGraph deps; + PackageGraph revDeps; + for (const PackageId& pid : allPids) { + deps.insert(pid, {}); + revDeps.insert(pid, {}); + } + + for (const PackageId& pid : graph.nodes()) { + if (!allPids.contains(pid)) { + continue; } - - for (const PackageId& pid : graph.nodes()) { - if (!allPids.contains(pid)) { - continue; - } - for (const ConstraintEdge& edge : graph.outgoing(pid)) { - if (!allPids.contains(edge.target)) { - continue; - } - switch (edge.type) { - case EdgeType::kLoadBefore: - deps[edge.target].insert(pid); - revDeps[pid].insert(edge.target); - break; - case EdgeType::kLoadAfter: - case EdgeType::kDependency: - deps[pid].insert(edge.target); - revDeps[edge.target].insert(pid); - break; - case EdgeType::kIncompatibility: - case EdgeType::kAlternative: - break; - } - } + for (const ConstraintEdge& edge : graph.outgoing(pid)) { + if (!allPids.contains(edge.target)) { + continue; + } + switch (edge.type) { + case EdgeType::kLoadBefore: + deps[edge.target].insert(pid); + revDeps[pid].insert(edge.target); + break; + case EdgeType::kLoadAfter: + case EdgeType::kDependency: + deps[pid].insert(edge.target); + revDeps[edge.target].insert(pid); + break; + case EdgeType::kIncompatibility: + case EdgeType::kAlternative: + break; + } } + } - return {std::move(deps), std::move(revDeps)}; + return {std::move(deps), std::move(revDeps)}; } -// Mods that community rules flag through `flag`, or none when community rules are disabled. +// Mods that community rules flag through `flag`, or none when community rules +// are disabled. QSet communityFlagged(const sort::CommunityRules* rules, - const QSet& allPids, - const sort::SortSettings& settings, - bool sort::CommunityRule::*flag) { - QSet flagged; - if (!settings.use_community_rules || rules == nullptr || rules->isEmpty()) { - return flagged; - } - for (const PackageId& pid : allPids) { - const auto rule = rules->constFind(pid); - if (rule != rules->cend() && rule.value().*flag) { - flagged.insert(pid); - } - } + const QSet& allPids, + const sort::SortSettings& settings, + bool sort::CommunityRule::* flag) { + QSet flagged; + if (!settings.use_community_rules || rules == nullptr || rules->isEmpty()) { return flagged; + } + for (const PackageId& pid : allPids) { + const auto rule = rules->constFind(pid); + if (rule != rules->cend() && rule.value().*flag) { + flagged.insert(pid); + } + } + return flagged; } -// Position of each package id in the tier config, so the user's explicit picks sort first. +// Position of each package id in the tier config, so the user's explicit picks +// sort first. QHash buildConfigPriority(const sort::TierConfig& tierConfig) { - QHash priority; - for (std::size_t i = 0; i < tierConfig.tier_0.size(); ++i) { - priority.insert(tierConfig.tier_0[i], static_cast(i)); - } - // Tier 1 and tier 3 only fill in ids that tier 0 did not already place, and they are - // numbered after the earlier lists. - int base = static_cast(tierConfig.tier_0.size()); - for (const auto* list : {&tierConfig.tier_1, &tierConfig.tier_3}) { - for (std::size_t i = 0; i < list->size(); ++i) { - const PackageId& pid = (*list)[i]; - if (!priority.contains(pid)) { - priority.insert(pid, base + static_cast(i)); - } - } - base += static_cast(list->size()); + QHash priority; + for (std::size_t i = 0; i < tierConfig.tier_0.size(); ++i) { + priority.insert(tierConfig.tier_0[i], static_cast(i)); + } + // Tier 1 and tier 3 only fill in ids that tier 0 did not already place, and + // they are numbered after the earlier lists. + int base = static_cast(tierConfig.tier_0.size()); + for (const auto* list : {&tierConfig.tier_1, &tierConfig.tier_3}) { + for (std::size_t i = 0; i < list->size(); ++i) { + const PackageId& pid = (*list)[i]; + if (!priority.contains(pid)) { + priority.insert(pid, base + static_cast(i)); + } } - return priority; + base += static_cast(list->size()); + } + return priority; } struct SortKey { - int priority; - QString name; - PackageId pid; + int priority; + QString name; + PackageId pid; }; -SortKey makeKey(const PackageId& pid, const QHash& configPriority) { - return SortKey{configPriority.value(pid, static_cast(configPriority.size())), - pid.str(), - pid}; +SortKey makeKey(const PackageId& pid, + const QHash& configPriority) { + return SortKey{ + configPriority.value(pid, static_cast(configPriority.size())), + pid.str(), pid}; } bool keyLess(const SortKey& a, const SortKey& b) { - if (a.priority != b.priority) { - return a.priority < b.priority; - } - return a.name < b.name; + if (a.priority != b.priority) { + return a.priority < b.priority; + } + return a.name < b.name; } // Heap order that pops the smallest key first. -bool keyGreater(const SortKey& a, const SortKey& b) { - return keyLess(b, a); -} +bool keyGreater(const SortKey& a, const SortKey& b) { return keyLess(b, a); } } // namespace -QList topologicalSort(const QHash>& activeMods, - const ConstraintGraph& graph, - const sort::SortSettings& settings, - const sort::CommunityRules* communityRules, - Profiler* timer) { - Profiler local; - Profiler& t = timer != nullptr ? *timer : local; - - const QSet allPids(activeMods.keyBegin(), activeMods.keyEnd()); - if (allPids.isEmpty()) { - return {}; - } - - PackageGraph deps; - PackageGraph revDeps; - { - auto scope = t("build_deps"); - std::tie(deps, revDeps) = buildDepsAndRevDeps(graph, allPids); - } - QSet loadFirst; - { - auto scope = t("build_load_first"); - loadFirst = communityFlagged(communityRules, - allPids, - settings, - &sort::CommunityRule::loadFirst); +QList topologicalSort( + const QHash>& activeMods, + const ConstraintGraph& graph, const sort::SortSettings& settings, + const sort::CommunityRules* communityRules, Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + const QSet allPids(activeMods.keyBegin(), activeMods.keyEnd()); + if (allPids.isEmpty()) { + return {}; + } + + PackageGraph deps; + PackageGraph revDeps; + { + auto scope = t("build_deps"); + std::tie(deps, revDeps) = buildDepsAndRevDeps(graph, allPids); + } + QSet loadFirst; + { + auto scope = t("build_load_first"); + loadFirst = communityFlagged(communityRules, allPids, settings, + &sort::CommunityRule::loadFirst); + } + QSet loadLast; + { + auto scope = t("build_load_last"); + loadLast = communityFlagged(communityRules, allPids, settings, + &sort::CommunityRule::loadLast); + } + + QHash tiers; + { + auto scope = t("assign_tiers"); + tiers = sort::assignTiers(allPids, deps, revDeps, loadFirst, loadLast, + settings.tier_config); + } + + constexpr std::array, 4> kTierPasses{{ + {Tier::kTier0, "kahn_tier_0"}, + {Tier::kTier1, "kahn_tier_1"}, + {Tier::kTier2, "kahn_tier_2"}, + {Tier::kTier3, "kahn_tier_3"}, + }}; + const QHash configPriority = + buildConfigPriority(settings.tier_config); + + QList order; + QSet ordered; + for (const auto& [tier, label] : kTierPasses) { + QSet tierPids; + for (const PackageId& pid : allPids) { + if (ordered.contains(pid)) { + continue; + } + const auto assigned = tiers.constFind(pid); + if (assigned != tiers.cend() && assigned.value() == tier) { + tierPids.insert(pid); + } } - QSet loadLast; - { - auto scope = t("build_load_last"); - loadLast = communityFlagged(communityRules, - allPids, - settings, - &sort::CommunityRule::loadLast); + if (tierPids.isEmpty()) { + continue; } - QHash tiers; - { - auto scope = t("assign_tiers"); - tiers = sort::assignTiers(allPids, - deps, - revDeps, - loadFirst, - loadLast, - settings.tier_config); + auto scope = t(label); + + // Dependencies are restricted to this tier: earlier tiers are already + // placed, and later tiers must not hold up this one. + QHash indegree; + QHash> rev; + for (const PackageId& pid : tierPids) { + QSet subDeps = deps.value(pid); + subDeps.intersect(tierPids); + indegree.insert(pid, static_cast(subDeps.size())); + for (const PackageId& dep : subDeps) { + rev[dep].insert(pid); + } } - constexpr std::array, 4> kTierPasses{{ - {Tier::kTier0, "kahn_tier_0"}, - {Tier::kTier1, "kahn_tier_1"}, - {Tier::kTier2, "kahn_tier_2"}, - {Tier::kTier3, "kahn_tier_3"}, - }}; - const QHash configPriority = buildConfigPriority(settings.tier_config); - - QList order; - QSet ordered; - for (const auto& [tier, label] : kTierPasses) { - QSet tierPids; - for (const PackageId& pid : allPids) { - if (ordered.contains(pid)) { - continue; - } - const auto assigned = tiers.constFind(pid); - if (assigned != tiers.cend() && assigned.value() == tier) { - tierPids.insert(pid); - } - } - if (tierPids.isEmpty()) { - continue; - } - - auto scope = t(label); - - // Dependencies are restricted to this tier: earlier tiers are already placed, and - // later tiers must not hold up this one. - QHash indegree; - QHash> rev; - for (const PackageId& pid : tierPids) { - QSet subDeps = deps.value(pid); - subDeps.intersect(tierPids); - indegree.insert(pid, static_cast(subDeps.size())); - for (const PackageId& dep : subDeps) { - rev[dep].insert(pid); - } - } - - std::vector queue; - for (const PackageId& pid : tierPids) { - if (indegree.value(pid) == 0) { - queue.push_back(makeKey(pid, configPriority)); - } - } - std::ranges::make_heap(queue, keyGreater); - - while (!queue.empty()) { - std::ranges::pop_heap(queue, keyGreater); - const PackageId pid = queue.back().pid; - queue.pop_back(); - order.append(pid); - ordered.insert(pid); - - for (const PackageId& dependent : rev.value(pid)) { - if (--indegree[dependent] == 0) { - queue.push_back(makeKey(dependent, configPriority)); - std::ranges::push_heap(queue, keyGreater); - } - } + std::vector queue; + for (const PackageId& pid : tierPids) { + if (indegree.value(pid) == 0) { + queue.push_back(makeKey(pid, configPriority)); + } + } + std::ranges::make_heap(queue, keyGreater); + + while (!queue.empty()) { + std::ranges::pop_heap(queue, keyGreater); + const PackageId pid = queue.back().pid; + queue.pop_back(); + order.append(pid); + ordered.insert(pid); + + for (const PackageId& dependent : rev.value(pid)) { + if (--indegree[dependent] == 0) { + queue.push_back(makeKey(dependent, configPriority)); + std::ranges::push_heap(queue, keyGreater); } + } + } - // Whatever is left sits on a dependency cycle; keep it deterministic at the tier's end. - std::vector remaining; - for (const PackageId& pid : tierPids) { - if (!ordered.contains(pid)) { - remaining.push_back(makeKey(pid, configPriority)); - } - } - std::ranges::sort(remaining, keyLess); - for (const SortKey& key : remaining) { - order.append(key.pid); - ordered.insert(key.pid); - } + // Whatever is left sits on a dependency cycle; keep it deterministic at the + // tier's end. + std::vector remaining; + for (const PackageId& pid : tierPids) { + if (!ordered.contains(pid)) { + remaining.push_back(makeKey(pid, configPriority)); + } + } + std::ranges::sort(remaining, keyLess); + for (const SortKey& key : remaining) { + order.append(key.pid); + ordered.insert(key.pid); } + } - return order; + return order; } } // namespace px::modrim::checker diff --git a/src/core/checker/sort.hpp b/src/core/checker/sort.hpp index be4d1ea..b80ac8b 100644 --- a/src/core/checker/sort.hpp +++ b/src/core/checker/sort.hpp @@ -1,9 +1,8 @@ #pragma once -#include - #include #include +#include #include "core/checker/graph.hpp" #include "core/checker/models.hpp" @@ -13,13 +12,13 @@ namespace px::modrim::checker { -// Orders active mods by tier, then by dependencies and load-order constraints, breaking ties by -// the tier config priority and then by package id. Mods caught in a cycle are appended at the -// end of their tier in the same key order. -QList topologicalSort(const QHash>& activeMods, - const ConstraintGraph& graph, - const sort::SortSettings& settings, - const sort::CommunityRules* communityRules = nullptr, - Profiler* timer = nullptr); +// Orders active mods by tier, then by dependencies and load-order constraints, +// breaking ties by the tier config priority and then by package id. Mods caught +// in a cycle are appended at the end of their tier in the same key order. +QList topologicalSort( + const QHash>& activeMods, + const ConstraintGraph& graph, const sort::SortSettings& settings, + const sort::CommunityRules* communityRules = nullptr, + Profiler* timer = nullptr); } // namespace px::modrim::checker diff --git a/src/core/config.cpp b/src/core/config.cpp index c7895b4..90e8f1f 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -1,9 +1,5 @@ #include "core/config.hpp" -#include -#include -#include - #include #include #include @@ -13,6 +9,10 @@ #include #include #include +#include +#include +#include + #include #include "core/constants.hpp" @@ -31,10 +31,10 @@ constexpr double kMaxSchemaVersion = 1e15; QString steamAppId() { return QString(kRimworldSteamAppId); } QString joinPath(QString path, std::initializer_list parts) { - for (const auto& part : parts) { - path.append(u'/').append(part); - } - return QDir::cleanPath(path); + for (const auto& part : parts) { + path.append(u'/').append(part); + } + return QDir::cleanPath(path); } bool isDir(const QString& path) { return QFileInfo(path).isDir(); } @@ -43,231 +43,270 @@ QString findRimworldInSteamRoot(const QString& root, const QString& steamId); // Parses a libraryfolders.vdf for an alternate library holding RimWorld. QString vdfFindRimworld(const QString& vdfPath, const QString& steamId) { - QFile file(vdfPath); - if (!file.open(QIODevice::ReadOnly)) { - spdlog::warn("Failed to parse libraryfolders.vdf"); - return {}; - } - const QString text = QString::fromUtf8(file.readAll()); - - // Matches `"path" ""` and `"" {` lines of libraryfolders.vdf. - static const QRegularExpression pathPattern(QStringLiteral(R"re("path"\s+"(.+?)")re")); - const QRegularExpression appPattern(QStringLiteral("\"") + QRegularExpression::escape(steamId) + - QStringLiteral(R"re("\s*\{)re")); - - QString currentPath; - for (const auto& rawLine : text.split(u'\n')) { - const QString line = rawLine.trimmed(); - if (const auto match = pathPattern.match(line); match.hasMatch()) { - currentPath = match.captured(1); - continue; - } - if (!currentPath.isEmpty() && appPattern.match(line).hasMatch()) { - const QString game = joinPath(currentPath, {QStringLiteral("steamapps"), - QStringLiteral("common"), - QStringLiteral("RimWorld")}); - if (isDir(game)) { - return game; - } - } - } + QFile file(vdfPath); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::warn("Failed to parse libraryfolders.vdf"); return {}; + } + const QString text = QString::fromUtf8(file.readAll()); + + // Matches `"path" ""` and `"" {` lines of libraryfolders.vdf. + static const QRegularExpression pathPattern( + QStringLiteral(R"re("path"\s+"(.+?)")re")); + const QRegularExpression appPattern(QStringLiteral("\"") + + QRegularExpression::escape(steamId) + + QStringLiteral(R"re("\s*\{)re")); + + QString currentPath; + for (const auto& rawLine : text.split(u'\n')) { + const QString line = rawLine.trimmed(); + if (const auto match = pathPattern.match(line); match.hasMatch()) { + currentPath = match.captured(1); + continue; + } + if (!currentPath.isEmpty() && appPattern.match(line).hasMatch()) { + const QString game = joinPath( + currentPath, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return game; + } + } + } + return {}; } QString findRimworldInSteamRoot(const QString& root, const QString& steamId) { - const QString game = joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("common"), - QStringLiteral("RimWorld")}); - if (isDir(game)) { - return game; - } - const QString configVdf = joinPath(root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); - if (QFileInfo::exists(configVdf)) { - return vdfFindRimworld(configVdf, steamId); - } - const QString steamappsVdf = - joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); - if (QFileInfo::exists(steamappsVdf)) { - return vdfFindRimworld(steamappsVdf, steamId); - } - return {}; + const QString game = + joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return game; + } + const QString configVdf = joinPath( + root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(configVdf)) { + return vdfFindRimworld(configVdf, steamId); + } + const QString steamappsVdf = joinPath( + root, + {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(steamappsVdf)) { + return vdfFindRimworld(steamappsVdf, steamId); + } + return {}; } // Workshop content lives beside the `common` folder that holds the game. -QString workshopPathFromGame(const QString& game, const QString& steamRoot, const QString& steamId) { - const QString normalized = QDir::fromNativeSeparators(game); - const QStringList parts = normalized.split(u'/', Qt::SkipEmptyParts); - const qsizetype common = parts.indexOf(QStringLiteral("common")); - if (common >= 0) { - QString base = parts.mid(0, common).join(u'/'); - if (normalized.startsWith(u'/')) { - base.prepend(u'/'); - } - const QString workshop = - joinPath(base, {QStringLiteral("workshop"), QStringLiteral("content"), steamId}); - if (isDir(workshop)) { - return workshop; - } - } - return joinPath(steamRoot, {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steamId}); +QString workshopPathFromGame(const QString& game, const QString& steamRoot, + const QString& steamId) { + const QString normalized = QDir::fromNativeSeparators(game); + const QStringList parts = normalized.split(u'/', Qt::SkipEmptyParts); + const qsizetype common = parts.indexOf(QStringLiteral("common")); + if (common >= 0) { + QString base = parts.mid(0, common).join(u'/'); + if (normalized.startsWith(u'/')) { + base.prepend(u'/'); + } + const QString workshop = joinPath( + base, {QStringLiteral("workshop"), QStringLiteral("content"), steamId}); + if (isDir(workshop)) { + return workshop; + } + } + return joinPath(steamRoot, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}); } struct GamePaths { - QString game; - QString local; - QString workshop; + QString game; + QString local; + QString workshop; }; GamePaths gamePathsFor(const QString& game, const QString& workshop) { - return GamePaths{ - .game = game, - .local = joinPath(game, {QStringLiteral("Mods")}), - .workshop = workshop, - }; + return GamePaths{ + .game = game, + .local = joinPath(game, {QStringLiteral("Mods")}), + .workshop = workshop, + }; } #if defined(Q_OS_LINUX) GamePaths findGamePaths(QString& foundRoot) { - const QString steamId = steamAppId(); - const QString home = QDir::homePath(); - const QStringList candidates = { - joinPath(home, {QStringLiteral(".steam"), QStringLiteral("debian-installation")}), - joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), - joinPath(home, {QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), - joinPath(home, {QStringLiteral(".var"), QStringLiteral("app"), QStringLiteral("com.valvesoftware.Steam"), - QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), - joinPath(home, {QStringLiteral("snap"), QStringLiteral("steam"), QStringLiteral("common"), - QStringLiteral(".local"), QStringLiteral("share"), QStringLiteral("Steam")}), - }; - for (const auto& root : candidates) { - if (!isDir(root)) { - continue; - } - if (const QString game = findRimworldInSteamRoot(root, steamId); !game.isEmpty()) { - foundRoot = root; - return gamePathsFor(game, workshopPathFromGame(game, root, steamId)); - } - } - - const QString fallbackRoot = joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); - const QString fallback = - joinPath(fallbackRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (isDir(fallback)) { - foundRoot = isDir(fallbackRoot) ? fallbackRoot : QString(); - return gamePathsFor(fallback, joinPath(fallbackRoot, {QStringLiteral("steamapps"), - QStringLiteral("workshop"), - QStringLiteral("content"), steamId})); - } - return {}; + const QString steamId = steamAppId(); + const QString home = QDir::homePath(); + const QStringList candidates = { + joinPath(home, {QStringLiteral(".steam"), + QStringLiteral("debian-installation")}), + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), + joinPath(home, {QStringLiteral(".local"), QStringLiteral("share"), + QStringLiteral("Steam")}), + joinPath(home, {QStringLiteral(".var"), QStringLiteral("app"), + QStringLiteral("com.valvesoftware.Steam"), + QStringLiteral(".local"), QStringLiteral("share"), + QStringLiteral("Steam")}), + joinPath(home, {QStringLiteral("snap"), QStringLiteral("steam"), + QStringLiteral("common"), QStringLiteral(".local"), + QStringLiteral("share"), QStringLiteral("Steam")}), + }; + for (const auto& root : candidates) { + if (!isDir(root)) { + continue; + } + if (const QString game = findRimworldInSteamRoot(root, steamId); + !game.isEmpty()) { + foundRoot = root; + return gamePathsFor(game, workshopPathFromGame(game, root, steamId)); + } + } + + const QString fallbackRoot = + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); + const QString fallback = joinPath( + fallbackRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(fallback)) { + foundRoot = isDir(fallbackRoot) ? fallbackRoot : QString(); + return gamePathsFor( + fallback, joinPath(fallbackRoot, {QStringLiteral("steamapps"), + QStringLiteral("workshop"), + QStringLiteral("content"), steamId})); + } + return {}; } #elif defined(Q_OS_MACOS) QString find_mac_app(const QString& rimworld_dir) { - if (!is_dir(rimworld_dir)) { - return {}; - } - const QStringList apps = QDir(rimworld_dir) - .entryList({QStringLiteral("*.app")}, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); - return apps.isEmpty() ? QString() : join_path(rimworld_dir, {apps.first()}); + if (!is_dir(rimworld_dir)) { + return {}; + } + const QStringList apps = + QDir(rimworld_dir) + .entryList({QStringLiteral("*.app")}, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name); + return apps.isEmpty() ? QString() : join_path(rimworld_dir, {apps.first()}); } GamePaths find_game_paths(QString& found_root) { - const QString steam_id = steam_app_id(); - const QString steam_root = - join_path(QDir::homePath(), {QStringLiteral("Library"), QStringLiteral("Application Support"), - QStringLiteral("Steam")}); - found_root = is_dir(steam_root) ? steam_root : QString(); - if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); !game.isEmpty()) { - return game_paths_for(game, workshop_path_from_game(game, steam_root, steam_id)); - } - const QString fallback = - join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (const QString app_bundle = find_mac_app(fallback); !app_bundle.isEmpty()) { - return GamePaths{ - .game = app_bundle, - .local = join_path(fallback, {QStringLiteral("Mods")}), - .workshop = join_path(steam_root, {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steam_id}), - }; - } - return {}; + const QString steam_id = steam_app_id(); + const QString steam_root = + join_path(QDir::homePath(), {QStringLiteral("Library"), + QStringLiteral("Application Support"), + QStringLiteral("Steam")}); + found_root = is_dir(steam_root) ? steam_root : QString(); + if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); + !game.isEmpty()) { + return game_paths_for(game, + workshop_path_from_game(game, steam_root, steam_id)); + } + const QString fallback = join_path( + steam_root, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (const QString app_bundle = find_mac_app(fallback); + !app_bundle.isEmpty()) { + return GamePaths{ + .game = app_bundle, + .local = join_path(fallback, {QStringLiteral("Mods")}), + .workshop = + join_path(steam_root, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steam_id}), + }; + } + return {}; } #elif defined(Q_OS_WIN) // Steam's install location is recorded in the registry; the 32-bit view is // checked first, as the legacy detection did. GamePaths find_game_paths(QString& found_root) { - const QString steam_id = steam_app_id(); - const QStringList registry_keys = { - QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam)"), - QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam)"), - }; - for (const auto& key : registry_keys) { - const QString steam_root = QSettings(key, QSettings::NativeFormat).value(QStringLiteral("InstallPath")).toString(); - if (steam_root.isEmpty()) { - spdlog::debug("Unable to inspect Windows Steam registry key {}", key.toStdString()); - continue; - } - if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); !game.isEmpty()) { - return game_paths_for(game, workshop_path_from_game(game, steam_root, steam_id)); - } - } - - const QString fallback = QStringLiteral("C:/Program Files (x86)/Steam"); - const QString game = - join_path(fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), QStringLiteral("RimWorld")}); - if (is_dir(game)) { - return game_paths_for(game, join_path(fallback, {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steam_id})); - } - return {}; + const QString steam_id = steam_app_id(); + const QStringList registry_keys = { + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam)"), + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam)"), + }; + for (const auto& key : registry_keys) { + const QString steam_root = QSettings(key, QSettings::NativeFormat) + .value(QStringLiteral("InstallPath")) + .toString(); + if (steam_root.isEmpty()) { + spdlog::debug("Unable to inspect Windows Steam registry key {}", + key.toStdString()); + continue; + } + if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); + !game.isEmpty()) { + return game_paths_for( + game, workshop_path_from_game(game, steam_root, steam_id)); + } + } + + const QString fallback = QStringLiteral("C:/Program Files (x86)/Steam"); + const QString game = join_path( + fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (is_dir(game)) { + return game_paths_for( + game, join_path(fallback, {QStringLiteral("steamapps"), + QStringLiteral("workshop"), + QStringLiteral("content"), steam_id})); + } + return {}; } #else GamePaths find_game_paths(QString&) { return {}; } #endif -// Locates the RimWorld Config folder, which the game writes beside its save data. +// Locates the RimWorld Config folder, which the game writes beside its save +// data. QString detectConfigFolder([[maybe_unused]] const QString& steamRoot) { #if defined(Q_OS_LINUX) - const QString steamId = steamAppId(); - if (!steamRoot.isEmpty()) { - const QString proton = - joinPath(steamRoot, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), steamId, - QStringLiteral("pfx"), QStringLiteral("drive_c"), QStringLiteral("users"), - QStringLiteral("steamuser"), QStringLiteral("AppData"), - QStringLiteral("LocalLow"), QStringLiteral("Ludeon Studios"), - QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); - if (isDir(proton)) { - spdlog::debug("Detected Proton config folder: {}", proton.toStdString()); - return proton; - } - } - const QString native = joinPath(QDir::homePath(), {QStringLiteral(".config"), QStringLiteral("unity3d"), - QStringLiteral("Ludeon Studios"), - QStringLiteral("RimWorld by Ludeon Studios"), - QStringLiteral("Config")}); - if (isDir(native)) { - spdlog::debug("Detected native config folder: {}", native.toStdString()); - return native; - } + const QString steamId = steamAppId(); + if (!steamRoot.isEmpty()) { + const QString proton = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), + steamId, QStringLiteral("pfx"), QStringLiteral("drive_c"), + QStringLiteral("users"), QStringLiteral("steamuser"), + QStringLiteral("AppData"), QStringLiteral("LocalLow"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), + QStringLiteral("Config")}); + if (isDir(proton)) { + spdlog::debug("Detected Proton config folder: {}", proton.toStdString()); + return proton; + } + } + const QString native = joinPath( + QDir::homePath(), + {QStringLiteral(".config"), QStringLiteral("unity3d"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); + if (isDir(native)) { + spdlog::debug("Detected native config folder: {}", native.toStdString()); + return native; + } #elif defined(Q_OS_MACOS) - const QString config = join_path(QDir::homePath(), {QStringLiteral("Library"), - QStringLiteral("Application Support"), - QStringLiteral("Rimworld"), QStringLiteral("Config")}); - if (is_dir(config)) { - spdlog::debug("Detected macOS config folder: {}", config.toStdString()); - return config; - } + const QString config = join_path( + QDir::homePath(), + {QStringLiteral("Library"), QStringLiteral("Application Support"), + QStringLiteral("Rimworld"), QStringLiteral("Config")}); + if (is_dir(config)) { + spdlog::debug("Detected macOS config folder: {}", config.toStdString()); + return config; + } #elif defined(Q_OS_WIN) - const QString config = join_path(QDir::homePath(), {QStringLiteral("AppData"), QStringLiteral("LocalLow"), - QStringLiteral("Ludeon Studios"), - QStringLiteral("RimWorld by Ludeon Studios"), - QStringLiteral("Config")}); - if (is_dir(config)) { - spdlog::debug("Detected Windows config folder: {}", config.toStdString()); - return config; - } + const QString config = join_path( + QDir::homePath(), + {QStringLiteral("AppData"), QStringLiteral("LocalLow"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); + if (is_dir(config)) { + spdlog::debug("Detected Windows config folder: {}", config.toStdString()); + return config; + } #endif - return {}; + return {}; } } // namespace @@ -278,177 +317,199 @@ ConfigService::ConfigService(QString configDir, JsonMigrations migrations) const QString& ConfigService::directory() const noexcept { return m_dir; } QString ConfigService::filePath(const QString& filename) const { - return QDir(m_dir).filePath(filename); + return QDir(m_dir).filePath(filename); } bool ConfigService::isVersioned(const QString& filename) { - return filename == kConfigFile || filename == kUiPrefsFile; + return filename == kConfigFile || filename == kUiPrefsFile; } std::string ConfigService::withSchemaMarker(std::string prettyObject) { - // Pretty output ends with the object's closing brace; the marker goes after the - // last non-space character before it, which is the last member's value or the opening brace. - const auto close = prettyObject.rfind('}'); - if (close == std::string::npos) { - return prettyObject; - } - auto last = prettyObject.find_last_not_of(" \t\r\n", close - 1); - if (last == std::string::npos) { - return prettyObject; - } - const bool emptyObject = prettyObject[last] == '{'; - const std::string marker = std::string("\"") + std::string(kSchemaVersionKey) + "\": " + - std::to_string(kCurrentConfigSchemaVersion); - prettyObject.insert(last + 1, emptyObject ? "\n " + marker + "\n" : ",\n " + marker); + // Pretty output ends with the object's closing brace; the marker goes after + // the last non-space character before it, which is the last member's value or + // the opening brace. + const auto close = prettyObject.rfind('}'); + if (close == std::string::npos) { + return prettyObject; + } + auto last = prettyObject.find_last_not_of(" \t\r\n", close - 1); + if (last == std::string::npos) { return prettyObject; + } + const bool emptyObject = prettyObject[last] == '{'; + const std::string marker = + std::string("\"") + std::string(kSchemaVersionKey) + + "\": " + std::to_string(kCurrentConfigSchemaVersion); + prettyObject.insert(last + 1, + emptyObject ? "\n " + marker + "\n" : ",\n " + marker); + return prettyObject; } JsonObject ConfigService::loadRaw(const QString& filename) const { - auto bytes = readFileBytes(filePath(filename)); - if (!bytes) { - return {}; - } - glz::json_t document; - if (glz::read_json(document, *bytes) || !document.is_object()) { - return {}; - } - return document.get_object(); + auto bytes = readFileBytes(filePath(filename)); + if (!bytes) { + return {}; + } + glz::json_t document; + if (glz::read_json(document, *bytes) || !document.is_object()) { + return {}; + } + return document.get_object(); } -Result> ConfigService::loadVersioned(const QString& filename) const { - const QString path = filePath(filename); - auto bytes = readFileBytes(path); - if (!bytes) { - spdlog::warn("Failed to load {}: {}", filename.toStdString(), bytes.error().message.toStdString()); - return std::optional{}; - } - - glz::json_t document; - if (auto error = glz::read_json(document, *bytes)) { - spdlog::warn("Failed to load {}: {}", filename.toStdString(), glz::format_error(error, *bytes)); - return std::optional{}; - } - if (!document.is_object()) { - spdlog::warn("Failed to load {}: top-level value is not an object", filename.toStdString()); +Result> ConfigService::loadVersioned( + const QString& filename) const { + const QString path = filePath(filename); + auto bytes = readFileBytes(path); + if (!bytes) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + bytes.error().message.toStdString()); + return std::optional{}; + } + + glz::json_t document; + if (auto error = glz::read_json(document, *bytes)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + glz::format_error(error, *bytes)); + return std::optional{}; + } + if (!document.is_object()) { + spdlog::warn("Failed to load {}: top-level value is not an object", + filename.toStdString()); + return std::optional{}; + } + JsonObject raw = document.get_object(); + + // A missing marker or an explicit null means schema version 0. + std::int64_t version = 0; + if (auto marker = raw.find(std::string(kSchemaVersionKey)); + marker != raw.end()) { + const auto& value = marker->second; + if (!value.is_null()) { + const bool valid = value.is_number() && value.get() >= 0 && + std::trunc(value.get()) == value.get(); + if (!valid) { + spdlog::warn("Failed to load {}: Invalid JSON schema version", + filename.toStdString()); return std::optional{}; + } + version = static_cast( + std::min(value.get(), kMaxSchemaVersion)); } - JsonObject raw = document.get_object(); - - // A missing marker or an explicit null means schema version 0. - std::int64_t version = 0; - if (auto marker = raw.find(std::string(kSchemaVersionKey)); marker != raw.end()) { - const auto& value = marker->second; - if (!value.is_null()) { - const bool valid = value.is_number() && value.get() >= 0 && - std::trunc(value.get()) == value.get(); - if (!valid) { - spdlog::warn("Failed to load {}: Invalid JSON schema version", filename.toStdString()); - return std::optional{}; - } - version = static_cast(std::min(value.get(), kMaxSchemaVersion)); - } - raw.erase(marker); - } + raw.erase(marker); + } - if (auto migrated = migrate(raw, version); !migrated) { - return std::unexpected(migrated.error()); - } - if (version == kCurrentConfigSchemaVersion) { - return std::optional(std::move(raw)); - } - - // Keep the pre-migration file as a backup before the rewritten one replaces it. - const QString backup = path + QStringLiteral(".bak.") + QString::number(QDateTime::currentSecsSinceEpoch()); - if (auto copied = copyFileReplacing(path, backup); !copied) { - spdlog::warn("Failed to load {}: {}", filename.toStdString(), copied.error().message.toStdString()); - return std::optional{}; - } - raw[std::string(kSchemaVersionKey)] = static_cast(kCurrentConfigSchemaVersion); - auto text = glz::write(raw); - if (!text) { - spdlog::warn("Failed to load {}: cannot encode migrated document", filename.toStdString()); - return std::optional{}; - } - if (auto written = writeFileAtomically(path, *text); !written) { - spdlog::warn("Failed to load {}: {}", filename.toStdString(), written.error().message.toStdString()); - return std::optional{}; - } + if (auto migrated = migrate(raw, version); !migrated) { + return std::unexpected(migrated.error()); + } + if (version == kCurrentConfigSchemaVersion) { return std::optional(std::move(raw)); + } + + // Keep the pre-migration file as a backup before the rewritten one replaces + // it. + const QString backup = path + QStringLiteral(".bak.") + + QString::number(QDateTime::currentSecsSinceEpoch()); + if (auto copied = copyFileReplacing(path, backup); !copied) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + copied.error().message.toStdString()); + return std::optional{}; + } + raw[std::string(kSchemaVersionKey)] = + static_cast(kCurrentConfigSchemaVersion); + auto text = glz::write(raw); + if (!text) { + spdlog::warn("Failed to load {}: cannot encode migrated document", + filename.toStdString()); + return std::optional{}; + } + if (auto written = writeFileAtomically(path, *text); !written) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + written.error().message.toStdString()); + return std::optional{}; + } + return std::optional(std::move(raw)); } -Result ConfigService::migrate(JsonObject& data, std::int64_t current) const { - if (current > kCurrentConfigSchemaVersion) { - return std::unexpected(Error{QStringLiteral("Unsupported JSON schema version %1; latest is %2") - .arg(current) - .arg(kCurrentConfigSchemaVersion)}); - } - for (std::int64_t target = current + 1; target <= kCurrentConfigSchemaVersion; ++target) { - const auto migration = m_migrations.find(static_cast(target)); - if (migration == m_migrations.end()) { - return std::unexpected( - Error{QStringLiteral("Missing JSON migration step for target version %1").arg(target)}); - } - migration->second(data); - } - return {}; +Result ConfigService::migrate(JsonObject& data, + std::int64_t current) const { + if (current > kCurrentConfigSchemaVersion) { + return std::unexpected( + Error{QStringLiteral("Unsupported JSON schema version %1; latest is %2") + .arg(current) + .arg(kCurrentConfigSchemaVersion)}); + } + for (std::int64_t target = current + 1; target <= kCurrentConfigSchemaVersion; + ++target) { + const auto migration = m_migrations.find(static_cast(target)); + if (migration == m_migrations.end()) { + return std::unexpected(Error{ + QStringLiteral("Missing JSON migration step for target version %1") + .arg(target)}); + } + migration->second(data); + } + return {}; } QString configDir() { - const QString base = qEnvironmentVariable("XDG_CONFIG_HOME", QDir::homePath() + QStringLiteral("/.config")); - return QDir(base).filePath(QStringLiteral("pxmodrim")); + const QString base = qEnvironmentVariable( + "XDG_CONFIG_HOME", QDir::homePath() + QStringLiteral("/.config")); + return QDir(base).filePath(QStringLiteral("pxmodrim")); } QString communityRulesFile() { - return QDir(configDir()).filePath(QStringLiteral("communityRules.json")); + return QDir(configDir()).filePath(QStringLiteral("communityRules.json")); } std::optional readGameVersion(const QString& gamePath) { - const QString path = QDir(gamePath).filePath(QStringLiteral("Version.txt")); - if (!QFileInfo::exists(path)) { - return std::nullopt; - } - auto bytes = readFileBytes(path); - if (!bytes) { - spdlog::warn("Failed to read game version from {}", path.toStdString()); - return std::nullopt; - } - return QString::fromUtf8(bytes->data(), static_cast(bytes->size())).trimmed(); + const QString path = QDir(gamePath).filePath(QStringLiteral("Version.txt")); + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + auto bytes = readFileBytes(path); + if (!bytes) { + spdlog::warn("Failed to read game version from {}", path.toStdString()); + return std::nullopt; + } + return QString::fromUtf8(bytes->data(), static_cast(bytes->size())) + .trimmed(); } PathConfig detectGamePaths() { - QString foundRoot; - const GamePaths found = findGamePaths(foundRoot); - - PathConfig result; - result.game = found.game.toStdString(); - result.local = found.local.toStdString(); - result.workshop = found.workshop.toStdString(); - result.config_folder = detectConfigFolder(foundRoot).toStdString(); - result.community_rules_file = communityRulesFile().toStdString(); - return result; + QString foundRoot; + const GamePaths found = findGamePaths(foundRoot); + + PathConfig result; + result.game = found.game.toStdString(); + result.local = found.local.toStdString(); + result.workshop = found.workshop.toStdString(); + result.config_folder = detectConfigFolder(foundRoot).toStdString(); + result.community_rules_file = communityRulesFile().toStdString(); + return result; } AppConfigService::AppConfigService(ConfigService& svc) : m_svc(svc) {} QCoro::Task> AppConfigService::setup() { - auto loaded = m_svc.load(kConfigFile); - if (!loaded) { - co_return std::unexpected(loaded.error()); - } - m_cfg = std::move(*loaded); - co_return {}; + auto loaded = m_svc.load(kConfigFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_cfg = std::move(*loaded); + co_return {}; } const AppConfig& AppConfigService::cfg() const { - Q_ASSERT(m_cfg.has_value()); - return *m_cfg; + Q_ASSERT(m_cfg.has_value()); + return *m_cfg; } Result AppConfigService::saveCfg() const { - if (!m_cfg) { - return {}; - } - return m_svc.save(kConfigFile, *m_cfg); + if (!m_cfg) { + return {}; + } + return m_svc.save(kConfigFile, *m_cfg); } } // namespace px::modrim diff --git a/src/core/config.hpp b/src/core/config.hpp index a1b9af0..64f1d93 100644 --- a/src/core/config.hpp +++ b/src/core/config.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -7,10 +9,9 @@ #include #include -#include #include -#include -#include + +#include #include #include "core/constants.hpp" @@ -23,9 +24,11 @@ namespace px::modrim { inline constexpr int kCurrentConfigSchemaVersion = 1; inline constexpr std::string_view kSchemaVersionKey = "schema_version"; -// Unknown keys are skipped so files written by newer or older builds still load. +// Unknown keys are skipped so files written by newer or older builds still +// load. inline constexpr glz::opts kJsonReadOpts{.error_on_unknown_keys = false}; -inline constexpr glz::opts kJsonPrettyOpts{.prettify = true, .indentation_width = 2}; +inline constexpr glz::opts kJsonPrettyOpts{.prettify = true, + .indentation_width = 2}; using JsonObject = glz::json_t::object_t; @@ -35,39 +38,41 @@ using JsonMigrations = std::map; // Version 1 is the first versioned layout, so there is nothing to rewrite yet. inline JsonMigrations defaultJsonMigrations() { - return {{1, [](JsonObject&) {}}}; + return {{1, [](JsonObject&) {}}}; } // glaze does not serialise QString, so serialised fields use std::string. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct PathConfig { - std::string game; - std::string local; - std::string workshop; - std::string config_folder; - std::string community_rules_file; - std::string no_version_warning_file; - std::string use_this_instead_file; - - bool operator==(const PathConfig&) const = default; + std::string game; + std::string local; + std::string workshop; + std::string config_folder; + std::string community_rules_file; + std::string no_version_warning_file; + std::string use_this_instead_file; + + bool operator==(const PathConfig&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct AppConfig { - PathConfig paths; - sort::SortSettings sort; - int max_snapshots = 10; - bool compact_mod_list = false; - std::string log_upload_endpoint = "https://paste.rs/"; - std::string launch_args; - std::string launch_wrapper; - AfterLaunch after_launch = AfterLaunch::kStay; - bool launch_confirm_errors = true; - bool launch_confirm_unsaved = true; - bool launch_confirm_running = true; - - bool operator==(const AppConfig&) const = default; + PathConfig paths; + sort::SortSettings sort; + int max_snapshots = 10; + bool compact_mod_list = false; + std::string log_upload_endpoint = "https://paste.rs/"; + std::string launch_args; + std::string launch_wrapper; + AfterLaunch after_launch = AfterLaunch::kStay; + bool launch_confirm_errors = true; + bool launch_confirm_unsaved = true; + bool launch_confirm_running = true; + + bool operator==(const AppConfig&) const = default; }; // NOLINTEND(readability-identifier-naming) @@ -76,121 +81,128 @@ struct AppConfig { // are migrated and rewritten on load when older. A missing, unreadable, or // corrupt file loads as the struct defaults, with a warning. class ConfigService { -public: - explicit ConfigService(QString configDir, JsonMigrations migrations = defaultJsonMigrations()); - - const QString& directory() const noexcept; - - // Fails only when a versioned file cannot be migrated. - template - Result load(const QString& filename) const; - - // Like `load`, but nullopt when the file does not exist yet. - template - Result> loadExisting(const QString& filename) const; - - template - Result save(const QString& filename, const T& data) const; - - // The undecoded top-level object of a file, or {} when it cannot be read. - JsonObject loadRaw(const QString& filename) const; - -private: - QString filePath(const QString& filename) const; - static bool isVersioned(const QString& filename); - // Appends the schema marker after the struct's own members, where the legacy writer put it. - static std::string withSchemaMarker(std::string prettyObject); - Result> loadVersioned(const QString& filename) const; - Result migrate(JsonObject& data, std::int64_t current) const; - - QString m_dir; - JsonMigrations m_migrations; + public: + explicit ConfigService(QString configDir, + JsonMigrations migrations = defaultJsonMigrations()); + + const QString& directory() const noexcept; + + // Fails only when a versioned file cannot be migrated. + template + Result load(const QString& filename) const; + + // Like `load`, but nullopt when the file does not exist yet. + template + Result> loadExisting(const QString& filename) const; + + template + Result save(const QString& filename, const T& data) const; + + // The undecoded top-level object of a file, or {} when it cannot be read. + JsonObject loadRaw(const QString& filename) const; + + private: + QString filePath(const QString& filename) const; + static bool isVersioned(const QString& filename); + // Appends the schema marker after the struct's own members, where the legacy + // writer put it. + static std::string withSchemaMarker(std::string prettyObject); + Result> loadVersioned( + const QString& filename) const; + Result migrate(JsonObject& data, std::int64_t current) const; + + QString m_dir; + JsonMigrations m_migrations; }; template Result ConfigService::load(const QString& filename) const { - const QString path = filePath(filename); - if (!QFileInfo::exists(path)) { - return T{}; + const QString path = filePath(filename); + if (!QFileInfo::exists(path)) { + return T{}; + } + + std::string text; + if (isVersioned(filename)) { + auto document = loadVersioned(filename); + if (!document) { + return std::unexpected(document.error()); } - - std::string text; - if (isVersioned(filename)) { - auto document = loadVersioned(filename); - if (!document) { - return std::unexpected(document.error()); - } - if (!document->has_value()) { - return T{}; - } - auto encoded = glz::write_json(**document); - if (!encoded) { - return T{}; - } - text = std::move(*encoded); - } else { - auto bytes = readFileBytes(path); - if (!bytes) { - return T{}; - } - text = std::move(*bytes); + if (!document->has_value()) { + return T{}; } - - T value{}; - if (auto error = glz::read(value, text)) { - spdlog::warn("Failed to load {}: {}", filename.toStdString(), glz::format_error(error, text)); - return T{}; + auto encoded = glz::write_json(**document); + if (!encoded) { + return T{}; } - return value; + text = std::move(*encoded); + } else { + auto bytes = readFileBytes(path); + if (!bytes) { + return T{}; + } + text = std::move(*bytes); + } + + T value{}; + if (auto error = glz::read(value, text)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + glz::format_error(error, text)); + return T{}; + } + return value; } template -Result> ConfigService::loadExisting(const QString& filename) const { - if (!QFileInfo::exists(filePath(filename))) { - return std::optional{}; - } - auto loaded = load(filename); - if (!loaded) { - return std::unexpected(loaded.error()); - } - return std::optional(std::move(*loaded)); +Result> ConfigService::loadExisting( + const QString& filename) const { + if (!QFileInfo::exists(filePath(filename))) { + return std::optional{}; + } + auto loaded = load(filename); + if (!loaded) { + return std::unexpected(loaded.error()); + } + return std::optional(std::move(*loaded)); } template Result ConfigService::save(const QString& filename, const T& data) const { - auto text = glz::write(data); - if (!text) { - return std::unexpected(Error{QStringLiteral("Cannot encode %1").arg(filename)}); - } - if (isVersioned(filename)) { - *text = withSchemaMarker(std::move(*text)); - } - return writeFileAtomically(filePath(filename), *text); + auto text = glz::write(data); + if (!text) { + return std::unexpected( + Error{QStringLiteral("Cannot encode %1").arg(filename)}); + } + if (isVersioned(filename)) { + *text = withSchemaMarker(std::move(*text)); + } + return writeFileAtomically(filePath(filename), *text); } QString configDir(); QString communityRulesFile(); -// The game version string from Version.txt, or nullopt when it is missing or unreadable. +// The game version string from Version.txt, or nullopt when it is missing or +// unreadable. std::optional readGameVersion(const QString& gamePath); PathConfig detectGamePaths(); // Owns the AppConfig lifecycle: load on setup, save on request. class AppConfigService { -public: - explicit AppConfigService(ConfigService& svc); + public: + explicit AppConfigService(ConfigService& svc); - QCoro::Task> setup(); + QCoro::Task> setup(); - // Valid only after a successful setup(). - const AppConfig& cfg() const; + // Valid only after a successful setup(). + const AppConfig& cfg() const; - Result saveCfg() const; + Result saveCfg() const; -private: - ConfigService& m_svc; - std::optional m_cfg; + private: + ConfigService& m_svc; + std::optional m_cfg; }; } // namespace px::modrim diff --git a/src/core/constants.hpp b/src/core/constants.hpp index b6f3dfa..046bc47 100644 --- a/src/core/constants.hpp +++ b/src/core/constants.hpp @@ -1,35 +1,35 @@ #pragma once -#include - #include #include #include #include +#include namespace px::modrim { inline constexpr QLatin1StringView kRimworldSteamAppId{"294100"}; enum class LaunchStrategy : int { - kDirect = 0, - kSteam = 1, + kDirect = 0, + kSteam = 1, }; enum class AfterLaunch : int { - kStay = 0, - kMinimize = 1, - kClose = 2, + kStay = 0, + kMinimize = 1, + kClose = 2, }; -inline constexpr QLatin1StringView kDefaultMissingPackageid{"missing.packageid"}; +inline constexpr QLatin1StringView kDefaultMissingPackageid{ + "missing.packageid"}; struct RimWorldDlc { - QLatin1StringView appId; - QLatin1StringView packageId; - QLatin1StringView name; - QLatin1StringView steamUrl; - QLatin1StringView description; + QLatin1StringView appId; + QLatin1StringView packageId; + QLatin1StringView name; + QLatin1StringView steamUrl; + QLatin1StringView description; }; // Ordered by Steam app id, matching the legacy dict's iteration order. @@ -40,43 +40,50 @@ inline constexpr std::array kRimworldDlcMetadata{{ QLatin1StringView{"Base game"}}, {QLatin1StringView{"1149640"}, QLatin1StringView{"ludeon.rimworld.royalty"}, QLatin1StringView{"RimWorld - Royalty"}, - QLatin1StringView{"https://store.steampowered.com/app/1149640/RimWorld__Royalty"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1149640/RimWorld__Royalty"}, QLatin1StringView{"DLC #1"}}, - {QLatin1StringView{"1392840"}, QLatin1StringView{"ludeon.rimworld.ideology"}, + {QLatin1StringView{"1392840"}, + QLatin1StringView{"ludeon.rimworld.ideology"}, QLatin1StringView{"RimWorld - Ideology"}, - QLatin1StringView{"https://store.steampowered.com/app/1392840/RimWorld__Ideology"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1392840/RimWorld__Ideology"}, QLatin1StringView{"DLC #2"}}, {QLatin1StringView{"1826140"}, QLatin1StringView{"ludeon.rimworld.biotech"}, QLatin1StringView{"RimWorld - Biotech"}, - QLatin1StringView{"https://store.steampowered.com/app/1826140/RimWorld__Biotech"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1826140/RimWorld__Biotech"}, QLatin1StringView{"DLC #3"}}, {QLatin1StringView{"2380740"}, QLatin1StringView{"ludeon.rimworld.anomaly"}, QLatin1StringView{"RimWorld - Anomaly"}, - QLatin1StringView{"https://store.steampowered.com/app/2380740/RimWorld__Anomaly/"}, + QLatin1StringView{ + "https://store.steampowered.com/app/2380740/RimWorld__Anomaly/"}, QLatin1StringView{"DLC #4"}}, {QLatin1StringView{"3022790"}, QLatin1StringView{"ludeon.rimworld.odyssey"}, QLatin1StringView{"RimWorld - Odyssey"}, - QLatin1StringView{"https://store.steampowered.com/app/3022790/RimWorld__Odyssey/"}, + QLatin1StringView{ + "https://store.steampowered.com/app/3022790/RimWorld__Odyssey/"}, QLatin1StringView{"DLC #5"}}, }}; // Ordered package ids of RIMWORLD_DLC_METADATA. inline const QStringList kRimworldPackageIds = [] { - QStringList ids; - for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { - ids.append(QString(dlc.packageId)); - } - return ids; + QStringList ids; + for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { + ids.append(QString(dlc.packageId)); + } + return ids; }(); -// Exact (case-sensitive) lookup; callers pass lowercase ids as produced by CaseInsensitiveStr. +// Exact (case-sensitive) lookup; callers pass lowercase ids as produced by +// CaseInsensitiveStr. inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { - for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { - if (packageId == QLatin1StringView(dlc.packageId)) { - return &dlc; - } + for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { + if (packageId == QLatin1StringView(dlc.packageId)) { + return &dlc; } - return nullptr; + } + return nullptr; } } // namespace px::modrim diff --git a/src/core/context.cpp b/src/core/context.cpp index 758d79c..f1bad12 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -1,9 +1,9 @@ #include "core/context.hpp" +#include #include #include -#include #include #include "core/downloads/downloader.hpp" @@ -27,153 +27,163 @@ constexpr qsizetype kVersionMinParts = 2; CoreContext::CoreContext(AppConfig cfg, ConfigService* configService) : m_cfg(std::move(cfg)), m_configService(configService) { - refreshGameVersion(); - m_plugins.registerPlugin(std::make_unique()); + refreshGameVersion(); + m_plugins.registerPlugin(std::make_unique()); } CoreContext::~CoreContext() = default; -std::unique_ptr CoreContext::create(AppConfig cfg, ConfigService* configService) { - std::unique_ptr ctx(new CoreContext(std::move(cfg), configService)); - ProfileSession session(QStringLiteral("services.create")); - Profiler& timer = session.timer(); - { - auto scope = timer(QStringLiteral("create_providers")); - ctx->m_modService = std::make_unique( - *ctx, providers::createProviders(ctx->m_cfg.paths, &ctx->m_pool)); - } - { - auto scope = timer(QStringLiteral("diagnostics_service")); - ctx->m_diagnosticsService = std::make_unique(*ctx, ctx->m_network); - } - { - auto scope = timer(QStringLiteral("sort_service")); - ctx->m_sortService = std::make_unique(*ctx, *ctx->m_diagnosticsService); - } - { - auto scope = timer(QStringLiteral("game_launcher")); - ctx->m_gameLauncher = std::make_unique(*ctx); - } - { - auto scope = timer(QStringLiteral("activation")); - ctx->m_activation = std::make_unique(*ctx); - } - return ctx; -} - -void CoreContext::load(QHash mods, QStringList activeUuids) { - m_mods = std::move(mods); - m_loadedActiveUuids = activeUuids; - m_activeUuids = std::move(activeUuids); +std::unique_ptr CoreContext::create(AppConfig cfg, + ConfigService* configService) { + std::unique_ptr ctx( + new CoreContext(std::move(cfg), configService)); + ProfileSession session(QStringLiteral("services.create")); + Profiler& timer = session.timer(); + { + auto scope = timer(QStringLiteral("create_providers")); + ctx->m_modService = std::make_unique( + *ctx, providers::createProviders(ctx->m_cfg.paths, &ctx->m_pool)); + } + { + auto scope = timer(QStringLiteral("diagnostics_service")); + ctx->m_diagnosticsService = + std::make_unique(*ctx, ctx->m_network); + } + { + auto scope = timer(QStringLiteral("sort_service")); + ctx->m_sortService = + std::make_unique(*ctx, *ctx->m_diagnosticsService); + } + { + auto scope = timer(QStringLiteral("game_launcher")); + ctx->m_gameLauncher = std::make_unique(*ctx); + } + { + auto scope = timer(QStringLiteral("activation")); + ctx->m_activation = std::make_unique(*ctx); + } + return ctx; +} + +void CoreContext::load(QHash mods, + QStringList activeUuids) { + m_mods = std::move(mods); + m_loadedActiveUuids = activeUuids; + m_activeUuids = std::move(activeUuids); } void CoreContext::setActive(QStringList uuids) { - const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); - const QSet newSet(uuids.cbegin(), uuids.cend()); - if (oldSet == newSet && m_activeUuids == uuids) { - return; - } - const bool setChanged = oldSet != newSet; - m_activeUuids = std::move(uuids); - if (setChanged) { - m_diagnosticsService->rebuild(m_activeUuids); - } else { - m_diagnosticsService->reorder(m_activeUuids); - } - m_activeStateChanged.notify(m_activeUuids); + const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QSet newSet(uuids.cbegin(), uuids.cend()); + if (oldSet == newSet && m_activeUuids == uuids) { + return; + } + const bool setChanged = oldSet != newSet; + m_activeUuids = std::move(uuids); + if (setChanged) { + m_diagnosticsService->rebuild(m_activeUuids); + } else { + m_diagnosticsService->reorder(m_activeUuids); + } + m_activeStateChanged.notify(m_activeUuids); } QCoro::Task> CoreContext::autoSort() { - const QSet active(m_activeUuids.cbegin(), m_activeUuids.cend()); - const QStringList deps = m_sortService->resolveMissingDependencies(active); - if (!deps.isEmpty()) { - m_activeUuids.append(deps); - m_diagnosticsService->rebuild(m_activeUuids); - m_activeStateChanged.notify(m_activeUuids); - spdlog::info("auto-sort: enabling {} missing dependencies", deps.size()); - } - - const auto started = std::chrono::steady_clock::now(); - m_activeUuids = co_await m_sortService->sortActiveMods(); - const double elapsedMs = - std::chrono::duration(std::chrono::steady_clock::now() - started).count(); - m_diagnosticsService->reorder(m_activeUuids); + const QSet active(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QStringList deps = m_sortService->resolveMissingDependencies(active); + if (!deps.isEmpty()) { + m_activeUuids.append(deps); + m_diagnosticsService->rebuild(m_activeUuids); m_activeStateChanged.notify(m_activeUuids); - const int count = static_cast(m_activeUuids.size()); - spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsedMs); - co_return std::pair{count, elapsedMs}; + spdlog::info("auto-sort: enabling {} missing dependencies", deps.size()); + } + + const auto started = std::chrono::steady_clock::now(); + m_activeUuids = co_await m_sortService->sortActiveMods(); + const double elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - started) + .count(); + m_diagnosticsService->reorder(m_activeUuids); + m_activeStateChanged.notify(m_activeUuids); + const int count = static_cast(m_activeUuids.size()); + spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsedMs); + co_return std::pair{count, elapsedMs}; } void CoreContext::updateConfig(AppConfig cfg) { - m_cfg = std::move(cfg); - refreshGameVersion(); - m_configChanged.notify(m_cfg); + m_cfg = std::move(cfg); + refreshGameVersion(); + m_configChanged.notify(m_cfg); } void CoreContext::resetProviders(const PathConfig& paths) { - m_modService->resetProviders(providers::createProviders(paths, &m_pool)); + m_modService->resetProviders(providers::createProviders(paths, &m_pool)); } void CoreContext::refreshGameVersion() { - if (m_cfg.paths.game.empty()) { - m_gameVersion = QStringLiteral("Unknown"); - return; - } - const auto version = readGameVersion(QString::fromStdString(m_cfg.paths.game)); - m_gameVersion = version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); + if (m_cfg.paths.game.empty()) { + m_gameVersion = QStringLiteral("Unknown"); + return; + } + const auto version = + readGameVersion(QString::fromStdString(m_cfg.paths.game)); + m_gameVersion = + version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); } QString CoreContext::targetVersion() const { - const QStringList parts = m_gameVersion.split(QLatin1Char('.')); - if (parts.size() >= kVersionMinParts) { - return QStringLiteral("%1.%2").arg(parts[0], parts[1]); - } - return QStringLiteral("Unknown"); -} - -CollectionStats CoreContext::computeStats(const std::optional& activeIds) const { - const QStringList& ids = activeIds ? *activeIds : m_activeUuids; - CollectionStats stats; - stats.total = m_mods.size(); - stats.active = ids.size(); - stats.inactive = stats.total - stats.active; - for (const auto& mod : m_mods) { - if (!mod->valid) { - ++stats.errors; - } + const QStringList parts = m_gameVersion.split(QLatin1Char('.')); + if (parts.size() >= kVersionMinParts) { + return QStringLiteral("%1.%2").arg(parts[0], parts[1]); + } + return QStringLiteral("Unknown"); +} + +CollectionStats CoreContext::computeStats( + const std::optional& activeIds) const { + const QStringList& ids = activeIds ? *activeIds : m_activeUuids; + CollectionStats stats; + stats.total = m_mods.size(); + stats.active = ids.size(); + stats.inactive = stats.total - stats.active; + for (const auto& mod : m_mods) { + if (!mod->valid) { + ++stats.errors; } - return stats; + } + return stats; } ConfigService& CoreContext::configService() const { - Q_ASSERT(m_configService != nullptr); - return *m_configService; + Q_ASSERT(m_configService != nullptr); + return *m_configService; } DownloadManager& CoreContext::downloads() const { - auto* manager = dynamic_cast(m_plugins.get(kDownloadsManagerName)); - Q_ASSERT(manager != nullptr); - return *manager; + auto* manager = + dynamic_cast(m_plugins.get(kDownloadsManagerName)); + Q_ASSERT(manager != nullptr); + return *manager; } QCoro::Task> CoreContext::initialize() { - co_return co_await m_modService->initialize(); + co_return co_await m_modService->initialize(); } QCoro::Task CoreContext::close() { - m_pool.waitForDone(); - co_await m_modService->startupImpact().closeConnection(); - co_await m_modService->close(); + m_pool.waitForDone(); + co_await m_modService->startupImpact().closeConnection(); + co_await m_modService->close(); } void CoreContext::closeSync() { - m_pool.waitForDone(); - m_modService->startupImpact().closeConnectionSync(); - m_modService->closeSync(); + m_pool.waitForDone(); + m_modService->startupImpact().closeConnectionSync(); + m_modService->closeSync(); } void CoreContext::registerPlugin(std::unique_ptr plugin) { - m_plugins.registerPlugin(std::move(plugin)); + m_plugins.registerPlugin(std::move(plugin)); } } // namespace px::modrim diff --git a/src/core/context.hpp b/src/core/context.hpp index f668bf6..65400a6 100644 --- a/src/core/context.hpp +++ b/src/core/context.hpp @@ -1,14 +1,14 @@ #pragma once +#include +#include +#include +#include #include #include #include #include -#include -#include -#include -#include #include "core/config.hpp" #include "core/error.hpp" @@ -30,88 +30,98 @@ namespace services { class GameLauncher; } -// Centralised application state: mods, active load order, config and game version, plus the -// services that act on them. Services hold a reference back to the context, so it is built only -// through create() and never moved. +// Centralised application state: mods, active load order, config and game +// version, plus the services that act on them. Services hold a reference back +// to the context, so it is built only through create() and never moved. class CoreContext { -public: - // `config_service` is not owned and must outlive the context. - static std::unique_ptr create(AppConfig cfg, - ConfigService* configService = nullptr); - - ~CoreContext(); - CoreContext(const CoreContext&) = delete; - CoreContext& operator=(const CoreContext&) = delete; - CoreContext(CoreContext&&) = delete; - CoreContext& operator=(CoreContext&&) = delete; - - // Replaces all mods and the active list, taking ownership of the data. - void load(QHash mods, QStringList activeUuids); - void setActive(QStringList uuids); - // Enables missing dependencies, then sorts the active mods. Resolves to the number of active - // mods and the time the sort took in milliseconds. - QCoro::Task> autoSort(); - // Replaces the live config and refreshes derived values (game version). - void updateConfig(AppConfig cfg); - // Rebuilds the providers for new paths. - void resetProviders(const PathConfig& paths); - CollectionStats computeStats(const std::optional& activeIds = std::nullopt) const; - - QCoro::Task> initialize(); - QCoro::Task close(); - // Synchronous teardown for use after the event loop has stopped. - void closeSync(); - - void registerPlugin(std::unique_ptr plugin); - - const AppConfig& config() const noexcept { return m_cfg; } - const QHash& allMods() const noexcept { return m_mods; } - const QStringList& activeUuids() const noexcept { return m_activeUuids; } - // Active UUIDs as last loaded from disk, unaffected by later edits. - const QStringList& loadedActiveUuids() const noexcept { return m_loadedActiveUuids; } - // "Unknown" when the game is not configured or its version cannot be read. - const QString& gameVersion() const noexcept { return m_gameVersion; } - // The "X.Y" part of the game version, or "Unknown". - QString targetVersion() const; - - Event& activeStateChanged() noexcept { return m_activeStateChanged; } - Event& configChanged() noexcept { return m_configChanged; } - - bool hasConfigService() const noexcept { return m_configService != nullptr; } - ConfigService& configService() const; - QNetworkAccessManager& network() noexcept { return m_network; } - - ModService& modService() noexcept { return *m_modService; } - DiagnosticsService& diagnosticsService() noexcept { return *m_diagnosticsService; } - SortService& sortService() noexcept { return *m_sortService; } - ActivationService& activation() noexcept { return *m_activation; } - services::GameLauncher& gameLauncher() noexcept { return *m_gameLauncher; } - DownloadManager& downloads() const; - PluginRegistry& plugins() noexcept { return m_plugins; } - -private: - CoreContext(AppConfig cfg, ConfigService* configService); - void refreshGameVersion(); - - AppConfig m_cfg; - ConfigService* m_configService; - QHash m_mods; - QStringList m_activeUuids; - QStringList m_loadedActiveUuids; - QString m_gameVersion; - Event m_activeStateChanged; - Event m_configChanged; - // Shared by providers and the network; declared before the services so they outlive them. - QThreadPool m_pool; - QNetworkAccessManager m_network; - // Services are declared before the plugins, so plugins (which disconnect from service - // events in their destructors) are destroyed first. - std::unique_ptr m_modService; - std::unique_ptr m_diagnosticsService; - std::unique_ptr m_sortService; - std::unique_ptr m_gameLauncher; - std::unique_ptr m_activation; - PluginRegistry m_plugins; + public: + // `config_service` is not owned and must outlive the context. + static std::unique_ptr create( + AppConfig cfg, ConfigService* configService = nullptr); + + ~CoreContext(); + CoreContext(const CoreContext&) = delete; + CoreContext& operator=(const CoreContext&) = delete; + CoreContext(CoreContext&&) = delete; + CoreContext& operator=(CoreContext&&) = delete; + + // Replaces all mods and the active list, taking ownership of the data. + void load(QHash mods, QStringList activeUuids); + void setActive(QStringList uuids); + // Enables missing dependencies, then sorts the active mods. Resolves to the + // number of active mods and the time the sort took in milliseconds. + QCoro::Task> autoSort(); + // Replaces the live config and refreshes derived values (game version). + void updateConfig(AppConfig cfg); + // Rebuilds the providers for new paths. + void resetProviders(const PathConfig& paths); + CollectionStats computeStats( + const std::optional& activeIds = std::nullopt) const; + + QCoro::Task> initialize(); + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void closeSync(); + + void registerPlugin(std::unique_ptr plugin); + + const AppConfig& config() const noexcept { return m_cfg; } + const QHash& allMods() const noexcept { + return m_mods; + } + const QStringList& activeUuids() const noexcept { return m_activeUuids; } + // Active UUIDs as last loaded from disk, unaffected by later edits. + const QStringList& loadedActiveUuids() const noexcept { + return m_loadedActiveUuids; + } + // "Unknown" when the game is not configured or its version cannot be read. + const QString& gameVersion() const noexcept { return m_gameVersion; } + // The "X.Y" part of the game version, or "Unknown". + QString targetVersion() const; + + Event& activeStateChanged() noexcept { + return m_activeStateChanged; + } + Event& configChanged() noexcept { return m_configChanged; } + + bool hasConfigService() const noexcept { return m_configService != nullptr; } + ConfigService& configService() const; + QNetworkAccessManager& network() noexcept { return m_network; } + + ModService& modService() noexcept { return *m_modService; } + DiagnosticsService& diagnosticsService() noexcept { + return *m_diagnosticsService; + } + SortService& sortService() noexcept { return *m_sortService; } + ActivationService& activation() noexcept { return *m_activation; } + services::GameLauncher& gameLauncher() noexcept { return *m_gameLauncher; } + DownloadManager& downloads() const; + PluginRegistry& plugins() noexcept { return m_plugins; } + + private: + CoreContext(AppConfig cfg, ConfigService* configService); + void refreshGameVersion(); + + AppConfig m_cfg; + ConfigService* m_configService; + QHash m_mods; + QStringList m_activeUuids; + QStringList m_loadedActiveUuids; + QString m_gameVersion; + Event m_activeStateChanged; + Event m_configChanged; + // Shared by providers and the network; declared before the services so they + // outlive them. + QThreadPool m_pool; + QNetworkAccessManager m_network; + // Services are declared before the plugins, so plugins (which disconnect from + // service events in their destructors) are destroyed first. + std::unique_ptr m_modService; + std::unique_ptr m_diagnosticsService; + std::unique_ptr m_sortService; + std::unique_ptr m_gameLauncher; + std::unique_ptr m_activation; + PluginRegistry m_plugins; }; } // namespace px::modrim diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp index 117b915..14e3882 100644 --- a/src/core/downloads/downloader.cpp +++ b/src/core/downloads/downloader.cpp @@ -16,16 +16,16 @@ const QString& Downloader::label() const noexcept { return m_label; } void Downloader::setup(CoreContext&) { m_manager.registerDownloader(*this); } QStringList Downloader::updatableIds(const QList& mods) const { - QStringList ids; - QSet seen; - for (const auto& mod : mods) { - auto id = updatableId(*mod); - if (id && !seen.contains(*id)) { - seen.insert(*id); - ids.append(std::move(*id)); - } + QStringList ids; + QSet seen; + for (const auto& mod : mods) { + auto id = updatableId(*mod); + if (id && !seen.contains(*id)) { + seen.insert(*id); + ids.append(std::move(*id)); } - return ids; + } + return ids; } } // namespace px::modrim diff --git a/src/core/downloads/downloader.hpp b/src/core/downloads/downloader.hpp index 59301e4..f60cb01 100644 --- a/src/core/downloads/downloader.hpp +++ b/src/core/downloads/downloader.hpp @@ -1,12 +1,12 @@ #pragma once -#include - -#include #include #include #include #include +#include + +#include #include "core/downloads/types.hpp" #include "core/error.hpp" @@ -24,45 +24,47 @@ class DownloadManager; // Constructing a downloader hands it to `manager`; `setup` registers it, so the // UI only talks to the manager and a downloader can be left out freely. class Downloader : public Plugin { -public: - // `label` is the human-readable source name shown in the UI, e.g. "Steam Workshop". - Downloader(QString name, QString label, DownloadManager& manager); + public: + // `label` is the human-readable source name shown in the UI, e.g. "Steam + // Workshop". + Downloader(QString name, QString label, DownloadManager& manager); - const QString& label() const noexcept; + const QString& label() const noexcept; - void setup(CoreContext& ctx) override; + void setup(CoreContext& ctx) override; - // Whether a batch is currently downloading. - virtual bool isDownloading() const = 0; - // Ids of the batch currently downloading. - virtual QSet activeIds() const = 0; - // Whether `mod_id` belongs to this source. - virtual bool accepts(const QString& modId) const = 0; - // Id of `mod` in this source if PxModRim downloaded it from here. - virtual std::optional updatableId(const ListedMod& mod) const = 0; - // Unix time of the last successful sync of `mod`, if known. - virtual std::optional lastSynced(const ListedMod& mod) const = 0; - // Downloads or updates `mod_ids`. Per-item failures are reported in the result; - // an error means the whole batch was refused. - virtual QCoro::Task> downloadMods(QStringList modIds) = 0; - virtual void cancel() = 0; + // Whether a batch is currently downloading. + virtual bool isDownloading() const = 0; + // Ids of the batch currently downloading. + virtual QSet activeIds() const = 0; + // Whether `mod_id` belongs to this source. + virtual bool accepts(const QString& modId) const = 0; + // Id of `mod` in this source if PxModRim downloaded it from here. + virtual std::optional updatableId(const ListedMod& mod) const = 0; + // Unix time of the last successful sync of `mod`, if known. + virtual std::optional lastSynced(const ListedMod& mod) const = 0; + // Downloads or updates `mod_ids`. Per-item failures are reported in the + // result; an error means the whole batch was refused. + virtual QCoro::Task> downloadMods( + QStringList modIds) = 0; + virtual void cancel() = 0; - // Distinct updatable ids among `mods`, in first-seen order. - QStringList updatableIds(const QList& mods) const; + // Distinct updatable ids among `mods`, in first-seen order. + QStringList updatableIds(const QList& mods) const; - Event statusMessageChanged; - Event downloadProgress; - Event downloadItemStatusChanged; - Event downloadItemTitled; - // "login" -> "query" (item details) -> "run" (all items resolved). - Event downloadPhaseChanged; - Event downloadFinished; - Event busyChanged; - Event batchStarted; + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; + // "login" -> "query" (item details) -> "run" (all items resolved). + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; -private: - QString m_label; - DownloadManager& m_manager; + private: + QString m_label; + DownloadManager& m_manager; }; } // namespace px::modrim diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp index 54c5b52..5fee529 100644 --- a/src/core/downloads/manager.cpp +++ b/src/core/downloads/manager.cpp @@ -9,144 +9,166 @@ namespace px::modrim { DownloadManager::DownloadManager() : Plugin(kDownloadsManagerName) {} void DownloadManager::registerDownloader(Downloader& downloader) { - m_downloaders.push_back(&downloader); - downloader.statusMessageChanged.connect([this](const QString& v) { statusMessageChanged.notify(v); }); - downloader.downloadProgress.connect([this](const DownloadProgress& v) { downloadProgress.notify(v); }); - downloader.downloadItemStatusChanged.connect( - [this](const DownloadItemStatus& v) { downloadItemStatusChanged.notify(v); }); - downloader.downloadItemTitled.connect([this](const DownloadItemTitle& v) { downloadItemTitled.notify(v); }); - downloader.downloadPhaseChanged.connect([this](const QString& v) { downloadPhaseChanged.notify(v); }); - downloader.downloadFinished.connect([this](const DownloadResult& v) { downloadFinished.notify(v); }); - downloader.batchStarted.connect([this](const QStringList& v) { batchStarted.notify(v); }); - downloader.busyChanged.connect([this](bool busy) { onBusy(busy); }); + m_downloaders.push_back(&downloader); + downloader.statusMessageChanged.connect( + [this](const QString& v) { statusMessageChanged.notify(v); }); + downloader.downloadProgress.connect( + [this](const DownloadProgress& v) { downloadProgress.notify(v); }); + downloader.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& v) { + downloadItemStatusChanged.notify(v); + }); + downloader.downloadItemTitled.connect( + [this](const DownloadItemTitle& v) { downloadItemTitled.notify(v); }); + downloader.downloadPhaseChanged.connect( + [this](const QString& v) { downloadPhaseChanged.notify(v); }); + downloader.downloadFinished.connect( + [this](const DownloadResult& v) { downloadFinished.notify(v); }); + downloader.batchStarted.connect( + [this](const QStringList& v) { batchStarted.notify(v); }); + downloader.busyChanged.connect([this](bool busy) { onBusy(busy); }); } void DownloadManager::onBusy(bool busy) { - if (busy) { - m_cancelled = false; - } - busyChanged.notify(isDownloading()); + if (busy) { + m_cancelled = false; + } + busyChanged.notify(isDownloading()); } -const std::vector& DownloadManager::downloaders() const noexcept { return m_downloaders; } +const std::vector& DownloadManager::downloaders() const noexcept { + return m_downloaders; +} -bool DownloadManager::available() const noexcept { return !m_downloaders.empty(); } +bool DownloadManager::available() const noexcept { + return !m_downloaders.empty(); +} bool DownloadManager::isDownloading() const { - return std::ranges::any_of(m_downloaders, [](const Downloader* d) { return d->isDownloading(); }); + return std::ranges::any_of( + m_downloaders, [](const Downloader* d) { return d->isDownloading(); }); } QSet DownloadManager::activeIds() const { - QSet ids; - for (const auto* downloader : m_downloaders) { - ids |= downloader->activeIds(); - } - return ids; + QSet ids; + for (const auto* downloader : m_downloaders) { + ids |= downloader->activeIds(); + } + return ids; } Downloader* DownloadManager::sourceOf(const ListedMod& mod) const { - for (auto* downloader : m_downloaders) { - if (downloader->updatableId(mod)) { - return downloader; - } + for (auto* downloader : m_downloaders) { + if (downloader->updatableId(mod)) { + return downloader; } - return nullptr; + } + return nullptr; } -std::optional DownloadManager::updatableId(const ListedMod& mod) const { - const Downloader* source = sourceOf(mod); - return source ? source->updatableId(mod) : std::nullopt; +std::optional DownloadManager::updatableId( + const ListedMod& mod) const { + const Downloader* source = sourceOf(mod); + return source ? source->updatableId(mod) : std::nullopt; } -QStringList DownloadManager::updatableIds(const QList& mods) const { - QStringList ids; - QSet seen; - for (const auto* downloader : m_downloaders) { - for (const auto& id : downloader->updatableIds(mods)) { - if (!seen.contains(id)) { - seen.insert(id); - ids.append(id); - } - } +QStringList DownloadManager::updatableIds( + const QList& mods) const { + QStringList ids; + QSet seen; + for (const auto* downloader : m_downloaders) { + for (const auto& id : downloader->updatableIds(mods)) { + if (!seen.contains(id)) { + seen.insert(id); + ids.append(id); + } } - return ids; + } + return ids; } -QHash DownloadManager::namesById(const QList& mods) const { - QHash names; - for (const auto& mod : mods) { - if (auto id = updatableId(*mod)) { - names.insert(*id, mod->name); - } +QHash DownloadManager::namesById( + const QList& mods) const { + QHash names; + for (const auto& mod : mods) { + if (auto id = updatableId(*mod)) { + names.insert(*id, mod->name); } - return names; + } + return names; } std::optional DownloadManager::lastSynced(const ListedMod& mod) const { - const Downloader* source = sourceOf(mod); - return source ? source->lastSynced(mod) : std::nullopt; + const Downloader* source = sourceOf(mod); + return source ? source->lastSynced(mod) : std::nullopt; } -QCoro::Task> DownloadManager::downloadMods(QStringList modIds) { - // Ids are grouped by owning downloader in first-seen order; duplicates are dropped. - std::vector> groups; - QSet seen; - for (const auto& modId : modIds) { - if (seen.contains(modId)) { - continue; - } - seen.insert(modId); - - Downloader* owner = nullptr; - for (auto* downloader : m_downloaders) { - if (downloader->accepts(modId)) { - owner = downloader; - break; - } - } - if (!owner) { - co_return std::unexpected(Error{QStringLiteral("No download source handles '%1'.").arg(modId)}); - } - auto group = std::ranges::find(groups, owner, &std::pair::first); - if (group == groups.end()) { - groups.emplace_back(owner, QStringList{}); - group = std::prev(groups.end()); - } - group->second.append(modId); +QCoro::Task> DownloadManager::downloadMods( + QStringList modIds) { + // Ids are grouped by owning downloader in first-seen order; duplicates are + // dropped. + std::vector> groups; + QSet seen; + for (const auto& modId : modIds) { + if (seen.contains(modId)) { + continue; } - if (groups.empty()) { - co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); + seen.insert(modId); + + Downloader* owner = nullptr; + for (auto* downloader : m_downloaders) { + if (downloader->accepts(modId)) { + owner = downloader; + break; + } } - if (isDownloading()) { - co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); + if (!owner) { + co_return std::unexpected( + Error{QStringLiteral("No download source handles '%1'.").arg(modId)}); } - - m_cancelled = false; - DownloadResult total; - for (auto& [downloader, group] : groups) { - if (m_cancelled) { - break; - } - auto result = co_await downloader->downloadMods(group); - if (!result) { - co_return std::unexpected(result.error()); - } - total.succeeded += result->succeeded; - total.failed += result->failed; - total.changed += result->changed; + auto group = std::ranges::find(groups, owner, + &std::pair::first); + if (group == groups.end()) { + groups.emplace_back(owner, QStringList{}); + group = std::prev(groups.end()); } - co_return total; + group->second.append(modId); + } + if (groups.empty()) { + co_return std::unexpected( + Error{QStringLiteral("No mods selected for download.")}); + } + if (isDownloading()) { + co_return std::unexpected( + Error{QStringLiteral("A download is already running.")}); + } + + m_cancelled = false; + DownloadResult total; + for (auto& [downloader, group] : groups) { + if (m_cancelled) { + break; + } + auto result = co_await downloader->downloadMods(group); + if (!result) { + co_return std::unexpected(result.error()); + } + total.succeeded += result->succeeded; + total.failed += result->failed; + total.changed += result->changed; + } + co_return total; } bool DownloadManager::cancelled() const noexcept { return m_cancelled; } void DownloadManager::cancel() { - m_cancelled = true; - for (auto* downloader : m_downloaders) { - if (downloader->isDownloading()) { - downloader->cancel(); - } + m_cancelled = true; + for (auto* downloader : m_downloaders) { + if (downloader->isDownloading()) { + downloader->cancel(); } + } } } // namespace px::modrim diff --git a/src/core/downloads/manager.hpp b/src/core/downloads/manager.hpp index 5a455e6..5ffc2aa 100644 --- a/src/core/downloads/manager.hpp +++ b/src/core/downloads/manager.hpp @@ -1,14 +1,14 @@ #pragma once -#include -#include - -#include #include #include #include #include #include +#include +#include + +#include #include "core/downloads/downloader.hpp" #include "core/downloads/types.hpp" @@ -20,49 +20,51 @@ namespace px::modrim { // The one download entry point for the UI; routes to registered downloaders. -// With no downloader registered nothing is updatable and `available()` is false, -// which is how the UI hides every download control. +// With no downloader registered nothing is updatable and `available()` is +// false, which is how the UI hides every download control. class DownloadManager : public Plugin { -public: - DownloadManager(); + public: + DownloadManager(); - // Called by each downloader from its `setup`. The downloader must outlive the manager's use of it. - void registerDownloader(Downloader& downloader); + // Called by each downloader from its `setup`. The downloader must outlive the + // manager's use of it. + void registerDownloader(Downloader& downloader); - const std::vector& downloaders() const noexcept; - bool available() const noexcept; - bool isDownloading() const; - QSet activeIds() const; + const std::vector& downloaders() const noexcept; + bool available() const noexcept; + bool isDownloading() const; + QSet activeIds() const; - // The downloader that can update `mod`, or nullptr. - Downloader* sourceOf(const ListedMod& mod) const; - std::optional updatableId(const ListedMod& mod) const; - QStringList updatableIds(const QList& mods) const; - // Display names of installed `mods`, keyed by their source id. - QHash namesById(const QList& mods) const; - std::optional lastSynced(const ListedMod& mod) const; + // The downloader that can update `mod`, or nullptr. + Downloader* sourceOf(const ListedMod& mod) const; + std::optional updatableId(const ListedMod& mod) const; + QStringList updatableIds(const QList& mods) const; + // Display names of installed `mods`, keyed by their source id. + QHash namesById(const QList& mods) const; + std::optional lastSynced(const ListedMod& mod) const; - // Downloads `mod_ids`, one downloader after another. Errors before anything starts - // (unclaimed id, nothing selected, already running) are returned without downloading. - QCoro::Task> downloadMods(QStringList modIds); + // Downloads `mod_ids`, one downloader after another. Errors before anything + // starts (unclaimed id, nothing selected, already running) are returned + // without downloading. + QCoro::Task> downloadMods(QStringList modIds); - bool cancelled() const noexcept; - void cancel(); + bool cancelled() const noexcept; + void cancel(); - Event statusMessageChanged; - Event downloadProgress; - Event downloadItemStatusChanged; - Event downloadItemTitled; - Event downloadPhaseChanged; - Event downloadFinished; - Event busyChanged; - Event batchStarted; + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; -private: - void onBusy(bool busy); + private: + void onBusy(bool busy); - std::vector m_downloaders; - bool m_cancelled = false; + std::vector m_downloaders; + bool m_cancelled = false; }; } // namespace px::modrim diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index 9a9890e..022a2b2 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -2,6 +2,11 @@ #include "core/downloads/steam.hpp" +#include +#include +#include +#include +#include #include #include #include @@ -11,11 +16,7 @@ #include #include -#include -#include -#include -#include -#include + #include #include "core/utils.hpp" @@ -28,100 +29,114 @@ constexpr auto kProgressEmitInterval = std::chrono::milliseconds(100); // Steam item ids are uint64; anything larger is not an item. std::optional parseItemId(const QString& text) { - if (!isAsciiDigits(text)) { - return std::nullopt; - } - const std::string utf8 = text.toStdString(); - std::uint64_t value = 0; - const char* const last = utf8.data() + utf8.size(); - const auto [end, ec] = std::from_chars(utf8.data(), last, value); - if (ec != std::errc{} || end != last) { - return std::nullopt; - } - return value; + if (!isAsciiDigits(text)) { + return std::nullopt; + } + const std::string utf8 = text.toStdString(); + std::uint64_t value = 0; + const char* const last = utf8.data() + utf8.size(); + const auto [end, ec] = std::from_chars(utf8.data(), last, value); + if (ec != std::errc{} || end != last) { + return std::nullopt; + } + return value; } -QString itemIdText(std::uint64_t id) { return QString::number(static_cast(id)); } +QString itemIdText(std::uint64_t id) { + return QString::number(static_cast(id)); +} double unixNow() { - return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); + return std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); } // Settings used to live in config.json under `workshop_` keys. QHash legacySettingsKeys() { - QHash keys; - for (const char* field : {"auto_update_hours", "parallel_items", "threads_per_item", "proxy", - "connect_timeout", "stall_timeout"}) { - keys.insert(QStringLiteral("workshop_") + QString::fromLatin1(field), QString::fromLatin1(field)); - } - return keys; + QHash keys; + for (const char* field : + {"auto_update_hours", "parallel_items", "threads_per_item", "proxy", + "connect_timeout", "stall_timeout"}) { + keys.insert(QStringLiteral("workshop_") + QString::fromLatin1(field), + QString::fromLatin1(field)); + } + return keys; } -// The real client; pxsteamdl::Client is thread-safe, so the batch worker may use it directly. +// The real client; pxsteamdl::Client is thread-safe, so the batch worker may +// use it directly. class PxSteamDlClient final : public WorkshopClient { -public: - explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) : m_client(options) {} - - std::vector download(std::span ids, const std::filesystem::path& root, - const pxsteamdl::Options& options) override { - return m_client.download(ids, root, options); - } - -private: - pxsteamdl::Client m_client; + public: + explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) + : m_client(options) {} + + std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + return m_client.download(ids, root, options); + } + + private: + pxsteamdl::Client m_client; }; } // namespace -QCoro::Task>> loginWithPxsteamdl(LoginOptions options) { - co_return co_await QtConcurrent::run( - [options = std::move(options)]() -> Result> { - pxsteamdl::ClientOptions native; - native.proxy = options.proxy; - native.connect_timeout = std::chrono::seconds(options.connectTimeout); - native.stall_timeout = std::chrono::seconds(options.stallTimeout); - try { - return std::shared_ptr(std::make_shared(native)); - } catch (const std::exception& e) { - return std::unexpected(Error{QString::fromUtf8(e.what())}); - } - }); +QCoro::Task>> loginWithPxsteamdl( + LoginOptions options) { + co_return co_await QtConcurrent::run( + [options = + std::move(options)]() -> Result> { + pxsteamdl::ClientOptions native; + native.proxy = options.proxy; + native.connect_timeout = std::chrono::seconds(options.connectTimeout); + native.stall_timeout = std::chrono::seconds(options.stallTimeout); + try { + return std::shared_ptr( + std::make_shared(native)); + } catch (const std::exception& e) { + return std::unexpected(Error{QString::fromUtf8(e.what())}); + } + }); } SteamBatch::SteamBatch(int totalItems) - : total(totalItems), started(std::chrono::steady_clock::now()), lastLogged(started) {} + : total(totalItems), + started(std::chrono::steady_clock::now()), + lastLogged(started) {} QStringList SteamBatch::changed() const { - QStringList result; - for (const auto& id : succeeded) { - if (bytes.value(id, {0, 0}).second > 0) { - result.append(id); - } + QStringList result; + for (const auto& id : succeeded) { + if (bytes.value(id, {0, 0}).second > 0) { + result.append(id); } - return result; + } + return result; } DownloadProgress SteamBatch::progress() const { - // Items that report all their bytes (or 0/0 for an up-to-date copy) count as done - // before their Result arrives. - QSet finished(succeeded.begin(), succeeded.end()); - finished.unite(QSet(failed.begin(), failed.end())); - qint64 bytesDone = 0; - qint64 bytesTotal = 0; - for (auto it = bytes.cbegin(); it != bytes.cend(); ++it) { - const auto [done, size] = it.value(); - if (done >= size) { - finished.insert(it.key()); - } - bytesDone += done; - bytesTotal += size; - } - return DownloadProgress{ - .total = total, - .completed = static_cast(finished.size()), - .bytesDone = bytesDone, - .bytesTotal = bytesTotal, - }; + // Items that report all their bytes (or 0/0 for an up-to-date copy) count as + // done before their Result arrives. + QSet finished(succeeded.begin(), succeeded.end()); + finished.unite(QSet(failed.begin(), failed.end())); + qint64 bytesDone = 0; + qint64 bytesTotal = 0; + for (auto it = bytes.cbegin(); it != bytes.cend(); ++it) { + const auto [done, size] = it.value(); + if (done >= size) { + finished.insert(it.key()); + } + bytesDone += done; + bytesTotal += size; + } + return DownloadProgress{ + .total = total, + .completed = static_cast(finished.size()), + .bytesDone = bytesDone, + .bytesTotal = bytesTotal, + }; } SteamDownloader::SteamDownloader(SteamDownloaderDeps deps) @@ -130,439 +145,486 @@ SteamDownloader::SteamDownloader(SteamDownloaderDeps deps) m_localModsPath(std::move(deps.localModsPath)), m_installedMods(std::move(deps.installedMods)), m_clientFactory(std::move(deps.clientFactory)), - m_settings(&deps.config, QStringLiteral("steam_downloader"), legacySettingsKeys()) { - m_autoTimer.setSingleShot(true); - QObject::connect(&m_autoTimer, &QTimer::timeout, &m_autoTimer, [this] { runScheduledAutoUpdate(); }); + m_settings(&deps.config, QStringLiteral("steam_downloader"), + legacySettingsKeys()) { + m_autoTimer.setSingleShot(true); + QObject::connect(&m_autoTimer, &QTimer::timeout, &m_autoTimer, + [this] { runScheduledAutoUpdate(); }); } QCoro::Task SteamDownloader::loadSyncState() { - ConfigService& config = m_config; - auto loaded = co_await QtConcurrent::run([&config] { return config.load(kWorkshopSyncFile); }); - if (loaded) { - m_sync = std::move(*loaded); - } else { - spdlog::warn("[workshop] cannot read sync times: {}", loaded.error().message.toStdString()); - } + ConfigService& config = m_config; + auto loaded = co_await QtConcurrent::run( + [&config] { return config.load(kWorkshopSyncFile); }); + if (loaded) { + m_sync = std::move(*loaded); + } else { + spdlog::warn("[workshop] cannot read sync times: {}", + loaded.error().message.toStdString()); + } } QCoro::Task SteamDownloader::init(CoreContext&) { - co_await loadSyncState(); - armAutoUpdate(kAutoUpdateStartupDelay); + co_await loadSyncState(); + armAutoUpdate(kAutoUpdateStartupDelay); } QCoro::Task SteamDownloader::shutdown() { - m_stopping = true; - m_autoTimer.stop(); - cancel(); - if (m_running.isValid()) { - QFuture pending = m_running; - co_await pending; - } + m_stopping = true; + m_autoTimer.stop(); + cancel(); + if (m_running.isValid()) { + QFuture pending = m_running; + co_await pending; + } } bool SteamDownloader::isDownloading() const { return m_stop.has_value(); } QSet SteamDownloader::activeIds() const { return m_activeIds; } -bool SteamDownloader::accepts(const QString& modId) const { return isAsciiDigits(modId); } +bool SteamDownloader::accepts(const QString& modId) const { + return isAsciiDigits(modId); +} -std::optional SteamDownloader::updatableId(const ListedMod& mod) const { - const QString local = localPath(); - if (local.isEmpty()) { - return std::nullopt; - } - const auto& path = mod.modPath(); - if (!path) { - return std::nullopt; - } - const QFileInfo info(*path); - if (QDir::cleanPath(info.path()) != QDir::cleanPath(local)) { - return std::nullopt; - } - const auto pfid = mod.publishedFileId(); - if (!pfid || *pfid != info.fileName()) { - return std::nullopt; - } - return pfid; +std::optional SteamDownloader::updatableId( + const ListedMod& mod) const { + const QString local = localPath(); + if (local.isEmpty()) { + return std::nullopt; + } + const auto& path = mod.modPath(); + if (!path) { + return std::nullopt; + } + const QFileInfo info(*path); + if (QDir::cleanPath(info.path()) != QDir::cleanPath(local)) { + return std::nullopt; + } + const auto pfid = mod.publishedFileId(); + if (!pfid || *pfid != info.fileName()) { + return std::nullopt; + } + return pfid; } std::optional SteamDownloader::lastSynced(const ListedMod& mod) const { - const auto id = updatableId(mod); - return id ? syncedAt(*id) : std::nullopt; + const auto id = updatableId(mod); + return id ? syncedAt(*id) : std::nullopt; } QStringList SteamDownloader::staleIds(double maxAgeS, double now) const { - const double cutoff = now - maxAgeS; - const auto syncedOrZero = [this](const QString& id) { return syncedAt(id).value_or(0.0); }; - QStringList stale; - for (const auto& id : updatableIds(m_installedMods())) { - if (syncedOrZero(id) < cutoff) { - stale.append(id); - } - } - std::stable_sort(stale.begin(), stale.end(), [&](const QString& a, const QString& b) { - return syncedOrZero(a) < syncedOrZero(b); - }); - return stale; + const double cutoff = now - maxAgeS; + const auto syncedOrZero = [this](const QString& id) { + return syncedAt(id).value_or(0.0); + }; + QStringList stale; + for (const auto& id : updatableIds(m_installedMods())) { + if (syncedOrZero(id) < cutoff) { + stale.append(id); + } + } + std::stable_sort(stale.begin(), stale.end(), + [&](const QString& a, const QString& b) { + return syncedOrZero(a) < syncedOrZero(b); + }); + return stale; } -QCoro::Task> SteamDownloader::downloadMods(QStringList modIds) { - if (modIds.isEmpty()) { - co_return std::unexpected(Error{QStringLiteral("No mods selected for download.")}); - } - for (const auto& id : modIds) { - if (!parseItemId(id)) { - co_return std::unexpected( - Error{QStringLiteral("Published file IDs must be ASCII decimal numbers within uint64.")}); - } - } - const QString root = localPath(); - if (root.isEmpty()) { - co_return std::unexpected(Error{QStringLiteral("Local mods path is not configured.")}); - } - if (isDownloading()) { - co_return std::unexpected(Error{QStringLiteral("A download is already running.")}); - } - - QStringList ids; - QSet seen; - for (const auto& id : modIds) { - if (!seen.contains(id)) { - seen.insert(id); - ids.append(id); - } - } - - auto batch = std::make_shared(static_cast(ids.size())); - m_stop.emplace(); - m_activeIds = QSet(ids.begin(), ids.end()); - busyChanged.notify(true); - batchStarted.notify(ids); - spdlog::info("[workshop] downloading {} items into {} (first ids: {})", ids.size(), root.toStdString(), - ids.mid(0, 5).join(QStringLiteral(", ")).toStdString()); - - try { - co_await runBatch(ids, root, batch); - } catch (...) { - finishBatch(batch); - throw; - } - co_return finishBatch(batch); +QCoro::Task> SteamDownloader::downloadMods( + QStringList modIds) { + if (modIds.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("No mods selected for download.")}); + } + for (const auto& id : modIds) { + if (!parseItemId(id)) { + co_return std::unexpected(Error{QStringLiteral( + "Published file IDs must be ASCII decimal numbers within uint64.")}); + } + } + const QString root = localPath(); + if (root.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("Local mods path is not configured.")}); + } + if (isDownloading()) { + co_return std::unexpected( + Error{QStringLiteral("A download is already running.")}); + } + + QStringList ids; + QSet seen; + for (const auto& id : modIds) { + if (!seen.contains(id)) { + seen.insert(id); + ids.append(id); + } + } + + auto batch = std::make_shared(static_cast(ids.size())); + m_stop.emplace(); + m_activeIds = QSet(ids.begin(), ids.end()); + busyChanged.notify(true); + batchStarted.notify(ids); + spdlog::info("[workshop] downloading {} items into {} (first ids: {})", + ids.size(), root.toStdString(), + ids.mid(0, 5).join(QStringLiteral(", ")).toStdString()); + + try { + co_await runBatch(ids, root, batch); + } catch (...) { + finishBatch(batch); + throw; + } + co_return finishBatch(batch); } void SteamDownloader::cancel() { - if (m_stop) { - spdlog::debug("[workshop] cancel requested"); - m_stop->request_stop(); - } + if (m_stop) { + spdlog::debug("[workshop] cancel requested"); + m_stop->request_stop(); + } } QString SteamDownloader::localPath() const { return m_localModsPath(); } std::optional SteamDownloader::syncedAt(const QString& modId) const { - const auto it = m_sync.synced.find(modId.toStdString()); - if (it == m_sync.synced.end()) { - return std::nullopt; - } - return it->second; + const auto it = m_sync.synced.find(modId.toStdString()); + if (it == m_sync.synced.end()) { + return std::nullopt; + } + return it->second; } LoginOptions SteamDownloader::wantedOptions() const { - const auto& cfg = m_settings.value(); - LoginOptions options; - const QString proxy = QString::fromStdString(cfg.proxy).trimmed(); - if (!proxy.isEmpty()) { - options.proxy = proxy.toStdString(); - } - options.connectTimeout = std::max(1, cfg.connect_timeout); - options.stallTimeout = std::max(1, cfg.stall_timeout); - return options; + const auto& cfg = m_settings.value(); + LoginOptions options; + const QString proxy = QString::fromStdString(cfg.proxy).trimmed(); + if (!proxy.isEmpty()) { + options.proxy = proxy.toStdString(); + } + options.connectTimeout = std::max(1, cfg.connect_timeout); + options.stallTimeout = std::max(1, cfg.stall_timeout); + return options; } -QCoro::Task>> SteamDownloader::ensureClient() { - const LoginOptions options = wantedOptions(); - if (m_client && options != m_clientOptions) { - m_client.reset(); - } - if (!m_client) { - statusMessageChanged.notify(QStringLiteral(u"Logging in to Steam\u2026")); - auto loggedIn = co_await m_clientFactory(options); - if (!loggedIn) { - co_return std::unexpected(loggedIn.error()); - } - m_client = std::move(*loggedIn); - m_clientOptions = options; - } - auto result = m_client; - co_return result; +QCoro::Task>> +SteamDownloader::ensureClient() { + const LoginOptions options = wantedOptions(); + if (m_client && options != m_clientOptions) { + m_client.reset(); + } + if (!m_client) { + statusMessageChanged.notify(QStringLiteral(u"Logging in to Steam\u2026")); + auto loggedIn = co_await m_clientFactory(options); + if (!loggedIn) { + co_return std::unexpected(loggedIn.error()); + } + m_client = std::move(*loggedIn); + m_clientOptions = options; + } + auto result = m_client; + co_return result; } -QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, std::shared_ptr batch) { - const std::filesystem::path rootPath(root.toStdWString()); - const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { - std::error_code ec; - std::filesystem::create_directories(rootPath, ec); - return ec; - }); - if (mkdirError) { - const QString message = - QStringLiteral("Cannot create %1: %2").arg(root, QString::fromStdString(mkdirError.message())); - spdlog::error("[workshop] {}", message.toStdString()); - statusMessageChanged.notify(message); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } - co_return; - } - - if (!m_client) { - downloadPhaseChanged.notify(QStringLiteral("login")); - } - const auto loginStarted = std::chrono::steady_clock::now(); - auto login = co_await ensureClient(); - if (!login) { - const QString message = QStringLiteral("Steam login failed: %1").arg(login.error().message); - spdlog::error("[workshop] {}", message.toStdString()); - statusMessageChanged.notify(message); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } - co_return; - } - spdlog::debug("[workshop] steam session ready in {:.1f}s", - std::chrono::duration(std::chrono::steady_clock::now() - loginStarted).count()); - const std::shared_ptr client = *login; - - const auto& cfg = m_settings.value(); - const int parallelItems = std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); - const int threadsPerItem = std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); - spdlog::debug("[workshop] client.download start: {} items, {} parallel x {} threads", ids.size(), parallelItems, - threadsPerItem); - - std::vector itemIds; - itemIds.reserve(ids.size()); +QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, + std::shared_ptr batch) { + const std::filesystem::path rootPath(root.toStdWString()); + const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { + std::error_code ec; + std::filesystem::create_directories(rootPath, ec); + return ec; + }); + if (mkdirError) { + const QString message = + QStringLiteral("Cannot create %1: %2") + .arg(root, QString::fromStdString(mkdirError.message())); + spdlog::error("[workshop] {}", message.toStdString()); + statusMessageChanged.notify(message); for (const auto& id : ids) { - itemIds.push_back(*parseItemId(id)); // validated by download_mods - } - - // PxSteamDL calls these on its worker threads. Their state lives on the main thread, - // so each call is posted there. - QObject* const target = &m_dispatch; - pxsteamdl::Options options; - options.parallel_items = static_cast(parallelItems); - options.threads_per_item = static_cast(threadsPerItem); - options.stop = m_stop->get_token(); - options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { - QMetaObject::invokeMethod( - target, - [this, batch, id = itemIdText(info.item_id), title = QString::fromStdString(info.title)] { - onResolved(*batch, id, title); - }, - Qt::QueuedConnection); - }; - options.on_progress = [this, target, batch](const pxsteamdl::Progress& p) { - QMetaObject::invokeMethod( - target, - [this, batch, id = itemIdText(p.item_id), done = static_cast(p.unpacked_bytes), - total = static_cast(p.unpacked_total)] { onProgress(*batch, id, done, total); }, - Qt::QueuedConnection); - }; - - downloadPhaseChanged.notify(QStringLiteral("query")); - QFuture call = QtConcurrent::run( - [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { - try { - return client->download(itemIds, rootPath, options); - } catch (const std::runtime_error& e) { - // Anything else propagates to the caller, as in the Python version. - const std::string what = e.what(); - return std::unexpected(what.empty() ? std::string("download failed") : what); - } - }); - m_running = call; - Outcome outcome; - try { - outcome = co_await QFuture(call); - } catch (...) { - m_running = {}; - throw; - } - m_running = {}; - - if (!outcome) { - // A failed call leaves the Steam session suspect; log in again next time. - m_client.reset(); - const QString message = QString::fromStdString(outcome.error()); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } - co_return; - } - - const auto& results = *outcome; - // A network failure leaves the Steam session suspect; log in again next time. - if (std::ranges::any_of(results, - [](const pxsteamdl::Result& r) { return r.error_kind == pxsteamdl::ErrorKind::kNetwork; })) { - m_client.reset(); - } - - // Progress posted by the worker is queued before the completion that resumes this - // coroutine, so the item callbacks have already been handled here. - QHash byId; - for (const auto& r : results) { - byId.insert(itemIdText(r.item_id), &r); - } + finishItem(*batch, id, message); + } + co_return; + } + + if (!m_client) { + downloadPhaseChanged.notify(QStringLiteral("login")); + } + const auto loginStarted = std::chrono::steady_clock::now(); + auto login = co_await ensureClient(); + if (!login) { + const QString message = + QStringLiteral("Steam login failed: %1").arg(login.error().message); + spdlog::error("[workshop] {}", message.toStdString()); + statusMessageChanged.notify(message); for (const auto& id : ids) { - const pxsteamdl::Result* result = byId.value(id, nullptr); - if (!result) { - finishItem(*batch, id, QStringLiteral("no result")); - } else if (!result->cancelled()) { - finishItem(*batch, id, QString::fromStdString(result->error)); + finishItem(*batch, id, message); + } + co_return; + } + spdlog::debug("[workshop] steam session ready in {:.1f}s", + std::chrono::duration(std::chrono::steady_clock::now() - + loginStarted) + .count()); + const std::shared_ptr client = *login; + + const auto& cfg = m_settings.value(); + const int parallelItems = + std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); + const int threadsPerItem = + std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); + spdlog::debug( + "[workshop] client.download start: {} items, {} parallel x {} threads", + ids.size(), parallelItems, threadsPerItem); + + std::vector itemIds; + itemIds.reserve(ids.size()); + for (const auto& id : ids) { + itemIds.push_back(*parseItemId(id)); // validated by download_mods + } + + // PxSteamDL calls these on its worker threads. Their state lives on the main + // thread, so each call is posted there. + QObject* const target = &m_dispatch; + pxsteamdl::Options options; + options.parallel_items = static_cast(parallelItems); + options.threads_per_item = static_cast(threadsPerItem); + options.stop = m_stop->get_token(); + options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { + QMetaObject::invokeMethod( + target, + [this, batch, id = itemIdText(info.item_id), + title = QString::fromStdString(info.title)] { + onResolved(*batch, id, title); + }, + Qt::QueuedConnection); + }; + options.on_progress = [this, target, batch](const pxsteamdl::Progress& p) { + QMetaObject::invokeMethod( + target, + [this, batch, id = itemIdText(p.item_id), + done = static_cast(p.unpacked_bytes), + total = static_cast(p.unpacked_total)] { + onProgress(*batch, id, done, total); + }, + Qt::QueuedConnection); + }; + + downloadPhaseChanged.notify(QStringLiteral("query")); + QFuture call = QtConcurrent::run( + [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { + try { + return client->download(itemIds, rootPath, options); + } catch (const std::runtime_error& e) { + // Anything else propagates to the caller, as in the Python version. + const std::string what = e.what(); + return std::unexpected(what.empty() ? std::string("download failed") + : what); } + }); + m_running = call; + Outcome outcome; + try { + outcome = co_await QFuture(call); + } catch (...) { + m_running = {}; + throw; + } + m_running = {}; + + if (!outcome) { + // A failed call leaves the Steam session suspect; log in again next time. + m_client.reset(); + const QString message = QString::fromStdString(outcome.error()); + for (const auto& id : ids) { + finishItem(*batch, id, message); + } + co_return; + } + + const auto& results = *outcome; + // A network failure leaves the Steam session suspect; log in again next time. + if (std::ranges::any_of(results, [](const pxsteamdl::Result& r) { + return r.error_kind == pxsteamdl::ErrorKind::kNetwork; + })) { + m_client.reset(); + } + + // Progress posted by the worker is queued before the completion that resumes + // this coroutine, so the item callbacks have already been handled here. + QHash byId; + for (const auto& r : results) { + byId.insert(itemIdText(r.item_id), &r); + } + for (const auto& id : ids) { + const pxsteamdl::Result* result = byId.value(id, nullptr); + if (!result) { + finishItem(*batch, id, QStringLiteral("no result")); + } else if (!result->cancelled()) { + finishItem(*batch, id, QString::fromStdString(result->error)); } + } } -DownloadResult SteamDownloader::finishBatch(const std::shared_ptr& batch) { - m_stop.reset(); - m_activeIds.clear(); - busyChanged.notify(false); - - DownloadResult result{ - .succeeded = batch->succeeded, - .failed = batch->failed, - .changed = batch->changed(), - }; - spdlog::info("[workshop] finished in {:.1f}s: {} ok, {} failed{}", - std::chrono::duration(std::chrono::steady_clock::now() - batch->started).count(), - result.succeeded.size(), result.failed.size(), - result.failed.isEmpty() - ? std::string() - : " (failed ids: " + result.failed.mid(0, 20).join(QStringLiteral(", ")).toStdString() + ")"); - recordSynced(result.succeeded); - downloadFinished.notify(result); - return result; +DownloadResult SteamDownloader::finishBatch( + const std::shared_ptr& batch) { + m_stop.reset(); + m_activeIds.clear(); + busyChanged.notify(false); + + DownloadResult result{ + .succeeded = batch->succeeded, + .failed = batch->failed, + .changed = batch->changed(), + }; + spdlog::info("[workshop] finished in {:.1f}s: {} ok, {} failed{}", + std::chrono::duration(std::chrono::steady_clock::now() - + batch->started) + .count(), + result.succeeded.size(), result.failed.size(), + result.failed.isEmpty() ? std::string() + : " (failed ids: " + + result.failed.mid(0, 20) + .join(QStringLiteral(", ")) + .toStdString() + + ")"); + recordSynced(result.succeeded); + downloadFinished.notify(result); + return result; } -void SteamDownloader::finishItem(SteamBatch& batch, const QString& modId, const QString& error) { - const qint64 total = batch.bytes.value(modId, {0, 0}).second; - DownloadItemStatus status; - if (!error.isEmpty()) { - spdlog::warn("[workshop] {} failed: {}", modId.toStdString(), error.toStdString()); - batch.failed.append(modId); - status = DownloadItemStatus{ - .modId = modId, - .status = QStringLiteral("error"), - .bytesDone = 0, - .bytesTotal = 0, - .error = error, - }; - } else { - batch.succeeded.append(modId); - batch.bytes.insert(modId, {total, total}); - status = DownloadItemStatus{ - .modId = modId, - .status = QStringLiteral("success"), - .bytesDone = total, - .bytesTotal = total, - .error = {}, - }; - } - downloadItemStatusChanged.notify(status); - downloadProgress.notify(batch.progress()); +void SteamDownloader::finishItem(SteamBatch& batch, const QString& modId, + const QString& error) { + const qint64 total = batch.bytes.value(modId, {0, 0}).second; + DownloadItemStatus status; + if (!error.isEmpty()) { + spdlog::warn("[workshop] {} failed: {}", modId.toStdString(), + error.toStdString()); + batch.failed.append(modId); + status = DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("error"), + .bytesDone = 0, + .bytesTotal = 0, + .error = error, + }; + } else { + batch.succeeded.append(modId); + batch.bytes.insert(modId, {total, total}); + status = DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("success"), + .bytesDone = total, + .bytesTotal = total, + .error = {}, + }; + } + downloadItemStatusChanged.notify(status); + downloadProgress.notify(batch.progress()); } void SteamDownloader::recordSynced(const QStringList& ids) { - if (ids.isEmpty()) { - return; - } - const double now = unixNow(); - for (const auto& id : ids) { - m_sync.synced[id.toStdString()] = now; - } - if (auto saved = m_config.save(kWorkshopSyncFile, m_sync); !saved) { - spdlog::warn("[workshop] cannot save sync times: {}", saved.error().message.toStdString()); - } + if (ids.isEmpty()) { + return; + } + const double now = unixNow(); + for (const auto& id : ids) { + m_sync.synced[id.toStdString()] = now; + } + if (auto saved = m_config.save(kWorkshopSyncFile, m_sync); !saved) { + spdlog::warn("[workshop] cannot save sync times: {}", + saved.error().message.toStdString()); + } } -void SteamDownloader::onResolved(SteamBatch& batch, const QString& modId, const QString& title) { - ++batch.resolved; - if (!title.isEmpty()) { - downloadItemTitled.notify(DownloadItemTitle{.modId = modId, .title = title}); - } - if (batch.resolved >= batch.total) { - downloadPhaseChanged.notify(QStringLiteral("run")); - } +void SteamDownloader::onResolved(SteamBatch& batch, const QString& modId, + const QString& title) { + ++batch.resolved; + if (!title.isEmpty()) { + downloadItemTitled.notify( + DownloadItemTitle{.modId = modId, .title = title}); + } + if (batch.resolved >= batch.total) { + downloadPhaseChanged.notify(QStringLiteral("run")); + } } -void SteamDownloader::onProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total) { - if (batch.succeeded.contains(modId) || batch.failed.contains(modId)) { - return; - } - if (!batch.bytes.contains(modId)) { - ++batch.seen; - spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", modId.toStdString(), done, - total, batch.seen, batch.total); - } - batch.bytes.insert(modId, {done, total}); - - // PxSteamDL reports per network chunk, thousands of times a second; the UI needs far fewer. - const auto now = std::chrono::steady_clock::now(); - if (done >= total || now - batch.lastEmitted >= kProgressEmitInterval) { - batch.lastEmitted = now; - emitItemProgress(batch, modId, done, total); - } - if (now - batch.lastLogged >= kProgressLogInterval) { - batch.lastLogged = now; - const DownloadProgress progress = batch.progress(); - spdlog::info("[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", progress.completed, - progress.total, static_cast(progress.bytesDone) / 1048576.0, - std::chrono::duration(now - batch.started).count()); - } +void SteamDownloader::onProgress(SteamBatch& batch, const QString& modId, + qint64 done, qint64 total) { + if (batch.succeeded.contains(modId) || batch.failed.contains(modId)) { + return; + } + if (!batch.bytes.contains(modId)) { + ++batch.seen; + spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", + modId.toStdString(), done, total, batch.seen, batch.total); + } + batch.bytes.insert(modId, {done, total}); + + // PxSteamDL reports per network chunk, thousands of times a second; the UI + // needs far fewer. + const auto now = std::chrono::steady_clock::now(); + if (done >= total || now - batch.lastEmitted >= kProgressEmitInterval) { + batch.lastEmitted = now; + emitItemProgress(batch, modId, done, total); + } + if (now - batch.lastLogged >= kProgressLogInterval) { + batch.lastLogged = now; + const DownloadProgress progress = batch.progress(); + spdlog::info("[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", + progress.completed, progress.total, + static_cast(progress.bytesDone) / 1048576.0, + std::chrono::duration(now - batch.started).count()); + } } -void SteamDownloader::emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total) { - downloadItemStatusChanged.notify(DownloadItemStatus{ - .modId = modId, - .status = QStringLiteral("downloading"), - .bytesDone = done, - .bytesTotal = total, - .error = {}, - }); - downloadProgress.notify(batch.progress()); +void SteamDownloader::emitItemProgress(SteamBatch& batch, const QString& modId, + qint64 done, qint64 total) { + downloadItemStatusChanged.notify(DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("downloading"), + .bytesDone = done, + .bytesTotal = total, + .error = {}, + }); + downloadProgress.notify(batch.progress()); } QCoro::Task SteamDownloader::autoUpdateOnce() { - if (isDownloading()) { - co_return; - } - const int hours = m_settings.value().auto_update_hours; - if (hours <= 0) { - co_return; - } - const QStringList ids = staleIds(hours * 3600.0, unixNow()); - if (ids.isEmpty()) { - co_return; - } - spdlog::info("[workshop] auto-update: {} mods older than {}h", ids.size(), hours); - const auto result = co_await downloadMods(ids); - if (!result) { - spdlog::warn("[workshop] auto-update not started: {}", result.error().message.toStdString()); - } + if (isDownloading()) { + co_return; + } + const int hours = m_settings.value().auto_update_hours; + if (hours <= 0) { + co_return; + } + const QStringList ids = staleIds(hours * 3600.0, unixNow()); + if (ids.isEmpty()) { + co_return; + } + spdlog::info("[workshop] auto-update: {} mods older than {}h", ids.size(), + hours); + const auto result = co_await downloadMods(ids); + if (!result) { + spdlog::warn("[workshop] auto-update not started: {}", + result.error().message.toStdString()); + } } void SteamDownloader::armAutoUpdate(std::chrono::milliseconds delay) { - if (!m_stopping) { - m_autoTimer.start(delay); - } + if (!m_stopping) { + m_autoTimer.start(delay); + } } QCoro::Task SteamDownloader::runScheduledAutoUpdate() { - try { - co_await autoUpdateOnce(); - } catch (const std::exception& e) { - spdlog::error("[workshop] auto-update iteration failed: {}", e.what()); - } - armAutoUpdate(kAutoUpdateCheckInterval); + try { + co_await autoUpdateOnce(); + } catch (const std::exception& e) { + spdlog::error("[workshop] auto-update iteration failed: {}", e.what()); + } + armAutoUpdate(kAutoUpdateCheckInterval); } } // namespace px::modrim diff --git a/src/core/downloads/steam.hpp b/src/core/downloads/steam.hpp index 670444c..f12a974 100644 --- a/src/core/downloads/steam.hpp +++ b/src/core/downloads/steam.hpp @@ -1,8 +1,17 @@ #pragma once -// Everything here talks to PxSteamDL, so the whole file exists only when Steam support is built. +// Everything here talks to PxSteamDL, so the whole file exists only when Steam +// support is built. #ifdef PX_HAS_STEAM +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -16,16 +25,9 @@ #include #include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include + +#include #include "core/config.hpp" #include "core/downloads/downloader.hpp" @@ -46,154 +48,173 @@ inline constexpr std::chrono::seconds kAutoUpdateCheckInterval{600}; // In the config folder; records when each Workshop item was last synced. inline const QString kWorkshopSyncFile = QStringLiteral("workshop_sync.json"); -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SteamSettings { - int auto_update_hours = 0; - int parallel_items = 8; - int threads_per_item = 1; - std::string proxy; - int connect_timeout = 10; - int stall_timeout = 30; - - bool operator==(const SteamSettings&) const = default; + int auto_update_hours = 0; + int parallel_items = 8; + int threads_per_item = 1; + std::string proxy; + int connect_timeout = 10; + int stall_timeout = 30; + + bool operator==(const SteamSettings&) const = default; }; // NOLINTEND(readability-identifier-naming) // Unix seconds of the last sync, keyed by Workshop id. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct WorkshopSyncState { - std::map synced; + std::map synced; }; // NOLINTEND(readability-identifier-naming) -// Network settings of one Steam login. An unset proxy means libcurl's environment. +// Network settings of one Steam login. An unset proxy means libcurl's +// environment. struct LoginOptions { - std::optional proxy; - int connectTimeout = 10; - int stallTimeout = 30; + std::optional proxy; + int connectTimeout = 10; + int stallTimeout = 30; - bool operator==(const LoginOptions&) const = default; + bool operator==(const LoginOptions&) const = default; }; -// The part of pxsteamdl::Client that the downloader uses. A seam: tests substitute a fake. +// The part of pxsteamdl::Client that the downloader uses. A seam: tests +// substitute a fake. class WorkshopClient { -public: - virtual ~WorkshopClient() = default; + public: + virtual ~WorkshopClient() = default; - virtual std::vector download(std::span ids, - const std::filesystem::path& root, - const pxsteamdl::Options& options) = 0; + virtual std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) = 0; }; -using ClientFactory = std::function>>(LoginOptions)>; +using ClientFactory = + std::function>>( + LoginOptions)>; // Logs in anonymously with PxSteamDL on a worker thread. -QCoro::Task>> loginWithPxsteamdl(LoginOptions options); +QCoro::Task>> loginWithPxsteamdl( + LoginOptions options); -// Progress of one download batch. An item counts as complete once all its bytes are in, -// which can happen before PxSteamDL returns its Result. +// Progress of one download batch. An item counts as complete once all its bytes +// are in, which can happen before PxSteamDL returns its Result. struct SteamBatch { - explicit SteamBatch(int totalItems); - - int total = 0; - std::chrono::steady_clock::time_point started; - std::chrono::steady_clock::time_point lastLogged; - std::chrono::steady_clock::time_point lastEmitted; - int seen = 0; - int resolved = 0; - // Item id -> (bytes done, bytes total). - QHash> bytes; - QStringList succeeded; - QStringList failed; - - // Succeeded items that transferred data; up-to-date items are excluded. - QStringList changed() const; - DownloadProgress progress() const; + explicit SteamBatch(int totalItems); + + int total = 0; + std::chrono::steady_clock::time_point started; + std::chrono::steady_clock::time_point lastLogged; + std::chrono::steady_clock::time_point lastEmitted; + int seen = 0; + int resolved = 0; + // Item id -> (bytes done, bytes total). + QHash> bytes; + QStringList succeeded; + QStringList failed; + + // Succeeded items that transferred data; up-to-date items are excluded. + QStringList changed() const; + DownloadProgress progress() const; }; -// What the downloader reads from the application. Passed in rather than taken from a -// CoreContext so the downloader can be built and tested without the whole application. +// What the downloader reads from the application. Passed in rather than taken +// from a CoreContext so the downloader can be built and tested without the +// whole application. struct SteamDownloaderDeps { - DownloadManager& manager; - ConfigService& config; - // The configured local mods folder; empty when not set. - std::function localModsPath; - // Mods the application knows about; auto-update picks the stale ones among them. - std::function()> installedMods; - ClientFactory clientFactory = loginWithPxsteamdl; + DownloadManager& manager; + ConfigService& config; + // The configured local mods folder; empty when not set. + std::function localModsPath; + // Mods the application knows about; auto-update picks the stale ones among + // them. + std::function()> installedMods; + ClientFactory clientFactory = loginWithPxsteamdl; }; // Downloads Steam Workshop items into the local mods folder via PxSteamDL. class SteamDownloader : public Downloader { -public: - explicit SteamDownloader(SteamDownloaderDeps deps); - - QCoro::Task init(CoreContext& ctx) override; - QCoro::Task shutdown() override; - - bool isDownloading() const override; - QSet activeIds() const override; - bool accepts(const QString& modId) const override; - std::optional updatableId(const ListedMod& mod) const override; - std::optional lastSynced(const ListedMod& mod) const override; - QCoro::Task> downloadMods(QStringList modIds) override; - void cancel() override; - - PluginConfig& settings() noexcept { return m_settings; } - const PluginConfig& settings() const noexcept { return m_settings; } - - // Loads workshop_sync.json off the UI thread; called by init(), exposed for tests. - QCoro::Task loadSyncState(); - - // Ids never synced, or last synced more than `max_age_s` before `now` (unix seconds), - // oldest first. - QStringList staleIds(double maxAgeS, double now) const; - - // One auto-update pass: downloads the stale mods when auto-update is enabled and nothing - // is running. The timer started by init() runs this. - QCoro::Task autoUpdateOnce(); - -private: - // The per-item results of one PxSteamDL call, or the error text if the call itself failed. - using Outcome = std::expected, std::string>; - - QString localPath() const; - std::optional syncedAt(const QString& modId) const; - LoginOptions wantedOptions() const; - QCoro::Task>> ensureClient(); - - QCoro::Task runBatch(QStringList ids, QString root, std::shared_ptr batch); - DownloadResult finishBatch(const std::shared_ptr& batch); - void finishItem(SteamBatch& batch, const QString& modId, const QString& error); - void recordSynced(const QStringList& ids); - - void onResolved(SteamBatch& batch, const QString& modId, const QString& title); - void onProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total); - void emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, qint64 total); - - void armAutoUpdate(std::chrono::milliseconds delay); - QCoro::Task runScheduledAutoUpdate(); - - ConfigService& m_config; - std::function m_localModsPath; - std::function()> m_installedMods; - ClientFactory m_clientFactory; - PluginConfig m_settings; - WorkshopSyncState m_sync; - - std::shared_ptr m_client; - LoginOptions m_clientOptions; - - // Set while a batch is downloading. - std::optional m_stop; - QSet m_activeIds; - // The PxSteamDL call of the running batch, so shutdown() can wait for it. - QFuture m_running; - - // Worker-thread callbacks are posted to this object, which lives on the main thread. - QObject m_dispatch; - QTimer m_autoTimer; - bool m_stopping = false; + public: + explicit SteamDownloader(SteamDownloaderDeps deps); + + QCoro::Task init(CoreContext& ctx) override; + QCoro::Task shutdown() override; + + bool isDownloading() const override; + QSet activeIds() const override; + bool accepts(const QString& modId) const override; + std::optional updatableId(const ListedMod& mod) const override; + std::optional lastSynced(const ListedMod& mod) const override; + QCoro::Task> downloadMods(QStringList modIds) override; + void cancel() override; + + PluginConfig& settings() noexcept { return m_settings; } + const PluginConfig& settings() const noexcept { + return m_settings; + } + + // Loads workshop_sync.json off the UI thread; called by init(), exposed for + // tests. + QCoro::Task loadSyncState(); + + // Ids never synced, or last synced more than `max_age_s` before `now` (unix + // seconds), oldest first. + QStringList staleIds(double maxAgeS, double now) const; + + // One auto-update pass: downloads the stale mods when auto-update is enabled + // and nothing is running. The timer started by init() runs this. + QCoro::Task autoUpdateOnce(); + + private: + // The per-item results of one PxSteamDL call, or the error text if the call + // itself failed. + using Outcome = std::expected, std::string>; + + QString localPath() const; + std::optional syncedAt(const QString& modId) const; + LoginOptions wantedOptions() const; + QCoro::Task>> ensureClient(); + + QCoro::Task runBatch(QStringList ids, QString root, + std::shared_ptr batch); + DownloadResult finishBatch(const std::shared_ptr& batch); + void finishItem(SteamBatch& batch, const QString& modId, + const QString& error); + void recordSynced(const QStringList& ids); + + void onResolved(SteamBatch& batch, const QString& modId, + const QString& title); + void onProgress(SteamBatch& batch, const QString& modId, qint64 done, + qint64 total); + void emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, + qint64 total); + + void armAutoUpdate(std::chrono::milliseconds delay); + QCoro::Task runScheduledAutoUpdate(); + + ConfigService& m_config; + std::function m_localModsPath; + std::function()> m_installedMods; + ClientFactory m_clientFactory; + PluginConfig m_settings; + WorkshopSyncState m_sync; + + std::shared_ptr m_client; + LoginOptions m_clientOptions; + + // Set while a batch is downloading. + std::optional m_stop; + QSet m_activeIds; + // The PxSteamDL call of the running batch, so shutdown() can wait for it. + QFuture m_running; + + // Worker-thread callbacks are posted to this object, which lives on the main + // thread. + QObject m_dispatch; + QTimer m_autoTimer; + bool m_stopping = false; }; } // namespace px::modrim diff --git a/src/core/downloads/types.hpp b/src/core/downloads/types.hpp index ee97c2f..a2ca741 100644 --- a/src/core/downloads/types.hpp +++ b/src/core/downloads/types.hpp @@ -6,33 +6,34 @@ namespace px::modrim { struct DownloadProgress { - int total = 0; - int completed = 0; - qint64 bytesDone = 0; - qint64 bytesTotal = 0; + int total = 0; + int completed = 0; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; }; // `status` is "downloading", "success" or "error". struct DownloadItemStatus { - QString modId; - QString status; - qint64 bytesDone = 0; - qint64 bytesTotal = 0; - QString error; + QString modId; + QString status; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; + QString error; }; struct DownloadItemTitle { - QString modId; - QString title; + QString modId; + QString title; }; struct DownloadResult { - QStringList succeeded; - QStringList failed; - // Succeeded items that actually transferred data (others were already current). - QStringList changed; + QStringList succeeded; + QStringList failed; + // Succeeded items that actually transferred data (others were already + // current). + QStringList changed; - bool operator==(const DownloadResult&) const = default; + bool operator==(const DownloadResult&) const = default; }; } // namespace px::modrim diff --git a/src/core/error.hpp b/src/core/error.hpp index 40231c8..233cdff 100644 --- a/src/core/error.hpp +++ b/src/core/error.hpp @@ -1,13 +1,12 @@ #pragma once -#include - #include +#include namespace px::modrim { struct Error { - QString message; + QString message; }; template diff --git a/src/core/events.hpp b/src/core/events.hpp index 5fd970c..a0f7845 100644 --- a/src/core/events.hpp +++ b/src/core/events.hpp @@ -13,38 +13,39 @@ namespace px::modrim { // so handlers may connect or disconnect while an emit is in progress. template class Event { -public: - using Handler = std::function; - using Id = std::uint64_t; - - Id connect(Handler handler) { - const Id id = m_nextId++; - m_handlers.emplace_back(id, std::move(handler)); - return id; + public: + using Handler = std::function; + using Id = std::uint64_t; + + Id connect(Handler handler) { + const Id id = m_nextId++; + m_handlers.emplace_back(id, std::move(handler)); + return id; + } + + // Returns false when `id` is not connected. + bool disconnect(Id id) { + for (auto it = m_handlers.begin(); it != m_handlers.end(); ++it) { + if (it->first == id) { + m_handlers.erase(it); + return true; + } } - - // Returns false when `id` is not connected. - bool disconnect(Id id) { - for (auto it = m_handlers.begin(); it != m_handlers.end(); ++it) { - if (it->first == id) { - m_handlers.erase(it); - return true; - } - } - return false; - } - - // Named `notify`: Qt defines `emit` as a macro, which would swallow a member named so. - void notify(const T& data) const { - const auto snapshot = m_handlers; - for (const auto& [id, handler] : snapshot) { - handler(data); - } + return false; + } + + // Named `notify`: Qt defines `emit` as a macro, which would swallow a member + // named so. + void notify(const T& data) const { + const auto snapshot = m_handlers; + for (const auto& [id, handler] : snapshot) { + handler(data); } + } -private: - std::vector> m_handlers; - Id m_nextId = 1; + private: + std::vector> m_handlers; + Id m_nextId = 1; }; } // namespace px::modrim diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp index bbd3ee1..90cefc9 100644 --- a/src/core/file_io.cpp +++ b/src/core/file_io.cpp @@ -8,39 +8,42 @@ namespace px::modrim { Result readFileBytes(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - return std::unexpected(Error{file.errorString()}); - } - const QByteArray bytes = file.readAll(); - return std::string(bytes.constData(), static_cast(bytes.size())); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + const QByteArray bytes = file.readAll(); + return std::string(bytes.constData(), static_cast(bytes.size())); } Result writeFileAtomically(const QString& path, std::string_view data) { - if (!QDir().mkpath(QFileInfo(path).absolutePath())) { - return std::unexpected(Error{QStringLiteral("Cannot create directory for %1").arg(path)}); - } - // QSaveFile writes to a temporary file and renames it over the target on commit; - // it discards the temporary file when the commit does not happen. - QSaveFile file(path); - if (!file.open(QIODevice::WriteOnly)) { - return std::unexpected(Error{file.errorString()}); - } - if (file.write(data.data(), static_cast(data.size())) != static_cast(data.size())) { - return std::unexpected(Error{file.errorString()}); - } - if (!file.commit()) { - return std::unexpected(Error{file.errorString()}); - } - return {}; + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory for %1").arg(path)}); + } + // QSaveFile writes to a temporary file and renames it over the target on + // commit; it discards the temporary file when the commit does not happen. + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (file.write(data.data(), static_cast(data.size())) != + static_cast(data.size())) { + return std::unexpected(Error{file.errorString()}); + } + if (!file.commit()) { + return std::unexpected(Error{file.errorString()}); + } + return {}; } Result copyFileReplacing(const QString& source, const QString& target) { - QFile::remove(target); - if (!QFile::copy(source, target)) { - return std::unexpected(Error{QStringLiteral("Cannot copy %1 to %2").arg(source, target)}); - } - return {}; + QFile::remove(target); + if (!QFile::copy(source, target)) { + return std::unexpected( + Error{QStringLiteral("Cannot copy %1 to %2").arg(source, target)}); + } + return {}; } } // namespace px::modrim diff --git a/src/core/file_io.hpp b/src/core/file_io.hpp index 5340bca..c0ca74b 100644 --- a/src/core/file_io.hpp +++ b/src/core/file_io.hpp @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include - #include "core/error.hpp" namespace px::modrim { diff --git a/src/core/loading.cpp b/src/core/loading.cpp index 95592fd..370e9c1 100644 --- a/src/core/loading.cpp +++ b/src/core/loading.cpp @@ -7,74 +7,77 @@ namespace px::modrim { LoadingState::LoadingState(QObject* parent) : QObject(parent) {} QStringList LoadingState::statuses() const { - QStringList result; - result.reserve(static_cast(m_stack.size())); - for (const auto& frame : m_stack) { - result.append(frame.status); - } - return result; + QStringList result; + result.reserve(static_cast(m_stack.size())); + for (const auto& frame : m_stack) { + result.append(frame.status); + } + return result; } void LoadingState::push(const QString& status, int totalSteps) { - m_stack.push_back(Frame{.status = status, .currentStep = 0, .totalSteps = totalSteps}); - emitChanged(); + m_stack.push_back( + Frame{.status = status, .currentStep = 0, .totalSteps = totalSteps}); + emitChanged(); } void LoadingState::pop() { - if (!m_stack.empty()) { - m_stack.pop_back(); - } - if (m_stack.empty()) { - Q_EMIT finished(); - } else { - emitChanged(); - } + if (!m_stack.empty()) { + m_stack.pop_back(); + } + if (m_stack.empty()) { + Q_EMIT finished(); + } else { + emitChanged(); + } } void LoadingState::step(int amount) { - if (m_stack.empty()) { - return; - } - auto& frame = m_stack.back(); - frame.currentStep = std::min(frame.totalSteps, frame.currentStep + amount); - emitChanged(); + if (m_stack.empty()) { + return; + } + auto& frame = m_stack.back(); + frame.currentStep = std::min(frame.totalSteps, frame.currentStep + amount); + emitChanged(); } void LoadingState::setTotal(int total) { - if (m_stack.empty()) { - return; - } - m_stack.back().totalSteps = std::max(1, total); - emitChanged(); + if (m_stack.empty()) { + return; + } + m_stack.back().totalSteps = std::max(1, total); + emitChanged(); } void LoadingState::setProgress(int value) { - if (m_stack.empty()) { - return; - } - auto& frame = m_stack.back(); - frame.currentStep = std::max(0, std::min(frame.totalSteps, value)); - emitChanged(); + if (m_stack.empty()) { + return; + } + auto& frame = m_stack.back(); + frame.currentStep = std::max(0, std::min(frame.totalSteps, value)); + emitChanged(); } -LoadingState::TaskScope LoadingState::task(const QString& status, int totalSteps) { - push(status, totalSteps); - return TaskScope(*this); +LoadingState::TaskScope LoadingState::task(const QString& status, + int totalSteps) { + push(status, totalSteps); + return TaskScope(*this); } void LoadingState::emitChanged() { - if (m_stack.empty()) { - return; - } - const auto& frame = m_stack.back(); - // Truncated through double, as the legacy float division does: 29/100 * 100 - // is 28.999... and must still report 28. - const int pct = frame.totalSteps != 0 - ? static_cast((static_cast(frame.currentStep) / - static_cast(frame.totalSteps)) * - 100.0) - : 0; - Q_EMIT changed(frame.status, pct, frame.currentStep, frame.totalSteps); + if (m_stack.empty()) { + return; + } + const auto& frame = m_stack.back(); + // Truncated through double, as the legacy float division does: 29/100 * 100 + // is 28.999... and must still report 28. + const int pct = + frame.totalSteps != 0 + ? static_cast((static_cast(frame.currentStep) / + static_cast(frame.totalSteps)) * + 100.0) + : 0; + Q_EMIT changed(frame.status, pct, frame.currentStep, frame.totalSteps); } } // namespace px::modrim diff --git a/src/core/loading.hpp b/src/core/loading.hpp index 2c91ad8..f9a6d3d 100644 --- a/src/core/loading.hpp +++ b/src/core/loading.hpp @@ -1,64 +1,64 @@ #pragma once -#include -#include - #include #include #include +#include +#include namespace px::modrim { // Stack of progress frames for one async operation. Each frame is a subtask; // signals describe the innermost (top) frame. class LoadingState : public QObject { - Q_OBJECT + Q_OBJECT -public: - // Pops its frame on destruction. - class TaskScope { - public: - explicit TaskScope(LoadingState& state) noexcept : m_state(&state) {} - TaskScope(TaskScope&& other) noexcept : m_state(std::exchange(other.m_state, nullptr)) {} - TaskScope(const TaskScope&) = delete; - TaskScope& operator=(const TaskScope&) = delete; - TaskScope& operator=(TaskScope&&) = delete; - ~TaskScope() { - if (m_state) { - m_state->pop(); - } - } + public: + // Pops its frame on destruction. + class TaskScope { + public: + explicit TaskScope(LoadingState& state) noexcept : m_state(&state) {} + TaskScope(TaskScope&& other) noexcept + : m_state(std::exchange(other.m_state, nullptr)) {} + TaskScope(const TaskScope&) = delete; + TaskScope& operator=(const TaskScope&) = delete; + TaskScope& operator=(TaskScope&&) = delete; + ~TaskScope() { + if (m_state) { + m_state->pop(); + } + } - private: - LoadingState* m_state; - }; + private: + LoadingState* m_state; + }; - explicit LoadingState(QObject* parent = nullptr); + explicit LoadingState(QObject* parent = nullptr); - QStringList statuses() const; + QStringList statuses() const; - void push(const QString& status, int totalSteps = 100); - void pop(); - void step(int amount = 1); - void setTotal(int total); - void setProgress(int value); + void push(const QString& status, int totalSteps = 100); + void pop(); + void step(int amount = 1); + void setTotal(int total); + void setProgress(int value); - [[nodiscard]] TaskScope task(const QString& status, int totalSteps = 100); + [[nodiscard]] TaskScope task(const QString& status, int totalSteps = 100); -Q_SIGNALS: - void changed(const QString& status, int pct, int current, int total); - void finished(); + Q_SIGNALS: + void changed(const QString& status, int pct, int current, int total); + void finished(); -private: - struct Frame { - QString status; - int currentStep = 0; - int totalSteps = 100; - }; + private: + struct Frame { + QString status; + int currentStep = 0; + int totalSteps = 100; + }; - void emitChanged(); + void emitChanged(); - std::vector m_stack; + std::vector m_stack; }; } // namespace px::modrim diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp index c299a6e..efe3249 100644 --- a/src/core/migrator.cpp +++ b/src/core/migrator.cpp @@ -5,6 +5,7 @@ #include #include #include + #include #include "core/file_io.hpp" @@ -14,147 +15,155 @@ namespace px::modrim { namespace { Result execute(QSqlDatabase& db, const QString& sql) { - QSqlQuery query(db); - if (!query.exec(sql)) { - return std::unexpected(Error{query.lastError().text()}); - } - return {}; + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; } // QSqlQuery runs one statement per exec, so a schema script is split first. -// Semicolons inside string literals, quoted identifiers, and comments do not split. -// CREATE TRIGGER bodies are not recognised: their inner semicolons split and fail. +// Semicolons inside string literals, quoted identifiers, and comments do not +// split. CREATE TRIGGER bodies are not recognised: their inner semicolons split +// and fail. QStringList splitStatements(const QString& script) { - QStringList statements; - QString current; - QChar quote; - bool inLineComment = false; - bool inBlockComment = false; - - const auto flush = [&statements](QString& text) { - if (const QString statement = text.trimmed(); !statement.isEmpty()) { - statements.append(statement); - } - text.clear(); - }; - - for (qsizetype i = 0; i < script.size(); ++i) { - const QChar c = script[i]; - const QChar next = i + 1 < script.size() ? script[i + 1] : QChar(); - current.append(c); - if (inLineComment) { - inLineComment = c != u'\n'; - } else if (inBlockComment) { - if (c == u'*' && next == u'/') { - inBlockComment = false; - current.append(next); - ++i; - } - } else if (!quote.isNull()) { - if (c == quote) { - if (next == quote) { - current.append(next); - ++i; - } else { - quote = QChar(); - } - } - } else if (c == u'-' && next == u'-') { - inLineComment = true; - } else if (c == u'/' && next == u'*') { - inBlockComment = true; - current.append(next); - ++i; - } else if (c == u'\'' || c == u'"' || c == u'`') { - quote = c; - } else if (c == u';') { - flush(current); + QStringList statements; + QString current; + QChar quote; + bool inLineComment = false; + bool inBlockComment = false; + + const auto flush = [&statements](QString& text) { + if (const QString statement = text.trimmed(); !statement.isEmpty()) { + statements.append(statement); + } + text.clear(); + }; + + for (qsizetype i = 0; i < script.size(); ++i) { + const QChar c = script[i]; + const QChar next = i + 1 < script.size() ? script[i + 1] : QChar(); + current.append(c); + if (inLineComment) { + inLineComment = c != u'\n'; + } else if (inBlockComment) { + if (c == u'*' && next == u'/') { + inBlockComment = false; + current.append(next); + ++i; + } + } else if (!quote.isNull()) { + if (c == quote) { + if (next == quote) { + current.append(next); + ++i; + } else { + quote = QChar(); } + } + } else if (c == u'-' && next == u'-') { + inLineComment = true; + } else if (c == u'/' && next == u'*') { + inBlockComment = true; + current.append(next); + ++i; + } else if (c == u'\'' || c == u'"' || c == u'`') { + quote = c; + } else if (c == u';') { + flush(current); } - flush(current); - return statements; + } + flush(current); + return statements; } Result executeScript(QSqlDatabase& db, const QString& script) { - for (const auto& statement : splitStatements(script)) { - if (auto applied = execute(db, statement); !applied) { - return applied; - } + for (const auto& statement : splitStatements(script)) { + if (auto applied = execute(db, statement); !applied) { + return applied; } - return {}; + } + return {}; } Result userVersion(QSqlDatabase& db) { - QSqlQuery query(db); - if (!query.exec(QStringLiteral("PRAGMA user_version"))) { - return std::unexpected(Error{query.lastError().text()}); - } - return query.next() ? query.value(0).toInt() : 0; + QSqlQuery query(db); + if (!query.exec(QStringLiteral("PRAGMA user_version"))) { + return std::unexpected(Error{query.lastError().text()}); + } + return query.next() ? query.value(0).toInt() : 0; } // `path` with its last suffix replaced, as Python's Path.with_suffix does. QString withSuffix(const QString& path, const QString& suffix) { - const QFileInfo info(path); - const QString name = info.fileName(); - const qsizetype dot = name.lastIndexOf(u'.'); - const QString stem = dot > 0 ? name.left(dot) : name; - return QDir(info.absolutePath()).filePath(stem + suffix); + const QFileInfo info(path); + const QString name = info.fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + const QString stem = dot > 0 ? name.left(dot) : name; + return QDir(info.absolutePath()).filePath(stem + suffix); } } // namespace -Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, int schemaVersion, - const MigrationSteps& steps, const std::optional& backupPath, - const QString& label) { - auto currentVersion = userVersion(db); - if (!currentVersion) { - return std::unexpected(currentVersion.error()); - } - const int current = *currentVersion; - if (current >= schemaVersion) { - return {}; - } +Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, + int schemaVersion, const MigrationSteps& steps, + const std::optional& backupPath, + const QString& label) { + auto currentVersion = userVersion(db); + if (!currentVersion) { + return std::unexpected(currentVersion.error()); + } + const int current = *currentVersion; + if (current >= schemaVersion) { + return {}; + } - const auto stamp = [&db](int version) { - return execute(db, QStringLiteral("PRAGMA user_version = %1").arg(version)); - }; + const auto stamp = [&db](int version) { + return execute(db, QStringLiteral("PRAGMA user_version = %1").arg(version)); + }; - if (current == 0) { - spdlog::info("Initializing {} (version {})", label.toStdString(), schemaVersion); - if (auto initialized = executeScript(db, schemaSql); !initialized) { - return initialized; - } - return stamp(schemaVersion); + if (current == 0) { + spdlog::info("Initializing {} (version {})", label.toStdString(), + schemaVersion); + if (auto initialized = executeScript(db, schemaSql); !initialized) { + return initialized; } - - spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, schemaVersion); - if (backupPath) { - // Fold the WAL into the main file so the copy below is complete. - if (auto checkpointed = execute(db, QStringLiteral("PRAGMA wal_checkpoint(FULL)")); !checkpointed) { - return checkpointed; - } - const QString backup = withSuffix( - *backupPath, QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); - if (auto copied = copyFileReplacing(*backupPath, backup); !copied) { - return copied; - } + return stamp(schemaVersion); + } + + spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, + schemaVersion); + if (backupPath) { + // Fold the WAL into the main file so the copy below is complete. + if (auto checkpointed = + execute(db, QStringLiteral("PRAGMA wal_checkpoint(FULL)")); + !checkpointed) { + return checkpointed; + } + const QString backup = withSuffix( + *backupPath, + QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); + if (auto copied = copyFileReplacing(*backupPath, backup); !copied) { + return copied; } + } - for (int target = current + 1; target <= schemaVersion; ++target) { - if (!steps.contains(target)) { - return std::unexpected( - Error{QStringLiteral("Missing migration step for target version %1").arg(target)}); - } + for (int target = current + 1; target <= schemaVersion; ++target) { + if (!steps.contains(target)) { + return std::unexpected( + Error{QStringLiteral("Missing migration step for target version %1") + .arg(target)}); } - for (int target = current + 1; target <= schemaVersion; ++target) { - if (auto applied = steps.at(target)(); !applied) { - return applied; - } - if (auto stamped = stamp(target); !stamped) { - return stamped; - } + } + for (int target = current + 1; target <= schemaVersion; ++target) { + if (auto applied = steps.at(target)(); !applied) { + return applied; } - return {}; + if (auto stamped = stamp(target); !stamped) { + return stamped; + } + } + return {}; } } // namespace px::modrim diff --git a/src/core/migrator.hpp b/src/core/migrator.hpp index 01c9b5d..d908663 100644 --- a/src/core/migrator.hpp +++ b/src/core/migrator.hpp @@ -1,12 +1,11 @@ #pragma once +#include +#include #include #include #include -#include -#include - #include "core/error.hpp" namespace px::modrim { @@ -17,12 +16,14 @@ using MigrationStep = std::function()>; // Keyed by target schema version. using MigrationSteps = std::map; -// Brings the `PRAGMA user_version` of `db` up to `schema_version`. A fresh database -// (version 0) gets `schema_sql`; an older one runs the steps in order, and a file -// copy of `backup_path` is taken first. Runs synchronously: call it on the thread -// that owns `db`. -Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, int schemaVersion, - const MigrationSteps& steps, const std::optional& backupPath = std::nullopt, - const QString& label = QStringLiteral("schema")); +// Brings the `PRAGMA user_version` of `db` up to `schema_version`. A fresh +// database (version 0) gets `schema_sql`; an older one runs the steps in order, +// and a file copy of `backup_path` is taken first. Runs synchronously: call it +// on the thread that owns `db`. +Result ensureSchema( + QSqlDatabase& db, const QString& schemaSql, int schemaVersion, + const MigrationSteps& steps, + const std::optional& backupPath = std::nullopt, + const QString& label = QStringLiteral("schema")); } // namespace px::modrim diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp index 6faa698..f7097be 100644 --- a/src/core/mod_service.cpp +++ b/src/core/mod_service.cpp @@ -1,12 +1,12 @@ #include "core/mod_service.hpp" +#include +#include +#include #include #include #include -#include -#include -#include #include #include "core/context.hpp" @@ -19,7 +19,7 @@ namespace px::modrim { namespace { QString configFolderOf(const CoreContext& ctx) { - return QString::fromStdString(ctx.config().paths.config_folder); + return QString::fromStdString(ctx.config().paths.config_folder); } } // namespace @@ -30,241 +30,257 @@ ModService::ModService(CoreContext& ctx, providers::ProviderList providers, m_providers(std::move(providers)), m_metadataCache(std::move(metadataCache)), m_startupImpact(ctx) { - if (!m_metadataCache && ctx.hasConfigService()) { - m_metadataCache = std::make_unique( - services::metadataCachePath(ctx.configService().directory())); - } + if (!m_metadataCache && ctx.hasConfigService()) { + m_metadataCache = std::make_unique( + services::metadataCachePath(ctx.configService().directory())); + } } void ModService::resetProviders(providers::ProviderList providers) { - m_providers = std::move(providers); + m_providers = std::move(providers); } -providers::BaseModProvider* ModService::getProvider(const QString& providerId) const { - for (const auto& provider : m_providers) { - if (provider->providerId() == providerId) { - return provider.get(); - } +providers::BaseModProvider* ModService::getProvider( + const QString& providerId) const { + for (const auto& provider : m_providers) { + if (provider->providerId() == providerId) { + return provider.get(); } - return nullptr; + } + return nullptr; } QHash ModService::providerColors() const { - QHash colors; - for (const auto& provider : m_providers) { - colors.insert(provider->providerId(), provider->color()); - } - return colors; + QHash colors; + for (const auto& provider : m_providers) { + colors.insert(provider->providerId(), provider->color()); + } + return colors; } -QCoro::Task> ModService::discoverOne(providers::BaseModProvider& provider, - Profiler& timer, bool forceReparse) { - auto scope = timer(QStringLiteral("provider.%1").arg(provider.providerId())); - co_return co_await provider.discover(m_ctx.targetVersion(), &timer, m_metadataCache.get(), - forceReparse); +QCoro::Task> ModService::discoverOne( + providers::BaseModProvider& provider, Profiler& timer, bool forceReparse) { + auto scope = timer(QStringLiteral("provider.%1").arg(provider.providerId())); + co_return co_await provider.discover(m_ctx.targetVersion(), &timer, + m_metadataCache.get(), forceReparse); } -QCoro::Task> ModService::discover(Profiler* timer, bool forceReparse) { - // Each provider profiles into its own tree, since their discoveries interleave. - std::unique_ptr ownTimer; - if (!timer) { - ownTimer = std::make_unique(); - timer = ownTimer.get(); +QCoro::Task> ModService::discover(Profiler* timer, + bool forceReparse) { + // Each provider profiles into its own tree, since their discoveries + // interleave. + std::unique_ptr ownTimer; + if (!timer) { + ownTimer = std::make_unique(); + timer = ownTimer.get(); + } + auto scope = (*timer)(QStringLiteral("discover")); + + std::vector> childTimers; + std::vector>> tasks; + childTimers.reserve(m_providers.size()); + tasks.reserve(m_providers.size()); + for (const auto& provider : m_providers) { + childTimers.push_back(std::make_unique()); + tasks.push_back(discoverOne(*provider, *childTimers.back(), forceReparse)); + } + + providers::ModMap allMods; + std::optional failure; + for (std::size_t i = 0; i < tasks.size(); ++i) { + auto result = co_await tasks[i]; + timer->adopt(*childTimers[i]); + if (!result) { + if (!failure) { + failure = result.error(); + } + continue; } - auto scope = (*timer)(QStringLiteral("discover")); - - std::vector> childTimers; - std::vector>> tasks; - childTimers.reserve(m_providers.size()); - tasks.reserve(m_providers.size()); - for (const auto& provider : m_providers) { - childTimers.push_back(std::make_unique()); - tasks.push_back(discoverOne(*provider, *childTimers.back(), forceReparse)); + for (auto it = result->cbegin(); it != result->cend(); ++it) { + allMods.insert(it.key(), it.value()); } - - providers::ModMap allMods; - std::optional failure; - for (std::size_t i = 0; i < tasks.size(); ++i) { - auto result = co_await tasks[i]; - timer->adopt(*childTimers[i]); - if (!result) { - if (!failure) { - failure = result.error(); - } - continue; - } - for (auto it = result->cbegin(); it != result->cend(); ++it) { - allMods.insert(it.key(), it.value()); - } - } - if (failure) { - co_return std::unexpected(*failure); - } - - if (m_metadataCache) { - auto prune = (*timer)(QStringLiteral("cache_prune")); - QStringList modPaths; - for (const auto& mod : allMods) { - if (mod->modPath()) { - modPaths.append(*mod->modPath()); - } - } - const QStringList livePaths = co_await providers::runOnPool( - nullptr, [modPaths] { return services::normalizePaths(modPaths); }); - co_await m_metadataCache->pruneDisappeared(QSet(livePaths.cbegin(), livePaths.cend())); - } - - { - auto resolve = (*timer)(QStringLiteral("resolve_active")); - const QStringList active = services::resolveActiveUuids(allMods, configFolderOf(m_ctx)); - m_ctx.load(std::move(allMods), active); - } - - if (ownTimer) { - spdlog::debug("Profile report:\n{}", ownTimer->render().toStdString()); + } + if (failure) { + co_return std::unexpected(*failure); + } + + if (m_metadataCache) { + auto prune = (*timer)(QStringLiteral("cache_prune")); + QStringList modPaths; + for (const auto& mod : allMods) { + if (mod->modPath()) { + modPaths.append(*mod->modPath()); + } } - co_return {}; + const QStringList livePaths = co_await providers::runOnPool( + nullptr, [modPaths] { return services::normalizePaths(modPaths); }); + co_await m_metadataCache->pruneDisappeared( + QSet(livePaths.cbegin(), livePaths.cend())); + } + + { + auto resolve = (*timer)(QStringLiteral("resolve_active")); + const QStringList active = + services::resolveActiveUuids(allMods, configFolderOf(m_ctx)); + m_ctx.load(std::move(allMods), active); + } + + if (ownTimer) { + spdlog::debug("Profile report:\n{}", ownTimer->render().toStdString()); + } + co_return {}; } -QCoro::Task> ModService::runRefreshPipeline(const QString& label, bool forceReparse) { - Profiler timer; - auto scope = timer(QStringLiteral("mod_service.%1").arg(label)); - - Profiler discoverTimer; - Profiler diagnosticsTimer; - Profiler impactTimer; - auto discovery = discover(&discoverTimer, forceReparse); - auto diagnostics = m_ctx.diagnosticsService().initialize(&diagnosticsTimer); - auto impact = m_startupImpact.load(); - - const auto discovered = co_await discovery; - const auto initialized = co_await diagnostics; - const auto loaded = co_await impact; - timer.adopt(discoverTimer); - timer.adopt(diagnosticsTimer); - timer.adopt(impactTimer); - - if (!discovered) { - co_return std::unexpected(discovered.error()); - } - if (!initialized) { - co_return std::unexpected(initialized.error()); - } - if (!loaded) { - co_return std::unexpected(loaded.error()); - } - m_ctx.diagnosticsService().rebuild(m_ctx.activeUuids(), &timer); - spdlog::debug("Profile report:\n{}", timer.render().toStdString()); - modsChanged.notify(std::monostate{}); - co_return {}; +QCoro::Task> ModService::runRefreshPipeline(const QString& label, + bool forceReparse) { + Profiler timer; + auto scope = timer(QStringLiteral("mod_service.%1").arg(label)); + + Profiler discoverTimer; + Profiler diagnosticsTimer; + Profiler impactTimer; + auto discovery = discover(&discoverTimer, forceReparse); + auto diagnostics = m_ctx.diagnosticsService().initialize(&diagnosticsTimer); + auto impact = m_startupImpact.load(); + + const auto discovered = co_await discovery; + const auto initialized = co_await diagnostics; + const auto loaded = co_await impact; + timer.adopt(discoverTimer); + timer.adopt(diagnosticsTimer); + timer.adopt(impactTimer); + + if (!discovered) { + co_return std::unexpected(discovered.error()); + } + if (!initialized) { + co_return std::unexpected(initialized.error()); + } + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_ctx.diagnosticsService().rebuild(m_ctx.activeUuids(), &timer); + spdlog::debug("Profile report:\n{}", timer.render().toStdString()); + modsChanged.notify(std::monostate{}); + co_return {}; } std::optional ModService::snapshotsDir() const { - if (!m_ctx.hasConfigService()) { - return std::nullopt; - } - return QDir(m_ctx.configService().directory()).filePath(QStringLiteral("snapshots")); + if (!m_ctx.hasConfigService()) { + return std::nullopt; + } + return QDir(m_ctx.configService().directory()) + .filePath(QStringLiteral("snapshots")); } void ModService::snapshotCurrentConfig() { - const QString configFolder = configFolderOf(m_ctx); - const auto snapshots = snapshotsDir(); - if (configFolder.isEmpty() || !snapshots) { - return; - } - const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); - if (!QFileInfo(configPath).isFile()) { - return; - } - if (auto created = createSnapshot(configPath, *snapshots, m_ctx.config().max_snapshots); !created) { - spdlog::warn("Failed to snapshot ModsConfig.xml: {}", created.error().message.toStdString()); - } + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + return; + } + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + if (!QFileInfo(configPath).isFile()) { + return; + } + if (auto created = + createSnapshot(configPath, *snapshots, m_ctx.config().max_snapshots); + !created) { + spdlog::warn("Failed to snapshot ModsConfig.xml: {}", + created.error().message.toStdString()); + } } QCoro::Task> ModService::initialize() { - snapshotCurrentConfig(); - co_return co_await runRefreshPipeline(QStringLiteral("initialize"), false); + snapshotCurrentConfig(); + co_return co_await runRefreshPipeline(QStringLiteral("initialize"), false); } QCoro::Task> ModService::reload(bool full) { - co_return co_await runRefreshPipeline(QStringLiteral("reload"), full); + co_return co_await runRefreshPipeline(QStringLiteral("reload"), full); } QStringList ModService::getSnapshots() const { - const auto snapshots = snapshotsDir(); - return snapshots ? px::modrim::getSnapshots(*snapshots) : QStringList{}; + const auto snapshots = snapshotsDir(); + return snapshots ? px::modrim::getSnapshots(*snapshots) : QStringList{}; } QCoro::Task ModService::restoreSnapshot(QString snapshotPath) { - const QString configFolder = configFolderOf(m_ctx); - const auto snapshots = snapshotsDir(); - if (configFolder.isEmpty() || !snapshots) { - spdlog::warn("Cannot restore snapshot: configuration is incomplete"); - co_return false; - } - - const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); - auto restored = px::modrim::restoreSnapshot(snapshotPath, configPath, *snapshots, - m_ctx.config().max_snapshots); - if (!restored) { - spdlog::warn("Failed to restore snapshot: {}", restored.error().message.toStdString()); - co_return false; - } - auto reloaded = co_await reload(); - if (!reloaded) { - spdlog::error("Reload after snapshot restore failed: {}", - reloaded.error().message.toStdString()); - co_return false; - } - co_return true; + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + spdlog::warn("Cannot restore snapshot: configuration is incomplete"); + co_return false; + } + + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + auto restored = px::modrim::restoreSnapshot( + snapshotPath, configPath, *snapshots, m_ctx.config().max_snapshots); + if (!restored) { + spdlog::warn("Failed to restore snapshot: {}", + restored.error().message.toStdString()); + co_return false; + } + auto reloaded = co_await reload(); + if (!reloaded) { + spdlog::error("Reload after snapshot restore failed: {}", + reloaded.error().message.toStdString()); + co_return false; + } + co_return true; } QCoro::Task ModService::saveActiveLayout(QStringList activeIds) { - const QString configFolder = configFolderOf(m_ctx); - const auto snapshots = snapshotsDir(); - if (configFolder.isEmpty() || !snapshots) { - co_return false; + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + co_return false; + } + + const auto& mods = m_ctx.allMods(); + QList packageIds; + for (const QString& uuid : activeIds) { + if (auto about = std::dynamic_pointer_cast(mods.value(uuid))) { + packageIds.append(about->packageId); } - - const auto& mods = m_ctx.allMods(); - QList packageIds; - for (const QString& uuid : activeIds) { - if (auto about = std::dynamic_pointer_cast(mods.value(uuid))) { - packageIds.append(about->packageId); - } - } - const QString configPath = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); - const QString targetVersion = m_ctx.targetVersion(); - const int maxSnapshots = m_ctx.config().max_snapshots; - const QString snapshotsPath = *snapshots; - - auto written = co_await providers::runOnPool>( - nullptr, [this, configPath, snapshotsPath, targetVersion, maxSnapshots, - packageIds = std::move(packageIds)] { - QMutexLocker lock(&m_saveMutex); - const auto existing = parseModsConfig(configPath); - ModsConfig data; - data.version = targetVersion; - data.activeMods = packageIds; - data.knownExpansions = existing ? existing->knownExpansions : QList{}; - return writeModsConfig(configPath, data, snapshotsPath, maxSnapshots); - }); - if (!written) { - spdlog::warn("Failed to save ModsConfig.xml: {}", written.error().message.toStdString()); - co_return false; - } - co_return true; + } + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const QString targetVersion = m_ctx.targetVersion(); + const int maxSnapshots = m_ctx.config().max_snapshots; + const QString snapshotsPath = *snapshots; + + auto written = co_await providers::runOnPool>( + nullptr, [this, configPath, snapshotsPath, targetVersion, maxSnapshots, + packageIds = std::move(packageIds)] { + QMutexLocker lock(&m_saveMutex); + const auto existing = parseModsConfig(configPath); + ModsConfig data; + data.version = targetVersion; + data.activeMods = packageIds; + data.knownExpansions = + existing ? existing->knownExpansions : QList{}; + return writeModsConfig(configPath, data, snapshotsPath, maxSnapshots); + }); + if (!written) { + spdlog::warn("Failed to save ModsConfig.xml: {}", + written.error().message.toStdString()); + co_return false; + } + co_return true; } QCoro::Task ModService::close() { - if (m_metadataCache) { - co_await m_metadataCache->close(); - } + if (m_metadataCache) { + co_await m_metadataCache->close(); + } } void ModService::closeSync() { - // Destroying the cache closes its connection and joins its worker thread. - m_metadataCache.reset(); + // Destroying the cache closes its connection and joins its worker thread. + m_metadataCache.reset(); } } // namespace px::modrim diff --git a/src/core/mod_service.hpp b/src/core/mod_service.hpp index 0dc6ed2..93d0f64 100644 --- a/src/core/mod_service.hpp +++ b/src/core/mod_service.hpp @@ -1,14 +1,14 @@ #pragma once +#include +#include +#include +#include #include #include #include #include -#include -#include -#include -#include #include "core/error.hpp" #include "core/events.hpp" @@ -23,55 +23,61 @@ class CoreContext; // Orchestrates mod discovery and lifecycle across a set of providers. class ModService { -public: - // `ctx` is not owned and must outlive the service. Without a `metadata_cache`, one is created - // in the config folder when the context has a config service. - ModService(CoreContext& ctx, providers::ProviderList providers, - std::unique_ptr metadataCache = nullptr); - - startup_impact::StartupImpactService& startupImpact() noexcept { return m_startupImpact; } - - // Replaces the provider set. - void resetProviders(providers::ProviderList providers); - // The provider with `provider_id`, or nullptr. - providers::BaseModProvider* getProvider(const QString& providerId) const; - QHash providerColors() const; - - // Runs all providers in parallel and loads the results into the context. `timer` is optional; - // without one, the profile report goes to the debug log. - QCoro::Task> discover(Profiler* timer = nullptr, bool forceReparse = false); - - // One-time startup work: snapshot the mod list, discover, initialize diagnostics, load impact. - QCoro::Task> initialize(); - // Reparses About.xml files when `full` is set. - QCoro::Task> reload(bool full = false); - - // Available mod-list snapshots, newest first. - QStringList getSnapshots() const; - // Restores a snapshot to ModsConfig.xml and reloads live state. - QCoro::Task restoreSnapshot(QString snapshotPath); - // Persists the given active UUIDs as ModsConfig.xml. - QCoro::Task saveActiveLayout(QStringList activeIds); - - QCoro::Task close(); - // Synchronous teardown for use after the event loop has stopped. - void closeSync(); - - Event modsChanged; - -private: - QCoro::Task> discoverOne(providers::BaseModProvider& provider, - Profiler& timer, bool forceReparse); - QCoro::Task> runRefreshPipeline(const QString& label, bool forceReparse); - void snapshotCurrentConfig(); - std::optional snapshotsDir() const; - - CoreContext& m_ctx; - providers::ProviderList m_providers; - std::unique_ptr m_metadataCache; - startup_impact::StartupImpactService m_startupImpact; - // Serializes ModsConfig.xml writes; they run on the thread pool. - mutable QMutex m_saveMutex; + public: + // `ctx` is not owned and must outlive the service. Without a + // `metadata_cache`, one is created in the config folder when the context has + // a config service. + ModService(CoreContext& ctx, providers::ProviderList providers, + std::unique_ptr metadataCache = nullptr); + + startup_impact::StartupImpactService& startupImpact() noexcept { + return m_startupImpact; + } + + // Replaces the provider set. + void resetProviders(providers::ProviderList providers); + // The provider with `provider_id`, or nullptr. + providers::BaseModProvider* getProvider(const QString& providerId) const; + QHash providerColors() const; + + // Runs all providers in parallel and loads the results into the context. + // `timer` is optional; without one, the profile report goes to the debug log. + QCoro::Task> discover(Profiler* timer = nullptr, + bool forceReparse = false); + + // One-time startup work: snapshot the mod list, discover, initialize + // diagnostics, load impact. + QCoro::Task> initialize(); + // Reparses About.xml files when `full` is set. + QCoro::Task> reload(bool full = false); + + // Available mod-list snapshots, newest first. + QStringList getSnapshots() const; + // Restores a snapshot to ModsConfig.xml and reloads live state. + QCoro::Task restoreSnapshot(QString snapshotPath); + // Persists the given active UUIDs as ModsConfig.xml. + QCoro::Task saveActiveLayout(QStringList activeIds); + + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void closeSync(); + + Event modsChanged; + + private: + QCoro::Task> discoverOne( + providers::BaseModProvider& provider, Profiler& timer, bool forceReparse); + QCoro::Task> runRefreshPipeline(const QString& label, + bool forceReparse); + void snapshotCurrentConfig(); + std::optional snapshotsDir() const; + + CoreContext& m_ctx; + providers::ProviderList m_providers; + std::unique_ptr m_metadataCache; + startup_impact::StartupImpactService m_startupImpact; + // Serializes ModsConfig.xml writes; they run on the thread pool. + mutable QMutex m_saveMutex; }; } // namespace px::modrim diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 420de3e..41cca09 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -1,9 +1,9 @@ #include "core/models/metadata/parsing.hpp" -#include - #include #include +#include + #include #include "core/constants.hpp" @@ -14,397 +14,417 @@ namespace px::modrim { namespace { -// Either the stripped text of an element or the stripped texts of its
  • children. +// Either the stripped text of an element or the stripped texts of its
  • +// children. using ElementValue = std::variant; std::string utf8(const QString& text) { return text.toStdString(); } void setInvalid(ListedMod& mod, const QString& message) { - mod.valid = false; - spdlog::warn("{}", utf8(message)); + mod.valid = false; + spdlog::warn("{}", utf8(message)); } CaseInsensitiveStr missingPackageId() { - return CaseInsensitiveStr(QString(kDefaultMissingPackageid)); + return CaseInsensitiveStr(QString(kDefaultMissingPackageid)); } // Text or
  • list of an element; nullopt when the element is absent or empty. std::optional elementValue(pugi::xml_node element) { - QStringList items; - bool hasItems = false; - for (pugi::xml_node li : element.children("li")) { - hasItems = true; - const QString text = rawText(li).trimmed(); - if (!text.isEmpty()) { - items.append(text); - } - } - if (hasItems) { - return ElementValue{items}; - } - const QString text = rawText(element).trimmed(); + QStringList items; + bool hasItems = false; + for (pugi::xml_node li : element.children("li")) { + hasItems = true; + const QString text = rawText(li).trimmed(); if (!text.isEmpty()) { - return ElementValue{text}; + items.append(text); } - return std::nullopt; + } + if (hasItems) { + return ElementValue{items}; + } + const QString text = rawText(element).trimmed(); + if (!text.isEmpty()) { + return ElementValue{text}; + } + return std::nullopt; } std::optional asText(const std::optional& value) { - if (value && std::holds_alternative(*value)) { - return std::get(*value); - } - return std::nullopt; + if (value && std::holds_alternative(*value)) { + return std::get(*value); + } + return std::nullopt; } std::optional asList(const std::optional& value) { - if (value && std::holds_alternative(*value)) { - return std::get(*value); - } - return std::nullopt; + if (value && std::holds_alternative(*value)) { + return std::get(*value); + } + return std::nullopt; } -// The `` child matching the target game version, or a null node when none -// matches or the version has no major.minor part. -pugi::xml_node matchVersionedChild(pugi::xml_node parent, const QString& targetVersion) { - const QStringList parts = targetVersion.split(QLatin1Char('.')); - if (parts.size() < 2) { - return {}; - } - const QString majorMinor = parts[0] + QLatin1Char('.') + parts[1]; - const std::string prefixed = utf8(QLatin1Char('v') + majorMinor); - return parent.child(prefixed.c_str()); +// The `` child matching the target game version, or a null +// node when none matches or the version has no major.minor part. +pugi::xml_node matchVersionedChild(pugi::xml_node parent, + const QString& targetVersion) { + const QStringList parts = targetVersion.split(QLatin1Char('.')); + if (parts.size() < 2) { + return {}; + } + const QString majorMinor = parts[0] + QLatin1Char('.') + parts[1]; + const std::string prefixed = utf8(QLatin1Char('v') + majorMinor); + return parent.child(prefixed.c_str()); } QString officialModName(const QString& packageId, const QString& name) { - if (!name.isEmpty() && name != QStringLiteral("Unknown Mod Name") && name != packageId) { - return name; - } - if (packageId == QLatin1StringView("ludeon.rimworld")) { - return QStringLiteral("Core"); - } - if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId)) { - const QString dlcName(dlc->name); - const QString prefix = QStringLiteral("RimWorld - "); - return dlcName.startsWith(prefix) ? dlcName.mid(prefix.size()) : dlcName; - } - return packageId; + if (!name.isEmpty() && name != QStringLiteral("Unknown Mod Name") && + name != packageId) { + return name; + } + if (packageId == QLatin1StringView("ludeon.rimworld")) { + return QStringLiteral("Core"); + } + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId)) { + const QString dlcName(dlc->name); + const QString prefix = QStringLiteral("RimWorld - "); + return dlcName.startsWith(prefix) ? dlcName.mid(prefix.size()) : dlcName; + } + return packageId; } DependencyMod dependencyFromLi(pugi::xml_node li) { - DependencyMod dep; - if (const auto packageId = childText(li, "packageId")) { - dep.packageId = CaseInsensitiveStr(*packageId); - } - if (const auto name = childText(li, "displayName")) { - dep.name = *name; - } - if (const auto url = childText(li, "workshopUrl")) { - dep.workshopUrl = *url; - } - if (pugi::xml_node alternatives = li.child("alternativePackageIds")) { - for (pugi::xml_node alternative : alternatives.children("li")) { - const QString text = rawText(alternative).trimmed(); - if (!text.isEmpty()) { - dep.alternativePackageIds.add(text); - } - } + DependencyMod dep; + if (const auto packageId = childText(li, "packageId")) { + dep.packageId = CaseInsensitiveStr(*packageId); + } + if (const auto name = childText(li, "displayName")) { + dep.name = *name; + } + if (const auto url = childText(li, "workshopUrl")) { + dep.workshopUrl = *url; + } + if (pugi::xml_node alternatives = li.child("alternativePackageIds")) { + for (pugi::xml_node alternative : alternatives.children("li")) { + const QString text = rawText(alternative).trimmed(); + if (!text.isEmpty()) { + dep.alternativePackageIds.add(text); + } } - return dep; + } + return dep; } // lxml's `len(li)` counts comments as well as elements, but not text. bool hasChildNodes(pugi::xml_node node) { - for (pugi::xml_node child = node.first_child(); child; child = child.next_sibling()) { - if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { - return true; - } + for (pugi::xml_node child = node.first_child(); child; + child = child.next_sibling()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { + return true; } - return false; + } + return false; } std::shared_ptr aboutModWithoutXml() { - auto mod = std::make_shared(); - mod->valid = false; - return mod; + auto mod = std::make_shared(); + mod->valid = false; + return mod; } template -Result> withModPath(std::shared_ptr mod, const QString& path) { - if (auto set = mod->setModPath(path); !set) { - return std::unexpected(set.error()); - } - return mod; +Result> withModPath(std::shared_ptr mod, + const QString& path) { + if (auto set = mod->setModPath(path); !set) { + return std::unexpected(set.error()); + } + return mod; } Result invalidListedMod(const QString& path) { - auto mod = std::make_shared(); - mod->valid = false; - return withModPath(std::move(mod), path); + auto mod = std::make_shared(); + mod->valid = false; + return withModPath(std::move(mod), path); } -// Mirrors the legacy `_create_about_mod_from_xml`: unreadable or malformed files still yield an -// invalid AboutXmlMod that carries the mod path, so the caller can cache and report it. -Result> createAboutModFromXml(const QString& basePath, - const QString& aboutXmlPath, - const QString& targetVersion, - bool preferVersioned) { - pugi::xml_document doc; - if (auto loaded = loadXmlFile(doc, aboutXmlPath); !loaded) { - spdlog::error("Unable to parse {}: {}", utf8(aboutXmlPath), utf8(loaded.error().message)); - return withModPath(aboutModWithoutXml(), basePath); - } - - auto mod = createAboutModFromElement(doc.document_element(), targetVersion, preferVersioned); - auto withPath = withModPath(std::move(mod), basePath); - if (!withPath) { - return withPath; - } - const QFileInfo info(aboutXmlPath); - (*withPath)->mtime = info.exists() ? info.lastModified().toMSecsSinceEpoch() / 1000.0 : 0.0; +// Mirrors the legacy `_create_about_mod_from_xml`: unreadable or malformed +// files still yield an invalid AboutXmlMod that carries the mod path, so the +// caller can cache and report it. +Result> createAboutModFromXml( + const QString& basePath, const QString& aboutXmlPath, + const QString& targetVersion, bool preferVersioned) { + pugi::xml_document doc; + if (auto loaded = loadXmlFile(doc, aboutXmlPath); !loaded) { + spdlog::error("Unable to parse {}: {}", utf8(aboutXmlPath), + utf8(loaded.error().message)); + return withModPath(aboutModWithoutXml(), basePath); + } + + auto mod = createAboutModFromElement(doc.document_element(), targetVersion, + preferVersioned); + auto withPath = withModPath(std::move(mod), basePath); + if (!withPath) { return withPath; + } + const QFileInfo info(aboutXmlPath); + (*withPath)->mtime = + info.exists() ? info.lastModified().toMSecsSinceEpoch() / 1000.0 : 0.0; + return withPath; } } // namespace -std::shared_ptr createAboutModFromElement(pugi::xml_node root, - const QString& targetVersion, - bool preferVersioned) { - auto mod = std::make_shared(); - bool hasPackageId = false; - - pugi::xml_node depsEl; - bool depsByVersion = false; - QStringList loadBeforeLi; - QStringList loadAfterLi; - QStringList incompatLi; - QStringList forceBeforeLi; - QStringList forceAfterLi; - pugi::xml_node loadBeforeByVersionEl; - pugi::xml_node loadAfterByVersionEl; - pugi::xml_node incompatByVersionEl; - - for (pugi::xml_node child = root.first_child(); child; child = child.next_sibling()) { - if (child.type() != pugi::node_element) { - continue; - } - const std::string_view tag = child.name(); - - if (tag == "packageId") { - hasPackageId = true; - const auto text = asText(elementValue(child)); - if (text && !text->isEmpty()) { - mod->packageId = CaseInsensitiveStr(*text); - } else { - setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel " - "'%1'.") - .arg(QString(kDefaultMissingPackageid))); - mod->packageId = missingPackageId(); - } - } else if (tag == "steamAppId") { - const auto text = asText(elementValue(child)); - if (text && isAsciiDigits(*text)) { - bool ok = false; - const qint64 appId = text->toLongLong(&ok); - if (ok) { - mod->steamAppId = appId; - } - } - } else if (tag == "name") { - if (const auto text = asText(elementValue(child))) { - mod->name = *text; - } - } else if (tag == "description") { - if (const auto text = asText(elementValue(child))) { - mod->description = *text; - } - } else if (tag == "author") { - if (const auto text = asText(elementValue(child))) { - mod->authors.append(*text); - } - } else if (tag == "authors") { - if (const auto list = asList(elementValue(child))) { - mod->authors.append(*list); - } - } else if (tag == "supportedVersions") { - if (const auto list = asList(elementValue(child))) { - mod->supportedVersions = QSet(list->cbegin(), list->cend()); - } - } else if (tag == "modVersion") { - if (const auto text = asText(elementValue(child))) { - mod->modVersion = *text; - } - } else if (tag == "modIconPath") { - if (const auto text = asText(elementValue(child))) { - mod->modIconPath = *text; - } - } else if (tag == "url") { - if (const auto text = asText(elementValue(child))) { - mod->url = *text; - } - } else if (tag == "modDependencies") { - if (!depsByVersion) { - depsEl = child; - } - } else if (tag == "modDependenciesByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { - depsEl = matched; - depsByVersion = true; - } - } - } else if (tag == "loadBefore") { - if (const auto list = asList(elementValue(child))) { - loadBeforeLi = *list; - } - } else if (tag == "loadBeforeByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { - loadBeforeByVersionEl = matched; - } - } - } else if (tag == "loadAfter") { - if (const auto list = asList(elementValue(child))) { - loadAfterLi = *list; - } - } else if (tag == "loadAfterByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { - loadAfterByVersionEl = matched; - } - } - } else if (tag == "forceLoadBefore") { - if (const auto list = asList(elementValue(child))) { - forceBeforeLi = *list; - } - } else if (tag == "forceLoadAfter") { - if (const auto list = asList(elementValue(child))) { - forceAfterLi = *list; - } - } else if (tag == "incompatibleWith") { - if (const auto list = asList(elementValue(child))) { - incompatLi = *list; - } - } else if (tag == "incompatibleWithByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { - incompatByVersionEl = matched; - } - } - } else if (tag == "descriptionsByVersion") { - if (const pugi::xml_node matched = matchVersionedChild(child, targetVersion)) { - const QString text = rawText(matched).trimmed(); - if (!text.isEmpty()) { - mod->description = text; - } - } - } +std::shared_ptr createAboutModFromElement( + pugi::xml_node root, const QString& targetVersion, bool preferVersioned) { + auto mod = std::make_shared(); + bool hasPackageId = false; + + pugi::xml_node depsEl; + bool depsByVersion = false; + QStringList loadBeforeLi; + QStringList loadAfterLi; + QStringList incompatLi; + QStringList forceBeforeLi; + QStringList forceAfterLi; + pugi::xml_node loadBeforeByVersionEl; + pugi::xml_node loadAfterByVersionEl; + pugi::xml_node incompatByVersionEl; + + for (pugi::xml_node child = root.first_child(); child; + child = child.next_sibling()) { + if (child.type() != pugi::node_element) { + continue; } - - if (!hasPackageId) { - setInvalid(*mod, QStringLiteral("packageId missing or invalid. Assigned sentinel '%1'.") + const std::string_view tag = child.name(); + + if (tag == "packageId") { + hasPackageId = true; + const auto text = asText(elementValue(child)); + if (text && !text->isEmpty()) { + mod->packageId = CaseInsensitiveStr(*text); + } else { + setInvalid(*mod, QStringLiteral( + "packageId missing or invalid. Assigned sentinel " + "'%1'.") .arg(QString(kDefaultMissingPackageid))); mod->packageId = missingPackageId(); - } - - // Versioned overrides replace the unversioned lists they shadow. - if (loadBeforeByVersionEl) { - if (const auto list = asList(elementValue(loadBeforeByVersionEl))) { - loadBeforeLi = *list; + } + } else if (tag == "steamAppId") { + const auto text = asText(elementValue(child)); + if (text && isAsciiDigits(*text)) { + bool ok = false; + const qint64 appId = text->toLongLong(&ok); + if (ok) { + mod->steamAppId = appId; } - } - if (loadAfterByVersionEl) { - if (const auto list = asList(elementValue(loadAfterByVersionEl))) { - loadAfterLi = *list; + } + } else if (tag == "name") { + if (const auto text = asText(elementValue(child))) { + mod->name = *text; + } + } else if (tag == "description") { + if (const auto text = asText(elementValue(child))) { + mod->description = *text; + } + } else if (tag == "author") { + if (const auto text = asText(elementValue(child))) { + mod->authors.append(*text); + } + } else if (tag == "authors") { + if (const auto list = asList(elementValue(child))) { + mod->authors.append(*list); + } + } else if (tag == "supportedVersions") { + if (const auto list = asList(elementValue(child))) { + mod->supportedVersions = QSet(list->cbegin(), list->cend()); + } + } else if (tag == "modVersion") { + if (const auto text = asText(elementValue(child))) { + mod->modVersion = *text; + } + } else if (tag == "modIconPath") { + if (const auto text = asText(elementValue(child))) { + mod->modIconPath = *text; + } + } else if (tag == "url") { + if (const auto text = asText(elementValue(child))) { + mod->url = *text; + } + } else if (tag == "modDependencies") { + if (!depsByVersion) { + depsEl = child; + } + } else if (tag == "modDependenciesByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = + matchVersionedChild(child, targetVersion)) { + depsEl = matched; + depsByVersion = true; } - } - if (incompatByVersionEl) { - if (const auto list = asList(elementValue(incompatByVersionEl))) { - incompatLi = *list; + } + } else if (tag == "loadBefore") { + if (const auto list = asList(elementValue(child))) { + loadBeforeLi = *list; + } + } else if (tag == "loadBeforeByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = + matchVersionedChild(child, targetVersion)) { + loadBeforeByVersionEl = matched; } - } - - loadBeforeLi.append(forceBeforeLi); - loadAfterLi.append(forceAfterLi); - - BaseRules rules; - rules.loadBefore = CaseInsensitiveSet(loadBeforeLi); - rules.loadAfter = CaseInsensitiveSet(loadAfterLi); - rules.incompatibleWith = CaseInsensitiveSet(incompatLi); - - if (depsEl) { - for (pugi::xml_node li = depsEl.first_child(); li; li = li.next_sibling()) { - if (li.type() != pugi::node_element || std::string_view(li.name()) != "li") { - continue; - } - if (std::string_view(li.attribute("isNull").value()) == "True") { - continue; - } - if (!li.first_attribute() && !hasChildNodes(li)) { - continue; - } - DependencyMod dep = dependencyFromLi(li); - if (rules.findDependency(dep.packageId)) { - spdlog::warn("Duplicate dependency found: {}. Skipping.", - utf8(dep.packageId.str())); - } else { - rules.dependencies.append(std::move(dep)); - } + } + } else if (tag == "loadAfter") { + if (const auto list = asList(elementValue(child))) { + loadAfterLi = *list; + } + } else if (tag == "loadAfterByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = + matchVersionedChild(child, targetVersion)) { + loadAfterByVersionEl = matched; + } + } + } else if (tag == "forceLoadBefore") { + if (const auto list = asList(elementValue(child))) { + forceBeforeLi = *list; + } + } else if (tag == "forceLoadAfter") { + if (const auto list = asList(elementValue(child))) { + forceAfterLi = *list; + } + } else if (tag == "incompatibleWith") { + if (const auto list = asList(elementValue(child))) { + incompatLi = *list; + } + } else if (tag == "incompatibleWithByVersion") { + if (preferVersioned) { + if (const pugi::xml_node matched = + matchVersionedChild(child, targetVersion)) { + incompatByVersionEl = matched; + } + } + } else if (tag == "descriptionsByVersion") { + if (const pugi::xml_node matched = + matchVersionedChild(child, targetVersion)) { + const QString text = rawText(matched).trimmed(); + if (!text.isEmpty()) { + mod->description = text; } + } } - - const QString packageId = mod->packageId.str(); - mod->name = officialModName(packageId, mod->name); - const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId); - if (dlc && mod->description.isEmpty()) { - mod->description = QString(dlc->description); + } + + if (!hasPackageId) { + setInvalid( + *mod, + QStringLiteral("packageId missing or invalid. Assigned sentinel '%1'.") + .arg(QString(kDefaultMissingPackageid))); + mod->packageId = missingPackageId(); + } + + // Versioned overrides replace the unversioned lists they shadow. + if (loadBeforeByVersionEl) { + if (const auto list = asList(elementValue(loadBeforeByVersionEl))) { + loadBeforeLi = *list; } - // DLC cannot load without the base game, so it always depends on RimWorld. - if (dlc && QLatin1StringView(dlc->appId) != kRimworldSteamAppId) { - const CaseInsensitiveStr rimworldPid(QStringLiteral("ludeon.rimworld")); - if (!rules.hasDependency(rimworldPid)) { - DependencyMod rimworld; - rimworld.name = QStringLiteral("RimWorld"); - rimworld.packageId = rimworldPid; - rimworld.workshopUrl = QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); - rules.dependencies.append(std::move(rimworld)); - } + } + if (loadAfterByVersionEl) { + if (const auto list = asList(elementValue(loadAfterByVersionEl))) { + loadAfterLi = *list; } - - mod->aboutRules = std::move(rules); - return mod; -} - -Result createListedModFromPath(const QString& path, const QString& targetVersion, - bool preferVersioned, - std::optional aboutXmlPath) { - if (!QFileInfo(path).isDir()) { - return std::unexpected(Error{QStringLiteral("Path must be a directory: %1").arg(path)}); + } + if (incompatByVersionEl) { + if (const auto list = asList(elementValue(incompatByVersionEl))) { + incompatLi = *list; } - if (!aboutXmlPath) { - aboutXmlPath = findAboutXml(path); + } + + loadBeforeLi.append(forceBeforeLi); + loadAfterLi.append(forceAfterLi); + + BaseRules rules; + rules.loadBefore = CaseInsensitiveSet(loadBeforeLi); + rules.loadAfter = CaseInsensitiveSet(loadAfterLi); + rules.incompatibleWith = CaseInsensitiveSet(incompatLi); + + if (depsEl) { + for (pugi::xml_node li = depsEl.first_child(); li; li = li.next_sibling()) { + if (li.type() != pugi::node_element || + std::string_view(li.name()) != "li") { + continue; + } + if (std::string_view(li.attribute("isNull").value()) == "True") { + continue; + } + if (!li.first_attribute() && !hasChildNodes(li)) { + continue; + } + DependencyMod dep = dependencyFromLi(li); + if (rules.findDependency(dep.packageId)) { + spdlog::warn("Duplicate dependency found: {}. Skipping.", + utf8(dep.packageId.str())); + } else { + rules.dependencies.append(std::move(dep)); + } } - if (aboutXmlPath) { - auto mod = createAboutModFromXml(path, *aboutXmlPath, targetVersion, preferVersioned); - if (!mod) { - return std::unexpected(mod.error()); - } - return ListedModPtr(std::move(*mod)); + } + + const QString packageId = mod->packageId.str(); + mod->name = officialModName(packageId, mod->name); + const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId); + if (dlc && mod->description.isEmpty()) { + mod->description = QString(dlc->description); + } + // DLC cannot load without the base game, so it always depends on RimWorld. + if (dlc && QLatin1StringView(dlc->appId) != kRimworldSteamAppId) { + const CaseInsensitiveStr rimworldPid(QStringLiteral("ludeon.rimworld")); + if (!rules.hasDependency(rimworldPid)) { + DependencyMod rimworld; + rimworld.name = QStringLiteral("RimWorld"); + rimworld.packageId = rimworldPid; + rimworld.workshopUrl = + QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); + rules.dependencies.append(std::move(rimworld)); } + } - const QStringList rscFiles = QDir(path).entryList( - {QStringLiteral("*.rsc")}, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::CaseSensitive); - if (rscFiles.size() > 1) { - spdlog::warn("Multiple .rsc files found in {}. Aborting.", utf8(path)); - return invalidListedMod(path); - } - if (rscFiles.size() == 1) { - // Scenario .rsc files are not parsed yet. - spdlog::warn("Scenario .rsc files not yet supported: {}", utf8(path)); - return invalidListedMod(path); + mod->aboutRules = std::move(rules); + return mod; +} + +Result createListedModFromPath( + const QString& path, const QString& targetVersion, bool preferVersioned, + std::optional aboutXmlPath) { + if (!QFileInfo(path).isDir()) { + return std::unexpected( + Error{QStringLiteral("Path must be a directory: %1").arg(path)}); + } + if (!aboutXmlPath) { + aboutXmlPath = findAboutXml(path); + } + if (aboutXmlPath) { + auto mod = createAboutModFromXml(path, *aboutXmlPath, targetVersion, + preferVersioned); + if (!mod) { + return std::unexpected(mod.error()); } - spdlog::warn("No About.xml found in directory: {}", utf8(path)); + return ListedModPtr(std::move(*mod)); + } + + const QStringList rscFiles = QDir(path).entryList( + {QStringLiteral("*.rsc")}, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::CaseSensitive); + if (rscFiles.size() > 1) { + spdlog::warn("Multiple .rsc files found in {}. Aborting.", utf8(path)); + return invalidListedMod(path); + } + if (rscFiles.size() == 1) { + // Scenario .rsc files are not parsed yet. + spdlog::warn("Scenario .rsc files not yet supported: {}", utf8(path)); return invalidListedMod(path); + } + spdlog::warn("No About.xml found in directory: {}", utf8(path)); + return invalidListedMod(path); } } // namespace px::modrim diff --git a/src/core/models/metadata/parsing.hpp b/src/core/models/metadata/parsing.hpp index e16ab9e..a24a475 100644 --- a/src/core/models/metadata/parsing.hpp +++ b/src/core/models/metadata/parsing.hpp @@ -1,9 +1,9 @@ #pragma once +#include #include #include -#include #include #include "core/error.hpp" @@ -11,20 +11,23 @@ namespace px::modrim { -// Builds a mod for the directory `path`, parsing its About.xml (found automatically, or the -// explicit `aboutXmlPath`). Returns Err only when `path` is not a directory. A mod whose metadata -// is unusable is still returned, with `valid == false`. +// Builds a mod for the directory `path`, parsing its About.xml (found +// automatically, or the explicit `aboutXmlPath`). Returns Err only when `path` +// is not a directory. A mod whose metadata is unusable is still returned, with +// `valid == false`. // // `targetVersion` selects `*ByVersion` entries: "1.5" matches ``. When // `preferVersioned` is false those entries are ignored. -Result createListedModFromPath(const QString& path, const QString& targetVersion, - bool preferVersioned = true, - std::optional aboutXmlPath = std::nullopt); +Result createListedModFromPath( + const QString& path, const QString& targetVersion, + bool preferVersioned = true, + std::optional aboutXmlPath = std::nullopt); -// Builds an AboutXmlMod from the `` root element of an About.xml. The result's -// `valid` flag reports whether the metadata is usable; no mod path is set. -std::shared_ptr createAboutModFromElement(pugi::xml_node root, - const QString& targetVersion, - bool preferVersioned = true); +// Builds an AboutXmlMod from the `` root element of an About.xml. +// The result's `valid` flag reports whether the metadata is usable; no mod path +// is set. +std::shared_ptr createAboutModFromElement( + pugi::xml_node root, const QString& targetVersion, + bool preferVersioned = true); } // namespace px::modrim diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp index db8d950..fa21ee1 100644 --- a/src/core/models/metadata/structures.cpp +++ b/src/core/models/metadata/structures.cpp @@ -12,210 +12,232 @@ namespace px::modrim { namespace { -// Python's Path.stem / Path.suffix rule: a leading dot does not start an extension. +// Python's Path.stem / Path.suffix rule: a leading dot does not start an +// extension. qsizetype lastExtensionDot(const QString& name) { - const qsizetype dot = name.lastIndexOf(QLatin1Char('.')); - return (dot > 0 && dot < name.size() - 1) ? dot : -1; + const qsizetype dot = name.lastIndexOf(QLatin1Char('.')); + return (dot > 0 && dot < name.size() - 1) ? dot : -1; } QString pathStem(const QString& name) { - const qsizetype dot = lastExtensionDot(name); - return dot < 0 ? name : name.left(dot); + const qsizetype dot = lastExtensionDot(name); + return dot < 0 ? name : name.left(dot); } QString pathSuffix(const QString& name) { - const qsizetype dot = lastExtensionDot(name); - return dot < 0 ? QString() : name.mid(dot); + const qsizetype dot = lastExtensionDot(name); + return dot < 0 ? QString() : name.mid(dot); } bool hasEntries(const QString& dirPath) { - return !QDir(dirPath) - .entryList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot) - .isEmpty(); + return !QDir(dirPath) + .entryList(QDir::AllEntries | QDir::Hidden | QDir::System | + QDir::NoDotAndDotDot) + .isEmpty(); } } // namespace CaseInsensitiveSet::CaseInsensitiveSet(const QStringList& values) { - for (const QString& value : values) { - m_data.insert(CaseInsensitiveStr(value)); - } + for (const QString& value : values) { + m_data.insert(CaseInsensitiveStr(value)); + } } -CaseInsensitiveSet::CaseInsensitiveSet(const QList& values) { - for (const CaseInsensitiveStr& value : values) { - m_data.insert(value); - } +CaseInsensitiveSet::CaseInsensitiveSet( + const QList& values) { + for (const CaseInsensitiveStr& value : values) { + m_data.insert(value); + } } bool CaseInsensitiveSet::contains(QStringView value) const { - return m_data.contains(CaseInsensitiveStr(value)); + return m_data.contains(CaseInsensitiveStr(value)); } bool CaseInsensitiveSet::contains(const CaseInsensitiveStr& value) const { - return m_data.contains(value); + return m_data.contains(value); } -void CaseInsensitiveSet::add(QStringView value) { m_data.insert(CaseInsensitiveStr(value)); } +void CaseInsensitiveSet::add(QStringView value) { + m_data.insert(CaseInsensitiveStr(value)); +} -void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { m_data.insert(value); } +void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { + m_data.insert(value); +} -void CaseInsensitiveSet::discard(QStringView value) { m_data.remove(CaseInsensitiveStr(value)); } +void CaseInsensitiveSet::discard(QStringView value) { + m_data.remove(CaseInsensitiveStr(value)); +} -void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { m_data.remove(value); } +void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { + m_data.remove(value); +} -CaseInsensitiveSet CaseInsensitiveSet::operator|(const CaseInsensitiveSet& other) const { - CaseInsensitiveSet result = *this; - result.m_data.unite(other.m_data); - return result; +CaseInsensitiveSet CaseInsensitiveSet::operator|( + const CaseInsensitiveSet& other) const { + CaseInsensitiveSet result = *this; + result.m_data.unite(other.m_data); + return result; } bool ModsConfig::checkActiveDuplicates() const { - const QSet unique(activeMods.cbegin(), activeMods.cend()); - return activeMods.size() != unique.size(); + const QSet unique(activeMods.cbegin(), activeMods.cend()); + return activeMods.size() != unique.size(); } bool ModsConfig::checkExpansionsDuplicates() const { - const QSet unique(knownExpansions.cbegin(), knownExpansions.cend()); - return knownExpansions.size() != unique.size(); + const QSet unique(knownExpansions.cbegin(), + knownExpansions.cend()); + return knownExpansions.size() != unique.size(); } -const DependencyMod* BaseRules::findDependency(const CaseInsensitiveStr& packageId) const { - for (const DependencyMod& dependency : dependencies) { - if (dependency.packageId == packageId) { - return &dependency; - } +const DependencyMod* BaseRules::findDependency( + const CaseInsensitiveStr& packageId) const { + for (const DependencyMod& dependency : dependencies) { + if (dependency.packageId == packageId) { + return &dependency; } - return nullptr; + } + return nullptr; } void BaseRules::setDependency(DependencyMod dependency) { - for (DependencyMod& existing : dependencies) { - if (existing.packageId == dependency.packageId) { - existing = std::move(dependency); - return; - } + for (DependencyMod& existing : dependencies) { + if (existing.packageId == dependency.packageId) { + existing = std::move(dependency); + return; } - dependencies.append(std::move(dependency)); + } + dependencies.append(std::move(dependency)); } -QString ListedMod::newUuid() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } +QString ListedMod::newUuid() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} QString ListedMod::uuid() const { - if (m_modPath) { - return *m_modPath; - } - return m_uuid; + if (m_modPath) { + return *m_modPath; + } + return m_uuid; } Result ListedMod::setModPath(const QString& path) { - if (m_modPath) { - return std::unexpected(Error{QStringLiteral("Mod path already set. Cannot override.")}); - } - m_modPath = path; - m_publishedFileIdCache.reset(); - return {}; + if (m_modPath) { + return std::unexpected( + Error{QStringLiteral("Mod path already set. Cannot override.")}); + } + m_modPath = path; + m_publishedFileIdCache.reset(); + return {}; } std::optional ListedMod::modFolder() const { - if (!m_modPath) { - return std::nullopt; - } - return pathStem(QFileInfo(QDir::cleanPath(*m_modPath)).fileName()); + if (!m_modPath) { + return std::nullopt; + } + return pathStem(QFileInfo(QDir::cleanPath(*m_modPath)).fileName()); } qint64 ListedMod::internalTimeTouched() const { - if (!m_modPath || !QFileInfo::exists(*m_modPath)) { - return -1; - } - return QFileInfo(*m_modPath).lastModified().toSecsSinceEpoch(); + if (!m_modPath || !QFileInfo::exists(*m_modPath)) { + return -1; + } + return QFileInfo(*m_modPath).lastModified().toSecsSinceEpoch(); } std::optional ListedMod::publishedFileId() const { - if (!m_publishedFileIdCache) { - m_publishedFileIdCache = [this]() -> std::optional { - if (!m_modPath) { - return std::nullopt; - } - const QString pfidPath = - QDir(*m_modPath).filePath(QStringLiteral("About/PublishedFileId.txt")); - if (QFileInfo::exists(pfidPath)) { - QFile file(pfidPath); - if (!file.open(QIODevice::ReadOnly)) { - return std::nullopt; - } - // utf-8-sig: drop a leading byte-order mark before trimming. - QString content = QString::fromUtf8(file.readAll()); - if (content.startsWith(QChar(0xFEFF))) { - content.remove(0, 1); - } - content = content.trimmed(); - if (isAsciiDigits(content)) { - return content; - } - } - const std::optional folder = modFolder(); - if (folder && isAsciiDigits(*folder)) { - return folder; - } - return std::nullopt; - }(); - } - return *m_publishedFileIdCache; + if (!m_publishedFileIdCache) { + m_publishedFileIdCache = [this]() -> std::optional { + if (!m_modPath) { + return std::nullopt; + } + const QString pfidPath = + QDir(*m_modPath) + .filePath(QStringLiteral("About/PublishedFileId.txt")); + if (QFileInfo::exists(pfidPath)) { + QFile file(pfidPath); + if (!file.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + // utf-8-sig: drop a leading byte-order mark before trimming. + QString content = QString::fromUtf8(file.readAll()); + if (content.startsWith(QChar(0xFEFF))) { + content.remove(0, 1); + } + content = content.trimmed(); + if (isAsciiDigits(content)) { + return content; + } + } + const std::optional folder = modFolder(); + if (folder && isAsciiDigits(*folder)) { + return folder; + } + return std::nullopt; + }(); + } + return *m_publishedFileIdCache; } bool ListedMod::cSharpMod() const { - if (!m_cSharpModCache) { - m_cSharpModCache = [this] { - if (!m_modPath) { - return false; - } - const QString assemblies = QDir(*m_modPath).filePath(QStringLiteral("Assemblies")); - return QFileInfo(assemblies).isDir() && hasEntries(assemblies); - }(); - } - return *m_cSharpModCache; + if (!m_cSharpModCache) { + m_cSharpModCache = [this] { + if (!m_modPath) { + return false; + } + const QString assemblies = + QDir(*m_modPath).filePath(QStringLiteral("Assemblies")); + return QFileInfo(assemblies).isDir() && hasEntries(assemblies); + }(); + } + return *m_cSharpModCache; } bool ListedMod::xmlPatchMod() const { - if (!m_xmlPatchModCache) { - m_xmlPatchModCache = [this] { - if (!m_modPath) { - return false; - } - const QString patches = QDir(*m_modPath).filePath(QStringLiteral("Patches")); - if (!QFileInfo(patches).isDir()) { - return false; - } - const QFileInfoList entries = QDir(patches).entryInfoList( - QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); - for (const QFileInfo& entry : entries) { - if (pathSuffix(entry.fileName()) == QStringLiteral(".xml")) { - return true; - } - } - return false; - }(); - } - return *m_xmlPatchModCache; + if (!m_xmlPatchModCache) { + m_xmlPatchModCache = [this] { + if (!m_modPath) { + return false; + } + const QString patches = + QDir(*m_modPath).filePath(QStringLiteral("Patches")); + if (!QFileInfo(patches).isDir()) { + return false; + } + const QFileInfoList entries = + QDir(patches).entryInfoList(QDir::AllEntries | QDir::Hidden | + QDir::System | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (pathSuffix(entry.fileName()) == QStringLiteral(".xml")) { + return true; + } + } + return false; + }(); + } + return *m_xmlPatchModCache; } std::optional ListedMod::previewImgPath() const { - if (!m_modPath) { - return std::nullopt; - } - const QString candidate = QDir(*m_modPath).filePath(QStringLiteral("About/Preview.png")); - if (QFileInfo::exists(candidate)) { - return candidate; - } + if (!m_modPath) { return std::nullopt; + } + const QString candidate = + QDir(*m_modPath).filePath(QStringLiteral("About/Preview.png")); + if (QFileInfo::exists(candidate)) { + return candidate; + } + return std::nullopt; } std::optional AboutXmlMod::getDlcName() const { - if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId.str())) { - return QString(dlc->name); - } - return std::nullopt; + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId.str())) { + return QString(dlc->name); + } + return std::nullopt; } } // namespace px::modrim diff --git a/src/core/models/metadata/structures.hpp b/src/core/models/metadata/structures.hpp index 998789a..1d99b65 100644 --- a/src/core/models/metadata/structures.hpp +++ b/src/core/models/metadata/structures.hpp @@ -1,5 +1,11 @@ #pragma once +#include +#include +#include +#include +#include +#include #include #include #include @@ -8,278 +14,296 @@ #include #include -#include -#include -#include -#include -#include -#include #include #include "core/error.hpp" namespace px::modrim { -// Package id that compares and hashes without regard to case. Stored lowercased. +// Package id that compares and hashes without regard to case. Stored +// lowercased. class CaseInsensitiveStr { -public: - CaseInsensitiveStr() = default; - explicit CaseInsensitiveStr(QStringView pid) : m_value(pid.toString().toLower()) {} + public: + CaseInsensitiveStr() = default; + explicit CaseInsensitiveStr(QStringView pid) + : m_value(pid.toString().toLower()) {} - const QString& str() const noexcept { return m_value; } + const QString& str() const noexcept { return m_value; } - friend bool operator==(const CaseInsensitiveStr&, const CaseInsensitiveStr&) = default; - friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { - return qHash(key.m_value, seed); - } + friend bool operator==(const CaseInsensitiveStr&, + const CaseInsensitiveStr&) = default; + friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { + return qHash(key.m_value, seed); + } -private: - QString m_value; + private: + QString m_value; }; // Set of CaseInsensitiveStr with string-friendly membership tests. class CaseInsensitiveSet { -public: - CaseInsensitiveSet() = default; - explicit CaseInsensitiveSet(const QStringList& values); - explicit CaseInsensitiveSet(const QList& values); + public: + CaseInsensitiveSet() = default; + explicit CaseInsensitiveSet(const QStringList& values); + explicit CaseInsensitiveSet(const QList& values); - bool contains(QStringView value) const; - bool contains(const CaseInsensitiveStr& value) const; - void add(QStringView value); - void add(const CaseInsensitiveStr& value); - void discard(QStringView value); - void discard(const CaseInsensitiveStr& value); + bool contains(QStringView value) const; + bool contains(const CaseInsensitiveStr& value) const; + void add(QStringView value); + void add(const CaseInsensitiveStr& value); + void discard(QStringView value); + void discard(const CaseInsensitiveStr& value); - qsizetype size() const noexcept { return m_data.size(); } - bool empty() const noexcept { return m_data.isEmpty(); } + qsizetype size() const noexcept { return m_data.size(); } + bool empty() const noexcept { return m_data.isEmpty(); } - // Set union; the result keeps the elements of both operands. - CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; + // Set union; the result keeps the elements of both operands. + CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; - auto begin() const { return m_data.cbegin(); } - auto end() const { return m_data.cend(); } + auto begin() const { return m_data.cbegin(); } + auto end() const { return m_data.cend(); } - friend bool operator==(const CaseInsensitiveSet&, const CaseInsensitiveSet&) = default; + friend bool operator==(const CaseInsensitiveSet&, + const CaseInsensitiveSet&) = default; -private: - QSet m_data; + private: + QSet m_data; }; // Parsed ModsConfig.xml data with active mods and known expansions. struct ModsConfig { - QString version; - QList activeMods; - QList knownExpansions; - - bool checkActiveDuplicates() const; - bool checkExpansionsDuplicates() const; - void clearActiveMods() { activeMods.clear(); } - void clearAll() { - activeMods.clear(); - knownExpansions.clear(); - } + QString version; + QList activeMods; + QList knownExpansions; + + bool checkActiveDuplicates() const; + bool checkExpansionsDuplicates() const; + void clearActiveMods() { activeMods.clear(); } + void clearAll() { + activeMods.clear(); + knownExpansions.clear(); + } }; struct ReplacementInfo { - QString name; - QString author; - QString packageid; - QString pfid; - QStringList supportedversions; - QString source = QStringLiteral("database"); + QString name; + QString author; + QString packageid; + QString pfid; + QStringList supportedversions; + QString source = QStringLiteral("database"); }; -// Dependency declared by a mod's About.xml (or synthesized, e.g. the RimWorld base game for DLC). +// Dependency declared by a mod's About.xml (or synthesized, e.g. the RimWorld +// base game for DLC). struct DependencyMod { - QString name; - CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; - QString workshopUrl; - CaseInsensitiveSet alternativePackageIds; + QString name; + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; + QString workshopUrl; + CaseInsensitiveSet alternativePackageIds; }; // Load-order rules and dependencies. Dependencies keep declaration order. struct BaseRules { - CaseInsensitiveSet loadAfter; - CaseInsensitiveSet loadBefore; - CaseInsensitiveSet incompatibleWith; - QList dependencies; - - const DependencyMod* findDependency(const CaseInsensitiveStr& packageId) const; - bool hasDependency(const CaseInsensitiveStr& packageId) const { - return findDependency(packageId) != nullptr; - } - // Inserts, or replaces the dependency with the same package id in place. - void setDependency(DependencyMod dependency); + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + CaseInsensitiveSet incompatibleWith; + QList dependencies; + + const DependencyMod* findDependency( + const CaseInsensitiveStr& packageId) const; + bool hasDependency(const CaseInsensitiveStr& packageId) const { + return findDependency(packageId) != nullptr; + } + // Inserts, or replaces the dependency with the same package id in place. + void setDependency(DependencyMod dependency); }; -// Mod discovered by any provider. Polymorphic so providers can hand out AboutXmlMod (and -// ScenarioMod) through a shared base pointer. +// Mod discovered by any provider. Polymorphic so providers can hand out +// AboutXmlMod (and ScenarioMod) through a shared base pointer. class ListedMod { -public: - ListedMod() = default; - ListedMod(const ListedMod&) = default; - ListedMod& operator=(const ListedMod&) = default; - virtual ~ListedMod() = default; - - QString name = QStringLiteral("Unknown Mod Name"); - bool valid = true; - QSet supportedVersions; - QString description = QStringLiteral( - "This mod is considered invalid by PxModRim (and the RimWorld game).\n\n" - "This mod does NOT contain an ./About/About.xml and is likely leftover from previous " - "usage."); - QString providerId; - bool obsolete = false; - bool dbBuilderNoName = false; - - // Identity: the mod path once it is known, otherwise a random id assigned at construction. - virtual QString uuid() const; - - const std::optional& modPath() const noexcept { return m_modPath; } - // Fails if a path was already set; a mod's directory is fixed after discovery. - Result setModPath(const QString& path); - - // Folder name without its last extension (Python's Path.stem); nullopt without a path. - std::optional modFolder() const; - // Modification time of the mod folder in seconds since the epoch, or -1 if unavailable. - qint64 internalTimeTouched() const; - - // Published file id from About/PublishedFileId.txt or a numeric folder name. Cached on first - // use; this touches the disk, so callers may warm it off the UI thread. - std::optional publishedFileId() const; - bool cSharpMod() const; - bool xmlPatchMod() const; - std::optional previewImgPath() const; - -private: - static QString newUuid(); - - QString m_uuid = newUuid(); - std::optional m_modPath; - mutable std::optional> m_publishedFileIdCache; - mutable std::optional m_cSharpModCache; - mutable std::optional m_xmlPatchModCache; + public: + ListedMod() = default; + ListedMod(const ListedMod&) = default; + ListedMod& operator=(const ListedMod&) = default; + virtual ~ListedMod() = default; + + QString name = QStringLiteral("Unknown Mod Name"); + bool valid = true; + QSet supportedVersions; + QString description = QStringLiteral( + "This mod is considered invalid by PxModRim (and the RimWorld game).\n\n" + "This mod does NOT contain an ./About/About.xml and is likely leftover " + "from previous " + "usage."); + QString providerId; + bool obsolete = false; + bool dbBuilderNoName = false; + + // Identity: the mod path once it is known, otherwise a random id assigned at + // construction. + virtual QString uuid() const; + + const std::optional& modPath() const noexcept { return m_modPath; } + // Fails if a path was already set; a mod's directory is fixed after + // discovery. + Result setModPath(const QString& path); + + // Folder name without its last extension (Python's Path.stem); nullopt + // without a path. + std::optional modFolder() const; + // Modification time of the mod folder in seconds since the epoch, or -1 if + // unavailable. + qint64 internalTimeTouched() const; + + // Published file id from About/PublishedFileId.txt or a numeric folder name. + // Cached on first use; this touches the disk, so callers may warm it off the + // UI thread. + std::optional publishedFileId() const; + bool cSharpMod() const; + bool xmlPatchMod() const; + std::optional previewImgPath() const; + + private: + static QString newUuid(); + + QString m_uuid = newUuid(); + std::optional m_modPath; + mutable std::optional> m_publishedFileIdCache; + mutable std::optional m_cSharpModCache; + mutable std::optional m_xmlPatchModCache; }; class ScenarioMod : public ListedMod { -public: - QString summary; + public: + QString summary; }; // A mod with an About.xml: authors, version, rules, and DLC metadata. class AboutXmlMod : public ListedMod { -public: - CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; - QStringList authors; - QString modVersion; - std::optional modIconPath; - qint64 steamAppId = -1; - QString url; - double mtime = 0.0; - BaseRules aboutRules; - - // Display name of the RimWorld DLC this package id belongs to, if any. - std::optional getDlcName() const; + public: + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; + QStringList authors; + QString modVersion; + std::optional modIconPath; + qint64 steamAppId = -1; + QString url; + double mtime = 0.0; + BaseRules aboutRules; + + // Display name of the RimWorld DLC this package id belongs to, if any. + std::optional getDlcName() const; }; using ListedModPtr = std::shared_ptr; // Pre-computed dependency graphs and tier classification for sorting. struct CompiledDependencyData { - QHash> depsGraph; - QHash> revDepsGraph; - QSet tierZeroMods; - QSet tierOneMods; - QSet tierThreeMods; - QHash> incompatibilities; - QHash> declaredIncompatibilities; + QHash> depsGraph; + QHash> revDepsGraph; + QSet tierZeroMods; + QSet tierOneMods; + QSet tierThreeMods; + QHash> incompatibilities; + QHash> declaredIncompatibilities; }; -// Glaze schemas for community rules and the Steam Workshop database. Fields the upstream JSON -// may write either as a single value or as a list use std::variant. +// Glaze schemas for community rules and the Steam Workshop database. Fields the +// upstream JSON may write either as a single value or as a list use +// std::variant. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SubExternalRule { - std::variant, std::string> name = std::string{}; - std::variant, std::string> comment = std::string{}; + std::variant, std::string> name = std::string{}; + std::variant, std::string> comment = std::string{}; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SubExternalBoolRule { - bool value = false; - std::variant, std::string> comment = std::string{}; + bool value = false; + std::variant, std::string> comment = std::string{}; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct ExternalRule { - std::map loadAfter; - std::map loadBefore; - SubExternalBoolRule loadTop; - SubExternalBoolRule loadBottom; + std::map loadAfter; + std::map loadBefore; + SubExternalBoolRule loadTop; + SubExternalBoolRule loadBottom; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct ExternalRulesSchema { - std::int64_t timestamp = 0; - std::map rules; + std::int64_t timestamp = 0; + std::map rules; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SteamDbEntryDependency { - std::string name; - std::string url; + std::string name; + std::string url; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SteamDbEntryBlacklist { - bool value = false; - std::string comment; + bool value = false; + std::string comment; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SteamDbEntry { - bool unpublished = false; - std::string url; - std::string packageId; - std::variant>, std::string, std::nullptr_t> - gameVersions = std::vector>{}; - std::string steamName; - std::string name; - std::variant, std::string, std::nullptr_t> authors = std::string{}; - std::map, SteamDbEntryDependency>> - dependencies; - SteamDbEntryBlacklist blacklist; - std::vector> tags; + bool unpublished = false; + std::string url; + std::string packageId; + std::variant>, std::string, + std::nullptr_t> + gameVersions = std::vector>{}; + std::string steamName; + std::string name; + std::variant, std::string, std::nullptr_t> authors = + std::string{}; + std::map, SteamDbEntryDependency>> + dependencies; + SteamDbEntryBlacklist blacklist; + std::vector> tags; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SteamDbSchema { - std::int64_t version = 0; - std::map database; + std::int64_t version = 0; + std::map database; }; // NOLINTEND(readability-identifier-naming) enum class WorkshopUpdateStatus { - kSuccess, - kNoWorkshopMods, - kPartial, - kFailed, + kSuccess, + kNoWorkshopMods, + kPartial, + kFailed, }; struct WorkshopUpdateResult { - WorkshopUpdateStatus status = WorkshopUpdateStatus::kFailed; - int modsChecked = 0; - int modsUpdated = 0; - QStringList failedPfids; - QStringList errors; + WorkshopUpdateStatus status = WorkshopUpdateStatus::kFailed; + int modsChecked = 0; + int modsUpdated = 0; + QStringList failedPfids; + QStringList errors; }; } // namespace px::modrim @@ -287,18 +311,21 @@ struct WorkshopUpdateResult { // Package ids are JSON strings on the wire. template <> struct glz::to { - template - static void op(const px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& b, auto&& ix) { - glz::to::template op(value.str().toStdString(), ctx, b, ix); - } + template + static void op(const px::modrim::CaseInsensitiveStr& value, auto&& ctx, + auto&& b, auto&& ix) { + glz::to::template op( + value.str().toStdString(), ctx, b, ix); + } }; template <> struct glz::from { - template - static void op(px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& it, auto&& end) { - std::string text; - glz::from::template op(text, ctx, it, end); - value = px::modrim::CaseInsensitiveStr(QString::fromStdString(text)); - } + template + static void op(px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& it, + auto&& end) { + std::string text; + glz::from::template op(text, ctx, it, end); + value = px::modrim::CaseInsensitiveStr(QString::fromStdString(text)); + } }; diff --git a/src/core/models/view/diagnostics.hpp b/src/core/models/view/diagnostics.hpp index 2106a75..1e495b5 100644 --- a/src/core/models/view/diagnostics.hpp +++ b/src/core/models/view/diagnostics.hpp @@ -1,28 +1,27 @@ #pragma once -#include - #include +#include namespace px::modrim::view { // UI-agnostic diagnostics summary for one mod. struct ModDiagnosticsView { - bool hasErrors = false; - bool hasWarnings = false; - QString errorTooltip; - QString warningTooltip; + bool hasErrors = false; + bool hasWarnings = false; + QString errorTooltip; + QString warningTooltip; - bool operator==(const ModDiagnosticsView&) const = default; + bool operator==(const ModDiagnosticsView&) const = default; }; struct ModIssueView { - QString category; - QString categoryDisplayName; - std::optional detail; - bool isError = false; + QString category; + QString categoryDisplayName; + std::optional detail; + bool isError = false; - bool operator==(const ModIssueView&) const = default; + bool operator==(const ModIssueView&) const = default; }; } // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp index 2910500..29c56a1 100644 --- a/src/core/models/view/sidebar.cpp +++ b/src/core/models/view/sidebar.cpp @@ -5,13 +5,13 @@ namespace px::modrim::view { QString providerLabel(const QString& providerId) { - static const QHash labels{ - {QStringLiteral("local"), QStringLiteral("Local")}, - {QStringLiteral("steam"), QStringLiteral("Steam Workshop")}, - {QStringLiteral("downloaded"), QStringLiteral("Steam Workshop")}, - {QStringLiteral("core"), QStringLiteral("System / Core")}, - }; - return labels.value(providerId, providerId); + static const QHash labels{ + {QStringLiteral("local"), QStringLiteral("Local")}, + {QStringLiteral("steam"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("downloaded"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("core"), QStringLiteral("System / Core")}, + }; + return labels.value(providerId, providerId); } } // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.hpp b/src/core/models/view/sidebar.hpp index 45bebe5..6874a94 100644 --- a/src/core/models/view/sidebar.hpp +++ b/src/core/models/view/sidebar.hpp @@ -1,26 +1,34 @@ #pragma once -#include - #include #include +#include namespace px::modrim::view { // Display name of a mod provider; an unknown provider is shown by its id. QString providerLabel(const QString& providerId); -// A sidebar filter and the mods it shows. `count` is derived from `visible_uuids`. +// A sidebar filter and the mods it shows. `count` is derived from +// `visible_uuids`. struct SidebarEntry { - enum class Kind : std::uint8_t { kAll, kActive, kInactive, kWithErrors, kWithWarnings, kProvider }; - - Kind kind = Kind::kAll; - QString label; - QSet visibleUuids; - // Only for Kind::Provider: two providers can share a label, so the id tells them apart. - QString providerId; - - int count() const noexcept { return static_cast(visibleUuids.size()); } + enum class Kind : std::uint8_t { + kAll, + kActive, + kInactive, + kWithErrors, + kWithWarnings, + kProvider + }; + + Kind kind = Kind::kAll; + QString label; + QSet visibleUuids; + // Only for Kind::Provider: two providers can share a label, so the id tells + // them apart. + QString providerId; + + int count() const noexcept { return static_cast(visibleUuids.size()); } }; } // namespace px::modrim::view diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp index 7908a03..58c9605 100644 --- a/src/core/mods_config.cpp +++ b/src/core/mods_config.cpp @@ -1,5 +1,8 @@ #include "core/mods_config.hpp" +#include +#include +#include #include #include #include @@ -12,9 +15,6 @@ #include #include -#include -#include -#include #include #include @@ -28,264 +28,296 @@ namespace { constexpr auto kRootTag = "ModsConfigData"; constexpr auto kDefaultVersion = "1.5"; -constexpr unsigned kParseFlags = pugi::parse_default | pugi::parse_comments | pugi::parse_pi; +constexpr unsigned kParseFlags = + pugi::parse_default | pugi::parse_comments | pugi::parse_pi; -std::filesystem::path toFs(const QString& path) { return std::filesystem::path(path.toStdU16String()); } +std::filesystem::path toFs(const QString& path) { + return std::filesystem::path(path.toStdU16String()); +} -QString fromFs(const std::filesystem::path& path) { return QString::fromStdU16String(path.u16string()); } +QString fromFs(const std::filesystem::path& path) { + return QString::fromStdU16String(path.u16string()); +} -// Packages listed in RIMWORLD_DLC_METADATA; used when a file declares no known expansions. +// Packages listed in RIMWORLD_DLC_METADATA; used when a file declares no known +// expansions. QList dlcPackageIds() { - QList ids; - for (const auto& id : kRimworldPackageIds) { - ids.append(CaseInsensitiveStr(id)); - } - return ids; + QList ids; + for (const auto& id : kRimworldPackageIds) { + ids.append(CaseInsensitiveStr(id)); + } + return ids; } -std::optional parseModsConfigBytes(std::string_view data, const QString& source) { - pugi::xml_document doc; - if (const auto result = doc.load_buffer(data.data(), data.size(), kParseFlags); !result) { - spdlog::error("Failed to parse ModsConfig.xml at {}: {}", source.toStdString(), result.description()); - return std::nullopt; +std::optional parseModsConfigBytes(std::string_view data, + const QString& source) { + pugi::xml_document doc; + if (const auto result = + doc.load_buffer(data.data(), data.size(), kParseFlags); + !result) { + spdlog::error("Failed to parse ModsConfig.xml at {}: {}", + source.toStdString(), result.description()); + return std::nullopt; + } + + const pugi::xml_node root = doc.document_element(); + const pugi::xml_node versionNode = root.child("version"); + const pugi::xml_node activeModsNode = root.child("activeMods"); + if (std::string_view(root.name()) != kRootTag || !versionNode || + !activeModsNode) { + spdlog::error("Invalid ModsConfig.xml structure at {}", + source.toStdString()); + return std::nullopt; + } + + // lxml's `text or "1.5"`: only a missing or empty text falls back to the + // default. + const QString rawVersion = rawText(versionNode); + ModsConfig config; + config.version = rawVersion.isEmpty() ? QString::fromLatin1(kDefaultVersion) + : rawVersion.trimmed(); + + for (const pugi::xml_node li : activeModsNode.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.activeMods.append(CaseInsensitiveStr(text)); } - - const pugi::xml_node root = doc.document_element(); - const pugi::xml_node versionNode = root.child("version"); - const pugi::xml_node activeModsNode = root.child("activeMods"); - if (std::string_view(root.name()) != kRootTag || !versionNode || !activeModsNode) { - spdlog::error("Invalid ModsConfig.xml structure at {}", source.toStdString()); - return std::nullopt; + } + for (const pugi::xml_node expansions : root.children("knownExpansions")) { + for (const pugi::xml_node li : expansions.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.knownExpansions.append(CaseInsensitiveStr(text)); + } } - - // lxml's `text or "1.5"`: only a missing or empty text falls back to the default. - const QString rawVersion = rawText(versionNode); - ModsConfig config; - config.version = rawVersion.isEmpty() ? QString::fromLatin1(kDefaultVersion) : rawVersion.trimmed(); - - for (const pugi::xml_node li : activeModsNode.children("li")) { - if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { - config.activeMods.append(CaseInsensitiveStr(text)); - } - } - for (const pugi::xml_node expansions : root.children("knownExpansions")) { - for (const pugi::xml_node li : expansions.children("li")) { - if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { - config.knownExpansions.append(CaseInsensitiveStr(text)); - } - } - } - if (config.knownExpansions.isEmpty()) { - config.knownExpansions = dlcPackageIds(); - } - return config; + } + if (config.knownExpansions.isEmpty()) { + config.knownExpansions = dlcPackageIds(); + } + return config; } struct SnapshotFile { - QString path; - std::int64_t mtimeNs = 0; - QString name; + QString path; + std::int64_t mtimeNs = 0; + QString name; }; // Snapshots ordered by (mtime, name), newest first. Nanosecond mtimes match the // legacy ordering; names break ties because they embed the creation time. QStringList snapshotFiles(const QString& snapshotsDir) { - std::vector found; - std::error_code ec; - std::filesystem::directory_iterator it(toFs(snapshotsDir), ec); - const std::filesystem::directory_iterator end; - for (; !ec && it != end; it.increment(ec)) { - const auto& entry = *it; - const QString name = fromFs(entry.path().filename()); - std::error_code entryEc; - if (!QDir::match(QStringLiteral("ModsConfig_*.xml"), name) || - !std::filesystem::is_regular_file(entry.path(), entryEc)) { - continue; - } - const auto mtime = std::filesystem::last_write_time(entry.path(), entryEc); - if (entryEc) { - continue; - } - found.push_back(SnapshotFile{ - .path = fromFs(entry.path()), - .mtimeNs = std::chrono::duration_cast(mtime.time_since_epoch()).count(), - .name = name, - }); + std::vector found; + std::error_code ec; + std::filesystem::directory_iterator it(toFs(snapshotsDir), ec); + const std::filesystem::directory_iterator end; + for (; !ec && it != end; it.increment(ec)) { + const auto& entry = *it; + const QString name = fromFs(entry.path().filename()); + std::error_code entryEc; + if (!QDir::match(QStringLiteral("ModsConfig_*.xml"), name) || + !std::filesystem::is_regular_file(entry.path(), entryEc)) { + continue; } - - std::ranges::sort(found, [](const SnapshotFile& a, const SnapshotFile& b) { - return std::tie(a.mtimeNs, a.name) > std::tie(b.mtimeNs, b.name); - }); - QStringList paths; - for (const auto& snapshot : found) { - paths.append(snapshot.path); + const auto mtime = std::filesystem::last_write_time(entry.path(), entryEc); + if (entryEc) { + continue; } - return paths; + found.push_back(SnapshotFile{ + .path = fromFs(entry.path()), + .mtimeNs = std::chrono::duration_cast( + mtime.time_since_epoch()) + .count(), + .name = name, + }); + } + + std::ranges::sort(found, [](const SnapshotFile& a, const SnapshotFile& b) { + return std::tie(a.mtimeNs, a.name) > std::tie(b.mtimeNs, b.name); + }); + QStringList paths; + for (const auto& snapshot : found) { + paths.append(snapshot.path); + } + return paths; } -// Removes the oldest snapshots beyond `max_snapshots` (at least one is always kept), -// never removing `preserved`. -void pruneSnapshots(const QString& snapshotsDir, int maxSnapshots, const QString& preserved = {}) { - const QStringList snapshots = getSnapshots(snapshotsDir); - qsizetype remaining = snapshots.size() - std::max(1, maxSnapshots); - for (auto it = snapshots.crbegin(); it != snapshots.crend() && remaining > 0; ++it) { - if (!preserved.isEmpty() && QDir::cleanPath(*it) == QDir::cleanPath(preserved)) { - continue; - } - --remaining; - if (!QFile::remove(*it)) { - spdlog::warn("Failed to remove old snapshot {}", it->toStdString()); - } +// Removes the oldest snapshots beyond `max_snapshots` (at least one is always +// kept), never removing `preserved`. +void pruneSnapshots(const QString& snapshotsDir, int maxSnapshots, + const QString& preserved = {}) { + const QStringList snapshots = getSnapshots(snapshotsDir); + qsizetype remaining = snapshots.size() - std::max(1, maxSnapshots); + for (auto it = snapshots.crbegin(); it != snapshots.crend() && remaining > 0; + ++it) { + if (!preserved.isEmpty() && + QDir::cleanPath(*it) == QDir::cleanPath(preserved)) { + continue; } + --remaining; + if (!QFile::remove(*it)) { + spdlog::warn("Failed to remove old snapshot {}", it->toStdString()); + } + } } // UTC timestamp with microseconds, e.g. 20260922_100000_123456. std::string snapshotTimestamp() { - using namespace std::chrono; - const auto micros = duration_cast(system_clock::now().time_since_epoch()); - const auto seconds = floor(micros); - const auto fraction = (micros - seconds).count(); - return std::format("{:%Y%m%d_%H%M%S}_{:06}", sys_seconds{seconds}, fraction); + using namespace std::chrono; + const auto micros = + duration_cast(system_clock::now().time_since_epoch()); + const auto seconds = floor(micros); + const auto fraction = (micros - seconds).count(); + return std::format("{:%Y%m%d_%H%M%S}_{:06}", sys_seconds{seconds}, fraction); } // Replaces the children named `name` with one `li` per item. -void replaceListItems(pugi::xml_node root, const char* name, const QList& items) { - pugi::xml_node node = root.child(name); - if (!node) { - node = root.append_child(name); - } - for (pugi::xml_node li = node.child("li"); li;) { - const pugi::xml_node next = li.next_sibling("li"); - node.remove_child(li); - li = next; - } - for (const auto& item : items) { - node.append_child("li").text().set(item.str().toUtf8().constData()); - } +void replaceListItems(pugi::xml_node root, const char* name, + const QList& items) { + pugi::xml_node node = root.child(name); + if (!node) { + node = root.append_child(name); + } + for (pugi::xml_node li = node.child("li"); li;) { + const pugi::xml_node next = li.next_sibling("li"); + node.remove_child(li); + li = next; + } + for (const auto& item : items) { + node.append_child("li").text().set(item.str().toUtf8().constData()); + } } } // namespace std::optional parseModsConfig(const QString& path) { - auto data = readFileBytes(path); - if (!data) { - spdlog::warn("Failed to read ModsConfig.xml at {}: {}", path.toStdString(), - data.error().message.toStdString()); - return std::nullopt; - } - return parseModsConfigBytes(*data, path); + auto data = readFileBytes(path); + if (!data) { + spdlog::warn("Failed to read ModsConfig.xml at {}: {}", path.toStdString(), + data.error().message.toStdString()); + return std::nullopt; + } + return parseModsConfigBytes(*data, path); } QStringList getSnapshots(const QString& snapshotsDir) { - if (!QFileInfo::exists(snapshotsDir)) { - return {}; - } - return snapshotFiles(snapshotsDir); + if (!QFileInfo::exists(snapshotsDir)) { + return {}; + } + return snapshotFiles(snapshotsDir); } -Result> createSnapshot(const QString& configPath, const QString& snapshotsDir, - int maxSnapshots) { - if (!QFileInfo(configPath).isFile()) { - return std::optional{}; - } - auto configData = readFileBytes(configPath); - if (!configData) { - spdlog::warn("Failed to read {} for snapshot: {}", configPath.toStdString(), - configData.error().message.toStdString()); - return std::optional{}; - } - - const QStringList snapshots = getSnapshots(snapshotsDir); - if (!snapshots.isEmpty()) { - if (auto newest = readFileBytes(snapshots.first()); newest && *newest == *configData) { - pruneSnapshots(snapshotsDir, maxSnapshots); - return std::optional(snapshots.first()); - } - } - - const QString timestamp = QString::fromStdString(snapshotTimestamp()); - QString snapshotPath = QDir(snapshotsDir).filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); - for (int counter = 1; QFileInfo::exists(snapshotPath); ++counter) { - snapshotPath = QDir(snapshotsDir) - .filePath(QStringLiteral("ModsConfig_%1_%2.xml") - .arg(timestamp) - .arg(counter, 3, 10, QLatin1Char('0'))); - } - - if (auto written = writeFileAtomically(snapshotPath, *configData); !written) { - return std::unexpected(written.error()); +Result> createSnapshot(const QString& configPath, + const QString& snapshotsDir, + int maxSnapshots) { + if (!QFileInfo(configPath).isFile()) { + return std::optional{}; + } + auto configData = readFileBytes(configPath); + if (!configData) { + spdlog::warn("Failed to read {} for snapshot: {}", configPath.toStdString(), + configData.error().message.toStdString()); + return std::optional{}; + } + + const QStringList snapshots = getSnapshots(snapshotsDir); + if (!snapshots.isEmpty()) { + if (auto newest = readFileBytes(snapshots.first()); + newest && *newest == *configData) { + pruneSnapshots(snapshotsDir, maxSnapshots); + return std::optional(snapshots.first()); } - pruneSnapshots(snapshotsDir, maxSnapshots); - spdlog::info("Snapshot created at {}", snapshotPath.toStdString()); - return std::optional(snapshotPath); + } + + const QString timestamp = QString::fromStdString(snapshotTimestamp()); + QString snapshotPath = + QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); + for (int counter = 1; QFileInfo::exists(snapshotPath); ++counter) { + snapshotPath = QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_%1_%2.xml") + .arg(timestamp) + .arg(counter, 3, 10, QLatin1Char('0'))); + } + + if (auto written = writeFileAtomically(snapshotPath, *configData); !written) { + return std::unexpected(written.error()); + } + pruneSnapshots(snapshotsDir, maxSnapshots); + spdlog::info("Snapshot created at {}", snapshotPath.toStdString()); + return std::optional(snapshotPath); } -Result restoreSnapshot(const QString& snapshotPath, const QString& targetPath, - const QString& snapshotsDir, int maxSnapshots) { - auto snapshotData = readFileBytes(snapshotPath); - if (!snapshotData) { - return std::unexpected(Error{ - QStringLiteral("Failed to read snapshot %1: %2").arg(snapshotPath, snapshotData.error().message)}); - } - if (!parseModsConfigBytes(*snapshotData, snapshotPath)) { - return std::unexpected(Error{QStringLiteral("Snapshot is invalid or corrupted: %1").arg(snapshotPath)}); - } - - if (QFileInfo::exists(targetPath)) { - const int limit = std::max(1, maxSnapshots); - if (auto kept = createSnapshot(targetPath, snapshotsDir, limit + 1); !kept) { - return std::unexpected(kept.error()); - } - pruneSnapshots(snapshotsDir, limit, snapshotPath); +Result restoreSnapshot(const QString& snapshotPath, + const QString& targetPath, + const QString& snapshotsDir, int maxSnapshots) { + auto snapshotData = readFileBytes(snapshotPath); + if (!snapshotData) { + return std::unexpected( + Error{QStringLiteral("Failed to read snapshot %1: %2") + .arg(snapshotPath, snapshotData.error().message)}); + } + if (!parseModsConfigBytes(*snapshotData, snapshotPath)) { + return std::unexpected( + Error{QStringLiteral("Snapshot is invalid or corrupted: %1") + .arg(snapshotPath)}); + } + + if (QFileInfo::exists(targetPath)) { + const int limit = std::max(1, maxSnapshots); + if (auto kept = createSnapshot(targetPath, snapshotsDir, limit + 1); + !kept) { + return std::unexpected(kept.error()); } - if (auto written = writeFileAtomically(targetPath, *snapshotData); !written) { - return std::unexpected(written.error()); - } - spdlog::info("Restored snapshot {} to {}", snapshotPath.toStdString(), targetPath.toStdString()); - return {}; + pruneSnapshots(snapshotsDir, limit, snapshotPath); + } + if (auto written = writeFileAtomically(targetPath, *snapshotData); !written) { + return std::unexpected(written.error()); + } + spdlog::info("Restored snapshot {} to {}", snapshotPath.toStdString(), + targetPath.toStdString()); + return {}; } -Result writeModsConfig(const QString& path, const ModsConfig& data, const QString& snapshotsDir, - int maxSnapshots) { - // An existing ModsConfigData document is updated in place so unknown XML survives; - // anything else starts a fresh document. - pugi::xml_document doc; - bool reusable = false; - if (auto existing = readFileBytes(path); existing) { - reusable = doc.load_buffer(existing->data(), existing->size(), kParseFlags) && - std::string_view(doc.document_element().name()) == kRootTag; - } - if (!reusable) { - doc.reset(); - doc.append_child(kRootTag); - } - pugi::xml_node root = doc.document_element(); - - pugi::xml_node versionNode = root.child("version"); - if (!versionNode) { - versionNode = root.append_child("version"); +Result writeModsConfig(const QString& path, const ModsConfig& data, + const QString& snapshotsDir, int maxSnapshots) { + // An existing ModsConfigData document is updated in place so unknown XML + // survives; anything else starts a fresh document. + pugi::xml_document doc; + bool reusable = false; + if (auto existing = readFileBytes(path); existing) { + reusable = + doc.load_buffer(existing->data(), existing->size(), kParseFlags) && + std::string_view(doc.document_element().name()) == kRootTag; + } + if (!reusable) { + doc.reset(); + doc.append_child(kRootTag); + } + pugi::xml_node root = doc.document_element(); + + pugi::xml_node versionNode = root.child("version"); + if (!versionNode) { + versionNode = root.append_child("version"); + } + versionNode.text().set(data.version.toUtf8().constData()); + replaceListItems(root, "activeMods", data.activeMods); + replaceListItems(root, "knownExpansions", data.knownExpansions); + + pugi::xml_node declaration = doc.prepend_child(pugi::node_declaration); + declaration.append_attribute("version") = "1.0"; + declaration.append_attribute("encoding") = "utf-8"; + + std::ostringstream out; + doc.save(out, " ", pugi::format_default, pugi::encoding_utf8); + + if (QFileInfo::exists(path)) { + if (auto kept = createSnapshot(path, snapshotsDir, maxSnapshots); !kept) { + return std::unexpected(kept.error()); } - versionNode.text().set(data.version.toUtf8().constData()); - replaceListItems(root, "activeMods", data.activeMods); - replaceListItems(root, "knownExpansions", data.knownExpansions); - - pugi::xml_node declaration = doc.prepend_child(pugi::node_declaration); - declaration.append_attribute("version") = "1.0"; - declaration.append_attribute("encoding") = "utf-8"; - - std::ostringstream out; - doc.save(out, " ", pugi::format_default, pugi::encoding_utf8); - - if (QFileInfo::exists(path)) { - if (auto kept = createSnapshot(path, snapshotsDir, maxSnapshots); !kept) { - return std::unexpected(kept.error()); - } - } - if (auto written = writeFileAtomically(path, out.str()); !written) { - return std::unexpected(written.error()); - } - spdlog::info("ModsConfig.xml written to {}", path.toStdString()); - return {}; + } + if (auto written = writeFileAtomically(path, out.str()); !written) { + return std::unexpected(written.error()); + } + spdlog::info("ModsConfig.xml written to {}", path.toStdString()); + return {}; } } // namespace px::modrim diff --git a/src/core/mods_config.hpp b/src/core/mods_config.hpp index 7b62200..e9435fc 100644 --- a/src/core/mods_config.hpp +++ b/src/core/mods_config.hpp @@ -1,34 +1,39 @@ #pragma once -#include - #include #include +#include #include "core/error.hpp" #include "core/models/metadata/structures.hpp" namespace px::modrim { -// Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not a ModsConfig document. +// Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not +// a ModsConfig document. std::optional parseModsConfig(const QString& path); // Snapshot files in `snapshots_dir`, newest first. QStringList getSnapshots(const QString& snapshotsDir); -// Keeps a copy of `config_path` in `snapshots_dir`. Yields the snapshot that holds the -// current contents (an existing identical one, or a new one), or nullopt when `config_path` -// is not a readable file. -Result> createSnapshot(const QString& configPath, const QString& snapshotsDir, - int maxSnapshots = 10); - -// Replaces `target_path` with a valid snapshot, keeping the replaced file as a snapshot. -Result restoreSnapshot(const QString& snapshotPath, const QString& targetPath, - const QString& snapshotsDir, int maxSnapshots = 10); - -// Writes `data` into `path`, keeping unknown XML in an existing ModsConfigData document. -// The previous file is kept as a snapshot first. -Result writeModsConfig(const QString& path, const ModsConfig& data, const QString& snapshotsDir, - int maxSnapshots = 10); +// Keeps a copy of `config_path` in `snapshots_dir`. Yields the snapshot that +// holds the current contents (an existing identical one, or a new one), or +// nullopt when `config_path` is not a readable file. +Result> createSnapshot(const QString& configPath, + const QString& snapshotsDir, + int maxSnapshots = 10); + +// Replaces `target_path` with a valid snapshot, keeping the replaced file as a +// snapshot. +Result restoreSnapshot(const QString& snapshotPath, + const QString& targetPath, + const QString& snapshotsDir, + int maxSnapshots = 10); + +// Writes `data` into `path`, keeping unknown XML in an existing ModsConfigData +// document. The previous file is kept as a snapshot first. +Result writeModsConfig(const QString& path, const ModsConfig& data, + const QString& snapshotsDir, + int maxSnapshots = 10); } // namespace px::modrim diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp index fe10adf..4145a6a 100644 --- a/src/core/organizer/db.cpp +++ b/src/core/organizer/db.cpp @@ -1,9 +1,5 @@ #include "core/organizer/db.hpp" -#include -#include - -#include #include #include #include @@ -11,6 +7,10 @@ #include #include #include +#include +#include + +#include #include #include "core/migrator.hpp" @@ -19,7 +19,8 @@ namespace px::modrim::organizer { namespace { -// Byte-for-byte the legacy schema: existing user databases carry this exact version-1 layout. +// Byte-for-byte the legacy schema: existing user databases carry this exact +// version-1 layout. const QString kSchema = QStringLiteral(R"sql( CREATE TABLE IF NOT EXISTS folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -68,560 +69,667 @@ const QString kPlaceSql = QStringLiteral( "INSERT INTO placements (package_id, folder_id) VALUES (?, ?) " "ON CONFLICT (package_id) DO UPDATE SET folder_id = excluded.folder_id"); const QString kInsertRuleSql = QStringLiteral( - "INSERT INTO rules (position, field, op, pattern, folder_id) VALUES (?, ?, ?, ?, ?)"); -// Re-parents placements of the whole subtree to the root before the folders are deleted. + "INSERT INTO rules (position, field, op, pattern, folder_id) VALUES (?, ?, " + "?, ?, ?)"); +// Re-parents placements of the whole subtree to the root before the folders are +// deleted. const QString kMoveSubtreeSql = QStringLiteral( "WITH RECURSIVE subtree(id) AS (" " SELECT ?" " UNION ALL" " SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id" ") " - "UPDATE placements SET folder_id = ? WHERE folder_id IN (SELECT id FROM subtree)"); + "UPDATE placements SET folder_id = ? WHERE folder_id IN (SELECT id FROM " + "subtree)"); Error errorOf(const QSqlQuery& query) { - return Error{query.lastError().text()}; -} - -Result runStatement(QSqlDatabase& db, const QString& sql, const QVariantList& params = {}) { - QSqlQuery query(db); - if (!query.prepare(sql)) { - return std::unexpected(errorOf(query)); - } - for (qsizetype i = 0; i < params.size(); ++i) { - query.bindValue(static_cast(i), params[i]); + return Error{query.lastError().text()}; +} + +Result runStatement(QSqlDatabase& db, const QString& sql, + const QVariantList& params = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(errorOf(query)); + } + for (qsizetype i = 0; i < params.size(); ++i) { + query.bindValue(static_cast(i), params[i]); + } + if (!query.exec()) { + return std::unexpected(errorOf(query)); + } + return query; +} + +// Prepares `sql` once and runs it for each row. Positional values are rebound +// every time. +Result runBatch(QSqlDatabase& db, const QString& sql, + const QList& rows) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(errorOf(query)); + } + for (const QVariantList& row : rows) { + for (qsizetype i = 0; i < row.size(); ++i) { + query.bindValue(static_cast(i), row[i]); } if (!query.exec()) { - return std::unexpected(errorOf(query)); + return std::unexpected(errorOf(query)); } - return query; + } + return {}; } -// Prepares `sql` once and runs it for each row. Positional values are rebound every time. -Result runBatch(QSqlDatabase& db, const QString& sql, const QList& rows) { - QSqlQuery query(db); - if (!query.prepare(sql)) { - return std::unexpected(errorOf(query)); - } - for (const QVariantList& row : rows) { - for (qsizetype i = 0; i < row.size(); ++i) { - query.bindValue(static_cast(i), row[i]); - } - if (!query.exec()) { - return std::unexpected(errorOf(query)); - } - } - return {}; -} - -// Visits each row of a SELECT. A failure part-way through is an error, not the end of the rows. +// Visits each row of a SELECT. A failure part-way through is an error, not the +// end of the rows. template -Result selectRows(QSqlDatabase& db, const QString& sql, const QVariantList& params, Visit&& visit) { - auto query = runStatement(db, sql, params); - if (!query) { - return std::unexpected(query.error()); - } - while (query->next()) { - visit(*query); - } - if (query->lastError().isValid()) { - return std::unexpected(errorOf(*query)); - } - return {}; -} - -// Runs `body` inside one transaction: commits when it succeeds, rolls back otherwise. +Result selectRows(QSqlDatabase& db, const QString& sql, + const QVariantList& params, Visit&& visit) { + auto query = runStatement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + while (query->next()) { + visit(*query); + } + if (query->lastError().isValid()) { + return std::unexpected(errorOf(*query)); + } + return {}; +} + +// Runs `body` inside one transaction: commits when it succeeds, rolls back +// otherwise. template auto inTransaction(QSqlDatabase& db, F&& body) { - using R = std::invoke_result_t; - if (!db.transaction()) { - return R(std::unexpected(Error{db.lastError().text()})); - } - R result = body(); - if (!result) { - db.rollback(); - return result; - } - if (!db.commit()) { - Error error{db.lastError().text()}; - db.rollback(); - return R(std::unexpected(std::move(error))); - } + using R = std::invoke_result_t; + if (!db.transaction()) { + return R(std::unexpected(Error{db.lastError().text()})); + } + R result = body(); + if (!result) { + db.rollback(); return result; -} - -// Runs an UPDATE or DELETE that must touch a row; `missing` is the error when none matched. -Result runExpectingRow(QSqlDatabase& db, const QString& sql, const QVariantList& params, - const QString& missing) { - auto query = runStatement(db, sql, params); - if (!query) { - return std::unexpected(query.error()); - } - if (query->numRowsAffected() == 0) { - return std::unexpected(Error{missing}); - } - return {}; + } + if (!db.commit()) { + Error error{db.lastError().text()}; + db.rollback(); + return R(std::unexpected(std::move(error))); + } + return result; +} + +// Runs an UPDATE or DELETE that must touch a row; `missing` is the error when +// none matched. +Result runExpectingRow(QSqlDatabase& db, const QString& sql, + const QVariantList& params, + const QString& missing) { + auto query = runStatement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + if (query->numRowsAffected() == 0) { + return std::unexpected(Error{missing}); + } + return {}; } QString folderMissing(qint64 folderId) { - return QStringLiteral("Folder with id %1 does not exist.").arg(folderId); + return QStringLiteral("Folder with id %1 does not exist.").arg(folderId); } QString tagMissing(qint64 tagId) { - return QStringLiteral("Tag with id %1 does not exist.").arg(tagId); + return QStringLiteral("Tag with id %1 does not exist.").arg(tagId); } Result loadState(QSqlDatabase& db) { - OrganizerState state; - - auto folders = selectRows(db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), {}, - [&state](QSqlQuery& row) { - Folder folder; - folder.id = row.value(0).toLongLong(); - if (!row.value(1).isNull()) { - folder.parentId = row.value(1).toLongLong(); - } - folder.name = row.value(2).toString(); - folder.collapsed = row.value(3).toLongLong() != 0; - state.folders.insert(folder.id, folder); - }); - if (!folders) { - return std::unexpected(folders.error()); - } - - auto placements = selectRows(db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, - [&state](QSqlQuery& row) { - state.placements[row.value(0).toString().toLower()] = - row.value(1).toLongLong(); - }); - if (!placements) { - return std::unexpected(placements.error()); - } - - auto tags = selectRows(db, QStringLiteral("SELECT id, name, color FROM tags"), {}, [&state](QSqlQuery& row) { - const Tag tag{.id = row.value(0).toLongLong(), - .name = row.value(1).toString(), - .color = row.value(2).toString()}; - state.tags.insert(tag.id, tag); - }); - if (!tags) { - return std::unexpected(tags.error()); - } - - auto modTags = selectRows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), {}, - [&state](QSqlQuery& row) { - state.modTags[row.value(0).toString().toLower()].insert( - row.value(1).toLongLong()); - }); - if (!modTags) { - return std::unexpected(modTags.error()); - } - - bool corruptRule = false; - auto rules = selectRows( - db, - QStringLiteral("SELECT id, position, field, op, pattern, folder_id FROM rules " - "ORDER BY position ASC, id ASC"), - {}, [&state, &corruptRule](QSqlQuery& row) { - const auto field = parseRuleField(row.value(2).toString()); - const auto op = parseRuleOp(row.value(3).toString()); - if (!field || !op) { - corruptRule = true; - return; - } - state.rules.append(Rule{.id = row.value(0).toLongLong(), - .position = row.value(1).toLongLong(), - .field = *field, - .op = *op, - .pattern = row.value(4).toString(), - .folderId = row.value(5).toLongLong()}); - }); - if (!rules) { - return std::unexpected(rules.error()); - } - if (corruptRule) { - return std::unexpected(Error{QStringLiteral("The organizer database holds an unknown rule field or operator.")}); - } - return state; -} - -Result insertFolder(QSqlDatabase& db, qint64 parentId, const QString& name) { - auto inserted = runStatement(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), - {parentId, name}); - if (!inserted) { - return std::unexpected(inserted.error()); - } - return Folder{.id = inserted->lastInsertId().toLongLong(), - .parentId = parentId, - .name = name, - .collapsed = false}; + OrganizerState state; + + auto folders = selectRows( + db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), + {}, [&state](QSqlQuery& row) { + Folder folder; + folder.id = row.value(0).toLongLong(); + if (!row.value(1).isNull()) { + folder.parentId = row.value(1).toLongLong(); + } + folder.name = row.value(2).toString(); + folder.collapsed = row.value(3).toLongLong() != 0; + state.folders.insert(folder.id, folder); + }); + if (!folders) { + return std::unexpected(folders.error()); + } + + auto placements = selectRows( + db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, + [&state](QSqlQuery& row) { + state.placements[row.value(0).toString().toLower()] = + row.value(1).toLongLong(); + }); + if (!placements) { + return std::unexpected(placements.error()); + } + + auto tags = selectRows(db, QStringLiteral("SELECT id, name, color FROM tags"), + {}, [&state](QSqlQuery& row) { + const Tag tag{.id = row.value(0).toLongLong(), + .name = row.value(1).toString(), + .color = row.value(2).toString()}; + state.tags.insert(tag.id, tag); + }); + if (!tags) { + return std::unexpected(tags.error()); + } + + auto modTags = + selectRows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), + {}, [&state](QSqlQuery& row) { + state.modTags[row.value(0).toString().toLower()].insert( + row.value(1).toLongLong()); + }); + if (!modTags) { + return std::unexpected(modTags.error()); + } + + bool corruptRule = false; + auto rules = selectRows( + db, + QStringLiteral( + "SELECT id, position, field, op, pattern, folder_id FROM rules " + "ORDER BY position ASC, id ASC"), + {}, [&state, &corruptRule](QSqlQuery& row) { + const auto field = parseRuleField(row.value(2).toString()); + const auto op = parseRuleOp(row.value(3).toString()); + if (!field || !op) { + corruptRule = true; + return; + } + state.rules.append(Rule{.id = row.value(0).toLongLong(), + .position = row.value(1).toLongLong(), + .field = *field, + .op = *op, + .pattern = row.value(4).toString(), + .folderId = row.value(5).toLongLong()}); + }); + if (!rules) { + return std::unexpected(rules.error()); + } + if (corruptRule) { + return std::unexpected(Error{QStringLiteral( + "The organizer database holds an unknown rule field or operator.")}); + } + return state; +} + +Result insertFolder(QSqlDatabase& db, qint64 parentId, + const QString& name) { + auto inserted = runStatement( + db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + {parentId, name}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Folder{.id = inserted->lastInsertId().toLongLong(), + .parentId = parentId, + .name = name, + .collapsed = false}; +} + +Result upsertPlacements(QSqlDatabase& db, const QStringList& packageIds, + qint64 folderId) { + QList rows; + for (const QString& pid : packageIds) { + rows.append({pid, folderId}); + } + return runBatch(db, kPlaceSql, rows); +} + +Result insertTag(QSqlDatabase& db, const QString& name, + const QString& color) { + auto inserted = runStatement( + db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), + {name, color}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Tag{.id = inserted->lastInsertId().toLongLong(), + .name = name, + .color = color}; } -Result upsertPlacements(QSqlDatabase& db, const QStringList& packageIds, qint64 folderId) { +Result deleteFolderRows(QSqlDatabase& db, qint64 folderId) { + bool exists = false; + auto found = + selectRows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), + {folderId}, [&exists](QSqlQuery&) { exists = true; }); + if (!found) { + return std::unexpected(found.error()); + } + if (!exists) { + return std::unexpected(Error{folderMissing(folderId)}); + } + auto moved = runStatement(db, kMoveSubtreeSql, {folderId, kRootId}); + if (!moved) { + return std::unexpected(moved.error()); + } + auto deleted = runStatement( + db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folderId}); + if (!deleted) { + return std::unexpected(deleted.error()); + } + return {}; +} + +Result setModTagRows(QSqlDatabase& db, const QStringList& packageIds, + const QSet& add, + const QSet& remove) { + if (!remove.isEmpty()) { QList rows; for (const QString& pid : packageIds) { - rows.append({pid, folderId}); - } - return runBatch(db, kPlaceSql, rows); -} - -Result insertTag(QSqlDatabase& db, const QString& name, const QString& color) { - auto inserted = runStatement(db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), {name, color}); + for (qint64 tagId : remove) { + rows.append({pid, tagId}); + } + } + if (auto removed = runBatch( + db, + QStringLiteral( + "DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), + rows); + !removed) { + return removed; + } + } + if (!add.isEmpty()) { + QList rows; + for (const QString& pid : packageIds) { + for (qint64 tagId : add) { + rows.append({pid, tagId}); + } + } + if (auto added = + runBatch(db, + QStringLiteral("INSERT OR IGNORE INTO mod_tags " + "(package_id, tag_id) VALUES (?, ?)"), + rows); + !added) { + return added; + } + } + return {}; +} + +Result> replaceRuleRows(QSqlDatabase& db, + const QList& specs) { + if (auto deleted = runStatement(db, QStringLiteral("DELETE FROM rules")); + !deleted) { + return std::unexpected(deleted.error()); + } + QList rules; + for (qsizetype position = 0; position < specs.size(); ++position) { + const RuleSpec& spec = specs[position]; + auto inserted = runStatement( + db, kInsertRuleSql, + {static_cast(position), ruleFieldName(spec.field).toString(), + ruleOpName(spec.op).toString(), spec.pattern, spec.folderId}); if (!inserted) { - return std::unexpected(inserted.error()); - } - return Tag{.id = inserted->lastInsertId().toLongLong(), .name = name, .color = color}; -} - -Result deleteFolderRows(QSqlDatabase& db, qint64 folderId) { - bool exists = false; - auto found = selectRows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), {folderId}, - [&exists](QSqlQuery&) { exists = true; }); - if (!found) { - return std::unexpected(found.error()); - } - if (!exists) { - return std::unexpected(Error{folderMissing(folderId)}); - } - auto moved = runStatement(db, kMoveSubtreeSql, {folderId, kRootId}); - if (!moved) { - return std::unexpected(moved.error()); - } - auto deleted = runStatement(db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folderId}); - if (!deleted) { - return std::unexpected(deleted.error()); - } - return {}; -} - -Result setModTagRows(QSqlDatabase& db, const QStringList& packageIds, const QSet& add, - const QSet& remove) { - if (!remove.isEmpty()) { - QList rows; - for (const QString& pid : packageIds) { - for (qint64 tagId : remove) { - rows.append({pid, tagId}); - } - } - if (auto removed = runBatch(db, QStringLiteral("DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), rows); - !removed) { - return removed; - } - } - if (!add.isEmpty()) { - QList rows; - for (const QString& pid : packageIds) { - for (qint64 tagId : add) { - rows.append({pid, tagId}); - } - } - if (auto added = runBatch(db, QStringLiteral("INSERT OR IGNORE INTO mod_tags (package_id, tag_id) VALUES (?, ?)"), rows); - !added) { - return added; - } - } - return {}; -} - -Result> replaceRuleRows(QSqlDatabase& db, const QList& specs) { - if (auto deleted = runStatement(db, QStringLiteral("DELETE FROM rules")); !deleted) { - return std::unexpected(deleted.error()); - } - QList rules; - for (qsizetype position = 0; position < specs.size(); ++position) { - const RuleSpec& spec = specs[position]; - auto inserted = runStatement(db, kInsertRuleSql, - {static_cast(position), ruleFieldName(spec.field).toString(), - ruleOpName(spec.op).toString(), spec.pattern, spec.folderId}); - if (!inserted) { - return std::unexpected(inserted.error()); - } - rules.append(Rule{.id = inserted->lastInsertId().toLongLong(), - .position = static_cast(position), - .field = spec.field, - .op = spec.op, - .pattern = spec.pattern, - .folderId = spec.folderId}); - } - return rules; -} - -Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, const QStringList& newFolders, - const QList& rules) { - QList folderRows; - for (const QString& name : newFolders) { - folderRows.append({parentId, name}); - } - if (auto created = runBatch(db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), folderRows); - !created) { - return created; - } - - QHash folderIds; - if (auto found = selectRows(db, QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), - {parentId}, [&folderIds](QSqlQuery& row) { - folderIds[row.value(1).toString().toCaseFolded()] = row.value(0).toLongLong(); - }); - !found) { - return found; - } - - qint64 start = 0; - if (auto next = selectRows(db, QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), {}, - [&start](QSqlQuery& row) { start = row.value(0).toLongLong(); }); - !next) { - return next; - } - - QList ruleRows; - for (qsizetype offset = 0; offset < rules.size(); ++offset) { - const StandardRule& rule = rules[offset]; - const auto folder = folderIds.constFind(rule.folder.toCaseFolded()); - if (folder == folderIds.cend()) { - return std::unexpected(Error{QStringLiteral("Folder \"%1\" does not exist.").arg(rule.folder)}); - } - ruleRows.append({start + static_cast(offset), ruleFieldName(rule.field).toString(), - ruleOpName(rule.op).toString(), rule.pattern, folder.value()}); - } - return runBatch(db, kInsertRuleSql, ruleRows); -} - -// Opens the connection named `name`, creating the file and schema on first use. Runs on the -// worker thread only. + return std::unexpected(inserted.error()); + } + rules.append(Rule{.id = inserted->lastInsertId().toLongLong(), + .position = static_cast(position), + .field = spec.field, + .op = spec.op, + .pattern = spec.pattern, + .folderId = spec.folderId}); + } + return rules; +} + +Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, + const QStringList& newFolders, + const QList& rules) { + QList folderRows; + for (const QString& name : newFolders) { + folderRows.append({parentId, name}); + } + if (auto created = runBatch( + db, + QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + folderRows); + !created) { + return created; + } + + QHash folderIds; + if (auto found = selectRows( + db, + QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), + {parentId}, + [&folderIds](QSqlQuery& row) { + folderIds[row.value(1).toString().toCaseFolded()] = + row.value(0).toLongLong(); + }); + !found) { + return found; + } + + qint64 start = 0; + if (auto next = selectRows( + db, + QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), + {}, [&start](QSqlQuery& row) { start = row.value(0).toLongLong(); }); + !next) { + return next; + } + + QList ruleRows; + for (qsizetype offset = 0; offset < rules.size(); ++offset) { + const StandardRule& rule = rules[offset]; + const auto folder = folderIds.constFind(rule.folder.toCaseFolded()); + if (folder == folderIds.cend()) { + return std::unexpected(Error{ + QStringLiteral("Folder \"%1\" does not exist.").arg(rule.folder)}); + } + ruleRows.append({start + static_cast(offset), + ruleFieldName(rule.field).toString(), + ruleOpName(rule.op).toString(), rule.pattern, + folder.value()}); + } + return runBatch(db, kInsertRuleSql, ruleRows); +} + +// Opens the connection named `name`, creating the file and schema on first use. +// Runs on the worker thread only. Result configureConnection(QSqlDatabase& db, const QString& path) { - if (!db.open()) { - return std::unexpected(Error{db.lastError().text()}); - } - // WAL keeps readers from blocking the writer; the busy timeout absorbs brief contention. - for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}) { - if (auto applied = runStatement(db, QString::fromLatin1(pragma)); !applied) { - return std::unexpected(applied.error()); - } - } - return ensureSchema(db, kSchema, kSchemaVersion, {}, path, QStringLiteral("organizer DB")); + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // WAL keeps readers from blocking the writer; the busy timeout absorbs brief + // contention. + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON"}) { + if (auto applied = runStatement(db, QString::fromLatin1(pragma)); + !applied) { + return std::unexpected(applied.error()); + } + } + return ensureSchema(db, kSchema, kSchemaVersion, {}, path, + QStringLiteral("organizer DB")); } Result openConnection(const QString& name, const QString& path) { - if (QSqlDatabase::contains(name)) { - return QSqlDatabase::database(name, false); - } - if (!QDir().mkpath(QFileInfo(path).absolutePath())) { - return std::unexpected(Error{QStringLiteral("Cannot create the directory for %1").arg(path)}); - } - QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); - db.setDatabaseName(path); - if (auto ready = configureConnection(db, path); !ready) { - // Drop the handle before removing the connection, so a retry can open it again. - db.close(); - db = QSqlDatabase(); - QSqlDatabase::removeDatabase(name); - return std::unexpected(ready.error()); - } - return db; + if (QSqlDatabase::contains(name)) { + return QSqlDatabase::database(name, false); + } + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected( + Error{QStringLiteral("Cannot create the directory for %1").arg(path)}); + } + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(path); + if (auto ready = configureConnection(db, path); !ready) { + // Drop the handle before removing the connection, so a retry can open it + // again. + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(name); + return std::unexpected(ready.error()); + } + return db; } void discardConnection(const QString& name) { - if (!QSqlDatabase::contains(name)) { - return; - } - { - QSqlDatabase db = QSqlDatabase::database(name, false); - db.close(); - } - QSqlDatabase::removeDatabase(name); + if (!QSqlDatabase::contains(name)) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(name, false); + db.close(); + } + QSqlDatabase::removeDatabase(name); } } // namespace QString dbPath(const QString& configDir) { - return QDir(configDir).filePath(QStringLiteral("organizer.db")); + return QDir(configDir).filePath(QStringLiteral("organizer.db")); } OrganizerDb::OrganizerDb(QString path) : m_path(std::move(path)), - m_connectionName(QStringLiteral("organizer-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { - m_pool.setMaxThreadCount(1); - m_pool.setExpiryTimeout(-1); + m_connectionName( + QStringLiteral("organizer-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); } OrganizerDb::~OrganizerDb() { - // Blocks until the worker has released the connection; the pool cannot stop before that. - QtConcurrent::run(&m_pool, [name = m_connectionName] { discardConnection(name); }).waitForFinished(); + // Blocks until the worker has released the connection; the pool cannot stop + // before that. + QtConcurrent::run(&m_pool, [name = m_connectionName] { + discardConnection(name); + }).waitForFinished(); } template auto OrganizerDb::submit(F fn) { - using R = std::invoke_result_t; - return QtConcurrent::run(&m_pool, [name = m_connectionName, path = m_path, fn = std::move(fn)]() -> R { + using R = std::invoke_result_t; + return QtConcurrent::run( + &m_pool, + [name = m_connectionName, path = m_path, fn = std::move(fn)]() -> R { auto db = openConnection(name, path); if (!db) { - return std::unexpected(db.error()); + return std::unexpected(db.error()); } return fn(*db); - }); + }); } QCoro::Task> OrganizerDb::load() { - co_return co_await submit([](QSqlDatabase& db) -> Result { return loadState(db); }); -} - -QCoro::Task> OrganizerDb::createFolder(qint64 parentId, QString name) { - co_return co_await submit([parentId, name](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { return insertFolder(db, parentId, name); }); - }); + co_return co_await submit( + [](QSqlDatabase& db) -> Result { return loadState(db); }); } -QCoro::Task> OrganizerDb::createFolderWith(qint64 parentId, QString name, - QStringList packageIds) { - co_return co_await submit([parentId, name, pids = cleanPackageIds(packageIds)](QSqlDatabase& db) -> Result { +QCoro::Task> OrganizerDb::createFolder(qint64 parentId, + QString name) { + co_return co_await submit( + [parentId, name](QSqlDatabase& db) -> Result { return inTransaction(db, [&]() -> Result { - auto folder = insertFolder(db, parentId, name); - if (!folder) { - return folder; - } - if (auto placed = upsertPlacements(db, pids, folder->id); !placed) { - return std::unexpected(placed.error()); - } - return folder; + return insertFolder(db, parentId, name); }); - }); + }); } -QCoro::Task> OrganizerDb::renameFolder(qint64 folderId, QString name) { - if (folderId == kRootId) { - co_return std::unexpected(Error{QStringLiteral("The root folder cannot be renamed.")}); - } - co_return co_await submit([folderId, name](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - return runExpectingRow(db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), - {name, folderId}, folderMissing(folderId)); +QCoro::Task> OrganizerDb::createFolderWith( + qint64 parentId, QString name, QStringList packageIds) { + co_return co_await submit( + [parentId, name, + pids = cleanPackageIds(packageIds)](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + auto folder = insertFolder(db, parentId, name); + if (!folder) { + return folder; + } + if (auto placed = upsertPlacements(db, pids, folder->id); !placed) { + return std::unexpected(placed.error()); + } + return folder; }); + }); +} + +QCoro::Task> OrganizerDb::renameFolder(qint64 folderId, + QString name) { + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be renamed.")}); + } + co_return co_await submit([folderId, name](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), + {name, folderId}, folderMissing(folderId)); }); -} - -QCoro::Task> OrganizerDb::setParent(qint64 folderId, qint64 parentId) { - if (folderId == kRootId) { - co_return std::unexpected(Error{QStringLiteral("The root folder cannot be moved.")}); - } - if (folderId == parentId) { - co_return std::unexpected(Error{QStringLiteral("A folder cannot be its own parent.")}); - } - co_return co_await submit([folderId, parentId](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - return runExpectingRow(db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), - {parentId, folderId}, folderMissing(folderId)); - }); + }); +} + +QCoro::Task> OrganizerDb::setParent(qint64 folderId, + qint64 parentId) { + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be moved.")}); + } + if (folderId == parentId) { + co_return std::unexpected( + Error{QStringLiteral("A folder cannot be its own parent.")}); + } + co_return co_await submit([folderId, + parentId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), + {parentId, folderId}, folderMissing(folderId)); }); + }); } -QCoro::Task> OrganizerDb::setCollapsed(qint64 folderId, bool collapsed) { - co_return co_await submit([folderId, collapsed](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - return runExpectingRow(db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), - {collapsed ? 1 : 0, folderId}, folderMissing(folderId)); - }); +QCoro::Task> OrganizerDb::setCollapsed(qint64 folderId, + bool collapsed) { + co_return co_await submit([folderId, + collapsed](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), + {collapsed ? 1 : 0, folderId}, folderMissing(folderId)); }); + }); } QCoro::Task> OrganizerDb::setAllCollapsed(bool collapsed) { - co_return co_await submit([collapsed](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - auto updated = runStatement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), - {collapsed ? 1 : 0}); - if (!updated) { - return std::unexpected(updated.error()); - } - return {}; - }); + co_return co_await submit([collapsed](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + auto updated = + runStatement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), + {collapsed ? 1 : 0}); + if (!updated) { + return std::unexpected(updated.error()); + } + return {}; }); + }); } QCoro::Task> OrganizerDb::deleteFolder(qint64 folderId) { - if (folderId == kRootId) { - co_return std::unexpected(Error{QStringLiteral("The root folder cannot be deleted.")}); - } - co_return co_await submit([folderId](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { return deleteFolderRows(db, folderId); }); - }); -} - -QCoro::Task> OrganizerDb::place(QStringList packageIds, qint64 folderId) { - const QStringList pids = cleanPackageIds(packageIds); - if (pids.isEmpty()) { - co_return {}; - } - co_return co_await submit([pids, folderId](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { return upsertPlacements(db, pids, folderId); }); + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be deleted.")}); + } + co_return co_await submit([folderId](QSqlDatabase& db) -> Result { + return inTransaction( + db, [&]() -> Result { return deleteFolderRows(db, folderId); }); + }); +} + +QCoro::Task> OrganizerDb::place(QStringList packageIds, + qint64 folderId) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids, folderId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return upsertPlacements(db, pids, folderId); }); + }); } QCoro::Task> OrganizerDb::unplace(QStringList packageIds) { - const QStringList pids = cleanPackageIds(packageIds); - if (pids.isEmpty()) { - co_return {}; - } - co_return co_await submit([pids](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - QList rows; - for (const QString& pid : pids) { - rows.append({pid}); - } - return runBatch(db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), rows); - }); + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + QList rows; + for (const QString& pid : pids) { + rows.append({pid}); + } + return runBatch( + db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), + rows); }); + }); } QCoro::Task> OrganizerDb::createTag(QString name, QString color) { - co_return co_await submit([name, color](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { return insertTag(db, name, color); }); - }); -} - -QCoro::Task> OrganizerDb::updateTag(qint64 tagId, QString name, QString color) { - co_return co_await submit([tagId, name, color](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { - return runExpectingRow(db, QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), - {name, color, tagId}, tagMissing(tagId)); - }); + co_return co_await submit([name, color](QSqlDatabase& db) -> Result { + return inTransaction( + db, [&]() -> Result { return insertTag(db, name, color); }); + }); +} + +QCoro::Task> OrganizerDb::updateTag(qint64 tagId, QString name, + QString color) { + co_return co_await submit([tagId, name, + color](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, + QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), + {name, color, tagId}, tagMissing(tagId)); }); + }); } QCoro::Task> OrganizerDb::deleteTag(qint64 tagId) { - co_return co_await submit([tagId](QSqlDatabase& db) -> Result { + co_return co_await submit([tagId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, + QStringLiteral("DELETE FROM tags WHERE id = ?"), + {tagId}, tagMissing(tagId)); + }); + }); +} + +QCoro::Task> OrganizerDb::setModTags(QStringList packageIds, + QSet add, + QSet remove) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + co_return co_await submit( + [pids, add, remove](QSqlDatabase& db) -> Result { return inTransaction(db, [&]() -> Result { - return runExpectingRow(db, QStringLiteral("DELETE FROM tags WHERE id = ?"), {tagId}, - tagMissing(tagId)); + return setModTagRows(db, pids, add, remove); }); - }); -} - -QCoro::Task> OrganizerDb::setModTags(QStringList packageIds, QSet add, - QSet remove) { - const QStringList pids = cleanPackageIds(packageIds); - if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { - co_return {}; - } - co_return co_await submit([pids, add, remove](QSqlDatabase& db) -> Result { - return inTransaction(db, [&]() -> Result { return setModTagRows(db, pids, add, remove); }); - }); + }); } -QCoro::Task>> OrganizerDb::replaceRules(QList specs) { - co_return co_await submit([specs](QSqlDatabase& db) -> Result> { - return inTransaction(db, [&]() -> Result> { return replaceRuleRows(db, specs); }); +QCoro::Task>> OrganizerDb::replaceRules( + QList specs) { + co_return co_await submit([specs](QSqlDatabase& db) -> Result> { + return inTransaction(db, [&]() -> Result> { + return replaceRuleRows(db, specs); }); + }); } -QCoro::Task> OrganizerDb::appendNamedRules(qint64 parentId, QStringList newFolders, - QList rules) { - co_return co_await submit([parentId, newFolders, rules](QSqlDatabase& db) -> Result { +QCoro::Task> OrganizerDb::appendNamedRules( + qint64 parentId, QStringList newFolders, QList rules) { + co_return co_await submit( + [parentId, newFolders, rules](QSqlDatabase& db) -> Result { return inTransaction(db, [&]() -> Result { - return appendNamedRuleRows(db, parentId, newFolders, rules); + return appendNamedRuleRows(db, parentId, newFolders, rules); }); - }); + }); } QCoro::Task OrganizerDb::close() { - co_await QtConcurrent::run(&m_pool, [name = m_connectionName] { discardConnection(name); }); + co_await QtConcurrent::run( + &m_pool, [name = m_connectionName] { discardConnection(name); }); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/db.hpp b/src/core/organizer/db.hpp index cc99b53..a2d93a4 100644 --- a/src/core/organizer/db.hpp +++ b/src/core/organizer/db.hpp @@ -1,11 +1,12 @@ #pragma once -#include #include #include #include #include +#include + #include "core/error.hpp" #include "core/organizer/defaults.hpp" #include "core/organizer/models.hpp" @@ -15,54 +16,60 @@ namespace px::modrim::organizer { // The organizer database file inside the config directory. QString dbPath(const QString& configDir); -// SQLite store for the organizer. Every operation runs on one worker thread that owns the -// connection, so operations are serialized in submission order and each is atomic. -// The connection opens on first use and closes on close() or destruction. +// SQLite store for the organizer. Every operation runs on one worker thread +// that owns the connection, so operations are serialized in submission order +// and each is atomic. The connection opens on first use and closes on close() +// or destruction. class OrganizerDb { -public: - explicit OrganizerDb(QString path); - ~OrganizerDb(); - - OrganizerDb(const OrganizerDb&) = delete; - OrganizerDb& operator=(const OrganizerDb&) = delete; - - QCoro::Task> load(); - - QCoro::Task> createFolder(qint64 parentId, QString name); - QCoro::Task> createFolderWith(qint64 parentId, QString name, - QStringList packageIds); - QCoro::Task> renameFolder(qint64 folderId, QString name); - QCoro::Task> setParent(qint64 folderId, qint64 parentId); - QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); - QCoro::Task> setAllCollapsed(bool collapsed); - QCoro::Task> deleteFolder(qint64 folderId); - - QCoro::Task> place(QStringList packageIds, qint64 folderId); - QCoro::Task> unplace(QStringList packageIds); - - QCoro::Task> createTag(QString name, QString color); - QCoro::Task> updateTag(qint64 tagId, QString name, QString color); - QCoro::Task> deleteTag(qint64 tagId); - QCoro::Task> setModTags(QStringList packageIds, QSet add, - QSet remove); - - QCoro::Task>> replaceRules(QList specs); - // Creates `new_folders` under `parent_id`, then appends `rules` after the existing ones, - // resolving each rule's folder by name among the children of `parent_id`. - QCoro::Task> appendNamedRules(qint64 parentId, QStringList newFolders, - QList rules); - - QCoro::Task close(); - -private: - // Runs `fn(QSqlDatabase&)` on the worker, opening the connection first if needed. - template - auto submit(F fn); - - QString m_path; - QString m_connectionName; - // One thread, never expiring: the connection stays on the thread that created it. - QThreadPool m_pool; + public: + explicit OrganizerDb(QString path); + ~OrganizerDb(); + + OrganizerDb(const OrganizerDb&) = delete; + OrganizerDb& operator=(const OrganizerDb&) = delete; + + QCoro::Task> load(); + + QCoro::Task> createFolder(qint64 parentId, QString name); + QCoro::Task> createFolderWith(qint64 parentId, QString name, + QStringList packageIds); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> setParent(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); + + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> unplace(QStringList packageIds); + + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, + QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, + QSet remove); + + QCoro::Task>> replaceRules(QList specs); + // Creates `new_folders` under `parent_id`, then appends `rules` after the + // existing ones, resolving each rule's folder by name among the children of + // `parent_id`. + QCoro::Task> appendNamedRules(qint64 parentId, + QStringList newFolders, + QList rules); + + QCoro::Task close(); + + private: + // Runs `fn(QSqlDatabase&)` on the worker, opening the connection first if + // needed. + template + auto submit(F fn); + + QString m_path; + QString m_connectionName; + // One thread, never expiring: the connection stays on the thread that created + // it. + QThreadPool m_pool; }; } // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp index 08f1f15..df19fe6 100644 --- a/src/core/organizer/defaults.cpp +++ b/src/core/organizer/defaults.cpp @@ -6,81 +6,93 @@ namespace px::modrim::organizer { namespace { -// Package ids come from each mod's own About.xml. Multi-category authors (brrainz, -// unlimitedhugs, bs, mlie) are matched by exact id only, and name heuristics such as -// "framework" or "performance" are avoided: they catch translations and unrelated -// content mods. Order matters (first match wins): libraries and performance precede -// the family prefixes that would swallow VE Framework and Dubs Performance Analyzer. +// Package ids come from each mod's own About.xml. Multi-category authors +// (brrainz, unlimitedhugs, bs, mlie) are matched by exact id only, and name +// heuristics such as "framework" or "performance" are avoided: they catch +// translations and unrelated content mods. Order matters (first match wins): +// libraries and performance precede the family prefixes that would swallow VE +// Framework and Dubs Performance Analyzer. -QList exact(const QString& folder, std::initializer_list ids) { - QList rules; - for (const char* id : ids) { - rules.append({folder, RuleField::kPackageId, RuleOp::kEquals, QString::fromLatin1(id)}); - } - return rules; +QList exact(const QString& folder, + std::initializer_list ids) { + QList rules; + for (const char* id : ids) { + rules.append({folder, RuleField::kPackageId, RuleOp::kEquals, + QString::fromLatin1(id)}); + } + return rules; } -QList prefixes(const QString& folder, std::initializer_list prefixes) { - QList rules; - for (const char* prefix : prefixes) { - rules.append({folder, RuleField::kPackageId, RuleOp::kPrefix, QString::fromLatin1(prefix)}); - } - return rules; +QList prefixes(const QString& folder, + std::initializer_list prefixes) { + QList rules; + for (const char* prefix : prefixes) { + rules.append({folder, RuleField::kPackageId, RuleOp::kPrefix, + QString::fromLatin1(prefix)}); + } + return rules; } QList buildStandardRules() { - QList rules; - const auto append = [&rules](const QList& more) { rules.append(more); }; - const auto add = [&rules](StandardRule rule) { rules.append(std::move(rule)); }; + QList rules; + const auto append = [&rules](const QList& more) { + rules.append(more); + }; + const auto add = [&rules](StandardRule rule) { + rules.append(std::move(rule)); + }; - append(prefixes("Official", {"ludeon.rimworld"})); - append(exact("Frameworks & Libraries", { - "brrainz.harmony", - "unlimitedhugs.hugslib", - "erdelf.humanoidalienraces", - "oskarpotocki.vanillafactionsexpanded.core", - "imranfish.xmlextensions", - "zetrith.prepatcher", - "jecrell.jecstools", - "bs.fishery", - "smashphil.vehicleframework", - })); - append(exact("Performance", { - "bs.performance", - "krkr.rocketman", - "taranchuk.performanceoptimizer", - })); - append(prefixes("Performance", {"dubwise.dubsperformanceanalyzer"})); - append(exact("Quality of Life", { - "brrainz.achtung", - "brrainz.cameraplus", - "unlimitedhugs.allowtool", - "jaxe.rimhud", - "mehni.pickupandhaul", - "avilmask.commonsense", - "dhultgren.smarterconstruction", - "roolo.searchanddestroy", - })); - append(prefixes("Vanilla Expanded", { - "vanillaexpanded.", - "oskarpotocki.", - "vanillaracesexpanded.", - "vanillaquestsexpanded.", - "vanillastorytellersexpanded.", - })); - add({"Vanilla Expanded", RuleField::kAuthor, RuleOp::kContains, "oskar potocki"}); - append(prefixes("Alpha Mods", {"sarg."})); - append(prefixes("Combat Extended", {"ceteam."})); - add({"Combat Extended", RuleField::kName, RuleOp::kContains, "combat extended"}); - append(prefixes("Dubs Mods", {"dubwise."})); - return rules; + append(prefixes("Official", {"ludeon.rimworld"})); + append(exact("Frameworks & Libraries", + { + "brrainz.harmony", + "unlimitedhugs.hugslib", + "erdelf.humanoidalienraces", + "oskarpotocki.vanillafactionsexpanded.core", + "imranfish.xmlextensions", + "zetrith.prepatcher", + "jecrell.jecstools", + "bs.fishery", + "smashphil.vehicleframework", + })); + append(exact("Performance", { + "bs.performance", + "krkr.rocketman", + "taranchuk.performanceoptimizer", + })); + append(prefixes("Performance", {"dubwise.dubsperformanceanalyzer"})); + append(exact("Quality of Life", { + "brrainz.achtung", + "brrainz.cameraplus", + "unlimitedhugs.allowtool", + "jaxe.rimhud", + "mehni.pickupandhaul", + "avilmask.commonsense", + "dhultgren.smarterconstruction", + "roolo.searchanddestroy", + })); + append(prefixes("Vanilla Expanded", { + "vanillaexpanded.", + "oskarpotocki.", + "vanillaracesexpanded.", + "vanillaquestsexpanded.", + "vanillastorytellersexpanded.", + })); + add({"Vanilla Expanded", RuleField::kAuthor, RuleOp::kContains, + "oskar potocki"}); + append(prefixes("Alpha Mods", {"sarg."})); + append(prefixes("Combat Extended", {"ceteam."})); + add({"Combat Extended", RuleField::kName, RuleOp::kContains, + "combat extended"}); + append(prefixes("Dubs Mods", {"dubwise."})); + return rules; } } // namespace const QList& standardRules() { - static const QList rules = buildStandardRules(); - return rules; + static const QList rules = buildStandardRules(); + return rules; } } // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.hpp b/src/core/organizer/defaults.hpp index 2aa0848..3fceac9 100644 --- a/src/core/organizer/defaults.hpp +++ b/src/core/organizer/defaults.hpp @@ -9,10 +9,10 @@ namespace px::modrim::organizer { // A built-in auto-folder rule targeting a top-level folder by name. struct StandardRule { - QString folder; - RuleField field = RuleField::kPackageId; - RuleOp op = RuleOp::kEquals; - QString pattern; + QString folder; + RuleField field = RuleField::kPackageId; + RuleOp op = RuleOp::kEquals; + QString pattern; }; // Built-in rules in priority order: the first match wins. diff --git a/src/core/organizer/models.hpp b/src/core/organizer/models.hpp index 6f4083b..e3310fe 100644 --- a/src/core/organizer/models.hpp +++ b/src/core/organizer/models.hpp @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include @@ -9,6 +7,7 @@ #include #include #include +#include namespace px::modrim::organizer { @@ -20,98 +19,106 @@ enum class RuleField { kPackageId, kName, kAuthor }; enum class RuleOp { kPrefix, kContains, kEquals }; inline QLatin1StringView ruleFieldName(RuleField field) { - switch (field) { - case RuleField::kPackageId: return QLatin1StringView("package_id"); - case RuleField::kName: return QLatin1StringView("name"); - case RuleField::kAuthor: return QLatin1StringView("author"); - } - return {}; + switch (field) { + case RuleField::kPackageId: + return QLatin1StringView("package_id"); + case RuleField::kName: + return QLatin1StringView("name"); + case RuleField::kAuthor: + return QLatin1StringView("author"); + } + return {}; } inline QLatin1StringView ruleOpName(RuleOp op) { - switch (op) { - case RuleOp::kPrefix: return QLatin1StringView("prefix"); - case RuleOp::kContains: return QLatin1StringView("contains"); - case RuleOp::kEquals: return QLatin1StringView("equals"); - } - return {}; + switch (op) { + case RuleOp::kPrefix: + return QLatin1StringView("prefix"); + case RuleOp::kContains: + return QLatin1StringView("contains"); + case RuleOp::kEquals: + return QLatin1StringView("equals"); + } + return {}; } inline std::optional parseRuleField(const QString& name) { - if (name == QLatin1StringView("package_id")) return RuleField::kPackageId; - if (name == QLatin1StringView("name")) return RuleField::kName; - if (name == QLatin1StringView("author")) return RuleField::kAuthor; - return std::nullopt; + if (name == QLatin1StringView("package_id")) return RuleField::kPackageId; + if (name == QLatin1StringView("name")) return RuleField::kName; + if (name == QLatin1StringView("author")) return RuleField::kAuthor; + return std::nullopt; } inline std::optional parseRuleOp(const QString& name) { - if (name == QLatin1StringView("prefix")) return RuleOp::kPrefix; - if (name == QLatin1StringView("contains")) return RuleOp::kContains; - if (name == QLatin1StringView("equals")) return RuleOp::kEquals; - return std::nullopt; + if (name == QLatin1StringView("prefix")) return RuleOp::kPrefix; + if (name == QLatin1StringView("contains")) return RuleOp::kContains; + if (name == QLatin1StringView("equals")) return RuleOp::kEquals; + return std::nullopt; } struct Folder { - qint64 id = 0; - std::optional parentId; // empty only for ROOT_ID - QString name; - bool collapsed = false; + qint64 id = 0; + std::optional parentId; // empty only for ROOT_ID + QString name; + bool collapsed = false; - friend bool operator==(const Folder&, const Folder&) = default; + friend bool operator==(const Folder&, const Folder&) = default; }; struct Tag { - qint64 id = 0; - QString name; - QString color; + qint64 id = 0; + QString name; + QString color; - friend bool operator==(const Tag&, const Tag&) = default; + friend bool operator==(const Tag&, const Tag&) = default; }; struct RuleSpec { - RuleField field = RuleField::kName; - RuleOp op = RuleOp::kEquals; - QString pattern; - qint64 folderId = 0; + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; + QString pattern; + qint64 folderId = 0; - friend bool operator==(const RuleSpec&, const RuleSpec&) = default; + friend bool operator==(const RuleSpec&, const RuleSpec&) = default; }; struct Rule { - qint64 id = 0; - qint64 position = 0; - RuleField field = RuleField::kName; - RuleOp op = RuleOp::kEquals; - QString pattern; - qint64 folderId = 0; - - friend bool operator==(const Rule&, const Rule&) = default; + qint64 id = 0; + qint64 position = 0; + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; + QString pattern; + qint64 folderId = 0; + + friend bool operator==(const Rule&, const Rule&) = default; }; // Everything the organizer keeps in memory, as loaded from organizer.db. // Package ids are lowercase keys. struct OrganizerState { - QHash folders; - QHash placements; - QHash tags; - QHash> modTags; - QList rules; - - friend bool operator==(const OrganizerState&, const OrganizerState&) = default; + QHash folders; + QHash placements; + QHash tags; + QHash> modTags; + QList rules; + + friend bool operator==(const OrganizerState&, + const OrganizerState&) = default; }; -// Lowercased, trimmed, de-duplicated package ids in first-seen order; blanks are dropped. +// Lowercased, trimmed, de-duplicated package ids in first-seen order; blanks +// are dropped. inline QStringList cleanPackageIds(const QStringList& packageIds) { - QStringList cleaned; - QSet seen; - for (const QString& raw : packageIds) { - const QString pid = raw.trimmed().toLower(); - if (!pid.isEmpty() && !seen.contains(pid)) { - seen.insert(pid); - cleaned.append(pid); - } + QStringList cleaned; + QSet seen; + for (const QString& raw : packageIds) { + const QString pid = raw.trimmed().toLower(); + if (!pid.isEmpty() && !seen.contains(pid)) { + seen.insert(pid); + cleaned.append(pid); } - return cleaned; + } + return cleaned; } } // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp index 106ac4d..b1e770f 100644 --- a/src/core/organizer/resolve.cpp +++ b/src/core/organizer/resolve.cpp @@ -10,375 +10,408 @@ namespace px::modrim::organizer { namespace { struct Matcher { - RuleOp op = RuleOp::kEquals; - QString pattern; // casefolded - - bool matches(const QString& value) const { - switch (op) { - case RuleOp::kPrefix: return value.startsWith(pattern); - case RuleOp::kContains: return value.contains(pattern); - case RuleOp::kEquals: return value == pattern; - } - return false; + RuleOp op = RuleOp::kEquals; + QString pattern; // casefolded + + bool matches(const QString& value) const { + switch (op) { + case RuleOp::kPrefix: + return value.startsWith(pattern); + case RuleOp::kContains: + return value.contains(pattern); + case RuleOp::kEquals: + return value == pattern; } + return false; + } }; Matcher makeMatcher(RuleOp op, const QString& pattern) { - return Matcher{op, pattern.toCaseFolded()}; + return Matcher{op, pattern.toCaseFolded()}; } struct FieldRule { - RuleField field = RuleField::kName; - Matcher matcher; - qint64 folderId = kRootId; + RuleField field = RuleField::kName; + Matcher matcher; + qint64 folderId = kRootId; }; // The casefolded value a rule field is matched against. -QString fieldValue(RuleField field, const QString& pid, const QString& name, const QString& author) { - switch (field) { - case RuleField::kPackageId: return pid.toCaseFolded(); - case RuleField::kName: return name.toCaseFolded(); - case RuleField::kAuthor: return author.toCaseFolded(); - } - return {}; +QString fieldValue(RuleField field, const QString& pid, const QString& name, + const QString& author) { + switch (field) { + case RuleField::kPackageId: + return pid.toCaseFolded(); + case RuleField::kName: + return name.toCaseFolded(); + case RuleField::kAuthor: + return author.toCaseFolded(); + } + return {}; } // Manual placements win over rules; the first matching rule by position wins. class Resolver { -public: - explicit Resolver(const OrganizerState& state) : m_placements(state.placements) { - for (const Rule& rule : state.rules) { - if (state.folders.contains(rule.folderId) && rule.folderId != kRootId) { - m_rules.append(FieldRule{rule.field, makeMatcher(rule.op, rule.pattern), rule.folderId}); - } - } + public: + explicit Resolver(const OrganizerState& state) + : m_placements(state.placements) { + for (const Rule& rule : state.rules) { + if (state.folders.contains(rule.folderId) && rule.folderId != kRootId) { + m_rules.append(FieldRule{rule.field, makeMatcher(rule.op, rule.pattern), + rule.folderId}); + } } + } - Resolution resolve(const std::optional& packageId, const QString& name, - const QString& author) const { - if (!packageId) { - return {kRootId, Placement::kNone}; - } - const QString pid = packageId->toLower(); - if (const auto manual = m_placements.constFind(pid); manual != m_placements.cend()) { - return {manual.value(), Placement::kManual}; - } - for (const FieldRule& rule : m_rules) { - if (rule.matcher.matches(fieldValue(rule.field, pid, name, author))) { - return {rule.folderId, Placement::kRule}; - } - } - return {kRootId, Placement::kNone}; + Resolution resolve(const std::optional& packageId, + const QString& name, const QString& author) const { + if (!packageId) { + return {kRootId, Placement::kNone}; } + const QString pid = packageId->toLower(); + if (const auto manual = m_placements.constFind(pid); + manual != m_placements.cend()) { + return {manual.value(), Placement::kManual}; + } + for (const FieldRule& rule : m_rules) { + if (rule.matcher.matches(fieldValue(rule.field, pid, name, author))) { + return {rule.folderId, Placement::kRule}; + } + } + return {kRootId, Placement::kNone}; + } -private: - const QHash& m_placements; - QList m_rules; + private: + const QHash& m_placements; + QList m_rules; }; -bool matchesQuery(const ModLeaf& leaf, const QString& providerId, const TreeQuery& query) { - if (query.status == StatusFilter::kActive && !leaf.enabled) { - return false; - } - if (query.status == StatusFilter::kInactive && leaf.enabled) { - return false; - } - if (!query.providerIds.isEmpty() && !query.providerIds.contains(providerId)) { - return false; - } - for (qint64 tagId : query.tagIds) { - if (!leaf.tagIds.contains(tagId)) { - return false; - } +bool matchesQuery(const ModLeaf& leaf, const QString& providerId, + const TreeQuery& query) { + if (query.status == StatusFilter::kActive && !leaf.enabled) { + return false; + } + if (query.status == StatusFilter::kInactive && leaf.enabled) { + return false; + } + if (!query.providerIds.isEmpty() && !query.providerIds.contains(providerId)) { + return false; + } + for (qint64 tagId : query.tagIds) { + if (!leaf.tagIds.contains(tagId)) { + return false; } - const QString text = query.text.trimmed().toCaseFolded(); - if (text.isEmpty()) { - return true; - } - return leaf.name.trimmed().toCaseFolded().contains(text) - || (leaf.packageId && leaf.packageId->contains(text)) - || leaf.author.toCaseFolded().contains(text); + } + const QString text = query.text.trimmed().toCaseFolded(); + if (text.isEmpty()) { + return true; + } + return leaf.name.trimmed().toCaseFolded().contains(text) || + (leaf.packageId && leaf.packageId->contains(text)) || + leaf.author.toCaseFolded().contains(text); } -// A folder with no mods counts as off, not on: "on" needs at least one enabled mod. +// A folder with no mods counts as off, not on: "on" needs at least one enabled +// mod. CheckState checkState(int total, int enabled) { - if (total != 0 && enabled == total) { - return CheckState::kOn; - } - if (enabled == 0) { - return CheckState::kOff; - } - return CheckState::kPartial; + if (total != 0 && enabled == total) { + return CheckState::kOn; + } + if (enabled == 0) { + return CheckState::kOff; + } + return CheckState::kPartial; } bool modLess(const ModLeaf& a, const ModLeaf& b) { - return std::pair(a.name.toCaseFolded(), a.uuid) < std::pair(b.name.toCaseFolded(), b.uuid); + return std::pair(a.name.toCaseFolded(), a.uuid) < + std::pair(b.name.toCaseFolded(), b.uuid); } bool folderLess(const Folder& a, const Folder& b) { - return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); + return std::pair(a.name.toCaseFolded(), a.id) < + std::pair(b.name.toCaseFolded(), b.id); } -// Builds the folder tree bottom-up. `visited` is shared across the whole walk, so a folder -// reachable twice is attached once. +// Builds the folder tree bottom-up. `visited` is shared across the whole walk, +// so a folder reachable twice is attached once. struct TreeBuilder { - const OrganizerState& state; - const QHash>& childrenOf; - const QHash>& allByFolder; - const QHash>& visibleByFolder; - const QSet& ruled; - bool filtering; - QSet visited; - - FolderNode build(const Folder& folder, int depth) { - visited.insert(folder.id); - FolderNode node; - node.folder = folder; - node.depth = depth; - node.hasRules = ruled.contains(folder.id); - - QList children = childrenOf.value(folder.id); - std::sort(children.begin(), children.end(), folderLess); - for (const Folder& child : children) { - if (visited.contains(child.id)) { - continue; - } - FolderNode sub = build(child, depth + 1); - node.total += sub.total; - node.enabled += sub.enabled; - node.visibleTotal += sub.visibleTotal; - if (!filtering || sub.visibleTotal != 0) { - node.children.push_back(std::move(sub)); - } - } + const OrganizerState& state; + const QHash>& childrenOf; + const QHash>& allByFolder; + const QHash>& visibleByFolder; + const QSet& ruled; + bool filtering; + QSet visited; + + FolderNode build(const Folder& folder, int depth) { + visited.insert(folder.id); + FolderNode node; + node.folder = folder; + node.depth = depth; + node.hasRules = ruled.contains(folder.id); + + QList children = childrenOf.value(folder.id); + std::sort(children.begin(), children.end(), folderLess); + for (const Folder& child : children) { + if (visited.contains(child.id)) { + continue; + } + FolderNode sub = build(child, depth + 1); + node.total += sub.total; + node.enabled += sub.enabled; + node.visibleTotal += sub.visibleTotal; + if (!filtering || sub.visibleTotal != 0) { + node.children.push_back(std::move(sub)); + } + } - const QList own = allByFolder.value(folder.id); - QList visible = visibleByFolder.value(folder.id); - std::sort(visible.begin(), visible.end(), modLess); - int ownEnabled = 0; - for (const ModLeaf& leaf : own) { - ownEnabled += leaf.enabled ? 1 : 0; - } - node.ownTotal = static_cast(own.size()); - node.ownEnabled = ownEnabled; - node.total += node.ownTotal; - node.enabled += ownEnabled; - node.visibleTotal += static_cast(visible.size()); - node.check = checkState(node.total, node.enabled); - node.mods = std::move(visible); - return node; + const QList own = allByFolder.value(folder.id); + QList visible = visibleByFolder.value(folder.id); + std::sort(visible.begin(), visible.end(), modLess); + int ownEnabled = 0; + for (const ModLeaf& leaf : own) { + ownEnabled += leaf.enabled ? 1 : 0; } + node.ownTotal = static_cast(own.size()); + node.ownEnabled = ownEnabled; + node.total += node.ownTotal; + node.enabled += ownEnabled; + node.visibleTotal += static_cast(visible.size()); + node.check = checkState(node.total, node.enabled); + node.mods = std::move(visible); + return node; + } }; } // namespace bool TreeQuery::isEmpty() const { - return text.trimmed().isEmpty() && tagIds.isEmpty() && status == StatusFilter::kAll && providerIds.isEmpty(); + return text.trimmed().isEmpty() && tagIds.isEmpty() && + status == StatusFilter::kAll && providerIds.isEmpty(); } TreeQuery TreeFilter::query(const QString& text) const { - TreeQuery query; - query.text = text; - query.status = status; - if (providerId) { - query.providerIds.insert(*providerId); - } - if (tagId) { - query.tagIds.insert(*tagId); - } - return query; + TreeQuery query; + query.text = text; + query.status = status; + if (providerId) { + query.providerIds.insert(*providerId); + } + if (tagId) { + query.tagIds.insert(*tagId); + } + return query; } CheckState FolderNode::ownCheck() const { - return checkState(ownTotal, ownEnabled); + return checkState(ownTotal, ownEnabled); } QList FolderNode::walk() const { - QList nodes{this}; - for (const FolderNode& child : children) { - nodes += child.walk(); - } - return nodes; + QList nodes{this}; + for (const FolderNode& child : children) { + nodes += child.walk(); + } + return nodes; } QList FolderNode::allMods() const { - QList modsOut; - for (const FolderNode* node : walk()) { - modsOut += node->mods; - } - return modsOut; + QList modsOut; + for (const FolderNode* node : walk()) { + modsOut += node->mods; + } + return modsOut; } std::optional modPackageId(const ListedMod& mod) { - if (const auto* about = dynamic_cast(&mod)) { - return about->packageId.str(); - } - return std::nullopt; + if (const auto* about = dynamic_cast(&mod)) { + return about->packageId.str(); + } + return std::nullopt; } QString modAuthor(const ListedMod& mod) { - if (const auto* about = dynamic_cast(&mod)) { - return about->authors.join(QStringLiteral(", ")); - } - return {}; + if (const auto* about = dynamic_cast(&mod)) { + return about->authors.join(QStringLiteral(", ")); + } + return {}; } -Resolution folderFor(const OrganizerState& state, const std::optional& packageId, - const QString& name, const QString& author) { - return Resolver(state).resolve(packageId, name, author); +Resolution folderFor(const OrganizerState& state, + const std::optional& packageId, + const QString& name, const QString& author) { + return Resolver(state).resolve(packageId, name, author); } -RulePreview previewRuleMatches(const OrganizerState& state, const ModsByUuid& mods, - const QList& specs) { - const Resolver current(state); - // A spec with a blank pattern, an unknown folder or the root matches nothing. The - // matcher still uses the raw pattern, as the legacy preview did. - std::vector> candidates; - candidates.reserve(static_cast(specs.size())); - for (const RuleSpec& spec : specs) { - const bool usable = !spec.pattern.trimmed().isEmpty() && state.folders.contains(spec.folderId) && - spec.folderId != kRootId; - candidates.push_back(usable ? std::optional(FieldRule{spec.field, makeMatcher(spec.op, spec.pattern), - spec.folderId}) - : std::nullopt); +RulePreview previewRuleMatches(const OrganizerState& state, + const ModsByUuid& mods, + const QList& specs) { + const Resolver current(state); + // A spec with a blank pattern, an unknown folder or the root matches nothing. + // The matcher still uses the raw pattern, as the legacy preview did. + std::vector> candidates; + candidates.reserve(static_cast(specs.size())); + for (const RuleSpec& spec : specs) { + const bool usable = !spec.pattern.trimmed().isEmpty() && + state.folders.contains(spec.folderId) && + spec.folderId != kRootId; + candidates.push_back( + usable ? std::optional(FieldRule{spec.field, + makeMatcher(spec.op, spec.pattern), + spec.folderId}) + : std::nullopt); + } + + RulePreview preview; + preview.counts.assign(specs.size(), 0); + for (const ListedModPtr& mod : mods) { + const auto pid = modPackageId(*mod); + if (!pid) { + continue; } - - RulePreview preview; - preview.counts.assign(specs.size(), 0); - for (const ListedModPtr& mod : mods) { - const auto pid = modPackageId(*mod); - if (!pid) { - continue; - } - const QString author = modAuthor(*mod); - for (qsizetype index = 0; index < specs.size(); ++index) { - const std::optional& candidate = candidates[static_cast(index)]; - if (!candidate || !candidate->matcher.matches(fieldValue(candidate->field, *pid, mod->name, author))) { - continue; - } - preview.counts[index] += 1; - if (!state.placements.contains(*pid) && current.resolve(pid, mod->name, author).folderId == kRootId) { - preview.assignable += 1; - } - break; - } + const QString author = modAuthor(*mod); + for (qsizetype index = 0; index < specs.size(); ++index) { + const std::optional& candidate = + candidates[static_cast(index)]; + if (!candidate || !candidate->matcher.matches(fieldValue( + candidate->field, *pid, mod->name, author))) { + continue; + } + preview.counts[index] += 1; + if (!state.placements.contains(*pid) && + current.resolve(pid, mod->name, author).folderId == kRootId) { + preview.assignable += 1; + } + break; } - return preview; + } + return preview; } -QList treeFilters(const ModsByUuid& mods, const QSet& active, - const OrganizerState* state) { - const int total = static_cast(mods.size()); - int enabled = 0; - QHash perProvider; - for (auto it = mods.cbegin(); it != mods.cend(); ++it) { - enabled += active.contains(it.key()) ? 1 : 0; - perProvider[it.value()->providerId] += 1; +QList treeFilters(const ModsByUuid& mods, + const QSet& active, + const OrganizerState* state) { + const int total = static_cast(mods.size()); + int enabled = 0; + QHash perProvider; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + enabled += active.contains(it.key()) ? 1 : 0; + perProvider[it.value()->providerId] += 1; + } + + const auto make = [](QString key, QString label, int count) { + TreeFilter filter; + filter.key = std::move(key); + filter.label = std::move(label); + filter.count = count; + return filter; + }; + + TreeFilter statusActive = + make(QStringLiteral("active"), QStringLiteral("Active"), enabled); + statusActive.status = StatusFilter::kActive; + TreeFilter statusInactive = make(QStringLiteral("inactive"), + QStringLiteral("Inactive"), total - enabled); + statusInactive.status = StatusFilter::kInactive; + QList filters{ + make(QStringLiteral("all"), QStringLiteral("All"), total), + std::move(statusActive), std::move(statusInactive)}; + + std::vector> labelled; // (label, provider id) + for (auto it = perProvider.cbegin(); it != perProvider.cend(); ++it) { + labelled.emplace_back(providerLabel(it.key()), it.key()); + } + std::sort(labelled.begin(), labelled.end()); + for (const auto& [label, pid] : labelled) { + TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, + perProvider.value(pid)); + provider.providerId = pid; + filters.append(std::move(provider)); + } + + if (state && !state->tags.isEmpty()) { + QHash tagCounts; + for (const auto& [id, tag] : state->tags.asKeyValueRange()) { + tagCounts.insert(id, 0); } - - const auto make = [](QString key, QString label, int count) { - TreeFilter filter; - filter.key = std::move(key); - filter.label = std::move(label); - filter.count = count; - return filter; - }; - - TreeFilter statusActive = make(QStringLiteral("active"), QStringLiteral("Active"), enabled); - statusActive.status = StatusFilter::kActive; - TreeFilter statusInactive = make(QStringLiteral("inactive"), QStringLiteral("Inactive"), total - enabled); - statusInactive.status = StatusFilter::kInactive; - QList filters{make(QStringLiteral("all"), QStringLiteral("All"), total), std::move(statusActive), - std::move(statusInactive)}; - - std::vector> labelled; // (label, provider id) - for (auto it = perProvider.cbegin(); it != perProvider.cend(); ++it) { - labelled.emplace_back(providerLabel(it.key()), it.key()); - } - std::sort(labelled.begin(), labelled.end()); - for (const auto& [label, pid] : labelled) { - TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, perProvider.value(pid)); - provider.providerId = pid; - filters.append(std::move(provider)); - } - - if (state && !state->tags.isEmpty()) { - QHash tagCounts; - for (const auto& [id, tag] : state->tags.asKeyValueRange()) { - tagCounts.insert(id, 0); - } - for (const ListedModPtr& mod : mods) { - const auto pid = modPackageId(*mod); - if (!pid) { - continue; - } - for (qint64 tagId : state->modTags.value(*pid)) { - if (tagCounts.contains(tagId)) { - tagCounts[tagId] += 1; - } - } - } - - std::vector sortedTags; - for (const Tag& tag : state->tags) { - sortedTags.push_back(tag); - } - std::sort(sortedTags.begin(), sortedTags.end(), [](const Tag& a, const Tag& b) { - return std::pair(a.name.toCaseFolded(), a.id) < std::pair(b.name.toCaseFolded(), b.id); - }); - for (const Tag& tag : sortedTags) { - TreeFilter tagFilter = make(QStringLiteral("tag:%1").arg(tag.id), tag.name, tagCounts.value(tag.id)); - tagFilter.tagId = tag.id; - filters.append(std::move(tagFilter)); + for (const ListedModPtr& mod : mods) { + const auto pid = modPackageId(*mod); + if (!pid) { + continue; + } + for (qint64 tagId : state->modTags.value(*pid)) { + if (tagCounts.contains(tagId)) { + tagCounts[tagId] += 1; } + } } - return filters; -} -FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, - const std::optional& query) { - const bool filtering = query && !query->isEmpty(); - const Resolver resolver(state); - - QHash> allByFolder; - QHash> visibleByFolder; - for (auto it = mods.cbegin(); it != mods.cend(); ++it) { - const ListedMod& mod = *it.value(); - const QString& uuid = it.key(); - const auto pid = modPackageId(mod); - const QString author = modAuthor(mod); - Resolution where = resolver.resolve(pid, mod.name, author); - if (!state.folders.contains(where.folderId)) { - where = Resolution{kRootId, Placement::kNone}; - } - const ModLeaf leaf{ - .uuid = uuid, - .packageId = pid, - .name = mod.name, - .author = author, - .enabled = active.contains(uuid), - .tagIds = pid ? state.modTags.value(*pid) : QSet{}, - .placement = where.placement, - }; - allByFolder[where.folderId].append(leaf); - if (!filtering || matchesQuery(leaf, mod.providerId, *query)) { - visibleByFolder[where.folderId].append(leaf); - } + std::vector sortedTags; + for (const Tag& tag : state->tags) { + sortedTags.push_back(tag); } + std::sort(sortedTags.begin(), sortedTags.end(), + [](const Tag& a, const Tag& b) { + return std::pair(a.name.toCaseFolded(), a.id) < + std::pair(b.name.toCaseFolded(), b.id); + }); + for (const Tag& tag : sortedTags) { + TreeFilter tagFilter = make(QStringLiteral("tag:%1").arg(tag.id), + tag.name, tagCounts.value(tag.id)); + tagFilter.tagId = tag.id; + filters.append(std::move(tagFilter)); + } + } + return filters; +} - QHash> childrenOf; - for (const Folder& folder : state.folders) { - if (folder.parentId) { - childrenOf[*folder.parentId].append(folder); - } +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, + const QSet& active, + const std::optional& query) { + const bool filtering = query && !query->isEmpty(); + const Resolver resolver(state); + + QHash> allByFolder; + QHash> visibleByFolder; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + const ListedMod& mod = *it.value(); + const QString& uuid = it.key(); + const auto pid = modPackageId(mod); + const QString author = modAuthor(mod); + Resolution where = resolver.resolve(pid, mod.name, author); + if (!state.folders.contains(where.folderId)) { + where = Resolution{kRootId, Placement::kNone}; } - QSet ruled; - for (const Rule& rule : state.rules) { - ruled.insert(rule.folderId); + const ModLeaf leaf{ + .uuid = uuid, + .packageId = pid, + .name = mod.name, + .author = author, + .enabled = active.contains(uuid), + .tagIds = pid ? state.modTags.value(*pid) : QSet{}, + .placement = where.placement, + }; + allByFolder[where.folderId].append(leaf); + if (!filtering || matchesQuery(leaf, mod.providerId, *query)) { + visibleByFolder[where.folderId].append(leaf); } + } - TreeBuilder builder{state, childrenOf, allByFolder, visibleByFolder, ruled, filtering, {}}; - return builder.build(state.folders.value(kRootId), 0); + QHash> childrenOf; + for (const Folder& folder : state.folders) { + if (folder.parentId) { + childrenOf[*folder.parentId].append(folder); + } + } + QSet ruled; + for (const Rule& rule : state.rules) { + ruled.insert(rule.folderId); + } + + TreeBuilder builder{state, childrenOf, allByFolder, visibleByFolder, + ruled, filtering, {}}; + return builder.build(state.folders.value(kRootId), 0); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.hpp b/src/core/organizer/resolve.hpp index ceae35e..c28099c 100644 --- a/src/core/organizer/resolve.hpp +++ b/src/core/organizer/resolve.hpp @@ -1,12 +1,11 @@ #pragma once -#include -#include - #include #include #include #include +#include +#include #include "core/models/metadata/structures.hpp" #include "core/organizer/models.hpp" @@ -16,89 +15,95 @@ namespace px::modrim::organizer { // Discovered mods keyed by uuid. using ModsByUuid = QHash; -// Where a mod's folder came from: a manual placement, an auto-folder rule, or nowhere (root). +// Where a mod's folder came from: a manual placement, an auto-folder rule, or +// nowhere (root). enum class Placement { kManual, kRule, kNone }; enum class CheckState { kOn, kOff, kPartial }; enum class StatusFilter { kAll, kActive, kInactive }; struct Resolution { - qint64 folderId = kRootId; - Placement placement = Placement::kNone; + qint64 folderId = kRootId; + Placement placement = Placement::kNone; }; struct TreeQuery { - QString text; - QSet tagIds; - StatusFilter status = StatusFilter::kAll; - QSet providerIds; + QString text; + QSet tagIds; + StatusFilter status = StatusFilter::kAll; + QSet providerIds; - bool isEmpty() const; + bool isEmpty() const; }; // A sidebar preset (status, provider, or tag) and the mods it selects. struct TreeFilter { - QString key; - QString label; - int count = 0; - StatusFilter status = StatusFilter::kAll; - std::optional providerId; - std::optional tagId; - - TreeQuery query(const QString& text = {}) const; + QString key; + QString label; + int count = 0; + StatusFilter status = StatusFilter::kAll; + std::optional providerId; + std::optional tagId; + + TreeQuery query(const QString& text = {}) const; }; struct ModLeaf { - QString uuid; - std::optional packageId; - QString name; - QString author; - bool enabled = false; - QSet tagIds; - Placement placement = Placement::kNone; + QString uuid; + std::optional packageId; + QString name; + QString author; + bool enabled = false; + QSet tagIds; + Placement placement = Placement::kNone; }; struct FolderNode { - Folder folder; - int depth = 0; - // std::vector: QList cannot hold its own incomplete element type. - std::vector children; - QList mods; - int total = 0; - int enabled = 0; - CheckState check = CheckState::kOff; - int visibleTotal = 0; - int ownTotal = 0; - int ownEnabled = 0; - bool hasRules = false; - - // Check state of the folder's direct mods only (the root's Ungrouped). - CheckState ownCheck() const; - // This node followed by all descendants, in pre-order. - QList walk() const; - QList allMods() const; + Folder folder; + int depth = 0; + // std::vector: QList cannot hold its own incomplete element type. + std::vector children; + QList mods; + int total = 0; + int enabled = 0; + CheckState check = CheckState::kOff; + int visibleTotal = 0; + int ownTotal = 0; + int ownEnabled = 0; + bool hasRules = false; + + // Check state of the folder's direct mods only (the root's Ungrouped). + CheckState ownCheck() const; + // This node followed by all descendants, in pre-order. + QList walk() const; + QList allMods() const; }; -// First-match counts for a draft rule list, and the ungrouped mods it would assign. +// First-match counts for a draft rule list, and the ungrouped mods it would +// assign. struct RulePreview { - QList counts; - int assignable = 0; + QList counts; + int assignable = 0; }; // Lowercased package id for About.xml mods; nullopt for mods without one. std::optional modPackageId(const ListedMod& mod); QString modAuthor(const ListedMod& mod); -Resolution folderFor(const OrganizerState& state, const std::optional& packageId, - const QString& name, const QString& author); +Resolution folderFor(const OrganizerState& state, + const std::optional& packageId, + const QString& name, const QString& author); -RulePreview previewRuleMatches(const OrganizerState& state, const ModsByUuid& mods, - const QList& specs); +RulePreview previewRuleMatches(const OrganizerState& state, + const ModsByUuid& mods, + const QList& specs); // `state` may be null, in which case no tag filters are produced. -QList treeFilters(const ModsByUuid& mods, const QSet& active, - const OrganizerState* state); +QList treeFilters(const ModsByUuid& mods, + const QSet& active, + const OrganizerState* state); -FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, - const std::optional& query = std::nullopt); +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, + const QSet& active, + const std::optional& query = std::nullopt); } // namespace px::modrim::organizer diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index d86223e..518a5f2 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -1,11 +1,11 @@ #include "core/organizer/service.hpp" +#include #include #include #include #include -#include #include #include "core/context.hpp" @@ -18,11 +18,12 @@ const QHash kEmptyMods; const QStringList kEmptyActive; Result cleanName(const QString& name, const QString& what) { - const QString cleaned = name.trimmed(); - if (cleaned.isEmpty()) { - return std::unexpected(Error{QStringLiteral("%1 name cannot be empty.").arg(what)}); - } - return cleaned; + const QString cleaned = name.trimmed(); + if (cleaned.isEmpty()) { + return std::unexpected( + Error{QStringLiteral("%1 name cannot be empty.").arg(what)}); + } + return cleaned; } } // namespace @@ -30,560 +31,602 @@ Result cleanName(const QString& name, const QString& what) { OrganizerService::OrganizerService(CoreContext& ctx, OrganizerDb& db) : Plugin(QStringLiteral("organizer")), m_ctx(&ctx), m_db(db) {} -OrganizerService::OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, OrganizerDb& db) +OrganizerService::OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, + OrganizerDb& db) : Plugin(QStringLiteral("organizer")), m_allModsFn(std::move(allMods)), m_activeUuidsFn(std::move(activeUuids)), m_db(db) {} const QHash& OrganizerService::allMods() const { - if (m_allModsFn) { - return m_allModsFn(); - } - if (m_ctx) { - return m_ctx->allMods(); - } - return kEmptyMods; + if (m_allModsFn) { + return m_allModsFn(); + } + if (m_ctx) { + return m_ctx->allMods(); + } + return kEmptyMods; } const QStringList& OrganizerService::activeUuids() const { - if (m_activeUuidsFn) { - return m_activeUuidsFn(); - } - if (m_ctx) { - return m_ctx->activeUuids(); - } - return kEmptyActive; + if (m_activeUuidsFn) { + return m_activeUuidsFn(); + } + if (m_ctx) { + return m_ctx->activeUuids(); + } + return kEmptyActive; } QCoro::Task OrganizerService::init() { - auto loaded = co_await m_db.load(); - if (!loaded) { - // Plugin::init cannot report failure, so the service stays not ready and says why. - spdlog::error("Cannot load organizer state: {}", loaded.error().message.toStdString()); - co_return; - } - m_state = std::move(*loaded); - changed.notify({}); + auto loaded = co_await m_db.load(); + if (!loaded) { + // Plugin::init cannot report failure, so the service stays not ready and + // says why. + spdlog::error("Cannot load organizer state: {}", + loaded.error().message.toStdString()); + co_return; + } + m_state = std::move(*loaded); + changed.notify({}); } -QCoro::Task OrganizerService::init(CoreContext&) { - co_await init(); -} +QCoro::Task OrganizerService::init(CoreContext&) { co_await init(); } -QCoro::Task OrganizerService::shutdown() { - co_await m_db.close(); -} +QCoro::Task OrganizerService::shutdown() { co_await m_db.close(); } -bool OrganizerService::ready() const noexcept { - return m_state.has_value(); -} +bool OrganizerService::ready() const noexcept { return m_state.has_value(); } const OrganizerState& OrganizerService::state() const { - Q_ASSERT(m_state.has_value()); - return *m_state; + Q_ASSERT(m_state.has_value()); + return *m_state; } QCoro::Task> OrganizerService::commit() { - auto loaded = co_await m_db.load(); - if (!loaded) { - co_return std::unexpected(loaded.error()); - } - m_state = std::move(*loaded); - changed.notify({}); - co_return {}; + auto loaded = co_await m_db.load(); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_state = std::move(*loaded); + changed.notify({}); + co_return {}; } // ── Validation helpers ─────────────────────────────────────────────────────── Result OrganizerService::findFolder(qint64 folderId) const { - const auto& folders = state().folders; - const auto it = folders.constFind(folderId); - if (it == folders.cend()) { - return std::unexpected(Error{QStringLiteral("That folder no longer exists.")}); - } - return it.value(); + const auto& folders = state().folders; + const auto it = folders.constFind(folderId); + if (it == folders.cend()) { + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + return it.value(); } Result OrganizerService::findNonRoot(qint64 folderId) const { - if (folderId == kRootId) { - return std::unexpected(Error{QStringLiteral("The root folder cannot be changed.")}); - } - return findFolder(folderId); + if (folderId == kRootId) { + return std::unexpected( + Error{QStringLiteral("The root folder cannot be changed.")}); + } + return findFolder(folderId); } Result OrganizerService::findTag(qint64 tagId) const { - const auto& tags = state().tags; - const auto it = tags.constFind(tagId); - if (it == tags.cend()) { - return std::unexpected(Error{QStringLiteral("That tag no longer exists.")}); - } - return it.value(); + const auto& tags = state().tags; + const auto it = tags.constFind(tagId); + if (it == tags.cend()) { + return std::unexpected(Error{QStringLiteral("That tag no longer exists.")}); + } + return it.value(); } int OrganizerService::depth(qint64 folderId) const { - const auto& folders = state().folders; - int count = 0; - Folder current = folders.value(folderId); - while (current.parentId) { - ++count; - current = folders.value(*current.parentId); - } - return count; + const auto& folders = state().folders; + int count = 0; + Folder current = folders.value(folderId); + while (current.parentId) { + ++count; + current = folders.value(*current.parentId); + } + return count; } QList OrganizerService::children(qint64 folderId) const { - QList result; - for (const Folder& folder : state().folders) { - if (folder.parentId == folderId) { - result.append(folder); - } + QList result; + for (const Folder& folder : state().folders) { + if (folder.parentId == folderId) { + result.append(folder); } - return result; + } + return result; } QSet OrganizerService::subtreeIds(qint64 folderId) const { - QSet ids{folderId}; - QList pending{folderId}; - while (!pending.isEmpty()) { - const qint64 current = pending.takeLast(); - for (const Folder& child : children(current)) { - if (!ids.contains(child.id)) { - ids.insert(child.id); - pending.append(child.id); - } - } + QSet ids{folderId}; + QList pending{folderId}; + while (!pending.isEmpty()) { + const qint64 current = pending.takeLast(); + for (const Folder& child : children(current)) { + if (!ids.contains(child.id)) { + ids.insert(child.id); + pending.append(child.id); + } } - return ids; + } + return ids; } int OrganizerService::height(qint64 folderId) const { - int tallest = 0; - for (const Folder& child : children(folderId)) { - tallest = std::max(tallest, height(child.id)); - } - return 1 + tallest; -} - -Result OrganizerService::checkDepth(qint64 parentId, int treeHeight) const { - if (depth(parentId) + treeHeight > kMaxDepth) { - return std::unexpected( - Error{QStringLiteral("Folders can be nested at most %1 levels deep.").arg(kMaxDepth)}); - } - return {}; -} - -Result OrganizerService::checkUniqueFolderName(qint64 parentId, const QString& name, - std::optional exclude) const { - const QString key = name.toCaseFolded(); - for (const Folder& sibling : children(parentId)) { - if (exclude && sibling.id == *exclude) { - continue; - } - if (sibling.name.toCaseFolded() == key) { - return std::unexpected(Error{QStringLiteral("A folder named \"%1\" already exists here.").arg(name)}); - } - } - return {}; -} - -Result OrganizerService::checkTag(const QString& name, const QString& color, - std::optional exclude) const { - auto cleaned = cleanName(name, QStringLiteral("Tag")); - if (!cleaned) { - return std::unexpected(cleaned.error()); - } - const QString key = cleaned->toCaseFolded(); - for (const Tag& tag : state().tags) { - if (exclude && tag.id == *exclude) { - continue; - } - if (tag.name.toCaseFolded() == key) { - return std::unexpected(Error{QStringLiteral("A tag named \"%1\" already exists.").arg(*cleaned)}); - } - } - static const QRegularExpression colorPattern(QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); - if (!colorPattern.match(color).hasMatch()) { - return std::unexpected(Error{QStringLiteral("\"%1\" is not a valid color (use #RRGGBB).").arg(color)}); - } - return *cleaned; -} - -Result OrganizerService::validateNewFolder(const QString& name, qint64 parentId) const { - auto cleaned = cleanName(name, QStringLiteral("Folder")); - if (!cleaned) { - return std::unexpected(cleaned.error()); - } - if (auto parent = findFolder(parentId); !parent) { - return std::unexpected(parent.error()); - } - if (auto fits = checkDepth(parentId, 1); !fits) { - return std::unexpected(fits.error()); - } - if (auto unique = checkUniqueFolderName(parentId, *cleaned); !unique) { - return std::unexpected(unique.error()); - } - return *cleaned; + int tallest = 0; + for (const Folder& child : children(folderId)) { + tallest = std::max(tallest, height(child.id)); + } + return 1 + tallest; +} + +Result OrganizerService::checkDepth(qint64 parentId, + int treeHeight) const { + if (depth(parentId) + treeHeight > kMaxDepth) { + return std::unexpected( + Error{QStringLiteral("Folders can be nested at most %1 levels deep.") + .arg(kMaxDepth)}); + } + return {}; +} + +Result OrganizerService::checkUniqueFolderName( + qint64 parentId, const QString& name, std::optional exclude) const { + const QString key = name.toCaseFolded(); + for (const Folder& sibling : children(parentId)) { + if (exclude && sibling.id == *exclude) { + continue; + } + if (sibling.name.toCaseFolded() == key) { + return std::unexpected( + Error{QStringLiteral("A folder named \"%1\" already exists here.") + .arg(name)}); + } + } + return {}; +} + +Result OrganizerService::checkTag( + const QString& name, const QString& color, + std::optional exclude) const { + auto cleaned = cleanName(name, QStringLiteral("Tag")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + const QString key = cleaned->toCaseFolded(); + for (const Tag& tag : state().tags) { + if (exclude && tag.id == *exclude) { + continue; + } + if (tag.name.toCaseFolded() == key) { + return std::unexpected(Error{ + QStringLiteral("A tag named \"%1\" already exists.").arg(*cleaned)}); + } + } + static const QRegularExpression colorPattern( + QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); + if (!colorPattern.match(color).hasMatch()) { + return std::unexpected( + Error{QStringLiteral("\"%1\" is not a valid color (use #RRGGBB).") + .arg(color)}); + } + return *cleaned; +} + +Result OrganizerService::validateNewFolder(const QString& name, + qint64 parentId) const { + auto cleaned = cleanName(name, QStringLiteral("Folder")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + if (auto parent = findFolder(parentId); !parent) { + return std::unexpected(parent.error()); + } + if (auto fits = checkDepth(parentId, 1); !fits) { + return std::unexpected(fits.error()); + } + if (auto unique = checkUniqueFolderName(parentId, *cleaned); !unique) { + return std::unexpected(unique.error()); + } + return *cleaned; } QSet OrganizerService::activeSet() const { - const QStringList& active = activeUuids(); - return QSet(active.cbegin(), active.cend()); + const QStringList& active = activeUuids(); + return QSet(active.cbegin(), active.cend()); } // ── Folders ────────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::createFolder(QString name, qint64 parentId) { - auto cleaned = validateNewFolder(name, parentId); - if (!cleaned) { - co_return std::unexpected(cleaned.error()); - } - auto created = co_await m_db.createFolder(parentId, *cleaned); - if (!created) { - co_return std::unexpected(created.error()); - } - if (auto committed = co_await commit(); !committed) { - co_return std::unexpected(committed.error()); - } - co_return *created; -} - -QCoro::Task> OrganizerService::createFolderFrom(QString name, QStringList packageIds, - qint64 parentId) { - auto cleaned = validateNewFolder(name, parentId); - if (!cleaned) { - co_return std::unexpected(cleaned.error()); - } - auto created = co_await m_db.createFolderWith(parentId, *cleaned, cleanPackageIds(packageIds)); - if (!created) { - co_return std::unexpected(created.error()); - } - if (auto committed = co_await commit(); !committed) { - co_return std::unexpected(committed.error()); - } - co_return *created; -} - -QCoro::Task> OrganizerService::renameFolder(qint64 folderId, QString name) { - auto existing = findNonRoot(folderId); - if (!existing) { - co_return std::unexpected(existing.error()); - } - auto cleaned = cleanName(name, QStringLiteral("Folder")); - if (!cleaned) { - co_return std::unexpected(cleaned.error()); - } - if (*cleaned == existing->name) { - co_return {}; - } - const qint64 parentId = existing->parentId.value_or(kRootId); - if (auto unique = checkUniqueFolderName(parentId, *cleaned, folderId); !unique) { - co_return std::unexpected(unique.error()); - } - if (auto renamed = co_await m_db.renameFolder(folderId, *cleaned); !renamed) { - co_return std::unexpected(renamed.error()); - } - co_return co_await commit(); -} - -QCoro::Task> OrganizerService::moveFolder(qint64 folderId, qint64 parentId) { - auto folder = findNonRoot(folderId); - if (!folder) { - co_return std::unexpected(folder.error()); - } - if (auto target = findFolder(parentId); !target) { - co_return std::unexpected(target.error()); - } - if (folder->parentId == parentId) { - co_return {}; - } - if (subtreeIds(folderId).contains(parentId)) { - co_return std::unexpected(Error{QStringLiteral("A folder cannot be moved into itself.")}); - } - // The whole subtree moves, so its height counts against the depth limit. - if (auto fits = checkDepth(parentId, height(folderId)); !fits) { - co_return std::unexpected(fits.error()); - } - if (auto unique = checkUniqueFolderName(parentId, folder->name, folderId); !unique) { - co_return std::unexpected(unique.error()); - } - if (auto moved = co_await m_db.setParent(folderId, parentId); !moved) { - co_return std::unexpected(moved.error()); - } - co_return co_await commit(); -} - -QCoro::Task> OrganizerService::setCollapsed(qint64 folderId, bool collapsed) { - auto folder = findFolder(folderId); - if (!folder) { - co_return std::unexpected(folder.error()); - } - if (folder->collapsed == collapsed) { - co_return {}; - } - if (auto updated = co_await m_db.setCollapsed(folderId, collapsed); !updated) { - co_return std::unexpected(updated.error()); - } - co_return co_await commit(); +QCoro::Task> OrganizerService::createFolder(QString name, + qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createFolder(parentId, *cleaned); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::createFolderFrom( + QString name, QStringList packageIds, qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createFolderWith(parentId, *cleaned, + cleanPackageIds(packageIds)); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::renameFolder(qint64 folderId, + QString name) { + auto existing = findNonRoot(folderId); + if (!existing) { + co_return std::unexpected(existing.error()); + } + auto cleaned = cleanName(name, QStringLiteral("Folder")); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (*cleaned == existing->name) { + co_return {}; + } + const qint64 parentId = existing->parentId.value_or(kRootId); + if (auto unique = checkUniqueFolderName(parentId, *cleaned, folderId); + !unique) { + co_return std::unexpected(unique.error()); + } + if (auto renamed = co_await m_db.renameFolder(folderId, *cleaned); !renamed) { + co_return std::unexpected(renamed.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::moveFolder(qint64 folderId, + qint64 parentId) { + auto folder = findNonRoot(folderId); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (auto target = findFolder(parentId); !target) { + co_return std::unexpected(target.error()); + } + if (folder->parentId == parentId) { + co_return {}; + } + if (subtreeIds(folderId).contains(parentId)) { + co_return std::unexpected( + Error{QStringLiteral("A folder cannot be moved into itself.")}); + } + // The whole subtree moves, so its height counts against the depth limit. + if (auto fits = checkDepth(parentId, height(folderId)); !fits) { + co_return std::unexpected(fits.error()); + } + if (auto unique = checkUniqueFolderName(parentId, folder->name, folderId); + !unique) { + co_return std::unexpected(unique.error()); + } + if (auto moved = co_await m_db.setParent(folderId, parentId); !moved) { + co_return std::unexpected(moved.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::setCollapsed(qint64 folderId, + bool collapsed) { + auto folder = findFolder(folderId); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (folder->collapsed == collapsed) { + co_return {}; + } + if (auto updated = co_await m_db.setCollapsed(folderId, collapsed); + !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); } QCoro::Task> OrganizerService::setAllCollapsed(bool collapsed) { - const bool unchanged = std::all_of(state().folders.cbegin(), state().folders.cend(), - [collapsed](const Folder& folder) { return folder.collapsed == collapsed; }); - if (unchanged) { - co_return {}; - } - if (auto updated = co_await m_db.setAllCollapsed(collapsed); !updated) { - co_return std::unexpected(updated.error()); - } - co_return co_await commit(); + const bool unchanged = + std::all_of(state().folders.cbegin(), state().folders.cend(), + [collapsed](const Folder& folder) { + return folder.collapsed == collapsed; + }); + if (unchanged) { + co_return {}; + } + if (auto updated = co_await m_db.setAllCollapsed(collapsed); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); } QCoro::Task> OrganizerService::deleteFolder(qint64 folderId) { - if (auto folder = findNonRoot(folderId); !folder) { - co_return std::unexpected(folder.error()); - } - if (auto deleted = co_await m_db.deleteFolder(folderId); !deleted) { - co_return std::unexpected(deleted.error()); - } - co_return co_await commit(); + if (auto folder = findNonRoot(folderId); !folder) { + co_return std::unexpected(folder.error()); + } + if (auto deleted = co_await m_db.deleteFolder(folderId); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); } // ── Placement ──────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::place(QStringList packageIds, qint64 folderId) { - if (auto folder = findFolder(folderId); !folder) { - co_return std::unexpected(folder.error()); - } - const QStringList pids = cleanPackageIds(packageIds); - if (pids.isEmpty()) { - co_return {}; - } - if (auto placed = co_await m_db.place(pids, folderId); !placed) { - co_return std::unexpected(placed.error()); - } - co_return co_await commit(); +QCoro::Task> OrganizerService::place(QStringList packageIds, + qint64 folderId) { + if (auto folder = findFolder(folderId); !folder) { + co_return std::unexpected(folder.error()); + } + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + if (auto placed = co_await m_db.place(pids, folderId); !placed) { + co_return std::unexpected(placed.error()); + } + co_return co_await commit(); } QCoro::Task> OrganizerService::ungroup(QStringList packageIds) { - co_return co_await place(std::move(packageIds), kRootId); + co_return co_await place(std::move(packageIds), kRootId); } -QCoro::Task> OrganizerService::resetToRules(QStringList packageIds) { - const QStringList pids = cleanPackageIds(packageIds); - if (pids.isEmpty()) { - co_return {}; - } - if (auto unplaced = co_await m_db.unplace(pids); !unplaced) { - co_return std::unexpected(unplaced.error()); - } - co_return co_await commit(); +QCoro::Task> OrganizerService::resetToRules( + QStringList packageIds) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + if (auto unplaced = co_await m_db.unplace(pids); !unplaced) { + co_return std::unexpected(unplaced.error()); + } + co_return co_await commit(); } // ── Tags ───────────────────────────────────────────────────────────────────── -QCoro::Task> OrganizerService::createTag(QString name, QString color) { - auto cleaned = checkTag(name, color); - if (!cleaned) { - co_return std::unexpected(cleaned.error()); - } - auto created = co_await m_db.createTag(*cleaned, color); - if (!created) { - co_return std::unexpected(created.error()); - } - if (auto committed = co_await commit(); !committed) { - co_return std::unexpected(committed.error()); - } - co_return *created; -} - -QCoro::Task> OrganizerService::updateTag(qint64 tagId, QString name, QString color) { - if (auto tag = findTag(tagId); !tag) { - co_return std::unexpected(tag.error()); - } - auto cleaned = checkTag(name, color, tagId); - if (!cleaned) { - co_return std::unexpected(cleaned.error()); - } - if (auto updated = co_await m_db.updateTag(tagId, *cleaned, color); !updated) { - co_return std::unexpected(updated.error()); - } - co_return co_await commit(); +QCoro::Task> OrganizerService::createTag(QString name, + QString color) { + auto cleaned = checkTag(name, color); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createTag(*cleaned, color); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::updateTag(qint64 tagId, + QString name, + QString color) { + if (auto tag = findTag(tagId); !tag) { + co_return std::unexpected(tag.error()); + } + auto cleaned = checkTag(name, color, tagId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (auto updated = co_await m_db.updateTag(tagId, *cleaned, color); + !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); } QCoro::Task> OrganizerService::deleteTag(qint64 tagId) { + if (auto tag = findTag(tagId); !tag) { + co_return std::unexpected(tag.error()); + } + if (auto deleted = co_await m_db.deleteTag(tagId); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::setModTags(QStringList packageIds, + QSet add, + QSet remove) { + const QStringList pids = cleanPackageIds(packageIds); + if (add.intersects(remove)) { + co_return std::unexpected( + Error{QStringLiteral("A tag cannot be added and removed at once.")}); + } + for (qint64 tagId : add | remove) { if (auto tag = findTag(tagId); !tag) { - co_return std::unexpected(tag.error()); - } - if (auto deleted = co_await m_db.deleteTag(tagId); !deleted) { - co_return std::unexpected(deleted.error()); + co_return std::unexpected(tag.error()); } - co_return co_await commit(); -} - -QCoro::Task> OrganizerService::setModTags(QStringList packageIds, QSet add, - QSet remove) { - const QStringList pids = cleanPackageIds(packageIds); - if (add.intersects(remove)) { - co_return std::unexpected(Error{QStringLiteral("A tag cannot be added and removed at once.")}); - } - for (qint64 tagId : add | remove) { - if (auto tag = findTag(tagId); !tag) { - co_return std::unexpected(tag.error()); - } - } - if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { - co_return {}; - } - if (auto updated = co_await m_db.setModTags(pids, add, remove); !updated) { - co_return std::unexpected(updated.error()); - } - co_return co_await commit(); + } + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + if (auto updated = co_await m_db.setModTags(pids, add, remove); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); } // ── Rules ──────────────────────────────────────────────────────────────────── QCoro::Task> OrganizerService::setRules(QList specs) { - QList cleaned; - cleaned.reserve(specs.size()); - for (RuleSpec spec : specs) { - spec.pattern = spec.pattern.trimmed(); - if (spec.pattern.isEmpty()) { - co_return std::unexpected(Error{QStringLiteral("Rule pattern cannot be empty.")}); - } - if (spec.folderId == kRootId) { - co_return std::unexpected(Error{QStringLiteral("Rules must target a folder, not the root.")}); - } - if (auto target = findFolder(spec.folderId); !target) { - co_return std::unexpected(target.error()); - } - cleaned.append(std::move(spec)); + QList cleaned; + cleaned.reserve(specs.size()); + for (RuleSpec spec : specs) { + spec.pattern = spec.pattern.trimmed(); + if (spec.pattern.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("Rule pattern cannot be empty.")}); } - if (auto replaced = co_await m_db.replaceRules(cleaned); !replaced) { - co_return std::unexpected(replaced.error()); + if (spec.folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("Rules must target a folder, not the root.")}); } - co_return co_await commit(); + if (auto target = findFolder(spec.folderId); !target) { + co_return std::unexpected(target.error()); + } + cleaned.append(std::move(spec)); + } + if (auto replaced = co_await m_db.replaceRules(cleaned); !replaced) { + co_return std::unexpected(replaced.error()); + } + co_return co_await commit(); } QCoro::Task> OrganizerService::addStandardRules() { - // (field, op, casefolded pattern) identifies a rule regardless of folder or position. - using RuleKey = std::tuple; - std::set present; - for (const Rule& rule : state().rules) { - present.emplace(static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()); - } - - QSet existing; - for (const Folder& folder : children(kRootId)) { - existing.insert(folder.name.toCaseFolded()); - } - - QList rules; - QStringList newFolders; - QSet newFolderKeys; - for (const StandardRule& rule : standardRules()) { - const RuleKey key{static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()}; - if (present.contains(key)) { - continue; - } - present.insert(key); - rules.append(rule); - // The first spelling of a missing top-level folder names it, as in the legacy service. - const QString folderKey = rule.folder.toCaseFolded(); - if (!existing.contains(folderKey) && !newFolderKeys.contains(folderKey)) { - newFolderKeys.insert(folderKey); - newFolders.append(rule.folder); - } - } - if (rules.isEmpty()) { - co_return 0; - } - if (auto appended = co_await m_db.appendNamedRules(kRootId, newFolders, rules); !appended) { - co_return std::unexpected(appended.error()); - } - if (auto committed = co_await commit(); !committed) { - co_return std::unexpected(committed.error()); - } - co_return static_cast(rules.size()); + // (field, op, casefolded pattern) identifies a rule regardless of folder or + // position. + using RuleKey = std::tuple; + std::set present; + for (const Rule& rule : state().rules) { + present.emplace(static_cast(rule.field), static_cast(rule.op), + rule.pattern.toCaseFolded()); + } + + QSet existing; + for (const Folder& folder : children(kRootId)) { + existing.insert(folder.name.toCaseFolded()); + } + + QList rules; + QStringList newFolders; + QSet newFolderKeys; + for (const StandardRule& rule : standardRules()) { + const RuleKey key{static_cast(rule.field), static_cast(rule.op), + rule.pattern.toCaseFolded()}; + if (present.contains(key)) { + continue; + } + present.insert(key); + rules.append(rule); + // The first spelling of a missing top-level folder names it, as in the + // legacy service. + const QString folderKey = rule.folder.toCaseFolded(); + if (!existing.contains(folderKey) && !newFolderKeys.contains(folderKey)) { + newFolderKeys.insert(folderKey); + newFolders.append(rule.folder); + } + } + if (rules.isEmpty()) { + co_return 0; + } + if (auto appended = + co_await m_db.appendNamedRules(kRootId, newFolders, rules); + !appended) { + co_return std::unexpected(appended.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return static_cast(rules.size()); } // ── Read helpers ───────────────────────────────────────────────────────────── FolderNode OrganizerService::tree(const std::optional& query) const { - return buildTree(state(), allMods(), activeSet(), query); -} - -Result OrganizerService::folderModUuids(qint64 folderId, bool recursive) const { - if (auto folder = findFolder(folderId); !folder) { - return std::unexpected(folder.error()); - } - const FolderNode root = tree(); - for (const FolderNode* node : root.walk()) { - if (node->folder.id != folderId) { - continue; - } - const QList leaves = recursive ? node->allMods() : node->mods; - QStringList uuids; - for (const ModLeaf& leaf : leaves) { - uuids.append(leaf.uuid); - } - return uuids; - } - return QStringList{}; -} - -Result OrganizerService::folderToggle(qint64 folderId, bool recursive) const { - if (auto folder = findFolder(folderId); !folder) { - return std::unexpected(folder.error()); - } - const FolderNode root = tree(); - for (const FolderNode* node : root.walk()) { - if (node->folder.id != folderId) { - continue; - } - const QList leaves = recursive ? node->allMods() : node->mods; - QStringList uuids; - for (const ModLeaf& leaf : leaves) { - uuids.append(leaf.uuid); - } - const CheckState check = recursive ? node->check : node->ownCheck(); - if (check == CheckState::kOn) { - return FolderToggle{QStringList{}, uuids}; - } - return FolderToggle{uuids, QStringList{}}; - } - return FolderToggle{}; -} - -Result> OrganizerService::folderMoveTargets(qint64 folderId) const { - auto folder = findNonRoot(folderId); - if (!folder) { - return std::unexpected(folder.error()); - } - const QSet subtree = subtreeIds(folderId); - const int treeHeight = height(folderId); - const QString key = folder->name.toCaseFolded(); - - QSet targets; - for (const Folder& candidate : state().folders) { - if (subtree.contains(candidate.id) || candidate.id == folder->parentId) { - continue; - } - if (depth(candidate.id) + treeHeight > kMaxDepth) { - continue; - } - const QList siblings = children(candidate.id); - const bool clash = std::any_of(siblings.cbegin(), siblings.cend(), - [&key](const Folder& child) { return child.name.toCaseFolded() == key; }); - if (!clash) { - targets.insert(candidate.id); - } - } - return targets; + return buildTree(state(), allMods(), activeSet(), query); +} + +Result OrganizerService::folderModUuids(qint64 folderId, + bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folderId) { + continue; + } + const QList leaves = recursive ? node->allMods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + return uuids; + } + return QStringList{}; +} + +Result OrganizerService::folderToggle(qint64 folderId, + bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folderId) { + continue; + } + const QList leaves = recursive ? node->allMods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + const CheckState check = recursive ? node->check : node->ownCheck(); + if (check == CheckState::kOn) { + return FolderToggle{QStringList{}, uuids}; + } + return FolderToggle{uuids, QStringList{}}; + } + return FolderToggle{}; +} + +Result> OrganizerService::folderMoveTargets( + qint64 folderId) const { + auto folder = findNonRoot(folderId); + if (!folder) { + return std::unexpected(folder.error()); + } + const QSet subtree = subtreeIds(folderId); + const int treeHeight = height(folderId); + const QString key = folder->name.toCaseFolded(); + + QSet targets; + for (const Folder& candidate : state().folders) { + if (subtree.contains(candidate.id) || candidate.id == folder->parentId) { + continue; + } + if (depth(candidate.id) + treeHeight > kMaxDepth) { + continue; + } + const QList siblings = children(candidate.id); + const bool clash = std::any_of(siblings.cbegin(), siblings.cend(), + [&key](const Folder& child) { + return child.name.toCaseFolded() == key; + }); + if (!clash) { + targets.insert(candidate.id); + } + } + return targets; } QList OrganizerService::treeFilters() const { - return organizer::treeFilters(allMods(), activeSet(), &state()); + return organizer::treeFilters(allMods(), activeSet(), &state()); } } // namespace px::modrim::organizer diff --git a/src/core/organizer/service.hpp b/src/core/organizer/service.hpp index c100cf3..751e950 100644 --- a/src/core/organizer/service.hpp +++ b/src/core/organizer/service.hpp @@ -1,13 +1,14 @@ #pragma once -#include -#include -#include -#include #include #include #include #include +#include +#include +#include + +#include #include "core/context_fwd.hpp" #include "core/error.hpp" @@ -24,95 +25,103 @@ class CoreContext; namespace px::modrim::organizer { struct FolderToggle { - QStringList enable; - QStringList disable; + QStringList enable; + QStringList disable; }; // Virtual folder tree, tags and auto-folder rules. Never touches load order. -// Reads mods and the active list from the context; each mutation persists, reloads the -// state from the database and then emits `changed`. +// Reads mods and the active list from the context; each mutation persists, +// reloads the state from the database and then emits `changed`. class OrganizerService : public Plugin { -public: - using AllModsFn = std::function&()>; - using ActiveUuidsFn = std::function; - - OrganizerService(CoreContext& ctx, OrganizerDb& db); - OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, OrganizerDb& db); - - Event changed; - - QCoro::Task init(); - QCoro::Task init(CoreContext&) override; - QCoro::Task shutdown() override; - - // Whether init() has loaded state; views built earlier must wait. - bool ready() const noexcept; - // Requires ready(): callers check before reading. - const OrganizerState& state() const; - - // ── Folders ────────────────────────────────────────────────────────────── - QCoro::Task> createFolder(QString name, qint64 parentId = kRootId); - QCoro::Task> createFolderFrom(QString name, QStringList packageIds, - qint64 parentId = kRootId); - QCoro::Task> renameFolder(qint64 folderId, QString name); - QCoro::Task> moveFolder(qint64 folderId, qint64 parentId); - QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); - QCoro::Task> setAllCollapsed(bool collapsed); - QCoro::Task> deleteFolder(qint64 folderId); - - // ── Placement ──────────────────────────────────────────────────────────── - QCoro::Task> place(QStringList packageIds, qint64 folderId); - QCoro::Task> ungroup(QStringList packageIds); - QCoro::Task> resetToRules(QStringList packageIds); - - // ── Tags ───────────────────────────────────────────────────────────────── - QCoro::Task> createTag(QString name, QString color); - QCoro::Task> updateTag(qint64 tagId, QString name, QString color); - QCoro::Task> deleteTag(qint64 tagId); - QCoro::Task> setModTags(QStringList packageIds, QSet add, QSet remove); - - // ── Rules ──────────────────────────────────────────────────────────────── - QCoro::Task> setRules(QList specs); - // Appends the built-in rules not yet present. Returns how many were added. - QCoro::Task> addStandardRules(); - - // ── Read helpers ───────────────────────────────────────────────────────── - FolderNode tree(const std::optional& query = std::nullopt) const; - // Mods under `folder_id`; `recursive = false` keeps only its direct mods. - Result folderModUuids(qint64 folderId, bool recursive = true) const; - // (enable, disable) for a folder checkbox click: a fully enabled folder disables - // everything; partial or off enables all. - Result folderToggle(qint64 folderId, bool recursive = true) const; - // Legal destinations, excluding the folder's current parent. - Result> folderMoveTargets(qint64 folderId) const; - QList treeFilters() const; - -private: - QCoro::Task> commit(); - - Result validateNewFolder(const QString& name, qint64 parentId) const; - Result findFolder(qint64 folderId) const; - Result findNonRoot(qint64 folderId) const; - Result findTag(qint64 tagId) const; - Result checkTag(const QString& name, const QString& color, - std::optional exclude = std::nullopt) const; - Result checkUniqueFolderName(qint64 parentId, const QString& name, - std::optional exclude = std::nullopt) const; - Result checkDepth(qint64 parentId, int height) const; - int depth(qint64 folderId) const; - int height(qint64 folderId) const; - QList children(qint64 folderId) const; - QSet subtreeIds(qint64 folderId) const; - - const QHash& allMods() const; - const QStringList& activeUuids() const; - QSet activeSet() const; - - CoreContext* m_ctx = nullptr; - AllModsFn m_allModsFn; - ActiveUuidsFn m_activeUuidsFn; - OrganizerDb& m_db; - std::optional m_state; + public: + using AllModsFn = std::function&()>; + using ActiveUuidsFn = std::function; + + OrganizerService(CoreContext& ctx, OrganizerDb& db); + OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, + OrganizerDb& db); + + Event changed; + + QCoro::Task init(); + QCoro::Task init(CoreContext&) override; + QCoro::Task shutdown() override; + + // Whether init() has loaded state; views built earlier must wait. + bool ready() const noexcept; + // Requires ready(): callers check before reading. + const OrganizerState& state() const; + + // ── Folders ────────────────────────────────────────────────────────────── + QCoro::Task> createFolder(QString name, + qint64 parentId = kRootId); + QCoro::Task> createFolderFrom(QString name, + QStringList packageIds, + qint64 parentId = kRootId); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> moveFolder(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); + + // ── Placement ──────────────────────────────────────────────────────────── + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> ungroup(QStringList packageIds); + QCoro::Task> resetToRules(QStringList packageIds); + + // ── Tags ───────────────────────────────────────────────────────────────── + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, + QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, + QSet remove); + + // ── Rules ──────────────────────────────────────────────────────────────── + QCoro::Task> setRules(QList specs); + // Appends the built-in rules not yet present. Returns how many were added. + QCoro::Task> addStandardRules(); + + // ── Read helpers ───────────────────────────────────────────────────────── + FolderNode tree(const std::optional& query = std::nullopt) const; + // Mods under `folder_id`; `recursive = false` keeps only its direct mods. + Result folderModUuids(qint64 folderId, + bool recursive = true) const; + // (enable, disable) for a folder checkbox click: a fully enabled folder + // disables everything; partial or off enables all. + Result folderToggle(qint64 folderId, + bool recursive = true) const; + // Legal destinations, excluding the folder's current parent. + Result> folderMoveTargets(qint64 folderId) const; + QList treeFilters() const; + + private: + QCoro::Task> commit(); + + Result validateNewFolder(const QString& name, qint64 parentId) const; + Result findFolder(qint64 folderId) const; + Result findNonRoot(qint64 folderId) const; + Result findTag(qint64 tagId) const; + Result checkTag(const QString& name, const QString& color, + std::optional exclude = std::nullopt) const; + Result checkUniqueFolderName( + qint64 parentId, const QString& name, + std::optional exclude = std::nullopt) const; + Result checkDepth(qint64 parentId, int height) const; + int depth(qint64 folderId) const; + int height(qint64 folderId) const; + QList children(qint64 folderId) const; + QSet subtreeIds(qint64 folderId) const; + + const QHash& allMods() const; + const QStringList& activeUuids() const; + QSet activeSet() const; + + CoreContext* m_ctx = nullptr; + AllModsFn m_allModsFn; + ActiveUuidsFn m_activeUuidsFn; + OrganizerDb& m_db; + std::optional m_state; }; } // namespace px::modrim::organizer diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp index 649ed0b..c76d645 100644 --- a/src/core/plugin.hpp +++ b/src/core/plugin.hpp @@ -1,14 +1,15 @@ #pragma once +#include +#include +#include #include #include #include #include #include -#include -#include -#include + #include #include "core/context_fwd.hpp" @@ -18,136 +19,139 @@ namespace px::modrim { // `setup` runs synchronously in dependency order; `init` is awaited right after -// setup, for I/O or async start-up. `shutdown` runs in reverse order. Templated over -// the context type so the UI layer can register plugins against its own context. +// setup, for I/O or async start-up. `shutdown` runs in reverse order. Templated +// over the context type so the UI layer can register plugins against its own +// context. template class BasicPlugin { -public: - BasicPlugin(QString name, QStringList dependencies = {}) - : m_name(std::move(name)), m_dependencies(std::move(dependencies)) {} - virtual ~BasicPlugin() = default; + public: + BasicPlugin(QString name, QStringList dependencies = {}) + : m_name(std::move(name)), m_dependencies(std::move(dependencies)) {} + virtual ~BasicPlugin() = default; - BasicPlugin(const BasicPlugin&) = delete; - BasicPlugin& operator=(const BasicPlugin&) = delete; + BasicPlugin(const BasicPlugin&) = delete; + BasicPlugin& operator=(const BasicPlugin&) = delete; - const QString& name() const noexcept { return m_name; } - const QStringList& dependencies() const noexcept { return m_dependencies; } + const QString& name() const noexcept { return m_name; } + const QStringList& dependencies() const noexcept { return m_dependencies; } - virtual void setup(Ctx&) {} - virtual QCoro::Task init(Ctx&) { co_return; } - virtual QCoro::Task shutdown() { co_return; } + virtual void setup(Ctx&) {} + virtual QCoro::Task init(Ctx&) { co_return; } + virtual QCoro::Task shutdown() { co_return; } -private: - QString m_name; - QStringList m_dependencies; + private: + QString m_name; + QStringList m_dependencies; }; template class BasicPluginRegistry { -public: - using Plugin = BasicPlugin; - - // Replaces a plugin registered under the same name, keeping its position. - void registerPlugin(std::unique_ptr plugin) { - spdlog::debug("plugin registered: {}", plugin->name().toStdString()); - for (auto& existing : m_plugins) { - if (existing->name() == plugin->name()) { - existing = std::move(plugin); - return; - } - } - m_plugins.push_back(std::move(plugin)); + public: + using Plugin = BasicPlugin; + + // Replaces a plugin registered under the same name, keeping its position. + void registerPlugin(std::unique_ptr plugin) { + spdlog::debug("plugin registered: {}", plugin->name().toStdString()); + for (auto& existing : m_plugins) { + if (existing->name() == plugin->name()) { + existing = std::move(plugin); + return; + } } - - Plugin* get(const QString& name) const { - for (const auto& plugin : m_plugins) { - if (plugin->name() == name) { - return plugin.get(); - } - } - return nullptr; + m_plugins.push_back(std::move(plugin)); + } + + Plugin* get(const QString& name) const { + for (const auto& plugin : m_plugins) { + if (plugin->name() == name) { + return plugin.get(); + } } + return nullptr; + } - QSet names() const { - QSet result; - for (const auto& plugin : m_plugins) { - result.insert(plugin->name()); - } - return result; + QSet names() const { + QSet result; + for (const auto& plugin : m_plugins) { + result.insert(plugin->name()); } - - // `extra_deps` are dependencies satisfied outside the registry. - Result setupAll(Ctx& ctx, const QSet& extraDeps = {}) { - for (const auto& plugin : m_plugins) { - for (const auto& dep : plugin->dependencies()) { - if (!get(dep) && !extraDeps.contains(dep)) { - return std::unexpected(Error{QStringLiteral("Plugin '%1' depends on '%2' which is not registered") - .arg(plugin->name(), dep)}); - } - } - } - - spdlog::info("setting up {} plugins...", m_plugins.size()); - auto order = dependencyOrder(); - if (!order) { - return std::unexpected(order.error()); - } - for (Plugin* plugin : *order) { - plugin->setup(ctx); + return result; + } + + // `extra_deps` are dependencies satisfied outside the registry. + Result setupAll(Ctx& ctx, const QSet& extraDeps = {}) { + for (const auto& plugin : m_plugins) { + for (const auto& dep : plugin->dependencies()) { + if (!get(dep) && !extraDeps.contains(dep)) { + return std::unexpected( + Error{QStringLiteral( + "Plugin '%1' depends on '%2' which is not registered") + .arg(plugin->name(), dep)}); } - spdlog::info("all plugins set up"); - return {}; + } } - QCoro::Task> initAll(Ctx& ctx) { - spdlog::info("initializing {} plugins...", m_plugins.size()); - auto order = dependencyOrder(); - if (!order) { - co_return std::unexpected(order.error()); - } - for (Plugin* plugin : *order) { - co_await plugin->init(ctx); - } - spdlog::info("all plugins initialized"); - co_return {}; + spdlog::info("setting up {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + return std::unexpected(order.error()); } - - QCoro::Task> shutdownAll() { - spdlog::info("shutting down {} plugins...", m_plugins.size()); - auto order = dependencyOrder(); - if (!order) { - co_return std::unexpected(order.error()); - } - for (auto it = order->rbegin(); it != order->rend(); ++it) { - co_await (*it)->shutdown(); - } - spdlog::info("all plugins shut down"); - co_return {}; + for (Plugin* plugin : *order) { + plugin->setup(ctx); + } + spdlog::info("all plugins set up"); + return {}; + } + + QCoro::Task> initAll(Ctx& ctx) { + spdlog::info("initializing {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (Plugin* plugin : *order) { + co_await plugin->init(ctx); + } + spdlog::info("all plugins initialized"); + co_return {}; + } + + QCoro::Task> shutdownAll() { + spdlog::info("shutting down {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (auto it = order->rbegin(); it != order->rend(); ++it) { + co_await (*it)->shutdown(); + } + spdlog::info("all plugins shut down"); + co_return {}; + } + + private: + Result> dependencyOrder() const { + std::map> graph; + for (const auto& plugin : m_plugins) { + const auto& deps = plugin->dependencies(); + graph.emplace(plugin->name(), QSet(deps.cbegin(), deps.cend())); } -private: - Result> dependencyOrder() const { - std::map> graph; - for (const auto& plugin : m_plugins) { - const auto& deps = plugin->dependencies(); - graph.emplace(plugin->name(), QSet(deps.cbegin(), deps.cend())); - } - - auto levels = toposortLevels(graph); - if (!levels) { - return std::unexpected(levels.error()); - } - std::vector order; - order.reserve(m_plugins.size()); - for (const auto& level : *levels) { - for (const auto& name : level) { - order.push_back(get(name)); - } - } - return order; + auto levels = toposortLevels(graph); + if (!levels) { + return std::unexpected(levels.error()); + } + std::vector order; + order.reserve(m_plugins.size()); + for (const auto& level : *levels) { + for (const auto& name : level) { + order.push_back(get(name)); + } } + return order; + } - std::vector> m_plugins; + std::vector> m_plugins; }; using Plugin = BasicPlugin; diff --git a/src/core/plugin_config.hpp b/src/core/plugin_config.hpp index 749f1d5..b531f30 100644 --- a/src/core/plugin_config.hpp +++ b/src/core/plugin_config.hpp @@ -1,13 +1,13 @@ #pragma once +#include +#include +#include #include #include #include #include -#include -#include -#include #include #include "core/config.hpp" @@ -19,92 +19,97 @@ namespace px::modrim { // the old shared config.json to fields of T; they seed the file the first time // it is created. Without a service the settings live in memory only. template - requires std::equality_comparable && std::default_initializable + requires std::equality_comparable && std::default_initializable class PluginConfig { -public: - PluginConfig(ConfigService* service, const QString& name, const QHash& legacy = {}); + public: + PluginConfig(ConfigService* service, const QString& name, + const QHash& legacy = {}); - const T& value() const noexcept { return m_value; } + const T& value() const noexcept { return m_value; } - void update(T value); + void update(T value); - Event changed; + Event changed; -private: - T seed(const QHash& legacy) const; - void store(const T& value) const; + private: + T seed(const QHash& legacy) const; + void store(const T& value) const; - ConfigService* m_service; - QString m_filename; - T m_value{}; + ConfigService* m_service; + QString m_filename; + T m_value{}; }; template - requires std::equality_comparable && std::default_initializable + requires std::equality_comparable && std::default_initializable PluginConfig::PluginConfig(ConfigService* service, const QString& name, const QHash& legacy) - : m_service(service), m_filename(QStringLiteral("plugins/%1.json").arg(name)) { - std::optional stored; - if (m_service) { - // plugins/*.json is unversioned, so loading cannot fail with a migration error. - auto loaded = m_service->loadExisting(m_filename); - Q_ASSERT(loaded.has_value()); - stored = loaded.value_or(std::nullopt); - } - m_value = stored ? std::move(*stored) : seed(legacy); + : m_service(service), + m_filename(QStringLiteral("plugins/%1.json").arg(name)) { + std::optional stored; + if (m_service) { + // plugins/*.json is unversioned, so loading cannot fail with a migration + // error. + auto loaded = m_service->loadExisting(m_filename); + Q_ASSERT(loaded.has_value()); + stored = loaded.value_or(std::nullopt); + } + m_value = stored ? std::move(*stored) : seed(legacy); } template - requires std::equality_comparable && std::default_initializable + requires std::equality_comparable && std::default_initializable T PluginConfig::seed(const QHash& legacy) const { - if (!m_service || legacy.isEmpty()) { - return T{}; - } - const auto raw = m_service->loadRaw("config.json"); - glz::json_t::object_t fields; - for (auto it = legacy.cbegin(); it != legacy.cend(); ++it) { - if (auto found = raw.find(it.key().toStdString()); found != raw.end()) { - fields[it.value().toStdString()] = found->second; - } + if (!m_service || legacy.isEmpty()) { + return T{}; + } + const auto raw = m_service->loadRaw("config.json"); + glz::json_t::object_t fields; + for (auto it = legacy.cbegin(); it != legacy.cend(); ++it) { + if (auto found = raw.find(it.key().toStdString()); found != raw.end()) { + fields[it.value().toStdString()] = found->second; } - - const auto text = glz::write_json(fields); - if (!text) { - spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", m_filename.toStdString()); - return T{}; - } - T value{}; - if (auto error = glz::read(value, *text)) { - spdlog::warn("Ignoring old settings for {}: {}", m_filename.toStdString(), - glz::format_error(error, *text)); - return T{}; - } - if (!fields.empty()) { - store(value); - } - return value; + } + + const auto text = glz::write_json(fields); + if (!text) { + spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", + m_filename.toStdString()); + return T{}; + } + T value{}; + if (auto error = glz::read(value, *text)) { + spdlog::warn("Ignoring old settings for {}: {}", m_filename.toStdString(), + glz::format_error(error, *text)); + return T{}; + } + if (!fields.empty()) { + store(value); + } + return value; } template - requires std::equality_comparable && std::default_initializable + requires std::equality_comparable && std::default_initializable void PluginConfig::update(T value) { - if (value == m_value) { - return; - } - m_value = std::move(value); - store(m_value); - changed.notify(m_value); + if (value == m_value) { + return; + } + m_value = std::move(value); + store(m_value); + changed.notify(m_value); } template - requires std::equality_comparable && std::default_initializable + requires std::equality_comparable && std::default_initializable void PluginConfig::store(const T& value) const { - if (!m_service) { - return; - } - if (auto saved = m_service->save(m_filename, value); !saved) { - spdlog::warn("Cannot save {}: {}", m_filename.toStdString(), saved.error().message.toStdString()); - } + if (!m_service) { + return; + } + if (auto saved = m_service->save(m_filename, value); !saved) { + spdlog::warn("Cannot save {}: {}", m_filename.toStdString(), + saved.error().message.toStdString()); + } } } // namespace px::modrim diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp index 6a7c2ad..3e7acc0 100644 --- a/src/core/profiler.cpp +++ b/src/core/profiler.cpp @@ -7,52 +7,54 @@ namespace px::modrim { namespace { void renderNode(const Profiler::Node& node, int depth, QString& out) { - using namespace std::chrono; - const double ms = duration(node.elapsed).count(); - out += QString(depth * 2, QChar(' ')) + node.name + QStringLiteral(": ") + - QString::number(ms, 'f', 3) + QStringLiteral(" ms\n"); - for (const auto& child : node.children) { - renderNode(*child, depth + 1, out); - } + using namespace std::chrono; + const double ms = duration(node.elapsed).count(); + out += QString(depth * 2, QChar(' ')) + node.name + QStringLiteral(": ") + + QString::number(ms, 'f', 3) + QStringLiteral(" ms\n"); + for (const auto& child : node.children) { + renderNode(*child, depth + 1, out); + } } } // namespace Profiler::Scope::Scope(Profiler& owner, QAnyStringView name) - : m_owner(owner), m_parent(owner.m_current), m_start(std::chrono::steady_clock::now()) { - auto node = std::make_unique(name.toString()); - m_node = node.get(); - m_parent->children.push_back(std::move(node)); - m_owner.m_current = m_node; + : m_owner(owner), + m_parent(owner.m_current), + m_start(std::chrono::steady_clock::now()) { + auto node = std::make_unique(name.toString()); + m_node = node.get(); + m_parent->children.push_back(std::move(node)); + m_owner.m_current = m_node; } Profiler::Scope::~Scope() { - m_node->elapsed = std::chrono::steady_clock::now() - m_start; - m_owner.m_current = m_parent; + m_node->elapsed = std::chrono::steady_clock::now() - m_start; + m_owner.m_current = m_parent; } void Profiler::adopt(Profiler& child) { - for (auto& node : child.m_root.children) { - m_current->children.push_back(std::move(node)); - } - child.m_root.children.clear(); + for (auto& node : child.m_root.children) { + m_current->children.push_back(std::move(node)); + } + child.m_root.children.clear(); } QString Profiler::render() const { - QString out; - for (const auto& child : m_root.children) { - renderNode(*child, 0, out); - } - return out; + QString out; + for (const auto& child : m_root.children) { + renderNode(*child, 0, out); + } + return out; } ProfileSession::ProfileSession(QAnyStringView label) : m_scope(std::make_unique(m_timer, label)) {} ProfileSession::~ProfileSession() { - // Close the top-level scope before rendering so its elapsed time is included. - m_scope.reset(); - spdlog::debug("Profile report:\n{}", m_timer.render().toStdString()); + // Close the top-level scope before rendering so its elapsed time is included. + m_scope.reset(); + spdlog::debug("Profile report:\n{}", m_timer.render().toStdString()); } } // namespace px::modrim diff --git a/src/core/profiler.hpp b/src/core/profiler.hpp index 8764190..c61cea9 100644 --- a/src/core/profiler.hpp +++ b/src/core/profiler.hpp @@ -1,70 +1,72 @@ #pragma once +#include +#include #include #include #include -#include -#include - namespace px::modrim { -// Hierarchical wall-clock timer. Scopes nest according to their lifetimes; `render()` prints -// the resulting tree. +// Hierarchical wall-clock timer. Scopes nest according to their lifetimes; +// `render()` prints the resulting tree. class Profiler { -public: - struct Node { - explicit Node(QString nodeName) : name(std::move(nodeName)) {} - - QString name; - std::chrono::steady_clock::duration elapsed{}; - std::vector> children; - }; - - class Scope { - public: - Scope(Profiler& owner, QAnyStringView name); - ~Scope(); - Scope(const Scope&) = delete; - Scope& operator=(const Scope&) = delete; - Scope(Scope&&) = delete; - Scope& operator=(Scope&&) = delete; - - private: - Profiler& m_owner; - Node* m_node; - Node* m_parent; - std::chrono::steady_clock::time_point m_start; - }; - - Profiler() = default; - - [[nodiscard]] Scope operator()(QAnyStringView name) { return Scope(*this, name); } - - // Moves the root timings of `child` under the currently open scope of this profiler, so a - // sub-profile collected elsewhere shows up in this report. `child` is left empty. - void adopt(Profiler& child); - - [[nodiscard]] QString render() const; - -private: - Node m_root{QStringLiteral("")}; - Node* m_current = &m_root; + public: + struct Node { + explicit Node(QString nodeName) : name(std::move(nodeName)) {} + + QString name; + std::chrono::steady_clock::duration elapsed{}; + std::vector> children; + }; + + class Scope { + public: + Scope(Profiler& owner, QAnyStringView name); + ~Scope(); + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; + + private: + Profiler& m_owner; + Node* m_node; + Node* m_parent; + std::chrono::steady_clock::time_point m_start; + }; + + Profiler() = default; + + [[nodiscard]] Scope operator()(QAnyStringView name) { + return Scope(*this, name); + } + + // Moves the root timings of `child` under the currently open scope of this + // profiler, so a sub-profile collected elsewhere shows up in this report. + // `child` is left empty. + void adopt(Profiler& child); + + [[nodiscard]] QString render() const; + + private: + Node m_root{QStringLiteral("")}; + Node* m_current = &m_root; }; // Logs the hierarchical report of a block at debug level when it ends. class ProfileSession { -public: - explicit ProfileSession(QAnyStringView label); - ~ProfileSession(); - ProfileSession(const ProfileSession&) = delete; - ProfileSession& operator=(const ProfileSession&) = delete; + public: + explicit ProfileSession(QAnyStringView label); + ~ProfileSession(); + ProfileSession(const ProfileSession&) = delete; + ProfileSession& operator=(const ProfileSession&) = delete; - [[nodiscard]] Profiler& timer() { return m_timer; } + [[nodiscard]] Profiler& timer() { return m_timer; } -private: - Profiler m_timer; - std::unique_ptr m_scope; + private: + Profiler m_timer; + std::unique_ptr m_scope; }; } // namespace px::modrim diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp index 9c4c21a..ec35523 100644 --- a/src/core/providers/base.cpp +++ b/src/core/providers/base.cpp @@ -2,6 +2,7 @@ #include #include + #include #include "core/models/metadata/parsing.hpp" @@ -12,151 +13,157 @@ namespace px::modrim::providers { namespace { QList statCandidates(const QList& dirs) { - QList candidates; - candidates.reserve(dirs.size()); - for (const ModDirectory& dir : dirs) { - const QFileInfo info(dir.aboutXmlPath); - if (!info.exists()) { - continue; - } - candidates.append(ModCandidate{dir.modPath, dir.aboutXmlPath, - info.lastModified().toMSecsSinceEpoch() / 1000.0, - info.size()}); + QList candidates; + candidates.reserve(dirs.size()); + for (const ModDirectory& dir : dirs) { + const QFileInfo info(dir.aboutXmlPath); + if (!info.exists()) { + continue; } - return candidates; + candidates.append(ModCandidate{ + dir.modPath, dir.aboutXmlPath, + info.lastModified().toMSecsSinceEpoch() / 1000.0, info.size()}); + } + return candidates; } -Result parseOne(const ModCandidate& candidate, const QString& targetVersion) { - auto mod = createListedModFromPath(candidate.modPath, targetVersion, true, - candidate.aboutXmlPath); - if (!mod) { - return std::unexpected(mod.error()); - } - return ParsedMod{std::move(*mod), candidate}; +Result parseOne(const ModCandidate& candidate, + const QString& targetVersion) { + auto mod = createListedModFromPath(candidate.modPath, targetVersion, true, + candidate.aboutXmlPath); + if (!mod) { + return std::unexpected(mod.error()); + } + return ParsedMod{std::move(*mod), candidate}; } } // namespace QList scanModDirectory(const QString& modsPath) { - const QFileInfo root(modsPath); - if (!root.exists() || !root.isDir()) { - spdlog::warn("Mod directory not found: {}", modsPath.toStdString()); - return {}; - } - - QList found; - const QDir dir(modsPath); - const QFileInfoList entries = - dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot); - for (const QFileInfo& entry : entries) { - const QString modPath = dir.filePath(entry.fileName()); - if (const auto aboutXml = findAboutXml(modPath)) { - found.append(ModDirectory{modPath, *aboutXml}); - } + const QFileInfo root(modsPath); + if (!root.exists() || !root.isDir()) { + spdlog::warn("Mod directory not found: {}", modsPath.toStdString()); + return {}; + } + + QList found; + const QDir dir(modsPath); + const QFileInfoList entries = + dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + const QString modPath = dir.filePath(entry.fileName()); + if (const auto aboutXml = findAboutXml(modPath)) { + found.append(ModDirectory{modPath, *aboutXml}); } - return found; + } + return found; } -BaseModProvider::BaseModProvider(QString providerId, QString color, QString path, - QThreadPool* pool, MetadataCacheStore* metadataCache) +BaseModProvider::BaseModProvider(QString providerId, QString color, + QString path, QThreadPool* pool, + MetadataCacheStore* metadataCache) : m_providerId(std::move(providerId)), m_color(std::move(color)), m_path(std::move(path)), m_pool(pool), m_metadataCache(metadataCache) {} -QCoro::Task> BaseModProvider::loadMods(QList dirs, - QString targetVersion, Profiler& timer, - MetadataCacheStore* metadataCache, - bool forceReparse) { - MetadataCacheStore* cache = metadataCache ? metadataCache : m_metadataCache; - - const QList candidates = co_await runOnPool>( - nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); - - ModMap result; - QList missing; - if (forceReparse) { - missing = candidates; - } else if (cache) { - auto scope = timer("cache_lookup"); - CacheLookup lookup = co_await cache->getMods(candidates, targetVersion); - for (ListedModPtr& mod : lookup.cached) { - mod->providerId = m_providerId; - result.insert(mod->uuid(), mod); - } - missing = std::move(lookup.missing); - } else { - missing = candidates; +QCoro::Task> BaseModProvider::loadMods( + QList dirs, QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, bool forceReparse) { + MetadataCacheStore* cache = metadataCache ? metadataCache : m_metadataCache; + + const QList candidates = + co_await runOnPool>( + nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); + + ModMap result; + QList missing; + if (forceReparse) { + missing = candidates; + } else if (cache) { + auto scope = timer("cache_lookup"); + CacheLookup lookup = co_await cache->getMods(candidates, targetVersion); + for (ListedModPtr& mod : lookup.cached) { + mod->providerId = m_providerId; + result.insert(mod->uuid(), mod); } - - if (!missing.isEmpty()) { - auto scope = timer("parse_xml"); - Result> parsed = co_await parseCandidates(missing, targetVersion); - if (!parsed) { - co_return std::unexpected(parsed.error()); - } - if (cache) { - QList cacheItems; - for (const ParsedMod& item : *parsed) { - if (auto about = std::dynamic_pointer_cast(item.mod)) { - cacheItems.append(CachedMod{std::move(about), item.candidate}); - } - } - if (!cacheItems.isEmpty()) { - co_await cache->putMods(std::move(cacheItems), targetVersion); - } - } - for (ParsedMod& item : *parsed) { - item.mod->providerId = m_providerId; - result.insert(item.mod->uuid(), item.mod); + missing = std::move(lookup.missing); + } else { + missing = candidates; + } + + if (!missing.isEmpty()) { + auto scope = timer("parse_xml"); + Result> parsed = + co_await parseCandidates(missing, targetVersion); + if (!parsed) { + co_return std::unexpected(parsed.error()); + } + if (cache) { + QList cacheItems; + for (const ParsedMod& item : *parsed) { + if (auto about = std::dynamic_pointer_cast(item.mod)) { + cacheItems.append(CachedMod{std::move(about), item.candidate}); } + } + if (!cacheItems.isEmpty()) { + co_await cache->putMods(std::move(cacheItems), targetVersion); + } + } + for (ParsedMod& item : *parsed) { + item.mod->providerId = m_providerId; + result.insert(item.mod->uuid(), item.mod); } + } - // Warm the disk-backed published-file-id cache off the calling thread. - const QList mods = result.values(); - co_await runOnPool(nullptr, [mods] { - for (const ListedModPtr& mod : mods) { - mod->publishedFileId(); - } - }); - co_return result; + // Warm the disk-backed published-file-id cache off the calling thread. + const QList mods = result.values(); + co_await runOnPool(nullptr, [mods] { + for (const ListedModPtr& mod : mods) { + mod->publishedFileId(); + } + }); + co_return result; } QCoro::Task>> BaseModProvider::parseCandidates( QList missing, QString targetVersion) { - if (m_pool == nullptr || missing.size() <= 1) { - co_return co_await runOnPool>>( - nullptr, [missing = std::move(missing), targetVersion = std::move(targetVersion)] { - QList parsed; - parsed.reserve(missing.size()); - for (const ModCandidate& candidate : missing) { - Result item = parseOne(candidate, targetVersion); - if (!item) { - return Result>(std::unexpected(item.error())); - } - parsed.append(std::move(*item)); - } - return Result>(std::move(parsed)); - }); - } - - QList>> pending; - pending.reserve(missing.size()); - for (const ModCandidate& candidate : missing) { - pending.append(runOnPool>( - m_pool, [candidate, targetVersion] { return parseOne(candidate, targetVersion); })); - } - QList parsed; - parsed.reserve(missing.size()); - for (QFuture>& future : pending) { - Result item = co_await future; - if (!item) { - co_return std::unexpected(item.error()); - } - parsed.append(std::move(*item)); + if (m_pool == nullptr || missing.size() <= 1) { + co_return co_await runOnPool>>( + nullptr, [missing = std::move(missing), + targetVersion = std::move(targetVersion)] { + QList parsed; + parsed.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + Result item = parseOne(candidate, targetVersion); + if (!item) { + return Result>(std::unexpected(item.error())); + } + parsed.append(std::move(*item)); + } + return Result>(std::move(parsed)); + }); + } + + QList>> pending; + pending.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + pending.append( + runOnPool>(m_pool, [candidate, targetVersion] { + return parseOne(candidate, targetVersion); + })); + } + QList parsed; + parsed.reserve(missing.size()); + for (QFuture>& future : pending) { + Result item = co_await future; + if (!item) { + co_return std::unexpected(item.error()); } - co_return parsed; + parsed.append(std::move(*item)); + } + co_return parsed; } } // namespace px::modrim::providers diff --git a/src/core/providers/base.hpp b/src/core/providers/base.hpp index d78bc6f..844e760 100644 --- a/src/core/providers/base.hpp +++ b/src/core/providers/base.hpp @@ -1,17 +1,17 @@ #pragma once -#include -#include -#include - -#include -#include #include #include #include #include #include #include +#include +#include +#include + +#include +#include #include "core/error.hpp" #include "core/models/metadata/structures.hpp" @@ -19,69 +19,75 @@ namespace px::modrim::providers { -// Runs `fn` on `pool`, or on the global pool when `pool` is null, and returns a future for its -// result. Awaiting the future from a coroutine resumes it on the calling thread. +// Runs `fn` on `pool`, or on the global pool when `pool` is null, and returns a +// future for its result. Awaiting the future from a coroutine resumes it on the +// calling thread. template QFuture runOnPool(QThreadPool* pool, F&& fn) { - auto promise = std::make_shared>(); - QFuture future = promise->future(); - QThreadPool* target = pool ? pool : QThreadPool::globalInstance(); - target->start(QRunnable::create([promise, fn = std::forward(fn)]() mutable { + auto promise = std::make_shared>(); + QFuture future = promise->future(); + QThreadPool* target = pool ? pool : QThreadPool::globalInstance(); + target->start( + QRunnable::create([promise, fn = std::forward(fn)]() mutable { promise->start(); if constexpr (std::is_void_v) { - fn(); + fn(); } else { - promise->addResult(fn()); + promise->addResult(fn()); } promise->finish(); - })); - return future; + })); + return future; } // A directory that holds a mod, with the About.xml that describes it. struct ModDirectory { - QString modPath; - QString aboutXmlPath; + QString modPath; + QString aboutXmlPath; }; -// A ModDirectory plus the About.xml stat data that cache entries are validated against. +// A ModDirectory plus the About.xml stat data that cache entries are validated +// against. struct ModCandidate { - QString modPath; - QString aboutXmlPath; - double mtime = 0.0; - qint64 size = 0; + QString modPath; + QString aboutXmlPath; + double mtime = 0.0; + qint64 size = 0; }; // A freshly parsed mod and the candidate it came from. struct ParsedMod { - ListedModPtr mod; - ModCandidate candidate; + ListedModPtr mod; + ModCandidate candidate; }; // An AboutXmlMod ready to be stored by a metadata cache. struct CachedMod { - std::shared_ptr mod; - ModCandidate candidate; + std::shared_ptr mod; + ModCandidate candidate; }; struct CacheLookup { - QList cached; - QList missing; + QList cached; + QList missing; }; -// Persistent store of parsed About.xml metadata, keyed by About.xml path and validated by its -// mtime and size. Implemented by the cache service; providers only depend on this interface. +// Persistent store of parsed About.xml metadata, keyed by About.xml path and +// validated by its mtime and size. Implemented by the cache service; providers +// only depend on this interface. class MetadataCacheStore { -public: - MetadataCacheStore() = default; - MetadataCacheStore(const MetadataCacheStore&) = delete; - MetadataCacheStore& operator=(const MetadataCacheStore&) = delete; - virtual ~MetadataCacheStore() = default; - - // Returns the entries that are still valid for `target_version`; everything else is missing. - virtual QCoro::Task getMods(QList candidates, - QString targetVersion) = 0; - virtual QCoro::Task putMods(QList entries, QString targetVersion) = 0; + public: + MetadataCacheStore() = default; + MetadataCacheStore(const MetadataCacheStore&) = delete; + MetadataCacheStore& operator=(const MetadataCacheStore&) = delete; + virtual ~MetadataCacheStore() = default; + + // Returns the entries that are still valid for `target_version`; everything + // else is missing. + virtual QCoro::Task getMods(QList candidates, + QString targetVersion) = 0; + virtual QCoro::Task putMods(QList entries, + QString targetVersion) = 0; }; // Discovered mods keyed by uuid (the mod path for mods on disk). @@ -91,40 +97,43 @@ using ModMap = QHash; QList scanModDirectory(const QString& modsPath); class BaseModProvider { -public: - // `path` is the root this provider scans. `pool` and `metadata_cache` are optional and not - // owned; they must outlive the provider. - BaseModProvider(QString providerId, QString color, QString path, - QThreadPool* pool = nullptr, MetadataCacheStore* metadataCache = nullptr); - BaseModProvider(const BaseModProvider&) = delete; - BaseModProvider& operator=(const BaseModProvider&) = delete; - virtual ~BaseModProvider() = default; - - const QString& providerId() const noexcept { return m_providerId; } - const QString& color() const noexcept { return m_color; } - const QString& path() const noexcept { return m_path; } - - // `metadata_cache` overrides the cache given at construction for this call only. - virtual QCoro::Task> discover(QString targetVersion, - Profiler* timer = nullptr, - MetadataCacheStore* metadataCache = nullptr, - bool forceReparse = false) = 0; - -protected: - // Loads metadata for `dirs`, using the cache where entries are still valid. - QCoro::Task> loadMods(QList dirs, QString targetVersion, - Profiler& timer, MetadataCacheStore* metadataCache, - bool forceReparse); - -private: - QCoro::Task>> parseCandidates(QList missing, - QString targetVersion); - - QString m_providerId; - QString m_color; - QString m_path; - QThreadPool* m_pool; - MetadataCacheStore* m_metadataCache; + public: + // `path` is the root this provider scans. `pool` and `metadata_cache` are + // optional and not owned; they must outlive the provider. + BaseModProvider(QString providerId, QString color, QString path, + QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + BaseModProvider(const BaseModProvider&) = delete; + BaseModProvider& operator=(const BaseModProvider&) = delete; + virtual ~BaseModProvider() = default; + + const QString& providerId() const noexcept { return m_providerId; } + const QString& color() const noexcept { return m_color; } + const QString& path() const noexcept { return m_path; } + + // `metadata_cache` overrides the cache given at construction for this call + // only. + virtual QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) = 0; + + protected: + // Loads metadata for `dirs`, using the cache where entries are still valid. + QCoro::Task> loadMods(QList dirs, + QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, + bool forceReparse); + + private: + QCoro::Task>> parseCandidates( + QList missing, QString targetVersion); + + QString m_providerId; + QString m_color; + QString m_path; + QThreadPool* m_pool; + MetadataCacheStore* m_metadataCache; }; } // namespace px::modrim::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp index a4a70e3..4a057a6 100644 --- a/src/core/providers/core.cpp +++ b/src/core/providers/core.cpp @@ -2,39 +2,41 @@ #include #include + #include namespace px::modrim::providers { -CoreModProvider::CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache) - : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), std::move(gamePath), - nullptr, metadataCache) {} - -QCoro::Task> CoreModProvider::discover(QString targetVersion, Profiler* timer, - MetadataCacheStore* metadataCache, - bool forceReparse) { - Profiler ownTimer; - Profiler& tm = timer ? *timer : ownTimer; - - const QString dataDir = QDir(path()).filePath(QStringLiteral("Data")); - spdlog::debug("CoreModProvider scanning: {}", dataDir.toStdString()); - if (!QFileInfo::exists(dataDir)) { - co_return ModMap{}; - } - - QList dirs; - { - auto scope = tm("scan_dir"); - dirs = co_await runOnPool>( - nullptr, [dataDir] { return scanModDirectory(dataDir); }); - } - - auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, - metadataCache, forceReparse); - if (discovered) { - spdlog::info("CoreModProvider discovered {} mods", discovered->size()); - } - co_return discovered; +CoreModProvider::CoreModProvider(QString gamePath, + MetadataCacheStore* metadataCache) + : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), + std::move(gamePath), nullptr, metadataCache) {} + +QCoro::Task> CoreModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + const QString dataDir = QDir(path()).filePath(QStringLiteral("Data")); + spdlog::debug("CoreModProvider scanning: {}", dataDir.toStdString()); + if (!QFileInfo::exists(dataDir)) { + co_return ModMap{}; + } + + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [dataDir] { return scanModDirectory(dataDir); }); + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("CoreModProvider discovered {} mods", discovered->size()); + } + co_return discovered; } } // namespace px::modrim::providers diff --git a/src/core/providers/core.hpp b/src/core/providers/core.hpp index a030eef..f7e761e 100644 --- a/src/core/providers/core.hpp +++ b/src/core/providers/core.hpp @@ -6,13 +6,15 @@ namespace px::modrim::providers { // Base-game and DLC mods under the game's `Data/` directory. class CoreModProvider final : public BaseModProvider { -public: - // `gamePath` is the RimWorld install root; `Data/` is resolved inside it. - explicit CoreModProvider(QString gamePath, MetadataCacheStore* metadataCache = nullptr); + public: + // `gamePath` is the RimWorld install root; `Data/` is resolved inside it. + explicit CoreModProvider(QString gamePath, + MetadataCacheStore* metadataCache = nullptr); - QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, - MetadataCacheStore* metadataCache = nullptr, - bool forceReparse = false) override; + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; }; } // namespace px::modrim::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp index ad8b11b..4e27de4 100644 --- a/src/core/providers/factory.cpp +++ b/src/core/providers/factory.cpp @@ -6,20 +6,21 @@ namespace px::modrim::providers { ProviderList createProviders(const PathConfig& paths, QThreadPool* pool) { - ProviderList providers; - if (!paths.game.empty()) { - providers.push_back(std::make_unique(QString::fromStdString(paths.game))); - } - if (!paths.local.empty()) { - const QString local = QString::fromStdString(paths.local); - providers.push_back(std::make_unique(local, pool)); - providers.push_back(std::make_unique(local, pool)); - } - if (!paths.workshop.empty()) { - providers.push_back(std::make_unique( - QString::fromStdString(paths.workshop), pool)); - } - return providers; + ProviderList providers; + if (!paths.game.empty()) { + providers.push_back( + std::make_unique(QString::fromStdString(paths.game))); + } + if (!paths.local.empty()) { + const QString local = QString::fromStdString(paths.local); + providers.push_back(std::make_unique(local, pool)); + providers.push_back(std::make_unique(local, pool)); + } + if (!paths.workshop.empty()) { + providers.push_back(std::make_unique( + QString::fromStdString(paths.workshop), pool)); + } + return providers; } } // namespace px::modrim::providers diff --git a/src/core/providers/factory.hpp b/src/core/providers/factory.hpp index c22ddd1..c159bab 100644 --- a/src/core/providers/factory.hpp +++ b/src/core/providers/factory.hpp @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include - #include "core/config.hpp" #include "core/providers/base.hpp" @@ -12,9 +11,10 @@ namespace px::modrim::providers { using ProviderList = std::vector>; -// The default providers for the configured paths: base game, local, downloaded and Workshop. -// Paths that are not set produce no provider. `pool` is not owned and must outlive the providers; -// null uses the global pool. -ProviderList createProviders(const PathConfig& paths, QThreadPool* pool = nullptr); +// The default providers for the configured paths: base game, local, downloaded +// and Workshop. Paths that are not set produce no provider. `pool` is not owned +// and must outlive the providers; null uses the global pool. +ProviderList createProviders(const PathConfig& paths, + QThreadPool* pool = nullptr); } // namespace px::modrim::providers diff --git a/src/core/providers/labels.hpp b/src/core/providers/labels.hpp index c72daeb..bcd6122 100644 --- a/src/core/providers/labels.hpp +++ b/src/core/providers/labels.hpp @@ -6,32 +6,34 @@ namespace px::modrim { -// Display name for a mod provider, mirroring the legacy PROVIDER_LABELS table. Unknown ids -// fall back to the id with underscores as spaces and each word title-cased, as Python's -// str.title() does: a letter starts a new word only after an uncased character. +// Display name for a mod provider, mirroring the legacy PROVIDER_LABELS table. +// Unknown ids fall back to the id with underscores as spaces and each word +// title-cased, as Python's str.title() does: a letter starts a new word only +// after an uncased character. inline QString providerLabel(const QString& providerId) { - if (providerId == QLatin1StringView("local")) { - return QStringLiteral("Local"); - } - if (providerId == QLatin1StringView("steam") || providerId == QLatin1StringView("downloaded")) { - return QStringLiteral("Steam Workshop"); - } - if (providerId == QLatin1StringView("core")) { - return QStringLiteral("System / Core"); - } + if (providerId == QLatin1StringView("local")) { + return QStringLiteral("Local"); + } + if (providerId == QLatin1StringView("steam") || + providerId == QLatin1StringView("downloaded")) { + return QStringLiteral("Steam Workshop"); + } + if (providerId == QLatin1StringView("core")) { + return QStringLiteral("System / Core"); + } - QString label = providerId; - label.replace(u'_', u' '); - bool afterLetter = false; - for (QChar& c : label) { - if (c.isLetter()) { - c = afterLetter ? c.toLower() : c.toUpper(); - afterLetter = true; - } else { - afterLetter = false; - } + QString label = providerId; + label.replace(u'_', u' '); + bool afterLetter = false; + for (QChar& c : label) { + if (c.isLetter()) { + c = afterLetter ? c.toLower() : c.toUpper(); + afterLetter = true; + } else { + afterLetter = false; } - return label; + } + return label; } } // namespace px::modrim diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp index 3893a7c..9b0e467 100644 --- a/src/core/providers/local.cpp +++ b/src/core/providers/local.cpp @@ -2,6 +2,7 @@ #include #include + #include namespace px::modrim::providers { @@ -9,108 +10,114 @@ namespace px::modrim::providers { namespace { struct ScannedDirectory { - ModDirectory dir; - bool hasPublishedFileId = false; + ModDirectory dir; + bool hasPublishedFileId = false; }; -// Runs off the UI thread: one directory listing plus a published-file-id probe per mod. +// Runs off the UI thread: one directory listing plus a published-file-id probe +// per mod. QList scanWithPublishedIds(const QString& root) { - QList scanned; - for (const ModDirectory& dir : scanModDirectory(root)) { - const bool hasId = QFileInfo::exists( - QDir(dir.modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); - scanned.append(ScannedDirectory{dir, hasId}); - } - return scanned; + QList scanned; + for (const ModDirectory& dir : scanModDirectory(root)) { + const bool hasId = QFileInfo::exists( + QDir(dir.modPath) + .filePath(QStringLiteral("About/PublishedFileId.txt"))); + scanned.append(ScannedDirectory{dir, hasId}); + } + return scanned; } } // namespace LocalModProvider::LocalModProvider(QString localPath, QThreadPool* pool, MetadataCacheStore* metadataCache) - : BaseModProvider(QStringLiteral("local"), QStringLiteral("#2ecc71"), std::move(localPath), - pool, metadataCache) {} - -QCoro::Task> LocalModProvider::discover(QString targetVersion, Profiler* timer, - MetadataCacheStore* metadataCache, - bool forceReparse) { - Profiler ownTimer; - Profiler& tm = timer ? *timer : ownTimer; - - if (!QFileInfo::exists(path())) { - spdlog::debug("LocalModProvider path does not exist: {}", path().toStdString()); - co_return ModMap{}; - } - spdlog::debug("LocalModProvider scanning: {}", path().toStdString()); - - QList scanned; - { - auto scope = tm("scan_dir"); - scanned = co_await runOnPool>( - nullptr, [root = path()] { return scanWithPublishedIds(root); }); - } - QList dirs; - for (const ScannedDirectory& item : scanned) { - if (!item.hasPublishedFileId) { - dirs.append(item.dir); - } - } - - auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, - metadataCache, forceReparse); - if (discovered) { - spdlog::info("LocalModProvider discovered {} mods", discovered->size()); + : BaseModProvider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + std::move(localPath), pool, metadataCache) {} + +QCoro::Task> LocalModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("LocalModProvider path does not exist: {}", + path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("LocalModProvider scanning: {}", path().toStdString()); + + QList scanned; + { + auto scope = tm("scan_dir"); + scanned = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root); }); + } + QList dirs; + for (const ScannedDirectory& item : scanned) { + if (!item.hasPublishedFileId) { + dirs.append(item.dir); } - co_return discovered; + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("LocalModProvider discovered {} mods", discovered->size()); + } + co_return discovered; } -DownloadedModProvider::DownloadedModProvider(QString localPath, QThreadPool* pool, +DownloadedModProvider::DownloadedModProvider(QString localPath, + QThreadPool* pool, MetadataCacheStore* metadataCache) - : DownloadedModProvider(QStringLiteral("downloaded"), std::move(localPath), pool, - metadataCache) {} + : DownloadedModProvider(QStringLiteral("downloaded"), std::move(localPath), + pool, metadataCache) {} DownloadedModProvider::DownloadedModProvider(QString providerId, QString path, - QThreadPool* pool, MetadataCacheStore* metadataCache) - : BaseModProvider(std::move(providerId), QStringLiteral("#3498db"), std::move(path), pool, - metadataCache) {} - -QCoro::Task> DownloadedModProvider::discover(QString targetVersion, - Profiler* timer, - MetadataCacheStore* metadataCache, - bool forceReparse) { - Profiler ownTimer; - Profiler& tm = timer ? *timer : ownTimer; - - if (!QFileInfo::exists(path())) { - spdlog::debug("DownloadedModProvider path does not exist: {}", path().toStdString()); - co_return ModMap{}; - } - spdlog::debug("DownloadedModProvider scanning: {}", path().toStdString()); - - QList scanned; - { - auto scope = tm("scan_dir"); - scanned = co_await runOnPool>( - nullptr, [root = path()] { return scanWithPublishedIds(root); }); - } - QList dirs; - for (const ScannedDirectory& item : scanned) { - if (item.hasPublishedFileId) { - dirs.append(item.dir); - } - } - - auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), tm, - metadataCache, forceReparse); - if (discovered) { - spdlog::info("{} discovered {} mods", providerId().toStdString(), discovered->size()); + QThreadPool* pool, + MetadataCacheStore* metadataCache) + : BaseModProvider(std::move(providerId), QStringLiteral("#3498db"), + std::move(path), pool, metadataCache) {} + +QCoro::Task> DownloadedModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("DownloadedModProvider path does not exist: {}", + path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("DownloadedModProvider scanning: {}", path().toStdString()); + + QList scanned; + { + auto scope = tm("scan_dir"); + scanned = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root); }); + } + QList dirs; + for (const ScannedDirectory& item : scanned) { + if (item.hasPublishedFileId) { + dirs.append(item.dir); } - co_return discovered; + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("{} discovered {} mods", providerId().toStdString(), + discovered->size()); + } + co_return discovered; } -SteamWorkshopModProvider::SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool, - MetadataCacheStore* metadataCache) - : DownloadedModProvider(QStringLiteral("steam"), std::move(workshopPath), pool, - metadataCache) {} +SteamWorkshopModProvider::SteamWorkshopModProvider( + QString workshopPath, QThreadPool* pool, MetadataCacheStore* metadataCache) + : DownloadedModProvider(QStringLiteral("steam"), std::move(workshopPath), + pool, metadataCache) {} } // namespace px::modrim::providers diff --git a/src/core/providers/local.hpp b/src/core/providers/local.hpp index 5ef6722..3012d57 100644 --- a/src/core/providers/local.hpp +++ b/src/core/providers/local.hpp @@ -4,37 +4,42 @@ namespace px::modrim::providers { -// Mods the user keeps in the local mods folder: directories without a published file id. +// Mods the user keeps in the local mods folder: directories without a published +// file id. class LocalModProvider final : public BaseModProvider { -public: - LocalModProvider(QString localPath, QThreadPool* pool = nullptr, - MetadataCacheStore* metadataCache = nullptr); - - QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, - MetadataCacheStore* metadataCache = nullptr, - bool forceReparse = false) override; + public: + LocalModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; }; -// Mods downloaded by the app into the local folder: directories that carry a published file id. +// Mods downloaded by the app into the local folder: directories that carry a +// published file id. class DownloadedModProvider : public BaseModProvider { -public: - DownloadedModProvider(QString localPath, QThreadPool* pool = nullptr, - MetadataCacheStore* metadataCache = nullptr); - - QCoro::Task> discover(QString targetVersion, Profiler* timer = nullptr, - MetadataCacheStore* metadataCache = nullptr, - bool forceReparse = false) override; - -protected: - DownloadedModProvider(QString providerId, QString path, QThreadPool* pool, - MetadataCacheStore* metadataCache); + public: + DownloadedModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; + + protected: + DownloadedModProvider(QString providerId, QString path, QThreadPool* pool, + MetadataCacheStore* metadataCache); }; -// Steam Workshop subscriptions: same layout as downloaded mods, under the workshop root. +// Steam Workshop subscriptions: same layout as downloaded mods, under the +// workshop root. class SteamWorkshopModProvider final : public DownloadedModProvider { -public: - SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool = nullptr, - MetadataCacheStore* metadataCache = nullptr); + public: + SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); }; } // namespace px::modrim::providers diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp index b00dd7d..5560f94 100644 --- a/src/core/services/activation_service.cpp +++ b/src/core/services/activation_service.cpp @@ -1,5 +1,7 @@ #include "core/services/activation_service.hpp" +#include +#include #include #include #include @@ -8,9 +10,6 @@ #include #include -#include -#include - #include "core/checker/graph.hpp" #include "core/context.hpp" #include "core/models/metadata/structures.hpp" @@ -28,383 +27,391 @@ using checker::PackageId; using ModMap = QHash; using Edge = std::pair; -// The About.xml mod behind `uuid`, or null when it is unknown or has no About.xml. -checker::AboutXmlModPtr aboutMod(const QHash& allMods, const QString& uuid) { - const auto it = allMods.constFind(uuid); - return it == allMods.cend() ? nullptr : std::dynamic_pointer_cast(it.value()); +// The About.xml mod behind `uuid`, or null when it is unknown or has no +// About.xml. +checker::AboutXmlModPtr aboutMod(const QHash& allMods, + const QString& uuid) { + const auto it = allMods.constFind(uuid); + return it == allMods.cend() + ? nullptr + : std::dynamic_pointer_cast(it.value()); } QStringList enableCandidates(const QHash& allMods, - const QStringList& enable, - const QSet& disableSet, - const QSet& activeSet) { - QStringList candidates; - QSet seen; - for (const QString& uuid : enable) { - if (seen.contains(uuid) || disableSet.contains(uuid)) { - continue; - } - seen.insert(uuid); - if (allMods.contains(uuid) && !activeSet.contains(uuid)) { - candidates.append(uuid); - } + const QStringList& enable, + const QSet& disableSet, + const QSet& activeSet) { + QStringList candidates; + QSet seen; + for (const QString& uuid : enable) { + if (seen.contains(uuid) || disableSet.contains(uuid)) { + continue; } - return candidates; + seen.insert(uuid); + if (allMods.contains(uuid) && !activeSet.contains(uuid)) { + candidates.append(uuid); + } + } + return candidates; } -// (before, after) pairs between mods touching any of `candidates`. Edges through ids outside -// `mods` (uninstalled optional mods) are dropped so they cannot chain unrelated mods together. -QList buildEdges(const ModMap& mods, - const QList& order, - const QList& candidates, - const sort::SortSettings& settings, - const sort::CommunityRules& communityRules) { - ConstraintGraph graph; - graph.build(mods, order, settings, &communityRules); - // Placement ignores active-to-active edges, so only edges incident to a candidate matter; - // the graph still resolves the full alternative map. - QSet incident; - for (const PackageId& pid : candidates) { - incident.unite(graph.outgoing(pid)); - incident.unite(graph.incoming(pid)); - } - - QList edges; - for (const ConstraintEdge& edge : incident) { - PackageId before; - PackageId after; - switch (edge.type) { - case EdgeType::kLoadBefore: - before = edge.source; - after = edge.target; - break; - case EdgeType::kDependency: - case EdgeType::kLoadAfter: - before = edge.target; - after = edge.source; - break; - default: - continue; - } - if (before != after && mods.contains(before) && mods.contains(after)) { - edges.append({before, after}); - } +// (before, after) pairs between mods touching any of `candidates`. Edges +// through ids outside `mods` (uninstalled optional mods) are dropped so they +// cannot chain unrelated mods together. +QList buildEdges(const ModMap& mods, const QList& order, + const QList& candidates, + const sort::SortSettings& settings, + const sort::CommunityRules& communityRules) { + ConstraintGraph graph; + graph.build(mods, order, settings, &communityRules); + // Placement ignores active-to-active edges, so only edges incident to a + // candidate matter; the graph still resolves the full alternative map. + QSet incident; + for (const PackageId& pid : candidates) { + incident.unite(graph.outgoing(pid)); + incident.unite(graph.incoming(pid)); + } + + QList edges; + for (const ConstraintEdge& edge : incident) { + PackageId before; + PackageId after; + switch (edge.type) { + case EdgeType::kLoadBefore: + before = edge.source; + after = edge.target; + break; + case EdgeType::kDependency: + case EdgeType::kLoadAfter: + before = edge.target; + after = edge.source; + break; + default: + continue; + } + if (before != after && mods.contains(before) && mods.contains(after)) { + edges.append({before, after}); } - return edges; + } + return edges; } // Iterative Tarjan SCC. Each component lists its members in `nodes` order. -QList stronglyConnected(const QStringList& nodes, - const QHash>& adjacent) { - struct Frame { - QString node; - QStringList children; - qsizetype next = 0; - }; - - QHash indexOf; - QHash lowlink; - QStringList stack; - QSet onStack; - QList components; - QList work; - - const auto visit = [&](const QString& node) { - const int index = static_cast(indexOf.size()); - indexOf.insert(node, index); - lowlink.insert(node, index); - stack.append(node); - onStack.insert(node); - const QSet successors = adjacent.value(node); - work.append(Frame{node, QStringList(successors.cbegin(), successors.cend()), 0}); - }; - - for (const QString& root : nodes) { - if (indexOf.contains(root)) { - continue; - } - visit(root); - while (!work.isEmpty()) { - Frame& frame = work.last(); - if (frame.next < frame.children.size()) { - const QString child = frame.children[frame.next++]; - if (!indexOf.contains(child)) { - // Invalidates `frame`; the loop re-reads work.last(). - visit(child); - } else if (onStack.contains(child)) { - lowlink[frame.node] = std::min(lowlink.value(frame.node), indexOf.value(child)); - } - continue; - } - - // Every child is done: close this node. - const QString node = frame.node; - work.removeLast(); - if (!work.isEmpty()) { - const QString parent = work.last().node; - lowlink[parent] = std::min(lowlink.value(parent), lowlink.value(node)); - } - if (lowlink.value(node) == indexOf.value(node)) { - QStringList component; - QString member; - do { - member = stack.takeLast(); - onStack.remove(member); - component.append(member); - } while (member != node); - components.append(component); - } - } +QList stronglyConnected( + const QStringList& nodes, const QHash>& adjacent) { + struct Frame { + QString node; + QStringList children; + qsizetype next = 0; + }; + + QHash indexOf; + QHash lowlink; + QStringList stack; + QSet onStack; + QList components; + QList work; + + const auto visit = [&](const QString& node) { + const int index = static_cast(indexOf.size()); + indexOf.insert(node, index); + lowlink.insert(node, index); + stack.append(node); + onStack.insert(node); + const QSet successors = adjacent.value(node); + work.append( + Frame{node, QStringList(successors.cbegin(), successors.cend()), 0}); + }; + + for (const QString& root : nodes) { + if (indexOf.contains(root)) { + continue; } - return components; + visit(root); + while (!work.isEmpty()) { + Frame& frame = work.last(); + if (frame.next < frame.children.size()) { + const QString child = frame.children[frame.next++]; + if (!indexOf.contains(child)) { + // Invalidates `frame`; the loop re-reads work.last(). + visit(child); + } else if (onStack.contains(child)) { + lowlink[frame.node] = + std::min(lowlink.value(frame.node), indexOf.value(child)); + } + continue; + } + + // Every child is done: close this node. + const QString node = frame.node; + work.removeLast(); + if (!work.isEmpty()) { + const QString parent = work.last().node; + lowlink[parent] = std::min(lowlink.value(parent), lowlink.value(node)); + } + if (lowlink.value(node) == indexOf.value(node)) { + QStringList component; + QString member; + do { + member = stack.takeLast(); + onStack.remove(member); + component.append(member); + } while (member != node); + components.append(component); + } + } + } + return components; } // Slots enable candidates into gaps of a fixed active order. // -// Gap i means "just before new_active[i]"; gap new_active.size() is the end. Bounds come only from -// direct edges to active mods, propagated through batch candidates, because the active order -// itself may already violate constraints and must not be re-derived transitively. +// Gap i means "just before new_active[i]"; gap new_active.size() is the end. +// Bounds come only from direct edges to active mods, propagated through batch +// candidates, because the active order itself may already violate constraints +// and must not be re-derived transitively. class Placement { -public: - Placement(QStringList newActive, - QStringList candidates, - const QHash& allMods, - const sort::SortSettings& settings, - const sort::CommunityRules& communityRules); - - QStringList assemble() const; - -private: - void addEdge(const PackageId& before, const PackageId& after); - std::pair> orderBatch() const; - QHash propagatedCeilings(const QStringList& ordered, - const QSet& cyclic) const; - int gapFor(const QString& uuid, - const QSet& cyclic, - const QHash& ceilings, - const QHash& gaps) const; - - QStringList m_newActive; - QStringList m_candidates; - int m_end; - - QHash m_firstPos; - QHash m_lastPos; - QHash m_uuidsByPid; - - // Batch-internal adjacency plus direct bounds against active mods. - QHash> m_after; - QHash> m_before; - QHash m_floor; - QHash m_ceiling; + public: + Placement(QStringList newActive, QStringList candidates, + const QHash& allMods, + const sort::SortSettings& settings, + const sort::CommunityRules& communityRules); + + QStringList assemble() const; + + private: + void addEdge(const PackageId& before, const PackageId& after); + std::pair> orderBatch() const; + QHash propagatedCeilings(const QStringList& ordered, + const QSet& cyclic) const; + int gapFor(const QString& uuid, const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const; + + QStringList m_newActive; + QStringList m_candidates; + int m_end; + + QHash m_firstPos; + QHash m_lastPos; + QHash m_uuidsByPid; + + // Batch-internal adjacency plus direct bounds against active mods. + QHash> m_after; + QHash> m_before; + QHash m_floor; + QHash m_ceiling; }; -Placement::Placement(QStringList newActive, - QStringList candidates, +Placement::Placement(QStringList newActive, QStringList candidates, const QHash& allMods, const sort::SortSettings& settings, const sort::CommunityRules& communityRules) : m_newActive(std::move(newActive)), m_candidates(std::move(candidates)), m_end(static_cast(m_newActive.size())) { - ModMap mods; - QList order; - for (qsizetype index = 0; index < m_newActive.size(); ++index) { - const checker::AboutXmlModPtr mod = aboutMod(allMods, m_newActive[index]); - if (!mod) { - continue; - } - const PackageId& pid = mod->packageId; - if (!m_firstPos.contains(pid)) { - m_firstPos.insert(pid, static_cast(index)); - } - m_lastPos.insert(pid, static_cast(index)); - if (!mods.contains(pid)) { - mods.insert(pid, mod); - order.append(pid); - } + ModMap mods; + QList order; + for (qsizetype index = 0; index < m_newActive.size(); ++index) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, m_newActive[index]); + if (!mod) { + continue; } - - for (const QString& uuid : m_candidates) { - m_after.insert(uuid, {}); - m_before.insert(uuid, {}); - const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); - if (!mod) { - continue; - } - const PackageId& pid = mod->packageId; - m_uuidsByPid[pid].append(uuid); - if (!mods.contains(pid)) { - mods.insert(pid, mod); - order.append(pid); - } + const PackageId& pid = mod->packageId; + if (!m_firstPos.contains(pid)) { + m_firstPos.insert(pid, static_cast(index)); + } + m_lastPos.insert(pid, static_cast(index)); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); } + } + + for (const QString& uuid : m_candidates) { + m_after.insert(uuid, {}); + m_before.insert(uuid, {}); + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (!mod) { + continue; + } + const PackageId& pid = mod->packageId; + m_uuidsByPid[pid].append(uuid); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); + } + } - if (!m_uuidsByPid.isEmpty()) { - for (const Edge& edge : - buildEdges(mods, order, m_uuidsByPid.keys(), settings, communityRules)) { - addEdge(edge.first, edge.second); - } + if (!m_uuidsByPid.isEmpty()) { + for (const Edge& edge : buildEdges(mods, order, m_uuidsByPid.keys(), + settings, communityRules)) { + addEdge(edge.first, edge.second); } + } } void Placement::addEdge(const PackageId& before, const PackageId& after) { - const QStringList beforeUuids = m_uuidsByPid.value(before); - const QStringList afterUuids = m_uuidsByPid.value(after); - for (const QString& b : beforeUuids) { - for (const QString& a : afterUuids) { - if (a != b) { - m_after[b].insert(a); - m_before[a].insert(b); - } - } - if (const auto first = m_firstPos.constFind(after); first != m_firstPos.cend()) { - m_ceiling[b] = std::min(m_ceiling.value(b, first.value()), first.value()); - } + const QStringList beforeUuids = m_uuidsByPid.value(before); + const QStringList afterUuids = m_uuidsByPid.value(after); + for (const QString& b : beforeUuids) { + for (const QString& a : afterUuids) { + if (a != b) { + m_after[b].insert(a); + m_before[a].insert(b); + } } - if (const auto last = m_lastPos.constFind(before); last != m_lastPos.cend()) { - for (const QString& a : afterUuids) { - m_floor[a] = std::max(m_floor.value(a, last.value() + 1), last.value() + 1); - } + if (const auto first = m_firstPos.constFind(after); + first != m_firstPos.cend()) { + m_ceiling[b] = std::min(m_ceiling.value(b, first.value()), first.value()); } + } + if (const auto last = m_lastPos.constFind(before); last != m_lastPos.cend()) { + for (const QString& a : afterUuids) { + m_floor[a] = + std::max(m_floor.value(a, last.value() + 1), last.value() + 1); + } + } } QStringList Placement::assemble() const { - const auto [ordered, cyclic] = orderBatch(); - const QHash ceilings = propagatedCeilings(ordered, cyclic); - QList buckets(m_end + 1); - QHash gaps; - for (const QString& uuid : ordered) { - const int gap = gapFor(uuid, cyclic, ceilings, gaps); - gaps.insert(uuid, gap); - buckets[gap].append(uuid); - } - - QStringList result; - for (qsizetype index = 0; index < m_newActive.size(); ++index) { - result.append(buckets[index]); - result.append(m_newActive[index]); - } - result.append(buckets[m_end]); - return result; + const auto [ordered, cyclic] = orderBatch(); + const QHash ceilings = propagatedCeilings(ordered, cyclic); + QList buckets(m_end + 1); + QHash gaps; + for (const QString& uuid : ordered) { + const int gap = gapFor(uuid, cyclic, ceilings, gaps); + gaps.insert(uuid, gap); + buckets[gap].append(uuid); + } + + QStringList result; + for (qsizetype index = 0; index < m_newActive.size(); ++index) { + result.append(buckets[index]); + result.append(m_newActive[index]); + } + result.append(buckets[m_end]); + return result; } -int Placement::gapFor(const QString& uuid, - const QSet& cyclic, - const QHash& ceilings, - const QHash& gaps) const { - if (cyclic.contains(uuid)) { - return m_end; - } - // Befores of an acyclic candidate sit in earlier components, so their gaps are final. - std::optional earliest; - const auto raiseFloor = [&earliest](int floor) { - earliest = earliest ? std::max(*earliest, floor) : floor; - }; - for (const QString& b : m_before.value(uuid)) { - raiseFloor(gaps.value(b)); - } - if (const auto floor = m_floor.constFind(uuid); floor != m_floor.cend()) { - raiseFloor(floor.value()); +int Placement::gapFor(const QString& uuid, const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const { + if (cyclic.contains(uuid)) { + return m_end; + } + // Befores of an acyclic candidate sit in earlier components, so their gaps + // are final. + std::optional earliest; + const auto raiseFloor = [&earliest](int floor) { + earliest = earliest ? std::max(*earliest, floor) : floor; + }; + for (const QString& b : m_before.value(uuid)) { + raiseFloor(gaps.value(b)); + } + if (const auto floor = m_floor.constFind(uuid); floor != m_floor.cend()) { + raiseFloor(floor.value()); + } + + std::optional ceiling; + if (const auto found = ceilings.constFind(uuid); found != ceilings.cend()) { + ceiling = found.value(); + } + if (earliest) { + if (ceiling && *earliest > *ceiling) { + return m_end; } + return *earliest; + } + return ceiling ? *ceiling : m_end; +} - std::optional ceiling; - if (const auto found = ceilings.constFind(uuid); found != ceilings.cend()) { - ceiling = found.value(); +QHash Placement::propagatedCeilings( + const QStringList& ordered, const QSet& cyclic) const { + // Earliest active position each candidate must precede, via batch chains. + QHash ceilings; + for (auto it = ordered.crbegin(); it != ordered.crend(); ++it) { + if (cyclic.contains(*it)) { + continue; } - if (earliest) { - if (ceiling && *earliest > *ceiling) { - return m_end; - } - return *earliest; + std::optional bound; + const auto tighten = [&bound](int value) { + bound = bound ? std::min(*bound, value) : value; + }; + for (const QString& a : m_after.value(*it)) { + if (const auto found = ceilings.constFind(a); found != ceilings.cend()) { + tighten(found.value()); + } } - return ceiling ? *ceiling : m_end; -} - -QHash Placement::propagatedCeilings(const QStringList& ordered, - const QSet& cyclic) const { - // Earliest active position each candidate must precede, via batch chains. - QHash ceilings; - for (auto it = ordered.crbegin(); it != ordered.crend(); ++it) { - if (cyclic.contains(*it)) { - continue; - } - std::optional bound; - const auto tighten = [&bound](int value) { - bound = bound ? std::min(*bound, value) : value; - }; - for (const QString& a : m_after.value(*it)) { - if (const auto found = ceilings.constFind(a); found != ceilings.cend()) { - tighten(found.value()); - } - } - if (const auto own = m_ceiling.constFind(*it); own != m_ceiling.cend()) { - tighten(own.value()); - } - if (bound) { - ceilings.insert(*it, *bound); - } + if (const auto own = m_ceiling.constFind(*it); own != m_ceiling.cend()) { + tighten(own.value()); } - return ceilings; + if (bound) { + ceilings.insert(*it, *bound); + } + } + return ceilings; } -// Topologically orders candidates by input order; cycle members are placed as a unit. +// Topologically orders candidates by input order; cycle members are placed as a +// unit. std::pair> Placement::orderBatch() const { - auto components = stronglyConnected(m_candidates, m_after); - - QHash inputIndex; - for (qsizetype index = 0; index < m_candidates.size(); ++index) { - inputIndex.insert(m_candidates[index], static_cast(index)); - } - - QHash componentOf; - QSet cyclic; - for (int index = 0; index < components.size(); ++index) { - QStringList& members = components[index]; - std::sort(members.begin(), members.end(), [&inputIndex](const QString& a, const QString& b) { - return inputIndex.value(a) < inputIndex.value(b); - }); - for (const QString& member : members) { - componentOf.insert(member, index); - } - if (members.size() > 1) { - cyclic.unite(QSet(members.cbegin(), members.cend())); - } + auto components = stronglyConnected(m_candidates, m_after); + + QHash inputIndex; + for (qsizetype index = 0; index < m_candidates.size(); ++index) { + inputIndex.insert(m_candidates[index], static_cast(index)); + } + + QHash componentOf; + QSet cyclic; + for (int index = 0; index < components.size(); ++index) { + QStringList& members = components[index]; + std::sort(members.begin(), members.end(), + [&inputIndex](const QString& a, const QString& b) { + return inputIndex.value(a) < inputIndex.value(b); + }); + for (const QString& member : members) { + componentOf.insert(member, index); } - - QList> successors(components.size()); - QList indegree(components.size(), 0); - for (auto it = m_after.cbegin(); it != m_after.cend(); ++it) { - const int source = componentOf.value(it.key()); - for (const QString& successor : it.value()) { - const int target = componentOf.value(successor); - if (target != source && !successors[source].contains(target)) { - successors[source].insert(target); - ++indegree[target]; - } - } + if (members.size() > 1) { + cyclic.unite(QSet(members.cbegin(), members.cend())); } - - // Ready components pop in input order of their first member. - using Ready = std::pair; - std::priority_queue, std::greater<>> ready; - for (int index = 0; index < components.size(); ++index) { - if (indegree[index] == 0) { - ready.emplace(inputIndex.value(components[index].front()), index); - } + } + + QList> successors(components.size()); + QList indegree(components.size(), 0); + for (auto it = m_after.cbegin(); it != m_after.cend(); ++it) { + const int source = componentOf.value(it.key()); + for (const QString& successor : it.value()) { + const int target = componentOf.value(successor); + if (target != source && !successors[source].contains(target)) { + successors[source].insert(target); + ++indegree[target]; + } } - QStringList ordered; - while (!ready.empty()) { - const int index = ready.top().second; - ready.pop(); - ordered.append(components[index]); - for (const int target : successors[index]) { - if (--indegree[target] == 0) { - ready.emplace(inputIndex.value(components[target].front()), target); - } - } + } + + // Ready components pop in input order of their first member. + using Ready = std::pair; + std::priority_queue, std::greater<>> ready; + for (int index = 0; index < components.size(); ++index) { + if (indegree[index] == 0) { + ready.emplace(inputIndex.value(components[index].front()), index); } - return {std::move(ordered), std::move(cyclic)}; + } + QStringList ordered; + while (!ready.empty()) { + const int index = ready.top().second; + ready.pop(); + ordered.append(components[index]); + for (const int target : successors[index]) { + if (--indegree[target] == 0) { + ready.emplace(inputIndex.value(components[target].front()), target); + } + } + } + return {std::move(ordered), std::move(cyclic)}; } } // namespace @@ -412,90 +419,89 @@ std::pair> Placement::orderBatch() const { ActivationService::ActivationService(CoreContext& ctx) : m_ctx(ctx) {} QStringList ActivationService::dependentsOf(const QStringList& uuids, - const QStringList& enable) const { - const QSet excluded(uuids.cbegin(), uuids.cend()); - const QHash& allMods = m_ctx.allMods(); - const QStringList& active = m_ctx.activeUuids(); - - QSet remaining; - const auto keep = [&](const QString& uuid) { - if (excluded.contains(uuid)) { - return; - } - if (const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid)) { - remaining.insert(mod->packageId); - } - }; - for (const QString& uuid : active) { - keep(uuid); + const QStringList& enable) const { + const QSet excluded(uuids.cbegin(), uuids.cend()); + const QHash& allMods = m_ctx.allMods(); + const QStringList& active = m_ctx.activeUuids(); + + QSet remaining; + const auto keep = [&](const QString& uuid) { + if (excluded.contains(uuid)) { + return; } - for (const QString& uuid : enable) { - keep(uuid); + if (const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid)) { + remaining.insert(mod->packageId); } - - QSet roots; - for (const QString& uuid : excluded) { - const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); - if (mod && !remaining.contains(mod->packageId)) { - roots.insert(mod->packageId); - } + }; + for (const QString& uuid : active) { + keep(uuid); + } + for (const QString& uuid : enable) { + keep(uuid); + } + + QSet roots; + for (const QString& uuid : excluded) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && !remaining.contains(mod->packageId)) { + roots.insert(mod->packageId); } - if (roots.isEmpty()) { - return {}; - } - - const ConstraintGraph& graph = m_ctx.diagnosticsService().constraintGraph(); - QSet dependents; - QList pending(roots.cbegin(), roots.cend()); - while (!pending.isEmpty()) { - const PackageId target = pending.takeLast(); - const auto edges = graph.incomingOfType(target, EdgeType::kDependency); - for (const ConstraintEdge& edge : edges) { - if (!roots.contains(edge.source) && !dependents.contains(edge.source)) { - dependents.insert(edge.source); - pending.append(edge.source); - } - } + } + if (roots.isEmpty()) { + return {}; + } + + const ConstraintGraph& graph = m_ctx.diagnosticsService().constraintGraph(); + QSet dependents; + QList pending(roots.cbegin(), roots.cend()); + while (!pending.isEmpty()) { + const PackageId target = pending.takeLast(); + const auto edges = graph.incomingOfType(target, EdgeType::kDependency); + for (const ConstraintEdge& edge : edges) { + if (!roots.contains(edge.source) && !dependents.contains(edge.source)) { + dependents.insert(edge.source); + pending.append(edge.source); + } } + } - QStringList result; - for (const QString& uuid : active) { - if (excluded.contains(uuid)) { - continue; - } - const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); - if (mod && dependents.contains(mod->packageId)) { - result.append(uuid); - } + QStringList result; + for (const QString& uuid : active) { + if (excluded.contains(uuid)) { + continue; } - return result; -} - -bool ActivationService::apply(const QStringList& enable, const QStringList& disable) { - const QStringList& active = m_ctx.activeUuids(); - const QSet disableSet(disable.cbegin(), disable.cend()); - QStringList newActive; - for (const QString& uuid : active) { - if (!disableSet.contains(uuid)) { - newActive.append(uuid); - } + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && dependents.contains(mod->packageId)) { + result.append(uuid); } - const QSet activeSet(active.cbegin(), active.cend()); - QStringList candidates = - enableCandidates(m_ctx.allMods(), enable, disableSet, activeSet); + } + return result; +} - const Placement placement(std::move(newActive), - std::move(candidates), - m_ctx.allMods(), - m_ctx.config().sort, - m_ctx.diagnosticsService().communityRules()); - const QStringList result = placement.assemble(); - if (result == active) { - return false; +bool ActivationService::apply(const QStringList& enable, + const QStringList& disable) { + const QStringList& active = m_ctx.activeUuids(); + const QSet disableSet(disable.cbegin(), disable.cend()); + QStringList newActive; + for (const QString& uuid : active) { + if (!disableSet.contains(uuid)) { + newActive.append(uuid); } - - m_ctx.setActive(result); - return true; + } + const QSet activeSet(active.cbegin(), active.cend()); + QStringList candidates = + enableCandidates(m_ctx.allMods(), enable, disableSet, activeSet); + + const Placement placement(std::move(newActive), std::move(candidates), + m_ctx.allMods(), m_ctx.config().sort, + m_ctx.diagnosticsService().communityRules()); + const QStringList result = placement.assemble(); + if (result == active) { + return false; + } + + m_ctx.setActive(result); + return true; } } // namespace px::modrim diff --git a/src/core/services/activation_service.hpp b/src/core/services/activation_service.hpp index 5be9740..348242e 100644 --- a/src/core/services/activation_service.hpp +++ b/src/core/services/activation_service.hpp @@ -8,20 +8,22 @@ class CoreContext; // Enables and disables mods without reordering the rest of the active list. class ActivationService { -public: - // `ctx` is not owned and must outlive the service. - explicit ActivationService(CoreContext& ctx); - - // Active mods that depend on packages lost once `uuids` are disabled and `enable` is - // enabled. The inputs themselves are excluded; order follows the active list. - QStringList dependentsOf(const QStringList& uuids, const QStringList& enable = {}) const; - - // Applies a batch of enable/disable changes and returns whether the active state changed. - // A uuid in both lists is disabled. - bool apply(const QStringList& enable = {}, const QStringList& disable = {}); - -private: - CoreContext& m_ctx; + public: + // `ctx` is not owned and must outlive the service. + explicit ActivationService(CoreContext& ctx); + + // Active mods that depend on packages lost once `uuids` are disabled and + // `enable` is enabled. The inputs themselves are excluded; order follows the + // active list. + QStringList dependentsOf(const QStringList& uuids, + const QStringList& enable = {}) const; + + // Applies a batch of enable/disable changes and returns whether the active + // state changed. A uuid in both lists is disabled. + bool apply(const QStringList& enable = {}, const QStringList& disable = {}); + + private: + CoreContext& m_ctx; }; } // namespace px::modrim diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp index 6a01b53..fd17b95 100644 --- a/src/core/services/diagnostics_service.cpp +++ b/src/core/services/diagnostics_service.cpp @@ -1,13 +1,13 @@ #include "core/services/diagnostics_service.hpp" +#include +#include +#include #include #include #include #include -#include -#include -#include #include #include "core/config.hpp" @@ -21,331 +21,346 @@ namespace { constexpr double kDatabaseMaxAgeS = 24 * 3600; bool needsRefresh(bool loaded, const std::optional& age) { - return !loaded || !age || *age > kDatabaseMaxAgeS; + return !loaded || !age || *age > kDatabaseMaxAgeS; } std::vector> defaultCheckers() { - std::vector> checkers; - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - return checkers; + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; } -view::SidebarEntry makeEntry(view::SidebarEntry::Kind kind, - QString label, - QSet visibleUuids, - QString providerId = {}) { - view::SidebarEntry entry; - entry.kind = kind; - entry.label = std::move(label); - entry.visibleUuids = std::move(visibleUuids); - entry.providerId = std::move(providerId); - return entry; +view::SidebarEntry makeEntry(view::SidebarEntry::Kind kind, QString label, + QSet visibleUuids, + QString providerId = {}) { + view::SidebarEntry entry; + entry.kind = kind; + entry.label = std::move(label); + entry.visibleUuids = std::move(visibleUuids); + entry.providerId = std::move(providerId); + return entry; } } // namespace -DiagnosticsService::DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network) +DiagnosticsService::DiagnosticsService(CoreContext& ctx, + QNetworkAccessManager& network) : m_ctx(ctx), m_noVersionWarningService(ctx.configService(), network), m_useThisInsteadService(ctx.configService(), network), - m_checker(defaultCheckers(), - ctx.config().sort, - ctx.targetVersion(), - [this](const checker::DiagnosticsByUuid& diagnostics) { - onCheckerDiagnosticsChanged(diagnostics); - }) {} + m_checker(defaultCheckers(), ctx.config().sort, ctx.targetVersion(), + [this](const checker::DiagnosticsByUuid& diagnostics) { + onCheckerDiagnosticsChanged(diagnostics); + }) {} // ── Lifecycle ────────────────────────────────────────────── QCoro::Task> DiagnosticsService::initialize(Profiler* timer) { - Profiler local; - Profiler& t = timer != nullptr ? *timer : local; - auto initScope = t("diagnostics.init"); - { - auto scope = t("ensure_databases"); - co_await ensureDatabases(); - } - - std::optional rules; - { - auto scope = t("load_community_rules"); - if (const auto path = communityRulesPath()) { - auto loaded = co_await QtConcurrent::run( - [p = *path] { return sort::loadCommunityRules(p); }); - if (!loaded) { - co_return std::unexpected(loaded.error()); - } - rules = std::move(*loaded); - } - } - if (rules && !rules->isEmpty()) { - m_checker.setCommunityRules(std::move(*rules)); + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto initScope = t("diagnostics.init"); + { + auto scope = t("ensure_databases"); + co_await ensureDatabases(); + } + + std::optional rules; + { + auto scope = t("load_community_rules"); + if (const auto path = communityRulesPath()) { + auto loaded = co_await QtConcurrent::run( + [p = *path] { return sort::loadCommunityRules(p); }); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + rules = std::move(*loaded); } - co_return {}; + } + if (rules && !rules->isEmpty()) { + m_checker.setCommunityRules(std::move(*rules)); + } + co_return {}; } QCoro::Task DiagnosticsService::ensureDatabases() { - // The cache files are read off the UI thread; the service is only touched from here. - auto nvw = co_await QtConcurrent::run( - [this] { return m_noVersionWarningService.loadIfExists(); }); - const bool nvwLoaded = !nvw.isEmpty(); - if (nvwLoaded) { - m_checker.setNoVersionWarning(std::move(nvw)); - } - auto uti = co_await QtConcurrent::run( - [this] { return m_useThisInsteadService.loadIfExists(); }); - const bool utiLoaded = !uti.isEmpty(); - if (utiLoaded) { - m_checker.setUseThisInstead(std::move(uti)); - } - - const bool refreshNvw = - needsRefresh(nvwLoaded, m_noVersionWarningService.cacheAgeS()); - const bool refreshUti = needsRefresh(utiLoaded, m_useThisInsteadService.cacheAgeS()); - if ((refreshNvw || refreshUti) && (!m_dbTask || m_dbTask->isReady())) { - m_dbTask = downloadDatabases(refreshNvw, refreshUti); - } + // The cache files are read off the UI thread; the service is only touched + // from here. + auto nvw = co_await QtConcurrent::run( + [this] { return m_noVersionWarningService.loadIfExists(); }); + const bool nvwLoaded = !nvw.isEmpty(); + if (nvwLoaded) { + m_checker.setNoVersionWarning(std::move(nvw)); + } + auto uti = co_await QtConcurrent::run( + [this] { return m_useThisInsteadService.loadIfExists(); }); + const bool utiLoaded = !uti.isEmpty(); + if (utiLoaded) { + m_checker.setUseThisInstead(std::move(uti)); + } + + const bool refreshNvw = + needsRefresh(nvwLoaded, m_noVersionWarningService.cacheAgeS()); + const bool refreshUti = + needsRefresh(utiLoaded, m_useThisInsteadService.cacheAgeS()); + if ((refreshNvw || refreshUti) && (!m_dbTask || m_dbTask->isReady())) { + m_dbTask = downloadDatabases(refreshNvw, refreshUti); + } } QCoro::Task DiagnosticsService::downloadDatabases(bool nvw, bool uti) { - backgroundTaskChanged.notify(QStringLiteral(u"Downloading rule databases\u2026")); - bool changed = false; - if (nvw) { - auto pids = co_await m_noVersionWarningService.ensure(true); - if (!pids.isEmpty()) { - m_checker.setNoVersionWarning(std::move(pids)); - changed = true; - } - } - if (uti) { - auto entries = co_await m_useThisInsteadService.ensure(true); - if (!entries.isEmpty()) { - m_checker.setUseThisInstead(std::move(entries)); - changed = true; - } + backgroundTaskChanged.notify( + QStringLiteral(u"Downloading rule databases\u2026")); + bool changed = false; + if (nvw) { + auto pids = co_await m_noVersionWarningService.ensure(true); + if (!pids.isEmpty()) { + m_checker.setNoVersionWarning(std::move(pids)); + changed = true; } - if (changed) { - rebuild(); + } + if (uti) { + auto entries = co_await m_useThisInsteadService.ensure(true); + if (!entries.isEmpty()) { + m_checker.setUseThisInstead(std::move(entries)); + changed = true; } - backgroundTaskChanged.notify(QString{}); + } + if (changed) { + rebuild(); + } + backgroundTaskChanged.notify(QString{}); } std::optional DiagnosticsService::communityRulesPath() const { - const AppConfig& config = m_ctx.config(); - if (!config.sort.use_community_rules) { - return std::nullopt; - } - QString path = QString::fromStdString(config.paths.community_rules_file); - if (path.isEmpty()) { - path = QDir(m_ctx.configService().directory()).filePath(QStringLiteral("communityRules.json")); - } - if (!QFileInfo::exists(path)) { - return std::nullopt; - } - return path; + const AppConfig& config = m_ctx.config(); + if (!config.sort.use_community_rules) { + return std::nullopt; + } + QString path = QString::fromStdString(config.paths.community_rules_file); + if (path.isEmpty()) { + path = QDir(m_ctx.configService().directory()) + .filePath(QStringLiteral("communityRules.json")); + } + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + return path; } // ── Mutations ────────────────────────────────────────────── void DiagnosticsService::rebuild(Profiler* timer) { - rebuild(m_ctx.activeUuids(), timer); + rebuild(m_ctx.activeUuids(), timer); } -void DiagnosticsService::rebuild(const QStringList& activeUuids, Profiler* timer) { - m_lastActiveUuids = activeUuids; - Profiler local; - Profiler& t = timer != nullptr ? *timer : local; - auto scope = t("diagnostics.rebuild"); - m_checker.rebuild(m_ctx.allMods(), activeUuids, &t); +void DiagnosticsService::rebuild(const QStringList& activeUuids, + Profiler* timer) { + m_lastActiveUuids = activeUuids; + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto scope = t("diagnostics.rebuild"); + m_checker.rebuild(m_ctx.allMods(), activeUuids, &t); } void DiagnosticsService::reorder(const QStringList& activeUuids) { - m_lastActiveUuids = activeUuids; - m_checker.reorder(activeUuids); + m_lastActiveUuids = activeUuids; + m_checker.reorder(activeUuids); } // ── Queries ──────────────────────────────────────────────── -QHash DiagnosticsService::activeModsByPid() const { - return m_checker.activeMods(); +QHash +DiagnosticsService::activeModsByPid() const { + return m_checker.activeMods(); } const checker::ConstraintGraph& DiagnosticsService::constraintGraph() const { - return m_checker.graph(); + return m_checker.graph(); } QList DiagnosticsService::activeOrderedPids() const { - return m_checker.orderedPids(); + return m_checker.orderedPids(); } const sort::CommunityRules& DiagnosticsService::communityRules() const { - return m_checker.communityRules(); + return m_checker.communityRules(); } -QList DiagnosticsService::issuesFor(const QString& uuid) const { - const checker::ModDiagnostics* diag = m_checker.diagnosticsFor(uuid); - return diag != nullptr ? toIssueViews(*diag) : QList{}; +QList DiagnosticsService::issuesFor( + const QString& uuid) const { + const checker::ModDiagnostics* diag = m_checker.diagnosticsFor(uuid); + return diag != nullptr ? toIssueViews(*diag) : QList{}; } -std::optional DiagnosticsService::summaryFor(const QString& uuid) const { - const auto it = m_lastSummary.constFind(uuid); - if (it == m_lastSummary.cend()) { - return std::nullopt; - } - return it.value(); +std::optional DiagnosticsService::summaryFor( + const QString& uuid) const { + const auto it = m_lastSummary.constFind(uuid); + if (it == m_lastSummary.cend()) { + return std::nullopt; + } + return it.value(); } int DiagnosticsService::activeErrorCount() const { - const auto diagnostics = m_checker.activeModDiagnostics(); - int count = 0; - for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { - count += diag.hasErrors() ? 1 : 0; - } - return count; + const auto diagnostics = m_checker.activeModDiagnostics(); + int count = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + count += diag.hasErrors() ? 1 : 0; + } + return count; } // ── Conversions ──────────────────────────────────────────── -QList DiagnosticsService::toIssueViews(const checker::ModDiagnostics& diag) { - QList views; - const auto append = [&views](const QList& issues, bool isError) { - for (const checker::ModIssue& issue : issues) { - view::ModIssueView view; - view.category = issue.category; - view.categoryDisplayName = - issue.categoryDisplayName.isEmpty() ? issue.category : issue.categoryDisplayName; - if (!issue.detailMessage.isEmpty()) { - view.detail = issue.detailMessage; - } - view.isError = isError; - views.append(std::move(view)); - } - }; - append(diag.errors, true); - append(diag.warnings, false); - return views; +QList DiagnosticsService::toIssueViews( + const checker::ModDiagnostics& diag) { + QList views; + const auto append = [&views](const QList& issues, + bool isError) { + for (const checker::ModIssue& issue : issues) { + view::ModIssueView view; + view.category = issue.category; + view.categoryDisplayName = issue.categoryDisplayName.isEmpty() + ? issue.category + : issue.categoryDisplayName; + if (!issue.detailMessage.isEmpty()) { + view.detail = issue.detailMessage; + } + view.isError = isError; + views.append(std::move(view)); + } + }; + append(diag.errors, true); + append(diag.warnings, false); + return views; } -view::ModDiagnosticsView DiagnosticsService::toView(const checker::ModDiagnostics& diag) { - view::ModDiagnosticsView view; - view.hasErrors = diag.hasErrors(); - view.hasWarnings = diag.hasWarnings(); - view.errorTooltip = diag.errorTooltip(); - view.warningTooltip = diag.warningTooltip(); - return view; +view::ModDiagnosticsView DiagnosticsService::toView( + const checker::ModDiagnostics& diag) { + view::ModDiagnosticsView view; + view.hasErrors = diag.hasErrors(); + view.hasWarnings = diag.hasWarnings(); + view.errorTooltip = diag.errorTooltip(); + view.warningTooltip = diag.warningTooltip(); + return view; } // ── Checker callback ─────────────────────────────────────── void DiagnosticsService::onCheckerDiagnosticsChanged( const checker::DiagnosticsByUuid& diagnostics) { - QHash summary; - summary.reserve(diagnostics.size()); - for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { - summary.insert(it.key(), toView(it.value())); - } - m_lastSummary = std::move(summary); - // Sidebar first: the summary handler re-applies the current sidebar filter and must see the - // entries for this state, not the previous one. - sidebarEntriesChanged.notify(buildSidebarEntries()); - diagnosticsSummaryChanged.notify(m_lastSummary); - statusMessageChanged.notify(formatStatus()); + QHash summary; + summary.reserve(diagnostics.size()); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + summary.insert(it.key(), toView(it.value())); + } + m_lastSummary = std::move(summary); + // Sidebar first: the summary handler re-applies the current sidebar filter + // and must see the entries for this state, not the previous one. + sidebarEntriesChanged.notify(buildSidebarEntries()); + diagnosticsSummaryChanged.notify(m_lastSummary); + statusMessageChanged.notify(formatStatus()); } // ── Status ───────────────────────────────────────────────── QString DiagnosticsService::formatStatus() const { - const auto diagnostics = m_checker.activeModDiagnostics(); - int errCount = 0; - int warnCount = 0; - for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { - errCount += diag.hasErrors() ? 1 : 0; - warnCount += diag.hasWarnings() ? 1 : 0; - } - - QStringList parts{QString::number(m_lastActiveUuids.size()) + QStringLiteral(" active")}; - if (errCount > 0) { - parts.append(QString::number(errCount) + QStringLiteral(" with errors")); - } - if (warnCount > 0) { - parts.append(QString::number(warnCount) + QStringLiteral(" with warnings")); - } - return parts.join(QStringLiteral(" | ")); + const auto diagnostics = m_checker.activeModDiagnostics(); + int errCount = 0; + int warnCount = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + errCount += diag.hasErrors() ? 1 : 0; + warnCount += diag.hasWarnings() ? 1 : 0; + } + + QStringList parts{QString::number(m_lastActiveUuids.size()) + + QStringLiteral(" active")}; + if (errCount > 0) { + parts.append(QString::number(errCount) + QStringLiteral(" with errors")); + } + if (warnCount > 0) { + parts.append(QString::number(warnCount) + QStringLiteral(" with warnings")); + } + return parts.join(QStringLiteral(" | ")); } // ── Sidebar ──────────────────────────────────────────────── QList DiagnosticsService::buildSidebarEntries() { - const QHash& mods = m_ctx.allMods(); - if (!m_sidebarBase || !m_sidebarBase->matches(mods)) { - m_sidebarBase.emplace(mods); + const QHash& mods = m_ctx.allMods(); + if (!m_sidebarBase || !m_sidebarBase->matches(mods)) { + m_sidebarBase.emplace(mods); + } + const SidebarBase& base = *m_sidebarBase; + + QSet errors = base.invalid; + QSet warnings; + const auto diagnostics = m_checker.activeModDiagnostics(); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + if (!mods.contains(it.key())) { + continue; } - const SidebarBase& base = *m_sidebarBase; - - QSet errors = base.invalid; - QSet warnings; - const auto diagnostics = m_checker.activeModDiagnostics(); - for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { - if (!mods.contains(it.key())) { - continue; - } - if (it.value().hasErrors()) { - errors.insert(it.key()); - } - if (it.value().hasWarnings()) { - warnings.insert(it.key()); - } + if (it.value().hasErrors()) { + errors.insert(it.key()); } - - const QSet active(m_lastActiveUuids.cbegin(), m_lastActiveUuids.cend()); - QSet inactive = base.allUuids; - inactive.subtract(active); - - using Kind = view::SidebarEntry::Kind; - QList entries{ - makeEntry(Kind::kAll, QStringLiteral("All"), base.allUuids), - makeEntry(Kind::kActive, QStringLiteral("Active"), active), - makeEntry(Kind::kInactive, QStringLiteral("Inactive"), std::move(inactive)), - makeEntry(Kind::kWithErrors, QStringLiteral("With errors"), std::move(errors)), - makeEntry(Kind::kWithWarnings, QStringLiteral("With warnings"), std::move(warnings)), - }; - for (auto it = base.byProvider.cbegin(); it != base.byProvider.cend(); ++it) { - entries.append(makeEntry(Kind::kProvider, - view::providerLabel(it.key()), - it.value(), - it.key())); + if (it.value().hasWarnings()) { + warnings.insert(it.key()); } - return entries; + } + + const QSet active(m_lastActiveUuids.cbegin(), + m_lastActiveUuids.cend()); + QSet inactive = base.allUuids; + inactive.subtract(active); + + using Kind = view::SidebarEntry::Kind; + QList entries{ + makeEntry(Kind::kAll, QStringLiteral("All"), base.allUuids), + makeEntry(Kind::kActive, QStringLiteral("Active"), active), + makeEntry(Kind::kInactive, QStringLiteral("Inactive"), + std::move(inactive)), + makeEntry(Kind::kWithErrors, QStringLiteral("With errors"), + std::move(errors)), + makeEntry(Kind::kWithWarnings, QStringLiteral("With warnings"), + std::move(warnings)), + }; + for (auto it = base.byProvider.cbegin(); it != base.byProvider.cend(); ++it) { + entries.append(makeEntry(Kind::kProvider, view::providerLabel(it.key()), + it.value(), it.key())); + } + return entries; } -DiagnosticsService::SidebarBase::SidebarBase(const QHash& current) +DiagnosticsService::SidebarBase::SidebarBase( + const QHash& current) : mods(current) { - for (auto it = current.cbegin(); it != current.cend(); ++it) { - allUuids.insert(it.key()); - byProvider[it.value()->providerId].insert(it.key()); - if (!it.value()->valid) { - invalid.insert(it.key()); - } + for (auto it = current.cbegin(); it != current.cend(); ++it) { + allUuids.insert(it.key()); + byProvider[it.value()->providerId].insert(it.key()); + if (!it.value()->valid) { + invalid.insert(it.key()); } + } } // Compares by mod identity: the same uuids mapping to the same objects. -bool DiagnosticsService::SidebarBase::matches(const QHash& current) const { - if (mods.size() != current.size()) { - return false; - } - for (auto it = current.cbegin(); it != current.cend(); ++it) { - const auto cached = mods.constFind(it.key()); - if (cached == mods.cend() || cached.value() != it.value()) { - return false; - } +bool DiagnosticsService::SidebarBase::matches( + const QHash& current) const { + if (mods.size() != current.size()) { + return false; + } + for (auto it = current.cbegin(); it != current.cend(); ++it) { + const auto cached = mods.constFind(it.key()); + if (cached == mods.cend() || cached.value() != it.value()) { + return false; } - return true; + } + return true; } } // namespace px::modrim diff --git a/src/core/services/diagnostics_service.hpp b/src/core/services/diagnostics_service.hpp index 1758e97..1aad84a 100644 --- a/src/core/services/diagnostics_service.hpp +++ b/src/core/services/diagnostics_service.hpp @@ -1,14 +1,14 @@ #pragma once -#include - -#include #include #include #include #include #include #include +#include + +#include #include "core/checker/checker.hpp" #include "core/checker/databases.hpp" @@ -27,77 +27,83 @@ namespace px::modrim { class CoreContext; -// Keeps the checker in step with the active mods and the rule databases, and publishes the -// diagnostics summary, the sidebar filters and the status line derived from it. +// Keeps the checker in step with the active mods and the rule databases, and +// publishes the diagnostics summary, the sidebar filters and the status line +// derived from it. class DiagnosticsService { -public: - // `network` is not owned and must outlive the service. - DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network); - - Event> diagnosticsSummaryChanged; - Event statusMessageChanged; - Event backgroundTaskChanged; - Event> sidebarEntriesChanged; - - // ── Lifecycle ───────────────────────────────────────────── - - // Applies cached databases and community rules, and starts a background refresh of stale - // databases. Fails when the community rules file is malformed. - QCoro::Task> initialize(Profiler* timer = nullptr); - - // ── Mutations ───────────────────────────────────────────── - - // Rebuilds the checker for the context's active mods, or for `active_uuids`. - void rebuild(Profiler* timer = nullptr); - void rebuild(const QStringList& activeUuids, Profiler* timer = nullptr); - - // Notifies the checker of a new active mod order. - void reorder(const QStringList& activeUuids); - - // ── Queries ─────────────────────────────────────────────── - - QHash activeModsByPid() const; - const checker::ConstraintGraph& constraintGraph() const; - QList activeOrderedPids() const; - const sort::CommunityRules& communityRules() const; - - // Views of every issue on a mod; empty when the mod is not active. - QList issuesFor(const QString& uuid) const; - std::optional summaryFor(const QString& uuid) const; - int activeErrorCount() const; - -private: - // Partitions that do not depend on diagnostics; reused until the mod list changes. - struct SidebarBase { - QHash mods; - QSet allUuids; - QSet invalid; - QMap> byProvider; - - explicit SidebarBase(const QHash& current); - bool matches(const QHash& current) const; - }; - - QCoro::Task ensureDatabases(); - QCoro::Task downloadDatabases(bool nvw, bool uti); - // The community rules file to load, or nullopt when rules are disabled or no file exists. - std::optional communityRulesPath() const; - - static QList toIssueViews(const checker::ModDiagnostics& diag); - static view::ModDiagnosticsView toView(const checker::ModDiagnostics& diag); - - void onCheckerDiagnosticsChanged(const checker::DiagnosticsByUuid& diagnostics); - QString formatStatus() const; - QList buildSidebarEntries(); - - CoreContext& m_ctx; - checker::NoVersionWarningService m_noVersionWarningService; - checker::UseThisInsteadService m_useThisInsteadService; - checker::ModChecker m_checker; - std::optional> m_dbTask; - QStringList m_lastActiveUuids; - QHash m_lastSummary; - std::optional m_sidebarBase; + public: + // `network` is not owned and must outlive the service. + DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network); + + Event> diagnosticsSummaryChanged; + Event statusMessageChanged; + Event backgroundTaskChanged; + Event> sidebarEntriesChanged; + + // ── Lifecycle ───────────────────────────────────────────── + + // Applies cached databases and community rules, and starts a background + // refresh of stale databases. Fails when the community rules file is + // malformed. + QCoro::Task> initialize(Profiler* timer = nullptr); + + // ── Mutations ───────────────────────────────────────────── + + // Rebuilds the checker for the context's active mods, or for `active_uuids`. + void rebuild(Profiler* timer = nullptr); + void rebuild(const QStringList& activeUuids, Profiler* timer = nullptr); + + // Notifies the checker of a new active mod order. + void reorder(const QStringList& activeUuids); + + // ── Queries ─────────────────────────────────────────────── + + QHash activeModsByPid() const; + const checker::ConstraintGraph& constraintGraph() const; + QList activeOrderedPids() const; + const sort::CommunityRules& communityRules() const; + + // Views of every issue on a mod; empty when the mod is not active. + QList issuesFor(const QString& uuid) const; + std::optional summaryFor(const QString& uuid) const; + int activeErrorCount() const; + + private: + // Partitions that do not depend on diagnostics; reused until the mod list + // changes. + struct SidebarBase { + QHash mods; + QSet allUuids; + QSet invalid; + QMap> byProvider; + + explicit SidebarBase(const QHash& current); + bool matches(const QHash& current) const; + }; + + QCoro::Task ensureDatabases(); + QCoro::Task downloadDatabases(bool nvw, bool uti); + // The community rules file to load, or nullopt when rules are disabled or no + // file exists. + std::optional communityRulesPath() const; + + static QList toIssueViews( + const checker::ModDiagnostics& diag); + static view::ModDiagnosticsView toView(const checker::ModDiagnostics& diag); + + void onCheckerDiagnosticsChanged( + const checker::DiagnosticsByUuid& diagnostics); + QString formatStatus() const; + QList buildSidebarEntries(); + + CoreContext& m_ctx; + checker::NoVersionWarningService m_noVersionWarningService; + checker::UseThisInsteadService m_useThisInsteadService; + checker::ModChecker m_checker; + std::optional> m_dbTask; + QStringList m_lastActiveUuids; + QHash m_lastSummary; + std::optional m_sidebarBase; }; } // namespace px::modrim diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp index c91ff93..7f556cf 100644 --- a/src/core/services/game_launcher.cpp +++ b/src/core/services/game_launcher.cpp @@ -1,11 +1,12 @@ #include "core/services/game_launcher.hpp" +#include +#include #include #include #include -#include -#include + #include #include "core/context.hpp" @@ -19,112 +20,121 @@ namespace px::modrim::services { namespace { -Result directArgv(const QString& exe, const AppConfig& cfg, QHash& env) { - const QString args = QString::fromStdString(cfg.launch_args); - if (currentPlatform() == Platform::kMacOs) { - auto split = splitArguments(args); - if (!split) { - return std::unexpected(split.error()); - } - return QStringList{QStringLiteral("open"), exe, QStringLiteral("--args")} + *split; +Result directArgv(const QString& exe, const AppConfig& cfg, + QHash& env) { + const QString args = QString::fromStdString(cfg.launch_args); + if (currentPlatform() == Platform::kMacOs) { + auto split = splitArguments(args); + if (!split) { + return std::unexpected(split.error()); } - auto command = buildDirectCommand(exe, QString::fromStdString(cfg.launch_wrapper), args); - if (!command) { - return std::unexpected(command.error()); - } - env = std::move(command->env); - return std::move(command->argv); + return QStringList{QStringLiteral("open"), exe, QStringLiteral("--args")} + + *split; + } + auto command = + buildDirectCommand(exe, QString::fromStdString(cfg.launch_wrapper), args); + if (!command) { + return std::unexpected(command.error()); + } + env = std::move(command->env); + return std::move(command->argv); } } // namespace -GameLauncher::GameLauncher(CoreContext& ctx, ProcessLister lister, MonitorTiming timing) +GameLauncher::GameLauncher(CoreContext& ctx, ProcessLister lister, + MonitorTiming timing) : m_ctx(ctx), m_lister(lister), m_monitor(std::move(lister), timing) {} QCoro::Task> GameLauncher::launch(LaunchStrategy strategy) { - if (m_ctx.config().paths.game.empty()) { - spdlog::warn("Launch aborted — game path not configured"); - co_return std::unexpected(Error{QStringLiteral("Game path not configured")}); - } - - spdlog::info("Launching game with strategy: {}", - strategy == LaunchStrategy::kDirect ? "Direct" : "Steam"); - co_await m_monitor.captureBaseline(); - if (strategy == LaunchStrategy::kDirect) { - co_return co_await launchDirect(); - } - co_return co_await launchSteam(); + if (m_ctx.config().paths.game.empty()) { + spdlog::warn("Launch aborted — game path not configured"); + co_return std::unexpected( + Error{QStringLiteral("Game path not configured")}); + } + + spdlog::info("Launching game with strategy: {}", + strategy == LaunchStrategy::kDirect ? "Direct" : "Steam"); + co_await m_monitor.captureBaseline(); + if (strategy == LaunchStrategy::kDirect) { + co_return co_await launchDirect(); + } + co_return co_await launchSteam(); } -bool GameLauncher::isRunning() const { - return isGameRunning(m_lister); -} +bool GameLauncher::isRunning() const { return isGameRunning(m_lister); } -QCoro::Task GameLauncher::waitForStart(std::chrono::milliseconds timeout) { - return m_monitor.waitForStart(timeout); +QCoro::Task GameLauncher::waitForStart( + std::chrono::milliseconds timeout) { + return m_monitor.waitForStart(timeout); } QCoro::Task> GameLauncher::waitForExit() { - return m_monitor.waitForExit(); + return m_monitor.waitForExit(); } QCoro::Task> GameLauncher::launchDirect() { - const QString game = QString::fromStdString(m_ctx.config().paths.game); - const auto exe = findExecutable(game); - if (!exe) { - spdlog::warn("Direct launch failed — executable not found in {}", game.toStdString()); - co_return std::unexpected(Error{QStringLiteral("Game executable not found")}); - } - - ensureSteamAppid(game); - - QHash env; - auto argv = directArgv(*exe, m_ctx.config(), env); - if (!argv) { - spdlog::warn("Invalid launch arguments: {}", argv.error().message.toStdString()); - co_return std::unexpected( - Error{QStringLiteral("Invalid launch arguments: %1").arg(argv.error().message)}); - } - - spdlog::info("Spawning process: {} (cwd: {})", argv->join(QLatin1Char(' ')).toStdString(), + const QString game = QString::fromStdString(m_ctx.config().paths.game); + const auto exe = findExecutable(game); + if (!exe) { + spdlog::warn("Direct launch failed — executable not found in {}", game.toStdString()); - auto proc = std::make_unique(); - proc->setProgram(argv->front()); - proc->setArguments(argv->mid(1)); - proc->setWorkingDirectory(game); - QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); - for (auto it = env.cbegin(); it != env.cend(); ++it) { - environment.insert(it.key(), it.value()); - } - proc->setProcessEnvironment(environment); - proc->setStandardOutputFile(QProcess::nullDevice()); - proc->setStandardErrorFile(QProcess::nullDevice()); + co_return std::unexpected( + Error{QStringLiteral("Game executable not found")}); + } + + ensureSteamAppid(game); + + QHash env; + auto argv = directArgv(*exe, m_ctx.config(), env); + if (!argv) { + spdlog::warn("Invalid launch arguments: {}", + argv.error().message.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Invalid launch arguments: %1") + .arg(argv.error().message)}); + } + + spdlog::info("Spawning process: {} (cwd: {})", + argv->join(QLatin1Char(' ')).toStdString(), game.toStdString()); + auto proc = std::make_unique(); + proc->setProgram(argv->front()); + proc->setArguments(argv->mid(1)); + proc->setWorkingDirectory(game); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + for (auto it = env.cbegin(); it != env.cend(); ++it) { + environment.insert(it.key(), it.value()); + } + proc->setProcessEnvironment(environment); + proc->setStandardOutputFile(QProcess::nullDevice()); + proc->setStandardErrorFile(QProcess::nullDevice()); #if defined(Q_OS_UNIX) - // The game gets its own session, as it did under start_new_session=True. - proc->setChildProcessModifier([] { ::setsid(); }); + // The game gets its own session, as it did under start_new_session=True. + proc->setChildProcessModifier([] { ::setsid(); }); #endif - proc->start(); - if (!proc->waitForStarted()) { - const QString reason = proc->errorString(); - spdlog::warn("Direct launch failed: {}", reason.toStdString()); - co_return std::unexpected(Error{QStringLiteral("Failed to launch: %1").arg(reason)}); - } - - m_monitor.attachProcess(std::move(proc)); - co_return QStringLiteral("Game launched"); + proc->start(); + if (!proc->waitForStarted()) { + const QString reason = proc->errorString(); + spdlog::warn("Direct launch failed: {}", reason.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Failed to launch: %1").arg(reason)}); + } + + m_monitor.attachProcess(std::move(proc)); + co_return QStringLiteral("Game launched"); } QCoro::Task> GameLauncher::launchSteam() { - const QString args = QString::fromStdString(m_ctx.config().launch_args); - auto opened = co_await providers::runOnPool>(nullptr, [args] { - return openSteamUrl(args); - }); - if (!opened) { - spdlog::warn("Steam launch failed: {}", opened.error().message.toStdString()); - co_return std::unexpected( - Error{QStringLiteral("Steam launch failed: %1").arg(opened.error().message)}); - } - co_return QStringLiteral("Launching via Steam..."); + const QString args = QString::fromStdString(m_ctx.config().launch_args); + auto opened = co_await providers::runOnPool>( + nullptr, [args] { return openSteamUrl(args); }); + if (!opened) { + spdlog::warn("Steam launch failed: {}", + opened.error().message.toStdString()); + co_return std::unexpected(Error{ + QStringLiteral("Steam launch failed: %1").arg(opened.error().message)}); + } + co_return QStringLiteral("Launching via Steam..."); } } // namespace px::modrim::services diff --git a/src/core/services/game_launcher.hpp b/src/core/services/game_launcher.hpp index 183414c..fb900b3 100644 --- a/src/core/services/game_launcher.hpp +++ b/src/core/services/game_launcher.hpp @@ -1,10 +1,10 @@ #pragma once +#include #include #include #include -#include #include "core/constants.hpp" #include "core/error.hpp" @@ -16,32 +16,36 @@ class CoreContext; namespace px::modrim::services { -// Starts RimWorld directly or through Steam, and tracks the game until it exits. +// Starts RimWorld directly or through Steam, and tracks the game until it +// exits. class GameLauncher { -public: - // `ctx` is not owned and must outlive the launcher. - GameLauncher(CoreContext& ctx, ProcessLister lister = hostProcessLister, - MonitorTiming timing = {}); + public: + // `ctx` is not owned and must outlive the launcher. + GameLauncher(CoreContext& ctx, ProcessLister lister = hostProcessLister, + MonitorTiming timing = {}); - // Resolves to a status message when the launch was started, or to the reason it was not. - QCoro::Task> launch(LaunchStrategy strategy); + // Resolves to a status message when the launch was started, or to the reason + // it was not. + QCoro::Task> launch(LaunchStrategy strategy); - // Whether a game process is running right now. Blocks while listing processes. - bool isRunning() const; + // Whether a game process is running right now. Blocks while listing + // processes. + bool isRunning() const; - QCoro::Task waitForStart(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + QCoro::Task waitForStart( + std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); - // Resolves to the spawned process's exit code, or nullopt when it was not spawned here or - // has not exited. - QCoro::Task> waitForExit(); + // Resolves to the spawned process's exit code, or nullopt when it was not + // spawned here or has not exited. + QCoro::Task> waitForExit(); -private: - QCoro::Task> launchDirect(); - QCoro::Task> launchSteam(); + private: + QCoro::Task> launchDirect(); + QCoro::Task> launchSteam(); - CoreContext& m_ctx; - ProcessLister m_lister; - GameProcessMonitor m_monitor; + CoreContext& m_ctx; + ProcessLister m_lister; + GameProcessMonitor m_monitor; }; } // namespace px::modrim::services diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 1510dce..514f9b0 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -1,5 +1,10 @@ #include "core/services/game_process.hpp" +#include +#include +#include +#include +#include #include #include #include @@ -7,11 +12,7 @@ #include #include -#include -#include -#include -#include -#include + #include #include "core/constants.hpp" @@ -31,9 +32,10 @@ #include #elif defined(Q_OS_MACOS) #include +#include + #include #include -#include #endif namespace px::modrim::services { @@ -49,405 +51,429 @@ const QSet kProcessNames = { QStringLiteral("RimWorld by Ludeon Studios"), }; -// Simple POSIX shlex-style tokenizer. Handles single/double quotes and backslash escapes. +// Simple POSIX shlex-style tokenizer. Handles single/double quotes and +// backslash escapes. Result splitPosix(const QString& text) { - QStringList tokens; - QString current; - enum State { kSpace, kWord, kSingleQuote, kDoubleQuote } state = kSpace; - bool escaped = false; - - for (const QChar ch : text) { - if (escaped) { - if (state == kDoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && ch != u'`') { - current.append(u'\\'); - } - current.append(ch); - escaped = false; - continue; + QStringList tokens; + QString current; + enum State { kSpace, kWord, kSingleQuote, kDoubleQuote } state = kSpace; + bool escaped = false; + + for (const QChar ch : text) { + if (escaped) { + if (state == kDoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && + ch != u'`') { + current.append(u'\\'); + } + current.append(ch); + escaped = false; + continue; + } + + switch (state) { + case kSpace: + if (ch.isSpace()) { + continue; + } + if (ch == u'\\') { + escaped = true; + state = kWord; + } else if (ch == u'\'') { + state = kSingleQuote; + } else if (ch == u'"') { + state = kDoubleQuote; + } else { + current.append(ch); + state = kWord; } + break; + + case kWord: + if (ch.isSpace()) { + tokens.append(current); + current.clear(); + state = kSpace; + } else if (ch == u'\\') { + escaped = true; + } else if (ch == u'\'') { + state = kSingleQuote; + } else if (ch == u'"') { + state = kDoubleQuote; + } else { + current.append(ch); + } + break; - switch (state) { - case kSpace: - if (ch.isSpace()) { - continue; - } - if (ch == u'\\') { - escaped = true; - state = kWord; - } else if (ch == u'\'') { - state = kSingleQuote; - } else if (ch == u'"') { - state = kDoubleQuote; - } else { - current.append(ch); - state = kWord; - } - break; - - case kWord: - if (ch.isSpace()) { - tokens.append(current); - current.clear(); - state = kSpace; - } else if (ch == u'\\') { - escaped = true; - } else if (ch == u'\'') { - state = kSingleQuote; - } else if (ch == u'"') { - state = kDoubleQuote; - } else { - current.append(ch); - } - break; - - case kSingleQuote: - if (ch == u'\'') { - state = kWord; - } else { - current.append(ch); - } - break; - - case kDoubleQuote: - if (ch == u'\\') { - escaped = true; - } else if (ch == u'"') { - state = kWord; - } else { - current.append(ch); - } - break; + case kSingleQuote: + if (ch == u'\'') { + state = kWord; + } else { + current.append(ch); } - } + break; - if (escaped || state == kSingleQuote || state == kDoubleQuote) { - return std::unexpected(Error{QStringLiteral("No closing quotation")}); - } - if (state == kWord || !current.isEmpty()) { - tokens.append(current); + case kDoubleQuote: + if (ch == u'\\') { + escaped = true; + } else if (ch == u'"') { + state = kWord; + } else { + current.append(ch); + } + break; } - return tokens; + } + + if (escaped || state == kSingleQuote || state == kDoubleQuote) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (state == kWord || !current.isEmpty()) { + tokens.append(current); + } + return tokens; } -// Python's shlex.split(posix=False), which the Windows launch path uses: quotes are kept in the -// token and there are no escapes, so whitespace inside quotes does not split. +// Python's shlex.split(posix=False), which the Windows launch path uses: quotes +// are kept in the token and there are no escapes, so whitespace inside quotes +// does not split. Result splitWindows(const QString& text) { - QStringList tokens; - QString current; - bool inToken = false; - QChar quote; - for (const QChar ch : text) { - if (!quote.isNull()) { - current.append(ch); - if (ch == quote) { - quote = QChar(); - } - continue; - } - if (ch == u'"' || ch == u'\'') { - quote = ch; - current.append(ch); - inToken = true; - } else if (ch.isSpace()) { - if (inToken) { - tokens.append(current); - current.clear(); - inToken = false; - } - } else { - current.append(ch); - inToken = true; - } - } + QStringList tokens; + QString current; + bool inToken = false; + QChar quote; + for (const QChar ch : text) { if (!quote.isNull()) { - return std::unexpected(Error{QStringLiteral("No closing quotation")}); + current.append(ch); + if (ch == quote) { + quote = QChar(); + } + continue; } - if (inToken) { + if (ch == u'"' || ch == u'\'') { + quote = ch; + current.append(ch); + inToken = true; + } else if (ch.isSpace()) { + if (inToken) { tokens.append(current); + current.clear(); + inToken = false; + } + } else { + current.append(ch); + inToken = true; } - return tokens; + } + if (!quote.isNull()) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (inToken) { + tokens.append(current); + } + return tokens; } bool isEnvAssignment(const QString& token) { - static const QRegularExpression pattern(QStringLiteral("^[A-Za-z_][A-Za-z0-9_]*=")); - return pattern.match(token).hasMatch(); + static const QRegularExpression pattern( + QStringLiteral("^[A-Za-z_][A-Za-z0-9_]*=")); + return pattern.match(token).hasMatch(); } } // namespace Platform currentPlatform() { #if defined(Q_OS_MACOS) - return Platform::MacOS; + return Platform::MacOS; #elif defined(Q_OS_WIN) - return Platform::Windows; + return Platform::Windows; #else - return Platform::kLinux; + return Platform::kLinux; #endif } Result splitArguments(const QString& text, Platform platform) { - return platform == Platform::kWindows ? splitWindows(text) : splitPosix(text); + return platform == Platform::kWindows ? splitWindows(text) : splitPosix(text); } -Result buildDirectCommand(const QString& exe, const QString& wrapper, - const QString& args, Platform platform) { - auto splitArgs = splitArguments(args, platform); - if (!splitArgs) { - return std::unexpected(splitArgs.error()); - } - auto splitWrapper = splitArguments(wrapper, platform); - if (!splitWrapper) { - return std::unexpected(splitWrapper.error()); - } - - QStringList game; - game.append(exe); - game.append(*splitArgs); - - QStringList tokens = *splitWrapper; - DirectCommand result; - while (!tokens.isEmpty() && isEnvAssignment(tokens.front())) { - const QString token = tokens.takeFirst(); - const qsizetype eq = token.indexOf(u'='); - result.env.insert(token.left(eq), token.mid(eq + 1)); - } - - const qsizetype placeholder = tokens.indexOf(QStringLiteral("%command%")); - if (placeholder >= 0) { - result.argv = tokens.mid(0, placeholder); - result.argv.append(game); - result.argv.append(tokens.mid(placeholder + 1)); - } else { - result.argv = tokens; - result.argv.append(game); - } - return result; +Result buildDirectCommand(const QString& exe, + const QString& wrapper, + const QString& args, + Platform platform) { + auto splitArgs = splitArguments(args, platform); + if (!splitArgs) { + return std::unexpected(splitArgs.error()); + } + auto splitWrapper = splitArguments(wrapper, platform); + if (!splitWrapper) { + return std::unexpected(splitWrapper.error()); + } + + QStringList game; + game.append(exe); + game.append(*splitArgs); + + QStringList tokens = *splitWrapper; + DirectCommand result; + while (!tokens.isEmpty() && isEnvAssignment(tokens.front())) { + const QString token = tokens.takeFirst(); + const qsizetype eq = token.indexOf(u'='); + result.env.insert(token.left(eq), token.mid(eq + 1)); + } + + const qsizetype placeholder = tokens.indexOf(QStringLiteral("%command%")); + if (placeholder >= 0) { + result.argv = tokens.mid(0, placeholder); + result.argv.append(game); + result.argv.append(tokens.mid(placeholder + 1)); + } else { + result.argv = tokens; + result.argv.append(game); + } + return result; } -std::optional findExecutable(const QString& gamePath, Platform platform) { - const QDir dir(gamePath); - if (platform == Platform::kLinux) { - for (const char* name : {"RimWorld", "RimWorldLinux"}) { - const QString candidate = dir.filePath(QString::fromLatin1(name)); - const QFileInfo info(candidate); - if (info.isFile() && info.isExecutable()) { - return candidate; - } - } - return std::nullopt; +std::optional findExecutable(const QString& gamePath, + Platform platform) { + const QDir dir(gamePath); + if (platform == Platform::kLinux) { + for (const char* name : {"RimWorld", "RimWorldLinux"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + const QFileInfo info(candidate); + if (info.isFile() && info.isExecutable()) { + return candidate; + } } - if (platform == Platform::kMacOs) { - const QString primary = dir.filePath(QStringLiteral("RimWorld.app")); - if (QFileInfo(primary).isDir()) { - return primary; - } - QStringList apps = dir.entryList(QStringList{QStringLiteral("*.app")}, QDir::Dirs); - apps.sort(); - if (!apps.isEmpty()) { - return dir.filePath(apps.first()); - } - return std::nullopt; + return std::nullopt; + } + if (platform == Platform::kMacOs) { + const QString primary = dir.filePath(QStringLiteral("RimWorld.app")); + if (QFileInfo(primary).isDir()) { + return primary; } - for (const char* name : {"RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"}) { - const QString candidate = dir.filePath(QString::fromLatin1(name)); - if (QFileInfo(candidate).isFile()) { - return candidate; - } + QStringList apps = + dir.entryList(QStringList{QStringLiteral("*.app")}, QDir::Dirs); + apps.sort(); + if (!apps.isEmpty()) { + return dir.filePath(apps.first()); } return std::nullopt; + } + for (const char* name : + {"RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + } + return std::nullopt; } void ensureSteamAppid(const QString& gamePath, Platform platform) { - const QString targetDir = - platform == Platform::kMacOs ? QFileInfo(gamePath).dir().absolutePath() : gamePath; - const QString path = QDir(targetDir).filePath(QStringLiteral("steam_appid.txt")); - if (QFileInfo::exists(path)) { - return; - } - const std::string_view appId(kRimworldSteamAppId.data(), kRimworldSteamAppId.size()); - if (auto written = writeFileAtomically(path, appId); !written) { - spdlog::warn("Failed to create steam_appid.txt: {}", written.error().message.toStdString()); - } else { - spdlog::debug("Created {}", path.toStdString()); - } + const QString targetDir = platform == Platform::kMacOs + ? QFileInfo(gamePath).dir().absolutePath() + : gamePath; + const QString path = + QDir(targetDir).filePath(QStringLiteral("steam_appid.txt")); + if (QFileInfo::exists(path)) { + return; + } + const std::string_view appId(kRimworldSteamAppId.data(), + kRimworldSteamAppId.size()); + if (auto written = writeFileAtomically(path, appId); !written) { + spdlog::warn("Failed to create steam_appid.txt: {}", + written.error().message.toStdString()); + } else { + spdlog::debug("Created {}", path.toStdString()); + } } QString steamRunUrl(const QString& launchArgs) { - const QString trimmed = launchArgs.trimmed(); - if (trimmed.isEmpty()) { - return QStringLiteral("steam://rungameid/%1").arg(QString(kRimworldSteamAppId)); - } - const QByteArray encoded = QUrl::toPercentEncoding(trimmed); - return QStringLiteral("steam://run/%1//%2/") - .arg(QString(kRimworldSteamAppId), QString::fromUtf8(encoded)); + const QString trimmed = launchArgs.trimmed(); + if (trimmed.isEmpty()) { + return QStringLiteral("steam://rungameid/%1") + .arg(QString(kRimworldSteamAppId)); + } + const QByteArray encoded = QUrl::toPercentEncoding(trimmed); + return QStringLiteral("steam://run/%1//%2/") + .arg(QString(kRimworldSteamAppId), QString::fromUtf8(encoded)); } Result openSteamUrl(const QString& launchArgs) { - const QString url = steamRunUrl(launchArgs); - spdlog::info("Opening Steam URL: {}", url.toStdString()); + const QString url = steamRunUrl(launchArgs); + spdlog::info("Opening Steam URL: {}", url.toStdString()); #if defined(Q_OS_WIN) - const bool started = QProcess::startDetached( - QStringLiteral("cmd"), - {QStringLiteral("/c"), QStringLiteral("start"), QString(), url}); + const bool started = QProcess::startDetached( + QStringLiteral("cmd"), + {QStringLiteral("/c"), QStringLiteral("start"), QString(), url}); #elif defined(Q_OS_MACOS) - const bool started = QProcess::startDetached(QStringLiteral("open"), {url}); + const bool started = QProcess::startDetached(QStringLiteral("open"), {url}); #else - const bool started = QProcess::startDetached(QStringLiteral("xdg-open"), {url}); + const bool started = + QProcess::startDetached(QStringLiteral("xdg-open"), {url}); #endif - if (!started) { - return std::unexpected(Error{QStringLiteral("could not start the system URL opener")}); - } - return {}; + if (!started) { + return std::unexpected( + Error{QStringLiteral("could not start the system URL opener")}); + } + return {}; } std::vector hostProcessLister() { - std::vector procs; + std::vector procs; #if defined(Q_OS_LINUX) - DIR* proc = opendir("/proc"); - if (!proc) { - return procs; + DIR* proc = opendir("/proc"); + if (!proc) { + return procs; + } + while (struct dirent* entry = readdir(proc)) { + if (entry->d_type != DT_DIR) { + continue; } - while (struct dirent* entry = readdir(proc)) { - if (entry->d_type != DT_DIR) { - continue; - } - bool ok = false; - const qint64 pid = QString::fromLatin1(entry->d_name).toLongLong(&ok); - if (!ok || pid <= 0) { - continue; - } - QFile file(QStringLiteral("/proc/%1/stat").arg(pid)); - if (!file.open(QIODevice::ReadOnly)) { - continue; - } - const QByteArray stat = file.readAll(); - const qsizetype openParen = stat.indexOf('('); - const qsizetype closeParen = stat.lastIndexOf(')'); - if (openParen < 0 || closeParen <= openParen) { - continue; - } - const QString comm = QString::fromUtf8(stat.mid(openParen + 1, closeParen - openParen - 1)); - const QByteArray rest = stat.mid(closeParen + 2); - const bool zombie = !rest.isEmpty() && rest.at(0) == 'Z'; - procs.push_back(ProcessInfo{pid, comm, zombie}); + bool ok = false; + const qint64 pid = QString::fromLatin1(entry->d_name).toLongLong(&ok); + if (!ok || pid <= 0) { + continue; } - closedir(proc); -#elif defined(Q_OS_WIN) - const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snapshot == INVALID_HANDLE_VALUE) { - return procs; + QFile file(QStringLiteral("/proc/%1/stat").arg(pid)); + if (!file.open(QIODevice::ReadOnly)) { + continue; } - PROCESSENTRY32W entry{}; - entry.dwSize = sizeof(entry); - for (BOOL more = Process32FirstW(snapshot, &entry); more; more = Process32NextW(snapshot, &entry)) { - // The toolhelp snapshot does not expose process state, so zombies are never seen. - procs.push_back(ProcessInfo{static_cast(entry.th32ProcessID), - QString::fromWCharArray(entry.szExeFile), false}); + const QByteArray stat = file.readAll(); + const qsizetype openParen = stat.indexOf('('); + const qsizetype closeParen = stat.lastIndexOf(')'); + if (openParen < 0 || closeParen <= openParen) { + continue; } - CloseHandle(snapshot); + const QString comm = + QString::fromUtf8(stat.mid(openParen + 1, closeParen - openParen - 1)); + const QByteArray rest = stat.mid(closeParen + 2); + const bool zombie = !rest.isEmpty() && rest.at(0) == 'Z'; + procs.push_back(ProcessInfo{pid, comm, zombie}); + } + closedir(proc); +#elif defined(Q_OS_WIN) + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return procs; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + for (BOOL more = Process32FirstW(snapshot, &entry); more; + more = Process32NextW(snapshot, &entry)) { + // The toolhelp snapshot does not expose process state, so zombies are never + // seen. + procs.push_back(ProcessInfo{static_cast(entry.th32ProcessID), + QString::fromWCharArray(entry.szExeFile), + false}); + } + CloseHandle(snapshot); #elif defined(Q_OS_MACOS) - // proc_listallpids takes a buffer size in bytes and returns the number of pids written. - std::vector pids(static_cast(proc_listallpids(nullptr, 0)) + 64); - const int written = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); - // SZOMBIE from , which is not usable from user code. - constexpr std::uint32_t kZombieStatus = 5; - for (int i = 0; i < written; ++i) { - const pid_t pid = pids[static_cast(i)]; - char name[256] = {}; - if (proc_name(pid, name, sizeof(name)) <= 0) { - continue; - } - proc_bsdinfo info{}; - if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != static_cast(sizeof(info))) { - continue; - } - procs.push_back(ProcessInfo{pid, QString::fromUtf8(name), info.pbi_status == kZombieStatus}); + // proc_listallpids takes a buffer size in bytes and returns the number of + // pids written. + std::vector pids( + static_cast(proc_listallpids(nullptr, 0)) + 64); + const int written = proc_listallpids( + pids.data(), static_cast(pids.size() * sizeof(pid_t))); + // SZOMBIE from , which is not usable from user code. + constexpr std::uint32_t kZombieStatus = 5; + for (int i = 0; i < written; ++i) { + const pid_t pid = pids[static_cast(i)]; + char name[256] = {}; + if (proc_name(pid, name, sizeof(name)) <= 0) { + continue; } + proc_bsdinfo info{}; + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != + static_cast(sizeof(info))) { + continue; + } + procs.push_back(ProcessInfo{pid, QString::fromUtf8(name), + info.pbi_status == kZombieStatus}); + } #endif - return procs; + return procs; } QSet liveGamePids(const ProcessLister& lister) { - QSet pids; - for (const ProcessInfo& proc : lister()) { - if (!proc.zombie && kProcessNames.contains(proc.name)) { - pids.insert(proc.pid); - } + QSet pids; + for (const ProcessInfo& proc : lister()) { + if (!proc.zombie && kProcessNames.contains(proc.name)) { + pids.insert(proc.pid); } - return pids; + } + return pids; } bool isGameRunning(const ProcessLister& lister) { - return !liveGamePids(lister).isEmpty(); + return !liveGamePids(lister).isEmpty(); } -GameProcessMonitor::GameProcessMonitor(ProcessLister lister, MonitorTiming timing) +GameProcessMonitor::GameProcessMonitor(ProcessLister lister, + MonitorTiming timing) : m_lister(std::move(lister)), m_timing(timing) {} -GameProcessMonitor::~GameProcessMonitor() { - dropProcess(); -} +GameProcessMonitor::~GameProcessMonitor() { dropProcess(); } QCoro::Task GameProcessMonitor::captureBaseline() { - m_baseline = co_await providers::runOnPool>(nullptr, [this] { - return liveGamePids(m_lister); - }); + m_baseline = co_await providers::runOnPool>( + nullptr, [this] { return liveGamePids(m_lister); }); } void GameProcessMonitor::attachProcess(std::unique_ptr proc) { - dropProcess(); - m_proc = std::move(proc); + dropProcess(); + m_proc = std::move(proc); } bool GameProcessMonitor::launchedAlive() const { - const QSet live = liveGamePids(m_lister); - return !(live - m_baseline).isEmpty(); + const QSet live = liveGamePids(m_lister); + return !(live - m_baseline).isEmpty(); } void GameProcessMonitor::dropProcess() { - if (m_proc && m_proc->state() != QProcess::NotRunning) { - // Intentionally leaked: the child keeps running after the monitor is gone, as it did - // under Popen, and destroying a running QProcess would kill it. - static_cast(m_proc.release()); - return; - } - m_proc.reset(); + if (m_proc && m_proc->state() != QProcess::NotRunning) { + // Intentionally leaked: the child keeps running after the monitor is gone, + // as it did under Popen, and destroying a running QProcess would kill it. + static_cast(m_proc.release()); + return; + } + m_proc.reset(); } -QCoro::Task GameProcessMonitor::waitForStart(std::chrono::milliseconds timeout) { - const auto deadline = std::chrono::steady_clock::now() + timeout; - while (std::chrono::steady_clock::now() < deadline) { - const bool alive = co_await providers::runOnPool(nullptr, [this] { - return launchedAlive(); - }); - if (alive) { - co_return true; - } - if (m_proc && m_proc->state() == QProcess::NotRunning) { - co_return false; - } - if (m_timing.pollStart.count() > 0) { - co_await QCoro::sleepFor(m_timing.pollStart); - } +QCoro::Task GameProcessMonitor::waitForStart( + std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + const bool alive = co_await providers::runOnPool( + nullptr, [this] { return launchedAlive(); }); + if (alive) { + co_return true; + } + if (m_proc && m_proc->state() == QProcess::NotRunning) { + co_return false; } - co_return false; + if (m_timing.pollStart.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollStart); + } + } + co_return false; } QCoro::Task> GameProcessMonitor::waitForExit() { - while (co_await providers::runOnPool(nullptr, [this] { return launchedAlive(); })) { - if (m_timing.pollExit.count() > 0) { - co_await QCoro::sleepFor(m_timing.pollExit); - } - } - if (!m_proc) { - co_return std::nullopt; - } - std::optional code; - if (m_proc->state() == QProcess::NotRunning) { - code = m_proc->exitCode(); + while (co_await providers::runOnPool( + nullptr, [this] { return launchedAlive(); })) { + if (m_timing.pollExit.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollExit); } - dropProcess(); - co_return code; + } + if (!m_proc) { + co_return std::nullopt; + } + std::optional code; + if (m_proc->state() == QProcess::NotRunning) { + code = m_proc->exitCode(); + } + dropProcess(); + co_return code; } } // namespace px::modrim::services diff --git a/src/core/services/game_process.hpp b/src/core/services/game_process.hpp index 01275cf..2e861f5 100644 --- a/src/core/services/game_process.hpp +++ b/src/core/services/game_process.hpp @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include +#include #include #include #include @@ -7,61 +12,62 @@ #include #include -#include -#include -#include -#include -#include #include "core/error.hpp" namespace px::modrim::services { enum class Platform { - kLinux, - kWindows, - kMacOs, + kLinux, + kWindows, + kMacOs, }; // Returns the host's platform. Platform currentPlatform(); -// Splits a command line the way the platform's launcher does: POSIX shell quoting, or the -// Windows command-line rules. Fails on unbalanced quotes. -Result splitArguments(const QString& text, Platform platform = currentPlatform()); +// Splits a command line the way the platform's launcher does: POSIX shell +// quoting, or the Windows command-line rules. Fails on unbalanced quotes. +Result splitArguments(const QString& text, + Platform platform = currentPlatform()); struct DirectCommand { - QStringList argv; - QHash env; + QStringList argv; + QHash env; }; -// Expands wrapper and arguments into argv plus extra environment variables. Leading KEY=VALUE -// tokens in the wrapper set environment variables. %command% marks where the game goes; -// without it the wrapper is a prefix. Fails on unbalanced quotes. -Result buildDirectCommand(const QString& exe, const QString& wrapper, - const QString& args, - Platform platform = currentPlatform()); - -// Locates the RimWorld executable inside `game_path`, preferring modern Waylith binaries over -// leftover Unity files on disk. +// Expands wrapper and arguments into argv plus extra environment variables. +// Leading KEY=VALUE tokens in the wrapper set environment variables. %command% +// marks where the game goes; without it the wrapper is a prefix. Fails on +// unbalanced quotes. +Result buildDirectCommand(const QString& exe, + const QString& wrapper, + const QString& args, + Platform platform = currentPlatform()); + +// Locates the RimWorld executable inside `game_path`, preferring modern Waylith +// binaries over leftover Unity files on disk. std::optional findExecutable(const QString& gamePath, - Platform platform = currentPlatform()); + Platform platform = currentPlatform()); -// Writes steam_appid.txt containing the RimWorld Steam app id into the game folder, unless it -// already exists. On macOS the file lives in the game directory's parent. -void ensureSteamAppid(const QString& gamePath, Platform platform = currentPlatform()); +// Writes steam_appid.txt containing the RimWorld Steam app id into the game +// folder, unless it already exists. On macOS the file lives in the game +// directory's parent. +void ensureSteamAppid(const QString& gamePath, + Platform platform = currentPlatform()); // The steam:// URL that launches RimWorld with the given launch arguments. QString steamRunUrl(const QString& launchArgs); -// Hands the steam:// URL to the desktop's handler. Fails when no opener could be started. +// Hands the steam:// URL to the desktop's handler. Fails when no opener could +// be started. Result openSteamUrl(const QString& launchArgs); // A running process seen on the host. struct ProcessInfo { - qint64 pid = 0; - QString name; - bool zombie = false; + qint64 pid = 0; + QString name; + bool zombie = false; }; // Enumerates processes; an injectable double replaces this in tests. @@ -77,46 +83,50 @@ bool isGameRunning(const ProcessLister& lister = hostProcessLister); QSet liveGamePids(const ProcessLister& lister = hostProcessLister); struct MonitorTiming { - std::chrono::milliseconds pollStart{500}; - std::chrono::milliseconds pollExit{2000}; + std::chrono::milliseconds pollStart{500}; + std::chrono::milliseconds pollExit{2000}; }; -// Tracks the game process lifecycle: detects when a newly spawned game process starts, and when -// all game processes have finished. Process listing runs on the thread pool, so awaiting the -// methods never blocks the event loop. +// Tracks the game process lifecycle: detects when a newly spawned game process +// starts, and when all game processes have finished. Process listing runs on +// the thread pool, so awaiting the methods never blocks the event loop. class GameProcessMonitor { -public: - explicit GameProcessMonitor(ProcessLister lister = hostProcessLister, - MonitorTiming timing = {}); - ~GameProcessMonitor(); - - GameProcessMonitor(const GameProcessMonitor&) = delete; - GameProcessMonitor& operator=(const GameProcessMonitor&) = delete; - - // Records the game PIDs already running, so they are not counted as newly started. - QCoro::Task captureBaseline(); - - // Takes ownership of the spawned process; it must already be started. - void attachProcess(std::unique_ptr proc); - - // Waits until a non-baseline game process appears. Returns false if the timeout expires or - // the spawned process dies before a game process appears. - QCoro::Task waitForStart(std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); - - // Waits until every non-baseline game process exits. Returns the spawned process's exit code - // if one was attached and has exited, or nullopt otherwise. - QCoro::Task> waitForExit(); - -private: - bool launchedAlive() const; - // Forgets the spawned process. A child still running is released rather than destroyed, - // because destroying a QProcess kills its child and the game must outlive the manager. - void dropProcess(); - - ProcessLister m_lister; - MonitorTiming m_timing; - QSet m_baseline; - std::unique_ptr m_proc; + public: + explicit GameProcessMonitor(ProcessLister lister = hostProcessLister, + MonitorTiming timing = {}); + ~GameProcessMonitor(); + + GameProcessMonitor(const GameProcessMonitor&) = delete; + GameProcessMonitor& operator=(const GameProcessMonitor&) = delete; + + // Records the game PIDs already running, so they are not counted as newly + // started. + QCoro::Task captureBaseline(); + + // Takes ownership of the spawned process; it must already be started. + void attachProcess(std::unique_ptr proc); + + // Waits until a non-baseline game process appears. Returns false if the + // timeout expires or the spawned process dies before a game process appears. + QCoro::Task waitForStart( + std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + + // Waits until every non-baseline game process exits. Returns the spawned + // process's exit code if one was attached and has exited, or nullopt + // otherwise. + QCoro::Task> waitForExit(); + + private: + bool launchedAlive() const; + // Forgets the spawned process. A child still running is released rather than + // destroyed, because destroying a QProcess kills its child and the game must + // outlive the manager. + void dropProcess(); + + ProcessLister m_lister; + MonitorTiming m_timing; + QSet m_baseline; + std::unique_ptr m_proc; }; } // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp index 4c1a46d..3cf6f85 100644 --- a/src/core/services/metadata_cache.cpp +++ b/src/core/services/metadata_cache.cpp @@ -1,9 +1,5 @@ #include "core/services/metadata_cache.hpp" -#include -#include -#include - #include #include #include @@ -15,6 +11,10 @@ #include #include #include +#include +#include +#include + #include #include "core/migrator.hpp" @@ -22,8 +22,8 @@ namespace px::modrim::services { -using providers::CacheLookup; using providers::CachedMod; +using providers::CacheLookup; using providers::ModCandidate; namespace { @@ -47,578 +47,634 @@ constexpr char kSchema[] = R"sql( )sql"; Result exec(QSqlDatabase& db, const QString& sql) { - QSqlQuery query(db); - if (!query.exec(sql)) { - return std::unexpected(Error{query.lastError().text()}); - } - return {}; + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; } -// ---- Path normalization ------------------------------------------------------------------- +// ---- Path normalization +// ------------------------------------------------------------------- QString joinPath(const QString& dir, const QString& name) { - return dir.endsWith(u'/') ? dir + name : dir + u'/' + name; + return dir.endsWith(u'/') ? dir + name : dir + u'/' + name; } // "/" for POSIX paths, "C:/" for drive paths. QString rootOf(const QString& absolute) { - if (absolute.size() >= 3 && absolute[0].isLetter() && absolute[1] == u':' && - absolute[2] == u'/') { - return absolute.left(3); - } - return QStringLiteral("/"); + if (absolute.size() >= 3 && absolute[0].isLetter() && absolute[1] == u':' && + absolute[2] == u'/') { + return absolute.left(3); + } + return QStringLiteral("/"); } QString parentOf(const QString& resolved, const QString& root) { - if (resolved.size() <= root.size()) { - return root; - } - const QString parent = resolved.left(resolved.lastIndexOf(u'/')); - return parent.size() < root.size() ? root : parent; + if (resolved.size() <= root.size()) { + return root; + } + const QString parent = resolved.left(resolved.lastIndexOf(u'/')); + return parent.size() < root.size() ? root : parent; } -// Resolves symlinks component by component, consuming ".." after the link target has been -// followed, as Python's posixpath.realpath does. +// Resolves symlinks component by component, consuming ".." after the link +// target has been followed, as Python's posixpath.realpath does. QString resolvePath(const QString& path) { - QString absolute = QDir::fromNativeSeparators(path); - if (!QDir::isAbsolutePath(absolute)) { - absolute = joinPath(QDir::currentPath(), absolute); - } - const QString root = rootOf(absolute); - QStringList pending = absolute.mid(root.size()).split(u'/', Qt::SkipEmptyParts); - QString resolved = root; - int hops = 0; - while (!pending.isEmpty()) { - const QString name = pending.takeFirst(); - if (name == QLatin1String(".")) { - continue; - } - if (name == QLatin1String("..")) { - resolved = parentOf(resolved, root); - continue; - } - const QString candidate = joinPath(resolved, name); - if (!QFileInfo(candidate).isSymLink()) { - resolved = candidate; - continue; - } - if (++hops > kMaxSymlinkHops) { - return candidate; - } - const QString target = QDir::fromNativeSeparators(QFile::symLinkTarget(candidate)); - if (QDir::isAbsolutePath(target)) { - resolved = root; - pending = target.mid(rootOf(target).size()).split(u'/', Qt::SkipEmptyParts) + pending; - } else { - pending = target.split(u'/', Qt::SkipEmptyParts) + pending; - } - } - return resolved; + QString absolute = QDir::fromNativeSeparators(path); + if (!QDir::isAbsolutePath(absolute)) { + absolute = joinPath(QDir::currentPath(), absolute); + } + const QString root = rootOf(absolute); + QStringList pending = + absolute.mid(root.size()).split(u'/', Qt::SkipEmptyParts); + QString resolved = root; + int hops = 0; + while (!pending.isEmpty()) { + const QString name = pending.takeFirst(); + if (name == QLatin1String(".")) { + continue; + } + if (name == QLatin1String("..")) { + resolved = parentOf(resolved, root); + continue; + } + const QString candidate = joinPath(resolved, name); + if (!QFileInfo(candidate).isSymLink()) { + resolved = candidate; + continue; + } + if (++hops > kMaxSymlinkHops) { + return candidate; + } + const QString target = + QDir::fromNativeSeparators(QFile::symLinkTarget(candidate)); + if (QDir::isAbsolutePath(target)) { + resolved = root; + pending = + target.mid(rootOf(target).size()).split(u'/', Qt::SkipEmptyParts) + + pending; + } else { + pending = target.split(u'/', Qt::SkipEmptyParts) + pending; + } + } + return resolved; } -// ---- Payload (de)serialization ------------------------------------------------------------ +// ---- Payload (de)serialization +// ------------------------------------------------------------ QJsonArray sortedArray(const QStringList& values) { - QStringList sorted = values; - sorted.sort(); - return QJsonArray::fromStringList(sorted); + QStringList sorted = values; + sorted.sort(); + return QJsonArray::fromStringList(sorted); } template QStringList sortedStrings(const Set& set) { - QStringList values; - values.reserve(set.size()); - for (const auto& item : set) { - values.append(item.str()); - } - values.sort(); - return values; + QStringList values; + values.reserve(set.size()); + for (const auto& item : set) { + values.append(item.str()); + } + values.sort(); + return values; } QJsonObject serializeMod(const AboutXmlMod& mod) { - QJsonObject object; - object[QStringLiteral("name")] = mod.name; - object[QStringLiteral("package_id")] = mod.packageId.str(); - object[QStringLiteral("authors")] = QJsonArray::fromStringList(mod.authors); - object[QStringLiteral("mod_version")] = mod.modVersion; - object[QStringLiteral("mod_icon_path")] = - mod.modIconPath ? QJsonValue(*mod.modIconPath) : QJsonValue(QJsonValue::Null); - object[QStringLiteral("steam_app_id")] = QJsonValue(static_cast(mod.steamAppId)); - object[QStringLiteral("url")] = mod.url; - object[QStringLiteral("description")] = mod.description; - QStringList versions = mod.supportedVersions.values(); - versions.sort(); - object[QStringLiteral("supported_versions")] = QJsonArray::fromStringList(versions); - object[QStringLiteral("valid")] = mod.valid; - object[QStringLiteral("mtime")] = mod.mtime; - - // Declaration order matters to the checker, which QJsonObject would sort away, so - // dependencies are an array here. deserialize_mod still reads the legacy object form. - QJsonArray dependencies; - for (const DependencyMod& dep : mod.aboutRules.dependencies) { - QJsonObject entry; - entry[QStringLiteral("name")] = dep.name; - entry[QStringLiteral("package_id")] = dep.packageId.str(); - entry[QStringLiteral("workshop_url")] = dep.workshopUrl; - entry[QStringLiteral("alternative_package_ids")] = - sortedArray(sortedStrings(dep.alternativePackageIds)); - dependencies.append(entry); - } - QJsonObject rules; - rules[QStringLiteral("load_before")] = sortedArray(sortedStrings(mod.aboutRules.loadBefore)); - rules[QStringLiteral("load_after")] = sortedArray(sortedStrings(mod.aboutRules.loadAfter)); - rules[QStringLiteral("incompatible_with")] = - sortedArray(sortedStrings(mod.aboutRules.incompatibleWith)); - rules[QStringLiteral("dependencies")] = dependencies; - object[QStringLiteral("about_rules")] = rules; - return object; + QJsonObject object; + object[QStringLiteral("name")] = mod.name; + object[QStringLiteral("package_id")] = mod.packageId.str(); + object[QStringLiteral("authors")] = QJsonArray::fromStringList(mod.authors); + object[QStringLiteral("mod_version")] = mod.modVersion; + object[QStringLiteral("mod_icon_path")] = mod.modIconPath + ? QJsonValue(*mod.modIconPath) + : QJsonValue(QJsonValue::Null); + object[QStringLiteral("steam_app_id")] = + QJsonValue(static_cast(mod.steamAppId)); + object[QStringLiteral("url")] = mod.url; + object[QStringLiteral("description")] = mod.description; + QStringList versions = mod.supportedVersions.values(); + versions.sort(); + object[QStringLiteral("supported_versions")] = + QJsonArray::fromStringList(versions); + object[QStringLiteral("valid")] = mod.valid; + object[QStringLiteral("mtime")] = mod.mtime; + + // Declaration order matters to the checker, which QJsonObject would sort + // away, so dependencies are an array here. deserialize_mod still reads the + // legacy object form. + QJsonArray dependencies; + for (const DependencyMod& dep : mod.aboutRules.dependencies) { + QJsonObject entry; + entry[QStringLiteral("name")] = dep.name; + entry[QStringLiteral("package_id")] = dep.packageId.str(); + entry[QStringLiteral("workshop_url")] = dep.workshopUrl; + entry[QStringLiteral("alternative_package_ids")] = + sortedArray(sortedStrings(dep.alternativePackageIds)); + dependencies.append(entry); + } + QJsonObject rules; + rules[QStringLiteral("load_before")] = + sortedArray(sortedStrings(mod.aboutRules.loadBefore)); + rules[QStringLiteral("load_after")] = + sortedArray(sortedStrings(mod.aboutRules.loadAfter)); + rules[QStringLiteral("incompatible_with")] = + sortedArray(sortedStrings(mod.aboutRules.incompatibleWith)); + rules[QStringLiteral("dependencies")] = dependencies; + object[QStringLiteral("about_rules")] = rules; + return object; } -QString stringOr(const QJsonObject& object, const char* key, const QString& fallback) { - const QJsonValue value = object.value(QLatin1StringView(key)); - return value.isString() ? value.toString() : fallback; +QString stringOr(const QJsonObject& object, const char* key, + const QString& fallback) { + const QJsonValue value = object.value(QLatin1StringView(key)); + return value.isString() ? value.toString() : fallback; } QStringList stringList(const QJsonValue& value) { - QStringList values; - for (const QJsonValue& item : value.toArray()) { - if (item.isString()) { - values.append(item.toString()); - } + QStringList values; + for (const QJsonValue& item : value.toArray()) { + if (item.isString()) { + values.append(item.toString()); } - return values; + } + return values; } -void addDependency(BaseRules& rules, const QJsonObject& data, const QString& fallbackPid) { - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(stringOr(data, "package_id", fallbackPid)); - dep.name = stringOr(data, "name", QString()); - dep.workshopUrl = stringOr(data, "workshop_url", QString()); - dep.alternativePackageIds = CaseInsensitiveSet(stringList(data.value(QLatin1StringView("alternative_package_ids")))); - rules.setDependency(std::move(dep)); +void addDependency(BaseRules& rules, const QJsonObject& data, + const QString& fallbackPid) { + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(stringOr(data, "package_id", fallbackPid)); + dep.name = stringOr(data, "name", QString()); + dep.workshopUrl = stringOr(data, "workshop_url", QString()); + dep.alternativePackageIds = CaseInsensitiveSet( + stringList(data.value(QLatin1StringView("alternative_package_ids")))); + rules.setDependency(std::move(dep)); } void readDependencies(BaseRules& rules, const QJsonValue& value) { - if (value.isArray()) { - for (const QJsonValue& item : value.toArray()) { - if (item.isObject()) { - addDependency(rules, item.toObject(), QStringLiteral("invalid.mod")); - } - } - } else if (value.isObject()) { - const QJsonObject legacy = value.toObject(); - for (auto it = legacy.constBegin(); it != legacy.constEnd(); ++it) { - if (it.value().isObject()) { - addDependency(rules, it.value().toObject(), it.key()); - } - } - } + if (value.isArray()) { + for (const QJsonValue& item : value.toArray()) { + if (item.isObject()) { + addDependency(rules, item.toObject(), QStringLiteral("invalid.mod")); + } + } + } else if (value.isObject()) { + const QJsonObject legacy = value.toObject(); + for (auto it = legacy.constBegin(); it != legacy.constEnd(); ++it) { + if (it.value().isObject()) { + addDependency(rules, it.value().toObject(), it.key()); + } + } + } } // The payload as a fresh AboutXmlMod, without a mod path; the caller sets it. Result> deserializeMod(const QString& payload) { - const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8()); - if (document.isNull() || !document.isObject()) { - return std::unexpected(Error{QStringLiteral("payload is not a JSON object")}); - } - const QJsonObject data = document.object(); - - auto mod = std::make_shared(); - mod->name = stringOr(data, "name", QStringLiteral("Unknown Mod Name")); - mod->packageId = CaseInsensitiveStr(stringOr(data, "package_id", QStringLiteral("invalid.mod"))); - mod->authors = stringList(data.value(QLatin1StringView("authors"))); - mod->modVersion = stringOr(data, "mod_version", QString()); - const QString icon = stringOr(data, "mod_icon_path", QString()); - if (!icon.isEmpty()) { - mod->modIconPath = icon; - } - mod->steamAppId = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); - mod->url = stringOr(data, "url", QString()); - mod->description = stringOr(data, "description", QString()); - const QStringList versions = stringList(data.value(QLatin1StringView("supported_versions"))); - mod->supportedVersions = QSet(versions.begin(), versions.end()); - mod->valid = data.value(QLatin1StringView("valid")).toBool(true); - mod->mtime = data.value(QLatin1StringView("mtime")).toDouble(0.0); - - const QJsonValue rulesValue = data.value(QLatin1StringView("about_rules")); - const QJsonObject rulesData = rulesValue.isObject() ? rulesValue.toObject() : QJsonObject{}; - mod->aboutRules.loadBefore = CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("load_before")))); - mod->aboutRules.loadAfter = CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("load_after")))); - mod->aboutRules.incompatibleWith = - CaseInsensitiveSet(stringList(rulesData.value(QLatin1StringView("incompatible_with")))); - readDependencies(mod->aboutRules, rulesData.value(QLatin1StringView("dependencies"))); - return mod; + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8()); + if (document.isNull() || !document.isObject()) { + return std::unexpected( + Error{QStringLiteral("payload is not a JSON object")}); + } + const QJsonObject data = document.object(); + + auto mod = std::make_shared(); + mod->name = stringOr(data, "name", QStringLiteral("Unknown Mod Name")); + mod->packageId = CaseInsensitiveStr( + stringOr(data, "package_id", QStringLiteral("invalid.mod"))); + mod->authors = stringList(data.value(QLatin1StringView("authors"))); + mod->modVersion = stringOr(data, "mod_version", QString()); + const QString icon = stringOr(data, "mod_icon_path", QString()); + if (!icon.isEmpty()) { + mod->modIconPath = icon; + } + mod->steamAppId = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); + mod->url = stringOr(data, "url", QString()); + mod->description = stringOr(data, "description", QString()); + const QStringList versions = + stringList(data.value(QLatin1StringView("supported_versions"))); + mod->supportedVersions = QSet(versions.begin(), versions.end()); + mod->valid = data.value(QLatin1StringView("valid")).toBool(true); + mod->mtime = data.value(QLatin1StringView("mtime")).toDouble(0.0); + + const QJsonValue rulesValue = data.value(QLatin1StringView("about_rules")); + const QJsonObject rulesData = + rulesValue.isObject() ? rulesValue.toObject() : QJsonObject{}; + mod->aboutRules.loadBefore = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("load_before")))); + mod->aboutRules.loadAfter = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("load_after")))); + mod->aboutRules.incompatibleWith = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("incompatible_with")))); + readDependencies(mod->aboutRules, + rulesData.value(QLatin1StringView("dependencies"))); + return mod; } -// ---- Batch bookkeeping -------------------------------------------------------------------- +// ---- Batch bookkeeping +// -------------------------------------------------------------------- -// Candidates keyed by normalized mod path. Like Python's dict, a repeated key keeps its first -// position and takes the later value. +// Candidates keyed by normalized mod path. Like Python's dict, a repeated key +// keeps its first position and takes the later value. struct NormalizedCandidates { - QList> entries; - QHash index; - - void insert(QString key, const ModCandidate& candidate) { - if (const auto found = index.constFind(key); found != index.constEnd()) { - entries[*found].second = candidate; - return; - } - index.insert(key, entries.size()); - entries.append({std::move(key), candidate}); + QList> entries; + QHash index; + + void insert(QString key, const ModCandidate& candidate) { + if (const auto found = index.constFind(key); found != index.constEnd()) { + entries[*found].second = candidate; + return; } + index.insert(key, entries.size()); + entries.append({std::move(key), candidate}); + } }; -NormalizedCandidates normalizeCandidates(const QList& candidates) { - QStringList modPaths; - modPaths.reserve(candidates.size()); - for (const ModCandidate& candidate : candidates) { - modPaths.append(candidate.modPath); - } - const QStringList normalized = normalizePaths(modPaths); - NormalizedCandidates result; - for (qsizetype i = 0; i < candidates.size(); ++i) { - result.insert(normalized[i], candidates[i]); - } - return result; +NormalizedCandidates normalizeCandidates( + const QList& candidates) { + QStringList modPaths; + modPaths.reserve(candidates.size()); + for (const ModCandidate& candidate : candidates) { + modPaths.append(candidate.modPath); + } + const QStringList normalized = normalizePaths(modPaths); + NormalizedCandidates result; + for (qsizetype i = 0; i < candidates.size(); ++i) { + result.insert(normalized[i], candidates[i]); + } + return result; } struct StoredRow { - QString modPath; - double mtime = 0.0; - qint64 fileSize = 0; - QString targetVersion; - QString payload; + QString modPath; + double mtime = 0.0; + qint64 fileSize = 0; + QString targetVersion; + QString payload; }; -Result> fetchRows(QSqlDatabase& db, const QStringList& normPaths) { - QList rows; - for (qsizetype start = 0; start < normPaths.size(); start += kFetchChunk) { - const qsizetype count = std::min(kFetchChunk, normPaths.size() - start); - QStringList placeholders(count, QStringLiteral("?")); - QSqlQuery query(db); - const QString sql = QStringLiteral("SELECT mod_path, about_path, mtime, file_size, " - "target_version, payload_json FROM mod_metadata " - "WHERE mod_path IN (%1)") - .arg(placeholders.join(QStringLiteral(","))); - if (!query.prepare(sql)) { - return std::unexpected(Error{query.lastError().text()}); - } - for (qsizetype i = 0; i < count; ++i) { - query.addBindValue(normPaths[start + i]); - } - if (!query.exec()) { - return std::unexpected(Error{query.lastError().text()}); - } - while (query.next()) { - rows.append(StoredRow{query.value(0).toString(), query.value(2).toDouble(), - query.value(3).toLongLong(), query.value(4).toString(), - query.value(5).toString()}); - } - } - return rows; +Result> fetchRows(QSqlDatabase& db, + const QStringList& normPaths) { + QList rows; + for (qsizetype start = 0; start < normPaths.size(); start += kFetchChunk) { + const qsizetype count = std::min(kFetchChunk, normPaths.size() - start); + QStringList placeholders(count, QStringLiteral("?")); + QSqlQuery query(db); + const QString sql = QStringLiteral( + "SELECT mod_path, about_path, mtime, file_size, " + "target_version, payload_json FROM mod_metadata " + "WHERE mod_path IN (%1)") + .arg(placeholders.join(QStringLiteral(","))); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (qsizetype i = 0; i < count; ++i) { + query.addBindValue(normPaths[start + i]); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + while (query.next()) { + rows.append( + StoredRow{query.value(0).toString(), query.value(2).toDouble(), + query.value(3).toLongLong(), query.value(4).toString(), + query.value(5).toString()}); + } + } + return rows; } } // namespace QString metadataCachePath(const QString& configDir) { - return QDir(configDir).filePath(QStringLiteral("metadata-cache.db")); + return QDir(configDir).filePath(QStringLiteral("metadata-cache.db")); } -QString normalizePath(const QString& path) { - return resolvePath(path); -} +QString normalizePath(const QString& path) { return resolvePath(path); } QStringList normalizePaths(const QStringList& paths) { - QHash resolvedParents; - QStringList out; - out.reserve(paths.size()); - for (const QString& raw : paths) { - const QString path = QDir::fromNativeSeparators(raw); - const qsizetype slash = path.lastIndexOf(u'/'); - const QString tail = slash < 0 ? QString() : path.mid(slash + 1); - if (slash < 0 || tail.isEmpty() || tail == QLatin1String(".") || tail == QLatin1String("..") || - QFileInfo(raw).isSymLink()) { - out.append(resolvePath(path)); - continue; - } - const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); - auto memo = resolvedParents.constFind(head); - if (memo == resolvedParents.constEnd()) { - memo = resolvedParents.insert(head, resolvePath(head)); - } - out.append(joinPath(*memo, tail)); - } - return out; + QHash resolvedParents; + QStringList out; + out.reserve(paths.size()); + for (const QString& raw : paths) { + const QString path = QDir::fromNativeSeparators(raw); + const qsizetype slash = path.lastIndexOf(u'/'); + const QString tail = slash < 0 ? QString() : path.mid(slash + 1); + if (slash < 0 || tail.isEmpty() || tail == QLatin1String(".") || + tail == QLatin1String("..") || QFileInfo(raw).isSymLink()) { + out.append(resolvePath(path)); + continue; + } + const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); + auto memo = resolvedParents.constFind(head); + if (memo == resolvedParents.constEnd()) { + memo = resolvedParents.insert(head, resolvePath(head)); + } + out.append(joinPath(*memo, tail)); + } + return out; } MetadataCache::MetadataCache(QString path) : m_path(std::move(path)), - m_connectionName(QStringLiteral("px-metadata-cache-%1") - .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { - m_worker.setMaxThreadCount(1); - // Keeps the single worker alive between calls, so the connection stays on one thread. - m_worker.setExpiryTimeout(-1); + m_connectionName( + QStringLiteral("px-metadata-cache-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + m_worker.setMaxThreadCount(1); + // Keeps the single worker alive between calls, so the connection stays on one + // thread. + m_worker.setExpiryTimeout(-1); } MetadataCache::~MetadataCache() { - providers::runOnPool(&m_worker, [this] { closeConnection(); }).waitForFinished(); + providers::runOnPool(&m_worker, [this] { + closeConnection(); + }).waitForFinished(); } QCoro::Task MetadataCache::getMods(QList candidates, - QString targetVersion) { - if (candidates.isEmpty()) { - co_return CacheLookup{}; - } - co_return co_await providers::runOnPool( - &m_worker, [this, candidates = std::move(candidates), - targetVersion = std::move(targetVersion)] { - return lookup(candidates, targetVersion); - }); + QString targetVersion) { + if (candidates.isEmpty()) { + co_return CacheLookup{}; + } + co_return co_await providers::runOnPool( + &m_worker, [this, candidates = std::move(candidates), + targetVersion = std::move(targetVersion)] { + return lookup(candidates, targetVersion); + }); } -QCoro::Task MetadataCache::putMods(QList entries, QString targetVersion) { - if (entries.isEmpty()) { - co_return; - } - co_await providers::runOnPool( - &m_worker, [this, entries = std::move(entries), targetVersion = std::move(targetVersion)] { - store(entries, targetVersion); - }); +QCoro::Task MetadataCache::putMods(QList entries, + QString targetVersion) { + if (entries.isEmpty()) { + co_return; + } + co_await providers::runOnPool( + &m_worker, [this, entries = std::move(entries), + targetVersion = std::move(targetVersion)] { + store(entries, targetVersion); + }); } QCoro::Task MetadataCache::pruneDisappeared(QSet liveModPaths) { - co_await providers::runOnPool( - &m_worker, [this, live = std::move(liveModPaths)] { prune(live); }); + co_await providers::runOnPool( + &m_worker, [this, live = std::move(liveModPaths)] { prune(live); }); } QCoro::Task MetadataCache::close() { - co_await providers::runOnPool(&m_worker, [this] { closeConnection(); }); + co_await providers::runOnPool(&m_worker, [this] { closeConnection(); }); } void MetadataCache::closeConnection() { - if (m_connection) { - m_connection->close(); - m_connection.reset(); - QSqlDatabase::removeDatabase(m_connectionName); - } + if (m_connection) { + m_connection->close(); + m_connection.reset(); + QSqlDatabase::removeDatabase(m_connectionName); + } } QSqlDatabase* MetadataCache::openConnection() { - if (m_disabled) { - return nullptr; - } - if (m_connection) { - return &*m_connection; - } - - if (!QDir().mkpath(QFileInfo(m_path).absolutePath())) { - spdlog::warn("Metadata cache unavailable at {}: cannot create its directory. Disabling cache.", - m_path.toStdString()); - m_disabled = true; - return nullptr; - } - if (auto opened = openDatabase(); !opened) { - spdlog::warn("Corrupt metadata cache database at {}: {}. Attempting recovery.", - m_path.toStdString(), opened.error().message.toStdString()); - removeDatabaseFiles(); - if (auto reopened = openDatabase(); !reopened) { - spdlog::error("Failed to re-initialize metadata cache at {}: {}. Disabling cache.", - m_path.toStdString(), reopened.error().message.toStdString()); - m_disabled = true; - return nullptr; - } - } + if (m_disabled) { + return nullptr; + } + if (m_connection) { return &*m_connection; + } + + if (!QDir().mkpath(QFileInfo(m_path).absolutePath())) { + spdlog::warn( + "Metadata cache unavailable at {}: cannot create its directory. " + "Disabling cache.", + m_path.toStdString()); + m_disabled = true; + return nullptr; + } + if (auto opened = openDatabase(); !opened) { + spdlog::warn( + "Corrupt metadata cache database at {}: {}. Attempting recovery.", + m_path.toStdString(), opened.error().message.toStdString()); + removeDatabaseFiles(); + if (auto reopened = openDatabase(); !reopened) { + spdlog::error( + "Failed to re-initialize metadata cache at {}: {}. Disabling cache.", + m_path.toStdString(), reopened.error().message.toStdString()); + m_disabled = true; + return nullptr; + } + } + return &*m_connection; } Result MetadataCache::openDatabase() { - Result ready; - { - QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); - db.setDatabaseName(m_path); - if (db.open()) { - ready = Result{}; - } else { - ready = std::unexpected(Error{db.lastError().text()}); - } - for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", - "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { - if (!ready) { - break; - } - ready = exec(db, QString::fromLatin1(pragma)); - } - if (ready) { - MigrationSteps steps; - steps[kSchemaVersion] = [&db] { return exec(db, QStringLiteral("DELETE FROM mod_metadata")); }; - ready = ensureSchema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, m_path, - QStringLiteral("metadata cache schema")); - } - if (ready) { - m_connection = db; - return {}; - } - db.close(); - } - // Dropped only once no handle to the connection remains. - QSqlDatabase::removeDatabase(m_connectionName); - return ready; + Result ready; + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(m_path); + if (db.open()) { + ready = Result{}; + } else { + ready = std::unexpected(Error{db.lastError().text()}); + } + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (!ready) { + break; + } + ready = exec(db, QString::fromLatin1(pragma)); + } + if (ready) { + MigrationSteps steps; + steps[kSchemaVersion] = [&db] { + return exec(db, QStringLiteral("DELETE FROM mod_metadata")); + }; + ready = + ensureSchema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, + m_path, QStringLiteral("metadata cache schema")); + } + if (ready) { + m_connection = db; + return {}; + } + db.close(); + } + // Dropped only once no handle to the connection remains. + QSqlDatabase::removeDatabase(m_connectionName); + return ready; } void MetadataCache::removeDatabaseFiles() const { - for (const QString& file : {m_path, m_path + QStringLiteral("-wal"), m_path + QStringLiteral("-shm")}) { - QFile::remove(file); - } + for (const QString& file : {m_path, m_path + QStringLiteral("-wal"), + m_path + QStringLiteral("-shm")}) { + QFile::remove(file); + } } CacheLookup MetadataCache::lookup(const QList& candidates, const QString& targetVersion) { - QSqlDatabase* db = openConnection(); - if (!db) { - return CacheLookup{{}, candidates}; - } - - const NormalizedCandidates normalized = normalizeCandidates(candidates); - QStringList keys; - keys.reserve(normalized.entries.size()); - for (const auto& entry : normalized.entries) { - keys.append(entry.first); - } - - auto rows = fetchRows(*db, keys); - if (!rows) { - spdlog::warn("Error reading metadata cache: {}", rows.error().message.toStdString()); - return CacheLookup{{}, candidates}; - } - - CacheLookup result; - QSet found; - for (const StoredRow& row : *rows) { - const auto index = normalized.index.constFind(row.modPath); - if (index == normalized.index.constEnd()) { - continue; - } - const ModCandidate& candidate = normalized.entries[*index].second; - if (std::abs(row.mtime - candidate.mtime) >= kMtimeTolerance || - row.fileSize != candidate.size || row.targetVersion != targetVersion) { - continue; - } - auto mod = deserializeMod(row.payload); - if (!mod) { - spdlog::warn("Corrupted metadata cache payload for {}: {}", row.modPath.toStdString(), - mod.error().message.toStdString()); - continue; - } - if (auto set = (*mod)->setModPath(candidate.modPath); !set) { - continue; - } - result.cached.append(*mod); - found.insert(row.modPath); - } - for (const auto& entry : normalized.entries) { - if (!found.contains(entry.first)) { - result.missing.append(entry.second); - } - } - return result; + QSqlDatabase* db = openConnection(); + if (!db) { + return CacheLookup{{}, candidates}; + } + + const NormalizedCandidates normalized = normalizeCandidates(candidates); + QStringList keys; + keys.reserve(normalized.entries.size()); + for (const auto& entry : normalized.entries) { + keys.append(entry.first); + } + + auto rows = fetchRows(*db, keys); + if (!rows) { + spdlog::warn("Error reading metadata cache: {}", + rows.error().message.toStdString()); + return CacheLookup{{}, candidates}; + } + + CacheLookup result; + QSet found; + for (const StoredRow& row : *rows) { + const auto index = normalized.index.constFind(row.modPath); + if (index == normalized.index.constEnd()) { + continue; + } + const ModCandidate& candidate = normalized.entries[*index].second; + if (std::abs(row.mtime - candidate.mtime) >= kMtimeTolerance || + row.fileSize != candidate.size || row.targetVersion != targetVersion) { + continue; + } + auto mod = deserializeMod(row.payload); + if (!mod) { + spdlog::warn("Corrupted metadata cache payload for {}: {}", + row.modPath.toStdString(), + mod.error().message.toStdString()); + continue; + } + if (auto set = (*mod)->setModPath(candidate.modPath); !set) { + continue; + } + result.cached.append(*mod); + found.insert(row.modPath); + } + for (const auto& entry : normalized.entries) { + if (!found.contains(entry.first)) { + result.missing.append(entry.second); + } + } + return result; } -void MetadataCache::store(const QList& entries, const QString& targetVersion) { - QSqlDatabase* db = openConnection(); - if (!db) { - return; - } +void MetadataCache::store(const QList& entries, + const QString& targetVersion) { + QSqlDatabase* db = openConnection(); + if (!db) { + return; + } - struct Row { - QString modPath; - QString aboutPath; - double mtime; - qint64 fileSize; - QString payload; - }; - QList rows; - for (const CachedMod& entry : entries) { - if (!entry.mod->modPath()) { - continue; - } - const QJsonDocument document(serializeMod(*entry.mod)); - rows.append(Row{normalizePath(*entry.mod->modPath()), - normalizePath(entry.candidate.aboutXmlPath), entry.candidate.mtime, - entry.candidate.size, QString::fromUtf8(document.toJson(QJsonDocument::Compact))}); - } - - QSqlQuery query(*db); - if (!query.prepare(QStringLiteral("INSERT OR REPLACE INTO mod_metadata " - "(mod_path, about_path, mtime, file_size, target_version, " - "payload_json) VALUES (?, ?, ?, ?, ?, ?)"))) { - spdlog::warn("Error writing to metadata cache: {}", query.lastError().text().toStdString()); - return; - } - db->transaction(); - for (const Row& row : rows) { - query.addBindValue(row.modPath); - query.addBindValue(row.aboutPath); - query.addBindValue(row.mtime); - query.addBindValue(row.fileSize); - query.addBindValue(targetVersion); - query.addBindValue(row.payload); - if (!query.exec()) { - spdlog::warn("Error writing to metadata cache: {}", query.lastError().text().toStdString()); - db->rollback(); - return; - } - } - if (!db->commit()) { - spdlog::warn("Error writing to metadata cache: {}", db->lastError().text().toStdString()); - } + struct Row { + QString modPath; + QString aboutPath; + double mtime; + qint64 fileSize; + QString payload; + }; + QList rows; + for (const CachedMod& entry : entries) { + if (!entry.mod->modPath()) { + continue; + } + const QJsonDocument document(serializeMod(*entry.mod)); + rows.append( + Row{normalizePath(*entry.mod->modPath()), + normalizePath(entry.candidate.aboutXmlPath), entry.candidate.mtime, + entry.candidate.size, + QString::fromUtf8(document.toJson(QJsonDocument::Compact))}); + } + + QSqlQuery query(*db); + if (!query.prepare(QStringLiteral( + "INSERT OR REPLACE INTO mod_metadata " + "(mod_path, about_path, mtime, file_size, target_version, " + "payload_json) VALUES (?, ?, ?, ?, ?, ?)"))) { + spdlog::warn("Error writing to metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const Row& row : rows) { + query.addBindValue(row.modPath); + query.addBindValue(row.aboutPath); + query.addBindValue(row.mtime); + query.addBindValue(row.fileSize); + query.addBindValue(targetVersion); + query.addBindValue(row.payload); + if (!query.exec()) { + spdlog::warn("Error writing to metadata cache: {}", + query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error writing to metadata cache: {}", + db->lastError().text().toStdString()); + } } void MetadataCache::prune(const QSet& liveModPaths) { - QSqlDatabase* db = openConnection(); - if (!db) { - return; - } - - QStringList cached; - { - QSqlQuery query(*db); - if (!query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata"))) { - spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); - return; - } - while (query.next()) { - cached.append(query.value(0).toString()); - } - } - - QStringList disappeared; - for (const QString& path : cached) { - if (!liveModPaths.contains(path)) { - disappeared.append(path); - } - } - if (disappeared.isEmpty()) { - return; - } - spdlog::debug("Pruning {} disappeared mods from metadata cache", disappeared.size()); + QSqlDatabase* db = openConnection(); + if (!db) { + return; + } + QStringList cached; + { QSqlQuery query(*db); - if (!query.prepare(QStringLiteral("DELETE FROM mod_metadata WHERE mod_path = ?"))) { - spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); - return; - } - db->transaction(); - for (const QString& path : disappeared) { - query.addBindValue(path); - if (!query.exec()) { - spdlog::warn("Error pruning metadata cache: {}", query.lastError().text().toStdString()); - db->rollback(); - return; - } - } - if (!db->commit()) { - spdlog::warn("Error pruning metadata cache: {}", db->lastError().text().toStdString()); - } + if (!query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata"))) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + while (query.next()) { + cached.append(query.value(0).toString()); + } + } + + QStringList disappeared; + for (const QString& path : cached) { + if (!liveModPaths.contains(path)) { + disappeared.append(path); + } + } + if (disappeared.isEmpty()) { + return; + } + spdlog::debug("Pruning {} disappeared mods from metadata cache", + disappeared.size()); + + QSqlQuery query(*db); + if (!query.prepare( + QStringLiteral("DELETE FROM mod_metadata WHERE mod_path = ?"))) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const QString& path : disappeared) { + query.addBindValue(path); + if (!query.exec()) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error pruning metadata cache: {}", + db->lastError().text().toStdString()); + } } } // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.hpp b/src/core/services/metadata_cache.hpp index bcee8ae..10b67a3 100644 --- a/src/core/services/metadata_cache.hpp +++ b/src/core/services/metadata_cache.hpp @@ -1,13 +1,13 @@ #pragma once -#include - -#include #include #include #include #include #include +#include + +#include #include "core/providers/base.hpp" @@ -16,49 +16,54 @@ namespace px::modrim::services { // The cache database inside the config directory. QString metadataCachePath(const QString& configDir); -// The symlink-resolved absolute form of `path`, as Python's os.path.realpath gives it. The path -// need not exist: components that do not exist are kept as written. +// The symlink-resolved absolute form of `path`, as Python's os.path.realpath +// gives it. The path need not exist: components that do not exist are kept as +// written. QString normalizePath(const QString& path); // normalize_path for many paths; each shared parent directory is resolved once. QStringList normalizePaths(const QStringList& paths); -// Persistent SQLite cache of parsed About.xml metadata (metadata-cache.db). All database work runs -// on one private worker thread, which owns the connection, so the connection is never used from two -// threads at once. A database that cannot be opened is recovered by deleting it; if that fails too, -// the cache disables itself and every lookup reports a miss. +// Persistent SQLite cache of parsed About.xml metadata (metadata-cache.db). All +// database work runs on one private worker thread, which owns the connection, +// so the connection is never used from two threads at once. A database that +// cannot be opened is recovered by deleting it; if that fails too, the cache +// disables itself and every lookup reports a miss. class MetadataCache final : public providers::MetadataCacheStore { -public: - explicit MetadataCache(QString path); - ~MetadataCache() override; + public: + explicit MetadataCache(QString path); + ~MetadataCache() override; - QCoro::Task getMods(QList candidates, - QString targetVersion) override; - QCoro::Task putMods(QList entries, - QString targetVersion) override; + QCoro::Task getMods( + QList candidates, + QString targetVersion) override; + QCoro::Task putMods(QList entries, + QString targetVersion) override; - // Removes entries whose normalized mod path is not in `live_mod_paths`. - QCoro::Task pruneDisappeared(QSet liveModPaths); + // Removes entries whose normalized mod path is not in `live_mod_paths`. + QCoro::Task pruneDisappeared(QSet liveModPaths); - // Closes the connection; the next operation reopens it. - QCoro::Task close(); + // Closes the connection; the next operation reopens it. + QCoro::Task close(); -private: - void closeConnection(); - QSqlDatabase* openConnection(); - Result openDatabase(); - void removeDatabaseFiles() const; + private: + void closeConnection(); + QSqlDatabase* openConnection(); + Result openDatabase(); + void removeDatabaseFiles() const; - providers::CacheLookup lookup(const QList& candidates, - const QString& targetVersion); - void store(const QList& entries, const QString& targetVersion); - void prune(const QSet& liveModPaths); + providers::CacheLookup lookup( + const QList& candidates, + const QString& targetVersion); + void store(const QList& entries, + const QString& targetVersion); + void prune(const QSet& liveModPaths); - QString m_path; - QString m_connectionName; - QThreadPool m_worker; - std::optional m_connection; // worker thread only - bool m_disabled = false; // worker thread only + QString m_path; + QString m_connectionName; + QThreadPool m_worker; + std::optional m_connection; // worker thread only + bool m_disabled = false; // worker thread only }; } // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp index 3273bb4..bf14688 100644 --- a/src/core/services/mod_discovery.cpp +++ b/src/core/services/mod_discovery.cpp @@ -1,10 +1,9 @@ #include "core/services/mod_discovery.hpp" -#include -#include - #include #include +#include +#include #include "core/mods_config.hpp" @@ -13,49 +12,53 @@ namespace px::modrim::services { namespace { QString stripSteam(QString value) { - if (value.endsWith(QLatin1String("_steam"), Qt::CaseInsensitive)) { - value.chop(6); - } - return value.toLower(); + if (value.endsWith(QLatin1String("_steam"), Qt::CaseInsensitive)) { + value.chop(6); + } + return value.toLower(); } } // namespace QStringList resolveActiveUuids(const QHash& allMods, - const QString& configFolder) { - if (configFolder.isEmpty()) { - return {}; - } - const QString path = QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); - const auto data = parseModsConfig(path); - if (!data) { - return {}; - } + const QString& configFolder) { + if (configFolder.isEmpty()) { + return {}; + } + const QString path = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const auto data = parseModsConfig(path); + if (!data) { + return {}; + } - // Sort UUIDs so the first-match tiebreaker is deterministic across hash iterations. - QStringList uuids = allMods.keys(); - std::ranges::sort(uuids); - - QHash pidToUuid; - for (const QString& uuid : uuids) { - const auto mod = std::dynamic_pointer_cast(allMods.value(uuid)); - if (!mod) { - continue; - } - const QString pid = stripSteam(mod->packageId.str()); - if (!pidToUuid.contains(pid)) { - pidToUuid.insert(pid, uuid); - } + // Sort UUIDs so the first-match tiebreaker is deterministic across hash + // iterations. + QStringList uuids = allMods.keys(); + std::ranges::sort(uuids); + + QHash pidToUuid; + for (const QString& uuid : uuids) { + const auto mod = + std::dynamic_pointer_cast(allMods.value(uuid)); + if (!mod) { + continue; + } + const QString pid = stripSteam(mod->packageId.str()); + if (!pidToUuid.contains(pid)) { + pidToUuid.insert(pid, uuid); } + } - QStringList active; - for (const CaseInsensitiveStr& pid : data->activeMods) { - const QString normalized = stripSteam(pid.str()); - if (const auto found = pidToUuid.constFind(normalized); found != pidToUuid.constEnd()) { - active.append(*found); - } + QStringList active; + for (const CaseInsensitiveStr& pid : data->activeMods) { + const QString normalized = stripSteam(pid.str()); + if (const auto found = pidToUuid.constFind(normalized); + found != pidToUuid.constEnd()) { + active.append(*found); } - return active; + } + return active; } } // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.hpp b/src/core/services/mod_discovery.hpp index dfb8248..0cef003 100644 --- a/src/core/services/mod_discovery.hpp +++ b/src/core/services/mod_discovery.hpp @@ -8,10 +8,11 @@ namespace px::modrim::services { -// Parses ModsConfig.xml inside `config_folder` and returns the ordered list of UUIDs matching -// active mods. If multiple discovered mods share a package id, the first match (in sorted UUID -// order) is chosen. The "_steam" suffix on package ids is ignored. +// Parses ModsConfig.xml inside `config_folder` and returns the ordered list of +// UUIDs matching active mods. If multiple discovered mods share a package id, +// the first match (in sorted UUID order) is chosen. The "_steam" suffix on +// package ids is ignored. QStringList resolveActiveUuids(const QHash& allMods, - const QString& configFolder); + const QString& configFolder); } // namespace px::modrim::services diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp index a0f03df..163ab81 100644 --- a/src/core/services/sort_service.cpp +++ b/src/core/services/sort_service.cpp @@ -1,10 +1,10 @@ #include "core/services/sort_service.hpp" +#include #include #include #include -#include #include "core/checker/sort.hpp" #include "core/context.hpp" @@ -16,7 +16,7 @@ namespace px::modrim { namespace { std::shared_ptr aboutXml(const ListedModPtr& mod) { - return std::dynamic_pointer_cast(mod); + return std::dynamic_pointer_cast(mod); } } // namespace @@ -24,96 +24,101 @@ std::shared_ptr aboutXml(const ListedModPtr& mod) { SortService::SortService(CoreContext& ctx, DiagnosticsService& diagnostics) : m_ctx(ctx), m_diagnostics(diagnostics) {} -// Python: asyncio.to_thread(topological_sort, ...) -> QtConcurrent::run + co_await. The inputs are -// copies or borrowed from the diagnostics service, which is not mutated while awaiting. +// Python: asyncio.to_thread(topological_sort, ...) -> QtConcurrent::run + +// co_await. The inputs are copies or borrowed from the diagnostics service, +// which is not mutated while awaiting. QCoro::Task SortService::sortActiveMods() { - ProfileSession session(QStringLiteral("sort")); - Profiler& timer = session.timer(); - const auto activeMods = m_diagnostics.activeModsByPid(); - const auto& graph = m_diagnostics.constraintGraph(); - const sort::SortSettings settings = m_ctx.config().sort; - const sort::CommunityRules* rules = &m_diagnostics.communityRules(); - - const QList sorted = co_await QtConcurrent::run([&] { - return checker::topologicalSort(activeMods, graph, settings, rules, &timer); - }); - co_return orderUuids(sorted, m_ctx.allMods(), m_ctx.activeUuids()); + ProfileSession session(QStringLiteral("sort")); + Profiler& timer = session.timer(); + const auto activeMods = m_diagnostics.activeModsByPid(); + const auto& graph = m_diagnostics.constraintGraph(); + const sort::SortSettings settings = m_ctx.config().sort; + const sort::CommunityRules* rules = &m_diagnostics.communityRules(); + + const QList sorted = co_await QtConcurrent::run([&] { + return checker::topologicalSort(activeMods, graph, settings, rules, &timer); + }); + co_return orderUuids(sorted, m_ctx.allMods(), m_ctx.activeUuids()); } -QStringList SortService::resolveMissingDependencies(const QSet& activeUuids) const { - return missingDependencyUuids(m_ctx.allMods(), activeUuids); +QStringList SortService::resolveMissingDependencies( + const QSet& activeUuids) const { + return missingDependencyUuids(m_ctx.allMods(), activeUuids); } QStringList SortService::orderUuids(const QList& sortedPids, - const QHash& allMods, - const QStringList& activeUuids) { - QHash pidToUuid; - for (const auto& uuid : activeUuids) { - if (auto about = aboutXml(allMods.value(uuid))) { - pidToUuid.insert(about->packageId, uuid); - } + const QHash& allMods, + const QStringList& activeUuids) { + QHash pidToUuid; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId, uuid); } + } - QStringList ordered; - for (const auto& pid : sortedPids) { - if (auto it = pidToUuid.constFind(pid); it != pidToUuid.constEnd()) { - ordered.append(it.value()); - } + QStringList ordered; + for (const auto& pid : sortedPids) { + if (auto it = pidToUuid.constFind(pid); it != pidToUuid.constEnd()) { + ordered.append(it.value()); } - return ordered; + } + return ordered; } -QStringList SortService::missingDependencyUuids(const QHash& allMods, - const QSet& activeUuids) { - // Visiting uuids in sorted order keeps the choice between duplicate inactive copies stable. - QStringList allUuids = allMods.keys(); - std::ranges::sort(allUuids); - QHash pidToUuid; - for (const auto& uuid : allUuids) { - if (auto about = aboutXml(allMods.value(uuid))) { - pidToUuid.insert(about->packageId.str(), uuid); - } +QStringList SortService::missingDependencyUuids( + const QHash& allMods, + const QSet& activeUuids) { + // Visiting uuids in sorted order keeps the choice between duplicate inactive + // copies stable. + QStringList allUuids = allMods.keys(); + std::ranges::sort(allUuids); + QHash pidToUuid; + for (const auto& uuid : allUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId.str(), uuid); } + } - QSet activePids; - for (const auto& uuid : activeUuids) { - if (auto about = aboutXml(allMods.value(uuid))) { - activePids.insert(about->packageId.str()); - } + QSet activePids; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + activePids.insert(about->packageId.str()); } + } - QStringList missing; - for (const auto& uuid : activeUuids) { - auto about = aboutXml(allMods.value(uuid)); - if (!about) { - continue; - } - for (const auto& dep : about->aboutRules.dependencies) { - const QString depPid = dep.packageId.str(); - if (activePids.contains(depPid)) { - continue; - } - const bool satisfiedByAlternative = std::ranges::any_of( - dep.alternativePackageIds, [&](const CaseInsensitiveStr& alt) { - return activePids.contains(alt.str()); - }); - if (satisfiedByAlternative) { - continue; - } - auto depUuid = pidToUuid.constFind(depPid); - if (depUuid != pidToUuid.constEnd() && !activeUuids.contains(depUuid.value())) { - missing.append(depUuid.value()); - } - } + QStringList missing; + for (const auto& uuid : activeUuids) { + auto about = aboutXml(allMods.value(uuid)); + if (!about) { + continue; + } + for (const auto& dep : about->aboutRules.dependencies) { + const QString depPid = dep.packageId.str(); + if (activePids.contains(depPid)) { + continue; + } + const bool satisfiedByAlternative = std::ranges::any_of( + dep.alternativePackageIds, [&](const CaseInsensitiveStr& alt) { + return activePids.contains(alt.str()); + }); + if (satisfiedByAlternative) { + continue; + } + auto depUuid = pidToUuid.constFind(depPid); + if (depUuid != pidToUuid.constEnd() && + !activeUuids.contains(depUuid.value())) { + missing.append(depUuid.value()); + } } + } - QStringList unique; - for (const auto& uuid : missing) { - if (!unique.contains(uuid)) { - unique.append(uuid); - } + QStringList unique; + for (const auto& uuid : missing) { + if (!unique.contains(uuid)) { + unique.append(uuid); } - return unique; + } + return unique; } } // namespace px::modrim diff --git a/src/core/services/sort_service.hpp b/src/core/services/sort_service.hpp index c84c011..e72efba 100644 --- a/src/core/services/sort_service.hpp +++ b/src/core/services/sort_service.hpp @@ -1,11 +1,12 @@ #pragma once -#include #include #include #include #include +#include + #include "core/models/metadata/structures.hpp" #include "core/sort/config.hpp" @@ -15,32 +16,36 @@ class CoreContext; class DiagnosticsService; class SortService { -public: - // Neither argument is owned; both must outlive the service. - SortService(CoreContext& ctx, DiagnosticsService& diagnostics); - - // Orders the active mods by tier, dependencies and load-order rules. Resolves to their uuids - // in that order. - QCoro::Task sortActiveMods(); - - // Uuids of inactive mods that the active mods need and that are not already covered. - QStringList resolveMissingDependencies(const QSet& activeUuids) const; - - // Maps topologically sorted package ids back to the uuids of the active mods that declare them. - // Only the active uuid of a package id is considered, so an inactive duplicate never stands in - // for an active mod. - static QStringList orderUuids(const QList& sortedPids, - const QHash& allMods, - const QStringList& activeUuids); - - // Uuids of inactive mods that satisfy a dependency of an active mod which no active mod (or - // alternative package id) already covers. Duplicates are removed, keeping first occurrence. - static QStringList missingDependencyUuids(const QHash& allMods, - const QSet& activeUuids); - -private: - CoreContext& m_ctx; - DiagnosticsService& m_diagnostics; + public: + // Neither argument is owned; both must outlive the service. + SortService(CoreContext& ctx, DiagnosticsService& diagnostics); + + // Orders the active mods by tier, dependencies and load-order rules. Resolves + // to their uuids in that order. + QCoro::Task sortActiveMods(); + + // Uuids of inactive mods that the active mods need and that are not already + // covered. + QStringList resolveMissingDependencies( + const QSet& activeUuids) const; + + // Maps topologically sorted package ids back to the uuids of the active mods + // that declare them. Only the active uuid of a package id is considered, so + // an inactive duplicate never stands in for an active mod. + static QStringList orderUuids(const QList& sortedPids, + const QHash& allMods, + const QStringList& activeUuids); + + // Uuids of inactive mods that satisfy a dependency of an active mod which no + // active mod (or alternative package id) already covers. Duplicates are + // removed, keeping first occurrence. + static QStringList missingDependencyUuids( + const QHash& allMods, + const QSet& activeUuids); + + private: + CoreContext& m_ctx; + DiagnosticsService& m_diagnostics; }; } // namespace px::modrim diff --git a/src/core/services/startup_impact_service/db.cpp b/src/core/services/startup_impact_service/db.cpp index 4e60631..86a35e3 100644 --- a/src/core/services/startup_impact_service/db.cpp +++ b/src/core/services/startup_impact_service/db.cpp @@ -1,8 +1,5 @@ #include "core/services/startup_impact_service/db.hpp" -#include - -#include #include #include #include @@ -14,6 +11,9 @@ #include #include #include +#include + +#include #include #include "core/migrator.hpp" @@ -22,18 +22,20 @@ namespace px::modrim::startup_impact { namespace { -// Recency weights: the newest session weighs WEIGHT_SPREAD, the next WEIGHT_SPREAD-1, and so -// on down to 1. Sessions beyond HISTORY_DEPTH are not kept. +// Recency weights: the newest session weighs WEIGHT_SPREAD, the next +// WEIGHT_SPREAD-1, and so on down to 1. Sessions beyond HISTORY_DEPTH are not +// kept. constexpr int kHistoryDepth = 5; constexpr int kWeightSpread = 10; constexpr int kSchemaVersion = 1; -// Above this size the file is rebuilt after a store, since freed pages are never returned -// otherwise. +// Above this size the file is rebuilt after a store, since freed pages are +// never returned otherwise. constexpr qint64 kVacuumThresholdBytes = 10 * 1024 * 1024; -// Schema text is identical to the legacy app's, so both can open the same cache.db. +// Schema text is identical to the legacy app's, so both can open the same +// cache.db. constexpr QLatin1StringView kSchema{R"sql( CREATE TABLE IF NOT EXISTS startup_impact_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -62,7 +64,8 @@ constexpr QLatin1StringView kSchema{R"sql( )sql"}; // Ranks the newest HISTORY_DEPTH sessions and assigns each a recency weight. -// Binds, in order: HISTORY_DEPTH, WEIGHT_SPREAD. Exposes columns session_id and weight. +// Binds, in order: HISTORY_DEPTH, WEIGHT_SPREAD. Exposes columns session_id and +// weight. constexpr QLatin1StringView kWeightedSessionsCte{R"sql( ranked AS ( SELECT id AS session_id, @@ -80,533 +83,570 @@ constexpr QLatin1StringView kWeightedSessionsCte{R"sql( // Wraps a SELECT that joins the weighted sessions as `w`. QString withWeights(const QString& selectSql) { - return QStringLiteral("WITH ") + QString(kWeightedSessionsCte) + selectSql; + return QStringLiteral("WITH ") + QString(kWeightedSessionsCte) + selectSql; } // The CTE's two parameters come first, followed by the query's own. QVariantList withCteBinds(const QVariantList& rest = {}) { - QVariantList binds{kHistoryDepth, kWeightSpread}; - binds.append(rest); - return binds; + QVariantList binds{kHistoryDepth, kWeightSpread}; + binds.append(rest); + return binds; } QVariantList toVariants(const QStringList& values) { - QVariantList binds; - binds.reserve(values.size()); - for (const QString& value : values) { - binds.append(value); - } - return binds; + QVariantList binds; + binds.reserve(values.size()); + for (const QString& value : values) { + binds.append(value); + } + return binds; } QString placeholderList(qsizetype count) { - QStringList marks(count, QStringLiteral("?")); - return marks.join(QLatin1Char(',')); -} - -// Prepares and runs one statement. The returned query stays positioned on the first row. -Result exec(QSqlDatabase& db, const QString& sql, const QVariantList& binds = {}) { - QSqlQuery query(db); - if (!query.prepare(sql)) { - return std::unexpected(Error{query.lastError().text()}); - } - for (const QVariant& value : binds) { - query.addBindValue(value); - } - if (!query.exec()) { - return std::unexpected(Error{query.lastError().text()}); - } - return query; -} - -Result run(QSqlDatabase& db, const QString& sql, const QVariantList& binds = {}) { - auto executed = exec(db, sql, binds); - if (!executed) { - return std::unexpected(executed.error()); - } - return {}; + QStringList marks(count, QStringLiteral("?")); + return marks.join(QLatin1Char(',')); +} + +// Prepares and runs one statement. The returned query stays positioned on the +// first row. +Result exec(QSqlDatabase& db, const QString& sql, + const QVariantList& binds = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (const QVariant& value : binds) { + query.addBindValue(value); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + return query; +} + +Result run(QSqlDatabase& db, const QString& sql, + const QVariantList& binds = {}) { + auto executed = exec(db, sql, binds); + if (!executed) { + return std::unexpected(executed.error()); + } + return {}; } // Stored metric maps hold seconds already. Non-numeric values are skipped. Result> loadMetricMap(const QString& jsonText) { - const QByteArray bytes = jsonText.isEmpty() ? QByteArrayLiteral("{}") : jsonText.toUtf8(); - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(bytes, &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) { - return std::unexpected(Error{QStringLiteral("Invalid stored metrics: %1") - .arg(error.errorString())}); - } - QMap values; - const QJsonObject object = document.object(); - for (auto it = object.constBegin(); it != object.constEnd(); ++it) { - if (it.value().isDouble()) { - values.insert(it.key(), it.value().toDouble()); - } - } - return values; + const QByteArray bytes = + jsonText.isEmpty() ? QByteArrayLiteral("{}") : jsonText.toUtf8(); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return std::unexpected(Error{ + QStringLiteral("Invalid stored metrics: %1").arg(error.errorString())}); + } + QMap values; + const QJsonObject object = document.object(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (it.value().isDouble()) { + values.insert(it.key(), it.value().toDouble()); + } + } + return values; } QString metricsToJson(const QMap& metrics) { - QJsonObject object; - for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { - object.insert(it.key(), it.value()); - } - return QString::fromUtf8(QJsonDocument(object).toJson(QJsonDocument::Compact)); + QJsonObject object; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + object.insert(it.key(), it.value()); + } + return QString::fromUtf8( + QJsonDocument(object).toJson(QJsonDocument::Compact)); } Result rowToMod(const QSqlQuery& row) { - const QString packageId = row.value(0).toString(); - const QString modName = row.value(1).toString(); - // A report without a package id stores the mod name in its place. Read it back as absent. - std::optional pid = packageId; - if (packageId == modName && packageId != kBaseGamePid) { - pid = std::nullopt; - } - - auto metrics = loadMetricMap(row.value(3).toString()); - if (!metrics) { - return std::unexpected(metrics.error()); - } - auto offThread = loadMetricMap(row.value(4).toString()); - if (!offThread) { - return std::unexpected(offThread.error()); - } - - return StartupImpactMod{ - .modName = modName, - .packageId = pid, - .totalImpactS = row.value(2).toDouble(), - .metrics = std::move(*metrics), - .offThreadMetrics = std::move(*offThread), - .offThreadTotalImpactS = row.value(5).toDouble(), - }; -} - -Result> loadMods(QSqlDatabase& db, qlonglong sessionId) { - auto query = exec(db, - "SELECT package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s" - " FROM startup_impact_entries WHERE session_id = ?", - {sessionId}); - if (!query) { - return std::unexpected(query.error()); - } - QList mods; - while (query->next()) { - auto mod = rowToMod(*query); - if (!mod) { - return std::unexpected(mod.error()); - } - mods.append(std::move(*mod)); - } - return mods; + const QString packageId = row.value(0).toString(); + const QString modName = row.value(1).toString(); + // A report without a package id stores the mod name in its place. Read it + // back as absent. + std::optional pid = packageId; + if (packageId == modName && packageId != kBaseGamePid) { + pid = std::nullopt; + } + + auto metrics = loadMetricMap(row.value(3).toString()); + if (!metrics) { + return std::unexpected(metrics.error()); + } + auto offThread = loadMetricMap(row.value(4).toString()); + if (!offThread) { + return std::unexpected(offThread.error()); + } + + return StartupImpactMod{ + .modName = modName, + .packageId = pid, + .totalImpactS = row.value(2).toDouble(), + .metrics = std::move(*metrics), + .offThreadMetrics = std::move(*offThread), + .offThreadTotalImpactS = row.value(5).toDouble(), + }; +} + +Result> loadMods(QSqlDatabase& db, + qlonglong sessionId) { + auto query = exec(db, + "SELECT package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s" + " FROM startup_impact_entries WHERE session_id = ?", + {sessionId}); + if (!query) { + return std::unexpected(query.error()); + } + QList mods; + while (query->next()) { + auto mod = rowToMod(*query); + if (!mod) { + return std::unexpected(mod.error()); + } + mods.append(std::move(*mod)); + } + return mods; } void closeConnection(StartupImpactDb::Connection& conn) { - if (!conn.open) { - return; - } - { - QSqlDatabase db = QSqlDatabase::database(conn.name, false); - db.close(); - } - // removeDatabase requires that no QSqlDatabase handle for the connection remains. + if (!conn.open) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(conn.name, false); + db.close(); + } + // removeDatabase requires that no QSqlDatabase handle for the connection + // remains. + QSqlDatabase::removeDatabase(conn.name); + conn.open = false; + conn.path.clear(); +} + +// Opens the connection on the worker, applying the legacy pragmas and schema. +// Reuses the open connection when the path has not changed. +Result openConnection(StartupImpactDb::Connection& conn, + const QString& path) { + if (conn.open && conn.path == path) { + return QSqlDatabase::database(conn.name, false); + } + closeConnection(conn); + + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), conn.name); + db.setDatabaseName(path); + + const auto prepare = [&db, &path]() -> Result { + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // Order matters: auto_vacuum only takes effect before the first table is + // created. + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (auto applied = run(db, QString::fromLatin1(pragma)); !applied) { + return applied; + } + } + return ensureSchema(db, QString(kSchema), kSchemaVersion, {}, path, + QStringLiteral("DB schema")); + }; + + if (auto ready = prepare(); !ready) { + const Error error = ready.error(); + db = QSqlDatabase(); QSqlDatabase::removeDatabase(conn.name); - conn.open = false; - conn.path.clear(); -} - -// Opens the connection on the worker, applying the legacy pragmas and schema. Reuses the open -// connection when the path has not changed. -Result openConnection(StartupImpactDb::Connection& conn, const QString& path) { - if (conn.open && conn.path == path) { - return QSqlDatabase::database(conn.name, false); - } - closeConnection(conn); - - QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), conn.name); - db.setDatabaseName(path); - - const auto prepare = [&db, &path]() -> Result { - if (!db.open()) { - return std::unexpected(Error{db.lastError().text()}); - } - // Order matters: auto_vacuum only takes effect before the first table is created. - for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", - "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { - if (auto applied = run(db, QString::fromLatin1(pragma)); !applied) { - return applied; - } - } - return ensureSchema(db, QString(kSchema), kSchemaVersion, {}, path, - QStringLiteral("DB schema")); - }; - - if (auto ready = prepare(); !ready) { - const Error error = ready.error(); - db = QSqlDatabase(); - QSqlDatabase::removeDatabase(conn.name); - return std::unexpected(error); - } - conn.path = path; - conn.open = true; - return db; + return std::unexpected(error); + } + conn.path = path; + conn.open = true; + return db; } // Read paths return empty results for a missing file rather than creating one. bool fileExists(const QString& path) { return QFileInfo::exists(path); } Result ensureParentDir(const QString& path) { - if (fileExists(path)) { - return {}; - } - const QString dir = QFileInfo(path).absolutePath(); - if (!QDir().mkpath(dir)) { - return std::unexpected(Error{QStringLiteral("Cannot create directory %1").arg(dir)}); - } + if (fileExists(path)) { return {}; + } + const QString dir = QFileInfo(path).absolutePath(); + if (!QDir().mkpath(dir)) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory %1").arg(dir)}); + } + return {}; } Result insertReport(QSqlDatabase& db, const StartupImpactReport& report) { - const QString timestamp = report.timestamp.isNull() ? QStringLiteral("") : report.timestamp; - auto session = exec(db, - "INSERT INTO startup_impact_sessions" - " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", - {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loadingTimeS, - timestamp}); - if (!session) { - return std::unexpected(session.error()); - } - const qlonglong sessionId = session->lastInsertId().toLongLong(); - - for (const StartupImpactMod& mod : report.mods) { - const bool hasPackageId = mod.packageId && !mod.packageId->isEmpty(); - const QString name = mod.modName.isNull() ? QStringLiteral("") : mod.modName; - const QString pid = hasPackageId ? *mod.packageId : name; - auto entry = run(db, - "INSERT INTO startup_impact_entries" - " (session_id, package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", - {sessionId, pid, name, mod.totalImpactS, - metricsToJson(mod.metrics), metricsToJson(mod.offThreadMetrics), - mod.offThreadTotalImpactS}); - if (!entry) { - return entry; - } - } - - return run(db, - "DELETE FROM startup_impact_sessions WHERE id NOT IN" - " (SELECT id FROM startup_impact_sessions ORDER BY id DESC LIMIT ?)", - {kHistoryDepth}); + const QString timestamp = + report.timestamp.isNull() ? QStringLiteral("") : report.timestamp; + auto session = + exec(db, + "INSERT INTO startup_impact_sessions" + " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", + {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loadingTimeS, + timestamp}); + if (!session) { + return std::unexpected(session.error()); + } + const qlonglong sessionId = session->lastInsertId().toLongLong(); + + for (const StartupImpactMod& mod : report.mods) { + const bool hasPackageId = mod.packageId && !mod.packageId->isEmpty(); + const QString name = + mod.modName.isNull() ? QStringLiteral("") : mod.modName; + const QString pid = hasPackageId ? *mod.packageId : name; + auto entry = + run(db, + "INSERT INTO startup_impact_entries" + " (session_id, package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + {sessionId, pid, name, mod.totalImpactS, metricsToJson(mod.metrics), + metricsToJson(mod.offThreadMetrics), mod.offThreadTotalImpactS}); + if (!entry) { + return entry; + } + } + + return run( + db, + "DELETE FROM startup_impact_sessions WHERE id NOT IN" + " (SELECT id FROM startup_impact_sessions ORDER BY id DESC LIMIT ?)", + {kHistoryDepth}); } Result compact(QSqlDatabase& db, const QString& path) { - // Checkpoint the WAL, then reclaim free pages. The file is only rebuilt when it has grown. - if (auto checkpointed = run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); !checkpointed) { - return checkpointed; - } - if (auto vacuumed = run(db, "PRAGMA incremental_vacuum"); !vacuumed) { - return vacuumed; - } - if (QFileInfo(path).size() > kVacuumThresholdBytes) { - return run(db, "VACUUM"); - } - return {}; + // Checkpoint the WAL, then reclaim free pages. The file is only rebuilt when + // it has grown. + if (auto checkpointed = run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); + !checkpointed) { + return checkpointed; + } + if (auto vacuumed = run(db, "PRAGMA incremental_vacuum"); !vacuumed) { + return vacuumed; + } + if (QFileInfo(path).size() > kVacuumThresholdBytes) { + return run(db, "VACUUM"); + } + return {}; } // Runs `work` inside one transaction, rolling back when it fails. template Result inTransaction(QSqlDatabase& db, Work&& work) { - if (!db.transaction()) { - return std::unexpected(Error{db.lastError().text()}); - } - if (auto done = work(); !done) { - db.rollback(); - return done; - } - if (!db.commit()) { - const Error error{db.lastError().text()}; - db.rollback(); - return std::unexpected(error); - } - return {}; -} - -Result storeReportSync(StartupImpactDb::Connection& conn, const QString& path, - const StartupImpactReport& report) { - if (auto dir = ensureParentDir(path); !dir) { - return dir; - } - auto opened = openConnection(conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - if (auto stored = inTransaction(db, [&] { return insertReport(db, report); }); !stored) { - return stored; - } - return compact(db, path); -} - -Result> getLatestReportSync(StartupImpactDb::Connection& conn, - const QString& path) { - if (!fileExists(path)) { - return std::nullopt; - } - auto opened = openConnection(conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - - auto session = exec(db, - "SELECT id, loading_time_s, timestamp" - " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1"); - if (!session) { - return std::unexpected(session.error()); - } - if (!session->next()) { - return std::nullopt; - } - const qlonglong sessionId = session->value(0).toLongLong(); - StartupImpactReport report; - report.loadingTimeS = session->value(1).toDouble(); - report.timestamp = session->value(2).toString(); - - auto mods = loadMods(db, sessionId); - if (!mods) { - return std::unexpected(mods.error()); - } - report.mods = std::move(*mods); - return report; + if (!db.transaction()) { + return std::unexpected(Error{db.lastError().text()}); + } + if (auto done = work(); !done) { + db.rollback(); + return done; + } + if (!db.commit()) { + const Error error{db.lastError().text()}; + db.rollback(); + return std::unexpected(error); + } + return {}; +} + +Result storeReportSync(StartupImpactDb::Connection& conn, + const QString& path, + const StartupImpactReport& report) { + if (auto dir = ensureParentDir(path); !dir) { + return dir; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto stored = inTransaction(db, [&] { return insertReport(db, report); }); + !stored) { + return stored; + } + return compact(db, path); +} + +Result> getLatestReportSync( + StartupImpactDb::Connection& conn, const QString& path) { + if (!fileExists(path)) { + return std::nullopt; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto session = exec(db, + "SELECT id, loading_time_s, timestamp" + " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1"); + if (!session) { + return std::unexpected(session.error()); + } + if (!session->next()) { + return std::nullopt; + } + const qlonglong sessionId = session->value(0).toLongLong(); + StartupImpactReport report; + report.loadingTimeS = session->value(1).toDouble(); + report.timestamp = session->value(2).toString(); + + auto mods = loadMods(db, sessionId); + if (!mods) { + return std::unexpected(mods.error()); + } + report.mods = std::move(*mods); + return report; } Result averageFor(QSqlDatabase& db, const QString& packageId) { - auto query = exec(db, - withWeights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" - " / NULLIF(SUM(w.weight), 0), 0)" - " FROM startup_impact_entries e" - " JOIN w ON w.session_id = e.session_id" - " WHERE e.package_id = ?"), - withCteBinds({packageId})); - if (!query) { - return std::unexpected(query.error()); - } - return query->next() ? query->value(0).toDouble() : 0.0; -} - -Result> totalsFor(QSqlDatabase& db, const QStringList& packageIds) { - auto query = exec(db, - withWeights("SELECT e.package_id," - " COALESCE(SUM(e.total_impact_s * w.weight)" - " * 1.0 / NULLIF(SUM(w.weight), 0), 0)," - " COALESCE(SUM(e.off_thread_total_s * w.weight)" - " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" - " FROM startup_impact_entries e" - " JOIN w ON w.session_id = e.session_id" - " WHERE e.package_id IN (" + - placeholderList(packageIds.size()) + - ") GROUP BY e.package_id"), - withCteBinds(toVariants(packageIds))); - if (!query) { - return std::unexpected(query.error()); - } - QHash totals; - while (query->next()) { - totals.insert(query->value(0).toString(), - ThreadAverages{query->value(1).toDouble(), query->value(2).toDouble()}); - } - return totals; + auto query = + exec(db, + withWeights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" + " / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id = ?"), + withCteBinds({packageId})); + if (!query) { + return std::unexpected(query.error()); + } + return query->next() ? query->value(0).toDouble() : 0.0; +} + +Result> totalsFor( + QSqlDatabase& db, const QStringList& packageIds) { + auto query = exec(db, + withWeights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)," + " COALESCE(SUM(e.off_thread_total_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id IN (" + + placeholderList(packageIds.size()) + + ") GROUP BY e.package_id"), + withCteBinds(toVariants(packageIds))); + if (!query) { + return std::unexpected(query.error()); + } + QHash totals; + while (query->next()) { + totals.insert( + query->value(0).toString(), + ThreadAverages{query->value(1).toDouble(), query->value(2).toDouble()}); + } + return totals; } Result> allAveragesFor(QSqlDatabase& db) { - auto query = exec(db, - withWeights("SELECT e.package_id," - " COALESCE(SUM(e.total_impact_s * w.weight)" - " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" - " FROM startup_impact_entries e" - " JOIN w ON w.session_id = e.session_id" - " GROUP BY e.package_id"), - withCteBinds()); - if (!query) { - return std::unexpected(query.error()); - } - QHash averages; - while (query->next()) { - averages.insert(query->value(0).toString(), query->value(1).toDouble()); - } - return averages; + auto query = exec(db, + withWeights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " GROUP BY e.package_id"), + withCteBinds()); + if (!query) { + return std::unexpected(query.error()); + } + QHash averages; + while (query->next()) { + averages.insert(query->value(0).toString(), query->value(1).toDouble()); + } + return averages; } Result clearSync(StartupImpactDb::Connection& conn, const QString& path) { - if (!fileExists(path)) { - return {}; - } - auto opened = openConnection(conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - if (auto cleared = inTransaction(db, [&]() -> Result { - if (auto entries = run(db, "DELETE FROM startup_impact_entries"); !entries) { - return entries; + if (!fileExists(path)) { + return {}; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto cleared = inTransaction( + db, + [&]() -> Result { + if (auto entries = run(db, "DELETE FROM startup_impact_entries"); + !entries) { + return entries; } return run(db, "DELETE FROM startup_impact_sessions"); - }); - !cleared) { - return cleared; - } - if (auto vacuumed = run(db, "VACUUM"); !vacuumed) { - return vacuumed; - } - return run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); + }); + !cleared) { + return cleared; + } + if (auto vacuumed = run(db, "VACUUM"); !vacuumed) { + return vacuumed; + } + return run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); } } // namespace QString dbPath(const QString& configDir) { - return QDir(configDir).filePath(QStringLiteral("cache.db")); + return QDir(configDir).filePath(QStringLiteral("cache.db")); } StartupImpactDb::StartupImpactDb() { - // A single thread keeps the connection on one thread. Idle threads must not expire, since - // the connection is bound to the thread that opened it. - m_pool.setMaxThreadCount(1); - m_pool.setExpiryTimeout(-1); - m_conn.name = QStringLiteral("startup_impact_%1") - .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + // A single thread keeps the connection on one thread. Idle threads must not + // expire, since the connection is bound to the thread that opened it. + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); + m_conn.name = QStringLiteral("startup_impact_%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); } StartupImpactDb::~StartupImpactDb() { closeSync(); } -QCoro::Task> StartupImpactDb::storeReport(QString path, StartupImpactReport report) { - co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path), - report = std::move(report)] { +QCoro::Task> StartupImpactDb::storeReport( + QString path, StartupImpactReport report) { + co_return co_await QtConcurrent::run( + &m_pool, [this, path = std::move(path), report = std::move(report)] { return storeReportSync(m_conn, path, report); - }); + }); } -QCoro::Task>> StartupImpactDb::getLatestReport( - QString path) { - co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { - return getLatestReportSync(m_conn, path); - }); +QCoro::Task>> +StartupImpactDb::getLatestReport(QString path) { + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return getLatestReportSync(m_conn, path); + }); } -QCoro::Task> StartupImpactDb::getAverage(QString path, QString packageId) { - co_return co_await QtConcurrent::run( - &m_pool, [this, path = std::move(path), packageId = std::move(packageId)]() -> Result { - if (!fileExists(path)) { - return 0.0; - } - auto opened = openConnection(m_conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - return averageFor(db, packageId); - }); -} - -QCoro::Task>> StartupImpactDb::getTotalsFor( - QString path, QStringList packageIds) { - co_return co_await QtConcurrent::run( - &m_pool, [this, path = std::move(path), packageIds = std::move(packageIds)]() - -> Result> { - // Ids without history are left out, so callers treat them as zero. - if (packageIds.isEmpty() || !fileExists(path)) { - return QHash{}; - } - auto opened = openConnection(m_conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - return totalsFor(db, packageIds); - }); +QCoro::Task> StartupImpactDb::getAverage(QString path, + QString packageId) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), + packageId = std::move(packageId)]() -> Result { + if (!fileExists(path)) { + return 0.0; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return averageFor(db, packageId); + }); +} + +QCoro::Task>> +StartupImpactDb::getTotalsFor(QString path, QStringList packageIds) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), packageIds = std::move(packageIds)]() + -> Result> { + // Ids without history are left out, so callers treat them as zero. + if (packageIds.isEmpty() || !fileExists(path)) { + return QHash{}; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return totalsFor(db, packageIds); + }); } -QCoro::Task>> StartupImpactDb::getAllAverages(QString path) { - co_return co_await QtConcurrent::run( - &m_pool, [this, path = std::move(path)]() -> Result> { - if (!fileExists(path)) { - return QHash{}; - } - auto opened = openConnection(m_conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; - return allAveragesFor(db); - }); +QCoro::Task>> StartupImpactDb::getAllAverages( + QString path) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path)]() -> Result> { + if (!fileExists(path)) { + return QHash{}; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return allAveragesFor(db); + }); } QCoro::Task> StartupImpactDb::getLatestWithAverages( QString path, QStringList activePids, std::optional selectedPid) { - co_return co_await QtConcurrent::run( - &m_pool, [this, path = std::move(path), activePids = std::move(activePids), - selectedPid = std::move(selectedPid)]() -> Result { - LatestWithAverages result; - for (const QString& pid : activePids) { - result.totals.insert(pid, ThreadAverages{}); - } - if (!fileExists(path)) { - return result; - } + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), activePids = std::move(activePids), + selectedPid = std::move(selectedPid)]() -> Result { + LatestWithAverages result; + for (const QString& pid : activePids) { + result.totals.insert(pid, ThreadAverages{}); + } + if (!fileExists(path)) { + return result; + } - auto latest = getLatestReportSync(m_conn, path); - if (!latest) { - return std::unexpected(latest.error()); - } - result.report = std::move(*latest); + auto latest = getLatestReportSync(m_conn, path); + if (!latest) { + return std::unexpected(latest.error()); + } + result.report = std::move(*latest); - auto opened = openConnection(m_conn, path); - if (!opened) { - return std::unexpected(opened.error()); - } - QSqlDatabase db = *opened; + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; - auto base = averageFor(db, QString(kBaseGamePid)); - if (!base) { - return std::unexpected(base.error()); - } - result.baseGameAvg = *base; - - if (!activePids.isEmpty()) { - auto totals = totalsFor(db, activePids); - if (!totals) { - return std::unexpected(totals.error()); - } - for (auto it = totals->cbegin(); it != totals->cend(); ++it) { - result.totals.insert(it.key(), it.value()); - } - } + auto base = averageFor(db, QString(kBaseGamePid)); + if (!base) { + return std::unexpected(base.error()); + } + result.baseGameAvg = *base; + + if (!activePids.isEmpty()) { + auto totals = totalsFor(db, activePids); + if (!totals) { + return std::unexpected(totals.error()); + } + for (auto it = totals->cbegin(); it != totals->cend(); ++it) { + result.totals.insert(it.key(), it.value()); + } + } - if (selectedPid) { - auto selected = averageFor(db, *selectedPid); - if (!selected) { - return std::unexpected(selected.error()); - } - result.selectedAvg = *selected; - } - return result; - }); + if (selectedPid) { + auto selected = averageFor(db, *selectedPid); + if (!selected) { + return std::unexpected(selected.error()); + } + result.selectedAvg = *selected; + } + return result; + }); } QCoro::Task> StartupImpactDb::clear(QString path) { - co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { - return clearSync(m_conn, path); - }); + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return clearSync(m_conn, path); + }); } QCoro::Task StartupImpactDb::close() { - co_await QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }); + co_await QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }); } void StartupImpactDb::closeSync() { - QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }).waitForFinished(); + QtConcurrent::run(&m_pool, [this] { + closeConnection(m_conn); + }).waitForFinished(); } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/db.hpp b/src/core/services/startup_impact_service/db.hpp index 8ff4cdf..56cebee 100644 --- a/src/core/services/startup_impact_service/db.hpp +++ b/src/core/services/startup_impact_service/db.hpp @@ -1,12 +1,12 @@ #pragma once -#include - -#include #include #include #include #include +#include + +#include #include "core/error.hpp" #include "core/services/startup_impact_service/models.hpp" @@ -15,59 +15,66 @@ namespace px::modrim::startup_impact { // Average on-thread and off-thread seconds for one package id. struct ThreadAverages { - double onThread = 0.0; - double offThread = 0.0; + double onThread = 0.0; + double offThread = 0.0; }; struct LatestWithAverages { - std::optional report; - double baseGameAvg = 0.0; - // Keyed by every requested package id; ids without history map to zeros. - QHash totals; - double selectedAvg = 0.0; + std::optional report; + double baseGameAvg = 0.0; + // Keyed by every requested package id; ids without history map to zeros. + QHash totals; + double selectedAvg = 0.0; }; -// `cache.db` in the config directory. Shared with the metadata cache, which owns other tables. +// `cache.db` in the config directory. Shared with the metadata cache, which +// owns other tables. QString dbPath(const QString& configDir); -// Startup impact history in SQLite, kept to the most recent sessions. Averages weight newer -// sessions more heavily. The schema matches the legacy Python app so either can read the file. +// Startup impact history in SQLite, kept to the most recent sessions. Averages +// weight newer sessions more heavily. The schema matches the legacy Python app +// so either can read the file. // -// All database work runs on one worker thread, which owns the single connection. Calls may -// therefore be made from the Qt event loop without blocking it, and need no further locking. +// All database work runs on one worker thread, which owns the single +// connection. Calls may therefore be made from the Qt event loop without +// blocking it, and need no further locking. class StartupImpactDb { -public: - // The open connection, identified by a name unique to this instance. Only the worker - // thread reads or writes it. - struct Connection { - QString name; - QString path; - bool open = false; - }; + public: + // The open connection, identified by a name unique to this instance. Only the + // worker thread reads or writes it. + struct Connection { + QString name; + QString path; + bool open = false; + }; - StartupImpactDb(); - ~StartupImpactDb(); - StartupImpactDb(const StartupImpactDb&) = delete; - StartupImpactDb& operator=(const StartupImpactDb&) = delete; + StartupImpactDb(); + ~StartupImpactDb(); + StartupImpactDb(const StartupImpactDb&) = delete; + StartupImpactDb& operator=(const StartupImpactDb&) = delete; - QCoro::Task> storeReport(QString path, StartupImpactReport report); - QCoro::Task>> getLatestReport(QString path); - QCoro::Task> getAverage(QString path, QString packageId); - QCoro::Task>> getTotalsFor(QString path, - QStringList packageIds); - QCoro::Task>> getAllAverages(QString path); - QCoro::Task> getLatestWithAverages( - QString path, QStringList activePids, std::optional selectedPid); - QCoro::Task> clear(QString path); + QCoro::Task> storeReport(QString path, + StartupImpactReport report); + QCoro::Task>> getLatestReport( + QString path); + QCoro::Task> getAverage(QString path, QString packageId); + QCoro::Task>> getTotalsFor( + QString path, QStringList packageIds); + QCoro::Task>> getAllAverages(QString path); + QCoro::Task> getLatestWithAverages( + QString path, QStringList activePids, std::optional selectedPid); + QCoro::Task> clear(QString path); - QCoro::Task close(); - // For shutdown paths that cannot await. Blocks until the connection is closed. - void closeSync(); + QCoro::Task close(); + // For shutdown paths that cannot await. Blocks until the connection is + // closed. + void closeSync(); -private: - // The destructor drains the pool through close_sync() before these members are destroyed. - QThreadPool m_pool; - Connection m_conn; + private: + // The destructor drains the pool through close_sync() before these members + // are destroyed. + QThreadPool m_pool; + Connection m_conn; }; } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.cpp b/src/core/services/startup_impact_service/labels.cpp index d5eabea..e879a0a 100644 --- a/src/core/services/startup_impact_service/labels.cpp +++ b/src/core/services/startup_impact_service/labels.cpp @@ -10,112 +10,131 @@ namespace { constexpr QLatin1StringView kPrefix{"LoadingProgress.StartupImpact."}; const QHash& metricLabels() { - static const QHash labels{ - {QStringLiteral("ModConstructor"), QStringLiteral("Running mod constructor")}, - {QStringLiteral("LoadDefs"), QStringLiteral("Loading defs")}, - {QStringLiteral("CombineXml"), QStringLiteral("Combining XML into a single XML document")}, - {QStringLiteral("TKeySystemParse"), QStringLiteral("Translation key system parsing")}, - {QStringLiteral("LoadedModManagerParseAndProcessXML"), - QStringLiteral("Parsing and processing XML")}, - {QStringLiteral("ErrorCheckPatches"), QStringLiteral("Error checking patches")}, - {QStringLiteral("LoadPatches"), QStringLiteral("Loading patches")}, - {QStringLiteral("ApplyPatches"), QStringLiteral("Applying patches")}, - {QStringLiteral("RegisterXmlInheritance"), QStringLiteral("Registering XML inheritance")}, - {QStringLiteral("ResolveXmlInheritance"), QStringLiteral("Resolving XML inheritance")}, - {QStringLiteral("ClearCachedPatches"), QStringLiteral("Clearing cached patches")}, - {QStringLiteral("ClearCachedXmlInheritance"), - QStringLiteral("Clearing cached XML inheritance")}, - {QStringLiteral("LanguageDatabaseInitAllMetadata"), - QStringLiteral("Loading language metadata")}, - {QStringLiteral("DefDatabaseAddAllInMods"), - QStringLiteral("Copying all Defs from mods to global databases")}, - {QStringLiteral("ResolveAllWantedCrossReferences"), - QStringLiteral("Resolving cross-references")}, - {QStringLiteral("ResolveAllWantedCrossReferences.NonImplied"), - QStringLiteral("Resolving cross-references between non-implied Defs")}, - {QStringLiteral("DefOfHelperRebindAllDefOfs"), QStringLiteral("Rebinding DefOfs")}, - {QStringLiteral("DefOfHelperRebindAllDefOfs.Early"), - QStringLiteral("Rebinding DefOfs (early)")}, - {QStringLiteral("DefOfHelperRebindAllDefOfs.Final"), - QStringLiteral("Rebinding DefOfs (final)")}, - {QStringLiteral("TKeySystemBuildMappings"), QStringLiteral("Translation key system mapping")}, - {QStringLiteral("BackStoryTranslationUtilityLoadAndInjectBackstoryData"), - QStringLiteral("Loading and injecting legacy backstory translations")}, - {QStringLiteral("ModContentPackReloadContentInt"), QStringLiteral("Loading content")}, - {QStringLiteral("ModContentPackReloadContentInt.AudioClips"), - QStringLiteral("Loading audio clips")}, - {QStringLiteral("ModContentPackReloadContentInt.Textures"), - QStringLiteral("Loading textures")}, - {QStringLiteral("ModContentPackReloadContentInt.Strings"), QStringLiteral("Loading strings")}, - {QStringLiteral("ModContentPackReloadContentInt.AssetBundles"), - QStringLiteral("Loading asset bundles")}, - {QStringLiteral("LoadedLanguageInjectIntoDataBeforeImpliedDefs"), - QStringLiteral("Injecting language data (early pass)")}, - {QStringLiteral("ColoredTextResetStaticData"), - QStringLiteral("Running global operations (early pass)")}, - {QStringLiteral("DefGeneratorGenerateImpliedDefsPreResolve"), - QStringLiteral("Generating implied Defs (pre-resolve)")}, - {QStringLiteral("ResolveAllWantedCrossReferences.Implied"), - QStringLiteral("Resolving cross-references between implied Defs")}, - {QStringLiteral("PlayDataLoaderResetStaticDataPre"), - QStringLiteral("Global operations (pre-resolve)")}, - {QStringLiteral("ResolveReferences"), QStringLiteral("Resolving references")}, - {QStringLiteral("DefGeneratorGenerateImpliedDefsPostResolve"), - QStringLiteral("Generating implied Defs (post-resolve)")}, - {QStringLiteral("PlayDataLoaderResetStaticDataPost"), - QStringLiteral("Global operations (post-resolve)")}, - {QStringLiteral("ErrorCheckAllDefs"), QStringLiteral("Error checking all defs")}, - {QStringLiteral("KeyPrefsInit"), QStringLiteral("Loading keyboard preferences")}, - {QStringLiteral("ShortHashGiverGiveAllShortHashes"), QStringLiteral("Short hash giving")}, - {QStringLiteral("ExecuteToExecuteWhenFinished"), - QStringLiteral("Running delayed initialization task")}, - {QStringLiteral("SolidBioDatabaseLoadAllBios"), QStringLiteral("Loading all bios")}, - {QStringLiteral("LoadedLanguageInjectIntoDataAfterImpliedDefs"), - QStringLiteral("Injecting language data")}, - {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), - QStringLiteral("Running static constructors")}, - {QStringLiteral("FloatMenuMakerMapInit"), QStringLiteral("Initializing float menu maker map")}, - {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), - QStringLiteral("Texture atlas baking")}, - {QStringLiteral("AbstractFilesystemClearAllCache"), - QStringLiteral("Clearing filesystem cache")}, - {QStringLiteral("LoadModXml"), QStringLiteral("Loading and parsing mod XML")}, - }; - return labels; + static const QHash labels{ + {QStringLiteral("ModConstructor"), + QStringLiteral("Running mod constructor")}, + {QStringLiteral("LoadDefs"), QStringLiteral("Loading defs")}, + {QStringLiteral("CombineXml"), + QStringLiteral("Combining XML into a single XML document")}, + {QStringLiteral("TKeySystemParse"), + QStringLiteral("Translation key system parsing")}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), + QStringLiteral("Parsing and processing XML")}, + {QStringLiteral("ErrorCheckPatches"), + QStringLiteral("Error checking patches")}, + {QStringLiteral("LoadPatches"), QStringLiteral("Loading patches")}, + {QStringLiteral("ApplyPatches"), QStringLiteral("Applying patches")}, + {QStringLiteral("RegisterXmlInheritance"), + QStringLiteral("Registering XML inheritance")}, + {QStringLiteral("ResolveXmlInheritance"), + QStringLiteral("Resolving XML inheritance")}, + {QStringLiteral("ClearCachedPatches"), + QStringLiteral("Clearing cached patches")}, + {QStringLiteral("ClearCachedXmlInheritance"), + QStringLiteral("Clearing cached XML inheritance")}, + {QStringLiteral("LanguageDatabaseInitAllMetadata"), + QStringLiteral("Loading language metadata")}, + {QStringLiteral("DefDatabaseAddAllInMods"), + QStringLiteral("Copying all Defs from mods to global databases")}, + {QStringLiteral("ResolveAllWantedCrossReferences"), + QStringLiteral("Resolving cross-references")}, + {QStringLiteral("ResolveAllWantedCrossReferences.NonImplied"), + QStringLiteral("Resolving cross-references between non-implied Defs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs"), + QStringLiteral("Rebinding DefOfs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Early"), + QStringLiteral("Rebinding DefOfs (early)")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Final"), + QStringLiteral("Rebinding DefOfs (final)")}, + {QStringLiteral("TKeySystemBuildMappings"), + QStringLiteral("Translation key system mapping")}, + {QStringLiteral("BackStoryTranslationUtilityLoadAndInjectBackstoryData"), + QStringLiteral("Loading and injecting legacy backstory translations")}, + {QStringLiteral("ModContentPackReloadContentInt"), + QStringLiteral("Loading content")}, + {QStringLiteral("ModContentPackReloadContentInt.AudioClips"), + QStringLiteral("Loading audio clips")}, + {QStringLiteral("ModContentPackReloadContentInt.Textures"), + QStringLiteral("Loading textures")}, + {QStringLiteral("ModContentPackReloadContentInt.Strings"), + QStringLiteral("Loading strings")}, + {QStringLiteral("ModContentPackReloadContentInt.AssetBundles"), + QStringLiteral("Loading asset bundles")}, + {QStringLiteral("LoadedLanguageInjectIntoDataBeforeImpliedDefs"), + QStringLiteral("Injecting language data (early pass)")}, + {QStringLiteral("ColoredTextResetStaticData"), + QStringLiteral("Running global operations (early pass)")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPreResolve"), + QStringLiteral("Generating implied Defs (pre-resolve)")}, + {QStringLiteral("ResolveAllWantedCrossReferences.Implied"), + QStringLiteral("Resolving cross-references between implied Defs")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPre"), + QStringLiteral("Global operations (pre-resolve)")}, + {QStringLiteral("ResolveReferences"), + QStringLiteral("Resolving references")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPostResolve"), + QStringLiteral("Generating implied Defs (post-resolve)")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPost"), + QStringLiteral("Global operations (post-resolve)")}, + {QStringLiteral("ErrorCheckAllDefs"), + QStringLiteral("Error checking all defs")}, + {QStringLiteral("KeyPrefsInit"), + QStringLiteral("Loading keyboard preferences")}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), + QStringLiteral("Short hash giving")}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), + QStringLiteral("Running delayed initialization task")}, + {QStringLiteral("SolidBioDatabaseLoadAllBios"), + QStringLiteral("Loading all bios")}, + {QStringLiteral("LoadedLanguageInjectIntoDataAfterImpliedDefs"), + QStringLiteral("Injecting language data")}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), + QStringLiteral("Running static constructors")}, + {QStringLiteral("FloatMenuMakerMapInit"), + QStringLiteral("Initializing float menu maker map")}, + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), + QStringLiteral("Texture atlas baking")}, + {QStringLiteral("AbstractFilesystemClearAllCache"), + QStringLiteral("Clearing filesystem cache")}, + {QStringLiteral("LoadModXml"), + QStringLiteral("Loading and parsing mod XML")}, + }; + return labels; } // The exact key, else its last dot-separated segment, else the key itself. QString baseLabel(const QString& key) { - const auto& labels = metricLabels(); - if (const auto found = labels.constFind(key); found != labels.cend()) { - return *found; - } - const QString tail = key.mid(key.lastIndexOf(u'.') + 1); - if (const auto found = labels.constFind(tail); found != labels.cend()) { - return *found; - } - return key; + const auto& labels = metricLabels(); + if (const auto found = labels.constFind(key); found != labels.cend()) { + return *found; + } + const QString tail = key.mid(key.lastIndexOf(u'.') + 1); + if (const auto found = labels.constFind(tail); found != labels.cend()) { + return *found; + } + return key; } } // namespace QString stripMetricPrefix(const QString& key) { - return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; + return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; } QString metricLabel(const QString& key) { - const QString stripped = stripMetricPrefix(key); - const qsizetype bar = stripped.indexOf(u'|'); - const QString base = bar < 0 ? stripped : stripped.left(bar); - const QString target = bar < 0 ? QString() : stripped.mid(bar + 1); - const QString label = baseLabel(base); - if (target.isEmpty()) { - return label; - } - // Deferred tasks name the method they run after " -> "; only the target type is shown. - const qsizetype arrow = target.indexOf(QStringLiteral(" -> ")); - const QString shown = arrow < 0 ? target : target.left(arrow); - return QStringLiteral("%1 (%2)").arg(label, shown); + const QString stripped = stripMetricPrefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString base = bar < 0 ? stripped : stripped.left(bar); + const QString target = bar < 0 ? QString() : stripped.mid(bar + 1); + const QString label = baseLabel(base); + if (target.isEmpty()) { + return label; + } + // Deferred tasks name the method they run after " -> "; only the target type + // is shown. + const qsizetype arrow = target.indexOf(QStringLiteral(" -> ")); + const QString shown = arrow < 0 ? target : target.left(arrow); + return QStringLiteral("%1 (%2)").arg(label, shown); } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.hpp b/src/core/services/startup_impact_service/labels.hpp index 0f498a0..cda0735 100644 --- a/src/core/services/startup_impact_service/labels.hpp +++ b/src/core/services/startup_impact_service/labels.hpp @@ -4,11 +4,12 @@ namespace px::modrim::startup_impact { -// Drops the "LoadingProgress.StartupImpact." prefix the game writes before every metric key. +// Drops the "LoadingProgress.StartupImpact." prefix the game writes before +// every metric key. QString stripMetricPrefix(const QString& key); -// Human-readable name for a metric key. A "|Target" suffix, as written for deferred tasks, -// is shown in parentheses after the label. +// Human-readable name for a metric key. A "|Target" suffix, as written for +// deferred tasks, is shown in parentheses after the label. QString metricLabel(const QString& key); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp index 343ead0..4f8565d 100644 --- a/src/core/services/startup_impact_service/models.cpp +++ b/src/core/services/startup_impact_service/models.cpp @@ -8,72 +8,75 @@ namespace { template double sumValues(const Map& map) { - double total = 0.0; - for (const double value : map) { - total += value; - } - return total; + double total = 0.0; + for (const double value : map) { + total += value; + } + return total; } -bool isSet(const std::optional& value) { return value && !value->isEmpty(); } +bool isSet(const std::optional& value) { + return value && !value->isEmpty(); +} } // namespace double StartupImpactReport::totalTime() const { - double total = 0.0; - for (const StartupImpactMod& mod : mods) { - total += mod.totalImpactS; - } - return total; + double total = 0.0; + for (const StartupImpactMod& mod : mods) { + total += mod.totalImpactS; + } + return total; } double StartupImpactReport::offThreadTotalTime() const { - double total = 0.0; - for (const StartupImpactMod& mod : mods) { - total += mod.offThreadTotalImpactS; - } - return total; + double total = 0.0; + for (const StartupImpactMod& mod : mods) { + total += mod.offThreadTotalImpactS; + } + return total; } double StartupImpactReport::modsTotalTime() const { - return totalTime() + offThreadTotalTime(); + return totalTime() + offThreadTotalTime(); } double StartupImpactReport::baseGameTime() const { return sumValues(metrics); } -const StartupImpactMod* StartupImpactReport::find(const std::optional& packageId, - const std::optional& modName) const { - if (isSet(packageId)) { - const QString normalized = normalizePackageId(*packageId); - for (const StartupImpactMod& mod : mods) { - if (mod.packageId == normalized) { - return &mod; - } - } +const StartupImpactMod* StartupImpactReport::find( + const std::optional& packageId, + const std::optional& modName) const { + if (isSet(packageId)) { + const QString normalized = normalizePackageId(*packageId); + for (const StartupImpactMod& mod : mods) { + if (mod.packageId == normalized) { + return &mod; + } } - if (isSet(modName)) { - const QString nameLower = modName->toLower(); - for (const StartupImpactMod& mod : mods) { - if (mod.modName.toLower() == nameLower) { - return &mod; - } - } + } + if (isSet(modName)) { + const QString nameLower = modName->toLower(); + for (const StartupImpactMod& mod : mods) { + if (mod.modName.toLower() == nameLower) { + return &mod; + } } - return nullptr; + } + return nullptr; } QString normalizePackageId(QStringView packageId) { - QString normalized = packageId.toString().toLower(); - constexpr QLatin1StringView kSteamSuffix{"_steam"}; - if (normalized.endsWith(kSteamSuffix)) { - normalized.chop(kSteamSuffix.size()); - } - return normalized; + QString normalized = packageId.toString().toLower(); + constexpr QLatin1StringView kSteamSuffix{"_steam"}; + if (normalized.endsWith(kSteamSuffix)) { + normalized.chop(kSteamSuffix.size()); + } + return normalized; } QString formatImpact(double seconds) { - const auto ms = static_cast(std::nearbyint(seconds * 1000.0)); - return QStringLiteral("%1ms").arg(ms); + const auto ms = static_cast(std::nearbyint(seconds * 1000.0)); + return QStringLiteral("%1ms").arg(ms); } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.hpp b/src/core/services/startup_impact_service/models.hpp index 4f86ac6..f3d76ff 100644 --- a/src/core/services/startup_impact_service/models.hpp +++ b/src/core/services/startup_impact_service/models.hpp @@ -1,56 +1,58 @@ #pragma once -#include - #include #include #include #include #include +#include namespace px::modrim::startup_impact { inline constexpr double kImpactWarnThresholdS = 1.0; inline constexpr double kImpactHighThresholdS = 5.0; -// Stored as the mod's package id for the RimWorld base game's own profiler metrics. +// Stored as the mod's package id for the RimWorld base game's own profiler +// metrics. inline constexpr QLatin1StringView kBaseGamePid{"__base_game__"}; -// Impact durations are in seconds. Metric maps are keyed by profiler metric name and -// ordered by name, so iteration is deterministic. +// Impact durations are in seconds. Metric maps are keyed by profiler metric +// name and ordered by name, so iteration is deterministic. struct StartupImpactMod { - QString modName; - std::optional packageId; - double totalImpactS = 0.0; - QMap metrics; - QMap offThreadMetrics; - double offThreadTotalImpactS = 0.0; + QString modName; + std::optional packageId; + double totalImpactS = 0.0; + QMap metrics; + QMap offThreadMetrics; + double offThreadTotalImpactS = 0.0; }; struct StartupImpactReport { - QString path; - double loadingTimeS = 0.0; - QList mods; - QString timestamp; - QMap metrics; - double totalImpact = 0.0; - QMap offThreadMetrics; - double offThreadTotalImpact = 0.0; - - double totalTime() const; - double offThreadTotalTime() const; - double modsTotalTime() const; - double baseGameTime() const; - - // Matches by normalized package id first, then by case-insensitive mod name. - const StartupImpactMod* find(const std::optional& packageId, - const std::optional& modName) const; + QString path; + double loadingTimeS = 0.0; + QList mods; + QString timestamp; + QMap metrics; + double totalImpact = 0.0; + QMap offThreadMetrics; + double offThreadTotalImpact = 0.0; + + double totalTime() const; + double offThreadTotalTime() const; + double modsTotalTime() const; + double baseGameTime() const; + + // Matches by normalized package id first, then by case-insensitive mod name. + const StartupImpactMod* find(const std::optional& packageId, + const std::optional& modName) const; }; -// Lowercases and drops the "_steam" suffix that Steam Workshop folders add to package ids. +// Lowercases and drops the "_steam" suffix that Steam Workshop folders add to +// package ids. QString normalizePackageId(QStringView packageId); -// Rounds half to even, as Python's round() does, so displayed values match the legacy app. +// Rounds half to even, as Python's round() does, so displayed values match the +// legacy app. QString formatImpact(double seconds); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp index 56aca8a..00b48f1 100644 --- a/src/core/services/startup_impact_service/parser.cpp +++ b/src/core/services/startup_impact_service/parser.cpp @@ -8,6 +8,7 @@ #include #include #include + #include namespace px::modrim::startup_impact { @@ -19,126 +20,134 @@ constexpr QLatin1StringView kReportFilename{"StartupImpactData.json"}; // The game writes milliseconds; the rest of the app works in seconds. double msToS(double ms) { return ms / 1000.0; } -// Python's bool is an int subclass, so the legacy parser accepts true/false as 1/0 ms. -bool isNumeric(const QJsonValue& value) { return value.isDouble() || value.isBool(); } +// Python's bool is an int subclass, so the legacy parser accepts true/false as +// 1/0 ms. +bool isNumeric(const QJsonValue& value) { + return value.isDouble() || value.isBool(); +} double numericMs(const QJsonValue& value) { - return value.isBool() ? static_cast(value.toBool()) : value.toDouble(); + return value.isBool() ? static_cast(value.toBool()) + : value.toDouble(); } double asSeconds(const QJsonValue& value) { - if (isNumeric(value)) { - return msToS(numericMs(value)); - } - if (value.isString()) { - bool ok = false; - const double ms = value.toString().toDouble(&ok); - return ok ? msToS(ms) : 0.0; - } - return 0.0; + if (isNumeric(value)) { + return msToS(numericMs(value)); + } + if (value.isString()) { + bool ok = false; + const double ms = value.toString().toDouble(&ok); + return ok ? msToS(ms) : 0.0; + } + return 0.0; } // Non-numeric values are skipped, as the legacy parser skips them. QMap asSecondsMap(const QJsonValue& value) { - QMap result; - if (!value.isObject()) { - return result; - } - const QJsonObject object = value.toObject(); - for (auto it = object.constBegin(); it != object.constEnd(); ++it) { - if (isNumeric(it.value())) { - result.insert(it.key(), msToS(numericMs(it.value()))); - } - } + QMap result; + if (!value.isObject()) { return result; + } + const QJsonObject object = value.toObject(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (isNumeric(it.value())) { + result.insert(it.key(), msToS(numericMs(it.value()))); + } + } + return result; } std::optional parseMod(const QJsonValue& value) { - if (!value.isObject()) { - return std::nullopt; - } - const QJsonObject entry = value.toObject(); - const QJsonValue modName = entry.value(QStringLiteral("modName")); - if (!modName.isString() || modName.toString().isEmpty()) { - return std::nullopt; - } - - const QJsonValue rawPackageId = entry.value(QStringLiteral("modPackageId")); - std::optional packageId; - if (rawPackageId.isString() && !rawPackageId.toString().isEmpty()) { - packageId = normalizePackageId(rawPackageId.toString()); - } - - return StartupImpactMod{ - .modName = modName.toString(), - .packageId = packageId, - .totalImpactS = asSeconds(entry.value(QStringLiteral("totalImpact"))), - .metrics = asSecondsMap(entry.value(QStringLiteral("metrics"))), - .offThreadMetrics = asSecondsMap(entry.value(QStringLiteral("offThreadMetrics"))), - .offThreadTotalImpactS = - asSeconds(entry.value(QStringLiteral("offThreadTotalImpact"))), - }; + if (!value.isObject()) { + return std::nullopt; + } + const QJsonObject entry = value.toObject(); + const QJsonValue modName = entry.value(QStringLiteral("modName")); + if (!modName.isString() || modName.toString().isEmpty()) { + return std::nullopt; + } + + const QJsonValue rawPackageId = entry.value(QStringLiteral("modPackageId")); + std::optional packageId; + if (rawPackageId.isString() && !rawPackageId.toString().isEmpty()) { + packageId = normalizePackageId(rawPackageId.toString()); + } + + return StartupImpactMod{ + .modName = modName.toString(), + .packageId = packageId, + .totalImpactS = asSeconds(entry.value(QStringLiteral("totalImpact"))), + .metrics = asSecondsMap(entry.value(QStringLiteral("metrics"))), + .offThreadMetrics = + asSecondsMap(entry.value(QStringLiteral("offThreadMetrics"))), + .offThreadTotalImpactS = + asSeconds(entry.value(QStringLiteral("offThreadTotalImpact"))), + }; } } // namespace QString getStartupImpactPath(const QString& configFolder) { - // Mirrors Path(config_folder).parent: a trailing separator is ignored and a bare name - // has the current directory as its parent. - const QString clean = QDir::cleanPath(configFolder); - const qsizetype slash = clean.lastIndexOf(u'/'); - const QString parent = slash < 0 ? QStringLiteral(".") - : slash == 0 ? QStringLiteral("/") - : clean.left(slash); - return QDir::cleanPath(QDir(parent).filePath(kReportFilename.toString())); + // Mirrors Path(config_folder).parent: a trailing separator is ignored and a + // bare name has the current directory as its parent. + const QString clean = QDir::cleanPath(configFolder); + const qsizetype slash = clean.lastIndexOf(u'/'); + const QString parent = slash < 0 ? QStringLiteral(".") + : slash == 0 ? QStringLiteral("/") + : clean.left(slash); + return QDir::cleanPath(QDir(parent).filePath(kReportFilename.toString())); } std::optional parseStartupImpact(const QString& path) { - if (!QFileInfo::exists(path)) { - spdlog::debug("Startup impact report not found at {}", path.toStdString()); - return std::nullopt; - } - - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - spdlog::warn("Failed to parse startup impact report at {}: {}", path.toStdString(), - file.errorString().toStdString()); - return std::nullopt; - } - - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError) { - spdlog::warn("Failed to parse startup impact report at {}: {}", path.toStdString(), - error.errorString().toStdString()); - return std::nullopt; - } - if (!document.isObject()) { - spdlog::warn("Startup impact report is not a JSON object: {}", path.toStdString()); - return std::nullopt; - } - - const QJsonObject data = document.object(); - StartupImpactReport report; - report.path = QDir::cleanPath(path); - report.loadingTimeS = asSeconds(data.value(QStringLiteral("loadingTime"))); - if (const QJsonValue timestamp = data.value(QStringLiteral("timestamp")); - timestamp.isString()) { - report.timestamp = timestamp.toString(); - } - report.metrics = asSecondsMap(data.value(QStringLiteral("metrics"))); - report.totalImpact = asSeconds(data.value(QStringLiteral("totalImpact"))); - report.offThreadMetrics = asSecondsMap(data.value(QStringLiteral("offThreadMetrics"))); - report.offThreadTotalImpact = - asSeconds(data.value(QStringLiteral("offThreadTotalImpact"))); - - // A non-array "mods" yields no entries, as iterating the legacy value did. - for (const QJsonValue& entry : data.value(QStringLiteral("mods")).toArray()) { - if (auto mod = parseMod(entry)) { - report.mods.append(std::move(*mod)); - } + if (!QFileInfo::exists(path)) { + spdlog::debug("Startup impact report not found at {}", path.toStdString()); + return std::nullopt; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::warn("Failed to parse startup impact report at {}: {}", + path.toStdString(), file.errorString().toStdString()); + return std::nullopt; + } + + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + spdlog::warn("Failed to parse startup impact report at {}: {}", + path.toStdString(), error.errorString().toStdString()); + return std::nullopt; + } + if (!document.isObject()) { + spdlog::warn("Startup impact report is not a JSON object: {}", + path.toStdString()); + return std::nullopt; + } + + const QJsonObject data = document.object(); + StartupImpactReport report; + report.path = QDir::cleanPath(path); + report.loadingTimeS = asSeconds(data.value(QStringLiteral("loadingTime"))); + if (const QJsonValue timestamp = data.value(QStringLiteral("timestamp")); + timestamp.isString()) { + report.timestamp = timestamp.toString(); + } + report.metrics = asSecondsMap(data.value(QStringLiteral("metrics"))); + report.totalImpact = asSeconds(data.value(QStringLiteral("totalImpact"))); + report.offThreadMetrics = + asSecondsMap(data.value(QStringLiteral("offThreadMetrics"))); + report.offThreadTotalImpact = + asSeconds(data.value(QStringLiteral("offThreadTotalImpact"))); + + // A non-array "mods" yields no entries, as iterating the legacy value did. + for (const QJsonValue& entry : data.value(QStringLiteral("mods")).toArray()) { + if (auto mod = parseMod(entry)) { + report.mods.append(std::move(*mod)); } - return report; + } + return report; } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.hpp b/src/core/services/startup_impact_service/parser.hpp index bc82139..ee72f77 100644 --- a/src/core/services/startup_impact_service/parser.hpp +++ b/src/core/services/startup_impact_service/parser.hpp @@ -1,8 +1,7 @@ #pragma once -#include - #include +#include #include "core/services/startup_impact_service/models.hpp" @@ -11,8 +10,9 @@ namespace px::modrim::startup_impact { // The report lives next to the game's config folder, not inside it. QString getStartupImpactPath(const QString& configFolder); -// Reads a StartupImpactData.json report. Returns nullopt when the file is missing, unreadable, -// or not a JSON object. Malformed entries are skipped rather than failing the whole report. +// Reads a StartupImpactData.json report. Returns nullopt when the file is +// missing, unreadable, or not a JSON object. Malformed entries are skipped +// rather than failing the whole report. std::optional parseStartupImpact(const QString& path); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp index ba5bd66..7cec902 100644 --- a/src/core/services/startup_impact_service/phases.cpp +++ b/src/core/services/startup_impact_service/phases.cpp @@ -1,10 +1,9 @@ #include "core/services/startup_impact_service/phases.hpp" -#include -#include - #include #include +#include +#include #include "core/services/startup_impact_service/labels.hpp" @@ -13,24 +12,26 @@ namespace px::modrim::startup_impact { namespace { const QHash& exactPhases() { - static const QHash phases{ - {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), Phase::kContent}, - {QStringLiteral("ModConstructor"), Phase::kModConstructors}, - {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), Phase::kStaticConstructors}, - {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::kDeferred}, - {QStringLiteral("LoadDefs"), Phase::kDefs}, - {QStringLiteral("LoadModXml"), Phase::kDefs}, - {QStringLiteral("CombineXml"), Phase::kDefs}, - {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::kDefs}, - {QStringLiteral("RegisterXmlInheritance"), Phase::kDefs}, - {QStringLiteral("ResolveXmlInheritance"), Phase::kDefs}, - {QStringLiteral("ClearCachedXmlInheritance"), Phase::kDefs}, - {QStringLiteral("DefDatabaseAddAllInMods"), Phase::kDefs}, - {QStringLiteral("ResolveReferences"), Phase::kDefs}, - {QStringLiteral("ErrorCheckAllDefs"), Phase::kDefs}, - {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::kDefs}, - }; - return phases; + static const QHash phases{ + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), + Phase::kContent}, + {QStringLiteral("ModConstructor"), Phase::kModConstructors}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), + Phase::kStaticConstructors}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::kDeferred}, + {QStringLiteral("LoadDefs"), Phase::kDefs}, + {QStringLiteral("LoadModXml"), Phase::kDefs}, + {QStringLiteral("CombineXml"), Phase::kDefs}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::kDefs}, + {QStringLiteral("RegisterXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ResolveXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ClearCachedXmlInheritance"), Phase::kDefs}, + {QStringLiteral("DefDatabaseAddAllInMods"), Phase::kDefs}, + {QStringLiteral("ResolveReferences"), Phase::kDefs}, + {QStringLiteral("ErrorCheckAllDefs"), Phase::kDefs}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::kDefs}, + }; + return phases; } // Checked in order after the exact names; the first matching prefix wins. @@ -44,50 +45,51 @@ constexpr std::array, 4> kPrefixPhases{{ } // namespace QString phaseLabel(Phase phase) { - switch (phase) { - case Phase::kContent: - return QStringLiteral("Textures & audio"); - case Phase::kModConstructors: - return QStringLiteral("Mod constructors"); - case Phase::kStaticConstructors: - return QStringLiteral("Static constructors"); - case Phase::kPatches: - return QStringLiteral("Patches"); - case Phase::kDefs: - return QStringLiteral("Defs & XML"); - case Phase::kDeferred: - return QStringLiteral("Deferred init"); - case Phase::kOther: - return QStringLiteral("Other"); - } - return QString(); + switch (phase) { + case Phase::kContent: + return QStringLiteral("Textures & audio"); + case Phase::kModConstructors: + return QStringLiteral("Mod constructors"); + case Phase::kStaticConstructors: + return QStringLiteral("Static constructors"); + case Phase::kPatches: + return QStringLiteral("Patches"); + case Phase::kDefs: + return QStringLiteral("Defs & XML"); + case Phase::kDeferred: + return QStringLiteral("Deferred init"); + case Phase::kOther: + return QStringLiteral("Other"); + } + return QString(); } Phase metricPhase(const QString& key) { - const QString stripped = stripMetricPrefix(key); - const qsizetype bar = stripped.indexOf(u'|'); - const QString name = bar < 0 ? stripped : stripped.left(bar); + const QString stripped = stripMetricPrefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString name = bar < 0 ? stripped : stripped.left(bar); - if (const auto found = exactPhases().constFind(name); found != exactPhases().cend()) { - return *found; - } - if (name.endsWith(QLatin1StringView{"Patches"})) { - return Phase::kPatches; + if (const auto found = exactPhases().constFind(name); + found != exactPhases().cend()) { + return *found; + } + if (name.endsWith(QLatin1StringView{"Patches"})) { + return Phase::kPatches; + } + for (const auto& [prefix, phase] : kPrefixPhases) { + if (name.startsWith(prefix)) { + return phase; } - for (const auto& [prefix, phase] : kPrefixPhases) { - if (name.startsWith(prefix)) { - return phase; - } - } - return Phase::kOther; + } + return Phase::kOther; } PhaseTotals phaseTotals(const QMap& metrics) { - PhaseTotals totals{}; - for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { - totals[phaseIndex(metricPhase(it.key()))] += it.value(); - } - return totals; + PhaseTotals totals{}; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + totals[phaseIndex(metricPhase(it.key()))] += it.value(); + } + return totals; } } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.hpp b/src/core/services/startup_impact_service/phases.hpp index 8078e8e..874e94e 100644 --- a/src/core/services/startup_impact_service/phases.hpp +++ b/src/core/services/startup_impact_service/phases.hpp @@ -1,22 +1,21 @@ #pragma once +#include +#include #include #include #include -#include -#include - namespace px::modrim::startup_impact { enum class Phase : std::uint8_t { - kContent, - kModConstructors, - kStaticConstructors, - kPatches, - kDefs, - kDeferred, - kOther, + kContent, + kModConstructors, + kStaticConstructors, + kPatches, + kDefs, + kDeferred, + kOther, }; inline constexpr std::size_t kPhaseCount = 7; @@ -24,14 +23,17 @@ inline constexpr std::size_t kPhaseCount = 7; // Seconds per phase, indexed by `phase_index`. using PhaseTotals = std::array; -constexpr std::size_t phaseIndex(Phase phase) { return static_cast(phase); } +constexpr std::size_t phaseIndex(Phase phase) { + return static_cast(phase); +} QString phaseLabel(Phase phase); // Phase a profiler metric key belongs to. Unknown keys fall into Other. Phase metricPhase(const QString& key); -// Sums metric seconds per phase. Every phase is present, even when it has no metrics. +// Sums metric seconds per phase. Every phase is present, even when it has no +// metrics. PhaseTotals phaseTotals(const QMap& metrics); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp index 40b0624..ce15b55 100644 --- a/src/core/services/startup_impact_service/service.cpp +++ b/src/core/services/startup_impact_service/service.cpp @@ -15,94 +15,106 @@ namespace { constexpr double kMinBaseGameTimeS = 0.001; -QString dbPathFor(CoreContext& ctx) { return dbPath(ctx.configService().directory()); } +QString dbPathFor(CoreContext& ctx) { + return dbPath(ctx.configService().directory()); +} QStringList normalized(const QStringList& pids) { - QStringList out; - out.reserve(pids.size()); - for (const QString& pid : pids) out.push_back(normalizePackageId(pid)); - return out; + QStringList out; + out.reserve(pids.size()); + for (const QString& pid : pids) out.push_back(normalizePackageId(pid)); + return out; } } // namespace StartupImpactService::StartupImpactService(CoreContext& ctx) : m_ctx(ctx) {} -QCoro::Task>> StartupImpactService::load() { - const std::string& configFolder = m_ctx.config().paths.config_folder; - if (configFolder.empty()) co_return std::optional{}; - - const QString dbPath = dbPathFor(m_ctx); - const QString jsonPath = getStartupImpactPath(QString::fromStdString(configFolder)); - spdlog::debug("Loading startup impact from {}", jsonPath.toStdString()); - - std::optional report = parseStartupImpact(jsonPath); - if (report) { - const double base = std::max(0.0, report->baseGameTime()); - QList entries = report->mods; - if (base > kMinBaseGameTimeS) { - StartupImpactMod baseGame; - baseGame.modName = QString(kBaseGamePid); - baseGame.packageId = QString(kBaseGamePid); - baseGame.totalImpactS = base; - baseGame.metrics = report->metrics; - baseGame.offThreadMetrics = report->offThreadMetrics; - baseGame.offThreadTotalImpactS = - std::accumulate(report->offThreadMetrics.cbegin(), - report->offThreadMetrics.cend(), 0.0); - entries.push_back(std::move(baseGame)); - } - StartupImpactReport full; - full.path = report->path; - full.loadingTimeS = report->loadingTimeS; - full.mods = std::move(entries); - full.timestamp = report->timestamp; - auto stored = co_await m_db.storeReport(dbPath, std::move(full)); - if (!stored) co_return std::unexpected(stored.error()); +QCoro::Task>> +StartupImpactService::load() { + const std::string& configFolder = m_ctx.config().paths.config_folder; + if (configFolder.empty()) co_return std::optional{}; + + const QString dbPath = dbPathFor(m_ctx); + const QString jsonPath = + getStartupImpactPath(QString::fromStdString(configFolder)); + spdlog::debug("Loading startup impact from {}", jsonPath.toStdString()); + + std::optional report = parseStartupImpact(jsonPath); + if (report) { + const double base = std::max(0.0, report->baseGameTime()); + QList entries = report->mods; + if (base > kMinBaseGameTimeS) { + StartupImpactMod baseGame; + baseGame.modName = QString(kBaseGamePid); + baseGame.packageId = QString(kBaseGamePid); + baseGame.totalImpactS = base; + baseGame.metrics = report->metrics; + baseGame.offThreadMetrics = report->offThreadMetrics; + baseGame.offThreadTotalImpactS = + std::accumulate(report->offThreadMetrics.cbegin(), + report->offThreadMetrics.cend(), 0.0); + entries.push_back(std::move(baseGame)); } - co_return report; + StartupImpactReport full; + full.path = report->path; + full.loadingTimeS = report->loadingTimeS; + full.mods = std::move(entries); + full.timestamp = report->timestamp; + auto stored = co_await m_db.storeReport(dbPath, std::move(full)); + if (!stored) co_return std::unexpected(stored.error()); + } + co_return report; } -QCoro::Task>> StartupImpactService::getLatestReport() { - co_return co_await m_db.getLatestReport(dbPathFor(m_ctx)); +QCoro::Task>> +StartupImpactService::getLatestReport() { + co_return co_await m_db.getLatestReport(dbPathFor(m_ctx)); } -QCoro::Task> StartupImpactService::estimatedImpactFor(QString packageId) { - co_return co_await m_db.getAverage(dbPathFor(m_ctx), normalizePackageId(packageId)); +QCoro::Task> StartupImpactService::estimatedImpactFor( + QString packageId) { + co_return co_await m_db.getAverage(dbPathFor(m_ctx), + normalizePackageId(packageId)); } -QCoro::Task> StartupImpactService::estimatedTotal(QStringList activePids) { - const QString dbPath = dbPathFor(m_ctx); - auto base = co_await m_db.getAverage(dbPath, QString(kBaseGamePid)); - if (!base) co_return std::unexpected(base.error()); - auto totals = co_await m_db.getTotalsFor(dbPath, normalized(activePids)); - if (!totals) co_return std::unexpected(totals.error()); - double modSum = 0.0; - for (const ThreadAverages& t : *totals) modSum += t.onThread + t.offThread; - co_return *base + modSum; +QCoro::Task> StartupImpactService::estimatedTotal( + QStringList activePids) { + const QString dbPath = dbPathFor(m_ctx); + auto base = co_await m_db.getAverage(dbPath, QString(kBaseGamePid)); + if (!base) co_return std::unexpected(base.error()); + auto totals = co_await m_db.getTotalsFor(dbPath, normalized(activePids)); + if (!totals) co_return std::unexpected(totals.error()); + double modSum = 0.0; + for (const ThreadAverages& t : *totals) modSum += t.onThread + t.offThread; + co_return *base + modSum; } -QCoro::Task>> StartupImpactService::getAllAverages() { - co_return co_await m_db.getAllAverages(dbPathFor(m_ctx)); +QCoro::Task>> +StartupImpactService::getAllAverages() { + co_return co_await m_db.getAllAverages(dbPathFor(m_ctx)); } QCoro::Task> StartupImpactService::baseGameAverage() { - co_return co_await m_db.getAverage(dbPathFor(m_ctx), QString(kBaseGamePid)); + co_return co_await m_db.getAverage(dbPathFor(m_ctx), QString(kBaseGamePid)); } QCoro::Task> StartupImpactService::snapshot( QStringList activePids, std::optional selectedPid) { - std::optional selected; - if (selectedPid && !selectedPid->isEmpty()) selected = normalizePackageId(*selectedPid); - co_return co_await m_db.getLatestWithAverages(dbPathFor(m_ctx), normalized(activePids), - std::move(selected)); + std::optional selected; + if (selectedPid && !selectedPid->isEmpty()) + selected = normalizePackageId(*selectedPid); + co_return co_await m_db.getLatestWithAverages( + dbPathFor(m_ctx), normalized(activePids), std::move(selected)); } QCoro::Task> StartupImpactService::clear() { - co_return co_await m_db.clear(dbPathFor(m_ctx)); + co_return co_await m_db.clear(dbPathFor(m_ctx)); } -QCoro::Task StartupImpactService::closeConnection() { co_await m_db.close(); } +QCoro::Task StartupImpactService::closeConnection() { + co_await m_db.close(); +} void StartupImpactService::closeConnectionSync() { m_db.closeSync(); } diff --git a/src/core/services/startup_impact_service/service.hpp b/src/core/services/startup_impact_service/service.hpp index 6d7eefe..17e81a9 100644 --- a/src/core/services/startup_impact_service/service.hpp +++ b/src/core/services/startup_impact_service/service.hpp @@ -1,11 +1,11 @@ #pragma once -#include - -#include #include #include #include +#include + +#include #include "core/error.hpp" #include "core/services/startup_impact_service/db.hpp" @@ -17,33 +17,34 @@ class CoreContext; namespace px::modrim::startup_impact { -// Loads the game's startup impact report into the history database and answers queries about -// historical averages and per-session breakdowns. +// Loads the game's startup impact report into the history database and answers +// queries about historical averages and per-session breakdowns. class StartupImpactService { -public: - // `ctx` is not owned and must outlive the service. - explicit StartupImpactService(CoreContext& ctx); - - // Imports the report from the game's config folder and records it in history. Resolves to - // nullopt when no config folder is set or no report can be read. - QCoro::Task>> load(); - - QCoro::Task>> getLatestReport(); - QCoro::Task> estimatedImpactFor(QString packageId); - // Base-game average plus the on-thread and off-thread averages of each active mod. - QCoro::Task> estimatedTotal(QStringList activePids); - QCoro::Task>> getAllAverages(); - QCoro::Task> baseGameAverage(); - QCoro::Task> snapshot(QStringList activePids, - std::optional selectedPid = {}); - QCoro::Task> clear(); - - QCoro::Task closeConnection(); - void closeConnectionSync(); - -private: - CoreContext& m_ctx; - StartupImpactDb m_db; + public: + // `ctx` is not owned and must outlive the service. + explicit StartupImpactService(CoreContext& ctx); + + // Imports the report from the game's config folder and records it in history. + // Resolves to nullopt when no config folder is set or no report can be read. + QCoro::Task>> load(); + + QCoro::Task>> getLatestReport(); + QCoro::Task> estimatedImpactFor(QString packageId); + // Base-game average plus the on-thread and off-thread averages of each active + // mod. + QCoro::Task> estimatedTotal(QStringList activePids); + QCoro::Task>> getAllAverages(); + QCoro::Task> baseGameAverage(); + QCoro::Task> snapshot( + QStringList activePids, std::optional selectedPid = {}); + QCoro::Task> clear(); + + QCoro::Task closeConnection(); + void closeConnectionSync(); + + private: + CoreContext& m_ctx; + StartupImpactDb m_db; }; } // namespace px::modrim::startup_impact diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp index f4dc99e..0e53178 100644 --- a/src/core/services/update_service.cpp +++ b/src/core/services/update_service.cpp @@ -1,9 +1,5 @@ #include "core/services/update_service.hpp" -#include -#include - -#include #include #include #include @@ -12,6 +8,11 @@ #include #include #include +#include +#include + +#include + #include namespace px::modrim::services { @@ -21,146 +22,154 @@ namespace { constexpr int kTransferTimeoutMs = 10'000; struct ParsedVersion { - std::vector numbers; - QString suffix; + std::vector numbers; + QString suffix; }; // Python's str.strip("-._ ") on both ends. QString stripSeparators(QString text) { - constexpr QStringView kSeparators = u"-._ "; - while (!text.isEmpty() && kSeparators.contains(text.front())) { - text.removeFirst(); - } - while (!text.isEmpty() && kSeparators.contains(text.back())) { - text.chop(1); - } - return text; + constexpr QStringView kSeparators = u"-._ "; + while (!text.isEmpty() && kSeparators.contains(text.front())) { + text.removeFirst(); + } + while (!text.isEmpty() && kSeparators.contains(text.back())) { + text.chop(1); + } + return text; } std::optional splitVersion(const QString& text) { - static const QRegularExpression pattern(QStringLiteral(R"(^v?([0-9]+(?:\.[0-9]+)*)([^+]*))")); - const QRegularExpressionMatch match = pattern.match(text.trimmed()); - if (!match.hasMatch()) { - return std::nullopt; - } - ParsedVersion parsed; - for (const QString& part : match.captured(1).split(u'.')) { - parsed.numbers.push_back(part.toLongLong()); - } - parsed.suffix = stripSeparators(match.captured(2)).toLower(); - return parsed; + static const QRegularExpression pattern( + QStringLiteral(R"(^v?([0-9]+(?:\.[0-9]+)*)([^+]*))")); + const QRegularExpressionMatch match = pattern.match(text.trimmed()); + if (!match.hasMatch()) { + return std::nullopt; + } + ParsedVersion parsed; + for (const QString& part : match.captured(1).split(u'.')) { + parsed.numbers.push_back(part.toLongLong()); + } + parsed.suffix = stripSeparators(match.captured(2)).toLower(); + return parsed; } -// Orders two versions the way the legacy key did: padded numbers, then release over -// pre-release, then the suffix text. +// Orders two versions the way the legacy key did: padded numbers, then release +// over pre-release, then the suffix text. int compareVersions(const ParsedVersion& a, const ParsedVersion& b) { - const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); - auto padded = [width](std::vector numbers) { - numbers.resize(width, 0); - return numbers; - }; - const std::vector left = padded(a.numbers); - const std::vector right = padded(b.numbers); - if (left != right) { - return left < right ? -1 : 1; - } - const bool leftRelease = a.suffix.isEmpty(); - const bool rightRelease = b.suffix.isEmpty(); - if (leftRelease != rightRelease) { - return leftRelease ? 1 : -1; - } - if (a.suffix != b.suffix) { - return a.suffix < b.suffix ? -1 : 1; - } - return 0; + const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); + auto padded = [width](std::vector numbers) { + numbers.resize(width, 0); + return numbers; + }; + const std::vector left = padded(a.numbers); + const std::vector right = padded(b.numbers); + if (left != right) { + return left < right ? -1 : 1; + } + const bool leftRelease = a.suffix.isEmpty(); + const bool rightRelease = b.suffix.isEmpty(); + if (leftRelease != rightRelease) { + return leftRelease ? 1 : -1; + } + if (a.suffix != b.suffix) { + return a.suffix < b.suffix ? -1 : 1; + } + return 0; } struct ReplyDeleter { - void operator()(QNetworkReply* reply) const { reply->deleteLater(); } + void operator()(QNetworkReply* reply) const { reply->deleteLater(); } }; QString stringField(const QJsonObject& object, const char* key) { - const QJsonValue value = object.value(QLatin1StringView(key)); - if (value.isString()) { - return value.toString(); - } - if (value.isDouble()) { - return QString::number(value.toDouble()); - } - return {}; + const QJsonValue value = object.value(QLatin1StringView(key)); + if (value.isString()) { + return value.toString(); + } + if (value.isDouble()) { + return QString::number(value.toDouble()); + } + return {}; } } // namespace std::optional> parseVersion(const QString& text) { - const auto parsed = splitVersion(text); - if (!parsed) { - return std::nullopt; - } - return parsed->numbers; + const auto parsed = splitVersion(text); + if (!parsed) { + return std::nullopt; + } + return parsed->numbers; } bool isNewer(const QString& tag, const QString& current) { - const auto latest = splitVersion(tag); - const auto running = splitVersion(current); - if (!latest || !running) { - return false; - } - return compareVersions(*latest, *running) > 0; + const auto latest = splitVersion(tag); + const auto running = splitVersion(current); + if (!latest || !running) { + return false; + } + return compareVersions(*latest, *running) > 0; } -UpdateService::UpdateService(QString currentVersion, QNetworkAccessManager& network, - QString releaseUrl) +UpdateService::UpdateService(QString currentVersion, + QNetworkAccessManager& network, QString releaseUrl) : m_current(std::move(currentVersion)), m_network(network), m_releaseUrl(std::move(releaseUrl)) {} QCoro::Task>> UpdateService::check() { - QNetworkRequest request{QUrl(m_releaseUrl)}; - request.setRawHeader("Accept", "application/vnd.github+json"); - request.setRawHeader("User-Agent", QStringLiteral("PxModRim/%1").arg(m_current).toUtf8()); - request.setTransferTimeout(kTransferTimeoutMs); - - QNetworkReply* pending = m_network.get(request); - std::unique_ptr reply(pending); - co_await pending; - - const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->error() != QNetworkReply::NoError) { - co_return std::unexpected(Error{reply->errorString()}); - } - if (status < 200 || status >= 300) { - co_return std::unexpected(Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(m_releaseUrl)}); - } - - const QByteArray body = reply->readAll(); - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); - if (parseError.error != QJsonParseError::NoError) { - co_return std::unexpected(Error{parseError.errorString()}); - } - if (!document.isObject()) { - co_return std::unexpected(Error{QStringLiteral("Latest release response is not a JSON object")}); - } - - const QJsonObject data = document.object(); - const QString tag = stringField(data, "tag_name"); - const QString url = stringField(data, "html_url"); - if (tag.isEmpty() || url.isEmpty()) { - co_return std::unexpected(Error{QStringLiteral("Latest release response lacks tag_name/html_url")}); - } - if (!isNewer(tag, m_current)) { - spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), m_current.toStdString()); - co_return std::optional{}; - } - - spdlog::info("Update available: {} (running {})", tag.toStdString(), m_current.toStdString()); - QString name = stringField(data, "name"); - if (name.isEmpty()) { - name = tag; - } - co_return std::optional( - ReleaseInfo{tag, name, stringField(data, "body"), url}); + QNetworkRequest request{QUrl(m_releaseUrl)}; + request.setRawHeader("Accept", "application/vnd.github+json"); + request.setRawHeader("User-Agent", + QStringLiteral("PxModRim/%1").arg(m_current).toUtf8()); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply* pending = m_network.get(request); + std::unique_ptr reply(pending); + co_await pending; + + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + if (status < 200 || status >= 300) { + co_return std::unexpected( + Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(m_releaseUrl)}); + } + + const QByteArray body = reply->readAll(); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError) { + co_return std::unexpected(Error{parseError.errorString()}); + } + if (!document.isObject()) { + co_return std::unexpected( + Error{QStringLiteral("Latest release response is not a JSON object")}); + } + + const QJsonObject data = document.object(); + const QString tag = stringField(data, "tag_name"); + const QString url = stringField(data, "html_url"); + if (tag.isEmpty() || url.isEmpty()) { + co_return std::unexpected(Error{ + QStringLiteral("Latest release response lacks tag_name/html_url")}); + } + if (!isNewer(tag, m_current)) { + spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), + m_current.toStdString()); + co_return std::optional{}; + } + + spdlog::info("Update available: {} (running {})", tag.toStdString(), + m_current.toStdString()); + QString name = stringField(data, "name"); + if (name.isEmpty()) { + name = tag; + } + co_return std::optional( + ReleaseInfo{tag, name, stringField(data, "body"), url}); } } // namespace px::modrim::services diff --git a/src/core/services/update_service.hpp b/src/core/services/update_service.hpp index 26142eb..30f3a26 100644 --- a/src/core/services/update_service.hpp +++ b/src/core/services/update_service.hpp @@ -1,11 +1,11 @@ #pragma once +#include +#include #include #include #include -#include -#include #include "core/error.hpp" @@ -17,39 +17,40 @@ inline constexpr QLatin1StringView kLatestReleaseUrl{ "https://api.github.com/repos/PyXiion/PxModRim/releases/latest"}; struct ReleaseInfo { - QString tag; - QString name; - QString notes; - QString url; + QString tag; + QString name; + QString notes; + QString url; }; -// The numeric components of `text` ("v1.2.3" -> 1, 2, 3), ignoring any suffix; nullopt when the -// text does not start with a version. +// The numeric components of `text` ("v1.2.3" -> 1, 2, 3), ignoring any suffix; +// nullopt when the text does not start with a version. std::optional> parseVersion(const QString& text); -// True when `tag` is a strictly higher release than `current`. A pre-release suffix ("-rc1") -// sorts below the plain release with the same numbers. Versions that do not parse are never -// reported as outdated. +// True when `tag` is a strictly higher release than `current`. A pre-release +// suffix ("-rc1") sorts below the plain release with the same numbers. Versions +// that do not parse are never reported as outdated. bool isNewer(const QString& tag, const QString& current); // Looks up the newest published GitHub release. class UpdateService { -public: - // `network` is not owned and must outlive the service. `release_url` is the latest-release - // endpoint; the default is the project's own. - UpdateService(QString currentVersion, QNetworkAccessManager& network, - QString releaseUrl = QString(kLatestReleaseUrl)); - - const QString& currentVersion() const noexcept { return m_current; } - - // The latest release when it is newer than the running app, nullopt otherwise. Fails on a - // network error, a non-success HTTP status or a malformed response. - QCoro::Task>> check(); - -private: - QString m_current; - QNetworkAccessManager& m_network; - QString m_releaseUrl; + public: + // `network` is not owned and must outlive the service. `release_url` is the + // latest-release endpoint; the default is the project's own. + UpdateService(QString currentVersion, QNetworkAccessManager& network, + QString releaseUrl = QString(kLatestReleaseUrl)); + + const QString& currentVersion() const noexcept { return m_current; } + + // The latest release when it is newer than the running app, nullopt + // otherwise. Fails on a network error, a non-success HTTP status or a + // malformed response. + QCoro::Task>> check(); + + private: + QString m_current; + QNetworkAccessManager& m_network; + QString m_releaseUrl; }; } // namespace px::modrim::services diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp index 06014f5..c3ce29b 100644 --- a/src/core/sort/community.cpp +++ b/src/core/sort/community.cpp @@ -11,47 +11,49 @@ namespace px::modrim::sort { namespace { -CaseInsensitiveSet toPackageSet(const std::map& rules) { - CaseInsensitiveSet result; - for (const auto& [pid, _] : rules) { - result.add(QString::fromStdString(pid)); - } - return result; +CaseInsensitiveSet toPackageSet( + const std::map& rules) { + CaseInsensitiveSet result; + for (const auto& [pid, _] : rules) { + result.add(QString::fromStdString(pid)); + } + return result; } } // namespace CommunityRules toCommunityRules(const ExternalRulesSchema& schema) { - CommunityRules result; - for (const auto& [pid_str, rule] : schema.rules) { - CommunityRule community; - community.packageId = PackageId(QString::fromStdString(pid_str)); - community.loadAfter = toPackageSet(rule.loadAfter); - community.loadBefore = toPackageSet(rule.loadBefore); - community.loadFirst = rule.loadTop.value; - community.loadLast = rule.loadBottom.value; - result.insert(community.packageId, std::move(community)); - } - return result; + CommunityRules result; + for (const auto& [pid_str, rule] : schema.rules) { + CommunityRule community; + community.packageId = PackageId(QString::fromStdString(pid_str)); + community.loadAfter = toPackageSet(rule.loadAfter); + community.loadBefore = toPackageSet(rule.loadBefore); + community.loadFirst = rule.loadTop.value; + community.loadLast = rule.loadBottom.value; + result.insert(community.packageId, std::move(community)); + } + return result; } Result loadCommunityRules(const QString& jsonPath) { - if (!QFileInfo::exists(jsonPath)) { - return CommunityRules{}; - } - - auto bytes = readFileBytes(jsonPath); - if (!bytes) { - return std::unexpected(bytes.error()); - } - - ExternalRulesSchema schema; - if (auto error = glz::read(schema, *bytes)) { - return std::unexpected(Error{QStringLiteral("Failed to parse community rules %1: %2") - .arg(jsonPath, QString::fromStdString( - glz::format_error(error, *bytes)))}); - } - return toCommunityRules(schema); + if (!QFileInfo::exists(jsonPath)) { + return CommunityRules{}; + } + + auto bytes = readFileBytes(jsonPath); + if (!bytes) { + return std::unexpected(bytes.error()); + } + + ExternalRulesSchema schema; + if (auto error = glz::read(schema, *bytes)) { + return std::unexpected(Error{ + QStringLiteral("Failed to parse community rules %1: %2") + .arg(jsonPath, + QString::fromStdString(glz::format_error(error, *bytes)))}); + } + return toCommunityRules(schema); } } // namespace px::modrim::sort diff --git a/src/core/sort/community.hpp b/src/core/sort/community.hpp index b21a4d3..ed05625 100644 --- a/src/core/sort/community.hpp +++ b/src/core/sort/community.hpp @@ -8,11 +8,12 @@ namespace px::modrim::sort { -// Converts the upstream schema into rules keyed by package id. Only the keys of loadAfter and -// loadBefore matter; their values are ignored. +// Converts the upstream schema into rules keyed by package id. Only the keys of +// loadAfter and loadBefore matter; their values are ignored. CommunityRules toCommunityRules(const ExternalRulesSchema& schema); -// Reads the community rules file. A missing file means no rules; a malformed one is an error. +// Reads the community rules file. A missing file means no rules; a malformed +// one is an error. Result loadCommunityRules(const QString& jsonPath); } // namespace px::modrim::sort diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp index 88911b5..746ef9c 100644 --- a/src/core/sort/community_service.cpp +++ b/src/core/sort/community_service.cpp @@ -1,11 +1,5 @@ #include "core/sort/community_service.hpp" -#include "core/sort/community.hpp" - -#include -#include - -#include #include #include #include @@ -14,10 +8,16 @@ #include #include #include -#include +#include +#include #include +#include + +#include + #include "core/file_io.hpp" +#include "core/sort/community.hpp" namespace px::modrim::sort { @@ -25,92 +25,112 @@ namespace { const QString kRulesFilename = QStringLiteral("communityRules.json"); const QString kRulesUrl = QStringLiteral( - "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/main.zip"); + "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/" + "main.zip"); -// Matches the httpx timeouts of the original client: a stalled transfer fails after these. +// Matches the httpx timeouts of the original client: a stalled transfer fails +// after these. constexpr int kHeadTransferTimeoutMs = 10'000; constexpr int kGetTransferTimeoutMs = 30'000; constexpr int kExtractChunkBytes = 64 * 1024; Error networkError(const QNetworkReply& reply) { - return Error{reply.errorString()}; + return Error{reply.errorString()}; } -// Writes one archive entry under `dest`. Entries whose names would escape `dest` are skipped, -// as the Python zipfile sanitiser also refuses to write outside the target directory. -Result extractEntry(zip_t* archive, zip_uint64_t index, const QDir& dest) { - zip_stat_t stat; - zip_stat_init(&stat); - if (zip_stat_index(archive, index, 0, &stat) != 0 || !(stat.valid & ZIP_STAT_NAME)) { - return std::unexpected(Error{QStringLiteral("Unreadable community rules archive entry")}); - } - - const QString name = QString::fromUtf8(stat.name); - const QString relative = QDir::cleanPath(name); - const bool escapes = relative == QLatin1String("..") || relative.startsWith(QLatin1String("../")) || - QDir::isAbsolutePath(relative); - if (escapes || relative == QLatin1String(".") || relative.isEmpty()) { - return {}; - } - - if (name.endsWith(u'/')) { - if (!dest.mkpath(relative)) { - return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(dest.filePath(relative))}); - } - return {}; - } - - if (!dest.mkpath(QFileInfo(relative).path())) { - return std::unexpected(Error{QStringLiteral("Cannot create directory for %1").arg(relative)}); - } - zip_file_t* entry = zip_fopen_index(archive, index, 0); - if (!entry) { - return std::unexpected(Error{QStringLiteral("Cannot read %1 from archive").arg(name)}); - } - auto closeEntry = qScopeGuard([entry] { zip_fclose(entry); }); - - const QString target = dest.filePath(relative); - QFile out(target); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(target)}); - } +// Writes one archive entry under `dest`. Entries whose names would escape +// `dest` are skipped, as the Python zipfile sanitiser also refuses to write +// outside the target directory. +Result extractEntry(zip_t* archive, zip_uint64_t index, + const QDir& dest) { + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || + !(stat.valid & ZIP_STAT_NAME)) { + return std::unexpected( + Error{QStringLiteral("Unreadable community rules archive entry")}); + } + + const QString name = QString::fromUtf8(stat.name); + const QString relative = QDir::cleanPath(name); + const bool escapes = relative == QLatin1String("..") || + relative.startsWith(QLatin1String("../")) || + QDir::isAbsolutePath(relative); + if (escapes || relative == QLatin1String(".") || relative.isEmpty()) { + return {}; + } - QByteArray buffer(kExtractChunkBytes, Qt::Uninitialized); - for (;;) { - const zip_int64_t read = zip_fread(entry, buffer.data(), static_cast(buffer.size())); - if (read < 0) { - return std::unexpected(Error{QStringLiteral("Corrupt archive entry %1").arg(name)}); - } - if (read == 0) { - break; - } - if (out.write(buffer.constData(), read) != read) { - return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(target)}); - } + if (name.endsWith(u'/')) { + if (!dest.mkpath(relative)) { + return std::unexpected(Error{ + QStringLiteral("Cannot create %1").arg(dest.filePath(relative))}); } return {}; + } + + if (!dest.mkpath(QFileInfo(relative).path())) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory for %1").arg(relative)}); + } + zip_file_t* entry = zip_fopen_index(archive, index, 0); + if (!entry) { + return std::unexpected( + Error{QStringLiteral("Cannot read %1 from archive").arg(name)}); + } + auto closeEntry = qScopeGuard([entry] { zip_fclose(entry); }); + + const QString target = dest.filePath(relative); + QFile out(target); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(target)}); + } + + QByteArray buffer(kExtractChunkBytes, Qt::Uninitialized); + for (;;) { + const zip_int64_t read = zip_fread( + entry, buffer.data(), static_cast(buffer.size())); + if (read < 0) { + return std::unexpected( + Error{QStringLiteral("Corrupt archive entry %1").arg(name)}); + } + if (read == 0) { + break; + } + if (out.write(buffer.constData(), read) != read) { + return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(target)}); + } + } + return {}; } Result extractZip(const QString& zipPath, const QString& destDir) { - int errorCode = 0; - zip_t* archive = zip_open(QFile::encodeName(zipPath).constData(), ZIP_RDONLY, &errorCode); - if (!archive) { - return std::unexpected( - Error{QStringLiteral("Cannot open %1 (libzip error %2)").arg(zipPath).arg(errorCode)}); - } - auto closeArchive = qScopeGuard([archive] { zip_close(archive); }); - - if (!QDir().mkpath(destDir)) { - return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(destDir)}); - } - const QDir dest(destDir); - const zip_int64_t count = zip_get_num_entries(archive, 0); - for (zip_int64_t i = 0; i < count; ++i) { - if (auto extracted = extractEntry(archive, static_cast(i), dest); !extracted) { - return extracted; - } - } - return {}; + int errorCode = 0; + zip_t* archive = + zip_open(QFile::encodeName(zipPath).constData(), ZIP_RDONLY, &errorCode); + if (!archive) { + return std::unexpected( + Error{QStringLiteral("Cannot open %1 (libzip error %2)") + .arg(zipPath) + .arg(errorCode)}); + } + auto closeArchive = qScopeGuard([archive] { zip_close(archive); }); + + if (!QDir().mkpath(destDir)) { + return std::unexpected( + Error{QStringLiteral("Cannot create %1").arg(destDir)}); + } + const QDir dest(destDir); + const zip_int64_t count = zip_get_num_entries(archive, 0); + for (zip_int64_t i = 0; i < count; ++i) { + if (auto extracted = + extractEntry(archive, static_cast(i), dest); + !extracted) { + return extracted; + } + } + return {}; } } // namespace @@ -121,94 +141,105 @@ CommunityRulesService::CommunityRulesService(const ConfigService& configService, m_jsonPath(QDir(m_cacheDir).filePath(kRulesFilename)), m_network(network) {} -QCoro::Task> CommunityRulesService::ensureRules(LoadingState& loading, - bool force) { - if (QFileInfo::exists(m_jsonPath) && !force) { - co_return m_jsonPath; - } - - auto result = co_await downloadRules(loading); - if (!result) { - spdlog::warn("Failed to download community rules: {}", result.error().message.toStdString()); - co_return std::nullopt; - } - co_return *result; +QCoro::Task> CommunityRulesService::ensureRules( + LoadingState& loading, bool force) { + if (QFileInfo::exists(m_jsonPath) && !force) { + co_return m_jsonPath; + } + + auto result = co_await downloadRules(loading); + if (!result) { + spdlog::warn("Failed to download community rules: {}", + result.error().message.toStdString()); + co_return std::nullopt; + } + co_return *result; } -QCoro::Task>> CommunityRulesService::downloadRules(LoadingState& loading) { - if (!QDir().mkpath(m_cacheDir)) { - co_return std::unexpected(Error{QStringLiteral("Cannot create %1").arg(m_cacheDir)}); - } - const QDir cache(m_cacheDir); - const QString zipPath = cache.filePath(QStringLiteral("community_rules.zip")); - const QString extractDir = cache.filePath(QStringLiteral("community_rules_extracted")); - - QNetworkRequest request{QUrl(kRulesUrl)}; - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - - // The HEAD request only sizes the progress bar; a missing Content-Length falls back to 100 - // steps, which is what the original did. - request.setTransferTimeout(kHeadTransferTimeoutMs); - QNetworkReply* head = m_network.head(request); - auto releaseHead = qScopeGuard([head] { head->deleteLater(); }); - co_await qCoro(head).waitForFinished(); - if (head->error() != QNetworkReply::NoError) { - co_return std::unexpected(networkError(*head)); - } - const qint64 contentLength = head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); - const int totalSize = contentLength > 0 - ? static_cast(std::min(contentLength, - std::numeric_limits::max())) - : 100; - - request.setTransferTimeout(kGetTransferTimeoutMs); - QNetworkReply* get = m_network.get(request); - auto releaseGet = qScopeGuard([get] { get->deleteLater(); }); - - QFile zipFile(zipPath); - if (!zipFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - co_return std::unexpected(Error{QStringLiteral("Cannot write %1").arg(zipPath)}); - } - { - auto download = loading.task(QStringLiteral("Downloading community rules..."), totalSize); - QObject::connect(get, &QNetworkReply::readyRead, get, [get, &zipFile, &loading] { - const QByteArray chunk = get->readAll(); - zipFile.write(chunk); - loading.step(static_cast(chunk.size())); - }); - co_await qCoro(get).waitForFinished(); - if (get->error() != QNetworkReply::NoError) { - co_return std::unexpected(networkError(*get)); - } - zipFile.write(get->readAll()); - if (zipFile.error() != QFileDevice::NoError) { - co_return std::unexpected(Error{zipFile.errorString()}); - } - zipFile.close(); - } - - { - auto extract = loading.task(QStringLiteral("Extracting..."), 100); - if (auto extracted = extractZip(zipPath, extractDir); !extracted) { - co_return std::unexpected(extracted.error()); - } - loading.step(50); - - QDirIterator rules(extractDir, {kRulesFilename}, QDir::Files, QDirIterator::Subdirectories); - if (!rules.hasNext()) { - // An archive without the rules file is not an error: there is simply nothing to cache. - co_return std::optional{}; - } - if (auto copied = copyFileReplacing(rules.next(), m_jsonPath); !copied) { - co_return std::unexpected(copied.error()); - } - loading.step(50); - } - co_return std::optional(m_jsonPath); +QCoro::Task>> +CommunityRulesService::downloadRules(LoadingState& loading) { + if (!QDir().mkpath(m_cacheDir)) { + co_return std::unexpected( + Error{QStringLiteral("Cannot create %1").arg(m_cacheDir)}); + } + const QDir cache(m_cacheDir); + const QString zipPath = cache.filePath(QStringLiteral("community_rules.zip")); + const QString extractDir = + cache.filePath(QStringLiteral("community_rules_extracted")); + + QNetworkRequest request{QUrl(kRulesUrl)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + // The HEAD request only sizes the progress bar; a missing Content-Length + // falls back to 100 steps, which is what the original did. + request.setTransferTimeout(kHeadTransferTimeoutMs); + QNetworkReply* head = m_network.head(request); + auto releaseHead = qScopeGuard([head] { head->deleteLater(); }); + co_await qCoro(head).waitForFinished(); + if (head->error() != QNetworkReply::NoError) { + co_return std::unexpected(networkError(*head)); + } + const qint64 contentLength = + head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + const int totalSize = + contentLength > 0 ? static_cast(std::min( + contentLength, std::numeric_limits::max())) + : 100; + + request.setTransferTimeout(kGetTransferTimeoutMs); + QNetworkReply* get = m_network.get(request); + auto releaseGet = qScopeGuard([get] { get->deleteLater(); }); + + QFile zipFile(zipPath); + if (!zipFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + co_return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(zipPath)}); + } + { + auto download = loading.task( + QStringLiteral("Downloading community rules..."), totalSize); + QObject::connect(get, &QNetworkReply::readyRead, get, + [get, &zipFile, &loading] { + const QByteArray chunk = get->readAll(); + zipFile.write(chunk); + loading.step(static_cast(chunk.size())); + }); + co_await qCoro(get).waitForFinished(); + if (get->error() != QNetworkReply::NoError) { + co_return std::unexpected(networkError(*get)); + } + zipFile.write(get->readAll()); + if (zipFile.error() != QFileDevice::NoError) { + co_return std::unexpected(Error{zipFile.errorString()}); + } + zipFile.close(); + } + + { + auto extract = loading.task(QStringLiteral("Extracting..."), 100); + if (auto extracted = extractZip(zipPath, extractDir); !extracted) { + co_return std::unexpected(extracted.error()); + } + loading.step(50); + + QDirIterator rules(extractDir, {kRulesFilename}, QDir::Files, + QDirIterator::Subdirectories); + if (!rules.hasNext()) { + // An archive without the rules file is not an error: there is simply + // nothing to cache. + co_return std::optional{}; + } + if (auto copied = copyFileReplacing(rules.next(), m_jsonPath); !copied) { + co_return std::unexpected(copied.error()); + } + loading.step(50); + } + co_return std::optional(m_jsonPath); } Result CommunityRulesService::loadRules() const { - return loadCommunityRules(m_jsonPath); + return loadCommunityRules(m_jsonPath); } } // namespace px::modrim::sort diff --git a/src/core/sort/community_service.hpp b/src/core/sort/community_service.hpp index 5e1fef6..fd46cfe 100644 --- a/src/core/sort/community_service.hpp +++ b/src/core/sort/community_service.hpp @@ -1,9 +1,9 @@ #pragma once +#include #include #include -#include #include "core/config.hpp" #include "core/error.hpp" @@ -14,26 +14,29 @@ class QNetworkAccessManager; namespace px::modrim::sort { -// Keeps the community rules file in the config directory, downloading the Community-Rules-Database -// archive when the file is missing. +// Keeps the community rules file in the config directory, downloading the +// Community-Rules-Database archive when the file is missing. class CommunityRulesService { -public: - // `network` is not owned and must outlive the service. - CommunityRulesService(const ConfigService& configService, QNetworkAccessManager& network); - - // Returns the rules file path, downloading first when it is missing or `force` is set. - // nullopt when the download or extraction failed, or the archive has no rules file; the - // failure is logged, not returned. - QCoro::Task> ensureRules(LoadingState& loading, bool force = false); - - Result loadRules() const; - -private: - QCoro::Task>> downloadRules(LoadingState& loading); - - QString m_cacheDir; - QString m_jsonPath; - QNetworkAccessManager& m_network; + public: + // `network` is not owned and must outlive the service. + CommunityRulesService(const ConfigService& configService, + QNetworkAccessManager& network); + + // Returns the rules file path, downloading first when it is missing or + // `force` is set. nullopt when the download or extraction failed, or the + // archive has no rules file; the failure is logged, not returned. + QCoro::Task> ensureRules(LoadingState& loading, + bool force = false); + + Result loadRules() const; + + private: + QCoro::Task>> downloadRules( + LoadingState& loading); + + QString m_cacheDir; + QString m_jsonPath; + QNetworkAccessManager& m_network; }; } // namespace px::modrim::sort diff --git a/src/core/sort/config.cpp b/src/core/sort/config.cpp index 508e0de..8335a9a 100644 --- a/src/core/sort/config.cpp +++ b/src/core/sort/config.cpp @@ -5,37 +5,38 @@ namespace px::modrim::sort { TierConfig TierConfig::defaults() { - return TierConfig{ - .tier_0 = - { - PackageId(QStringLiteral("zetrith.prepatcher")), - PackageId(QStringLiteral("brrainz.harmony")), - PackageId(QStringLiteral("brrainz.visualexceptions")), - PackageId(QStringLiteral("ludeon.rimworld")), - PackageId(QStringLiteral("ludeon.rimworld.royalty")), - PackageId(QStringLiteral("ludeon.rimworld.ideology")), - PackageId(QStringLiteral("ludeon.rimworld.biotech")), - PackageId(QStringLiteral("ludeon.rimworld.anomaly")), - PackageId(QStringLiteral("ludeon.rimworld.odyssey")), - }, - .tier_1 = - { - PackageId(QStringLiteral("adaptive.storage.framework")), - PackageId(QStringLiteral("aoba.framework")), - PackageId(QStringLiteral("aoba.exosuit.framework")), - PackageId(QStringLiteral("ebsg.framework")), - PackageId(QStringLiteral("imranfish.xmlextensions")), - PackageId(QStringLiteral("thesepeople.ritualattachableoutcomes")), - PackageId(QStringLiteral("ohno.asf.ab.local")), - PackageId(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")), - PackageId(QStringLiteral("owlchemist.cherrypicker")), - PackageId(QStringLiteral("redmattis.betterprerequisites")), - PackageId(QStringLiteral("smashphil.vehicleframework")), - PackageId(QStringLiteral("unlimitedhugs.hugslib")), - PackageId(QStringLiteral("vanillaexpanded.backgrounds")), - }, - .tier_3 = {}, - }; + return TierConfig{ + .tier_0 = + { + PackageId(QStringLiteral("zetrith.prepatcher")), + PackageId(QStringLiteral("brrainz.harmony")), + PackageId(QStringLiteral("brrainz.visualexceptions")), + PackageId(QStringLiteral("ludeon.rimworld")), + PackageId(QStringLiteral("ludeon.rimworld.royalty")), + PackageId(QStringLiteral("ludeon.rimworld.ideology")), + PackageId(QStringLiteral("ludeon.rimworld.biotech")), + PackageId(QStringLiteral("ludeon.rimworld.anomaly")), + PackageId(QStringLiteral("ludeon.rimworld.odyssey")), + }, + .tier_1 = + { + PackageId(QStringLiteral("adaptive.storage.framework")), + PackageId(QStringLiteral("aoba.framework")), + PackageId(QStringLiteral("aoba.exosuit.framework")), + PackageId(QStringLiteral("ebsg.framework")), + PackageId(QStringLiteral("imranfish.xmlextensions")), + PackageId(QStringLiteral("thesepeople.ritualattachableoutcomes")), + PackageId(QStringLiteral("ohno.asf.ab.local")), + PackageId( + QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")), + PackageId(QStringLiteral("owlchemist.cherrypicker")), + PackageId(QStringLiteral("redmattis.betterprerequisites")), + PackageId(QStringLiteral("smashphil.vehicleframework")), + PackageId(QStringLiteral("unlimitedhugs.hugslib")), + PackageId(QStringLiteral("vanillaexpanded.backgrounds")), + }, + .tier_3 = {}, + }; } } // namespace px::modrim::sort diff --git a/src/core/sort/config.hpp b/src/core/sort/config.hpp index 1c68f51..a74e9a7 100644 --- a/src/core/sort/config.hpp +++ b/src/core/sort/config.hpp @@ -11,26 +11,28 @@ using PackageId = CaseInsensitiveStr; enum class Tier : int { kTier0 = 0, kTier1 = 1, kTier2 = 2, kTier3 = 3 }; // Serialised by glaze into config.json: member names are the JSON keys. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct TierConfig { - std::vector tier_0; - std::vector tier_1; - std::vector tier_3; + std::vector tier_0; + std::vector tier_1; + std::vector tier_3; - static TierConfig defaults(); + static TierConfig defaults(); - bool operator==(const TierConfig&) const = default; + bool operator==(const TierConfig&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct SortSettings { - bool use_alternative_package_ids = true; - bool check_missing_dependencies = true; - bool use_community_rules = true; - TierConfig tier_config = TierConfig::defaults(); + bool use_alternative_package_ids = true; + bool check_missing_dependencies = true; + bool use_community_rules = true; + TierConfig tier_config = TierConfig::defaults(); - bool operator==(const SortSettings&) const = default; + bool operator==(const SortSettings&) const = default; }; // NOLINTEND(readability-identifier-naming) diff --git a/src/core/sort/models.hpp b/src/core/sort/models.hpp index 699fa9d..2ba3d69 100644 --- a/src/core/sort/models.hpp +++ b/src/core/sort/models.hpp @@ -10,19 +10,20 @@ namespace px::modrim::sort { // Load-order rule from the community database for one package id. struct CommunityRule { - PackageId packageId; - CaseInsensitiveSet loadAfter; - CaseInsensitiveSet loadBefore; - bool loadFirst = false; - bool loadLast = false; - CaseInsensitiveSet incompatibleWith; - - bool operator==(const CommunityRule&) const = default; + PackageId packageId; + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + bool loadFirst = false; + bool loadLast = false; + CaseInsensitiveSet incompatibleWith; + + bool operator==(const CommunityRule&) const = default; }; using CommunityRules = QHash; -// Adjacency map over package ids: a node's dependencies or reverse dependencies. +// Adjacency map over package ids: a node's dependencies or reverse +// dependencies. using PackageGraph = QHash>; } // namespace px::modrim::sort diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp index d9ce2e4..7410c57 100644 --- a/src/core/sort/tiers.cpp +++ b/src/core/sort/tiers.cpp @@ -7,158 +7,167 @@ namespace px::modrim::sort { namespace { -// Every node reachable from `start` by at least one edge. `start` is included only when a cycle -// leads back to it. +// Every node reachable from `start` by at least one edge. `start` is included +// only when a cycle leads back to it. QSet reachable(const PackageId& start, const PackageGraph& graph) { - QSet result; - QSet visited{start}; - std::vector stack{start}; - while (!stack.empty()) { - const PackageId pid = stack.back(); - stack.pop_back(); - for (const auto& next : graph.value(pid)) { - result.insert(next); - if (!visited.contains(next)) { - visited.insert(next); - stack.push_back(next); - } - } + QSet result; + QSet visited{start}; + std::vector stack{start}; + while (!stack.empty()) { + const PackageId pid = stack.back(); + stack.pop_back(); + for (const auto& next : graph.value(pid)) { + result.insert(next); + if (!visited.contains(next)) { + visited.insert(next); + stack.push_back(next); + } } - return result; + } + return result; } -// Adds `ids` to `tiers` as `tier`, skipping ids that are not canonical (keys of `tiers`). -void assignPresent(QHash& tiers, const QSet& ids, Tier tier) { - for (const auto& pid : ids) { - if (auto it = tiers.find(pid); it != tiers.end()) { - *it = tier; - } +// Adds `ids` to `tiers` as `tier`, skipping ids that are not canonical (keys of +// `tiers`). +void assignPresent(QHash& tiers, const QSet& ids, + Tier tier) { + for (const auto& pid : ids) { + if (auto it = tiers.find(pid); it != tiers.end()) { + *it = tier; } + } } } // namespace QHash assignTiers(const QSet& canonicalIds, - const PackageGraph& deps, - const PackageGraph& revDeps, - const QSet& loadFirst, - const QSet& loadLast, - const TierConfig& tierConfig) { - QHash tiers; - for (const auto& pid : canonicalIds) { - tiers.insert(pid, Tier::kTier2); - } - - // Tier 3 is the bottom of the load order, so dependents of late mods also sink there. - QSet tier3Set = loadLast; - for (const auto& pid : loadLast) { - tier3Set |= reachable(pid, revDeps); - } - for (const auto& pid : tierConfig.tier_3) { - if (canonicalIds.contains(pid)) { - tier3Set.insert(pid); - tier3Set |= reachable(pid, revDeps); - } + const PackageGraph& deps, + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig) { + QHash tiers; + for (const auto& pid : canonicalIds) { + tiers.insert(pid, Tier::kTier2); + } + + // Tier 3 is the bottom of the load order, so dependents of late mods also + // sink there. + QSet tier3Set = loadLast; + for (const auto& pid : loadLast) { + tier3Set |= reachable(pid, revDeps); + } + for (const auto& pid : tierConfig.tier_3) { + if (canonicalIds.contains(pid)) { + tier3Set.insert(pid); + tier3Set |= reachable(pid, revDeps); } - assignPresent(tiers, tier3Set, Tier::kTier3); - - // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a tier-3 mark. - QSet tier0Set; - for (const auto& pid : tierConfig.tier_0) { - if (canonicalIds.contains(pid)) { - tier0Set.insert(pid); - tier0Set |= reachable(pid, deps); - } + } + assignPresent(tiers, tier3Set, Tier::kTier3); + + // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a + // tier-3 mark. + QSet tier0Set; + for (const auto& pid : tierConfig.tier_0) { + if (canonicalIds.contains(pid)) { + tier0Set.insert(pid); + tier0Set |= reachable(pid, deps); } - assignPresent(tiers, tier0Set, Tier::kTier0); - - // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. - QSet tier1Set; - for (const auto& pid : tierConfig.tier_1) { - if (canonicalIds.contains(pid)) { - tier1Set.insert(pid); - tier1Set |= reachable(pid, deps); - } + } + assignPresent(tiers, tier0Set, Tier::kTier0); + + // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. + QSet tier1Set; + for (const auto& pid : tierConfig.tier_1) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); } - for (const auto& pid : loadFirst) { - if (canonicalIds.contains(pid)) { - tier1Set.insert(pid); - tier1Set |= reachable(pid, deps); - } + } + for (const auto& pid : loadFirst) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); } - for (const auto& pid : tier1Set) { - if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::kTier2) { - *it = Tier::kTier1; - } + } + for (const auto& pid : tier1Set) { + if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::kTier2) { + *it = Tier::kTier1; } + } - return tiers; + return tiers; } -QSet getDepsRecursive(const PackageId& start, const PackageGraph& deps) { - return reachable(start, deps); +QSet getDepsRecursive(const PackageId& start, + const PackageGraph& deps) { + return reachable(start, deps); } -QSet getReverseDepsRecursive(const PackageId& start, const PackageGraph& revDeps) { - return reachable(start, revDeps); +QSet getReverseDepsRecursive(const PackageId& start, + const PackageGraph& revDeps) { + return reachable(start, revDeps); } -std::optional> findCycle(const QSet& nodes, const PackageGraph& deps) { - // One frame per node on the DFS stack; `next` indexes the node's outgoing edges. - struct Frame { - PackageId pid; - QList edges; - qsizetype next = 0; - }; - - QSet visited; - QSet recStack; - QHash parent; - - for (const auto& root : nodes) { - if (visited.contains(root)) { - continue; - } +std::optional> findCycle(const QSet& nodes, + const PackageGraph& deps) { + // One frame per node on the DFS stack; `next` indexes the node's outgoing + // edges. + struct Frame { + PackageId pid; + QList edges; + qsizetype next = 0; + }; + + QSet visited; + QSet recStack; + QHash parent; + + for (const auto& root : nodes) { + if (visited.contains(root)) { + continue; + } - parent.insert(root, root); - visited.insert(root); - recStack.insert(root); - std::vector stack{Frame{root, deps.value(root).values()}}; - while (!stack.empty()) { - Frame& top = stack.back(); - if (top.next == top.edges.size()) { - recStack.remove(top.pid); - stack.pop_back(); - continue; - } - - const PackageId pid = top.pid; - const PackageId dep = top.edges[top.next++]; - if (!nodes.contains(dep)) { - continue; - } - if (!visited.contains(dep)) { - parent.insert(dep, pid); - visited.insert(dep); - recStack.insert(dep); - // `top` is invalid after this push. - stack.push_back(Frame{dep, deps.value(dep).values()}); - } else if (recStack.contains(dep)) { - // Walk parents from the current node back to `dep`, which is on the DFS path. - QList cycle{dep}; - PackageId current = pid; - while (current != dep) { - cycle.append(current); - current = parent.value(current); - } - cycle.append(dep); - std::ranges::reverse(cycle); - return cycle; - } + parent.insert(root, root); + visited.insert(root); + recStack.insert(root); + std::vector stack{Frame{root, deps.value(root).values()}}; + while (!stack.empty()) { + Frame& top = stack.back(); + if (top.next == top.edges.size()) { + recStack.remove(top.pid); + stack.pop_back(); + continue; + } + + const PackageId pid = top.pid; + const PackageId dep = top.edges[top.next++]; + if (!nodes.contains(dep)) { + continue; + } + if (!visited.contains(dep)) { + parent.insert(dep, pid); + visited.insert(dep); + recStack.insert(dep); + // `top` is invalid after this push. + stack.push_back(Frame{dep, deps.value(dep).values()}); + } else if (recStack.contains(dep)) { + // Walk parents from the current node back to `dep`, which is on the DFS + // path. + QList cycle{dep}; + PackageId current = pid; + while (current != dep) { + cycle.append(current); + current = parent.value(current); } + cycle.append(dep); + std::ranges::reverse(cycle); + return cycle; + } } + } - return std::nullopt; + return std::nullopt; } } // namespace px::modrim::sort diff --git a/src/core/sort/tiers.hpp b/src/core/sort/tiers.hpp index 62bfd34..92c15c9 100644 --- a/src/core/sort/tiers.hpp +++ b/src/core/sort/tiers.hpp @@ -1,32 +1,36 @@ #pragma once -#include - #include #include +#include #include "core/sort/config.hpp" #include "core/sort/models.hpp" namespace px::modrim::sort { -// Assigns each canonical id to a Tier from load order and dependency constraints. Ids outside -// `canonical_ids` are never returned. +// Assigns each canonical id to a Tier from load order and dependency +// constraints. Ids outside `canonical_ids` are never returned. QHash assignTiers(const QSet& canonicalIds, - const PackageGraph& deps, - const PackageGraph& revDeps, - const QSet& loadFirst, - const QSet& loadLast, - const TierConfig& tierConfig); - -// Transitive closure over `deps`, excluding `start` itself unless it is reachable from itself. -QSet getDepsRecursive(const PackageId& start, const PackageGraph& deps); - -// Transitive closure over `rev_deps`, excluding `start` itself unless it is reachable from itself. -QSet getReverseDepsRecursive(const PackageId& start, const PackageGraph& revDeps); - -// Returns one cycle among `nodes` as a closed path (first == last), or nullopt when the nodes -// form a DAG. Edges leaving `nodes` are ignored. -std::optional> findCycle(const QSet& nodes, const PackageGraph& deps); + const PackageGraph& deps, + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig); + +// Transitive closure over `deps`, excluding `start` itself unless it is +// reachable from itself. +QSet getDepsRecursive(const PackageId& start, + const PackageGraph& deps); + +// Transitive closure over `rev_deps`, excluding `start` itself unless it is +// reachable from itself. +QSet getReverseDepsRecursive(const PackageId& start, + const PackageGraph& revDeps); + +// Returns one cycle among `nodes` as a closed path (first == last), or nullopt +// when the nodes form a DAG. Edges leaving `nodes` are ignored. +std::optional> findCycle(const QSet& nodes, + const PackageGraph& deps); } // namespace px::modrim::sort diff --git a/src/core/structures.hpp b/src/core/structures.hpp index cf52abf..e0cf887 100644 --- a/src/core/structures.hpp +++ b/src/core/structures.hpp @@ -5,10 +5,10 @@ namespace px::modrim { struct CollectionStats { - std::int64_t total = 0; - std::int64_t active = 0; - std::int64_t inactive = 0; - std::int64_t errors = 0; + std::int64_t total = 0; + std::int64_t active = 0; + std::int64_t inactive = 0; + std::int64_t errors = 0; }; } // namespace px::modrim diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp index a3fae6b..2afd4a4 100644 --- a/src/core/support/report.cpp +++ b/src/core/support/report.cpp @@ -1,9 +1,5 @@ #include "core/support/report.hpp" -#include -#include - -#include #include #include #include @@ -18,6 +14,10 @@ #include #include #include +#include +#include + +#include #include "core/models/metadata/structures.hpp" #include "core/version.hpp" @@ -34,284 +34,303 @@ namespace { constexpr qint64 kMib = 1024 * 1024; constexpr qint64 kKib = 1024; constexpr qint64 kMinReadWindow = 10 * kMib; -// Upload responses are shown back to the user, so only a short prefix is echoed. +// Upload responses are shown back to the user, so only a short prefix is +// echoed. constexpr qsizetype kMaxEchoedBody = 100; -// A path component may not run into adjacent name characters, so "ludeon.rimworld" is never -// mistaken for a path that contains the username "rimworld". +// A path component may not run into adjacent name characters, so +// "ludeon.rimworld" is never mistaken for a path that contains the username +// "rimworld". constexpr QLatin1StringView kComponentStart{R"re((?pw_name); - } + if (const passwd* entry = getpwuid(getuid())) { + return QString::fromLocal8Bit(entry->pw_name); + } #endif - return QString(); + return QString(); } QString formatSize(qint64 bytes) { - if (bytes >= kMib) { - return QString::number(static_cast(bytes) / static_cast(kMib), 'f', 1) + - QStringLiteral(" MiB"); - } - if (bytes >= kKib) { - return QString::number(static_cast(bytes) / static_cast(kKib), 'f', 1) + - QStringLiteral(" KiB"); - } - return QString::number(bytes) + QStringLiteral(" B"); + if (bytes >= kMib) { + return QString::number( + static_cast(bytes) / static_cast(kMib), 'f', 1) + + QStringLiteral(" MiB"); + } + if (bytes >= kKib) { + return QString::number( + static_cast(bytes) / static_cast(kKib), 'f', 1) + + QStringLiteral(" KiB"); + } + return QString::number(bytes) + QStringLiteral(" B"); } -// Splits the way Python's str.splitlines() does: several characters end a line, and a trailing -// terminator does not start an empty line. +// Splits the way Python's str.splitlines() does: several characters end a line, +// and a trailing terminator does not start an empty line. QStringList splitLines(const QString& text) { - QStringList lines; - qsizetype start = 0; - for (qsizetype i = 0; i < text.size(); ++i) { - const char16_t c = text[i].unicode(); - const bool boundary = c == u'\n' || c == u'\r' || c == u'\v' || c == u'\f' || - c == 0x1c || c == 0x1d || c == 0x1e || c == 0x85 || c == 0x2028 || - c == 0x2029; - if (!boundary) { - continue; - } - lines.append(text.mid(start, i - start)); - if (c == u'\r' && i + 1 < text.size() && text[i + 1] == u'\n') { - ++i; - } - start = i + 1; + QStringList lines; + qsizetype start = 0; + for (qsizetype i = 0; i < text.size(); ++i) { + const char16_t c = text[i].unicode(); + const bool boundary = c == u'\n' || c == u'\r' || c == u'\v' || + c == u'\f' || c == 0x1c || c == 0x1d || c == 0x1e || + c == 0x85 || c == 0x2028 || c == 0x2029; + if (!boundary) { + continue; } - if (start < text.size()) { - lines.append(text.mid(start)); + lines.append(text.mid(start, i - start)); + if (c == u'\r' && i + 1 < text.size() && text[i + 1] == u'\n') { + ++i; } - return lines; + start = i + 1; + } + if (start < text.size()) { + lines.append(text.mid(start)); + } + return lines; } -// loguru writes each record as a JSON object whose "text" holds the formatted line. Lines -// that are not such records pass through unchanged. +// loguru writes each record as a JSON object whose "text" holds the formatted +// line. Lines that are not such records pass through unchanged. QString logLineText(const QString& line) { - const QString trimmed = line.trimmed(); - if (!trimmed.startsWith(u'{') || !trimmed.endsWith(u'}')) { - return line; - } - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(trimmed.toUtf8(), &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) { - return line; - } - const QJsonValue text = document.object().value(QStringLiteral("text")); - if (!text.isString()) { - return line; - } - QString result = text.toString(); - while (result.endsWith(u'\r') || result.endsWith(u'\n')) { - result.chop(1); - } - return result; + const QString trimmed = line.trimmed(); + if (!trimmed.startsWith(u'{') || !trimmed.endsWith(u'}')) { + return line; + } + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(trimmed.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return line; + } + const QJsonValue text = document.object().value(QStringLiteral("text")); + if (!text.isString()) { + return line; + } + QString result = text.toString(); + while (result.endsWith(u'\r') || result.endsWith(u'\n')) { + result.chop(1); + } + return result; } QString parseLogText(const QString& rawText) { - QStringList lines = splitLines(rawText); - for (QString& line : lines) { - line = logLineText(line); - } - QString result = lines.join(QLatin1Char('\n')); - if (rawText.endsWith(u'\n') && !result.isEmpty()) { - result += u'\n'; - } - return result; + QStringList lines = splitLines(rawText); + for (QString& line : lines) { + line = logLineText(line); + } + QString result = lines.join(QLatin1Char('\n')); + if (rawText.endsWith(u'\n') && !result.isEmpty()) { + result += u'\n'; + } + return result; } // Drops the partial line at the start of a tail slice. QByteArray afterFirstNewline(const QByteArray& data) { - const qsizetype newline = data.indexOf('\n'); - return newline < 0 ? data : data.mid(newline + 1); + const qsizetype newline = data.indexOf('\n'); + return newline < 0 ? data : data.mid(newline + 1); } Result readRawTail(QFile& file, qint64 size, qint64 window) { - if (!file.open(QIODevice::ReadOnly)) { - return std::unexpected(Error{file.errorString()}); - } - if (size <= window) { - return file.readAll(); - } - file.seek(size - window); - return afterFirstNewline(file.read(window)); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (size <= window) { + return file.readAll(); + } + file.seek(size - window); + return afterFirstNewline(file.read(window)); } -// Python's repr() of a string, for quoting part of a server response in an error message. +// Python's repr() of a string, for quoting part of a server response in an +// error message. QString pyRepr(const QString& text) { - const QChar quote = text.contains(u'\'') && !text.contains(u'"') ? QChar(u'"') : QChar(u'\''); - QString out(quote); - for (const QChar c : text) { - switch (c.unicode()) { - case u'\\': - out += QStringLiteral("\\\\"); - break; - case u'\n': - out += QStringLiteral("\\n"); - break; - case u'\r': - out += QStringLiteral("\\r"); - break; - case u'\t': - out += QStringLiteral("\\t"); - break; - default: - if (c == quote) { - out += u'\\'; - } - out += c; + const QChar quote = + text.contains(u'\'') && !text.contains(u'"') ? QChar(u'"') : QChar(u'\''); + QString out(quote); + for (const QChar c : text) { + switch (c.unicode()) { + case u'\\': + out += QStringLiteral("\\\\"); + break; + case u'\n': + out += QStringLiteral("\\n"); + break; + case u'\r': + out += QStringLiteral("\\r"); + break; + case u'\t': + out += QStringLiteral("\\t"); + break; + default: + if (c == quote) { + out += u'\\'; } + out += c; } - out += quote; - return out; + } + out += quote; + return out; } } // namespace QString redact(const QString& text, const std::optional& homeDir, const std::optional& username) { - QString result = text; - - QString home = homeDir ? *homeDir : QDir::homePath(); - while (home.endsWith(u'/') || home.endsWith(u'\\')) { - home.chop(1); + QString result = text; + + QString home = homeDir ? *homeDir : QDir::homePath(); + while (home.endsWith(u'/') || home.endsWith(u'\\')) { + home.chop(1); + } + if (!home.isEmpty()) { + // The same directory may appear with either separator, and with doubled + // backslashes when it was embedded in a repr(). + const QString backslashed = QString(home).replace(u'/', u'\\'); + QSet variants{ + home, QString(home).replace(u'\\', u'/'), backslashed, + QString(backslashed).replace(u'\\', QStringLiteral("\\\\"))}; + QStringList ordered(variants.begin(), variants.end()); + // Longest first, so a variant that is a prefix of another cannot shadow it. + std::ranges::stable_sort(ordered, {}, + [](const QString& s) { return s.size(); }); + std::ranges::reverse(ordered); + + QStringList alternatives; + for (const QString& variant : ordered) { + alternatives.append(QRegularExpression::escape(variant)); } - if (!home.isEmpty()) { - // The same directory may appear with either separator, and with doubled backslashes - // when it was embedded in a repr(). - const QString backslashed = QString(home).replace(u'/', u'\\'); - QSet variants{home, QString(home).replace(u'\\', u'/'), backslashed, - QString(backslashed).replace(u'\\', QStringLiteral("\\\\"))}; - QStringList ordered(variants.begin(), variants.end()); - // Longest first, so a variant that is a prefix of another cannot shadow it. - std::ranges::stable_sort(ordered, {}, [](const QString& s) { return s.size(); }); - std::ranges::reverse(ordered); - - QStringList alternatives; - for (const QString& variant : ordered) { - alternatives.append(QRegularExpression::escape(variant)); - } - const QRegularExpression homePattern = unicodeRegex( - QString(kComponentStart) + QStringLiteral("(?:") + alternatives.join(u'|') + - QStringLiteral(")") + QString(kComponentEnd)); - result.replace(homePattern, QStringLiteral("~")); - } - - // Only in path contexts: a bare word match would corrupt ids such as ludeon.rimworld. - const QString user = username ? *username : currentUsername(); - if (!user.isEmpty()) { - const QRegularExpression userPattern = unicodeRegex( - QStringLiteral("(") + QString(kUserDirPrefix) + QStringLiteral(")") + - QRegularExpression::escape(user) + QString(kComponentEnd), - QRegularExpression::CaseInsensitiveOption); - result.replace(userPattern, QStringLiteral("\\1")); - } - return result; + const QRegularExpression homePattern = unicodeRegex( + QString(kComponentStart) + QStringLiteral("(?:") + + alternatives.join(u'|') + QStringLiteral(")") + QString(kComponentEnd)); + result.replace(homePattern, QStringLiteral("~")); + } + + // Only in path contexts: a bare word match would corrupt ids such as + // ludeon.rimworld. + const QString user = username ? *username : currentUsername(); + if (!user.isEmpty()) { + const QRegularExpression userPattern = unicodeRegex( + QStringLiteral("(") + QString(kUserDirPrefix) + QStringLiteral(")") + + QRegularExpression::escape(user) + QString(kComponentEnd), + QRegularExpression::CaseInsensitiveOption); + result.replace(userPattern, QStringLiteral("\\1")); + } + return result; } QString readLogTail(const std::optional& logPath, qint64 maxBytes) { - const QString path = logPath ? *logPath - : QDir(configDir()).filePath(QStringLiteral("logs/pxmodrim.log")); - const QFileInfo info(path); - if (!info.isFile()) { - return QStringLiteral(""); - } - const qint64 size = info.size(); - if (size == 0) { - return QStringLiteral(""); - } - - // JSON records shrink once unpacked, so read a wider raw window than the byte budget. - const qint64 readWindow = std::max(maxBytes * 5, kMinReadWindow); - QFile file(path); - auto raw = readRawTail(file, size, readWindow); - if (!raw) { - return QStringLiteral("").arg(raw.error().message); - } - - const QString plain = parseLogText(QString::fromUtf8(*raw)); - const QByteArray encoded = plain.toUtf8(); - if (encoded.size() <= maxBytes) { - return plain; - } - - const QByteArray tail = afterFirstNewline(encoded.right(maxBytes)); - return QStringLiteral("[... truncated, showing last %1 of %2 ...]\n") - .arg(formatSize(tail.size()), formatSize(encoded.size())) + - QString::fromUtf8(tail); + const QString path = + logPath ? *logPath + : QDir(configDir()).filePath(QStringLiteral("logs/pxmodrim.log")); + const QFileInfo info(path); + if (!info.isFile()) { + return QStringLiteral(""); + } + const qint64 size = info.size(); + if (size == 0) { + return QStringLiteral(""); + } + + // JSON records shrink once unpacked, so read a wider raw window than the byte + // budget. + const qint64 readWindow = std::max(maxBytes * 5, kMinReadWindow); + QFile file(path); + auto raw = readRawTail(file, size, readWindow); + if (!raw) { + return QStringLiteral("") + .arg(raw.error().message); + } + + const QString plain = parseLogText(QString::fromUtf8(*raw)); + const QByteArray encoded = plain.toUtf8(); + if (encoded.size() <= maxBytes) { + return plain; + } + + const QByteArray tail = afterFirstNewline(encoded.right(maxBytes)); + return QStringLiteral("[... truncated, showing last %1 of %2 ...]\n") + .arg(formatSize(tail.size()), formatSize(encoded.size())) + + QString::fromUtf8(tail); } -QString buildReport(const ReportSource& source, const std::optional& logPath, - const std::optional& homeDir, const std::optional& username, - qint64 maxLogBytes) { - QStringList lines; - - lines << QStringLiteral("=== PxModRim System Report ==="); - lines << QStringLiteral("App Version: ") + appVersion(); - lines << QStringLiteral("Qt Version: ") + QString::fromLatin1(qVersion()); - lines << QStringLiteral("OS: %1 %2 (%3)") - .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), - QSysInfo::currentCpuArchitecture()); - lines << QString(); - - const PathConfig& paths = source.paths; - lines << QStringLiteral("=== RimWorld Configuration ==="); - lines << QStringLiteral("Game Version: ") + source.gameVersion; - const auto orNone = [](const std::string& value) { - return value.empty() ? QStringLiteral("") : QString::fromStdString(value); - }; - lines << QStringLiteral("Game Path: ") + orNone(paths.game); - lines << QStringLiteral("Config Folder: ") + orNone(paths.config_folder); - lines << QStringLiteral("Local Mods Path: ") + orNone(paths.local); - lines << QStringLiteral("Workshop Mods Path: ") + orNone(paths.workshop); - lines << QString(); - - lines << QStringLiteral("=== Mod Collection ==="); - lines << QStringLiteral("Total Mods: %1").arg(source.allMods.size()); - lines << QStringLiteral("Active Mods: %1").arg(source.activeUuids.size()); - lines << QString(); - - lines << QStringLiteral("=== Active Mods (Load Order) ==="); - if (source.activeUuids.isEmpty()) { - lines << QStringLiteral(" "); +QString buildReport(const ReportSource& source, + const std::optional& logPath, + const std::optional& homeDir, + const std::optional& username, + qint64 maxLogBytes) { + QStringList lines; + + lines << QStringLiteral("=== PxModRim System Report ==="); + lines << QStringLiteral("App Version: ") + appVersion(); + lines << QStringLiteral("Qt Version: ") + QString::fromLatin1(qVersion()); + lines << QStringLiteral("OS: %1 %2 (%3)") + .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture()); + lines << QString(); + + const PathConfig& paths = source.paths; + lines << QStringLiteral("=== RimWorld Configuration ==="); + lines << QStringLiteral("Game Version: ") + source.gameVersion; + const auto orNone = [](const std::string& value) { + return value.empty() ? QStringLiteral("") + : QString::fromStdString(value); + }; + lines << QStringLiteral("Game Path: ") + orNone(paths.game); + lines << QStringLiteral("Config Folder: ") + orNone(paths.config_folder); + lines << QStringLiteral("Local Mods Path: ") + orNone(paths.local); + lines << QStringLiteral("Workshop Mods Path: ") + orNone(paths.workshop); + lines << QString(); + + lines << QStringLiteral("=== Mod Collection ==="); + lines << QStringLiteral("Total Mods: %1").arg(source.allMods.size()); + lines << QStringLiteral("Active Mods: %1").arg(source.activeUuids.size()); + lines << QString(); + + lines << QStringLiteral("=== Active Mods (Load Order) ==="); + if (source.activeUuids.isEmpty()) { + lines << QStringLiteral(" "); + } + for (qsizetype i = 0; i < source.activeUuids.size(); ++i) { + const QString& uuid = source.activeUuids[i]; + QString modName = QStringLiteral("Unknown Mod"); + QString pid = uuid; + if (const auto found = source.allMods.constFind(uuid); + found != source.allMods.cend()) { + modName = (*found)->name; + const auto* about = dynamic_cast(found->get()); + pid = about ? about->packageId.str() : QStringLiteral("Unknown"); } - for (qsizetype i = 0; i < source.activeUuids.size(); ++i) { - const QString& uuid = source.activeUuids[i]; - QString modName = QStringLiteral("Unknown Mod"); - QString pid = uuid; - if (const auto found = source.allMods.constFind(uuid); found != source.allMods.cend()) { - modName = (*found)->name; - const auto* about = dynamic_cast(found->get()); - pid = about ? about->packageId.str() : QStringLiteral("Unknown"); - } - lines << QStringLiteral("%1. %2 (%3)") - .arg(QString::number(i + 1).rightJustified(3), modName, pid); - } - lines << QString(); + lines << QStringLiteral("%1. %2 (%3)") + .arg(QString::number(i + 1).rightJustified(3), modName, pid); + } + lines << QString(); - lines << QStringLiteral("=== Application Log ==="); - lines << readLogTail(logPath, maxLogBytes); + lines << QStringLiteral("=== Application Log ==="); + lines << readLogTail(logPath, maxLogBytes); - return redact(lines.join(QLatin1Char('\n')), homeDir, username); + return redact(lines.join(QLatin1Char('\n')), homeDir, username); } PasteUploader::PasteUploader(QNetworkAccessManager& network, QString endpoint, @@ -319,50 +338,58 @@ PasteUploader::PasteUploader(QNetworkAccessManager& network, QString endpoint, : m_network(network), m_endpoint(std::move(endpoint)), m_timeout(timeout) {} QCoro::Task> PasteUploader::upload(QString text) { - if (text.trimmed().isEmpty()) { - co_return std::unexpected(Error{QStringLiteral("Report text is empty")}); - } - - QNetworkRequest request{QUrl(m_endpoint)}; - request.setRawHeader("User-Agent", (QStringLiteral("PxModRim/") + appVersion()).toUtf8()); - request.setHeader(QNetworkRequest::ContentTypeHeader, - QStringLiteral("text/plain; charset=utf-8")); - request.setTransferTimeout(static_cast(m_timeout.count())); - - QNetworkReply* reply = m_network.post(request, text.toUtf8()); - const auto releaseReply = qScopeGuard([reply] { reply->deleteLater(); }); - co_await qCoro(reply).waitForFinished(); - - if (reply->error() == QNetworkReply::TimeoutError) { - co_return std::unexpected(Error{QStringLiteral("Upload failed: request timed out after %1s") - .arg(QString::number(m_timeout.count() / 1000.0))}); - } - // No HTTP status means the request never got a response. - const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (status == 0) { - co_return std::unexpected(Error{QStringLiteral("Upload failed: network error (%1)") - .arg(reply->errorString())}); - } - if (status < 200 || status >= 300) { - const QString reason = QString::fromUtf8( - reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toByteArray()); - co_return std::unexpected(Error{ - reason.isEmpty() ? QStringLiteral("Upload failed: HTTP %1").arg(status) - : QStringLiteral("Upload failed: HTTP %1 %2").arg(status).arg(reason)}); - } - if (reply->error() != QNetworkReply::NoError) { - co_return std::unexpected(Error{QStringLiteral("Upload failed: network error (%1)") - .arg(reply->errorString())}); - } - - const QString body = QString::fromUtf8(reply->readAll()).trimmed(); - if (!body.startsWith(QLatin1StringView("http://")) && - !body.startsWith(QLatin1StringView("https://"))) { - co_return std::unexpected(Error{ - QStringLiteral("Upload failed: server did not return a valid URL: %1") - .arg(pyRepr(body.left(kMaxEchoedBody)))}); - } - co_return body; + if (text.trimmed().isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Report text is empty")}); + } + + QNetworkRequest request{QUrl(m_endpoint)}; + request.setRawHeader("User-Agent", + (QStringLiteral("PxModRim/") + appVersion()).toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("text/plain; charset=utf-8")); + request.setTransferTimeout(static_cast(m_timeout.count())); + + QNetworkReply* reply = m_network.post(request, text.toUtf8()); + const auto releaseReply = qScopeGuard([reply] { reply->deleteLater(); }); + co_await qCoro(reply).waitForFinished(); + + if (reply->error() == QNetworkReply::TimeoutError) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: request timed out after %1s") + .arg(QString::number(m_timeout.count() / 1000.0))}); + } + // No HTTP status means the request never got a response. + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status == 0) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + if (status < 200 || status >= 300) { + const QString reason = QString::fromUtf8( + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute) + .toByteArray()); + co_return std::unexpected(Error{ + reason.isEmpty() ? QStringLiteral("Upload failed: HTTP %1").arg(status) + : QStringLiteral("Upload failed: HTTP %1 %2") + .arg(status) + .arg(reason)}); + } + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (!body.startsWith(QLatin1StringView("http://")) && + !body.startsWith(QLatin1StringView("https://"))) { + co_return std::unexpected(Error{ + QStringLiteral("Upload failed: server did not return a valid URL: %1") + .arg(pyRepr(body.left(kMaxEchoedBody)))}); + } + co_return body; } } // namespace px::modrim::support diff --git a/src/core/support/report.hpp b/src/core/support/report.hpp index 737d2c2..b1049b4 100644 --- a/src/core/support/report.hpp +++ b/src/core/support/report.hpp @@ -1,13 +1,13 @@ #pragma once -#include -#include - -#include #include #include #include #include +#include +#include + +#include #include "core/config.hpp" #include "core/error.hpp" @@ -20,47 +20,51 @@ namespace px::modrim::support { inline constexpr qint64 kMaxLogBytes = 2 * 1024 * 1024; inline constexpr QLatin1StringView kDefaultUploadEndpoint{"https://paste.rs/"}; -// The app state a diagnostic report describes. Copied out of the live application so that -// building a report never touches it. +// The app state a diagnostic report describes. Copied out of the live +// application so that building a report never touches it. struct ReportSource { - QString gameVersion = QStringLiteral("Unknown"); - PathConfig paths; - // Keyed by uuid, as the mod registry is. - QHash allMods; - QStringList activeUuids; + QString gameVersion = QStringLiteral("Unknown"); + PathConfig paths; + // Keyed by uuid, as the mod registry is. + QHash allMods; + QStringList activeUuids; }; -// Replaces the home directory with "~" and user-directory usernames with "". The home -// directory and username default to the current user's when not given. -QString redact(const QString& text, const std::optional& homeDir = std::nullopt, +// Replaces the home directory with "~" and user-directory usernames with +// "". The home directory and username default to the current user's when +// not given. +QString redact(const QString& text, + const std::optional& homeDir = std::nullopt, const std::optional& username = std::nullopt); -// The last `max_bytes` of the log as plain text, with loguru JSON records unpacked. Defaults to -// the app's own log under the config directory. +// The last `max_bytes` of the log as plain text, with loguru JSON records +// unpacked. Defaults to the app's own log under the config directory. QString readLogTail(const std::optional& logPath = std::nullopt, - qint64 maxBytes = kMaxLogBytes); + qint64 maxBytes = kMaxLogBytes); // Plain-text system and log report, with the user's details redacted. QString buildReport(const ReportSource& source, - const std::optional& logPath = std::nullopt, - const std::optional& homeDir = std::nullopt, - const std::optional& username = std::nullopt, - qint64 maxLogBytes = kMaxLogBytes); + const std::optional& logPath = std::nullopt, + const std::optional& homeDir = std::nullopt, + const std::optional& username = std::nullopt, + qint64 maxLogBytes = kMaxLogBytes); -// Posts plain-text reports to a paste service. Not owned; `network` must outlive the uploader. +// Posts plain-text reports to a paste service. Not owned; `network` must +// outlive the uploader. class PasteUploader { -public: - PasteUploader(QNetworkAccessManager& network, - QString endpoint = QString(kDefaultUploadEndpoint), - std::chrono::milliseconds timeout = std::chrono::seconds(30)); + public: + PasteUploader(QNetworkAccessManager& network, + QString endpoint = QString(kDefaultUploadEndpoint), + std::chrono::milliseconds timeout = std::chrono::seconds(30)); - // Resolves to the URL the service returned, or to a user-facing error message. - QCoro::Task> upload(QString text); + // Resolves to the URL the service returned, or to a user-facing error + // message. + QCoro::Task> upload(QString text); -private: - QNetworkAccessManager& m_network; - QString m_endpoint; - std::chrono::milliseconds m_timeout; + private: + QNetworkAccessManager& m_network; + QString m_endpoint; + std::chrono::milliseconds m_timeout; }; } // namespace px::modrim::support diff --git a/src/core/toposort.hpp b/src/core/toposort.hpp index d18ca0d..0bd2648 100644 --- a/src/core/toposort.hpp +++ b/src/core/toposort.hpp @@ -1,14 +1,13 @@ #pragma once +#include +#include +#include #include #include #include #include -#include -#include -#include - #include "core/error.hpp" namespace px::modrim { @@ -20,44 +19,45 @@ namespace px::modrim { // not keys of `graph` are ignored. inline Result> toposortLevels( const std::map>& graph) { - std::map> pending; - for (const auto& [node, deps] : graph) { - QSet edges; - for (const auto& dep : deps) { - if (dep != node && graph.contains(dep)) { - edges.insert(dep); - } - } - pending.emplace(node, std::move(edges)); + std::map> pending; + for (const auto& [node, deps] : graph) { + QSet edges; + for (const auto& dep : deps) { + if (dep != node && graph.contains(dep)) { + edges.insert(dep); + } } + pending.emplace(node, std::move(edges)); + } - std::vector levels; - while (!pending.empty()) { - QStringList level; - for (const auto& [node, deps] : pending) { - if (deps.isEmpty()) { - level.append(node); - } - } - if (level.isEmpty()) { - QStringList stuck; - for (const auto& [node, deps] : pending) { - stuck.append(node); - } - return std::unexpected(Error{QStringLiteral("Circular dependencies exist among these plugins: %1") - .arg(stuck.join(QStringLiteral(", ")))}); - } - for (const auto& node : level) { - pending.erase(node); - } - for (auto& [node, deps] : pending) { - for (const auto& done : level) { - deps.remove(done); - } - } - levels.push_back(std::move(level)); + std::vector levels; + while (!pending.empty()) { + QStringList level; + for (const auto& [node, deps] : pending) { + if (deps.isEmpty()) { + level.append(node); + } + } + if (level.isEmpty()) { + QStringList stuck; + for (const auto& [node, deps] : pending) { + stuck.append(node); + } + return std::unexpected(Error{ + QStringLiteral("Circular dependencies exist among these plugins: %1") + .arg(stuck.join(QStringLiteral(", ")))}); + } + for (const auto& node : level) { + pending.erase(node); + } + for (auto& [node, deps] : pending) { + for (const auto& done : level) { + deps.remove(done); + } } - return levels; + levels.push_back(std::move(level)); + } + return levels; } } // namespace px::modrim diff --git a/src/core/utils.cpp b/src/core/utils.cpp index c5ecc6d..d950663 100644 --- a/src/core/utils.cpp +++ b/src/core/utils.cpp @@ -6,46 +6,47 @@ namespace px::modrim { std::optional findAboutXml(const QString& modPath) { - if (!QFileInfo(modPath).isDir()) { - return std::nullopt; - } - const QDir modDir(modPath); - const QString candidate = modDir.filePath(QStringLiteral("About/About.xml")); - if (QFileInfo(candidate).isFile()) { - return candidate; - } + if (!QFileInfo(modPath).isDir()) { + return std::nullopt; + } + const QDir modDir(modPath); + const QString candidate = modDir.filePath(QStringLiteral("About/About.xml")); + if (QFileInfo(candidate).isFile()) { + return candidate; + } - // Only the first directory whose name matches "about" (any case) is searched, as in the - // legacy implementation. - const QFileInfoList entries = - modDir.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); - for (const QFileInfo& entry : entries) { - if (entry.fileName().toLower() != QStringLiteral("about") || !entry.isDir()) { - continue; - } - const QDir aboutDir(entry.absoluteFilePath()); - const QFileInfoList children = - aboutDir.entryInfoList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); - for (const QFileInfo& child : children) { - if (child.fileName().toLower() == QStringLiteral("about.xml")) { - return child.absoluteFilePath(); - } - } - break; + // Only the first directory whose name matches "about" (any case) is searched, + // as in the legacy implementation. + const QFileInfoList entries = modDir.entryInfoList( + QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (entry.fileName().toLower() != QStringLiteral("about") || + !entry.isDir()) { + continue; } - return std::nullopt; + const QDir aboutDir(entry.absoluteFilePath()); + const QFileInfoList children = aboutDir.entryInfoList( + QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& child : children) { + if (child.fileName().toLower() == QStringLiteral("about.xml")) { + return child.absoluteFilePath(); + } + } + break; + } + return std::nullopt; } bool isAsciiDigits(QStringView text) { - if (text.isEmpty()) { - return false; - } - for (const QChar ch : text) { - if (ch.unicode() < u'0' || ch.unicode() > u'9') { - return false; - } + if (text.isEmpty()) { + return false; + } + for (const QChar ch : text) { + if (ch.unicode() < u'0' || ch.unicode() > u'9') { + return false; } - return true; + } + return true; } } // namespace px::modrim diff --git a/src/core/utils.hpp b/src/core/utils.hpp index acd2e6b..40321e5 100644 --- a/src/core/utils.hpp +++ b/src/core/utils.hpp @@ -1,16 +1,16 @@ #pragma once -#include - #include #include +#include namespace px::modrim { // Case-insensitive search for About/About.xml inside a mod directory. std::optional findAboutXml(const QString& modPath); -// True for a non-empty string made only of ASCII digits (Python's str.isdigit for ASCII input). +// True for a non-empty string made only of ASCII digits (Python's str.isdigit +// for ASCII input). bool isAsciiDigits(QStringView text); } // namespace px::modrim diff --git a/src/core/version.cpp b/src/core/version.cpp index 8695f51..1d31b44 100644 --- a/src/core/version.cpp +++ b/src/core/version.cpp @@ -1,18 +1,19 @@ #include "core/version.hpp" -#include #include +#include + namespace px::modrim { QString appVersion() { return QStringLiteral("0.3.1"); } QCoro::Task delayedVersion(int ms) { - QTimer timer; - timer.setSingleShot(true); - timer.start(ms); - co_await timer; - co_return appVersion(); + QTimer timer; + timer.setSingleShot(true); + timer.start(ms); + co_await timer; + co_return appVersion(); } } // namespace px::modrim diff --git a/src/core/version.hpp b/src/core/version.hpp index 620e95d..5cd9a05 100644 --- a/src/core/version.hpp +++ b/src/core/version.hpp @@ -1,6 +1,7 @@ #pragma once #include + #include namespace px::modrim { diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index 82bb5d7..65ccfa9 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -1,13 +1,12 @@ #include "core/workshop/catalog.hpp" -#include -#include -#include - #include #include #include #include +#include +#include +#include #include "core/context.hpp" #include "core/mod_service.hpp" @@ -21,32 +20,37 @@ constexpr qsizetype kCacheLimit = 200; constexpr qsizetype kDiskLimit = 300; QStringList toQstrings(const std::vector& values) { - QStringList out; - out.reserve(static_cast(values.size())); - for (const std::string& value : values) { - out.append(QString::fromStdString(value)); - } - return out; + QStringList out; + out.reserve(static_cast(values.size())); + for (const std::string& value : values) { + out.append(QString::fromStdString(value)); + } + return out; } const std::string& titleOf(const CatalogEntry& entry) { - return std::visit([](const auto& item) -> const std::string& { return item.title; }, entry); + return std::visit( + [](const auto& item) -> const std::string& { return item.title; }, entry); } } // namespace namespace detail { -Result writeCached(const QString& directory, const QString& path, const std::string& text) { - if (auto saved = writeFileAtomically(path, text); !saved) { - return saved; - } - QFileInfoList files = QDir(directory).entryInfoList({QStringLiteral("*.json")}, QDir::Files); - std::ranges::sort(files, [](const QFileInfo& a, const QFileInfo& b) { return a.lastModified() < b.lastModified(); }); - for (qsizetype i = 0; i + kDiskLimit < files.size(); ++i) { - QFile::remove(files[i].absoluteFilePath()); - } - return {}; +Result writeCached(const QString& directory, const QString& path, + const std::string& text) { + if (auto saved = writeFileAtomically(path, text); !saved) { + return saved; + } + QFileInfoList files = + QDir(directory).entryInfoList({QStringLiteral("*.json")}, QDir::Files); + std::ranges::sort(files, [](const QFileInfo& a, const QFileInfo& b) { + return a.lastModified() < b.lastModified(); + }); + for (qsizetype i = 0; i + kDiskLimit < files.size(); ++i) { + QFile::remove(files[i].absoluteFilePath()); + } + return {}; } } // namespace detail @@ -54,288 +58,330 @@ Result writeCached(const QString& directory, const QString& path, const st WorkshopCatalog::WorkshopCatalog() : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), m_settings(nullptr, QStringLiteral("workshop_catalog")), - m_baseUrl(normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), m_favourites(nullptr) { - m_settings.changed.connect([this](const CatalogSettings& value) { changeUrl(QString::fromStdString(value.url)); }); + m_baseUrl( + normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), + m_favourites(nullptr) { + m_settings.changed.connect([this](const CatalogSettings& value) { + changeUrl(QString::fromStdString(value.url)); + }); } void WorkshopCatalog::setup(CoreContext& ctx) { - m_context = &ctx; - m_installedMods = [&ctx] { return ctx.allMods().values(); }; - if (ctx.hasConfigService()) { - m_settings = PluginConfig(&ctx.configService(), QStringLiteral("workshop_catalog"), - {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}); - m_settings.changed.connect([this](const CatalogSettings& value) { changeUrl(QString::fromStdString(value.url)); }); - changeUrl(QString::fromStdString(m_settings.value().url)); - m_cacheDir = ctx.configService().directory() + QStringLiteral("/workshop-cache"); - m_favourites = FavouriteCollections(&ctx.configService()); - } - bindDownloads(ctx.downloads()); - m_modsChangedId = ctx.modService().modsChanged.connect([this](const std::monostate&) { invalidateInstalled(); }); - m_configChangedId = ctx.configChanged().connect([this](const AppConfig&) { invalidateInstalled(); }); + m_context = &ctx; + m_installedMods = [&ctx] { return ctx.allMods().values(); }; + if (ctx.hasConfigService()) { + m_settings = PluginConfig( + &ctx.configService(), QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}); + m_settings.changed.connect([this](const CatalogSettings& value) { + changeUrl(QString::fromStdString(value.url)); + }); + changeUrl(QString::fromStdString(m_settings.value().url)); + m_cacheDir = + ctx.configService().directory() + QStringLiteral("/workshop-cache"); + m_favourites = FavouriteCollections(&ctx.configService()); + } + bindDownloads(ctx.downloads()); + m_modsChangedId = ctx.modService().modsChanged.connect( + [this](const std::monostate&) { invalidateInstalled(); }); + m_configChangedId = ctx.configChanged().connect( + [this](const AppConfig&) { invalidateInstalled(); }); } -WorkshopCatalog::WorkshopCatalog(ConfigService* config, DownloadManager& downloads, +WorkshopCatalog::WorkshopCatalog(ConfigService* config, + DownloadManager& downloads, InstalledModsProvider installedMods) : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), - m_settings(config, QStringLiteral("workshop_catalog"), - {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}), - m_baseUrl(normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), + m_settings( + config, QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}), + m_baseUrl( + normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), m_favourites(config), m_downloads(&downloads), m_installedMods(std::move(installedMods)) { - if (config) { - m_cacheDir = config->directory() + QStringLiteral("/workshop-cache"); - } - m_settings.changed.connect( - [this](const CatalogSettings& settings) { changeUrl(QString::fromStdString(settings.url)); }); - bindDownloads(downloads); + if (config) { + m_cacheDir = config->directory() + QStringLiteral("/workshop-cache"); + } + m_settings.changed.connect([this](const CatalogSettings& settings) { + changeUrl(QString::fromStdString(settings.url)); + }); + bindDownloads(downloads); } void WorkshopCatalog::bindDownloads(DownloadManager& downloads) { - m_downloads = &downloads; - m_downloadFinishedId = m_downloads->downloadFinished.connect([this](const DownloadResult&) { invalidateInstalled(); }); - m_busyChangedId = m_downloads->busyChanged.connect([this](bool busy) { onBusyChanged(busy); }); + m_downloads = &downloads; + m_downloadFinishedId = m_downloads->downloadFinished.connect( + [this](const DownloadResult&) { invalidateInstalled(); }); + m_busyChangedId = m_downloads->busyChanged.connect( + [this](bool busy) { onBusyChanged(busy); }); } void WorkshopCatalog::disconnectEvents() { - if (m_downloads) { - m_downloads->downloadFinished.disconnect(m_downloadFinishedId); - m_downloads->busyChanged.disconnect(m_busyChangedId); - m_downloadFinishedId = 0; - m_busyChangedId = 0; - } - if (m_context) { - m_context->modService().modsChanged.disconnect(m_modsChangedId); - m_context->configChanged().disconnect(m_configChangedId); - m_context = nullptr; - } + if (m_downloads) { + m_downloads->downloadFinished.disconnect(m_downloadFinishedId); + m_downloads->busyChanged.disconnect(m_busyChangedId); + m_downloadFinishedId = 0; + m_busyChangedId = 0; + } + if (m_context) { + m_context->modService().modsChanged.disconnect(m_modsChangedId); + m_context->configChanged().disconnect(m_configChangedId); + m_context = nullptr; + } } WorkshopCatalog::~WorkshopCatalog() { - disconnectEvents(); - // Destroying the queue's coroutine must not leave the downloads holding its handle. - m_idleWaiters.clear(); - m_runner.reset(); + disconnectEvents(); + // Destroying the queue's coroutine must not leave the downloads holding its + // handle. + m_idleWaiters.clear(); + m_runner.reset(); } QCoro::Task WorkshopCatalog::shutdown() { - disconnectEvents(); - cancelQueue(); - m_idleWaiters.clear(); - m_runner.reset(); - m_draining = false; - for (const auto& client : m_clients) { - client->shutdown(); - } - co_return; + disconnectEvents(); + cancelQueue(); + m_idleWaiters.clear(); + m_runner.reset(); + m_draining = false; + for (const auto& client : m_clients) { + client->shutdown(); + } + co_return; } void WorkshopCatalog::setBaseUrl(const QString& url) { - m_settings.update(CatalogSettings{.url = normalizeBaseUrl(url).toStdString()}); + m_settings.update( + CatalogSettings{.url = normalizeBaseUrl(url).toStdString()}); } void WorkshopCatalog::changeUrl(const QString& url) { - const QString normalized = normalizeBaseUrl(url); - if (normalized == m_baseUrl) { - return; - } - m_baseUrl = normalized; - m_client = nullptr; - catalogUrlChanged.notify(m_baseUrl); + const QString normalized = normalizeBaseUrl(url); + if (normalized == m_baseUrl) { + return; + } + m_baseUrl = normalized; + m_client = nullptr; + catalogUrlChanged.notify(m_baseUrl); } CatalogClient& WorkshopCatalog::client() { - if (!m_client) { - m_clients.push_back(std::make_unique(m_baseUrl)); - m_client = m_clients.back().get(); - } - return *m_client; + if (!m_client) { + m_clients.push_back(std::make_unique(m_baseUrl)); + m_client = m_clients.back().get(); + } + return *m_client; } QString WorkshopCatalog::cacheFile(const QString& key) const { - const QByteArray digest = QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256).toHex(); - return *m_cacheDir + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + QStringLiteral(".json"); + const QByteArray digest = + QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256) + .toHex(); + return *m_cacheDir + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + + QStringLiteral(".json"); } -void WorkshopCatalog::remember(const QString& key, std::any value, bool moveToEnd) { - if (moveToEnd || !m_responses.contains(key)) { - m_responseOrder.removeAll(key); - m_responseOrder.append(key); - } - m_responses.insert(key, std::move(value)); - while (m_responseOrder.size() > kCacheLimit) { - m_responses.remove(m_responseOrder.first()); - m_responseOrder.removeFirst(); - } +void WorkshopCatalog::remember(const QString& key, std::any value, + bool moveToEnd) { + if (moveToEnd || !m_responses.contains(key)) { + m_responseOrder.removeAll(key); + m_responseOrder.append(key); + } + m_responses.insert(key, std::move(value)); + while (m_responseOrder.size() > kCacheLimit) { + m_responses.remove(m_responseOrder.first()); + m_responseOrder.removeFirst(); + } } QCoro::Task> WorkshopCatalog::discover() { - co_return co_await client().discover(); + co_return co_await client().discover(); } -QCoro::Task>> WorkshopCatalog::mods(CatalogQuery query) { - co_return co_await client().mods(std::move(query)); +QCoro::Task>> WorkshopCatalog::mods( + CatalogQuery query) { + co_return co_await client().mods(std::move(query)); } -QCoro::Task>> WorkshopCatalog::collections(CatalogQuery query) { - co_return co_await client().collections(std::move(query)); +QCoro::Task>> +WorkshopCatalog::collections(CatalogQuery query) { + co_return co_await client().collections(std::move(query)); } -QCoro::Task>> WorkshopCatalog::mod(QString id) { - co_return co_await client().mod(std::move(id)); +QCoro::Task>> WorkshopCatalog::mod( + QString id) { + co_return co_await client().mod(std::move(id)); } -QCoro::Task>> WorkshopCatalog::collection(QString id) { - co_return co_await client().collection(std::move(id)); +QCoro::Task>> +WorkshopCatalog::collection(QString id) { + co_return co_await client().collection(std::move(id)); } const WorkshopCatalog::InstalledIndex& WorkshopCatalog::installed() { - if (!m_installedIndex) { - InstalledIndex index; - for (const ListedModPtr& mod : m_installedMods()) { - if (auto id = mod->publishedFileId()) { - index[*id].append(mod); - } - } - m_installedIndex = std::move(index); + if (!m_installedIndex) { + InstalledIndex index; + for (const ListedModPtr& mod : m_installedMods()) { + if (auto id = mod->publishedFileId()) { + index[*id].append(mod); + } } - return *m_installedIndex; + m_installedIndex = std::move(index); + } + return *m_installedIndex; } -InstallState WorkshopCatalog::installStateOf(const CatalogMod& mod, const InstalledIndex& index) const { - const QList local = index.value(QString::fromStdString(mod.id)); - if (local.isEmpty()) { - return InstallState::kMissing; - } - // Only copies downloaded through the manager have a sync time; a copy without one is never outdated. - std::optional newestSync; - for (const ListedModPtr& item : local) { - if (auto synced = m_downloads->lastSynced(*item)) { - newestSync = newestSync ? std::max(*newestSync, *synced) : *synced; - } +InstallState WorkshopCatalog::installStateOf( + const CatalogMod& mod, const InstalledIndex& index) const { + const QList local = index.value(QString::fromStdString(mod.id)); + if (local.isEmpty()) { + return InstallState::kMissing; + } + // Only copies downloaded through the manager have a sync time; a copy without + // one is never outdated. + std::optional newestSync; + for (const ListedModPtr& item : local) { + if (auto synced = m_downloads->lastSynced(*item)) { + newestSync = newestSync ? std::max(*newestSync, *synced) : *synced; } - if (newestSync && mod.updated_at && *newestSync < static_cast(*mod.updated_at)) { - return InstallState::kOutdated; - } - return InstallState::kInstalled; + } + if (newestSync && mod.updated_at && + *newestSync < static_cast(*mod.updated_at)) { + return InstallState::kOutdated; + } + return InstallState::kInstalled; } InstallState WorkshopCatalog::installState(const CatalogMod& mod) { - return installStateOf(mod, installed()); + return installStateOf(mod, installed()); } void WorkshopCatalog::invalidateInstalled() { - m_installedIndex.reset(); - installedChanged.notify(std::monostate{}); + m_installedIndex.reset(); + installedChanged.notify(std::monostate{}); } -QCoro::Task>> WorkshopCatalog::installedWithUpdates() { - QStringList ids = installed().keys(); - // The index is a hash, so sort the ids to request them in a stable order. - ids.sort(); - auto batch = co_await client().modsBatch(std::move(ids)); - if (!batch) { - co_return std::unexpected(batch.error()); - } - co_return std::move(batch->items); +QCoro::Task>> +WorkshopCatalog::installedWithUpdates() { + QStringList ids = installed().keys(); + // The index is a hash, so sort the ids to request them in a stable order. + ids.sort(); + auto batch = co_await client().modsBatch(std::move(ids)); + if (!batch) { + co_return std::unexpected(batch.error()); + } + co_return std::move(batch->items); } -QCoro::Task> WorkshopCatalog::plan(QStringList modIds, QStringList collectionIds) { - auto resolved = co_await client().resolve(std::move(modIds), std::move(collectionIds)); - if (!resolved) { - co_return std::unexpected(resolved.error()); +QCoro::Task> WorkshopCatalog::plan( + QStringList modIds, QStringList collectionIds) { + auto resolved = + co_await client().resolve(std::move(modIds), std::move(collectionIds)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + const InstalledIndex& index = installed(); + DownloadPlan result; + for (const std::string& id : resolved->mod_ids) { + const auto found = resolved->items.find(id); + const auto* mod = found == resolved->items.end() + ? nullptr + : std::get_if(&found->second); + if (!mod) { + co_return std::unexpected(CatalogError{ + QStringLiteral( + "The Workshop catalog returned an invalid download plan."), + std::nullopt}); } - const InstalledIndex& index = installed(); - DownloadPlan result; - for (const std::string& id : resolved->mod_ids) { - const auto found = resolved->items.find(id); - const auto* mod = found == resolved->items.end() ? nullptr : std::get_if(&found->second); - if (!mod) { - co_return std::unexpected(CatalogError{ - QStringLiteral("The Workshop catalog returned an invalid download plan."), std::nullopt}); - } - const QString qid = QString::fromStdString(id); - if (installStateOf(*mod, index) == InstallState::kInstalled) { - result.already_current.append(qid); - } else { - result.to_download.append(qid); - } + const QString qid = QString::fromStdString(id); + if (installStateOf(*mod, index) == InstallState::kInstalled) { + result.already_current.append(qid); + } else { + result.to_download.append(qid); } - for (const auto& [id, entry] : resolved->items) { - result.titles.insert(QString::fromStdString(id), QString::fromStdString(titleOf(entry))); - } - result.unavailable_ids = toQstrings(resolved->unavailable_ids); - result.incomplete_collection_ids = toQstrings(resolved->incomplete_collection_ids); - result.is_complete = resolved->is_complete; - co_return result; + } + for (const auto& [id, entry] : resolved->items) { + result.titles.insert(QString::fromStdString(id), + QString::fromStdString(titleOf(entry))); + } + result.unavailable_ids = toQstrings(resolved->unavailable_ids); + result.incomplete_collection_ids = + toQstrings(resolved->incomplete_collection_ids); + result.is_complete = resolved->is_complete; + co_return result; } -QCoro::Task> WorkshopCatalog::download(DownloadPlan downloadPlan) { - co_return co_await m_downloads->downloadMods(std::move(downloadPlan.to_download)); +QCoro::Task> WorkshopCatalog::download( + DownloadPlan downloadPlan) { + co_return co_await m_downloads->downloadMods( + std::move(downloadPlan.to_download)); } void WorkshopCatalog::enqueue(const DownloadPlan& downloadPlan) { - const QSet active = m_downloads->activeIds(); - QStringList ids; - for (const QString& id : downloadPlan.to_download) { - if (!m_queued.contains(id) && !active.contains(id)) { - ids.append(id); - } - } - if (ids.isEmpty()) { - return; - } - m_queue.append(ids); - m_queued.unite(QSet(ids.begin(), ids.end())); - queueChanged.notify(std::monostate{}); - if (!m_draining) { - m_draining = true; - m_runner.emplace(drain()); + const QSet active = m_downloads->activeIds(); + QStringList ids; + for (const QString& id : downloadPlan.to_download) { + if (!m_queued.contains(id) && !active.contains(id)) { + ids.append(id); } + } + if (ids.isEmpty()) { + return; + } + m_queue.append(ids); + m_queued.unite(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (!m_draining) { + m_draining = true; + m_runner.emplace(drain()); + } } void WorkshopCatalog::cancelQueue() { - m_queue.clear(); - m_queued.clear(); - queueChanged.notify(std::monostate{}); + m_queue.clear(); + m_queued.clear(); + queueChanged.notify(std::monostate{}); } QCoro::Task WorkshopCatalog::drain() { - while (!m_queue.isEmpty()) { - const QStringList ids = m_queue.takeFirst(); - // A batch that waited for the running download stops the queue if that download was cancelled. - const bool waited = m_downloads->isDownloading(); - while (m_downloads->isDownloading()) { - co_await IdleWait{*this}; - } - if (waited && m_downloads->cancelled()) { - cancelQueue(); - break; - } - - auto result = co_await m_downloads->downloadMods(ids); - if (!result) { - spdlog::warn("[workshop] queued download failed: {}", result.error().message.toStdString()); - } - m_queued.subtract(QSet(ids.begin(), ids.end())); - queueChanged.notify(std::monostate{}); - if (m_downloads->cancelled()) { - cancelQueue(); - break; - } + while (!m_queue.isEmpty()) { + const QStringList ids = m_queue.takeFirst(); + // A batch that waited for the running download stops the queue if that + // download was cancelled. + const bool waited = m_downloads->isDownloading(); + while (m_downloads->isDownloading()) { + co_await IdleWait{*this}; + } + if (waited && m_downloads->cancelled()) { + cancelQueue(); + break; } - m_draining = false; -} -void WorkshopCatalog::onBusyChanged(bool busy) { - if (busy) { - return; + auto result = co_await m_downloads->downloadMods(ids); + if (!result) { + spdlog::warn("[workshop] queued download failed: {}", + result.error().message.toStdString()); } - auto waiters = std::move(m_idleWaiters); - m_idleWaiters.clear(); - for (std::coroutine_handle<> waiter : waiters) { - waiter.resume(); + m_queued.subtract(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (m_downloads->cancelled()) { + cancelQueue(); + break; } + } + m_draining = false; +} + +void WorkshopCatalog::onBusyChanged(bool busy) { + if (busy) { + return; + } + auto waiters = std::move(m_idleWaiters); + m_idleWaiters.clear(); + for (std::coroutine_handle<> waiter : waiters) { + waiter.resume(); + } } } // namespace px::modrim::workshop diff --git a/src/core/workshop/catalog.hpp b/src/core/workshop/catalog.hpp index 8ffab66..725d1c7 100644 --- a/src/core/workshop/catalog.hpp +++ b/src/core/workshop/catalog.hpp @@ -1,5 +1,11 @@ #pragma once +#include +#include +#include +#include +#include +#include #include #include #include @@ -10,16 +16,11 @@ #include #include -#include #include #include -#include -#include -#include -#include -#include -#include #include + +#include #include #include "core/config.hpp" @@ -36,30 +37,34 @@ namespace px::modrim::workshop { -inline constexpr std::string_view kDefaultCatalogUrl = "https://api.modrim.pyxiion.dev"; +inline constexpr std::string_view kDefaultCatalogUrl = + "https://api.modrim.pyxiion.dev"; -// A value remembered for less than this many seconds is served without contacting the catalog. +// A value remembered for less than this many seconds is served without +// contacting the catalog. inline constexpr double kFreshSeconds = 300.0; // Stored as plugins/workshop_catalog.json. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CatalogSettings { - std::string url = std::string(kDefaultCatalogUrl); + std::string url = std::string(kDefaultCatalogUrl); - bool operator==(const CatalogSettings&) const = default; + bool operator==(const CatalogSettings&) const = default; }; // NOLINTEND(readability-identifier-naming) -// The installed mods the catalog compares against. The composition root supplies it, so the catalog -// does not reach for global state. +// The installed mods the catalog compares against. The composition root +// supplies it, so the catalog does not reach for global state. using InstalledModsProvider = std::function()>; // A cached value with the time it was fetched (seconds since the epoch). -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire template struct Remembered { - double at = 0; - T value{}; + double at = 0; + T value{}; }; // NOLINTEND(readability-identifier-naming) @@ -68,205 +73,224 @@ namespace detail { // A missing, unreadable or undecodable cache file is treated as a miss. template std::optional> readCached(const QString& path) { - auto bytes = readFileBytes(path); - if (!bytes) { - return std::nullopt; - } - Remembered entry; - if (glz::read(entry, *bytes)) { - return std::nullopt; - } - return entry; + auto bytes = readFileBytes(path); + if (!bytes) { + return std::nullopt; + } + Remembered entry; + if (glz::read(entry, *bytes)) { + return std::nullopt; + } + return entry; } // Writes one cache file, then drops the oldest files beyond the disk limit. -Result writeCached(const QString& directory, const QString& path, const std::string& text); +Result writeCached(const QString& directory, const QString& path, + const std::string& text); } // namespace detail -// The Workshop catalog: remote browsing and resolution of mods, plus a download queue that runs behind -// the downloads manager. The catalog never mutates installed mods itself. +// The Workshop catalog: remote browsing and resolution of mods, plus a download +// queue that runs behind the downloads manager. The catalog never mutates +// installed mods itself. class WorkshopCatalog : public Plugin { -public: - WorkshopCatalog(); - void setup(CoreContext& ctx) override; - // `config` may be null: settings, favourites and the response cache then stay in memory. - WorkshopCatalog(ConfigService* config, DownloadManager& downloads, InstalledModsProvider installedMods); - ~WorkshopCatalog(); - - WorkshopCatalog(const WorkshopCatalog&) = delete; - WorkshopCatalog& operator=(const WorkshopCatalog&) = delete; - - // Stops the queue and aborts requests in flight. - QCoro::Task shutdown() override; - - Event catalogUrlChanged; - Event installedChanged; - Event queueChanged; - - const CatalogSettings& settings() const noexcept { return m_settings.value(); } - void setBaseUrl(const QString& url); - bool configured() const noexcept { return !m_baseUrl.isEmpty(); } - - FavouriteCollections& favourites() noexcept { return m_favourites; } - - QCoro::Task> discover(); - QCoro::Task>> mods(CatalogQuery query); - QCoro::Task>> collections(CatalogQuery query); - QCoro::Task>> mod(QString id); - QCoro::Task>> collection(QString id); - - // Serves the remembered value at once through `show`, then the refreshed one when it differs. - // A value younger than `fresh_for` is served without contacting the catalog. With `persist`, the - // value also survives a restart. `T` must be serialisable with glaze and comparable. - template - QCoro::Task> cached(QString key, std::function>()> produce, - std::function show, double freshFor = kFreshSeconds, - bool persist = false); - - // Installed state is cached until invalidate_installed(). - InstallState installState(const CatalogMod& mod); - QCoro::Task>> installedWithUpdates(); - - // Resolves ids into the mods to fetch, skipping those already current. - QCoro::Task> plan(QStringList modIds, QStringList collectionIds); - QCoro::Task> download(DownloadPlan downloadPlan); - - // Ids waiting in the queue or downloading. - QSet queuedIds() const { return m_queued; } - // Queues `plan` behind any running download without waiting for it. - void enqueue(const DownloadPlan& downloadPlan); - void cancelQueue(); - - // Call when the installed mods changed (mods changed, config changed). - void invalidateInstalled(); - -private: - using InstalledIndex = QHash>; - - // Suspends the queue until the downloads report they are idle again. - struct IdleWait { - WorkshopCatalog& catalog; - - // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol - bool await_ready() const noexcept { return false; } - void await_suspend(std::coroutine_handle<> handle) const { catalog.m_idleWaiters.push_back(handle); } - void await_resume() const noexcept {} - // NOLINTEND(readability-identifier-naming) - }; - - CatalogClient& client(); - const InstalledIndex& installed(); - void bindDownloads(DownloadManager& downloads); - void disconnectEvents(); - InstallState installStateOf(const CatalogMod& mod, const InstalledIndex& installed) const; - void changeUrl(const QString& url); - void onBusyChanged(bool busy); - QCoro::Task drain(); - - template - std::optional> recall(const QString& key) const; - // Keeps the memory cache within its limit. `move_to_end` marks the entry as the newest. - void remember(const QString& key, std::any value, bool moveToEnd); - QString cacheFile(const QString& key) const; - - PluginConfig m_settings; - QString m_baseUrl; - std::optional m_cacheDir; - FavouriteCollections m_favourites; - DownloadManager* m_downloads = nullptr; - InstalledModsProvider m_installedMods; - Event::Id m_downloadFinishedId = 0; - Event::Id m_busyChangedId = 0; - CoreContext* m_context = nullptr; - Event::Id m_modsChangedId = 0; - Event::Id m_configChangedId = 0; - - std::optional m_installedIndex; - // Insertion-ordered: the oldest entries are evicted first. - QList m_responseOrder; - // Each value is a Remembered for the T it was stored with. - QHash m_responses; - - // Every client made so far stays alive until shutdown, so requests in flight on a replaced client finish. - std::vector> m_clients; - CatalogClient* m_client = nullptr; - - QList m_queue; - QSet m_queued; - bool m_draining = false; - std::optional> m_runner; - std::vector> m_idleWaiters; + public: + WorkshopCatalog(); + void setup(CoreContext& ctx) override; + // `config` may be null: settings, favourites and the response cache then stay + // in memory. + WorkshopCatalog(ConfigService* config, DownloadManager& downloads, + InstalledModsProvider installedMods); + ~WorkshopCatalog(); + + WorkshopCatalog(const WorkshopCatalog&) = delete; + WorkshopCatalog& operator=(const WorkshopCatalog&) = delete; + + // Stops the queue and aborts requests in flight. + QCoro::Task shutdown() override; + + Event catalogUrlChanged; + Event installedChanged; + Event queueChanged; + + const CatalogSettings& settings() const noexcept { + return m_settings.value(); + } + void setBaseUrl(const QString& url); + bool configured() const noexcept { return !m_baseUrl.isEmpty(); } + + FavouriteCollections& favourites() noexcept { return m_favourites; } + + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections( + CatalogQuery query); + QCoro::Task>> mod(QString id); + QCoro::Task>> collection( + QString id); + + // Serves the remembered value at once through `show`, then the refreshed one + // when it differs. A value younger than `fresh_for` is served without + // contacting the catalog. With `persist`, the value also survives a restart. + // `T` must be serialisable with glaze and comparable. + template + QCoro::Task> cached( + QString key, std::function>()> produce, + std::function show, double freshFor = kFreshSeconds, + bool persist = false); + + // Installed state is cached until invalidate_installed(). + InstallState installState(const CatalogMod& mod); + QCoro::Task>> installedWithUpdates(); + + // Resolves ids into the mods to fetch, skipping those already current. + QCoro::Task> plan(QStringList modIds, + QStringList collectionIds); + QCoro::Task> download(DownloadPlan downloadPlan); + + // Ids waiting in the queue or downloading. + QSet queuedIds() const { return m_queued; } + // Queues `plan` behind any running download without waiting for it. + void enqueue(const DownloadPlan& downloadPlan); + void cancelQueue(); + + // Call when the installed mods changed (mods changed, config changed). + void invalidateInstalled(); + + private: + using InstalledIndex = QHash>; + + // Suspends the queue until the downloads report they are idle again. + struct IdleWait { + WorkshopCatalog& catalog; + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) const { + catalog.m_idleWaiters.push_back(handle); + } + void await_resume() const noexcept {} + // NOLINTEND(readability-identifier-naming) + }; + + CatalogClient& client(); + const InstalledIndex& installed(); + void bindDownloads(DownloadManager& downloads); + void disconnectEvents(); + InstallState installStateOf(const CatalogMod& mod, + const InstalledIndex& installed) const; + void changeUrl(const QString& url); + void onBusyChanged(bool busy); + QCoro::Task drain(); + + template + std::optional> recall(const QString& key) const; + // Keeps the memory cache within its limit. `move_to_end` marks the entry as + // the newest. + void remember(const QString& key, std::any value, bool moveToEnd); + QString cacheFile(const QString& key) const; + + PluginConfig m_settings; + QString m_baseUrl; + std::optional m_cacheDir; + FavouriteCollections m_favourites; + DownloadManager* m_downloads = nullptr; + InstalledModsProvider m_installedMods; + Event::Id m_downloadFinishedId = 0; + Event::Id m_busyChangedId = 0; + CoreContext* m_context = nullptr; + Event::Id m_modsChangedId = 0; + Event::Id m_configChangedId = 0; + + std::optional m_installedIndex; + // Insertion-ordered: the oldest entries are evicted first. + QList m_responseOrder; + // Each value is a Remembered for the T it was stored with. + QHash m_responses; + + // Every client made so far stays alive until shutdown, so requests in flight + // on a replaced client finish. + std::vector> m_clients; + CatalogClient* m_client = nullptr; + + QList m_queue; + QSet m_queued; + bool m_draining = false; + std::optional> m_runner; + std::vector> m_idleWaiters; }; template std::optional> WorkshopCatalog::recall(const QString& key) const { - const auto found = m_responses.constFind(key); - if (found == m_responses.constEnd()) { - return std::nullopt; - } - const auto* typed = std::any_cast>(&found.value()); - if (!typed) { - return std::nullopt; - } - return *typed; + const auto found = m_responses.constFind(key); + if (found == m_responses.constEnd()) { + return std::nullopt; + } + const auto* typed = std::any_cast>(&found.value()); + if (!typed) { + return std::nullopt; + } + return *typed; } template -QCoro::Task> WorkshopCatalog::cached(QString key, - std::function>()> produce, - std::function show, double freshFor, - bool persist) { - const QString fullKey = m_baseUrl + QLatin1Char('|') + key; - std::optional path; - if (persist && m_cacheDir) { - path = cacheFile(fullKey); - } - - std::optional> entry = recall(fullKey); - if (!entry && path) { - entry = co_await QtConcurrent::run([path = *path] { return detail::readCached(path); }); - if (entry) { - remember(fullKey, *entry, false); - } - } +QCoro::Task> WorkshopCatalog::cached( + QString key, std::function>()> produce, + std::function show, double freshFor, bool persist) { + const QString fullKey = m_baseUrl + QLatin1Char('|') + key; + std::optional path; + if (persist && m_cacheDir) { + path = cacheFile(fullKey); + } + + std::optional> entry = recall(fullKey); + if (!entry && path) { + entry = co_await QtConcurrent::run( + [path = *path] { return detail::readCached(path); }); if (entry) { - show(entry->value); - const double age = QDateTime::currentMSecsSinceEpoch() / 1000.0 - entry->at; - if (age < freshFor) { - co_return {}; - } - } - - auto fetched = co_await produce(); - if (!fetched) { - co_return std::unexpected(fetched.error()); - } - T value = std::move(*fetched); - const bool changed = !entry || value != entry->value; - if (entry && !changed) { - // Keep the displayed copy so an equal refresh does not replace what is on screen. - value = entry->value; + remember(fullKey, *entry, false); } - const Remembered remembered{QDateTime::currentMSecsSinceEpoch() / 1000.0, value}; - remember(fullKey, remembered, true); - - if (path) { - auto encoded = glz::write(remembered); - if (encoded) { - auto saved = co_await QtConcurrent::run( - [directory = *m_cacheDir, file = *path, bytes = std::move(*encoded)] { - return detail::writeCached(directory, file, bytes); - }); - if (!saved) { - spdlog::warn("[workshop] cannot write cache: {}", saved.error().message.toStdString()); - } - } + } + if (entry) { + show(entry->value); + const double age = QDateTime::currentMSecsSinceEpoch() / 1000.0 - entry->at; + if (age < freshFor) { + co_return {}; } - if (changed) { - show(value); + } + + auto fetched = co_await produce(); + if (!fetched) { + co_return std::unexpected(fetched.error()); + } + T value = std::move(*fetched); + const bool changed = !entry || value != entry->value; + if (entry && !changed) { + // Keep the displayed copy so an equal refresh does not replace what is on + // screen. + value = entry->value; + } + const Remembered remembered{QDateTime::currentMSecsSinceEpoch() / 1000.0, + value}; + remember(fullKey, remembered, true); + + if (path) { + auto encoded = glz::write(remembered); + if (encoded) { + auto saved = co_await QtConcurrent::run( + [directory = *m_cacheDir, file = *path, bytes = std::move(*encoded)] { + return detail::writeCached(directory, file, bytes); + }); + if (!saved) { + spdlog::warn("[workshop] cannot write cache: {}", + saved.error().message.toStdString()); + } } - co_return {}; + } + if (changed) { + show(value); + } + co_return {}; } } // namespace px::modrim::workshop diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index 9f97e3c..ee98245 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -1,292 +1,337 @@ #include "core/workshop/client.hpp" +#include +#include +#include +#include #include #include #include #include #include -#include -#include #include -#include -#include -#include + +#include namespace px::modrim::workshop { namespace { constexpr int kRequestTimeoutMs = 30000; -// Per-request limits of the catalog service; larger inputs are split across requests. +// Per-request limits of the catalog service; larger inputs are split across +// requests. constexpr qsizetype kIdsPerBatch = 100; constexpr qsizetype kModIdsPerResolve = 50; constexpr qsizetype kCollectionIdsPerResolve = 10; struct ErrorMessage { - std::string message; + std::string message; }; struct ErrorEnvelope { - ErrorMessage error; + ErrorMessage error; }; // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct BatchWire { - std::vector items; - std::vector unavailable_ids; + std::vector items; + std::vector unavailable_ids; }; // NOLINTEND(readability-identifier-naming) struct IdsBody { - std::vector ids; + std::vector ids; }; // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolveBody { - std::vector ids; - std::vector collection_ids; + std::vector ids; + std::vector collection_ids; }; // NOLINTEND(readability-identifier-naming) std::vector toUtf8(const QStringList& list) { - std::vector out; - out.reserve(static_cast(list.size())); - for (const QString& value : list) { - out.push_back(value.toStdString()); - } - return out; + std::vector out; + out.reserve(static_cast(list.size())); + for (const QString& value : list) { + out.push_back(value.toStdString()); + } + return out; } -// Percent-encodes everything except the unreserved characters, as Python's quote(safe="") does. +// Percent-encodes everything except the unreserved characters, as Python's +// quote(safe="") does. QString encodeSegment(const QString& segment) { - return QString::fromLatin1(QUrl::toPercentEncoding(segment)); + return QString::fromLatin1(QUrl::toPercentEncoding(segment)); } QUrlQuery queryOf(const CatalogQuery& query) { - QUrlQuery params; - params.addQueryItem(QStringLiteral("q"), query.query); - params.addQueryItem(QStringLiteral("sort"), query.sort); - params.addQueryItem(QStringLiteral("limit"), QString::number(query.limit)); - params.addQueryItem(QStringLiteral("source"), query.source); - if (query.tag) { - params.addQueryItem(QStringLiteral("tag"), *query.tag); - } - if (query.version) { - params.addQueryItem(QStringLiteral("version"), *query.version); - } - if (query.cursor) { - params.addQueryItem(QStringLiteral("cursor"), *query.cursor); - } - return params; + QUrlQuery params; + params.addQueryItem(QStringLiteral("q"), query.query); + params.addQueryItem(QStringLiteral("sort"), query.sort); + params.addQueryItem(QStringLiteral("limit"), QString::number(query.limit)); + params.addQueryItem(QStringLiteral("source"), query.source); + if (query.tag) { + params.addQueryItem(QStringLiteral("tag"), *query.tag); + } + if (query.version) { + params.addQueryItem(QStringLiteral("version"), *query.version); + } + if (query.cursor) { + params.addQueryItem(QStringLiteral("cursor"), *query.cursor); + } + return params; } template std::string toJson(const T& value) { - auto text = glz::write(value); - Q_ASSERT(text.has_value()); - return std::move(*text); + auto text = glz::write(value); + Q_ASSERT(text.has_value()); + return std::move(*text); } template CatalogResult decode(const std::string& body, int status) { - T value{}; - if (auto error = glz::read(value, body)) { - return std::unexpected(CatalogError{QStringLiteral("The Workshop catalog returned an invalid response."), status}); - } - return value; + T value{}; + if (auto error = glz::read(value, body)) { + return std::unexpected(CatalogError{ + QStringLiteral("The Workshop catalog returned an invalid response."), + status}); + } + return value; } -// Appends the values not seen before, keeping first-occurrence order (Python dict.fromkeys). -void addUnique(std::vector& out, std::unordered_set& seen, - const std::vector& values) { - for (const std::string& value : values) { - if (seen.insert(value).second) { - out.push_back(value); - } +// Appends the values not seen before, keeping first-occurrence order (Python +// dict.fromkeys). +void addUnique(std::vector& out, + std::unordered_set& seen, + const std::vector& values) { + for (const std::string& value : values) { + if (seen.insert(value).second) { + out.push_back(value); } + } } } // namespace QString normalizeBaseUrl(QString url) { - url = url.trimmed(); - while (url.endsWith(u'/')) { - url.chop(1); - } - return url; + url = url.trimmed(); + while (url.endsWith(u'/')) { + url.chop(1); + } + return url; } -CatalogClient::CatalogClient(QString baseUrl) : m_baseUrl(normalizeBaseUrl(std::move(baseUrl))) {} +CatalogClient::CatalogClient(QString baseUrl) + : m_baseUrl(normalizeBaseUrl(std::move(baseUrl))) {} void CatalogClient::shutdown() { - // abort() can resume waiting requests, which remove themselves from pending_: iterate a copy. - const auto replies = m_pending; - for (QNetworkReply* reply : replies) { - reply->abort(); - } + // abort() can resume waiting requests, which remove themselves from pending_: + // iterate a copy. + const auto replies = m_pending; + for (QNetworkReply* reply : replies) { + reply->abort(); + } } -QUrl CatalogClient::endpoint(const QString& path, const QUrlQuery& query) const { - QUrl url(m_baseUrl + QStringLiteral("/catalog/") + path); - url.setQuery(query); - return url; +QUrl CatalogClient::endpoint(const QString& path, + const QUrlQuery& query) const { + QUrl url(m_baseUrl + QStringLiteral("/catalog/") + path); + url.setQuery(query); + return url; } -QCoro::Task> CatalogClient::send(bool post, QUrl url, std::string payload) { - if (m_baseUrl.isEmpty()) { - co_return std::unexpected(CatalogError{QStringLiteral("Workshop catalog is disabled."), std::nullopt}); - } - - QNetworkRequest request(url); - request.setTransferTimeout(kRequestTimeoutMs); - QNetworkReply* reply = nullptr; - if (post) { - request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); - reply = m_network.post(request, QByteArray::fromStdString(payload)); - } else { - reply = m_network.get(request); - } - m_pending.insert(reply); - // Runs however the coroutine leaves, so a finished reply is never left registered for abort. - struct Release { - QSet& pending; - QNetworkReply* reply; - ~Release() { - pending.remove(reply); - reply->deleteLater(); - } - } release{m_pending, reply}; - - co_await qCoro(reply).waitForFinished(); - - // An HTTP status is only present when the server answered; without one the request failed in transport. - const QVariant statusAttribute = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); - if (!statusAttribute.isValid()) { - co_return std::unexpected(CatalogError{ - QStringLiteral("Cannot reach the Workshop catalog: %1").arg(reply->errorString()), std::nullopt}); - } - - const int status = statusAttribute.toInt(); - Reply received{status, reply->readAll().toStdString()}; - if (status >= 200 && status < 300) { - co_return received; - } - - QString message; - ErrorEnvelope envelope; - if (!glz::read(envelope, received.body)) { - message = QString::fromStdString(envelope.error.message); - } else { - const QString reason = reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); - message = reason.isEmpty() - ? QStringLiteral("Workshop catalog request failed (HTTP %1).").arg(status) - : QStringLiteral("Workshop catalog request failed (HTTP %1 %2).").arg(status).arg(reason); +QCoro::Task> CatalogClient::send( + bool post, QUrl url, std::string payload) { + if (m_baseUrl.isEmpty()) { + co_return std::unexpected(CatalogError{ + QStringLiteral("Workshop catalog is disabled."), std::nullopt}); + } + + QNetworkRequest request(url); + request.setTransferTimeout(kRequestTimeoutMs); + QNetworkReply* reply = nullptr; + if (post) { + request.setHeader(QNetworkRequest::ContentTypeHeader, + QByteArrayLiteral("application/json")); + reply = m_network.post(request, QByteArray::fromStdString(payload)); + } else { + reply = m_network.get(request); + } + m_pending.insert(reply); + // Runs however the coroutine leaves, so a finished reply is never left + // registered for abort. + struct Release { + QSet& pending; + QNetworkReply* reply; + ~Release() { + pending.remove(reply); + reply->deleteLater(); } - co_return std::unexpected(CatalogError{message, status}); + } release{m_pending, reply}; + + co_await qCoro(reply).waitForFinished(); + + // An HTTP status is only present when the server answered; without one the + // request failed in transport. + const QVariant statusAttribute = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (!statusAttribute.isValid()) { + co_return std::unexpected( + CatalogError{QStringLiteral("Cannot reach the Workshop catalog: %1") + .arg(reply->errorString()), + std::nullopt}); + } + + const int status = statusAttribute.toInt(); + Reply received{status, reply->readAll().toStdString()}; + if (status >= 200 && status < 300) { + co_return received; + } + + QString message; + ErrorEnvelope envelope; + if (!glz::read(envelope, received.body)) { + message = QString::fromStdString(envelope.error.message); + } else { + const QString reason = + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + message = + reason.isEmpty() + ? QStringLiteral("Workshop catalog request failed (HTTP %1).") + .arg(status) + : QStringLiteral("Workshop catalog request failed (HTTP %1 %2).") + .arg(status) + .arg(reason); + } + co_return std::unexpected(CatalogError{message, status}); } template -QCoro::Task> CatalogClient::call(bool post, QUrl url, std::string payload) { - auto reply = co_await send(post, std::move(url), std::move(payload)); - if (!reply) { - co_return std::unexpected(reply.error()); - } - co_return decode(reply->body, reply->status); +QCoro::Task> CatalogClient::call(bool post, QUrl url, + std::string payload) { + auto reply = co_await send(post, std::move(url), std::move(payload)); + if (!reply) { + co_return std::unexpected(reply.error()); + } + co_return decode(reply->body, reply->status); } QCoro::Task> CatalogClient::discover() { - co_return co_await call(false, endpoint(QStringLiteral("discover"))); + co_return co_await call(false, + endpoint(QStringLiteral("discover"))); } -QCoro::Task>> CatalogClient::mods(CatalogQuery query) { - co_return co_await call>(false, endpoint(QStringLiteral("mods"), queryOf(query))); +QCoro::Task>> CatalogClient::mods( + CatalogQuery query) { + co_return co_await call>( + false, endpoint(QStringLiteral("mods"), queryOf(query))); } -QCoro::Task>> CatalogClient::collections(CatalogQuery query) { - co_return co_await call>( - false, endpoint(QStringLiteral("collections"), queryOf(query))); +QCoro::Task>> +CatalogClient::collections(CatalogQuery query) { + co_return co_await call>( + false, endpoint(QStringLiteral("collections"), queryOf(query))); } -QCoro::Task>> CatalogClient::mod(QString id) { - auto found = co_await call(false, endpoint(QStringLiteral("mods/") + encodeSegment(id))); - if (found) { - co_return std::optional(std::move(*found)); - } - if (found.error().status == 404) { - co_return std::optional{}; - } - co_return std::unexpected(found.error()); +QCoro::Task>> CatalogClient::mod( + QString id) { + auto found = co_await call( + false, endpoint(QStringLiteral("mods/") + encodeSegment(id))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); } -QCoro::Task>> CatalogClient::collection(QString id) { - // Collection ids are ":"; the key may itself contain colons. - const qsizetype colon = id.indexOf(u':'); - const QString source = colon < 0 ? QString() : id.left(colon); - const QString key = colon < 0 ? QString() : id.mid(colon + 1); - if (colon < 0 || key.isEmpty() || - (source != QStringLiteral("steam") && source != QStringLiteral("picked"))) { - co_return std::unexpected(CatalogError{QStringLiteral("Invalid Workshop collection ID."), std::nullopt}); - } - - auto found = co_await call( - false, endpoint(QStringLiteral("collections/") + source + QLatin1Char('/') + encodeSegment(key))); - if (found) { - co_return std::optional(std::move(*found)); - } - if (found.error().status == 404) { - co_return std::optional{}; - } - co_return std::unexpected(found.error()); +QCoro::Task>> +CatalogClient::collection(QString id) { + // Collection ids are ":"; the key may itself contain colons. + const qsizetype colon = id.indexOf(u':'); + const QString source = colon < 0 ? QString() : id.left(colon); + const QString key = colon < 0 ? QString() : id.mid(colon + 1); + if (colon < 0 || key.isEmpty() || + (source != QStringLiteral("steam") && + source != QStringLiteral("picked"))) { + co_return std::unexpected(CatalogError{ + QStringLiteral("Invalid Workshop collection ID."), std::nullopt}); + } + + auto found = co_await call( + false, endpoint(QStringLiteral("collections/") + source + + QLatin1Char('/') + encodeSegment(key))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); } -QCoro::Task> CatalogClient::modsBatch(QStringList ids) { - BatchResult result; - for (qsizetype start = 0; start < ids.size(); start += kIdsPerBatch) { - auto batch = co_await call( - true, endpoint(QStringLiteral("mods/batch")), - toJson(IdsBody{toUtf8(ids.mid(start, kIdsPerBatch))})); - if (!batch) { - co_return std::unexpected(batch.error()); - } - result.items.insert(result.items.end(), std::make_move_iterator(batch->items.begin()), - std::make_move_iterator(batch->items.end())); - result.unavailableIds.insert(result.unavailableIds.end(), batch->unavailable_ids.begin(), - batch->unavailable_ids.end()); +QCoro::Task> CatalogClient::modsBatch( + QStringList ids) { + BatchResult result; + for (qsizetype start = 0; start < ids.size(); start += kIdsPerBatch) { + auto batch = co_await call( + true, endpoint(QStringLiteral("mods/batch")), + toJson(IdsBody{toUtf8(ids.mid(start, kIdsPerBatch))})); + if (!batch) { + co_return std::unexpected(batch.error()); } - co_return result; + result.items.insert(result.items.end(), + std::make_move_iterator(batch->items.begin()), + std::make_move_iterator(batch->items.end())); + result.unavailableIds.insert(result.unavailableIds.end(), + batch->unavailable_ids.begin(), + batch->unavailable_ids.end()); + } + co_return result; } -QCoro::Task> CatalogClient::resolve(QStringList ids, QStringList collectionIds) { - // One request covers up to 50 mods and 10 collections; the longer list sets the number of requests. - const qsizetype batches = std::max({(ids.size() + kModIdsPerResolve - 1) / kModIdsPerResolve, - (collectionIds.size() + kCollectionIdsPerResolve - 1) / kCollectionIdsPerResolve, - qsizetype{1}}); - ResolvedDownload merged; - merged.is_complete = true; - std::unordered_set rootsSeen; - std::unordered_set modIdsSeen; - std::unordered_set unavailableSeen; - std::unordered_set incompleteSeen; - for (qsizetype batch = 0; batch < batches; ++batch) { - ResolveBody body{ - toUtf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), - toUtf8(collectionIds.mid(batch * kCollectionIdsPerResolve, kCollectionIdsPerResolve)), - }; - auto resolved = co_await call(true, endpoint(QStringLiteral("resolve")), toJson(body)); - if (!resolved) { - co_return std::unexpected(resolved.error()); - } - addUnique(merged.roots, rootsSeen, resolved->roots); - for (auto& [id, entry] : resolved->items) { - merged.items.insert_or_assign(id, std::move(entry)); - } - addUnique(merged.mod_ids, modIdsSeen, resolved->mod_ids); - addUnique(merged.unavailable_ids, unavailableSeen, resolved->unavailable_ids); - addUnique(merged.incomplete_collection_ids, incompleteSeen, resolved->incomplete_collection_ids); - merged.is_complete = merged.is_complete && resolved->is_complete; +QCoro::Task> CatalogClient::resolve( + QStringList ids, QStringList collectionIds) { + // One request covers up to 50 mods and 10 collections; the longer list sets + // the number of requests. + const qsizetype batches = + std::max({(ids.size() + kModIdsPerResolve - 1) / kModIdsPerResolve, + (collectionIds.size() + kCollectionIdsPerResolve - 1) / + kCollectionIdsPerResolve, + qsizetype{1}}); + ResolvedDownload merged; + merged.is_complete = true; + std::unordered_set rootsSeen; + std::unordered_set modIdsSeen; + std::unordered_set unavailableSeen; + std::unordered_set incompleteSeen; + for (qsizetype batch = 0; batch < batches; ++batch) { + ResolveBody body{ + toUtf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), + toUtf8(collectionIds.mid(batch * kCollectionIdsPerResolve, + kCollectionIdsPerResolve)), + }; + auto resolved = co_await call( + true, endpoint(QStringLiteral("resolve")), toJson(body)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + addUnique(merged.roots, rootsSeen, resolved->roots); + for (auto& [id, entry] : resolved->items) { + merged.items.insert_or_assign(id, std::move(entry)); } - co_return merged; + addUnique(merged.mod_ids, modIdsSeen, resolved->mod_ids); + addUnique(merged.unavailable_ids, unavailableSeen, + resolved->unavailable_ids); + addUnique(merged.incomplete_collection_ids, incompleteSeen, + resolved->incomplete_collection_ids); + merged.is_complete = merged.is_complete && resolved->is_complete; + } + co_return merged; } } // namespace px::modrim::workshop diff --git a/src/core/workshop/client.hpp b/src/core/workshop/client.hpp index c76b411..992609d 100644 --- a/src/core/workshop/client.hpp +++ b/src/core/workshop/client.hpp @@ -1,66 +1,72 @@ #pragma once -#include -#include -#include - -#include #include #include #include #include #include #include +#include +#include +#include + +#include #include "core/workshop/types.hpp" namespace px::modrim::workshop { -// Trims whitespace and trailing slashes, so "https://x/" and "https://x" name the same catalog. +// Trims whitespace and trailing slashes, so "https://x/" and "https://x" name +// the same catalog. QString normalizeBaseUrl(QString url); -// HTTP client for the Workshop catalog service. An empty base URL disables the catalog: every call -// fails with a CatalogError and nothing is sent. +// HTTP client for the Workshop catalog service. An empty base URL disables the +// catalog: every call fails with a CatalogError and nothing is sent. class CatalogClient { -public: - struct BatchResult { - std::vector items; - std::vector unavailableIds; - }; + public: + struct BatchResult { + std::vector items; + std::vector unavailableIds; + }; - explicit CatalogClient(QString baseUrl); - CatalogClient(const CatalogClient&) = delete; - CatalogClient& operator=(const CatalogClient&) = delete; + explicit CatalogClient(QString baseUrl); + CatalogClient(const CatalogClient&) = delete; + CatalogClient& operator=(const CatalogClient&) = delete; - // Aborts requests still in flight; each one completes with a CatalogError. - void shutdown(); + // Aborts requests still in flight; each one completes with a CatalogError. + void shutdown(); - QCoro::Task> discover(); - QCoro::Task>> mods(CatalogQuery query); - QCoro::Task>> collections(CatalogQuery query); - // nullopt when the catalog does not know the mod (HTTP 404). - QCoro::Task>> mod(QString id); - // nullopt when the catalog does not know the collection (HTTP 404). - QCoro::Task>> collection(QString id); - QCoro::Task> modsBatch(QStringList ids); - QCoro::Task> resolve(QStringList ids, QStringList collectionIds); + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections( + CatalogQuery query); + // nullopt when the catalog does not know the mod (HTTP 404). + QCoro::Task>> mod(QString id); + // nullopt when the catalog does not know the collection (HTTP 404). + QCoro::Task>> collection( + QString id); + QCoro::Task> modsBatch(QStringList ids); + QCoro::Task> resolve( + QStringList ids, QStringList collectionIds); -private: - struct Reply { - int status = 0; - std::string body; - }; + private: + struct Reply { + int status = 0; + std::string body; + }; - template - QCoro::Task> call(bool post, QUrl url, std::string payload = {}); + template + QCoro::Task> call(bool post, QUrl url, + std::string payload = {}); - QCoro::Task> send(bool post, QUrl url, std::string payload); - QUrl endpoint(const QString& path, const QUrlQuery& query = {}) const; + QCoro::Task> send(bool post, QUrl url, + std::string payload); + QUrl endpoint(const QString& path, const QUrlQuery& query = {}) const; - QString m_baseUrl; - QNetworkAccessManager m_network; - // Requests in flight, aborted by shutdown(). - QSet m_pending; + QString m_baseUrl; + QNetworkAccessManager m_network; + // Requests in flight, aborted by shutdown(). + QSet m_pending; }; } // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp index 647422e..7d59656 100644 --- a/src/core/workshop/favourites.cpp +++ b/src/core/workshop/favourites.cpp @@ -7,54 +7,55 @@ namespace px::modrim::workshop { FavouriteCollections::FavouriteCollections(ConfigService* service) : m_config(service, QStringLiteral("workshop_favourites")) { - for (const CatalogCollection& collection : m_config.value().collections) { - m_ids.insert(collection.id); - } + for (const CatalogCollection& collection : m_config.value().collections) { + m_ids.insert(collection.id); + } } bool FavouriteCollections::contains(const std::string& collectionId) const { - return m_ids.contains(collectionId); + return m_ids.contains(collectionId); } -void FavouriteCollections::set(const CatalogCollection& collection, bool favourite) { - if (contains(collection.id) == favourite) { - return; - } - std::vector rest; - for (const CatalogCollection& stored : m_config.value().collections) { - if (stored.id != collection.id) { - rest.push_back(stored); - } - } - if (favourite) { - rest.insert(rest.begin(), collection); - } - save(std::move(rest)); +void FavouriteCollections::set(const CatalogCollection& collection, + bool favourite) { + if (contains(collection.id) == favourite) { + return; + } + std::vector rest; + for (const CatalogCollection& stored : m_config.value().collections) { + if (stored.id != collection.id) { + rest.push_back(stored); + } + } + if (favourite) { + rest.insert(rest.begin(), collection); + } + save(std::move(rest)); } void FavouriteCollections::remember(const CatalogCollection& collection) { - if (!contains(collection.id)) { - return; - } - const auto& stored = m_config.value().collections; - if (std::ranges::find(stored, collection) != stored.end()) { - return; - } - std::vector updated = stored; - for (CatalogCollection& entry : updated) { - if (entry.id == collection.id) { - entry = collection; - } - } - save(std::move(updated)); + if (!contains(collection.id)) { + return; + } + const auto& stored = m_config.value().collections; + if (std::ranges::find(stored, collection) != stored.end()) { + return; + } + std::vector updated = stored; + for (CatalogCollection& entry : updated) { + if (entry.id == collection.id) { + entry = collection; + } + } + save(std::move(updated)); } void FavouriteCollections::save(std::vector collections) { - m_ids.clear(); - for (const CatalogCollection& collection : collections) { - m_ids.insert(collection.id); - } - m_config.update(FavouritesState{std::move(collections)}); + m_ids.clear(); + for (const CatalogCollection& collection : collections) { + m_ids.insert(collection.id); + } + m_config.update(FavouritesState{std::move(collections)}); } } // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.hpp b/src/core/workshop/favourites.hpp index fc4954f..7fd0041 100644 --- a/src/core/workshop/favourites.hpp +++ b/src/core/workshop/favourites.hpp @@ -12,34 +12,39 @@ namespace px::modrim::workshop { // The persisted form, plugins/workshop_favourites.json. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct FavouritesState { - std::vector collections; + std::vector collections; - bool operator==(const FavouritesState&) const = default; + bool operator==(const FavouritesState&) const = default; }; // NOLINTEND(readability-identifier-naming) -// Collections the user starred, newest first. Each entry is the last catalog snapshot of the collection, -// so the list shows without a network round trip and survives the catalog dropping it. +// Collections the user starred, newest first. Each entry is the last catalog +// snapshot of the collection, so the list shows without a network round trip +// and survives the catalog dropping it. class FavouriteCollections { -public: - // Without a service the favourites live in memory only. - explicit FavouriteCollections(ConfigService* service); - - Event& changed() noexcept { return m_config.changed; } - const std::vector& items() const noexcept { return m_config.value().collections; } - bool contains(const std::string& collectionId) const; - - void set(const CatalogCollection& collection, bool favourite); - // Refreshes the stored snapshot of a starred collection; other collections are ignored. - void remember(const CatalogCollection& collection); - -private: - void save(std::vector collections); - - PluginConfig m_config; - std::unordered_set m_ids; + public: + // Without a service the favourites live in memory only. + explicit FavouriteCollections(ConfigService* service); + + Event& changed() noexcept { return m_config.changed; } + const std::vector& items() const noexcept { + return m_config.value().collections; + } + bool contains(const std::string& collectionId) const; + + void set(const CatalogCollection& collection, bool favourite); + // Refreshes the stored snapshot of a starred collection; other collections + // are ignored. + void remember(const CatalogCollection& collection); + + private: + void save(std::vector collections); + + PluginConfig m_config; + std::unordered_set m_ids; }; } // namespace px::modrim::workshop diff --git a/src/core/workshop/types.hpp b/src/core/workshop/types.hpp index 21ee686..9a41748 100644 --- a/src/core/workshop/types.hpp +++ b/src/core/workshop/types.hpp @@ -1,29 +1,31 @@ #pragma once +#include +#include +#include #include +#include #include #include -#include #include #include #include #include -#include -#include -#include namespace px::modrim::workshop { -// Catalog responses are strict: every key is present (nullable ones may be null), unknown keys are -// ignored and a value of the wrong type fails. Writing emits nulls so payloads round-trip exactly. +// Catalog responses are strict: every key is present (nullable ones may be +// null), unknown keys are ignored and a value of the wrong type fails. Writing +// emits nulls so payloads round-trip exactly. inline constexpr glz::opts kWireOpts{ .error_on_unknown_keys = false, .skip_null_members = false, .error_on_missing_keys = true, }; -// Literal-typed wire fields are enums so that a value outside the catalog's contract fails to decode. +// Literal-typed wire fields are enums so that a value outside the catalog's +// contract fails to decode. enum class PreviewType { kImage, kVideo }; enum class ModSource { kSteam }; enum class ModDescriptionFormat { kBbcode }; @@ -32,170 +34,184 @@ enum class CollectionSource { kSteam, kPicked }; enum class CollectionDescriptionFormat { kBbcode, kText }; enum class CollectionKind { kCollection }; -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct Author { - std::optional id; - std::optional name; - std::optional profile_url; + std::optional id; + std::optional name; + std::optional profile_url; - bool operator==(const Author&) const = default; + bool operator==(const Author&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct Preview { - PreviewType type = PreviewType::kImage; - std::string url; + PreviewType type = PreviewType::kImage; + std::string url; - bool operator==(const Preview&) const = default; + bool operator==(const Preview&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct Votes { - std::int64_t up = 0; - std::int64_t down = 0; - std::optional positive_percent; + std::int64_t up = 0; + std::int64_t down = 0; + std::optional positive_percent; - bool operator==(const Votes&) const = default; + bool operator==(const Votes&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CatalogMod { - std::string id; - ModSource source = ModSource::kSteam; - std::string title; - Author author; - std::string description; - ModDescriptionFormat description_format = ModDescriptionFormat::kBbcode; - std::optional preview_url; - std::vector previews; - std::string workshop_url; - std::vector tags; - std::vector supported_versions; - std::optional created_at; - std::optional updated_at; - std::optional file_size; - std::optional subscriptions; - std::optional votes; - std::vector dependencies; - std::optional package_id; - bool incompatible = false; - // The wire tag: decoding a collection payload as a mod fails here. - ModKind kind = ModKind::kMod; - - bool operator==(const CatalogMod&) const = default; + std::string id; + ModSource source = ModSource::kSteam; + std::string title; + Author author; + std::string description; + ModDescriptionFormat description_format = ModDescriptionFormat::kBbcode; + std::optional preview_url; + std::vector previews; + std::string workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::optional file_size; + std::optional subscriptions; + std::optional votes; + std::vector dependencies; + std::optional package_id; + bool incompatible = false; + // The wire tag: decoding a collection payload as a mod fails here. + ModKind kind = ModKind::kMod; + + bool operator==(const CatalogMod&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CatalogCollection { - std::string id; - CollectionSource source = CollectionSource::kSteam; - std::optional steam_id; - std::string title; - Author author; - std::string description; - CollectionDescriptionFormat description_format = CollectionDescriptionFormat::kText; - std::optional preview_url; - std::vector previews; - std::optional workshop_url; - std::vector tags; - std::vector supported_versions; - std::optional created_at; - std::optional updated_at; - std::vector member_ids; - std::int64_t member_count = 0; - std::vector member_previews; - std::optional total_size; - bool no_common_version = false; - // The wire tag: decoding a mod payload as a collection fails here. - CollectionKind kind = CollectionKind::kCollection; - - bool operator==(const CatalogCollection&) const = default; + std::string id; + CollectionSource source = CollectionSource::kSteam; + std::optional steam_id; + std::string title; + Author author; + std::string description; + CollectionDescriptionFormat description_format = + CollectionDescriptionFormat::kText; + std::optional preview_url; + std::vector previews; + std::optional workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::vector member_ids; + std::int64_t member_count = 0; + std::vector member_previews; + std::optional total_size; + bool no_common_version = false; + // The wire tag: decoding a mod payload as a collection fails here. + CollectionKind kind = CollectionKind::kCollection; + + bool operator==(const CatalogCollection&) const = default; }; // NOLINTEND(readability-identifier-naming) // A mod or a collection, told apart by the wire `kind` tag. using CatalogEntry = std::variant; -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire template struct CatalogPage { - std::vector items; - std::int64_t total = 0; - std::optional next_cursor; + std::vector items; + std::int64_t total = 0; + std::optional next_cursor; - bool operator==(const CatalogPage&) const = default; + bool operator==(const CatalogPage&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CollectionDetail { - CatalogCollection collection; - std::vector members; - std::vector unavailable_ids; - bool is_complete = false; + CatalogCollection collection; + std::vector members; + std::vector unavailable_ids; + bool is_complete = false; - bool operator==(const CollectionDetail&) const = default; + bool operator==(const CollectionDetail&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct Discover { - CatalogPage mods; - CatalogPage collections; + CatalogPage mods; + CatalogPage collections; - bool operator==(const Discover&) const = default; + bool operator==(const Discover&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct ResolvedDownload { - std::vector roots; - std::map items; - std::vector mod_ids; - std::vector unavailable_ids; - std::vector incomplete_collection_ids; - bool is_complete = false; - - bool operator==(const ResolvedDownload&) const = default; + std::vector roots; + std::map items; + std::vector mod_ids; + std::vector unavailable_ids; + std::vector incomplete_collection_ids; + bool is_complete = false; + + bool operator==(const ResolvedDownload&) const = default; }; // NOLINTEND(readability-identifier-naming) -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CatalogQuery { - QString query; - std::optional tag; - std::optional version; - QString sort = QStringLiteral("popular"); - int limit = 24; - std::optional cursor; - QString source = QStringLiteral("all"); + QString query; + std::optional tag; + std::optional version; + QString sort = QStringLiteral("popular"); + int limit = 24; + std::optional cursor; + QString source = QStringLiteral("all"); }; // NOLINTEND(readability-identifier-naming) enum class InstallState { kMissing, kInstalled, kOutdated }; -// What a download would fetch, in dependency order. Built on the client side, so it uses Qt types. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// What a download would fetch, in dependency order. Built on the client side, +// so it uses Qt types. NOLINTBEGIN(readability-identifier-naming): fields are +// JSON keys on disk or on the wire struct DownloadPlan { - QStringList to_download{}; - QStringList already_current{}; - QStringList unavailable_ids{}; - QStringList incomplete_collection_ids{}; - QHash titles{}; - bool is_complete = false; + QStringList to_download{}; + QStringList already_current{}; + QStringList unavailable_ids{}; + QStringList incomplete_collection_ids{}; + QHash titles{}; + bool is_complete = false; }; // NOLINTEND(readability-identifier-naming) -// Failure of a catalog request. `status` is the HTTP status when the server answered, empty when the -// request never got a response (disabled catalog, unreachable host, undecodable payload). -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// Failure of a catalog request. `status` is the HTTP status when the server +// answered, empty when the request never got a response (disabled catalog, +// unreachable host, undecodable payload). +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct CatalogError { - QString message; - std::optional status; + QString message; + std::optional status; }; // NOLINTEND(readability-identifier-naming) @@ -204,48 +220,52 @@ using CatalogResult = std::expected; } // namespace px::modrim::workshop -// The JSON strings are part of the catalog API, so they are spelled out instead of derived from -// the enumerator names. -// NOLINTBEGIN(readability-identifier-naming): glaze looks the member up by this exact name +// The JSON strings are part of the catalog API, so they are spelled out instead +// of derived from the enumerator names. +// NOLINTBEGIN(readability-identifier-naming): glaze looks the member up by this +// exact name template <> struct glz::meta { - using enum px::modrim::workshop::PreviewType; - static constexpr auto value = glz::enumerate("image", kImage, "video", kVideo); + using enum px::modrim::workshop::PreviewType; + static constexpr auto value = + glz::enumerate("image", kImage, "video", kVideo); }; template <> struct glz::meta { - using enum px::modrim::workshop::ModSource; - static constexpr auto value = glz::enumerate("steam", kSteam); + using enum px::modrim::workshop::ModSource; + static constexpr auto value = glz::enumerate("steam", kSteam); }; template <> struct glz::meta { - using enum px::modrim::workshop::ModDescriptionFormat; - static constexpr auto value = glz::enumerate("bbcode", kBbcode); + using enum px::modrim::workshop::ModDescriptionFormat; + static constexpr auto value = glz::enumerate("bbcode", kBbcode); }; template <> struct glz::meta { - using enum px::modrim::workshop::ModKind; - static constexpr auto value = glz::enumerate("mod", kMod); + using enum px::modrim::workshop::ModKind; + static constexpr auto value = glz::enumerate("mod", kMod); }; template <> struct glz::meta { - using enum px::modrim::workshop::CollectionSource; - static constexpr auto value = glz::enumerate("steam", kSteam, "picked", kPicked); + using enum px::modrim::workshop::CollectionSource; + static constexpr auto value = + glz::enumerate("steam", kSteam, "picked", kPicked); }; template <> struct glz::meta { - using enum px::modrim::workshop::CollectionDescriptionFormat; - static constexpr auto value = glz::enumerate("bbcode", kBbcode, "text", kText); + using enum px::modrim::workshop::CollectionDescriptionFormat; + static constexpr auto value = + glz::enumerate("bbcode", kBbcode, "text", kText); }; template <> struct glz::meta { - using enum px::modrim::workshop::CollectionKind; - static constexpr auto value = glz::enumerate("collection", kCollection); + using enum px::modrim::workshop::CollectionKind; + static constexpr auto value = glz::enumerate("collection", kCollection); }; // NOLINTEND(readability-identifier-naming) diff --git a/src/core/xml.cpp b/src/core/xml.cpp index d69c066..f828197 100644 --- a/src/core/xml.cpp +++ b/src/core/xml.cpp @@ -2,7 +2,6 @@ #include #include - #include #include #include @@ -12,195 +11,206 @@ namespace px::modrim { namespace { glz::json_t jsonString(std::string value) { - glz::json_t out; - out.data = std::move(value); - return out; + glz::json_t out; + out.data = std::move(value); + return out; } QString scalarToString(const glz::json_t& value) { - return std::visit( - [](const auto& v) -> QString { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return QString::fromStdString(v); - } else if constexpr (std::is_same_v) { - // Python's str(True) spelling, matching the legacy writer. - return v ? QStringLiteral("True") : QStringLiteral("False"); - } else if constexpr (std::is_same_v) { - return QString::number(v, 'g', 17); - } else { - return {}; - } - }, - value.data); + return std::visit( + [](const auto& v) -> QString { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return QString::fromStdString(v); + } else if constexpr (std::is_same_v) { + // Python's str(True) spelling, matching the legacy writer. + return v ? QStringLiteral("True") : QStringLiteral("False"); + } else if constexpr (std::is_same_v) { + return QString::number(v, 'g', 17); + } else { + return {}; + } + }, + value.data); } // The value part of `{tag: value}` for one element. glz::json_t elementValue(pugi::xml_node element) { - glz::json_t::object_t container; - bool hasChildren = false; - for (pugi::xml_node child = element.first_child(); child; child = child.next_sibling()) { - if (child.type() != pugi::node_element) { - continue; - } - hasChildren = true; - const std::string key = child.name(); - glz::json_t value = elementValue(child); - auto existing = container.find(key); - if (existing == container.end()) { - container.emplace(key, std::move(value)); - continue; - } - if (auto* items = std::get_if(&existing->second.data)) { - items->push_back(std::move(value)); - } else { - glz::json_t::array_t merged; - merged.push_back(std::move(existing->second)); - merged.push_back(std::move(value)); - existing->second.data = std::move(merged); - } + glz::json_t::object_t container; + bool hasChildren = false; + for (pugi::xml_node child = element.first_child(); child; + child = child.next_sibling()) { + if (child.type() != pugi::node_element) { + continue; } - - bool hasAttributes = false; - for (pugi::xml_attribute attribute = element.first_attribute(); attribute; - attribute = attribute.next_attribute()) { - hasAttributes = true; - container["@" + std::string(attribute.name())] = jsonString(attribute.value()); + hasChildren = true; + const std::string key = child.name(); + glz::json_t value = elementValue(child); + auto existing = container.find(key); + if (existing == container.end()) { + container.emplace(key, std::move(value)); + continue; } - - // lxml's `if t.text:` is a truthiness check on the raw text; the stored value is stripped. - const QString text = rawText(element); - const QString trimmed = text.trimmed(); - - glz::json_t result; - if (hasChildren || hasAttributes) { - if (!trimmed.isEmpty()) { - container["#text"] = jsonString(trimmed.toStdString()); - } - result.data = std::move(container); - } else if (!text.isEmpty()) { - result = jsonString(trimmed.toStdString()); + if (auto* items = + std::get_if(&existing->second.data)) { + items->push_back(std::move(value)); } else { - result.data = std::move(container); + glz::json_t::array_t merged; + merged.push_back(std::move(existing->second)); + merged.push_back(std::move(value)); + existing->second.data = std::move(merged); } - return result; + } + + bool hasAttributes = false; + for (pugi::xml_attribute attribute = element.first_attribute(); attribute; + attribute = attribute.next_attribute()) { + hasAttributes = true; + container["@" + std::string(attribute.name())] = + jsonString(attribute.value()); + } + + // lxml's `if t.text:` is a truthiness check on the raw text; the stored value + // is stripped. + const QString text = rawText(element); + const QString trimmed = text.trimmed(); + + glz::json_t result; + if (hasChildren || hasAttributes) { + if (!trimmed.isEmpty()) { + container["#text"] = jsonString(trimmed.toStdString()); + } + result.data = std::move(container); + } else if (!text.isEmpty()) { + result = jsonString(trimmed.toStdString()); + } else { + result.data = std::move(container); + } + return result; } -// Fills `element` from a JSON value: objects describe children/attributes/text, arrays become -// anonymous `li` children, and scalars become the element's text. +// Fills `element` from a JSON value: objects describe children/attributes/text, +// arrays become anonymous `li` children, and scalars become the element's text. Result fillElement(pugi::xml_node element, const glz::json_t& value) { - if (const auto* object = std::get_if(&value.data)) { - for (const auto& [key, child] : *object) { - if (key.starts_with('@')) { - const std::string name = key.substr(1); - pugi::xml_attribute attribute = element.attribute(name.c_str()); - if (!attribute) { - attribute = element.append_attribute(name.c_str()); - } - attribute.set_value(scalarToString(child).toUtf8().constData()); - } else if (key == "#text") { - element.text().set(scalarToString(child).toUtf8().constData()); - } else if (const auto* items = std::get_if(&child.data)) { - for (const glz::json_t& item : *items) { - pugi::xml_node repeated = element.append_child(key.c_str()); - if (auto filled = fillElement(repeated, item); !filled) { - return filled; - } - } - } else { - pugi::xml_node single = element.append_child(key.c_str()); - if (auto filled = fillElement(single, child); !filled) { - return filled; - } - } + if (const auto* object = std::get_if(&value.data)) { + for (const auto& [key, child] : *object) { + if (key.starts_with('@')) { + const std::string name = key.substr(1); + pugi::xml_attribute attribute = element.attribute(name.c_str()); + if (!attribute) { + attribute = element.append_attribute(name.c_str()); } - return {}; - } - if (const auto* items = std::get_if(&value.data)) { + attribute.set_value(scalarToString(child).toUtf8().constData()); + } else if (key == "#text") { + element.text().set(scalarToString(child).toUtf8().constData()); + } else if (const auto* items = + std::get_if(&child.data)) { for (const glz::json_t& item : *items) { - pugi::xml_node li = element.append_child("li"); - if (auto filled = fillElement(li, item); !filled) { - return filled; - } + pugi::xml_node repeated = element.append_child(key.c_str()); + if (auto filled = fillElement(repeated, item); !filled) { + return filled; + } + } + } else { + pugi::xml_node single = element.append_child(key.c_str()); + if (auto filled = fillElement(single, child); !filled) { + return filled; } - return {}; + } } - element.text().set(scalarToString(value).toUtf8().constData()); return {}; + } + if (const auto* items = std::get_if(&value.data)) { + for (const glz::json_t& item : *items) { + pugi::xml_node li = element.append_child("li"); + if (auto filled = fillElement(li, item); !filled) { + return filled; + } + } + return {}; + } + element.text().set(scalarToString(value).toUtf8().constData()); + return {}; } } // namespace Result loadXmlFile(pugi::xml_document& doc, const QString& path) { - if (!QFileInfo::exists(path)) { - return std::unexpected(Error{QStringLiteral("XML file does not exist at: %1").arg(path)}); - } - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - return std::unexpected( - Error{QStringLiteral("Error parsing XML file %1: %2").arg(path, file.errorString())}); - } - const QByteArray bytes = file.readAll(); - - const pugi::xml_parse_result parsed = doc.load_buffer(bytes.constData(), bytes.size()); - if (!parsed) { - return std::unexpected(Error{QStringLiteral("Error parsing XML file %1: %2") - .arg(path, QString::fromUtf8(parsed.description()))}); - } - if (!doc.document_element()) { - return std::unexpected( - Error{QStringLiteral("Error parsing XML file %1: no root element").arg(path)}); - } - return {}; + if (!QFileInfo::exists(path)) { + return std::unexpected( + Error{QStringLiteral("XML file does not exist at: %1").arg(path)}); + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{QStringLiteral("Error parsing XML file %1: %2") + .arg(path, file.errorString())}); + } + const QByteArray bytes = file.readAll(); + + const pugi::xml_parse_result parsed = + doc.load_buffer(bytes.constData(), bytes.size()); + if (!parsed) { + return std::unexpected( + Error{QStringLiteral("Error parsing XML file %1: %2") + .arg(path, QString::fromUtf8(parsed.description()))}); + } + if (!doc.document_element()) { + return std::unexpected( + Error{QStringLiteral("Error parsing XML file %1: no root element") + .arg(path)}); + } + return {}; } QString rawText(pugi::xml_node node) { - QString text; - for (pugi::xml_node child = node.first_child(); child; child = child.next_sibling()) { - if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { - break; - } - text += QString::fromUtf8(child.value()); + QString text; + for (pugi::xml_node child = node.first_child(); child; + child = child.next_sibling()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { + break; } - return text; + text += QString::fromUtf8(child.value()); + } + return text; } std::optional childText(pugi::xml_node parent, const char* tag) { - const pugi::xml_node found = parent.child(tag); - if (!found) { - return std::nullopt; - } - const QString text = rawText(found).trimmed(); - if (text.isEmpty()) { - return std::nullopt; - } - return text; + const pugi::xml_node found = parent.child(tag); + if (!found) { + return std::nullopt; + } + const QString text = rawText(found).trimmed(); + if (text.isEmpty()) { + return std::nullopt; + } + return text; } glz::json_t elementToJson(pugi::xml_node element) { - glz::json_t::object_t wrapped; - wrapped.emplace(std::string(element.name()), elementValue(element)); - glz::json_t out; - out.data = std::move(wrapped); - return out; + glz::json_t::object_t wrapped; + wrapped.emplace(std::string(element.name()), elementValue(element)); + glz::json_t out; + out.data = std::move(wrapped); + return out; } Result xmlFileToJson(const QString& path) { - pugi::xml_document doc; - if (auto loaded = loadXmlFile(doc, path); !loaded) { - return std::unexpected(loaded.error()); - } - return elementToJson(doc.document_element()); + pugi::xml_document doc; + if (auto loaded = loadXmlFile(doc, path); !loaded) { + return std::unexpected(loaded.error()); + } + return elementToJson(doc.document_element()); } Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data) { - const auto* object = std::get_if(&data.data); - if (object == nullptr || object->size() != 1) { - return std::unexpected(Error{QStringLiteral("Dictionary must have exactly one root key")}); - } - const auto& [rootKey, rootValue] = *object->begin(); - pugi::xml_node root = parent.append_child(rootKey.c_str()); - return fillElement(root, rootValue); + const auto* object = std::get_if(&data.data); + if (object == nullptr || object->size() != 1) { + return std::unexpected( + Error{QStringLiteral("Dictionary must have exactly one root key")}); + } + const auto& [rootKey, rootValue] = *object->begin(); + pugi::xml_node root = parent.append_child(rootKey.c_str()); + return fillElement(root, rootValue); } } // namespace px::modrim diff --git a/src/core/xml.hpp b/src/core/xml.hpp index f6ea6b3..9e72391 100644 --- a/src/core/xml.hpp +++ b/src/core/xml.hpp @@ -1,8 +1,8 @@ #pragma once +#include #include -#include #include #include @@ -10,25 +10,30 @@ namespace px::modrim { -// Loads and parses an XML file into `doc`. Fails if the file is missing, unreadable, malformed, -// or has no root element. +// Loads and parses an XML file into `doc`. Fails if the file is missing, +// unreadable, malformed, or has no root element. Result loadXmlFile(pugi::xml_document& doc, const QString& path); -// Text before the first child node of `node` (lxml's `.text`), unstripped; empty when absent. +// Text before the first child node of `node` (lxml's `.text`), unstripped; +// empty when absent. QString rawText(pugi::xml_node node); -// Stripped text of the first child element named `tag`, or nullopt when missing or blank. +// Stripped text of the first child element named `tag`, or nullopt when missing +// or blank. std::optional childText(pugi::xml_node parent, const char* tag); -// Converts an element into the legacy dict shape: `{tag: value}`. Repeated child tags become -// arrays, attributes become "@name" keys, and mixed text becomes "#text". +// Converts an element into the legacy dict shape: `{tag: value}`. Repeated +// child tags become arrays, attributes become "@name" keys, and mixed text +// becomes "#text". glz::json_t elementToJson(pugi::xml_node element); -// Parses an XML file and returns `{rootTag: value}` as produced by elementToJson. +// Parses an XML file and returns `{rootTag: value}` as produced by +// elementToJson. Result xmlFileToJson(const QString& path); -// Appends the one-key dict `{tag: value}` as an element under `parent` (the inverse of -// elementToJson). Fails unless `data` is an object with exactly one key. +// Appends the one-key dict `{tag: value}` as an element under `parent` (the +// inverse of elementToJson). Fails unless `data` is an object with exactly one +// key. Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data); } // namespace px::modrim diff --git a/src/ui/Header.qml b/src/ui/Header.qml index 9352c51..a3d8c6d 100644 --- a/src/ui/Header.qml +++ b/src/ui/Header.qml @@ -231,7 +231,7 @@ Rectangle { Row { Layout.alignment: Qt.AlignRight spacing: 6 - visible: root.controller.is_frameless + visible: root.controller.isFrameless PxButton { variant: "ghost" @@ -363,6 +363,6 @@ Rectangle { onPressed: root.controller.dragStarted() onDoubleClicked: root.controller.maximize() - cursorShape: root.controller.is_frameless ? Qt.OpenHandCursor : Qt.ArrowCursor + cursorShape: root.controller.isFrameless ? Qt.OpenHandCursor : Qt.ArrowCursor } } diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp index 00e6f9c..7a9713b 100644 --- a/src/ui/components/banner_widget.cpp +++ b/src/ui/components/banner_widget.cpp @@ -1,14 +1,13 @@ #include "ui/components/banner_widget.hpp" -#include - #include #include #include -#include #include +#include #include #include +#include #include "ui/theme/palette.hpp" @@ -19,117 +18,117 @@ namespace { constexpr int kFallbackWidth = 300; constexpr int kOverlayPad = 16; -// Floor division by two, so a negative offset rounds the way the legacy `//` did. +// Floor division by two, so a negative offset rounds the way the legacy `//` +// did. int floorHalf(int value) { - return value >= 0 ? value / 2 : -((-value + 1) / 2); + return value >= 0 ? value / 2 : -((-value + 1) / 2); } } // namespace AspectRatioBanner::AspectRatioBanner(QWidget* parent, int maxHeight) : QWidget(parent), m_maxHeight(maxHeight) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } void AspectRatioBanner::setPixmap(const QPixmap& pixmap) { - m_pixmap = pixmap; - recomputeScaledPixmap(); - updateGeometry(); - update(); + m_pixmap = pixmap; + recomputeScaledPixmap(); + updateGeometry(); + update(); } void AspectRatioBanner::setTitle(const QString& title) { - m_title = title; - update(); + m_title = title; + update(); } void AspectRatioBanner::setSubtitle(const QString& subtitle) { - m_subtitle = subtitle; - update(); + m_subtitle = subtitle; + update(); } void AspectRatioBanner::setShowOverlay(bool show) { - m_showOverlay = show; - update(); + m_showOverlay = show; + update(); } -bool AspectRatioBanner::hasHeightForWidth() const { - return true; -} +bool AspectRatioBanner::hasHeightForWidth() const { return true; } int AspectRatioBanner::heightForWidth(int w) const { - return std::min(w * kBannerAspectH / kBannerAspectW, m_maxHeight); + return std::min(w * kBannerAspectH / kBannerAspectW, m_maxHeight); } QSize AspectRatioBanner::sizeHint() const { - const int w = width() > 0 ? width() : kFallbackWidth; - return QSize(w, heightForWidth(w)); + const int w = width() > 0 ? width() : kFallbackWidth; + return QSize(w, heightForWidth(w)); } void AspectRatioBanner::recomputeScaledPixmap() { - if (m_pixmap.isNull() || width() <= 0) { - m_scaledPixmap = QPixmap(); - return; - } - - m_scaledPixmap = m_pixmap.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, - Qt::SmoothTransformation); + if (m_pixmap.isNull() || width() <= 0) { + m_scaledPixmap = QPixmap(); + return; + } + + m_scaledPixmap = + m_pixmap.scaled(width(), heightForWidth(width()), + Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); } void AspectRatioBanner::paintEvent(QPaintEvent*) { - QPainter painter(this); - painter.setRenderHint(QPainter::SmoothPixmapTransform); - painter.setClipRect(rect()); - - if (m_scaledPixmap.isNull()) { - painter.fillRect(rect(), panelBgQ()); - } else { - const int x = floorHalf(width() - m_scaledPixmap.width()); - const int y = floorHalf(height() - m_scaledPixmap.height()); - painter.drawPixmap(x, y, m_scaledPixmap); - } - - if (m_title.isEmpty() || !m_showOverlay) { - return; - } - - const int overlayTop = height() - kBannerOverlayHeight; - QLinearGradient gradient(0, overlayTop, 0, height()); - gradient.setColorAt(0.0, QColor(0, 0, 0, 0)); - gradient.setColorAt(1.0, QColor(0, 0, 0, 180)); - painter.fillRect(0, overlayTop, width(), kBannerOverlayHeight, gradient); - - const int textAreaTop = overlayTop + 12; - const int textAreaW = width() - kOverlayPad * 2; - - QFont font = painter.font(); - font.setPointSize(18); - font.setBold(true); + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + + if (m_scaledPixmap.isNull()) { + painter.fillRect(rect(), panelBgQ()); + } else { + const int x = floorHalf(width() - m_scaledPixmap.width()); + const int y = floorHalf(height() - m_scaledPixmap.height()); + painter.drawPixmap(x, y, m_scaledPixmap); + } + + if (m_title.isEmpty() || !m_showOverlay) { + return; + } + + const int overlayTop = height() - kBannerOverlayHeight; + QLinearGradient gradient(0, overlayTop, 0, height()); + gradient.setColorAt(0.0, QColor(0, 0, 0, 0)); + gradient.setColorAt(1.0, QColor(0, 0, 0, 180)); + painter.fillRect(0, overlayTop, width(), kBannerOverlayHeight, gradient); + + const int textAreaTop = overlayTop + 12; + const int textAreaW = width() - kOverlayPad * 2; + + QFont font = painter.font(); + font.setPointSize(18); + font.setBold(true); + painter.setFont(font); + painter.setPen(textMainQ()); + + int titleH = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) { + titleH = titleH * 3 / 5; + } + painter.drawText(kOverlayPad, textAreaTop, textAreaW, titleH, + Qt::AlignLeft | Qt::AlignBottom, m_title); + + if (!m_subtitle.isEmpty()) { + font.setPointSize(12); + font.setBold(false); painter.setFont(font); - painter.setPen(textMainQ()); - - int titleH = kBannerOverlayHeight - 24; - if (!m_subtitle.isEmpty()) { - titleH = titleH * 3 / 5; - } - painter.drawText(kOverlayPad, textAreaTop, textAreaW, titleH, - Qt::AlignLeft | Qt::AlignBottom, m_title); - - if (!m_subtitle.isEmpty()) { - font.setPointSize(12); - font.setBold(false); - painter.setFont(font); - painter.setPen(textMutedQ()); - const int subtitleTop = textAreaTop + titleH + 2; - const int subtitleH = kBannerOverlayHeight - titleH - 26; - painter.drawText(kOverlayPad, subtitleTop, textAreaW, subtitleH, - Qt::AlignLeft | Qt::AlignTop, m_subtitle); - } + painter.setPen(textMutedQ()); + const int subtitleTop = textAreaTop + titleH + 2; + const int subtitleH = kBannerOverlayHeight - titleH - 26; + painter.drawText(kOverlayPad, subtitleTop, textAreaW, subtitleH, + Qt::AlignLeft | Qt::AlignTop, m_subtitle); + } } void AspectRatioBanner::resizeEvent(QResizeEvent* event) { - recomputeScaledPixmap(); - QWidget::resizeEvent(event); + recomputeScaledPixmap(); + QWidget::resizeEvent(event); } } // namespace px::modrim diff --git a/src/ui/components/banner_widget.hpp b/src/ui/components/banner_widget.hpp index 092eb06..be9801a 100644 --- a/src/ui/components/banner_widget.hpp +++ b/src/ui/components/banner_widget.hpp @@ -11,36 +11,37 @@ class QResizeEvent; namespace px::modrim { -// Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it, with an optional -// title and subtitle over a gradient at the bottom. +// Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it, +// with an optional title and subtitle over a gradient at the bottom. class AspectRatioBanner : public QWidget { - Q_OBJECT - -public: - explicit AspectRatioBanner(QWidget* parent = nullptr, int maxHeight = kBannerMaxHeight); - - void setPixmap(const QPixmap& pixmap); - void setTitle(const QString& title); - void setSubtitle(const QString& subtitle); - void setShowOverlay(bool show); - - bool hasHeightForWidth() const override; - int heightForWidth(int width) const override; - QSize sizeHint() const override; - -protected: - void paintEvent(QPaintEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - -private: - void recomputeScaledPixmap(); - - QPixmap m_pixmap; - QPixmap m_scaledPixmap; - QString m_title; - QString m_subtitle; - int m_maxHeight; - bool m_showOverlay = true; + Q_OBJECT + + public: + explicit AspectRatioBanner(QWidget* parent = nullptr, + int maxHeight = kBannerMaxHeight); + + void setPixmap(const QPixmap& pixmap); + void setTitle(const QString& title); + void setSubtitle(const QString& subtitle); + void setShowOverlay(bool show); + + bool hasHeightForWidth() const override; + int heightForWidth(int width) const override; + QSize sizeHint() const override; + + protected: + void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + private: + void recomputeScaledPixmap(); + + QPixmap m_pixmap; + QPixmap m_scaledPixmap; + QString m_title; + QString m_subtitle; + int m_maxHeight; + bool m_showOverlay = true; }; } // namespace px::modrim diff --git a/src/ui/components/button.cpp b/src/ui/components/button.cpp index 3d30f9b..f2f628b 100644 --- a/src/ui/components/button.cpp +++ b/src/ui/components/button.cpp @@ -4,9 +4,10 @@ namespace px::modrim { -AppButton::AppButton(const QString& text, QWidget* parent) : QPushButton(text, parent) { - setObjectName(QStringLiteral("appBtn")); - setCursor(Qt::PointingHandCursor); +AppButton::AppButton(const QString& text, QWidget* parent) + : QPushButton(text, parent) { + setObjectName(QStringLiteral("appBtn")); + setCursor(Qt::PointingHandCursor); } } // namespace px::modrim diff --git a/src/ui/components/button.hpp b/src/ui/components/button.hpp index 68e7f0c..6cecaf4 100644 --- a/src/ui/components/button.hpp +++ b/src/ui/components/button.hpp @@ -7,10 +7,11 @@ class QWidget; namespace px::modrim { -// Push button in the application's standard look (QSS `appBtn`) with a pointing-hand cursor. +// Push button in the application's standard look (QSS `appBtn`) with a +// pointing-hand cursor. class AppButton : public QPushButton { -public: - explicit AppButton(const QString& text = {}, QWidget* parent = nullptr); + public: + explicit AppButton(const QString& text = {}, QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp index 8b4e182..b013a65 100644 --- a/src/ui/components/dialog_chrome.cpp +++ b/src/ui/components/dialog_chrome.cpp @@ -29,151 +29,161 @@ constexpr int kBorder = 1; constexpr int kGripSize = 16; constexpr int kCloseSize = 26; -// Every icon name used here is in the icon table; a miss is a programming error. +// Every icon name used here is in the icon table; a miss is a programming +// error. QIcon requireIcon(QStringView name, int size, QStringView color) { - auto found = icon(name, size, color); - Q_ASSERT(found.has_value()); - return found.value_or(QIcon()); + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); } // Soft-red close button that fills solid on hover, like the main window's. class CloseButton : public QPushButton { -public: - explicit CloseButton(QWidget* parent) : QPushButton(parent) { - setObjectName(QStringLiteral("dialogCloseBtn")); - setFixedSize(kCloseSize, kCloseSize); - // A global QPushButton min-height would otherwise beat setFixedSize. - constexpr int kInner = kCloseSize - 2; // the 1px QSS border sits outside the content box - setStyleSheet(QStringLiteral("min-width: %1px; max-width: %1px; min-height: %1px; max-height: %1px;") - .arg(kInner)); - setToolTip(QStringLiteral("Close")); - setCursor(Qt::PointingHandCursor); - setFocusPolicy(Qt::NoFocus); - m_normalIcon = requireIcon(u"close", 12, paletteValue(u"DANGER")); - m_hoverIcon = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); - setIcon(m_normalIcon); - } - -protected: - void enterEvent(QEnterEvent* event) override { - setIcon(m_hoverIcon); - QPushButton::enterEvent(event); - } - - void leaveEvent(QEvent* event) override { - setIcon(m_normalIcon); - QPushButton::leaveEvent(event); - } - -private: - QIcon m_normalIcon; - QIcon m_hoverIcon; + public: + explicit CloseButton(QWidget* parent) : QPushButton(parent) { + setObjectName(QStringLiteral("dialogCloseBtn")); + setFixedSize(kCloseSize, kCloseSize); + // A global QPushButton min-height would otherwise beat setFixedSize. + constexpr int kInner = + kCloseSize - 2; // the 1px QSS border sits outside the content box + setStyleSheet(QStringLiteral("min-width: %1px; max-width: %1px; " + "min-height: %1px; max-height: %1px;") + .arg(kInner)); + setToolTip(QStringLiteral("Close")); + setCursor(Qt::PointingHandCursor); + setFocusPolicy(Qt::NoFocus); + m_normalIcon = requireIcon(u"close", 12, paletteValue(u"DANGER")); + m_hoverIcon = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); + setIcon(m_normalIcon); + } + + protected: + void enterEvent(QEnterEvent* event) override { + setIcon(m_hoverIcon); + QPushButton::enterEvent(event); + } + + void leaveEvent(QEvent* event) override { + setIcon(m_normalIcon); + QPushButton::leaveEvent(event); + } + + private: + QIcon m_normalIcon; + QIcon m_hoverIcon; }; // Closes the dialog the way Escape does: a message box clicks its escape button // (or Cancel), anything else rejects. void closeLikeEscape(QDialog* dialog) { - if (auto* box = qobject_cast(dialog)) { - if (QAbstractButton* button = box->escapeButton()) { - button->click(); - return; - } - if (box->button(QMessageBox::Cancel) != nullptr) { - box->done(static_cast(QMessageBox::Cancel)); - return; - } + if (auto* box = qobject_cast(dialog)) { + if (QAbstractButton* button = box->escapeButton()) { + button->click(); + return; + } + if (box->button(QMessageBox::Cancel) != nullptr) { + box->done(static_cast(QMessageBox::Cancel)); + return; } - dialog->reject(); + } + dialog->reject(); } class TitleBar : public QWidget { -public: - TitleBar(QDialog* dialog, bool closable) : QWidget(dialog) { - setObjectName(QStringLiteral("dialogTitleBar")); - setAttribute(Qt::WA_StyledBackground, true); - auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(14, 0, 6, 0); - layout->setSpacing(6); - - auto* title = new QLabel(dialog->windowTitle(), this); - title->setObjectName(QStringLiteral("dialogTitleText")); - layout->addWidget(title, 1); - connect(dialog, &QWidget::windowTitleChanged, title, &QLabel::setText); - - if (closable) { - auto* close = new CloseButton(this); - connect(close, &QPushButton::clicked, dialog, [dialog] { closeLikeEscape(dialog); }); - layout->addWidget(close); - } + public: + TitleBar(QDialog* dialog, bool closable) : QWidget(dialog) { + setObjectName(QStringLiteral("dialogTitleBar")); + setAttribute(Qt::WA_StyledBackground, true); + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(14, 0, 6, 0); + layout->setSpacing(6); + + auto* title = new QLabel(dialog->windowTitle(), this); + title->setObjectName(QStringLiteral("dialogTitleText")); + layout->addWidget(title, 1); + connect(dialog, &QWidget::windowTitleChanged, title, &QLabel::setText); + + if (closable) { + auto* close = new CloseButton(this); + connect(close, &QPushButton::clicked, dialog, + [dialog] { closeLikeEscape(dialog); }); + layout->addWidget(close); } - -protected: - void mousePressEvent(QMouseEvent* event) override { - if (event->button() == Qt::LeftButton) { - if (QWindow* handle = window()->windowHandle()) { - handle->startSystemMove(); - event->accept(); - return; - } - } - QWidget::mousePressEvent(event); + } + + protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + if (QWindow* handle = window()->windowHandle()) { + handle->startSystemMove(); + event->accept(); + return; + } } + QWidget::mousePressEvent(event); + } }; // Keeps the overlay title bar and size grip pinned to the dialog frame. class Chrome : public QObject { -public: - Chrome(QDialog* dialog, bool closable, bool resizable) : QObject(dialog), m_dialog(dialog) { - m_bar = new TitleBar(dialog, closable); - m_grip = resizable ? new QSizeGrip(dialog) : nullptr; - dialog->installEventFilter(this); - place(); + public: + Chrome(QDialog* dialog, bool closable, bool resizable) + : QObject(dialog), m_dialog(dialog) { + m_bar = new TitleBar(dialog, closable); + m_grip = resizable ? new QSizeGrip(dialog) : nullptr; + dialog->installEventFilter(this); + place(); + } + + protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == m_dialog && + (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + place(); } - -protected: - bool eventFilter(QObject* watched, QEvent* event) override { - if (watched == m_dialog && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { - place(); - } - return false; - } - -private: - void place() { - const int width = m_dialog->width(); - m_bar->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); - m_bar->raise(); - if (m_grip) { - m_grip->setGeometry(width - kGripSize - kBorder, m_dialog->height() - kGripSize - kBorder, - kGripSize, kGripSize); - m_grip->raise(); - } + return false; + } + + private: + void place() { + const int width = m_dialog->width(); + m_bar->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); + m_bar->raise(); + if (m_grip) { + m_grip->setGeometry(width - kGripSize - kBorder, + m_dialog->height() - kGripSize - kBorder, kGripSize, + kGripSize); + m_grip->raise(); } + } - QDialog* m_dialog; - TitleBar* m_bar = nullptr; - QSizeGrip* m_grip = nullptr; + QDialog* m_dialog; + TitleBar* m_bar = nullptr; + QSizeGrip* m_grip = nullptr; }; } // namespace -void installDialogChrome(QDialog* dialog, bool closable, std::optional resizable) { - if (qobject_cast(dialog) || dialog->property("chromed").toBool()) { - return; - } - const bool grip = resizable.value_or( - !(qobject_cast(dialog) || dialog->isSizeGripEnabled())); - - const Qt::WindowFlags keep = dialog->windowFlags() & Qt::WindowStaysOnTopHint; - dialog->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | keep); - dialog->setProperty("chromed", true); - - if (QLayout* layout = dialog->layout()) { - const QMargins m = layout->contentsMargins(); - layout->setContentsMargins(m.left() + kBorder, m.top() + kTitleBarHeight + kBorder, - m.right() + kBorder, m.bottom() + kBorder); - } - new Chrome(dialog, closable, grip); +void installDialogChrome(QDialog* dialog, bool closable, + std::optional resizable) { + if (qobject_cast(dialog) || + dialog->property("chromed").toBool()) { + return; + } + const bool grip = resizable.value_or( + !(qobject_cast(dialog) || dialog->isSizeGripEnabled())); + + const Qt::WindowFlags keep = dialog->windowFlags() & Qt::WindowStaysOnTopHint; + dialog->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | keep); + dialog->setProperty("chromed", true); + + if (QLayout* layout = dialog->layout()) { + const QMargins m = layout->contentsMargins(); + layout->setContentsMargins(m.left() + kBorder, + m.top() + kTitleBarHeight + kBorder, + m.right() + kBorder, m.bottom() + kBorder); + } + new Chrome(dialog, closable, grip); } } // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.hpp b/src/ui/components/dialog_chrome.hpp index ceda2d9..0ea740b 100644 --- a/src/ui/components/dialog_chrome.hpp +++ b/src/ui/components/dialog_chrome.hpp @@ -6,10 +6,10 @@ class QDialog; namespace px::modrim { -// Gives `dialog` the frameless title-bar look of the main window. Call after the -// dialog's layout is built and before it is shown. Native file pickers keep the -// OS look. The title bar is an overlay, so the dialog's own layout only needs -// extra top margin, which is added here. +// Gives `dialog` the frameless title-bar look of the main window. Call after +// the dialog's layout is built and before it is shown. Native file pickers keep +// the OS look. The title bar is an overlay, so the dialog's own layout only +// needs extra top margin, which is added here. void installDialogChrome(QDialog* dialog, bool closable = true, std::optional resizable = std::nullopt); diff --git a/src/ui/components/dialogs.hpp b/src/ui/components/dialogs.hpp index 891a023..e424b5a 100644 --- a/src/ui/components/dialogs.hpp +++ b/src/ui/components/dialogs.hpp @@ -1,62 +1,68 @@ #pragma once -// Modal dialogs as coroutines: `co_await awaitDialog(args...)` shows the dialog and -// resumes with its result code and pointer. The dialog is scheduled for deletion, so -// read what you need from it before the next event-loop pass (the coroutine resumes -// synchronously from `finished`, so that holds for any code after the co_await). +// Modal dialogs as coroutines: `co_await awaitDialog(args...)` shows the +// dialog and resumes with its result code and pointer. The dialog is scheduled +// for deletion, so read what you need from it before the next event-loop pass +// (the coroutine resumes synchronously from `finished`, so that holds for any +// code after the co_await). +#include +#include #include #include #include #include -#include -#include #include "ui/components/dialog_chrome.hpp" namespace px::modrim { -// Resumes from inside `finished` itself. A queued resume could run after the dialog, and the -// window owning it, are gone; the coroutine must not outlive the emission. +// Resumes from inside `finished` itself. A queued resume could run after the +// dialog, and the window owning it, are gone; the coroutine must not outlive +// the emission. class DialogFinished { -public: - explicit DialogFinished(QDialog* dialog) - : m_connection(QObject::connect(dialog, &QDialog::finished, dialog, [this](int code) { + public: + explicit DialogFinished(QDialog* dialog) + : m_connection(QObject::connect( + dialog, &QDialog::finished, dialog, [this](int code) { m_code = code; m_finished = true; if (m_waiter) std::exchange(m_waiter, nullptr).resume(); - })) {} - DialogFinished(const DialogFinished&) = delete; - DialogFinished& operator=(const DialogFinished&) = delete; - ~DialogFinished() { QObject::disconnect(m_connection); } - - // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol - bool await_ready() const noexcept { return m_finished; } - void await_suspend(std::coroutine_handle<> waiting) noexcept { m_waiter = waiting; } - int await_resume() const noexcept { return m_code; } - // NOLINTEND(readability-identifier-naming) - -private: - QMetaObject::Connection m_connection; - std::coroutine_handle<> m_waiter; - int m_code = QDialog::Rejected; - bool m_finished = false; + })) {} + DialogFinished(const DialogFinished&) = delete; + DialogFinished& operator=(const DialogFinished&) = delete; + ~DialogFinished() { QObject::disconnect(m_connection); } + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return m_finished; } + void await_suspend(std::coroutine_handle<> waiting) noexcept { + m_waiter = waiting; + } + int await_resume() const noexcept { return m_code; } + // NOLINTEND(readability-identifier-naming) + + private: + QMetaObject::Connection m_connection; + std::coroutine_handle<> m_waiter; + int m_code = QDialog::Rejected; + bool m_finished = false; }; template T, class... Args> QCoro::Task> awaitDialog(Args&&... args) { - auto* dialog = new T(std::forward(args)...); - installDialogChrome(dialog); - dialog->setModal(true); + auto* dialog = new T(std::forward(args)...); + installDialogChrome(dialog); + dialog->setModal(true); - // Subscribe before show(), so a finish that happens immediately is not missed. - DialogFinished finished(dialog); - dialog->show(); - const int code = co_await finished; + // Subscribe before show(), so a finish that happens immediately is not + // missed. + DialogFinished finished(dialog); + dialog->show(); + const int code = co_await finished; - dialog->deleteLater(); - co_return std::pair{code, dialog}; + dialog->deleteLater(); + co_return std::pair{code, dialog}; } } // namespace px::modrim diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp index 8c02fa1..0f6dd60 100644 --- a/src/ui/components/filter_sidebar.cpp +++ b/src/ui/components/filter_sidebar.cpp @@ -1,21 +1,27 @@ #include "ui/components/filter_sidebar.hpp" -#include "ui/theme/palette.hpp" + #include #include #include + +#include "ui/theme/palette.hpp" namespace px::modrim { -FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent) +FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, + QWidget* parent) : QQuickWidget(engine, parent) { - setResizeMode(QQuickWidget::SizeRootObjectToView); - setClearColor(paletteColor(u"ELEVATE_2")); - rootContext()->setContextProperty(QStringLiteral("filterSidebarModel"), model); - setInitialProperties({{QStringLiteral("model"), QVariant::fromValue(model)}}); - setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/FilterSidebar.qml"))); - if (auto* root = rootObject()) connect(root, SIGNAL(activated(int)), this, SIGNAL(activated(int))); + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + rootContext()->setContextProperty(QStringLiteral("filterSidebarModel"), + model); + setInitialProperties({{QStringLiteral("model"), QVariant::fromValue(model)}}); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/FilterSidebar.qml"))); + if (auto* root = rootObject()) + connect(root, SIGNAL(activated(int)), this, SIGNAL(activated(int))); } int FilterSidebar::currentIndex() const { - const auto* root = rootObject(); - const auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; - return view ? view->property("currentIndex").toInt() : -1; -} + const auto* root = rootObject(); + const auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + return view ? view->property("currentIndex").toInt() : -1; } +} // namespace px::modrim diff --git a/src/ui/components/filter_sidebar.hpp b/src/ui/components/filter_sidebar.hpp index 6be4c13..b381794 100644 --- a/src/ui/components/filter_sidebar.hpp +++ b/src/ui/components/filter_sidebar.hpp @@ -3,11 +3,12 @@ class QAbstractItemModel; namespace px::modrim { class FilterSidebar : public QQuickWidget { - Q_OBJECT -public: - FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, QWidget* parent = nullptr); - int currentIndex() const; -signals: - void activated(int index); + Q_OBJECT + public: + FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, + QWidget* parent = nullptr); + int currentIndex() const; + signals: + void activated(int index); }; -} +} // namespace px::modrim diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp index 1396a43..88e8fdc 100644 --- a/src/ui/components/header_controller.cpp +++ b/src/ui/components/header_controller.cpp @@ -10,19 +10,20 @@ namespace px::modrim { namespace { QString strategyLabel(LaunchStrategy strategy) { - switch (strategy) { - case LaunchStrategy::kDirect: - return QStringLiteral("Direct executable"); - case LaunchStrategy::kSteam: - return QStringLiteral("Steam"); - } - return {}; + switch (strategy) { + case LaunchStrategy::kDirect: + return QStringLiteral("Direct executable"); + case LaunchStrategy::kSteam: + return QStringLiteral("Steam"); + } + return {}; } } // namespace -HeaderController::HeaderController(bool isFrameless, int initialStrategy, QString appVersion, - QVariantMap tooltips, bool downloadsAvailable, QObject* parent) +HeaderController::HeaderController(bool isFrameless, int initialStrategy, + QString appVersion, QVariantMap tooltips, + bool downloadsAvailable, QObject* parent) : QObject(parent), m_isFrameless(isFrameless), m_appVersion(std::move(appVersion)), @@ -31,61 +32,64 @@ HeaderController::HeaderController(bool isFrameless, int initialStrategy, QStrin m_strategyIndex(initialStrategy) {} QVariantList HeaderController::strategies() const { - QVariantList list; - for (const auto strategy : {LaunchStrategy::kDirect, LaunchStrategy::kSteam}) { - list.append(QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, - {QStringLiteral("label"), strategyLabel(strategy)}}); - } - return list; + QVariantList list; + for (const auto strategy : + {LaunchStrategy::kDirect, LaunchStrategy::kSteam}) { + list.append( + QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, + {QStringLiteral("label"), strategyLabel(strategy)}}); + } + return list; } void HeaderController::setMaximized(bool value) { - if (m_maximized != value) { - m_maximized = value; - emit maximizedChanged(); - } + if (m_maximized != value) { + m_maximized = value; + emit maximizedChanged(); + } } void HeaderController::setUnsavedChanges(bool value) { - if (m_unsavedChanges != value) { - m_unsavedChanges = value; - emit unsavedChangesChanged(); - } + if (m_unsavedChanges != value) { + m_unsavedChanges = value; + emit unsavedChangesChanged(); + } } void HeaderController::setLaunchState(const QString& state) { - if (m_launchState != state) { - m_launchState = state; - emit launchStateChanged(); - } + if (m_launchState != state) { + m_launchState = state; + emit launchStateChanged(); + } } void HeaderController::setTaskText(const QString& text) { - if (m_taskText != text) { - m_taskText = text; - emit taskTextChanged(); - } + if (m_taskText != text) { + m_taskText = text; + emit taskTextChanged(); + } } void HeaderController::setDownloadsBusy(bool busy) { - if (m_downloadsBusy != busy) { - m_downloadsBusy = busy; - emit downloadsBusyChanged(); - } + if (m_downloadsBusy != busy) { + m_downloadsBusy = busy; + emit downloadsBusyChanged(); + } } void HeaderController::setDownloadsProgressShown(bool shown) { - if (m_downloadsProgressShown != shown) { - m_downloadsProgressShown = shown; - emit downloadsProgressShownChanged(); - } + if (m_downloadsProgressShown != shown) { + m_downloadsProgressShown = shown; + emit downloadsProgressShownChanged(); + } } -void HeaderController::setDownloadsProgress(const QString& text, int value, int total) { - m_downloadsText = text; - m_downloadsValue = value; - m_downloadsTotal = total; - emit downloadsProgressChanged(); +void HeaderController::setDownloadsProgress(const QString& text, int value, + int total) { + m_downloadsText = text; + m_downloadsValue = value; + m_downloadsTotal = total; + emit downloadsProgressChanged(); } void HeaderController::refresh() { emit refreshRequested(); } @@ -107,10 +111,10 @@ void HeaderController::maximize() { emit maximizeRequested(); } void HeaderController::closeWindow() { emit closeRequested(); } void HeaderController::setStrategy(int index) { - if (m_strategyIndex != index) { - m_strategyIndex = index; - emit strategyChanged(index); - } + if (m_strategyIndex != index) { + m_strategyIndex = index; + emit strategyChanged(index); + } } void HeaderController::dragStarted() { emit dragStartRequested(); } diff --git a/src/ui/components/header_controller.hpp b/src/ui/components/header_controller.hpp index 69a39e4..ad99b1a 100644 --- a/src/ui/components/header_controller.hpp +++ b/src/ui/components/header_controller.hpp @@ -7,109 +7,120 @@ namespace px::modrim { -// State and commands behind Header.qml, exposed to it as `headerController`. The -// QML-facing names (properties and invokables) match the legacy HeaderController; -// the C++ side uses camelCase signals and setters. Owned by the caller. +// State and commands behind Header.qml, exposed to it as `headerController`. +// The QML-facing names (properties and invokables) match the legacy +// HeaderController; the C++ side uses camelCase signals and setters. Owned by +// the caller. class HeaderController : public QObject { - Q_OBJECT + Q_OBJECT - Q_PROPERTY(bool is_frameless READ isFrameless CONSTANT) - Q_PROPERTY(QString appVersion READ appVersion CONSTANT) - Q_PROPERTY(QVariantMap tooltips READ tooltips CONSTANT) - Q_PROPERTY(bool downloadsAvailable READ downloadsAvailable CONSTANT) - Q_PROPERTY(QVariantList strategies READ strategies CONSTANT) + Q_PROPERTY(bool isFrameless READ isFrameless CONSTANT) + Q_PROPERTY(QString appVersion READ appVersion CONSTANT) + Q_PROPERTY(QVariantMap tooltips READ tooltips CONSTANT) + Q_PROPERTY(bool downloadsAvailable READ downloadsAvailable CONSTANT) + Q_PROPERTY(QVariantList strategies READ strategies CONSTANT) - Q_PROPERTY(bool maximized READ maximized WRITE setMaximized NOTIFY maximizedChanged) - Q_PROPERTY(int strategyIndex READ strategyIndex NOTIFY strategyChanged) - Q_PROPERTY(bool unsavedChanges READ unsavedChanges WRITE setUnsavedChanges NOTIFY unsavedChangesChanged) - Q_PROPERTY(QString launchState READ launchState WRITE setLaunchState NOTIFY launchStateChanged) - Q_PROPERTY(QString taskText READ taskText NOTIFY taskTextChanged) - Q_PROPERTY(bool downloadsBusy READ downloadsBusy WRITE setDownloadsBusy NOTIFY downloadsBusyChanged) - Q_PROPERTY(bool downloadsProgressShown READ downloadsProgressShown NOTIFY downloadsProgressShownChanged) - Q_PROPERTY(QString downloadsText READ downloadsText NOTIFY downloadsProgressChanged) - Q_PROPERTY(int downloadsValue READ downloadsValue NOTIFY downloadsProgressChanged) - Q_PROPERTY(int downloadsTotal READ downloadsTotal NOTIFY downloadsProgressChanged) + Q_PROPERTY( + bool maximized READ maximized WRITE setMaximized NOTIFY maximizedChanged) + Q_PROPERTY(int strategyIndex READ strategyIndex NOTIFY strategyChanged) + Q_PROPERTY(bool unsavedChanges READ unsavedChanges WRITE setUnsavedChanges + NOTIFY unsavedChangesChanged) + Q_PROPERTY(QString launchState READ launchState WRITE setLaunchState NOTIFY + launchStateChanged) + Q_PROPERTY(QString taskText READ taskText NOTIFY taskTextChanged) + Q_PROPERTY(bool downloadsBusy READ downloadsBusy WRITE setDownloadsBusy NOTIFY + downloadsBusyChanged) + Q_PROPERTY(bool downloadsProgressShown READ downloadsProgressShown NOTIFY + downloadsProgressShownChanged) + Q_PROPERTY( + QString downloadsText READ downloadsText NOTIFY downloadsProgressChanged) + Q_PROPERTY( + int downloadsValue READ downloadsValue NOTIFY downloadsProgressChanged) + Q_PROPERTY( + int downloadsTotal READ downloadsTotal NOTIFY downloadsProgressChanged) -public: - HeaderController(bool isFrameless, int initialStrategy, QString appVersion, QVariantMap tooltips, - bool downloadsAvailable, QObject* parent = nullptr); + public: + HeaderController(bool isFrameless, int initialStrategy, QString appVersion, + QVariantMap tooltips, bool downloadsAvailable, + QObject* parent = nullptr); - bool isFrameless() const { return m_isFrameless; } - const QString& appVersion() const { return m_appVersion; } - const QVariantMap& tooltips() const { return m_tooltips; } - bool downloadsAvailable() const { return m_downloadsAvailable; } - // [{index, label}] for each launch strategy, in enum order. - QVariantList strategies() const; + bool isFrameless() const { return m_isFrameless; } + const QString& appVersion() const { return m_appVersion; } + const QVariantMap& tooltips() const { return m_tooltips; } + bool downloadsAvailable() const { return m_downloadsAvailable; } + // [{index, label}] for each launch strategy, in enum order. + QVariantList strategies() const; - bool maximized() const { return m_maximized; } - int strategyIndex() const { return m_strategyIndex; } - bool unsavedChanges() const { return m_unsavedChanges; } - const QString& launchState() const { return m_launchState; } - const QString& taskText() const { return m_taskText; } - bool downloadsBusy() const { return m_downloadsBusy; } - bool downloadsProgressShown() const { return m_downloadsProgressShown; } - const QString& downloadsText() const { return m_downloadsText; } - int downloadsValue() const { return m_downloadsValue; } - int downloadsTotal() const { return m_downloadsTotal; } + bool maximized() const { return m_maximized; } + int strategyIndex() const { return m_strategyIndex; } + bool unsavedChanges() const { return m_unsavedChanges; } + const QString& launchState() const { return m_launchState; } + const QString& taskText() const { return m_taskText; } + bool downloadsBusy() const { return m_downloadsBusy; } + bool downloadsProgressShown() const { return m_downloadsProgressShown; } + const QString& downloadsText() const { return m_downloadsText; } + int downloadsValue() const { return m_downloadsValue; } + int downloadsTotal() const { return m_downloadsTotal; } - void setMaximized(bool value); - void setUnsavedChanges(bool value); - void setLaunchState(const QString& state); - void setTaskText(const QString& text); - void setDownloadsBusy(bool busy); - void setDownloadsProgressShown(bool shown); - void setDownloadsProgress(const QString& text, int value, int total); + void setMaximized(bool value); + void setUnsavedChanges(bool value); + void setLaunchState(const QString& state); + void setTaskText(const QString& text); + void setDownloadsBusy(bool busy); + void setDownloadsProgressShown(bool shown); + void setDownloadsProgress(const QString& text, int value, int total); - Q_INVOKABLE void refresh(); - Q_INVOKABLE void autoSort(); - Q_INVOKABLE void openDownloads(); - Q_INVOKABLE void updateMods(); - Q_INVOKABLE void save(); - Q_INVOKABLE void launch(); - Q_INVOKABLE void minimize(); - Q_INVOKABLE void maximize(); - Q_INVOKABLE void closeWindow(); - Q_INVOKABLE void setStrategy(int index); - // Called by QML when a drag starts on the header background. Emits dragStartRequested. - Q_INVOKABLE void dragStarted(); + Q_INVOKABLE void refresh(); + Q_INVOKABLE void autoSort(); + Q_INVOKABLE void openDownloads(); + Q_INVOKABLE void updateMods(); + Q_INVOKABLE void save(); + Q_INVOKABLE void launch(); + Q_INVOKABLE void minimize(); + Q_INVOKABLE void maximize(); + Q_INVOKABLE void closeWindow(); + Q_INVOKABLE void setStrategy(int index); + // Called by QML when a drag starts on the header background. Emits + // dragStartRequested. + Q_INVOKABLE void dragStarted(); -signals: - void refreshRequested(); - void sortRequested(); - void saveRequested(); - void launchRequested(); - void strategyChanged(int index); - void minimizeRequested(); - void maximizeRequested(); - void closeRequested(); - void dragStartRequested(); - void updateModsRequested(); - void downloadsRequested(); + signals: + void refreshRequested(); + void sortRequested(); + void saveRequested(); + void launchRequested(); + void strategyChanged(int index); + void minimizeRequested(); + void maximizeRequested(); + void closeRequested(); + void dragStartRequested(); + void updateModsRequested(); + void downloadsRequested(); - void maximizedChanged(); - void unsavedChangesChanged(); - void launchStateChanged(); - void taskTextChanged(); - void downloadsBusyChanged(); - void downloadsProgressShownChanged(); - void downloadsProgressChanged(); + void maximizedChanged(); + void unsavedChangesChanged(); + void launchStateChanged(); + void taskTextChanged(); + void downloadsBusyChanged(); + void downloadsProgressShownChanged(); + void downloadsProgressChanged(); -private: - bool m_isFrameless; - QString m_appVersion; - QVariantMap m_tooltips; - bool m_downloadsAvailable; + private: + bool m_isFrameless; + QString m_appVersion; + QVariantMap m_tooltips; + bool m_downloadsAvailable; - bool m_maximized = false; - int m_strategyIndex; - bool m_unsavedChanges = false; - QString m_launchState = QStringLiteral("idle"); - QString m_taskText; - bool m_downloadsBusy = false; - bool m_downloadsProgressShown = true; - QString m_downloadsText; - int m_downloadsValue = 0; - int m_downloadsTotal = 0; + bool m_maximized = false; + int m_strategyIndex; + bool m_unsavedChanges = false; + QString m_launchState = QStringLiteral("idle"); + QString m_taskText; + bool m_downloadsBusy = false; + bool m_downloadsProgressShown = true; + QString m_downloadsText; + int m_downloadsValue = 0; + int m_downloadsTotal = 0; }; } // namespace px::modrim diff --git a/src/ui/components/header_panel.cpp b/src/ui/components/header_panel.cpp index a0eab48..1bf17e5 100644 --- a/src/ui/components/header_panel.cpp +++ b/src/ui/components/header_panel.cpp @@ -3,6 +3,7 @@ #include #include #include + #include #include "ui/components/header_controller.hpp" @@ -10,15 +11,17 @@ namespace px::modrim { -HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent) +HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, + QWidget* parent) : QQuickWidget(engine, parent) { - setResizeMode(QQuickWidget::SizeRootObjectToView); - setClearColor(paletteColor(u"ELEVATE_1")); - rootContext()->setContextProperty(QStringLiteral("headerController"), &controller); - setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Header.qml"))); - if (rootObject() == nullptr) { - spdlog::error("Header.qml failed to load: rootObject is null"); - } + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_1")); + rootContext()->setContextProperty(QStringLiteral("headerController"), + &controller); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Header.qml"))); + if (rootObject() == nullptr) { + spdlog::error("Header.qml failed to load: rootObject is null"); + } } } // namespace px::modrim diff --git a/src/ui/components/header_panel.hpp b/src/ui/components/header_panel.hpp index 8b812c1..ae3a7b9 100644 --- a/src/ui/components/header_panel.hpp +++ b/src/ui/components/header_panel.hpp @@ -9,11 +9,13 @@ namespace px::modrim { class HeaderController; -// Hosts Header.qml, the main window's top bar. The controller is injected and exposed -// to QML as `headerController`; the caller owns it and must outlive this panel. +// Hosts Header.qml, the main window's top bar. The controller is injected and +// exposed to QML as `headerController`; the caller owns it and must outlive +// this panel. class HeaderPanel : public QQuickWidget { -public: - HeaderPanel(HeaderController& controller, QQmlEngine* engine, QWidget* parent = nullptr); + public: + HeaderPanel(HeaderController& controller, QQmlEngine* engine, + QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp index b12cc47..cb78ec2 100644 --- a/src/ui/components/icon_button.cpp +++ b/src/ui/components/icon_button.cpp @@ -1,8 +1,7 @@ #include "ui/components/icon_button.hpp" -#include - #include +#include #include "ui/components/icons.hpp" #include "ui/theme/palette.hpp" @@ -11,36 +10,40 @@ namespace px::modrim { namespace { -// Every icon name used here is in the icon table; a miss is a programming error. +// Every icon name used here is in the icon table; a miss is a programming +// error. QIcon requireIcon(QStringView name, int size, QStringView color) { - auto found = icon(name, size, color); - Q_ASSERT(found.has_value()); - return found.value_or(QIcon()); + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); } } // namespace -IconButton::IconButton(const QString& iconName, const QString& tooltip, bool primary, int size, - QWidget* parent) - : QPushButton(parent), m_iconName(iconName), m_iconPx(std::max(16, size / 2)) { - setIconColor(paletteValue(u"TEXT_MUTED")); - setFixedSize(size, size); - setObjectName(QStringLiteral("iconBtn")); - // QSS min-height on QPushButton overrides setFixedSize; the 1px border sits outside the - // QSS content box, so subtract it. - const int inner = size - 2; - setStyleSheet(QStringLiteral("QPushButton#iconBtn { min-width: %1px; max-width: %1px;" - " min-height: %1px; max-height: %1px; }") - .arg(inner)); - if (primary) { - setProperty("primary", true); - } - setCursor(Qt::PointingHandCursor); - setToolTip(tooltip); +IconButton::IconButton(const QString& iconName, const QString& tooltip, + bool primary, int size, QWidget* parent) + : QPushButton(parent), + m_iconName(iconName), + m_iconPx(std::max(16, size / 2)) { + setIconColor(paletteValue(u"TEXT_MUTED")); + setFixedSize(size, size); + setObjectName(QStringLiteral("iconBtn")); + // QSS min-height on QPushButton overrides setFixedSize; the 1px border sits + // outside the QSS content box, so subtract it. + const int inner = size - 2; + setStyleSheet( + QStringLiteral("QPushButton#iconBtn { min-width: %1px; max-width: %1px;" + " min-height: %1px; max-height: %1px; }") + .arg(inner)); + if (primary) { + setProperty("primary", true); + } + setCursor(Qt::PointingHandCursor); + setToolTip(tooltip); } void IconButton::setIconColor(const QString& color) { - setIcon(requireIcon(m_iconName, m_iconPx, color)); + setIcon(requireIcon(m_iconName, m_iconPx, color)); } } // namespace px::modrim diff --git a/src/ui/components/icon_button.hpp b/src/ui/components/icon_button.hpp index 3513ab8..5a69a01 100644 --- a/src/ui/components/icon_button.hpp +++ b/src/ui/components/icon_button.hpp @@ -7,17 +7,18 @@ class QWidget; namespace px::modrim { -// Square icon-only button (QSS `iconBtn`). `primary` paints it in the accent color. +// Square icon-only button (QSS `iconBtn`). `primary` paints it in the accent +// color. class IconButton : public QPushButton { -public: - IconButton(const QString& iconName, const QString& tooltip = {}, bool primary = false, - int size = 32, QWidget* parent = nullptr); + public: + IconButton(const QString& iconName, const QString& tooltip = {}, + bool primary = false, int size = 32, QWidget* parent = nullptr); - void setIconColor(const QString& color); + void setIconColor(const QString& color); -private: - QString m_iconName; - int m_iconPx; + private: + QString m_iconName; + int m_iconPx; }; } // namespace px::modrim diff --git a/src/ui/components/icons.cpp b/src/ui/components/icons.cpp index de386fd..3263c13 100644 --- a/src/ui/components/icons.cpp +++ b/src/ui/components/icons.cpp @@ -1,84 +1,88 @@ #include "ui/components/icons.hpp" -#include - #include #include #include #include #include #include +#include namespace px::modrim { namespace { struct IconEntry { - QLatin1StringView name; - QLatin1StringView svg; + QLatin1StringView name; + QLatin1StringView svg; }; constexpr IconEntry kIcons[]{ #include "ui/components/icon_table.inc" }; -constexpr QLatin1StringView kSvgNamespace{"xmlns=\"http://www.w3.org/2000/svg\""}; +constexpr QLatin1StringView kSvgNamespace{ + "xmlns=\"http://www.w3.org/2000/svg\""}; const IconEntry* findIcon(QStringView name) { - for (const auto& entry : kIcons) { - if (QString(entry.name) == name) { - return &entry; - } + for (const auto& entry : kIcons) { + if (QString(entry.name) == name) { + return &entry; } - return nullptr; + } + return nullptr; } } // namespace std::optional iconSvg(QStringView name, QStringView color) { - const IconEntry* entry = findIcon(name); - if (!entry) { - return std::nullopt; - } - QString svg(entry->svg); - if (!svg.contains(QString(kSvgNamespace))) { - svg.replace(QStringLiteral("svg); + if (!svg.contains(QString(kSvgNamespace))) { + svg.replace( + QStringLiteral(" iconPixmap(QStringView name, int size, QStringView color) { - const auto svg = iconSvg(name, color); - if (!svg) { - return std::nullopt; - } - - const QScreen* screen = QGuiApplication::primaryScreen(); - const qreal scale = std::max(2.0, screen ? screen->devicePixelRatio() : 1.0); - const int pixels = qRound(size * scale); - - QPixmap pixmap(pixels, pixels); - pixmap.setDevicePixelRatio(scale); - pixmap.fill(Qt::transparent); - - QPainter painter(&pixmap); - painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform); - QSvgRenderer renderer(svg->toUtf8()); - if (renderer.isValid()) { - renderer.render(&painter, QRectF(0, 0, size, size)); - } - painter.end(); - return pixmap; +std::optional iconPixmap(QStringView name, int size, + QStringView color) { + const auto svg = iconSvg(name, color); + if (!svg) { + return std::nullopt; + } + + const QScreen* screen = QGuiApplication::primaryScreen(); + const qreal scale = + std::max(2.0, screen ? screen->devicePixelRatio() : 1.0); + const int pixels = qRound(size * scale); + + QPixmap pixmap(pixels, pixels); + pixmap.setDevicePixelRatio(scale); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHints(QPainter::Antialiasing | + QPainter::SmoothPixmapTransform); + QSvgRenderer renderer(svg->toUtf8()); + if (renderer.isValid()) { + renderer.render(&painter, QRectF(0, 0, size, size)); + } + painter.end(); + return pixmap; } std::optional icon(QStringView name, int size, QStringView color) { - auto pixmap = iconPixmap(name, size, color); - if (!pixmap) { - return std::nullopt; - } - return QIcon(*pixmap); + auto pixmap = iconPixmap(name, size, color); + if (!pixmap) { + return std::nullopt; + } + return QIcon(*pixmap); } } // namespace px::modrim diff --git a/src/ui/components/icons.hpp b/src/ui/components/icons.hpp index 8caebb1..ae284d3 100644 --- a/src/ui/components/icons.hpp +++ b/src/ui/components/icons.hpp @@ -1,11 +1,10 @@ #pragma once -#include - #include #include #include #include +#include namespace px::modrim { @@ -15,7 +14,8 @@ std::optional iconSvg(QStringView name, QStringView color); // Rasterised at >= 2x so small icons stay crisp on 1x screens; the pixmap's // device pixel ratio carries the scale. nullopt for an unknown name. -std::optional iconPixmap(QStringView name, int size, QStringView color); +std::optional iconPixmap(QStringView name, int size, + QStringView color); std::optional icon(QStringView name, int size, QStringView color); diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp index 3d21155..4cc2155 100644 --- a/src/ui/components/mod_activation.cpp +++ b/src/ui/components/mod_activation.cpp @@ -1,43 +1,60 @@ #include "ui/components/mod_activation.hpp" -#include "ui/components/dialogs.hpp" + +#include + #include "core/context.hpp" #include "core/services/activation_service.hpp" -#include +#include "ui/components/dialogs.hpp" namespace px::modrim { -DependentModsDialog::DependentModsDialog(const QStringList& dependents, QWidget* parent) : QMessageBox(parent) { - setIcon(QMessageBox::Warning); - setWindowTitle(QStringLiteral("Disable Dependent Mods")); - QStringList details; - for (const auto& dependent : dependents) details.append(QStringLiteral("• %1").arg(dependent)); - setText(QStringLiteral("These active mods depend on the mod you are disabling:\n\n") + details.join('\n')); - setInformativeText(QStringLiteral("Choose whether to disable only the selected mod or these dependents too.")); - setStandardButtons(QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); - setDefaultButton(QMessageBox::No); - button(QMessageBox::Yes)->setText(QStringLiteral("Disable selected + dependents")); - button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); - button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +DependentModsDialog::DependentModsDialog(const QStringList& dependents, + QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Disable Dependent Mods")); + QStringList details; + for (const auto& dependent : dependents) + details.append(QStringLiteral("• %1").arg(dependent)); + setText(QStringLiteral( + "These active mods depend on the mod you are disabling:\n\n") + + details.join('\n')); + setInformativeText( + QStringLiteral("Choose whether to disable only the selected mod or these " + "dependents too.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); + setDefaultButton(QMessageBox::No); + button(QMessageBox::Yes) + ->setText(QStringLiteral("Disable selected + dependents")); + button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); } -QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, const QStringList& enable, const QStringList& disable) { - // Copy before suspension: callers often pass temporary batches. - const QStringList enabling = enable; - QStringList disabling = disable; - const auto dependents = ctx.activation().dependentsOf(disabling, enabling); - if (!dependents.isEmpty()) { - QStringList details; - for (const auto& uuid : dependents) { - const auto& mod = ctx.allMods().value(uuid); - const auto* about = dynamic_cast(mod.get()); - details.append(about ? QStringLiteral("%1 (%2)").arg(mod->name, about->packageId.str()) : mod->name); - } - const auto [result, dialog] = co_await awaitDialog(details, parent); - if (result == QMessageBox::Cancel) co_return false; - if (result == QMessageBox::Yes) disabling.append(dependents); +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, + const QStringList& enable, + const QStringList& disable) { + // Copy before suspension: callers often pass temporary batches. + const QStringList enabling = enable; + QStringList disabling = disable; + const auto dependents = ctx.activation().dependentsOf(disabling, enabling); + if (!dependents.isEmpty()) { + QStringList details; + for (const auto& uuid : dependents) { + const auto& mod = ctx.allMods().value(uuid); + const auto* about = dynamic_cast(mod.get()); + details.append(about ? QStringLiteral("%1 (%2)").arg( + mod->name, about->packageId.str()) + : mod->name); } - co_return ctx.activation().apply(enabling, disabling); -} -QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, const QStringList& uuids) { - QStringList enable, disable; - for (const auto& uuid : uuids) (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); - co_return co_await applyActivation(ctx, parent, enable, disable); + const auto [result, dialog] = + co_await awaitDialog(details, parent); + if (result == QMessageBox::Cancel) co_return false; + if (result == QMessageBox::Yes) disabling.append(dependents); + } + co_return ctx.activation().apply(enabling, disabling); } +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, + const QStringList& uuids) { + QStringList enable, disable; + for (const auto& uuid : uuids) + (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); + co_return co_await applyActivation(ctx, parent, enable, disable); } +} // namespace px::modrim diff --git a/src/ui/components/mod_activation.hpp b/src/ui/components/mod_activation.hpp index c6b5cc7..8afb5af 100644 --- a/src/ui/components/mod_activation.hpp +++ b/src/ui/components/mod_activation.hpp @@ -1,13 +1,19 @@ #pragma once -#include #include #include -namespace px::modrim { class CoreContext; } + +#include +namespace px::modrim { +class CoreContext; +} namespace px::modrim { class DependentModsDialog : public QMessageBox { -public: - DependentModsDialog(const QStringList& dependents, QWidget* parent); + public: + DependentModsDialog(const QStringList& dependents, QWidget* parent); }; -QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, const QStringList& enable = {}, const QStringList& disable = {}); -QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, const QStringList& uuids); -} +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, + const QStringList& enable = {}, + const QStringList& disable = {}); +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, + const QStringList& uuids); +} // namespace px::modrim diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp index 53734ec..b12cc36 100644 --- a/src/ui/components/mod_updates.cpp +++ b/src/ui/components/mod_updates.cpp @@ -1,57 +1,78 @@ #include "ui/components/mod_updates.hpp" -#include "ui/components/dialogs.hpp" -#include "core/context.hpp" -#include "core/downloads/manager.hpp" + #include #include #include #include + +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "ui/components/dialogs.hpp" namespace px::modrim { QStringList updatableIds(CoreContext& ctx, const QStringList& uuids) { - QList mods; - mods.reserve(uuids.size()); - for (const auto& uuid : uuids) { - const auto it = ctx.allMods().constFind(uuid); - if (it != ctx.allMods().cend()) mods.append(it.value()); - } - return ctx.downloads().updatableIds(mods); + QList mods; + mods.reserve(uuids.size()); + for (const auto& uuid : uuids) { + const auto it = ctx.allMods().constFind(uuid); + if (it != ctx.allMods().cend()) mods.append(it.value()); + } + return ctx.downloads().updatableIds(mods); } UpdateState updateState(CoreContext& ctx, const QStringList& uuids) { - const auto ids = updatableIds(ctx, uuids); - QString label = QStringLiteral("Update"); - if (uuids.size() > 1) label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); - const auto& downloads = ctx.downloads(); - if (!downloads.available()) return {label, false, QStringLiteral("No download sources are enabled")}; - if (downloads.isDownloading()) return {label, false, QStringLiteral("A download is in progress")}; - if (ids.isEmpty()) return {label, false, QStringLiteral("None of these mods were downloaded by PxModRim")}; - return {label, true, {}}; + const auto ids = updatableIds(ctx, uuids); + QString label = QStringLiteral("Update"); + if (uuids.size() > 1) + label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); + const auto& downloads = ctx.downloads(); + if (!downloads.available()) + return {label, false, QStringLiteral("No download sources are enabled")}; + if (downloads.isDownloading()) + return {label, false, QStringLiteral("A download is in progress")}; + if (ids.isEmpty()) + return {label, false, + QStringLiteral("None of these mods were downloaded by PxModRim")}; + return {label, true, {}}; } -QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler) { - if (!ctx.downloads().available()) return nullptr; - const auto state = updateState(ctx, uuids); - auto* action = menu->addAction(state.label); - QObject::connect(action, &QAction::triggered, menu, [handler = std::move(handler)] { handler(); }); - action->setEnabled(state.enabled); action->setToolTip(state.tooltip); - return action; +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, + const QStringList& uuids, + std::function handler) { + if (!ctx.downloads().available()) return nullptr; + const auto state = updateState(ctx, uuids); + auto* action = menu->addAction(state.label); + QObject::connect(action, &QAction::triggered, menu, + [handler = std::move(handler)] { handler(); }); + action->setEnabled(state.enabled); + action->setToolTip(state.tooltip); + return action; } -ConfirmUpdateDialog::ConfirmUpdateDialog(int count, QWidget* parent) : QMessageBox(parent) { - setIcon(QMessageBox::Question); setWindowTitle(QStringLiteral("Update Mods")); - setText(QStringLiteral("Update %1 mods?").arg(count)); - setInformativeText(QStringLiteral("Every mod downloaded by PxModRim is checked against its source and changed files are downloaded. Mods managed by other tools, such as Steam's own Workshop folder, are not included. This can take a long time.")); - setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); - setDefaultButton(QMessageBox::Cancel); - button(QMessageBox::Yes)->setText(QStringLiteral("Update")); - button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +ConfirmUpdateDialog::ConfirmUpdateDialog(int count, QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Question); + setWindowTitle(QStringLiteral("Update Mods")); + setText(QStringLiteral("Update %1 mods?").arg(count)); + setInformativeText( + QStringLiteral("Every mod downloaded by PxModRim is checked against its " + "source and changed files are downloaded. Mods managed by " + "other tools, such as Steam's own Workshop folder, are " + "not included. This can take a long time.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + button(QMessageBox::Yes)->setText(QStringLiteral("Update")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); } QCoro::Task confirmUpdate(int count, QWidget* parent) { - if (count < kConfirmThreshold) co_return true; - const auto [result, dialog] = co_await awaitDialog(count, parent); - co_return result == QMessageBox::Yes; -} -QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, QWidget* parent) { - const auto ids = updatableIds(ctx, uuids); - if (ids.isEmpty() || !(co_await confirmUpdate(static_cast(ids.size()), parent))) co_return; - const auto result = co_await ctx.downloads().downloadMods(ids); - if (!result) qWarning() << "Update not started:" << result.error().message; + if (count < kConfirmThreshold) co_return true; + const auto [result, dialog] = + co_await awaitDialog(count, parent); + co_return result == QMessageBox::Yes; } +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, + QWidget* parent) { + const auto ids = updatableIds(ctx, uuids); + if (ids.isEmpty() || + !(co_await confirmUpdate(static_cast(ids.size()), parent))) + co_return; + const auto result = co_await ctx.downloads().downloadMods(ids); + if (!result) qWarning() << "Update not started:" << result.error().message; } +} // namespace px::modrim diff --git a/src/ui/components/mod_updates.hpp b/src/ui/components/mod_updates.hpp index d45db10..2941a08 100644 --- a/src/ui/components/mod_updates.hpp +++ b/src/ui/components/mod_updates.hpp @@ -1,21 +1,31 @@ #pragma once -#include #include #include #include + +#include class QMenu; class QAction; -namespace px::modrim { class CoreContext; } +namespace px::modrim { +class CoreContext; +} namespace px::modrim { inline constexpr int kConfirmThreshold = 25; -struct UpdateState { QString label; bool enabled; QString tooltip; }; +struct UpdateState { + QString label; + bool enabled; + QString tooltip; +}; QStringList updatableIds(CoreContext& ctx, const QStringList& uuids); UpdateState updateState(CoreContext& ctx, const QStringList& uuids); -QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, const QStringList& uuids, std::function handler); +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, + const QStringList& uuids, + std::function handler); class ConfirmUpdateDialog : public QMessageBox { -public: - ConfirmUpdateDialog(int count, QWidget* parent); + public: + ConfirmUpdateDialog(int count, QWidget* parent); }; QCoro::Task confirmUpdate(int count, QWidget* parent); -QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, QWidget* parent); -} +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, + QWidget* parent); +} // namespace px::modrim diff --git a/src/ui/components/popup_parent.cpp b/src/ui/components/popup_parent.cpp index dac648a..7658ea3 100644 --- a/src/ui/components/popup_parent.cpp +++ b/src/ui/components/popup_parent.cpp @@ -11,14 +11,14 @@ namespace px::modrim { QuickPopupParenting::QuickPopupParenting(QObject* parent) : QObject(parent) {} bool QuickPopupParenting::eventFilter(QObject* watched, QEvent* event) { - if (event->type() == QEvent::Show) { - if (auto* quick = qobject_cast(watched)) { - if (QWindow* top = quick->window()->windowHandle()) { - quick->quickWindow()->setTransientParent(top); - } - } + if (event->type() == QEvent::Show) { + if (auto* quick = qobject_cast(watched)) { + if (QWindow* top = quick->window()->windowHandle()) { + quick->quickWindow()->setTransientParent(top); + } } - return false; + } + return false; } } // namespace px::modrim diff --git a/src/ui/components/popup_parent.hpp b/src/ui/components/popup_parent.hpp index 4f33e37..ad13f9f 100644 --- a/src/ui/components/popup_parent.hpp +++ b/src/ui/components/popup_parent.hpp @@ -11,13 +11,13 @@ namespace px::modrim { // combo box lists, tooltips): "Failed to create popup … transientParent". // Install on the QApplication, parented to it. class QuickPopupParenting : public QObject { - Q_OBJECT + Q_OBJECT -public: - explicit QuickPopupParenting(QObject* parent); + public: + explicit QuickPopupParenting(QObject* parent); -protected: - bool eventFilter(QObject* watched, QEvent* event) override; + protected: + bool eventFilter(QObject* watched, QEvent* event) override; }; } // namespace px::modrim diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp index cd5bff8..947d7a5 100644 --- a/src/ui/components/procedural_preview.cpp +++ b/src/ui/components/procedural_preview.cpp @@ -1,5 +1,5 @@ #include "ui/components/procedural_preview.hpp" -#include "ui/theme/constants.hpp" + #include #include #include @@ -7,92 +7,113 @@ #include #include #include -#include #include +#include #include #include +#include "ui/theme/constants.hpp" + namespace px::modrim { QString initials(const QString& title) { - static const QRegularExpression tags(R"([\[(][^\])]*[\])])"); - static const QRegularExpression words(R"([^\W_]+)", QRegularExpression::UseUnicodePropertiesOption); - static const QRegularExpression camel(R"([A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+)", QRegularExpression::UseUnicodePropertiesOption); - static const QSet stop{"a", "an", "and", "for", "of", "the", "to", "in", "on"}; - QString stripped = title; - stripped.replace(tags, " "); - const bool useStripped = words.match(stripped).hasMatch(); - QStringList all, meaningful; - auto chunks = words.globalMatch(useStripped ? stripped : title); - while (chunks.hasNext()) { - const QString chunk = chunks.next().captured(); - auto parts = camel.globalMatch(chunk); - if (!parts.hasNext()) all.append(chunk); - while (parts.hasNext()) all.append(parts.next().captured()); - } - for (const auto& word : all) if (!stop.contains(word.toLower())) meaningful.append(word); - if (meaningful.isEmpty()) meaningful = all; - if (meaningful.isEmpty()) return title.trimmed().isEmpty() ? QStringLiteral("?") : title.trimmed().left(2).toUpper(); - if (meaningful.size() == 1) { - auto tagMatches = tags.globalMatch(title); - QString tagText; - while (tagMatches.hasNext()) tagText += tagMatches.next().captured() + " "; - const auto tagWord = words.match(tagText); - if (useStripped && tagWord.hasMatch()) return (tagWord.captured().left(1) + meaningful.front().left(1)).toUpper(); - return meaningful.front().left(2).toUpper(); - } - return (meaningful[0].left(1) + meaningful[1].left(1)).toUpper(); + static const QRegularExpression tags(R"([\[(][^\])]*[\])])"); + static const QRegularExpression words( + R"([^\W_]+)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression camel( + R"([A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+)", + QRegularExpression::UseUnicodePropertiesOption); + static const QSet stop{"a", "an", "and", "for", "of", + "the", "to", "in", "on"}; + QString stripped = title; + stripped.replace(tags, " "); + const bool useStripped = words.match(stripped).hasMatch(); + QStringList all, meaningful; + auto chunks = words.globalMatch(useStripped ? stripped : title); + while (chunks.hasNext()) { + const QString chunk = chunks.next().captured(); + auto parts = camel.globalMatch(chunk); + if (!parts.hasNext()) all.append(chunk); + while (parts.hasNext()) all.append(parts.next().captured()); + } + for (const auto& word : all) + if (!stop.contains(word.toLower())) meaningful.append(word); + if (meaningful.isEmpty()) meaningful = all; + if (meaningful.isEmpty()) + return title.trimmed().isEmpty() ? QStringLiteral("?") + : title.trimmed().left(2).toUpper(); + if (meaningful.size() == 1) { + auto tagMatches = tags.globalMatch(title); + QString tagText; + while (tagMatches.hasNext()) tagText += tagMatches.next().captured() + " "; + const auto tagWord = words.match(tagText); + if (useStripped && tagWord.hasMatch()) + return (tagWord.captured().left(1) + meaningful.front().left(1)) + .toUpper(); + return meaningful.front().left(2).toUpper(); + } + return (meaningful[0].left(1) + meaningful[1].left(1)).toUpper(); } namespace { double seedrand(const QString& seed) { - const auto hash = QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); - unsigned remainder = 0; - for (char byte : hash) remainder = (remainder * 256 + static_cast(byte)) % 10000; - return remainder / 10000.0; + const auto hash = + QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); + unsigned remainder = 0; + for (char byte : hash) + remainder = (remainder * 256 + static_cast(byte)) % 10000; + return remainder / 10000.0; } QColor hsl(double hue, double saturation, double lightness) { - return QColor::fromHslF(hue - std::floor(hue), std::clamp(saturation, 0.0, 1.0), std::clamp(lightness, 0.0, 1.0)); -} -void blob(QPainter& painter, double x, double y, double radius, const QColor& color) { - QRadialGradient gradient(QPointF(x, y), radius); - QColor outer = color; - outer.setAlpha(0); - gradient.setColorAt(0, color); - gradient.setColorAt(1, outer); - painter.setBrush(gradient); - painter.drawEllipse(QPointF(x, y), radius, radius); + return QColor::fromHslF(hue - std::floor(hue), + std::clamp(saturation, 0.0, 1.0), + std::clamp(lightness, 0.0, 1.0)); } +void blob(QPainter& painter, double x, double y, double radius, + const QColor& color) { + QRadialGradient gradient(QPointF(x, y), radius); + QColor outer = color; + outer.setAlpha(0); + gradient.setColorAt(0, color); + gradient.setColorAt(1, outer); + painter.setBrush(gradient); + painter.drawEllipse(QPointF(x, y), radius, radius); } +} // namespace QImage generatePreview(const QString& title, int width, int height) { - static QMutex mutex; - static QCache cache(16); - const QString key = QString::number(width) + ":" + QString::number(height) + ":" + title; - { - QMutexLocker lock(&mutex); - if (auto* cached = cache.object(key)) return *cached; - } - QImage image(width, height, QImage::Format_ARGB32_Premultiplied); - const double hue = seedrand(title); - image.fill(hsl(hue, .40, .12)); - QPainter painter(&image); - painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(Qt::NoPen); - const int span = std::max(width, height); - const double jx = seedrand(title + "jx") * .2, jy = seedrand(title + "jy") * .2; - QColor warm = hsl(hue, .7, .5), cool = hsl(hue + .14, .7, .5); - warm.setAlphaF(.75); cool.setAlphaF(.65); - blob(painter, width * (.15 + jx), height * (.1 + jy), span * .6, warm); - blob(painter, width * (.85 - jx), height * (.9 - jy), span * .55, cool); - QFont font = painter.font(); - font.setBold(true); font.setWeight(QFont::ExtraBold); - font.setPixelSize(std::max(32, static_cast(height * .36))); - font.setLetterSpacing(QFont::PercentageSpacing, 96); - painter.setFont(font); - painter.setPen(QColor(255, 255, 255, 210)); - const int visible = std::max(height - kBannerOverlayHeight, height / 2); - painter.drawText(QRectF(0, 0, width, visible), Qt::AlignCenter, initials(title)); - painter.end(); + static QMutex mutex; + static QCache cache(16); + const QString key = + QString::number(width) + ":" + QString::number(height) + ":" + title; + { QMutexLocker lock(&mutex); - cache.insert(key, new QImage(image)); - return image; -} + if (auto* cached = cache.object(key)) return *cached; + } + QImage image(width, height, QImage::Format_ARGB32_Premultiplied); + const double hue = seedrand(title); + image.fill(hsl(hue, .40, .12)); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + const int span = std::max(width, height); + const double jx = seedrand(title + "jx") * .2, + jy = seedrand(title + "jy") * .2; + QColor warm = hsl(hue, .7, .5), cool = hsl(hue + .14, .7, .5); + warm.setAlphaF(.75); + cool.setAlphaF(.65); + blob(painter, width * (.15 + jx), height * (.1 + jy), span * .6, warm); + blob(painter, width * (.85 - jx), height * (.9 - jy), span * .55, cool); + QFont font = painter.font(); + font.setBold(true); + font.setWeight(QFont::ExtraBold); + font.setPixelSize(std::max(32, static_cast(height * .36))); + font.setLetterSpacing(QFont::PercentageSpacing, 96); + painter.setFont(font); + painter.setPen(QColor(255, 255, 255, 210)); + const int visible = std::max(height - kBannerOverlayHeight, height / 2); + painter.drawText(QRectF(0, 0, width, visible), Qt::AlignCenter, + initials(title)); + painter.end(); + QMutexLocker lock(&mutex); + cache.insert(key, new QImage(image)); + return image; } +} // namespace px::modrim diff --git a/src/ui/components/procedural_preview.hpp b/src/ui/components/procedural_preview.hpp index e54d2fe..7cb862e 100644 --- a/src/ui/components/procedural_preview.hpp +++ b/src/ui/components/procedural_preview.hpp @@ -4,4 +4,4 @@ namespace px::modrim { QString initials(const QString& title); QImage generatePreview(const QString& title, int width, int height); -} +} // namespace px::modrim diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp index 1c42cc7..e633745 100644 --- a/src/ui/components/progress_dialog.cpp +++ b/src/ui/components/progress_dialog.cpp @@ -22,127 +22,131 @@ constexpr int kHideDelayMs = 300; constexpr int kFadeInMs = 150; constexpr int kFadeOutMs = 200; -// Every icon name used here is in the icon table; a miss is a programming error. +// Every icon name used here is in the icon table; a miss is a programming +// error. QIcon requireIcon(QStringView name, int size, QStringView color) { - auto found = icon(name, size, color); - Q_ASSERT(found.has_value()); - return found.value_or(QIcon()); + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); } } // namespace ProgressDialog::ProgressDialog(LoadingState& loading, QWidget* parent) - : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | Qt::WindowTitleHint), + : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | + Qt::WindowTitleHint), m_loading(loading) { - setWindowTitle(QStringLiteral("Loading")); - setMinimumWidth(420); - setModal(true); - - m_crumbs = new QWidget(this); - m_crumbsLayout = new QHBoxLayout(m_crumbs); - m_crumbsLayout->setContentsMargins(0, 0, 0, 8); - m_crumbsLayout->setSpacing(4); - - m_progress = new QProgressBar(this); - m_progress->setTextVisible(false); - m_progress->setRange(0, 100); - - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(12, 12, 12, 12); - layout->setSpacing(0); - layout->addWidget(m_crumbs); - layout->addWidget(m_progress); - installDialogChrome(this, false); - - m_hideTimer = new QTimer(this); - m_hideTimer->setSingleShot(true); - m_hideTimer->setInterval(kHideDelayMs); - connect(m_hideTimer, &QTimer::timeout, this, &ProgressDialog::beginHide); - - m_fade = new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); - connect(m_fade, &QPropertyAnimation::finished, this, &ProgressDialog::onFadeFinished); - - m_connections.push_back(QObject::connect( - &m_loading, &LoadingState::changed, this, - [this](const QString&, int pct, int, int) { onChanged(pct); })); - m_connections.push_back( - QObject::connect(&m_loading, &LoadingState::finished, this, &ProgressDialog::onFinished)); + setWindowTitle(QStringLiteral("Loading")); + setMinimumWidth(420); + setModal(true); + + m_crumbs = new QWidget(this); + m_crumbsLayout = new QHBoxLayout(m_crumbs); + m_crumbsLayout->setContentsMargins(0, 0, 0, 8); + m_crumbsLayout->setSpacing(4); + + m_progress = new QProgressBar(this); + m_progress->setTextVisible(false); + m_progress->setRange(0, 100); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(0); + layout->addWidget(m_crumbs); + layout->addWidget(m_progress); + installDialogChrome(this, false); + + m_hideTimer = new QTimer(this); + m_hideTimer->setSingleShot(true); + m_hideTimer->setInterval(kHideDelayMs); + connect(m_hideTimer, &QTimer::timeout, this, &ProgressDialog::beginHide); + + m_fade = + new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); + connect(m_fade, &QPropertyAnimation::finished, this, + &ProgressDialog::onFadeFinished); + + m_connections.push_back(QObject::connect( + &m_loading, &LoadingState::changed, this, + [this](const QString&, int pct, int, int) { onChanged(pct); })); + m_connections.push_back(QObject::connect(&m_loading, &LoadingState::finished, + this, &ProgressDialog::onFinished)); } ProgressDialog::~ProgressDialog() { - for (const auto& connection : m_connections) { - QObject::disconnect(connection); - } + for (const auto& connection : m_connections) { + QObject::disconnect(connection); + } } void ProgressDialog::onChanged(int pct) { - m_hideTimer->stop(); - if (!m_shown) { - m_shown = true; - if (!isVisible()) { - setWindowOpacity(0.0); - show(); - } - fadeTo(1.0, kFadeInMs); - } - - const QStringList statuses = m_loading.statuses(); - if (!statuses.isEmpty()) { - setWindowTitle(statuses.first()); + m_hideTimer->stop(); + if (!m_shown) { + m_shown = true; + if (!isVisible()) { + setWindowOpacity(0.0); + show(); } - updateBreadcrumbs(statuses); - m_progress->setValue(pct); + fadeTo(1.0, kFadeInMs); + } + + const QStringList statuses = m_loading.statuses(); + if (!statuses.isEmpty()) { + setWindowTitle(statuses.first()); + } + updateBreadcrumbs(statuses); + m_progress->setValue(pct); } -void ProgressDialog::onFinished() { - m_hideTimer->start(); -} +void ProgressDialog::onFinished() { m_hideTimer->start(); } void ProgressDialog::updateBreadcrumbs(const QStringList& statuses) { - while (QLayoutItem* item = m_crumbsLayout->takeAt(0)) { - if (QWidget* widget = item->widget()) { - widget->hide(); - widget->deleteLater(); - } - delete item; + while (QLayoutItem* item = m_crumbsLayout->takeAt(0)) { + if (QWidget* widget = item->widget()) { + widget->hide(); + widget->deleteLater(); } - - const qsizetype last = statuses.size() - 1; - for (qsizetype i = 0; i < statuses.size(); ++i) { - auto* label = new QLabel(statuses[i], m_crumbs); - if (i == last) { - QFont font = label->font(); - font.setBold(true); - label->setFont(font); - } - m_crumbsLayout->addWidget(label); - - if (i < last) { - auto* separator = new QLabel(m_crumbs); - separator->setPixmap(requireIcon(u"chevron", 12, paletteValue(u"TEXT_DIM")).pixmap(12, 12)); - m_crumbsLayout->addWidget(separator); - } + delete item; + } + + const qsizetype last = statuses.size() - 1; + for (qsizetype i = 0; i < statuses.size(); ++i) { + auto* label = new QLabel(statuses[i], m_crumbs); + if (i == last) { + QFont font = label->font(); + font.setBold(true); + label->setFont(font); + } + m_crumbsLayout->addWidget(label); + + if (i < last) { + auto* separator = new QLabel(m_crumbs); + separator->setPixmap( + requireIcon(u"chevron", 12, paletteValue(u"TEXT_DIM")) + .pixmap(12, 12)); + m_crumbsLayout->addWidget(separator); } - m_crumbsLayout->addStretch(); + } + m_crumbsLayout->addStretch(); } void ProgressDialog::beginHide() { - m_shown = false; - fadeTo(0.0, kFadeOutMs); + m_shown = false; + fadeTo(0.0, kFadeOutMs); } void ProgressDialog::fadeTo(qreal end, int durationMs) { - m_fade->stop(); - m_fade->setDuration(durationMs); - m_fade->setStartValue(windowOpacity()); - m_fade->setEndValue(end); - m_fade->start(); + m_fade->stop(); + m_fade->setDuration(durationMs); + m_fade->setStartValue(windowOpacity()); + m_fade->setEndValue(end); + m_fade->start(); } void ProgressDialog::onFadeFinished() { - if (!m_shown) { - hide(); - } + if (!m_shown) { + hide(); + } } } // namespace px::modrim diff --git a/src/ui/components/progress_dialog.hpp b/src/ui/components/progress_dialog.hpp index 269e9cc..f0a62a0 100644 --- a/src/ui/components/progress_dialog.hpp +++ b/src/ui/components/progress_dialog.hpp @@ -1,10 +1,9 @@ #pragma once -#include - #include #include #include +#include #include "core/loading.hpp" @@ -16,35 +15,36 @@ class QWidget; namespace px::modrim { -// Modal progress dialog showing the stacked loading tasks as breadcrumbs. Shows when the first -// task reports, and fades out shortly after the last one finishes unless a new task starts. +// Modal progress dialog showing the stacked loading tasks as breadcrumbs. Shows +// when the first task reports, and fades out shortly after the last one +// finishes unless a new task starts. class ProgressDialog : public QDialog { - Q_OBJECT + Q_OBJECT -public: - ProgressDialog(LoadingState& loading, QWidget* parent); - ~ProgressDialog() override; + public: + ProgressDialog(LoadingState& loading, QWidget* parent); + ~ProgressDialog() override; - LoadingState& loading() const noexcept { return m_loading; } + LoadingState& loading() const noexcept { return m_loading; } -private: - void onChanged(int pct); - void onFinished(); - void updateBreadcrumbs(const QStringList& statuses); - void beginHide(); - void fadeTo(qreal end, int durationMs); - void onFadeFinished(); + private: + void onChanged(int pct); + void onFinished(); + void updateBreadcrumbs(const QStringList& statuses); + void beginHide(); + void fadeTo(qreal end, int durationMs); + void onFadeFinished(); - LoadingState& m_loading; - bool m_shown = false; + LoadingState& m_loading; + bool m_shown = false; - QWidget* m_crumbs = nullptr; - QHBoxLayout* m_crumbsLayout = nullptr; - QProgressBar* m_progress = nullptr; - QTimer* m_hideTimer = nullptr; - QPropertyAnimation* m_fade = nullptr; + QWidget* m_crumbs = nullptr; + QHBoxLayout* m_crumbsLayout = nullptr; + QProgressBar* m_progress = nullptr; + QTimer* m_hideTimer = nullptr; + QPropertyAnimation* m_fade = nullptr; - std::vector m_connections; + std::vector m_connections; }; } // namespace px::modrim diff --git a/src/ui/components/svg_provider.cpp b/src/ui/components/svg_provider.cpp index 442faf9..12c52d8 100644 --- a/src/ui/components/svg_provider.cpp +++ b/src/ui/components/svg_provider.cpp @@ -13,54 +13,59 @@ namespace px::modrim { namespace { -constexpr QLatin1StringView kEmptySvg{R"()"}; +constexpr QLatin1StringView kEmptySvg{ + R"()"}; // The color parameter, percent-decoded. QML URL-encodes "#" and some paths // deliver the digits without it, so a leading "23" is taken as an encoded "#". QString colorFromQuery(const QString& query) { - QString color = paletteValue(u"TEXT_MUTED"); - for (const QString& param : query.split(QLatin1Char('&'))) { - if (!param.startsWith(QLatin1StringView("color="))) { - continue; - } - color = QUrl::fromPercentEncoding(param.mid(6).toUtf8()); - if (!color.startsWith(QLatin1Char('#')) && color.startsWith(QLatin1StringView("23"))) { - color = QStringLiteral("#") + color.mid(2); - } + QString color = paletteValue(u"TEXT_MUTED"); + for (const QString& param : query.split(QLatin1Char('&'))) { + if (!param.startsWith(QLatin1StringView("color="))) { + continue; } - return color; + color = QUrl::fromPercentEncoding(param.mid(6).toUtf8()); + if (!color.startsWith(QLatin1Char('#')) && + color.startsWith(QLatin1StringView("23"))) { + color = QStringLiteral("#") + color.mid(2); + } + } + return color; } } // namespace -SvgIconProvider::SvgIconProvider() : QQuickImageProvider(QQuickImageProvider::Pixmap) {} +SvgIconProvider::SvgIconProvider() + : QQuickImageProvider(QQuickImageProvider::Pixmap) {} -QPixmap SvgIconProvider::requestPixmap(const QString& id, QSize*, const QSize& requestedSize) { - const qsizetype questionMark = id.indexOf(QLatin1Char('?')); - const QString name = questionMark < 0 ? id : id.left(questionMark); - const QString query = questionMark < 0 ? QString{} : id.mid(questionMark + 1); +QPixmap SvgIconProvider::requestPixmap(const QString& id, QSize*, + const QSize& requestedSize) { + const qsizetype questionMark = id.indexOf(QLatin1Char('?')); + const QString name = questionMark < 0 ? id : id.left(questionMark); + const QString query = questionMark < 0 ? QString{} : id.mid(questionMark + 1); - const QString svg = iconSvg(name, colorFromQuery(query)).value_or(QString::fromLatin1(kEmptySvg)); + const QString svg = iconSvg(name, colorFromQuery(query)) + .value_or(QString::fromLatin1(kEmptySvg)); - const QSize size = requestedSize.isValid() ? requestedSize : QSize(16, 16); - QPixmap pixmap(size); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - QSvgRenderer renderer(svg.toUtf8()); - renderer.render(&painter); - painter.end(); - return pixmap; + const QSize size = requestedSize.isValid() ? requestedSize : QSize(16, 16); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + QSvgRenderer renderer(svg.toUtf8()); + renderer.render(&painter); + painter.end(); + return pixmap; } QQmlEngine* createQmlEngine(QObject* parent) { - if (QQuickStyle::name().isEmpty()) { - QQuickStyle::setStyle(QStringLiteral("Basic")); - } - auto* engine = new QQmlEngine(parent); - // The engine takes ownership of image providers (it holds them in a shared - // pointer), so the provider is created without a parent. - engine->addImageProvider(QStringLiteral("icons"), new SvgIconProvider()); - return engine; + if (QQuickStyle::name().isEmpty()) { + QQuickStyle::setStyle(QStringLiteral("Basic")); + } + auto* engine = new QQmlEngine(parent); + // The engine takes ownership of image providers (it holds them in a shared + // pointer), so the provider is created without a parent. + engine->addImageProvider(QStringLiteral("icons"), new SvgIconProvider()); + return engine; } } // namespace px::modrim diff --git a/src/ui/components/svg_provider.hpp b/src/ui/components/svg_provider.hpp index c3a3026..fe13e8a 100644 --- a/src/ui/components/svg_provider.hpp +++ b/src/ui/components/svg_provider.hpp @@ -13,10 +13,11 @@ namespace px::modrim { // Serves named SVG icons to QML via image://icons/?color=. // An unknown name renders an empty SVG, so the image is transparent. class SvgIconProvider : public QQuickImageProvider { -public: - SvgIconProvider(); + public: + SvgIconProvider(); - QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override; + QPixmap requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) override; }; // The one way to build a QML engine: every QML surface shares it, and it always diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp index 1de0f85..359890f 100644 --- a/src/ui/components/toast.cpp +++ b/src/ui/components/toast.cpp @@ -1,7 +1,5 @@ #include "ui/components/toast.hpp" -#include - #include #include #include @@ -11,6 +9,7 @@ #include #include #include +#include #include "ui/components/icons.hpp" #include "ui/theme/palette.hpp" @@ -20,8 +19,8 @@ namespace px::modrim { namespace { struct LevelStyle { - QStringView icon; - QStringView accent; + QStringView icon; + QStringView accent; }; // Indexed by ToastLevel. @@ -32,124 +31,131 @@ constexpr LevelStyle kLevelStyles[]{ {u"error", u"DANGER"}, }; -// Every icon name used here is in the icon table; a miss is a programming error. +// Every icon name used here is in the icon table; a miss is a programming +// error. QIcon requireIcon(QStringView name, int size, QStringView color) { - auto found = icon(name, size, color); - Q_ASSERT(found.has_value()); - return found.value_or(QIcon()); + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); } } // namespace -Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) : QWidget(parent) { - setObjectName(QStringLiteral("toast")); - setFixedWidth(320); - setAttribute(Qt::WA_StyledBackground, true); - - const LevelStyle& style = kLevelStyles[static_cast(level)]; - - m_opacityEffect = new QGraphicsOpacityEffect(this); - m_opacityEffect->setOpacity(0.0); - setGraphicsEffect(m_opacityEffect); - - auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(12, 10, 8, 10); - layout->setSpacing(8); - - auto* iconLabel = new QLabel(this); - iconLabel->setPixmap(requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); - iconLabel->setFixedSize(16, 16); - layout->addWidget(iconLabel); - - auto* messageLabel = new QLabel(message, this); - messageLabel->setObjectName(QStringLiteral("toastMessage")); - messageLabel->setWordWrap(true); - layout->addWidget(messageLabel, 1); - - auto* close = new QPushButton(this); - close->setObjectName(QStringLiteral("toastClose")); - close->setIcon(requireIcon(u"close", 10, paletteValue(u"TEXT_DIM"))); - close->setFixedSize(20, 20); - close->setToolTip(QStringLiteral("Dismiss")); - connect(close, &QPushButton::clicked, this, &Toast::dismiss); - layout->addWidget(close); - - m_opacityAnim = new QPropertyAnimation(m_opacityEffect, QByteArrayLiteral("opacity"), this); - m_opacityAnim->setDuration(200); - m_opacityAnim->setStartValue(0.0); - m_opacityAnim->setEndValue(1.0); - - m_fadeOutAnim = new QPropertyAnimation(m_opacityEffect, QByteArrayLiteral("opacity"), this); - m_fadeOutAnim->setDuration(150); - m_fadeOutAnim->setEndValue(0.0); - connect(m_fadeOutAnim, &QPropertyAnimation::finished, this, &Toast::dismissed); +Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) + : QWidget(parent) { + setObjectName(QStringLiteral("toast")); + setFixedWidth(320); + setAttribute(Qt::WA_StyledBackground, true); + + const LevelStyle& style = kLevelStyles[static_cast(level)]; + + m_opacityEffect = new QGraphicsOpacityEffect(this); + m_opacityEffect->setOpacity(0.0); + setGraphicsEffect(m_opacityEffect); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(12, 10, 8, 10); + layout->setSpacing(8); + + auto* iconLabel = new QLabel(this); + iconLabel->setPixmap( + requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); + iconLabel->setFixedSize(16, 16); + layout->addWidget(iconLabel); + + auto* messageLabel = new QLabel(message, this); + messageLabel->setObjectName(QStringLiteral("toastMessage")); + messageLabel->setWordWrap(true); + layout->addWidget(messageLabel, 1); + + auto* close = new QPushButton(this); + close->setObjectName(QStringLiteral("toastClose")); + close->setIcon(requireIcon(u"close", 10, paletteValue(u"TEXT_DIM"))); + close->setFixedSize(20, 20); + close->setToolTip(QStringLiteral("Dismiss")); + connect(close, &QPushButton::clicked, this, &Toast::dismiss); + layout->addWidget(close); + + m_opacityAnim = new QPropertyAnimation(m_opacityEffect, + QByteArrayLiteral("opacity"), this); + m_opacityAnim->setDuration(200); + m_opacityAnim->setStartValue(0.0); + m_opacityAnim->setEndValue(1.0); + + m_fadeOutAnim = new QPropertyAnimation(m_opacityEffect, + QByteArrayLiteral("opacity"), this); + m_fadeOutAnim->setDuration(150); + m_fadeOutAnim->setEndValue(0.0); + connect(m_fadeOutAnim, &QPropertyAnimation::finished, this, + &Toast::dismissed); } void Toast::showWithAnimation() { - show(); - raise(); - m_opacityAnim->setDirection(QAbstractAnimation::Direction::Forward); - m_opacityAnim->start(); + show(); + raise(); + m_opacityAnim->setDirection(QAbstractAnimation::Direction::Forward); + m_opacityAnim->start(); } void Toast::dismiss() { - if (m_dismissing) { - return; - } - m_dismissing = true; - m_opacityAnim->stop(); - m_fadeOutAnim->setStartValue(m_opacityEffect->opacity()); - m_fadeOutAnim->start(); + if (m_dismissing) { + return; + } + m_dismissing = true; + m_opacityAnim->stop(); + m_fadeOutAnim->setStartValue(m_opacityEffect->opacity()); + m_fadeOutAnim->start(); } ToastManager::ToastManager(QWidget* parent) : QWidget(parent) { - setObjectName(QStringLiteral("toastManager")); - setAttribute(Qt::WA_TransparentForMouseEvents, true); + setObjectName(QStringLiteral("toastManager")); + setAttribute(Qt::WA_TransparentForMouseEvents, true); - m_layout = new QVBoxLayout(this); - m_layout->setContentsMargins(0, 0, 16, 16); - m_layout->setSpacing(8); - m_layout->setAlignment(Qt::AlignBottom | Qt::AlignRight); + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 16, 16); + m_layout->setSpacing(8); + m_layout->setAlignment(Qt::AlignBottom | Qt::AlignRight); } -void ToastManager::showToast(const QString& message, ToastLevel level, int durationMs) { - auto* toast = new Toast(message, level, this); - m_layout->addWidget(toast); - connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); +void ToastManager::showToast(const QString& message, ToastLevel level, + int durationMs) { + auto* toast = new Toast(message, level, this); + m_layout->addWidget(toast); + connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); - toast->showWithAnimation(); + toast->showWithAnimation(); - if (durationMs > 0) { - QTimer::singleShot(durationMs, toast, &Toast::dismiss); - } + if (durationMs > 0) { + QTimer::singleShot(durationMs, toast, &Toast::dismiss); + } } void ToastManager::info(const QString& message, int durationMs) { - showToast(message, ToastLevel::kInfo, durationMs); + showToast(message, ToastLevel::kInfo, durationMs); } void ToastManager::success(const QString& message, int durationMs) { - showToast(message, ToastLevel::kSuccess, durationMs); + showToast(message, ToastLevel::kSuccess, durationMs); } void ToastManager::warning(const QString& message, int durationMs) { - showToast(message, ToastLevel::kWarning, durationMs); + showToast(message, ToastLevel::kWarning, durationMs); } void ToastManager::error(const QString& message, int durationMs) { - showToast(message, ToastLevel::kError, durationMs); + showToast(message, ToastLevel::kError, durationMs); } void ToastManager::resizeToParent() { - if (const QWidget* parent = parentWidget()) { - setGeometry(parent->rect()); - } + if (const QWidget* parent = parentWidget()) { + setGeometry(parent->rect()); + } } void ToastManager::remove(Toast* toast) { - toast->hide(); - m_layout->removeWidget(toast); - toast->deleteLater(); + toast->hide(); + m_layout->removeWidget(toast); + toast->deleteLater(); } } // namespace px::modrim diff --git a/src/ui/components/toast.hpp b/src/ui/components/toast.hpp index c835a7f..f96696b 100644 --- a/src/ui/components/toast.hpp +++ b/src/ui/components/toast.hpp @@ -13,48 +13,50 @@ namespace px::modrim { enum class ToastLevel { kInfo, kSuccess, kWarning, kError }; -// One message card. Fades in on show; dismiss() fades it out and then emits dismissed(). +// One message card. Fades in on show; dismiss() fades it out and then emits +// dismissed(). class Toast : public QWidget { - Q_OBJECT + Q_OBJECT -public: - Toast(const QString& message, ToastLevel level, QWidget* parent = nullptr); + public: + Toast(const QString& message, ToastLevel level, QWidget* parent = nullptr); - void showWithAnimation(); - void dismiss(); + void showWithAnimation(); + void dismiss(); -Q_SIGNALS: - void dismissed(); + Q_SIGNALS: + void dismissed(); -private: - QGraphicsOpacityEffect* m_opacityEffect = nullptr; - QPropertyAnimation* m_opacityAnim = nullptr; - QPropertyAnimation* m_fadeOutAnim = nullptr; - bool m_dismissing = false; + private: + QGraphicsOpacityEffect* m_opacityEffect = nullptr; + QPropertyAnimation* m_opacityAnim = nullptr; + QPropertyAnimation* m_fadeOutAnim = nullptr; + bool m_dismissing = false; }; -// Stacks toasts bottom-right over its parent. Durations are milliseconds; 0 keeps the toast -// until its close button is clicked. +// Stacks toasts bottom-right over its parent. Durations are milliseconds; 0 +// keeps the toast until its close button is clicked. class ToastManager : public QWidget, public Notifier { - Q_OBJECT + Q_OBJECT -public: - explicit ToastManager(QWidget* parent = nullptr); + public: + explicit ToastManager(QWidget* parent = nullptr); - void showToast(const QString& message, ToastLevel level = ToastLevel::kInfo, int durationMs = 3000); + void showToast(const QString& message, ToastLevel level = ToastLevel::kInfo, + int durationMs = 3000); - void info(const QString& message, int durationMs = 3000) override; - void success(const QString& message, int durationMs = 3000) override; - void warning(const QString& message, int durationMs = 4000) override; - void error(const QString& message, int durationMs = 5000) override; + void info(const QString& message, int durationMs = 3000) override; + void success(const QString& message, int durationMs = 3000) override; + void warning(const QString& message, int durationMs = 4000) override; + void error(const QString& message, int durationMs = 5000) override; - // Covers the parent's rectangle; call on every parent resize. - void resizeToParent(); + // Covers the parent's rectangle; call on every parent resize. + void resizeToParent(); -private: - void remove(Toast* toast); + private: + void remove(Toast* toast); - QVBoxLayout* m_layout = nullptr; + QVBoxLayout* m_layout = nullptr; }; } // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp index fb1c454..1a7cc23 100644 --- a/src/ui/components/unity_rich_text.cpp +++ b/src/ui/components/unity_rich_text.cpp @@ -1,4 +1,5 @@ #include "ui/components/unity_rich_text.hpp" + #include #include #include @@ -8,128 +9,156 @@ namespace px::modrim { QString escapeHtml(const QString& text) { - QString out = text.toHtmlEscaped(); - return out.replace('\'', QStringLiteral("'")); + QString out = text.toHtmlEscaped(); + return out.replace('\'', QStringLiteral("'")); } namespace { QString unquote(QString value) { - while (!value.isEmpty() && (value.front() == '\'' || value.front() == '"')) value.remove(0, 1); - while (!value.isEmpty() && (value.back() == '\'' || value.back() == '"')) value.chop(1); - return value; + while (!value.isEmpty() && (value.front() == '\'' || value.front() == '"')) + value.remove(0, 1); + while (!value.isEmpty() && (value.back() == '\'' || value.back() == '"')) + value.chop(1); + return value; } QString color(QString value) { - if (value.startsWith('#')) { - const QString hex = value.mid(1); - if (hex.size() == 3 || hex.size() == 4) { - QString expanded = "#"; - for (QChar c : hex) { expanded += c; expanded += c; } - return expanded; - } - if (hex.size() == 6) return value; - if (hex.size() == 8) return value.left(7); + if (value.startsWith('#')) { + const QString hex = value.mid(1); + if (hex.size() == 3 || hex.size() == 4) { + QString expanded = "#"; + for (QChar c : hex) { + expanded += c; + expanded += c; + } + return expanded; } - static const QHash names{{"red", "#ff0000"}, {"green", "#00ff00"}, - {"blue", "#0000ff"}, {"white", "#ffffff"}, {"black", "#000000"}, - {"yellow", "#ffff00"}, {"cyan", "#00ffff"}, {"magenta", "#ff00ff"}, - {"gray", "#808080"}, {"grey", "#808080"}, {"orange", "#ffa500"}, {"purple", "#800080"}}; - return names.value(value.toLower(), "#ffffff"); + if (hex.size() == 6) return value; + if (hex.size() == 8) return value.left(7); + } + static const QHash names{ + {"red", "#ff0000"}, {"green", "#00ff00"}, {"blue", "#0000ff"}, + {"white", "#ffffff"}, {"black", "#000000"}, {"yellow", "#ffff00"}, + {"cyan", "#00ffff"}, {"magenta", "#ff00ff"}, {"gray", "#808080"}, + {"grey", "#808080"}, {"orange", "#ffa500"}, {"purple", "#800080"}}; + return names.value(value.toLower(), "#ffffff"); } QString closeTag(const QString& tag) { - if (tag == "b" || tag == "bold") return ""; - if (tag == "i" || tag == "italic") return ""; - if (tag == "u" || tag == "underline" || tag == "size") return ""; - if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; - if (tag == "color") return ""; - if (tag == "align" || tag == "indent") return "
  • "; - if (tag == "a" || tag == "link") return ""; - if (tag == "mark" || tag == "sub" || tag == "sup") return ""; - return {}; + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return "
    "; + if (tag == "u" || tag == "underline" || tag == "size") return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "color") return ""; + if (tag == "align" || tag == "indent") return "
    "; + if (tag == "a" || tag == "link") return ""; + if (tag == "mark" || tag == "sub" || tag == "sup") return ""; + return {}; } QString openTag(const QString& tag, QString attr) { - attr = unquote(attr); - if (tag == "b" || tag == "bold") return ""; - if (tag == "i" || tag == "italic") return ""; - if (tag == "u" || tag == "underline") return ""; - if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; - if (tag == "br" || tag == "sub" || tag == "sup") return "<" + tag + ">"; - if (tag == "color") { - static const QRegularExpression valid("^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$"); - return ""; - } - if (tag == "size" || tag == "indent") { - static const QRegularExpression number("^([+-]?\\d+(?:\\.\\d+)?)(px|em|%)?$"); - auto match = number.match(attr); - double value = tag == "size" ? 13.0 : 20.0; - if (match.hasMatch()) { - bool ok = false; - const double raw = match.captured(1).toDouble(&ok); - if (ok && std::isfinite(raw)) { - const QString unit = match.captured(2); - if (tag == "size") { - if (unit == "%") value = 12 * raw / 100; - else if (unit == "em") value = 12 * raw; - else if (attr.startsWith('+') || attr.startsWith('-')) value = 12 + raw; - else value = raw; - if (!std::isfinite(value)) value = 13; - } else if (unit == "%") value = 4 * raw; - else if (unit == "em") value = 16 * raw; - else value = raw; - } - } - if (tag == "size") return ""; - return "
    (std::clamp(value, -10000.0, 10000.0))) + "px;\">"; - } - if (tag == "align") { - attr = attr.toLower(); - if (attr == "justified" || attr == "flush") attr = "justify"; - if (attr != "left" && attr != "right" && attr != "center" && attr != "justify") attr = "left"; - return "
    "; + attr = unquote(attr); + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return ""; + if (tag == "u" || tag == "underline") + return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "br" || tag == "sub" || tag == "sup") return "<" + tag + ">"; + if (tag == "color") { + static const QRegularExpression valid("^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$"); + return ""; + } + if (tag == "size" || tag == "indent") { + static const QRegularExpression number( + "^([+-]?\\d+(?:\\.\\d+)?)(px|em|%)?$"); + auto match = number.match(attr); + double value = tag == "size" ? 13.0 : 20.0; + if (match.hasMatch()) { + bool ok = false; + const double raw = match.captured(1).toDouble(&ok); + if (ok && std::isfinite(raw)) { + const QString unit = match.captured(2); + if (tag == "size") { + if (unit == "%") + value = 12 * raw / 100; + else if (unit == "em") + value = 12 * raw; + else if (attr.startsWith('+') || attr.startsWith('-')) + value = 12 + raw; + else + value = raw; + if (!std::isfinite(value)) value = 13; + } else if (unit == "%") + value = 4 * raw; + else if (unit == "em") + value = 16 * raw; + else + value = raw; + } } - if (tag == "mark") { - QString background = attr.isEmpty() ? QStringLiteral("#ffff00") : color(attr); - if (attr.size() == 9 && attr.startsWith('#')) { - bool ok; - int alpha = attr.right(2).toInt(&ok, 16); - if (ok) background += QString::number(alpha / 255.0, 'f', 2); - } - return ""; + if (tag == "size") + return ""; + return "
    (std::clamp(value, -10000.0, 10000.0))) + + "px;\">"; + } + if (tag == "align") { + attr = attr.toLower(); + if (attr == "justified" || attr == "flush") attr = "justify"; + if (attr != "left" && attr != "right" && attr != "center" && + attr != "justify") + attr = "left"; + return "
    "; + } + if (tag == "mark") { + QString background = + attr.isEmpty() ? QStringLiteral("#ffff00") : color(attr); + if (attr.size() == 9 && attr.startsWith('#')) { + bool ok; + int alpha = attr.right(2).toInt(&ok, 16); + if (ok) background += QString::number(alpha / 255.0, 'f', 2); } - if (tag == "a" || tag == "link") { - const QUrl url(attr); - const QString scheme = url.scheme().toLower(); - if ((scheme == "http" || scheme == "https") && !url.authority().isEmpty()) - return ""; - } - return {}; -} + return ""; + } + if (tag == "a" || tag == "link") { + const QUrl url(attr); + const QString scheme = url.scheme().toLower(); + if ((scheme == "http" || scheme == "https") && !url.authority().isEmpty()) + return ""; + } + return {}; } +} // namespace QString unityRichTextToHtml(const QString& text) { - static const QRegularExpression tags("\\s]+))?\\s*>"); - QString result; - result.reserve(text.size()); - QStringList stack; - qsizetype end = 0; - auto matches = tags.globalMatch(text); - while (matches.hasNext()) { - const auto match = matches.next(); - result += escapeHtml(text.mid(end, match.capturedStart() - end)); - const QString tag = match.captured(1).toLower(); - if (match.captured().startsWith("= 0) { - while (stack.size() > index) stack.removeLast(); - result += closeTag(tag); - } - } else { - const QString html = openTag(tag, match.captured(2)); - if (!html.isEmpty()) { stack.append(tag); result += html; } - } - end = match.capturedEnd(); + static const QRegularExpression tags("\\s]+))?\\s*>"); + QString result; + result.reserve(text.size()); + QStringList stack; + qsizetype end = 0; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + result += escapeHtml(text.mid(end, match.capturedStart() - end)); + const QString tag = match.captured(1).toLower(); + if (match.captured().startsWith("= 0) { + while (stack.size() > index) stack.removeLast(); + result += closeTag(tag); + } + } else { + const QString html = openTag(tag, match.captured(2)); + if (!html.isEmpty()) { + stack.append(tag); + result += html; + } } - result += escapeHtml(text.mid(end)); - result.replace('\n', "
    "); - result.replace("\\n", "
    "); - for (auto it = stack.crbegin(); it != stack.crend(); ++it) result += closeTag(*it); - return result; -} + end = match.capturedEnd(); + } + result += escapeHtml(text.mid(end)); + result.replace('\n', "
    "); + result.replace("\\n", "
    "); + for (auto it = stack.crbegin(); it != stack.crend(); ++it) + result += closeTag(*it); + return result; } +} // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.hpp b/src/ui/components/unity_rich_text.hpp index 50b2d5c..3d3c7dc 100644 --- a/src/ui/components/unity_rich_text.hpp +++ b/src/ui/components/unity_rich_text.hpp @@ -3,4 +3,4 @@ namespace px::modrim { QString escapeHtml(const QString& text); QString unityRichTextToHtml(const QString& text); -} +} // namespace px::modrim diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp index 6adaf62..15bc2db 100644 --- a/src/ui/components/view_rail_panel.cpp +++ b/src/ui/components/view_rail_panel.cpp @@ -3,6 +3,7 @@ #include #include #include + #include #include "ui/theme/constants.hpp" @@ -10,33 +11,36 @@ namespace px::modrim { -ViewRailPanel::ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, QQmlEngine* engine, - QWidget* parent) +ViewRailPanel::ViewRailPanel(const QVariantList& tabs, + const QVariantList& helpEntries, + QQmlEngine* engine, QWidget* parent) : QQuickWidget(engine, parent) { - setResizeMode(QQuickWidget::SizeRootObjectToView); - setClearColor(paletteColor(u"ELEVATE_2")); - setMinimumWidth(kRailMinWidth); - setMaximumWidth(kRailMaxWidth); - - rootContext()->setContextProperty(QStringLiteral("railModel"), tabs); - rootContext()->setContextProperty(QStringLiteral("railHelpEntries"), helpEntries); - setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/IconRail.qml"))); - - QObject* root = rootObject(); - if (root == nullptr) { - spdlog::error("IconRail.qml failed to load: rootObject is null"); - return; - } - connect(root, SIGNAL(tabSelected(int)), this, SIGNAL(currentChanged(int))); - connect(root, SIGNAL(tabHovered(int)), this, SIGNAL(hovered(int))); - connect(root, SIGNAL(settingsRequested()), this, SIGNAL(settingsRequested())); - connect(root, SIGNAL(helpActionRequested(QString)), this, SIGNAL(helpActionRequested(QString))); + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + setMinimumWidth(kRailMinWidth); + setMaximumWidth(kRailMaxWidth); + + rootContext()->setContextProperty(QStringLiteral("railModel"), tabs); + rootContext()->setContextProperty(QStringLiteral("railHelpEntries"), + helpEntries); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/IconRail.qml"))); + + QObject* root = rootObject(); + if (root == nullptr) { + spdlog::error("IconRail.qml failed to load: rootObject is null"); + return; + } + connect(root, SIGNAL(tabSelected(int)), this, SIGNAL(currentChanged(int))); + connect(root, SIGNAL(tabHovered(int)), this, SIGNAL(hovered(int))); + connect(root, SIGNAL(settingsRequested()), this, SIGNAL(settingsRequested())); + connect(root, SIGNAL(helpActionRequested(QString)), this, + SIGNAL(helpActionRequested(QString))); } void ViewRailPanel::setCurrent(int index) { - if (QObject* root = rootObject()) { - root->setProperty("currentIndex", index); - } + if (QObject* root = rootObject()) { + root->setProperty("currentIndex", index); + } } } // namespace px::modrim diff --git a/src/ui/components/view_rail_panel.hpp b/src/ui/components/view_rail_panel.hpp index 423e956..9409061 100644 --- a/src/ui/components/view_rail_panel.hpp +++ b/src/ui/components/view_rail_panel.hpp @@ -9,23 +9,24 @@ class QWidget; namespace px::modrim { -// Hosts IconRail.qml, the left navigation rail. `tabs` are maps with `icon` and `label` -// keys; `helpEntries` are maps with `id` and `label` keys for the Help menu. +// Hosts IconRail.qml, the left navigation rail. `tabs` are maps with `icon` and +// `label` keys; `helpEntries` are maps with `id` and `label` keys for the Help +// menu. class ViewRailPanel : public QQuickWidget { - Q_OBJECT + Q_OBJECT -public: - ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, QQmlEngine* engine, - QWidget* parent = nullptr); + public: + ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, + QQmlEngine* engine, QWidget* parent = nullptr); - // Moves the highlighted tab without emitting currentChanged. - void setCurrent(int index); + // Moves the highlighted tab without emitting currentChanged. + void setCurrent(int index); -signals: - void currentChanged(int index); - void hovered(int index); - void settingsRequested(); - void helpActionRequested(const QString& actionId); + signals: + void currentChanged(int index); + void hovered(int index); + void settingsRequested(); + void helpActionRequested(const QString& actionId); }; } // namespace px::modrim diff --git a/src/ui/config.cpp b/src/ui/config.cpp index 30e4a8e..e843fac 100644 --- a/src/ui/config.cpp +++ b/src/ui/config.cpp @@ -11,30 +11,30 @@ const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); } // namespace Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc) { - return svc.save(kUiPrefsFile, prefs); + return svc.save(kUiPrefsFile, prefs); } UiPrefsService::UiPrefsService(ConfigService& svc) : m_svc(svc) {} QCoro::Task> UiPrefsService::setup() { - auto loaded = m_svc.load(kUiPrefsFile); - if (!loaded) { - co_return std::unexpected(loaded.error()); - } - m_prefs = std::move(*loaded); - co_return {}; + auto loaded = m_svc.load(kUiPrefsFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_prefs = std::move(*loaded); + co_return {}; } const UiPrefs& UiPrefsService::prefs() const { - Q_ASSERT(m_prefs.has_value()); - return *m_prefs; + Q_ASSERT(m_prefs.has_value()); + return *m_prefs; } Result UiPrefsService::savePrefs() const { - if (!m_prefs) { - return {}; - } - return saveUiPrefs(*m_prefs, m_svc); + if (!m_prefs) { + return {}; + } + return saveUiPrefs(*m_prefs, m_svc); } } // namespace px::modrim diff --git a/src/ui/config.hpp b/src/ui/config.hpp index 0d3542c..4d75338 100644 --- a/src/ui/config.hpp +++ b/src/ui/config.hpp @@ -16,19 +16,19 @@ Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc); // Owns the UiPrefs lifecycle: loaded once in setup(), saved on request. The // ConfigService must outlive this object. class UiPrefsService { -public: - explicit UiPrefsService(ConfigService& svc); + public: + explicit UiPrefsService(ConfigService& svc); - QCoro::Task> setup(); + QCoro::Task> setup(); - // Valid only after a successful setup(). - const UiPrefs& prefs() const; + // Valid only after a successful setup(). + const UiPrefs& prefs() const; - Result savePrefs() const; + Result savePrefs() const; -private: - ConfigService& m_svc; - std::optional m_prefs; + private: + ConfigService& m_svc; + std::optional m_prefs; }; } // namespace px::modrim diff --git a/src/ui/context.cpp b/src/ui/context.cpp index aae661c..178edbe 100644 --- a/src/ui/context.cpp +++ b/src/ui/context.cpp @@ -7,7 +7,8 @@ namespace px::modrim { -RailView::RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +RailView::RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app) : QWidget(parent), m_ctx(ctx), m_engine(engine), m_app(app) {} void RailView::openRoute(const QStringList&) {} @@ -22,49 +23,49 @@ AppContext::AppContext(CoreContext& core, UiPrefs prefs) AppContext::~AppContext() = default; void AppContext::registerPlugin(std::unique_ptr plugin) { - m_plugins.registerPlugin(std::move(plugin)); + m_plugins.registerPlugin(std::move(plugin)); } void AppContext::addSettingsSection(SettingsSectionFactory factory) { - m_settingsSections.push_back(std::move(factory)); + m_settingsSections.push_back(std::move(factory)); } void AppContext::navigate(const QString& url) const { - if (m_navigate) { - m_navigate(url); - } + if (m_navigate) { + m_navigate(url); + } } void AppContext::setNavigator(std::function navigate) { - m_navigate = std::move(navigate); + m_navigate = std::move(navigate); } Result AppContext::setupAll() { - if (auto core = m_core.plugins().setupAll(m_core); !core) { - return std::unexpected(core.error()); - } - return m_plugins.setupAll(*this, m_core.plugins().names()); + if (auto core = m_core.plugins().setupAll(m_core); !core) { + return std::unexpected(core.error()); + } + return m_plugins.setupAll(*this, m_core.plugins().names()); } QCoro::Task> AppContext::initAll() { - if (auto core = co_await m_core.plugins().initAll(m_core); !core) { - co_return std::unexpected(core.error()); - } - co_return co_await m_plugins.initAll(*this); + if (auto core = co_await m_core.plugins().initAll(m_core); !core) { + co_return std::unexpected(core.error()); + } + co_return co_await m_plugins.initAll(*this); } QCoro::Task> AppContext::refreshMods(bool full) { - if (auto reloaded = co_await m_core.modService().reload(full); !reloaded) { - co_return std::unexpected(reloaded.error()); - } - co_return static_cast(m_core.allMods().size()); + if (auto reloaded = co_await m_core.modService().reload(full); !reloaded) { + co_return std::unexpected(reloaded.error()); + } + co_return static_cast(m_core.allMods().size()); } QCoro::Task> AppContext::shutdownAll() { - if (auto ui = co_await m_plugins.shutdownAll(); !ui) { - co_return std::unexpected(ui.error()); - } - co_return co_await m_core.plugins().shutdownAll(); + if (auto ui = co_await m_plugins.shutdownAll(); !ui) { + co_return std::unexpected(ui.error()); + } + co_return co_await m_core.plugins().shutdownAll(); } } // namespace px::modrim diff --git a/src/ui/context.hpp b/src/ui/context.hpp index c1c03eb..32750cf 100644 --- a/src/ui/context.hpp +++ b/src/ui/context.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include +#include #include #include #include @@ -8,10 +12,6 @@ #include #include -#include -#include -#include -#include #include "core/context.hpp" #include "core/error.hpp" @@ -25,141 +25,151 @@ namespace px::modrim { class AppContext; -// UI-layer plugins: same lifecycle as core plugins, but their context is the AppContext. +// UI-layer plugins: same lifecycle as core plugins, but their context is the +// AppContext. using UiPlugin = BasicPlugin; using UiPluginRegistry = BasicPluginRegistry; -// Toast sink. Durations are milliseconds; the defaults match the legacy toast manager. +// Toast sink. Durations are milliseconds; the defaults match the legacy toast +// manager. class Notifier { -public: - virtual ~Notifier() = default; + public: + virtual ~Notifier() = default; - virtual void info(const QString& message, int durationMs = 3000) = 0; - virtual void success(const QString& message, int durationMs = 3000) = 0; - virtual void warning(const QString& message, int durationMs = 4000) = 0; - virtual void error(const QString& message, int durationMs = 5000) = 0; + virtual void info(const QString& message, int durationMs = 3000) = 0; + virtual void success(const QString& message, int durationMs = 3000) = 0; + virtual void warning(const QString& message, int durationMs = 4000) = 0; + virtual void error(const QString& message, int durationMs = 5000) = 0; }; -// Class-level metadata of a rail view, read before the view is constructed. Each -// RailView subclass declares `static constexpr RailViewMeta kRailMeta`. +// Class-level metadata of a rail view, read before the view is constructed. +// Each RailView subclass declares `static constexpr RailViewMeta kRailMeta`. struct RailViewMeta { - QLatin1StringView viewId; - QLatin1StringView iconName; - QLatin1StringView label; - // The download queue: the header's downloads button opens it, and the header - // hides its own download progress while it is shown. - bool listsDownloads = false; + QLatin1StringView viewId; + QLatin1StringView iconName; + QLatin1StringView label; + // The download queue: the header's downloads button opens it, and the header + // hides its own download progress while it is shown. + bool listsDownloads = false; }; // Base of widgets registered on the navigation rail. class RailView : public QWidget { - Q_OBJECT + Q_OBJECT -public: - RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + public: + RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); - // Handles the part of a `modrim:///…` link after the view id. - virtual void openRoute(const QStringList& path); + // Handles the part of a `modrim:///…` link after the view id. + virtual void openRoute(const QStringList& path); - // Called when the rail reveals the view ahead of its first use, and when the window - // closes. Views that hold resources (e.g. web profiles) release them in teardown(). - virtual void preload(); - virtual void teardown(); + // Called when the rail reveals the view ahead of its first use, and when the + // window closes. Views that hold resources (e.g. web profiles) release them + // in teardown(). + virtual void preload(); + virtual void teardown(); -protected: - CoreContext& m_ctx; - QQmlEngine* m_engine; - AppContext& m_app; + protected: + CoreContext& m_ctx; + QQmlEngine* m_engine; + AppContext& m_app; }; -using RailViewFactory = std::function; +using RailViewFactory = + std::function; struct RailViewSpec { - RailViewMeta meta; - RailViewFactory create; + RailViewMeta meta; + RailViewFactory create; }; template -concept RailViewType = std::derived_from && requires { - V::kRailMeta; -}; +concept RailViewType = + std::derived_from && requires { V::kRailMeta; }; -// The UI composition context: owns the UI plugin registry, the rail and settings -// contributions, the toast sink and the navigator. Core state is read through -// coreContext(), which must outlive this object. +// The UI composition context: owns the UI plugin registry, the rail and +// settings contributions, the toast sink and the navigator. Core state is read +// through coreContext(), which must outlive this object. class AppContext { -public: - AppContext(CoreContext& core, UiPrefs prefs = {}); - AppContext(const AppContext&) = delete; - AppContext& operator=(const AppContext&) = delete; - AppContext(AppContext&&) = delete; - AppContext& operator=(AppContext&&) = delete; - ~AppContext(); - - // ── Plugin system (UI layer) ────────────────────── - void registerPlugin(std::unique_ptr plugin); - UiPluginRegistry& plugins() noexcept { return m_plugins; } - - // ── Rail views ──────────────────────────────────── - // Appends, or inserts at `position` when given. - template - void addRailView(std::optional position = std::nullopt); - const std::vector& railViews() const noexcept { return m_railViews; } - - // ── Settings sections ───────────────────────────── - // Each section is built per dialog, parented to that dialog. - void addSettingsSection(SettingsSectionFactory factory); - const std::vector& settingsSections() const noexcept { return m_settingsSections; } - - // ── Toasts ──────────────────────────────────────── - // Not owned; nullptr when no toast surface is shown. - Notifier* toasts() const noexcept { return m_toasts; } - void setToasts(Notifier* notifier) noexcept { m_toasts = notifier; } - - // ── Navigation ──────────────────────────────────── - // Opens a `modrim://[/…]` link; a no-op until a navigator is set. - void navigate(const QString& url) const; - void setNavigator(std::function navigate); - - // ── UI prefs ────────────────────────────────────── - UiPrefs& uiPrefs() noexcept { return m_uiPrefs; } - const UiPrefs& uiPrefs() const noexcept { return m_uiPrefs; } - - // ── Core access ─────────────────────────────────── - CoreContext& coreContext() const noexcept { return m_core; } - - // ── Lifecycle ───────────────────────────────────── - // Core plugins first, then UI plugins; UI plugins may depend on core plugin names. - Result setupAll(); - QCoro::Task> initAll(); - // Reloads mods and resolves to how many are now loaded. - QCoro::Task> refreshMods(bool full = false); - // UI plugins shut down before core plugins. - QCoro::Task> shutdownAll(); - -private: - CoreContext& m_core; - UiPluginRegistry m_plugins; - std::vector m_railViews; - std::vector m_settingsSections; - Notifier* m_toasts = nullptr; - std::function m_navigate; - UiPrefs m_uiPrefs; + public: + AppContext(CoreContext& core, UiPrefs prefs = {}); + AppContext(const AppContext&) = delete; + AppContext& operator=(const AppContext&) = delete; + AppContext(AppContext&&) = delete; + AppContext& operator=(AppContext&&) = delete; + ~AppContext(); + + // ── Plugin system (UI layer) ────────────────────── + void registerPlugin(std::unique_ptr plugin); + UiPluginRegistry& plugins() noexcept { return m_plugins; } + + // ── Rail views ──────────────────────────────────── + // Appends, or inserts at `position` when given. + template + void addRailView(std::optional position = std::nullopt); + const std::vector& railViews() const noexcept { + return m_railViews; + } + + // ── Settings sections ───────────────────────────── + // Each section is built per dialog, parented to that dialog. + void addSettingsSection(SettingsSectionFactory factory); + const std::vector& settingsSections() const noexcept { + return m_settingsSections; + } + + // ── Toasts ──────────────────────────────────────── + // Not owned; nullptr when no toast surface is shown. + Notifier* toasts() const noexcept { return m_toasts; } + void setToasts(Notifier* notifier) noexcept { m_toasts = notifier; } + + // ── Navigation ──────────────────────────────────── + // Opens a `modrim://[/…]` link; a no-op until a navigator is set. + void navigate(const QString& url) const; + void setNavigator(std::function navigate); + + // ── UI prefs ────────────────────────────────────── + UiPrefs& uiPrefs() noexcept { return m_uiPrefs; } + const UiPrefs& uiPrefs() const noexcept { return m_uiPrefs; } + + // ── Core access ─────────────────────────────────── + CoreContext& coreContext() const noexcept { return m_core; } + + // ── Lifecycle ───────────────────────────────────── + // Core plugins first, then UI plugins; UI plugins may depend on core plugin + // names. + Result setupAll(); + QCoro::Task> initAll(); + // Reloads mods and resolves to how many are now loaded. + QCoro::Task> refreshMods(bool full = false); + // UI plugins shut down before core plugins. + QCoro::Task> shutdownAll(); + + private: + CoreContext& m_core; + UiPluginRegistry m_plugins; + std::vector m_railViews; + std::vector m_settingsSections; + Notifier* m_toasts = nullptr; + std::function m_navigate; + UiPrefs m_uiPrefs; }; template void AppContext::addRailView(std::optional position) { - RailViewSpec spec{ - V::kRailMeta, - [](CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) -> RailView* { - return new V(ctx, engine, parent, app); - }, - }; - if (position) { - m_railViews.insert(m_railViews.begin() + static_cast(*position), std::move(spec)); - } else { - m_railViews.push_back(std::move(spec)); - } + RailViewSpec spec{ + V::kRailMeta, + [](CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + -> RailView* { return new V(ctx, engine, parent, app); }, + }; + if (position) { + m_railViews.insert( + m_railViews.begin() + static_cast(*position), + std::move(spec)); + } else { + m_railViews.push_back(std::move(spec)); + } } } // namespace px::modrim diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp index 377b379..cd7fd27 100644 --- a/src/ui/mod_selection.cpp +++ b/src/ui/mod_selection.cpp @@ -6,54 +6,56 @@ namespace px::modrim { -ModSelectionPresenter::ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink) +ModSelectionPresenter::ModSelectionPresenter(CoreContext& ctx, + ModInfoSink& sink) : m_ctx(ctx), m_sink(sink) {} QCoro::Task ModSelectionPresenter::show(QString uuid) { - const std::uint64_t generation = ++m_generation; + const std::uint64_t generation = ++m_generation; - if (uuid.isEmpty()) { - m_sink.clear(); - co_return; - } - - const auto& mods = m_ctx.allMods(); - const auto found = mods.constFind(uuid); - if (found == mods.cend()) { - m_sink.clear(); - co_return; - } - - const ListedMod& mod = *found.value(); - m_sink.showMod(mod, m_ctx.diagnosticsService().issuesFor(uuid)); - - std::optional packageId; - if (const auto* about = dynamic_cast(&mod)) { - packageId = about->packageId.str(); - } - const QStringList activePackageIds = resolveActivePackageIds(); + if (uuid.isEmpty()) { + m_sink.clear(); + co_return; + } - if (generation != m_generation) { - co_return; - } - co_await m_sink.setTimeAnalytics(packageId, activePackageIds); + const auto& mods = m_ctx.allMods(); + const auto found = mods.constFind(uuid); + if (found == mods.cend()) { + m_sink.clear(); + co_return; + } + + const ListedMod& mod = *found.value(); + m_sink.showMod(mod, m_ctx.diagnosticsService().issuesFor(uuid)); + + std::optional packageId; + if (const auto* about = dynamic_cast(&mod)) { + packageId = about->packageId.str(); + } + const QStringList activePackageIds = resolveActivePackageIds(); + + if (generation != m_generation) { + co_return; + } + co_await m_sink.setTimeAnalytics(packageId, activePackageIds); } void ModSelectionPresenter::clear() { - ++m_generation; - m_sink.clear(); + ++m_generation; + m_sink.clear(); } QStringList ModSelectionPresenter::resolveActivePackageIds() const { - const QSet active(m_ctx.activeUuids().cbegin(), m_ctx.activeUuids().cend()); - QStringList packageIds; - for (const ListedModPtr& mod : m_ctx.allMods()) { - const auto* about = dynamic_cast(mod.get()); - if (about && active.contains(about->uuid())) { - packageIds.append(about->packageId.str()); - } + const QSet active(m_ctx.activeUuids().cbegin(), + m_ctx.activeUuids().cend()); + QStringList packageIds; + for (const ListedModPtr& mod : m_ctx.allMods()) { + const auto* about = dynamic_cast(mod.get()); + if (about && active.contains(about->uuid())) { + packageIds.append(about->packageId.str()); } - return packageIds; + } + return packageIds; } } // namespace px::modrim diff --git a/src/ui/mod_selection.hpp b/src/ui/mod_selection.hpp index 13f54f9..cb6c465 100644 --- a/src/ui/mod_selection.hpp +++ b/src/ui/mod_selection.hpp @@ -1,12 +1,12 @@ #pragma once +#include +#include +#include #include #include #include -#include -#include -#include #include "core/context.hpp" #include "core/models/metadata/structures.hpp" @@ -14,33 +14,38 @@ namespace px::modrim { -// The mod-details surface the presenter drives. The mod info panel implements it. +// The mod-details surface the presenter drives. The mod info panel implements +// it. class ModInfoSink { -public: - virtual ~ModInfoSink() = default; - - virtual void clear() = 0; - virtual void showMod(const ListedMod& mod, const QList& issues) = 0; - // `packageId` is nullopt for mods that have no package id (non-About.xml mods). - virtual QCoro::Task setTimeAnalytics(const std::optional& packageId, - const QStringList& activePackageIds) = 0; + public: + virtual ~ModInfoSink() = default; + + virtual void clear() = 0; + virtual void showMod(const ListedMod& mod, + const QList& issues) = 0; + // `packageId` is nullopt for mods that have no package id (non-About.xml + // mods). + virtual QCoro::Task setTimeAnalytics( + const std::optional& packageId, + const QStringList& activePackageIds) = 0; }; // Loads a selected mod's details into a ModInfoSink for any view. class ModSelectionPresenter { -public: - ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink); + public: + ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink); - // A stale show() (superseded by a later one or clear()) stops before its analytics await. - QCoro::Task show(QString uuid); - void clear(); + // A stale show() (superseded by a later one or clear()) stops before its + // analytics await. + QCoro::Task show(QString uuid); + void clear(); -private: - QStringList resolveActivePackageIds() const; + private: + QStringList resolveActivePackageIds() const; - CoreContext& m_ctx; - ModInfoSink& m_sink; - std::uint64_t m_generation = 0; + CoreContext& m_ctx; + ModInfoSink& m_sink; + std::uint64_t m_generation = 0; }; } // namespace px::modrim diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp index b27558c..5707581 100644 --- a/src/ui/models/impact.cpp +++ b/src/ui/models/impact.cpp @@ -1,19 +1,25 @@ #include "ui/models/impact.hpp" + +#include + #include "core/services/startup_impact_service/models.hpp" #include "ui/theme/palette.hpp" -#include namespace px::modrim { QString formatDuration(double seconds) { - if (seconds < 0.001) return QStringLiteral("0 ms"); - if (seconds < 1) return QStringLiteral("%1 ms").arg(static_cast(std::nearbyint(seconds * 1000))); - if (seconds < 60) return QStringLiteral("%1 s").arg(seconds, 0, 'f', 2); - const auto rounded = static_cast(std::nearbyint(seconds)); - return QStringLiteral("%1m %2s").arg(rounded / 60).arg(rounded % 60, 2, 10, QLatin1Char('0')); + if (seconds < 0.001) return QStringLiteral("0 ms"); + if (seconds < 1) + return QStringLiteral("%1 ms").arg( + static_cast(std::nearbyint(seconds * 1000))); + if (seconds < 60) return QStringLiteral("%1 s").arg(seconds, 0, 'f', 2); + const auto rounded = static_cast(std::nearbyint(seconds)); + return QStringLiteral("%1m %2s") + .arg(rounded / 60) + .arg(rounded % 60, 2, 10, QLatin1Char('0')); } QString impactColor(double seconds) { - using namespace startup_impact; - if (seconds < kImpactWarnThresholdS) return paletteValue(u"SUCCESS"); - if (seconds < kImpactHighThresholdS) return paletteValue(u"WARNING"); - return paletteValue(u"DANGER"); -} + using namespace startup_impact; + if (seconds < kImpactWarnThresholdS) return paletteValue(u"SUCCESS"); + if (seconds < kImpactHighThresholdS) return paletteValue(u"WARNING"); + return paletteValue(u"DANGER"); } +} // namespace px::modrim diff --git a/src/ui/models/impact.hpp b/src/ui/models/impact.hpp index 0930e55..a020554 100644 --- a/src/ui/models/impact.hpp +++ b/src/ui/models/impact.hpp @@ -3,4 +3,4 @@ namespace px::modrim { QString formatDuration(double seconds); QString impactColor(double seconds); -} +} // namespace px::modrim diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp index ca6575d..2dec95a 100644 --- a/src/ui/models/mod_list_model.cpp +++ b/src/ui/models/mod_list_model.cpp @@ -1,200 +1,301 @@ #include "ui/models/mod_list_model.hpp" + +#include +#include + #include "core/services/diagnostics_service.hpp" #include "ui/models/impact.hpp" #include "ui/theme/palette.hpp" -#include -#include namespace px::modrim { QString providerLabel(const QString& providerId, const QString& packageId) { - const auto pid = providerId.trimmed().toLower(); - const auto pkg = packageId.trimmed().toLower(); - if (pid == "steam" || pid == "downloaded") return QStringLiteral("Workshop"); - if (pid == "local") return QStringLiteral("Local"); - if (pid == "core") return !pkg.isEmpty() && pkg != "ludeon.rimworld" ? QStringLiteral("DLC") : QStringLiteral("Core"); - if (pkg.startsWith("ludeon.rimworld.")) return QStringLiteral("DLC"); - if (pkg == "ludeon.rimworld") return QStringLiteral("Core"); - return pid.isEmpty() ? pid : pid.first(1).toUpper() + pid.sliced(1); + const auto pid = providerId.trimmed().toLower(); + const auto pkg = packageId.trimmed().toLower(); + if (pid == "steam" || pid == "downloaded") return QStringLiteral("Workshop"); + if (pid == "local") return QStringLiteral("Local"); + if (pid == "core") + return !pkg.isEmpty() && pkg != "ludeon.rimworld" ? QStringLiteral("DLC") + : QStringLiteral("Core"); + if (pkg.startsWith("ludeon.rimworld.")) return QStringLiteral("DLC"); + if (pkg == "ludeon.rimworld") return QStringLiteral("Core"); + return pid.isEmpty() ? pid : pid.first(1).toUpper() + pid.sliced(1); } QString sectionName(bool checked, int activeCount, int inactiveCount) { - return checked || activeCount == 0 || inactiveCount == 0 ? QString{} : QStringLiteral("Inactive (%1)").arg(inactiveCount); + return checked || activeCount == 0 || inactiveCount == 0 + ? QString{} + : QStringLiteral("Inactive (%1)").arg(inactiveCount); } -ModListModel::ModListModel(QHash colors, DiagnosticsService* diagnostics, QObject* parent) - : QAbstractListModel(parent), m_providerColors(std::move(colors)), m_diagnostics(diagnostics) { - if (m_diagnostics) m_diagnosticsConnection = m_diagnostics->diagnosticsSummaryChanged.connect( +ModListModel::ModListModel(QHash colors, + DiagnosticsService* diagnostics, QObject* parent) + : QAbstractListModel(parent), + m_providerColors(std::move(colors)), + m_diagnostics(diagnostics) { + if (m_diagnostics) + m_diagnosticsConnection = m_diagnostics->diagnosticsSummaryChanged.connect( [this](const auto& summaries) { diagnosticsChanged(summaries); }); } ModListModel::~ModListModel() { - if (m_diagnostics) m_diagnostics->diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); + if (m_diagnostics) + m_diagnostics->diagnosticsSummaryChanged.disconnect( + m_diagnosticsConnection); +} +int ModListModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} +const ModItem* ModListModel::getItem(int row) const { + return row >= 0 && row < m_items.size() ? &m_items[row] : nullptr; } -int ModListModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_items.size()); } -const ModItem* ModListModel::getItem(int row) const { return row >= 0 && row < m_items.size() ? &m_items[row] : nullptr; } const ModItem* ModListModel::getItemByUuid(const QString& uuid) const { - for (const auto& item : m_items) if (item.uuid == uuid) return &item; - return nullptr; + for (const auto& item : m_items) + if (item.uuid == uuid) return &item; + return nullptr; } QVariant ModListModel::data(const QModelIndex& index, int role) const { - const auto* item = index.isValid() ? getItem(index.row()) : nullptr; - if (!item) return {}; - const auto* about = dynamic_cast(item->mod.get()); - switch (role) { - case Qt::DisplayRole: return item->mod->name; - case Qt::ToolTipRole: return item->mod->description; - case kCheckStateRole: return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); - case kPackageIdRole: return about ? about->packageId.str() : QString{}; - case kModVersionRole: return about ? about->modVersion : QString{}; - case kProviderColorRole: return item->providerColor; - case kUuidRole: return item->uuid; - case kStartupImpactRole: return item->startupImpactS; - case kStartupImpactColorRole: return impactColor(item->startupImpactS); - case kStartupImpactTextRole: return formatDuration(item->startupImpactS); - case kLoadIndexRole: return item->loadIndex; - case kIsActiveRole: return item->checked; - case kSectionNameRole: return sectionName(item->checked, m_activeCount, static_cast(m_items.size()) - m_activeCount); - case kProviderLabelRole: return item->providerLabel; - case kHasErrorRole: case kHasWarningRole: case kErrorTooltipRole: case kWarningTooltipRole: { - const auto diag = m_diagnostics ? m_diagnostics->summaryFor(item->uuid) : std::nullopt; - if (role == kHasErrorRole) return diag && diag->hasErrors ? 1 : 0; - if (role == kHasWarningRole) return diag && diag->hasWarnings ? 1 : 0; - if (role == kErrorTooltipRole) return diag ? diag->errorTooltip : QString{}; - return diag ? diag->warningTooltip : QString{}; - } - default: return {}; + const auto* item = index.isValid() ? getItem(index.row()) : nullptr; + if (!item) return {}; + const auto* about = dynamic_cast(item->mod.get()); + switch (role) { + case Qt::DisplayRole: + return item->mod->name; + case Qt::ToolTipRole: + return item->mod->description; + case kCheckStateRole: + return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); + case kPackageIdRole: + return about ? about->packageId.str() : QString{}; + case kModVersionRole: + return about ? about->modVersion : QString{}; + case kProviderColorRole: + return item->providerColor; + case kUuidRole: + return item->uuid; + case kStartupImpactRole: + return item->startupImpactS; + case kStartupImpactColorRole: + return impactColor(item->startupImpactS); + case kStartupImpactTextRole: + return formatDuration(item->startupImpactS); + case kLoadIndexRole: + return item->loadIndex; + case kIsActiveRole: + return item->checked; + case kSectionNameRole: + return sectionName(item->checked, m_activeCount, + static_cast(m_items.size()) - m_activeCount); + case kProviderLabelRole: + return item->providerLabel; + case kHasErrorRole: + case kHasWarningRole: + case kErrorTooltipRole: + case kWarningTooltipRole: { + const auto diag = + m_diagnostics ? m_diagnostics->summaryFor(item->uuid) : std::nullopt; + if (role == kHasErrorRole) return diag && diag->hasErrors ? 1 : 0; + if (role == kHasWarningRole) return diag && diag->hasWarnings ? 1 : 0; + if (role == kErrorTooltipRole) + return diag ? diag->errorTooltip : QString{}; + return diag ? diag->warningTooltip : QString{}; } + default: + return {}; + } } -bool ModListModel::setData(const QModelIndex& index, const QVariant& value, int role) { - if (!index.isValid() || !getItem(index.row()) || role != kCheckStateRole) return false; - setCheckStates({index.row()}, value.toInt() == Qt::Checked); - return true; +bool ModListModel::setData(const QModelIndex& index, const QVariant& value, + int role) { + if (!index.isValid() || !getItem(index.row()) || role != kCheckStateRole) + return false; + setCheckStates({index.row()}, value.toInt() == Qt::Checked); + return true; } Qt::ItemFlags ModListModel::flags(const QModelIndex& index) const { - if (!index.isValid()) return Qt::ItemIsDropEnabled; - return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | Qt::ItemIsUserCheckable; + if (!index.isValid()) return Qt::ItemIsDropEnabled; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | + Qt::ItemIsUserCheckable; } QHash ModListModel::roleNames() const { - return {{Qt::DisplayRole,"name"},{kCheckStateRole,"checkState"},{kPackageIdRole,"packageId"}, - {kModVersionRole,"modVersion"},{kProviderColorRole,"providerColor"},{kUuidRole,"uuid"}, - {kHasErrorRole,"hasError"},{kHasWarningRole,"hasWarning"},{kErrorTooltipRole,"errorTooltip"}, - {kWarningTooltipRole,"warningTooltip"},{kStartupImpactRole,"startupImpact"},{kLoadIndexRole,"loadIndex"}, - {kIsActiveRole,"isActive"},{kSectionNameRole,"sectionName"},{kProviderLabelRole,"providerLabel"}, - {kStartupImpactColorRole,"startupImpactColor"},{kStartupImpactTextRole,"startupImpactText"},{Qt::ToolTipRole,"toolTip"}}; -} -void ModListModel::updateProviderColors(QHash colors) { m_providerColors = std::move(colors); } -ModItem ModListModel::makeItem(const QString& uuid, ListedModPtr mod, bool checked) const { - const auto* about = dynamic_cast(mod.get()); - auto label = providerLabel(mod->providerId, about ? about->packageId.str() : QString{}); - auto color = m_providerColors.value(mod->providerId, paletteValue(u"TEXT_DIM")); - return {std::move(mod), uuid, checked, std::move(color), 0, -1, std::move(label)}; -} -void ModListModel::loadMods(const QHash& mods, const QStringList& activeUuids) { - beginResetModel(); - m_items.clear(); - m_items.reserve(mods.size()); - const QSet active(activeUuids.begin(), activeUuids.end()); - for (const auto& uuid : activeUuids) { - const auto it = mods.constFind(uuid); - if (it != mods.cend()) m_items.append(makeItem(uuid, it.value(), true)); - } - QList inactive; - inactive.reserve(mods.size() - m_items.size()); - for (auto it = mods.cbegin(); it != mods.cend(); ++it) - if (!active.contains(it.key())) inactive.append(makeItem(it.key(), it.value(), false)); - std::stable_sort(inactive.begin(), inactive.end(), [](const auto& a, const auto& b) { return a.mod->name.toLower() < b.mod->name.toLower(); }); - m_items.append(std::move(inactive)); - updateLoadIndices(); - endResetModel(); + return {{Qt::DisplayRole, "name"}, + {kCheckStateRole, "checkState"}, + {kPackageIdRole, "packageId"}, + {kModVersionRole, "modVersion"}, + {kProviderColorRole, "providerColor"}, + {kUuidRole, "uuid"}, + {kHasErrorRole, "hasError"}, + {kHasWarningRole, "hasWarning"}, + {kErrorTooltipRole, "errorTooltip"}, + {kWarningTooltipRole, "warningTooltip"}, + {kStartupImpactRole, "startupImpact"}, + {kLoadIndexRole, "loadIndex"}, + {kIsActiveRole, "isActive"}, + {kSectionNameRole, "sectionName"}, + {kProviderLabelRole, "providerLabel"}, + {kStartupImpactColorRole, "startupImpactColor"}, + {kStartupImpactTextRole, "startupImpactText"}, + {Qt::ToolTipRole, "toolTip"}}; +} +void ModListModel::updateProviderColors(QHash colors) { + m_providerColors = std::move(colors); +} +ModItem ModListModel::makeItem(const QString& uuid, ListedModPtr mod, + bool checked) const { + const auto* about = dynamic_cast(mod.get()); + auto label = providerLabel(mod->providerId, + about ? about->packageId.str() : QString{}); + auto color = + m_providerColors.value(mod->providerId, paletteValue(u"TEXT_DIM")); + return {std::move(mod), uuid, checked, std::move(color), 0, -1, + std::move(label)}; +} +void ModListModel::loadMods(const QHash& mods, + const QStringList& activeUuids) { + beginResetModel(); + m_items.clear(); + m_items.reserve(mods.size()); + const QSet active(activeUuids.begin(), activeUuids.end()); + for (const auto& uuid : activeUuids) { + const auto it = mods.constFind(uuid); + if (it != mods.cend()) m_items.append(makeItem(uuid, it.value(), true)); + } + QList inactive; + inactive.reserve(mods.size() - m_items.size()); + for (auto it = mods.cbegin(); it != mods.cend(); ++it) + if (!active.contains(it.key())) + inactive.append(makeItem(it.key(), it.value(), false)); + std::stable_sort(inactive.begin(), inactive.end(), + [](const auto& a, const auto& b) { + return a.mod->name.toLower() < b.mod->name.toLower(); + }); + m_items.append(std::move(inactive)); + updateLoadIndices(); + endResetModel(); } QStringList ModListModel::activeUuids() const { - QStringList result; - result.reserve(m_activeCount); - for (const auto& item : m_items) if (item.checked) result.append(item.uuid); - return result; + QStringList result; + result.reserve(m_activeCount); + for (const auto& item : m_items) + if (item.checked) result.append(item.uuid); + return result; } QList ModListModel::updateLoadIndices() { - QList changed; - int count = 0; - for (int row = 0; row < m_items.size(); ++row) { - auto& item = m_items[row]; - const int index = item.checked ? ++count : -1; - if (item.loadIndex != index) { item.loadIndex = index; changed.append(row); } + QList changed; + int count = 0; + for (int row = 0; row < m_items.size(); ++row) { + auto& item = m_items[row]; + const int index = item.checked ? ++count : -1; + if (item.loadIndex != index) { + item.loadIndex = index; + changed.append(row); } - if (m_activeCount != count) { m_activeCount = count; emit activeCountChanged(); } - return changed; + } + if (m_activeCount != count) { + m_activeCount = count; + emit activeCountChanged(); + } + return changed; } bool ModListModel::moveRow(int source, int target) { - if (source == target || !getItem(source) || !getItem(target)) return false; - beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); - m_items.move(source, target); - endMoveRows(); - const auto changed = updateLoadIndices(); - if (!changed.isEmpty()) emit dataChanged(index(changed.first()), index(changed.last()), {kLoadIndexRole,kSectionNameRole}); - return true; + if (source == target || !getItem(source) || !getItem(target)) return false; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + m_items.move(source, target); + endMoveRows(); + const auto changed = updateLoadIndices(); + if (!changed.isEmpty()) + emit dataChanged(index(changed.first()), index(changed.last()), + {kLoadIndexRole, kSectionNameRole}); + return true; } void ModListModel::commitOrder(const QStringList& uuids) { - QHash oldRows; - for (int row = 0; row < m_items.size(); ++row) oldRows.insert(m_items[row].uuid, row); - QList order; - order.reserve(m_items.size()); - QSet ordered; - for (const auto& uuid : uuids) if (oldRows.contains(uuid)) { order.append(oldRows.value(uuid)); ordered.insert(uuid); } - QList inactive; - for (int row = 0; row < m_items.size(); ++row) if (!ordered.contains(m_items[row].uuid)) { - if (m_items[row].checked) order.append(row); else inactive.append(row); + QHash oldRows; + for (int row = 0; row < m_items.size(); ++row) + oldRows.insert(m_items[row].uuid, row); + QList order; + order.reserve(m_items.size()); + QSet ordered; + for (const auto& uuid : uuids) + if (oldRows.contains(uuid)) { + order.append(oldRows.value(uuid)); + ordered.insert(uuid); } - std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { return m_items[a].mod->name.toLower() < m_items[b].mod->name.toLower(); }); - order.append(inactive); - bool same = order.size() == m_items.size(); - for (int i = 0; same && i < order.size(); ++i) same = order[i] == i; - if (same) return; - emit layoutAboutToBeChanged(); - const auto persistent = persistentIndexList(); - QList next; - next.reserve(order.size()); - QHash newRows; - for (int row = 0; row < order.size(); ++row) { newRows.insert(order[row], row); next.append(m_items[order[row]]); } - m_items = std::move(next); - updateLoadIndices(); - QModelIndexList replacements; - replacements.reserve(persistent.size()); - for (const auto& previous : persistent) replacements.append(index(newRows.value(previous.row(), -1))); - changePersistentIndexList(persistent, replacements); - emit layoutChanged(); + QList inactive; + for (int row = 0; row < m_items.size(); ++row) + if (!ordered.contains(m_items[row].uuid)) { + if (m_items[row].checked) + order.append(row); + else + inactive.append(row); + } + std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { + return m_items[a].mod->name.toLower() < m_items[b].mod->name.toLower(); + }); + order.append(inactive); + bool same = order.size() == m_items.size(); + for (int i = 0; same && i < order.size(); ++i) same = order[i] == i; + if (same) return; + emit layoutAboutToBeChanged(); + const auto persistent = persistentIndexList(); + QList next; + next.reserve(order.size()); + QHash newRows; + for (int row = 0; row < order.size(); ++row) { + newRows.insert(order[row], row); + next.append(m_items[order[row]]); + } + m_items = std::move(next); + updateLoadIndices(); + QModelIndexList replacements; + replacements.reserve(persistent.size()); + for (const auto& previous : persistent) + replacements.append(index(newRows.value(previous.row(), -1))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); } void ModListModel::setCheckable(const QSet& uuids, bool checked) { - QList rows; - for (int row = 0; row < m_items.size(); ++row) - if (uuids.contains(m_items[row].uuid) && m_items[row].checked != checked) rows.append(row); - setCheckStates(rows, checked); + QList rows; + for (int row = 0; row < m_items.size(); ++row) + if (uuids.contains(m_items[row].uuid) && m_items[row].checked != checked) + rows.append(row); + setCheckStates(rows, checked); } void ModListModel::setCheckStates(const QList& rows, bool checked) { - int first = static_cast(m_items.size()); - for (int row : rows) if (getItem(row) && m_items[row].checked != checked) { - m_items[row].checked = checked; first = std::min(first, row); + int first = static_cast(m_items.size()); + for (int row : rows) + if (getItem(row) && m_items[row].checked != checked) { + m_items[row].checked = checked; + first = std::min(first, row); } - if (first == m_items.size()) return; - const auto loadChanged = updateLoadIndices(); - if (!loadChanged.isEmpty()) first = std::min(first, loadChanged.first()); - emit dataChanged(index(first), index(rowCount()-1), {kCheckStateRole,kLoadIndexRole,kIsActiveRole,kSectionNameRole}); - emit activeModsChanged(); + if (first == m_items.size()) return; + const auto loadChanged = updateLoadIndices(); + if (!loadChanged.isEmpty()) first = std::min(first, loadChanged.first()); + emit dataChanged( + index(first), index(rowCount() - 1), + {kCheckStateRole, kLoadIndexRole, kIsActiveRole, kSectionNameRole}); + emit activeModsChanged(); } void ModListModel::setStartupImpact(const QHash& impacts) { - if (m_items.isEmpty()) return; - for (auto& item : m_items) { - const auto* about = dynamic_cast(item.mod.get()); - item.startupImpactS = about ? impacts.value(about->packageId.str()) : 0; - } - emit dataChanged(index(0), index(rowCount()-1), {kStartupImpactRole,kStartupImpactColorRole,kStartupImpactTextRole}); -} -void ModListModel::diagnosticsChanged(const QHash& summaries) { - if (m_items.isEmpty()) return; - int first = rowCount(), last = -1; - for (int row = 0; row < m_items.size(); ++row) { - const auto& uuid = m_items[row].uuid; - if (m_lastDiagnostics.contains(uuid) != summaries.contains(uuid) || m_lastDiagnostics.value(uuid) != summaries.value(uuid)) { - first = std::min(first, row); last = row; - } - } - m_lastDiagnostics = summaries; - if (last >= 0) emit dataChanged(index(first), index(last), {kHasErrorRole,kHasWarningRole,kErrorTooltipRole,kWarningTooltipRole}); + if (m_items.isEmpty()) return; + for (auto& item : m_items) { + const auto* about = dynamic_cast(item.mod.get()); + item.startupImpactS = about ? impacts.value(about->packageId.str()) : 0; + } + emit dataChanged( + index(0), index(rowCount() - 1), + {kStartupImpactRole, kStartupImpactColorRole, kStartupImpactTextRole}); } +void ModListModel::diagnosticsChanged( + const QHash& summaries) { + if (m_items.isEmpty()) return; + int first = rowCount(), last = -1; + for (int row = 0; row < m_items.size(); ++row) { + const auto& uuid = m_items[row].uuid; + if (m_lastDiagnostics.contains(uuid) != summaries.contains(uuid) || + m_lastDiagnostics.value(uuid) != summaries.value(uuid)) { + first = std::min(first, row); + last = row; + } + } + m_lastDiagnostics = summaries; + if (last >= 0) + emit dataChanged(index(first), index(last), + {kHasErrorRole, kHasWarningRole, kErrorTooltipRole, + kWarningTooltipRole}); } +} // namespace px::modrim diff --git a/src/ui/models/mod_list_model.hpp b/src/ui/models/mod_list_model.hpp index 00085a1..99f1281 100644 --- a/src/ui/models/mod_list_model.hpp +++ b/src/ui/models/mod_list_model.hpp @@ -5,66 +5,87 @@ #include #include #include + #include "core/models/metadata/structures.hpp" #include "core/models/view/diagnostics.hpp" -namespace px::modrim { class DiagnosticsService; } +namespace px::modrim { +class DiagnosticsService; +} namespace px::modrim { QString providerLabel(const QString& providerId, const QString& packageId = {}); QString sectionName(bool checked, int activeCount, int inactiveCount); struct ModItem { - ListedModPtr mod; - QString uuid; - bool checked = false; - QString providerColor; - double startupImpactS = 0; - int loadIndex = -1; - QString providerLabel; + ListedModPtr mod; + QString uuid; + bool checked = false; + QString providerColor; + double startupImpactS = 0; + int loadIndex = -1; + QString providerLabel; }; class ModListModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) -public: - enum Role { - kCheckStateRole = Qt::UserRole + 1, kPackageIdRole, kModVersionRole, kProviderColorRole, - kUuidRole, kHasErrorRole, kHasWarningRole, kErrorTooltipRole, kWarningTooltipRole, - kStartupImpactRole, kLoadIndexRole, kIsActiveRole, kSectionNameRole, kProviderLabelRole, - kStartupImpactColorRole, kStartupImpactTextRole - }; - Q_ENUM(Role) - explicit ModListModel(QHash providerColors = {}, - DiagnosticsService* diagnostics = nullptr, QObject* parent = nullptr); - ~ModListModel() override; - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; - Qt::ItemFlags flags(const QModelIndex& index) const override; - QHash roleNames() const override; - void updateProviderColors(QHash colors); - void loadMods(const QHash& mods, const QStringList& activeUuids); - QStringList activeUuids() const; - int activeCount() const { return m_activeCount; } - const ModItem* getItem(int row) const; - const ModItem* getItemByUuid(const QString& uuid) const; - bool moveRow(int sourceRow, int targetRow); - void commitOrder(const QStringList& uuids); - void setCheckable(const QSet& uuids, bool checked); - void setCheckStates(const QList& rows, bool checked); - void setStartupImpact(const QHash& impacts = {}); -signals: - void activeModsChanged(); - void activeCountChanged(); -private: - QList updateLoadIndices(); - ModItem makeItem(const QString& uuid, ListedModPtr mod, bool checked) const; - void diagnosticsChanged(const QHash& summaries); - QHash m_providerColors; - QList m_items; - int m_activeCount = 0; - DiagnosticsService* m_diagnostics; - std::uint64_t m_diagnosticsConnection = 0; - QHash m_lastDiagnostics; + Q_OBJECT + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) + public: + enum Role { + kCheckStateRole = Qt::UserRole + 1, + kPackageIdRole, + kModVersionRole, + kProviderColorRole, + kUuidRole, + kHasErrorRole, + kHasWarningRole, + kErrorTooltipRole, + kWarningTooltipRole, + kStartupImpactRole, + kLoadIndexRole, + kIsActiveRole, + kSectionNameRole, + kProviderLabelRole, + kStartupImpactColorRole, + kStartupImpactTextRole + }; + Q_ENUM(Role) + explicit ModListModel(QHash providerColors = {}, + DiagnosticsService* diagnostics = nullptr, + QObject* parent = nullptr); + ~ModListModel() override; + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + void updateProviderColors(QHash colors); + void loadMods(const QHash& mods, + const QStringList& activeUuids); + QStringList activeUuids() const; + int activeCount() const { return m_activeCount; } + const ModItem* getItem(int row) const; + const ModItem* getItemByUuid(const QString& uuid) const; + bool moveRow(int sourceRow, int targetRow); + void commitOrder(const QStringList& uuids); + void setCheckable(const QSet& uuids, bool checked); + void setCheckStates(const QList& rows, bool checked); + void setStartupImpact(const QHash& impacts = {}); + signals: + void activeModsChanged(); + void activeCountChanged(); + + private: + QList updateLoadIndices(); + ModItem makeItem(const QString& uuid, ListedModPtr mod, bool checked) const; + void diagnosticsChanged( + const QHash& summaries); + QHash m_providerColors; + QList m_items; + int m_activeCount = 0; + DiagnosticsService* m_diagnostics; + std::uint64_t m_diagnosticsConnection = 0; + QHash m_lastDiagnostics; }; -} +} // namespace px::modrim diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp index 38d6a1e..fe5e2f4 100644 --- a/src/ui/models/mod_list_proxy_model.cpp +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -1,138 +1,188 @@ #include "ui/models/mod_list_proxy_model.hpp" + #include #include namespace px::modrim { -ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) { - connect(m_source, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::sourceDataChanged); - connect(m_source, &QAbstractItemModel::layoutChanged, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); - connect(m_source, &QAbstractItemModel::modelReset, this, [this] { beginResetModel(); rebuild(); endResetModel(); }); - connect(m_source, &QAbstractItemModel::rowsMoved, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); - connect(m_source, &QAbstractItemModel::rowsInserted, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); - connect(m_source, &QAbstractItemModel::rowsRemoved, this, [this] { if (!m_inProxyMove) layoutRebuild(); }); +ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) + : QAbstractListModel(parent), m_source(source) { + connect(m_source, &QAbstractItemModel::dataChanged, this, + &ModListProxyModel::sourceDataChanged); + connect(m_source, &QAbstractItemModel::layoutChanged, this, [this] { + if (!m_inProxyMove) layoutRebuild(); + }); + connect(m_source, &QAbstractItemModel::modelReset, this, [this] { + beginResetModel(); rebuild(); + endResetModel(); + }); + connect(m_source, &QAbstractItemModel::rowsMoved, this, [this] { + if (!m_inProxyMove) layoutRebuild(); + }); + connect(m_source, &QAbstractItemModel::rowsInserted, this, [this] { + if (!m_inProxyMove) layoutRebuild(); + }); + connect(m_source, &QAbstractItemModel::rowsRemoved, this, [this] { + if (!m_inProxyMove) layoutRebuild(); + }); + rebuild(); +} +int ModListProxyModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_visibleUuids.size()); } -int ModListProxyModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_visibleUuids.size()); } void ModListProxyModel::rebuildSourceMapping() { - m_sourceRows.clear(); - for (int row = 0; row < m_source->rowCount(); ++row) m_sourceRows.insert(m_source->getItem(row)->uuid, row); + m_sourceRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) + m_sourceRows.insert(m_source->getItem(row)->uuid, row); } bool ModListProxyModel::acceptItem(const ModItem& item) const { - if (m_sidebarUuids && !m_sidebarUuids->contains(item.uuid)) return false; - if (m_searchText.isEmpty() || item.mod->name.trimmed().toCaseFolded().contains(m_searchText)) return true; - const auto* about = dynamic_cast(item.mod.get()); - return about && about->packageId.str().trimmed().toCaseFolded().contains(m_searchText); + if (m_sidebarUuids && !m_sidebarUuids->contains(item.uuid)) return false; + if (m_searchText.isEmpty() || + item.mod->name.trimmed().toCaseFolded().contains(m_searchText)) + return true; + const auto* about = dynamic_cast(item.mod.get()); + return about && + about->packageId.str().trimmed().toCaseFolded().contains(m_searchText); } void ModListProxyModel::rebuild() { - rebuildSourceMapping(); - m_visibleUuids.clear(); m_proxyRows.clear(); - for (int row = 0; row < m_source->rowCount(); ++row) { - const auto& item = *m_source->getItem(row); - if (acceptItem(item)) { m_proxyRows.insert(item.uuid, static_cast(m_visibleUuids.size())); m_visibleUuids.append(item.uuid); } + rebuildSourceMapping(); + m_visibleUuids.clear(); + m_proxyRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) { + const auto& item = *m_source->getItem(row); + if (acceptItem(item)) { + m_proxyRows.insert(item.uuid, static_cast(m_visibleUuids.size())); + m_visibleUuids.append(item.uuid); } - updateVisibleCounts(); + } + updateVisibleCounts(); } void ModListProxyModel::updateVisibleCounts() { - int active = 0; - for (const auto& uuid : m_visibleUuids) { - const auto* item = m_source->getItem(m_sourceRows.value(uuid, -1)); - if (item && item->checked) ++active; - } - const bool changed = active != m_visibleActiveCount; - m_visibleActiveCount = active; - m_visibleInactiveCount = static_cast(m_visibleUuids.size()) - active; - if (changed) emit activeCountChanged(); + int active = 0; + for (const auto& uuid : m_visibleUuids) { + const auto* item = m_source->getItem(m_sourceRows.value(uuid, -1)); + if (item && item->checked) ++active; + } + const bool changed = active != m_visibleActiveCount; + m_visibleActiveCount = active; + m_visibleInactiveCount = static_cast(m_visibleUuids.size()) - active; + if (changed) emit activeCountChanged(); +} +int ModListProxyModel::proxyRowForUuid(const QString& uuid) const { + return m_proxyRows.value(uuid, -1); } -int ModListProxyModel::proxyRowForUuid(const QString& uuid) const { return m_proxyRows.value(uuid, -1); } QModelIndex ModListProxyModel::mapToSource(const QModelIndex& index) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_visibleUuids.size()) return {}; - return m_source->index(m_sourceRows.value(m_visibleUuids[index.row()], -1)); -} -QModelIndex ModListProxyModel::mapFromSource(const QModelIndex& sourceIndex) const { - const auto* item = sourceIndex.isValid() ? m_source->getItem(sourceIndex.row()) : nullptr; - return item ? index(proxyRowForUuid(item->uuid)) : QModelIndex{}; -} -QVariant ModListProxyModel::data(const QModelIndex& proxyIndex, int role) const { - const auto sourceIndex = mapToSource(proxyIndex); - if (!sourceIndex.isValid()) return {}; - if (role == ModListModel::kSectionNameRole) { - return sectionName(m_source->getItem(sourceIndex.row())->checked, m_visibleActiveCount, m_visibleInactiveCount); - } - return m_source->data(sourceIndex, role); -} -bool ModListProxyModel::setData(const QModelIndex& index, const QVariant& value, int role) { - const auto sourceIndex = mapToSource(index); - return sourceIndex.isValid() && m_source->setData(sourceIndex, value, role); + if (!index.isValid() || index.row() < 0 || + index.row() >= m_visibleUuids.size()) + return {}; + return m_source->index(m_sourceRows.value(m_visibleUuids[index.row()], -1)); +} +QModelIndex ModListProxyModel::mapFromSource( + const QModelIndex& sourceIndex) const { + const auto* item = + sourceIndex.isValid() ? m_source->getItem(sourceIndex.row()) : nullptr; + return item ? index(proxyRowForUuid(item->uuid)) : QModelIndex{}; +} +QVariant ModListProxyModel::data(const QModelIndex& proxyIndex, + int role) const { + const auto sourceIndex = mapToSource(proxyIndex); + if (!sourceIndex.isValid()) return {}; + if (role == ModListModel::kSectionNameRole) { + return sectionName(m_source->getItem(sourceIndex.row())->checked, + m_visibleActiveCount, m_visibleInactiveCount); + } + return m_source->data(sourceIndex, role); +} +bool ModListProxyModel::setData(const QModelIndex& index, const QVariant& value, + int role) { + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() && m_source->setData(sourceIndex, value, role); } Qt::ItemFlags ModListProxyModel::flags(const QModelIndex& index) const { - if (!index.isValid()) return Qt::ItemIsDropEnabled; - const auto sourceIndex = mapToSource(index); - return sourceIndex.isValid() ? m_source->flags(sourceIndex) : Qt::NoItemFlags; + if (!index.isValid()) return Qt::ItemIsDropEnabled; + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? m_source->flags(sourceIndex) : Qt::NoItemFlags; +} +QHash ModListProxyModel::roleNames() const { + return m_source->roleNames(); } -QHash ModListProxyModel::roleNames() const { return m_source->roleNames(); } void ModListProxyModel::layoutRebuild() { - const auto persistent = persistentIndexList(); - QStringList uuids; - uuids.reserve(persistent.size()); - for (const auto& index : persistent) uuids.append(index.isValid() && index.row() < m_visibleUuids.size() ? m_visibleUuids[index.row()] : QString{}); - emit layoutAboutToBeChanged(); - rebuild(); - QModelIndexList replacements; - replacements.reserve(uuids.size()); - for (const auto& uuid : uuids) replacements.append(index(proxyRowForUuid(uuid))); - changePersistentIndexList(persistent, replacements); - emit layoutChanged(); + const auto persistent = persistentIndexList(); + QStringList uuids; + uuids.reserve(persistent.size()); + for (const auto& index : persistent) + uuids.append(index.isValid() && index.row() < m_visibleUuids.size() + ? m_visibleUuids[index.row()] + : QString{}); + emit layoutAboutToBeChanged(); + rebuild(); + QModelIndexList replacements; + replacements.reserve(uuids.size()); + for (const auto& uuid : uuids) + replacements.append(index(proxyRowForUuid(uuid))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); } void ModListProxyModel::setSearchFilter(const QString& text) { - const auto normalized = text.trimmed().toCaseFolded(); - if (normalized == m_searchText) return; - const bool wasFiltered = isFiltered(); - m_searchText = normalized; - layoutRebuild(); - if (wasFiltered != isFiltered()) emit isFilteredChanged(); + const auto normalized = text.trimmed().toCaseFolded(); + if (normalized == m_searchText) return; + const bool wasFiltered = isFiltered(); + m_searchText = normalized; + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); } void ModListProxyModel::setSidebarFilter(std::optional> uuids) { - if (m_sidebarUuids == uuids) return; - const bool wasFiltered = isFiltered(); - m_sidebarUuids = std::move(uuids); - layoutRebuild(); - if (wasFiltered != isFiltered()) emit isFilteredChanged(); + if (m_sidebarUuids == uuids) return; + const bool wasFiltered = isFiltered(); + m_sidebarUuids = std::move(uuids); + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); } bool ModListProxyModel::moveRow(int source, int target, bool clampToActive) { - if (source < 0 || source >= m_visibleUuids.size()) return false; - const auto uuid = m_visibleUuids[source]; - const int sourceRow = m_sourceRows.value(uuid, -1); - if (clampToActive) { - const auto* item = m_source->getItem(sourceRow); - if (isFiltered() || !item || !item->checked) return false; - target = std::max(0, std::min(target, m_visibleActiveCount - 1)); - } - if (source == target || target < 0 || target >= m_visibleUuids.size()) return false; - const int targetRow = m_sourceRows.value(m_visibleUuids[target], -1); - if (sourceRow < 0 || targetRow < 0) return false; - // Source data signals refer to post-move rows, while the proxy is still moving. - m_inProxyMove = true; - beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); - m_visibleUuids.move(source, target); - m_proxyRows.clear(); - for (int row = 0; row < m_visibleUuids.size(); ++row) m_proxyRows.insert(m_visibleUuids[row], row); - m_source->moveRow(sourceRow, targetRow); - rebuildSourceMapping(); - endMoveRows(); - m_inProxyMove = false; - emit dataChanged(index(std::min(source,target)), index(std::max(source,target)), {ModListModel::kLoadIndexRole,ModListModel::kSectionNameRole}); - return true; -} -void ModListProxyModel::sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, const QList& roles) { - if (m_inProxyMove) return; - if (roles.isEmpty() || roles.contains(ModListModel::kCheckStateRole) || roles.contains(ModListModel::kIsActiveRole)) updateVisibleCounts(); - int first = rowCount(), last = -1; - for (int row = top.row(); row <= bottom.row(); ++row) { - const auto* item = m_source->getItem(row); - if (!item) continue; - const int proxyRow = proxyRowForUuid(item->uuid); - if (proxyRow < 0) continue; - first = std::min(first, proxyRow); last = std::max(last, proxyRow); - } - if (last >= 0) emit dataChanged(index(first), index(last), roles); -} -} + if (source < 0 || source >= m_visibleUuids.size()) return false; + const auto uuid = m_visibleUuids[source]; + const int sourceRow = m_sourceRows.value(uuid, -1); + if (clampToActive) { + const auto* item = m_source->getItem(sourceRow); + if (isFiltered() || !item || !item->checked) return false; + target = std::max(0, std::min(target, m_visibleActiveCount - 1)); + } + if (source == target || target < 0 || target >= m_visibleUuids.size()) + return false; + const int targetRow = m_sourceRows.value(m_visibleUuids[target], -1); + if (sourceRow < 0 || targetRow < 0) return false; + // Source data signals refer to post-move rows, while the proxy is still + // moving. + m_inProxyMove = true; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + m_visibleUuids.move(source, target); + m_proxyRows.clear(); + for (int row = 0; row < m_visibleUuids.size(); ++row) + m_proxyRows.insert(m_visibleUuids[row], row); + m_source->moveRow(sourceRow, targetRow); + rebuildSourceMapping(); + endMoveRows(); + m_inProxyMove = false; + emit dataChanged( + index(std::min(source, target)), index(std::max(source, target)), + {ModListModel::kLoadIndexRole, ModListModel::kSectionNameRole}); + return true; +} +void ModListProxyModel::sourceDataChanged(const QModelIndex& top, + const QModelIndex& bottom, + const QList& roles) { + if (m_inProxyMove) return; + if (roles.isEmpty() || roles.contains(ModListModel::kCheckStateRole) || + roles.contains(ModListModel::kIsActiveRole)) + updateVisibleCounts(); + int first = rowCount(), last = -1; + for (int row = top.row(); row <= bottom.row(); ++row) { + const auto* item = m_source->getItem(row); + if (!item) continue; + const int proxyRow = proxyRowForUuid(item->uuid); + if (proxyRow < 0) continue; + first = std::min(first, proxyRow); + last = std::max(last, proxyRow); + } + if (last >= 0) emit dataChanged(index(first), index(last), roles); +} +} // namespace px::modrim diff --git a/src/ui/models/mod_list_proxy_model.hpp b/src/ui/models/mod_list_proxy_model.hpp index aa31dc7..8f8ce48 100644 --- a/src/ui/models/mod_list_proxy_model.hpp +++ b/src/ui/models/mod_list_proxy_model.hpp @@ -1,44 +1,51 @@ #pragma once #include #include + #include "ui/models/mod_list_model.hpp" namespace px::modrim { class ModListProxyModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(bool isFiltered READ isFiltered NOTIFY isFilteredChanged) - Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) -public: - explicit ModListProxyModel(ModListModel* source, QObject* parent = nullptr); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; - Qt::ItemFlags flags(const QModelIndex& index) const override; - QHash roleNames() const override; - QModelIndex mapToSource(const QModelIndex& index) const; - QModelIndex mapFromSource(const QModelIndex& index) const; - int proxyRowForUuid(const QString& uuid) const; - void setSearchFilter(const QString& text); - void setSidebarFilter(std::optional> uuids); - bool isFiltered() const { return !m_searchText.isEmpty() || m_sidebarUuids.has_value(); } - int activeCount() const { return m_visibleActiveCount; } - bool moveRow(int source, int target, bool clampToActive = false); -signals: - void isFilteredChanged(); - void activeCountChanged(); -private: - void rebuild(); - void rebuildSourceMapping(); - void layoutRebuild(); - void updateVisibleCounts(); - bool acceptItem(const ModItem& item) const; - void sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, const QList& roles); - ModListModel* m_source; - QString m_searchText; - std::optional> m_sidebarUuids; - QStringList m_visibleUuids; - QHash m_sourceRows, m_proxyRows; - bool m_inProxyMove = false; - int m_visibleActiveCount = 0; - int m_visibleInactiveCount = 0; + Q_OBJECT + Q_PROPERTY(bool isFiltered READ isFiltered NOTIFY isFilteredChanged) + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) + public: + explicit ModListProxyModel(ModListModel* source, QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + QModelIndex mapToSource(const QModelIndex& index) const; + QModelIndex mapFromSource(const QModelIndex& index) const; + int proxyRowForUuid(const QString& uuid) const; + void setSearchFilter(const QString& text); + void setSidebarFilter(std::optional> uuids); + bool isFiltered() const { + return !m_searchText.isEmpty() || m_sidebarUuids.has_value(); + } + int activeCount() const { return m_visibleActiveCount; } + bool moveRow(int source, int target, bool clampToActive = false); + signals: + void isFilteredChanged(); + void activeCountChanged(); + + private: + void rebuild(); + void rebuildSourceMapping(); + void layoutRebuild(); + void updateVisibleCounts(); + bool acceptItem(const ModItem& item) const; + void sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, + const QList& roles); + ModListModel* m_source; + QString m_searchText; + std::optional> m_sidebarUuids; + QStringList m_visibleUuids; + QHash m_sourceRows, m_proxyRows; + bool m_inProxyMove = false; + int m_visibleActiveCount = 0; + int m_visibleInactiveCount = 0; }; -} +} // namespace px::modrim diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp index ff1abee..efd8d3f 100644 --- a/src/ui/models/sidebar_model.cpp +++ b/src/ui/models/sidebar_model.cpp @@ -1,74 +1,115 @@ #include "ui/models/sidebar_model.hpp" + #include "ui/theme/palette.hpp" namespace px::modrim { SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} -int SidebarModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : static_cast(m_items.size()); } +int SidebarModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} QVariant SidebarModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; - const auto& item = m_items[index.row()]; - switch (role) { - case Qt::DisplayRole: case kLabelRole: return item.entry.label; - case kCountRole: return item.entry.count(); - case kBadgeBgRole: return item.badgeBg; - case kBadgeFgRole: return item.badgeFg; - case kIconRole: return item.iconName; - case kIconColorRole: return item.iconColor; - case kSectionRole: return item.sectionName; - default: return {}; - } + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& item = m_items[index.row()]; + switch (role) { + case Qt::DisplayRole: + case kLabelRole: + return item.entry.label; + case kCountRole: + return item.entry.count(); + case kBadgeBgRole: + return item.badgeBg; + case kBadgeFgRole: + return item.badgeFg; + case kIconRole: + return item.iconName; + case kIconColorRole: + return item.iconColor; + case kSectionRole: + return item.sectionName; + default: + return {}; + } } -QHash SidebarModel::roleNames() const { - return {{kLabelRole,"filterLabel"},{kCountRole,"count"},{kBadgeBgRole,"badgeBg"},{kBadgeFgRole,"badgeFg"}, - {kIconRole,"iconName"},{kIconColorRole,"iconColor"},{kSectionRole,"sectionName"}}; +QHash SidebarModel::roleNames() const { + return {{kLabelRole, "filterLabel"}, {kCountRole, "count"}, + {kBadgeBgRole, "badgeBg"}, {kBadgeFgRole, "badgeFg"}, + {kIconRole, "iconName"}, {kIconColorRole, "iconColor"}, + {kSectionRole, "sectionName"}}; } SidebarItem SidebarModel::makeItem(const view::SidebarEntry& entry) { - using Kind = view::SidebarEntry::Kind; - SidebarItem item{entry,paletteValue(u"ELEVATE_4"),paletteValue(u"TEXT_MUTED"),QStringLiteral("grid"),paletteValue(u"TEXT_DIM"),QStringLiteral("Status")}; - switch (entry.kind) { - case Kind::kAll: break; - case Kind::kActive: item.iconName = QStringLiteral("check-circle"); break; - case Kind::kInactive: item.iconName = QStringLiteral("ban"); break; + using Kind = view::SidebarEntry::Kind; + SidebarItem item{entry, + paletteValue(u"ELEVATE_4"), + paletteValue(u"TEXT_MUTED"), + QStringLiteral("grid"), + paletteValue(u"TEXT_DIM"), + QStringLiteral("Status")}; + switch (entry.kind) { + case Kind::kAll: + break; + case Kind::kActive: + item.iconName = QStringLiteral("check-circle"); + break; + case Kind::kInactive: + item.iconName = QStringLiteral("ban"); + break; case Kind::kWithErrors: - item.badgeBg = paletteValue(u"DANGER_BG_QML"); item.badgeFg = item.iconColor = paletteValue(u"DANGER"); item.iconName = QStringLiteral("error"); break; + item.badgeBg = paletteValue(u"DANGER_BG_QML"); + item.badgeFg = item.iconColor = paletteValue(u"DANGER"); + item.iconName = QStringLiteral("error"); + break; case Kind::kWithWarnings: - item.badgeBg = paletteValue(u"WARNING_BG_QML"); item.badgeFg = item.iconColor = paletteValue(u"WARNING"); item.iconName = QStringLiteral("warning"); break; + item.badgeBg = paletteValue(u"WARNING_BG_QML"); + item.badgeFg = item.iconColor = paletteValue(u"WARNING"); + item.iconName = QStringLiteral("warning"); + break; case Kind::kProvider: { - item.sectionName = QStringLiteral("Providers"); + item.sectionName = QStringLiteral("Providers"); + item.iconName = QStringLiteral("folder"); + const auto label = entry.label.toLower(); + if (label.contains("steam") || label.contains("downloaded")) + item.iconName = QStringLiteral("steam"); + else if (label.contains("local")) item.iconName = QStringLiteral("folder"); - const auto label = entry.label.toLower(); - if (label.contains("steam") || label.contains("downloaded")) item.iconName = QStringLiteral("steam"); - else if (label.contains("local")) item.iconName = QStringLiteral("folder"); - else if (label.contains("core")) item.iconName = QStringLiteral("grid"); - else if (label.contains("git")) item.iconName = QStringLiteral("git"); - break; - } + else if (label.contains("core")) + item.iconName = QStringLiteral("grid"); + else if (label.contains("git")) + item.iconName = QStringLiteral("git"); + break; } - return item; + } + return item; } void SidebarModel::setEntries(const QList& entries) { - beginResetModel(); m_items.clear(); m_items.reserve(entries.size()); - for (const auto& entry : entries) m_items.append(makeItem(entry)); - endResetModel(); + beginResetModel(); + m_items.clear(); + m_items.reserve(entries.size()); + for (const auto& entry : entries) m_items.append(makeItem(entry)); + endResetModel(); } void SidebarModel::updateEntries(const QList& entries) { - if (entries.size() != m_items.size()) { setEntries(entries); return; } - QSet changed; - for (int row = 0; row < entries.size(); ++row) { - auto next = makeItem(entries[row]); - const auto& old = m_items[row]; - if (old.entry.label != next.entry.label) changed.insert(kLabelRole); - if (old.entry.count() != next.entry.count()) changed.insert(kCountRole); - if (old.badgeBg != next.badgeBg) changed.insert(kBadgeBgRole); - if (old.badgeFg != next.badgeFg) changed.insert(kBadgeFgRole); - if (old.iconName != next.iconName) changed.insert(kIconRole); - if (old.iconColor != next.iconColor) changed.insert(kIconColorRole); - if (old.sectionName != next.sectionName) changed.insert(kSectionRole); - m_items[row] = std::move(next); - } - if (!changed.isEmpty() && !m_items.isEmpty()) emit dataChanged(index(0), index(rowCount()-1), changed.values()); + if (entries.size() != m_items.size()) { + setEntries(entries); + return; + } + QSet changed; + for (int row = 0; row < entries.size(); ++row) { + auto next = makeItem(entries[row]); + const auto& old = m_items[row]; + if (old.entry.label != next.entry.label) changed.insert(kLabelRole); + if (old.entry.count() != next.entry.count()) changed.insert(kCountRole); + if (old.badgeBg != next.badgeBg) changed.insert(kBadgeBgRole); + if (old.badgeFg != next.badgeFg) changed.insert(kBadgeFgRole); + if (old.iconName != next.iconName) changed.insert(kIconRole); + if (old.iconColor != next.iconColor) changed.insert(kIconColorRole); + if (old.sectionName != next.sectionName) changed.insert(kSectionRole); + m_items[row] = std::move(next); + } + if (!changed.isEmpty() && !m_items.isEmpty()) + emit dataChanged(index(0), index(rowCount() - 1), changed.values()); } std::optional SidebarModel::getEntry(int row) const { - if (row < 0 || row >= m_items.size()) return std::nullopt; - return m_items[row].entry; -} + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].entry; } +} // namespace px::modrim diff --git a/src/ui/models/sidebar_model.hpp b/src/ui/models/sidebar_model.hpp index 5cf1515..b0dfbeb 100644 --- a/src/ui/models/sidebar_model.hpp +++ b/src/ui/models/sidebar_model.hpp @@ -1,26 +1,37 @@ #pragma once #include #include + #include "core/models/view/sidebar.hpp" namespace px::modrim { struct SidebarItem { - view::SidebarEntry entry; - QString badgeBg, badgeFg, iconName, iconColor, sectionName; + view::SidebarEntry entry; + QString badgeBg, badgeFg, iconName, iconColor, sectionName; }; class SidebarModel : public QAbstractListModel { - Q_OBJECT -public: - enum Role { kLabelRole = Qt::UserRole + 1, kCountRole, kBadgeBgRole, kBadgeFgRole, kIconRole, kIconColorRole, kSectionRole }; - Q_ENUM(Role) - explicit SidebarModel(QObject* parent = nullptr); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - void setEntries(const QList& entries); - void updateEntries(const QList& entries); - std::optional getEntry(int row) const; -private: - static SidebarItem makeItem(const view::SidebarEntry& entry); - QList m_items; + Q_OBJECT + public: + enum Role { + kLabelRole = Qt::UserRole + 1, + kCountRole, + kBadgeBgRole, + kBadgeFgRole, + kIconRole, + kIconColorRole, + kSectionRole + }; + Q_ENUM(Role) + explicit SidebarModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void setEntries(const QList& entries); + void updateEntries(const QList& entries); + std::optional getEntry(int row) const; + + private: + static SidebarItem makeItem(const view::SidebarEntry& entry); + QList m_items; }; -} +} // namespace px::modrim diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp index 6bd0dd5..e666ba8 100644 --- a/src/ui/navigation.cpp +++ b/src/ui/navigation.cpp @@ -8,27 +8,33 @@ namespace px::modrim { QString Route::url() const { return buildRoute(viewId, path); } QString buildRoute(const QString& viewId, const QStringList& path) { - QString url = QStringLiteral("%1://%2").arg(QString(kRouteScheme), viewId); - for (const QString& part : path) { - // An empty exclusion set encodes everything but the RFC 3986 unreserved characters. - url += QLatin1Char('/') + QString::fromLatin1(part.toUtf8().toPercentEncoding()); - } - return url; + QString url = QStringLiteral("%1://%2").arg(QString(kRouteScheme), viewId); + for (const QString& part : path) { + // An empty exclusion set encodes everything but the RFC 3986 unreserved + // characters. + url += QLatin1Char('/') + + QString::fromLatin1(part.toUtf8().toPercentEncoding()); + } + return url; } std::optional parseRoute(const QString& url) { - const QUrl parsed(url, QUrl::StrictMode); - if (!parsed.isValid() || parsed.scheme() != kRouteScheme || parsed.authority().isEmpty()) { - return std::nullopt; - } + const QUrl parsed(url, QUrl::StrictMode); + if (!parsed.isValid() || parsed.scheme() != kRouteScheme || + parsed.authority().isEmpty()) { + return std::nullopt; + } - // Split the encoded path first, then decode each part, so an encoded '/' stays inside its segment. - Route route{parsed.authority(), {}}; - const QStringList encodedParts = parsed.path(QUrl::FullyEncoded).split(QLatin1Char('/'), Qt::SkipEmptyParts); - for (const QString& part : encodedParts) { - route.path.append(QUrl::fromPercentEncoding(part.toUtf8())); - } - return route; + // Split the encoded path first, then decode each part, so an encoded '/' + // stays inside its segment. + Route route{parsed.authority(), {}}; + const QStringList encodedParts = + parsed.path(QUrl::FullyEncoded) + .split(QLatin1Char('/'), Qt::SkipEmptyParts); + for (const QString& part : encodedParts) { + route.path.append(QUrl::fromPercentEncoding(part.toUtf8())); + } + return route; } } // namespace px::modrim diff --git a/src/ui/navigation.hpp b/src/ui/navigation.hpp index 46b4468..2e70c53 100644 --- a/src/ui/navigation.hpp +++ b/src/ui/navigation.hpp @@ -1,27 +1,28 @@ #pragma once -#include - #include #include #include +#include namespace px::modrim { -// modrim://[/…] links that open a view and optionally a sub-route inside it. +// modrim://[/…] links that open a view and optionally a +// sub-route inside it. inline constexpr QLatin1StringView kRouteScheme{"modrim"}; inline constexpr QLatin1StringView kSettingsViewId{"settings"}; struct Route { - QString viewId; - QStringList path; + QString viewId; + QStringList path; - QString url() const; + QString url() const; - bool operator==(const Route&) const = default; + bool operator==(const Route&) const = default; }; -// Each path part is percent-encoded with nothing left unescaped, so ids containing +// Each path part is percent-encoded with nothing left unescaped, so ids +// containing // '/' or ':' round-trip. QString buildRoute(const QString& viewId, const QStringList& path = {}); diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp index f52ad45..3c728d2 100644 --- a/src/ui/panels/about_panel.cpp +++ b/src/ui/panels/about_panel.cpp @@ -16,9 +16,10 @@ #include #include #include +#include + #include #include -#include #include "core/version.hpp" #include "ui/components/dialog_chrome.hpp" @@ -28,206 +29,248 @@ namespace px::modrim { QStringList AboutPanel::environment() { #if defined(__clang__) - const QString compiler = QStringLiteral("Clang " __clang_version__); + const QString compiler = QStringLiteral("Clang " __clang_version__); #elif defined(__GNUC__) - const QString compiler = QStringLiteral("GCC " __VERSION__); + const QString compiler = QStringLiteral("GCC " __VERSION__); #elif defined(_MSC_VER) - const QString compiler = QStringLiteral("MSVC %1").arg(_MSC_VER); + const QString compiler = QStringLiteral("MSVC %1").arg(_MSC_VER); #else - const QString compiler = QStringLiteral("C++23"); + const QString compiler = QStringLiteral("C++23"); #endif - return {QStringLiteral("C++23 (%1)").arg(compiler), QStringLiteral("Qt %1").arg(qVersion()), - QStringLiteral("%1 %2 (%3)").arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), - QSysInfo::currentCpuArchitecture())}; + return {QStringLiteral("C++23 (%1)").arg(compiler), + QStringLiteral("Qt %1").arg(qVersion()), + QStringLiteral("%1 %2 (%3)") + .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture())}; } QString AboutPanel::systemInformation() const { - return (QStringList{QStringLiteral("PxModRim %1").arg(appVersion())} + environment()).join(u'\n'); + return (QStringList{QStringLiteral("PxModRim %1").arg(appVersion())} + + environment()) + .join(u'\n'); } QString AboutPanel::creditsHtml() { - return QStringLiteral( - "

    PxModRim

    Created and maintained by PyXiion. " - "Licensed under GPL-3.0-or-later.

    " - "

    Inspiration

    RimSort

    " - "

    C++ dependencies

    " - "" - "" - "" - "" - "" - "" - "" - "
    LibraryVersionLicense
    Qt%1LGPL-3.0 / GPL-3.0 / commercial
    QCoroBuild-linkedMIT
    pugixml%2.%3.%4MIT
    libzip%5BSD-3-Clause
    spdlog%6.%7.%8MIT
    Glaze5.5.4 (build dependency)MIT
    ") - .arg(QString::fromLatin1(qVersion())) - .arg(PUGIXML_VERSION / 1000).arg(PUGIXML_VERSION / 10 % 100).arg(PUGIXML_VERSION % 10) - .arg(QString::fromLatin1(zip_libzip_version())) - .arg(SPDLOG_VER_MAJOR).arg(SPDLOG_VER_MINOR).arg(SPDLOG_VER_PATCH); + return QStringLiteral( + "

    PxModRim

    Created and maintained by PyXiion. " + "Licensed under GPL-3.0-or-later.

    " + "

    Inspiration

    RimSort

    " + "

    C++ dependencies

    " + "" + "" + "" + "" + "" + "" + "" + "
    LibraryVersionLicense
    Qt%1LGPL-3.0 / " + "GPL-3.0 / commercial
    QCoroBuild-linkedMIT
    pugixml%2.%3.%4MIT
    libzip%5BSD-3-Clause
    spdlog%6.%7.%8MIT
    Glaze5.5.4 (build dependency)MIT
    ") + .arg(QString::fromLatin1(qVersion())) + .arg(PUGIXML_VERSION / 1000) + .arg(PUGIXML_VERSION / 10 % 100) + .arg(PUGIXML_VERSION % 10) + .arg(QString::fromLatin1(zip_libzip_version())) + .arg(SPDLOG_VER_MAJOR) + .arg(SPDLOG_VER_MINOR) + .arg(SPDLOG_VER_PATCH); } AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) : QDialog(parent), m_ctx(ctx), m_toast(toast) { - setObjectName(QStringLiteral("aboutPanel")); - setWindowTitle(tr("About PxModRim")); - setModal(true); - resize(700, 740); - setMinimumSize(640, 620); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(24, 24, 24, 20); - layout->setSpacing(18); - auto* hero = new QFrame(this); - hero->setObjectName(QStringLiteral("aboutHero")); - auto* heroLayout = new QHBoxLayout(hero); - heroLayout->setContentsMargins(20, 20, 20, 20); - heroLayout->setSpacing(20); - auto* logo = new QLabel(hero); - logo->setPixmap(QPixmap(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg")) - .scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - heroLayout->addWidget(logo, 0, Qt::AlignTop); - auto* identity = new QVBoxLayout; - identity->setSpacing(6); - auto* titleRow = new QHBoxLayout; - auto* title = new QLabel(QStringLiteral("PxModRim"), hero); - title->setObjectName(QStringLiteral("aboutTitle")); - titleRow->addWidget(title); - titleRow->addStretch(); - auto* version = new QLabel(QStringLiteral("v%1").arg(appVersion()), hero); - version->setObjectName(QStringLiteral("aboutVersionBadge")); - titleRow->addWidget(version, 0, Qt::AlignVCenter); - identity->addLayout(titleRow); - auto* tagline = new QLabel(tr("A friendly, modern mod manager for RimWorld."), hero); - tagline->setObjectName(QStringLiteral("aboutTagline")); - identity->addWidget(tagline); - auto* description = new QLabel(tr("Scan, sort, resolve dependencies, and manage large mod lists easily."), hero); - description->setObjectName(QStringLiteral("aboutDescription")); - description->setWordWrap(true); - identity->addWidget(description); - identity->addStretch(); - heroLayout->addLayout(identity, 1); - layout->addWidget(hero); + setObjectName(QStringLiteral("aboutPanel")); + setWindowTitle(tr("About PxModRim")); + setModal(true); + resize(700, 740); + setMinimumSize(640, 620); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 20); + layout->setSpacing(18); + auto* hero = new QFrame(this); + hero->setObjectName(QStringLiteral("aboutHero")); + auto* heroLayout = new QHBoxLayout(hero); + heroLayout->setContentsMargins(20, 20, 20, 20); + heroLayout->setSpacing(20); + auto* logo = new QLabel(hero); + logo->setPixmap( + QPixmap(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg")) + .scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + heroLayout->addWidget(logo, 0, Qt::AlignTop); + auto* identity = new QVBoxLayout; + identity->setSpacing(6); + auto* titleRow = new QHBoxLayout; + auto* title = new QLabel(QStringLiteral("PxModRim"), hero); + title->setObjectName(QStringLiteral("aboutTitle")); + titleRow->addWidget(title); + titleRow->addStretch(); + auto* version = new QLabel(QStringLiteral("v%1").arg(appVersion()), hero); + version->setObjectName(QStringLiteral("aboutVersionBadge")); + titleRow->addWidget(version, 0, Qt::AlignVCenter); + identity->addLayout(titleRow); + auto* tagline = + new QLabel(tr("A friendly, modern mod manager for RimWorld."), hero); + tagline->setObjectName(QStringLiteral("aboutTagline")); + identity->addWidget(tagline); + auto* description = new QLabel(tr("Scan, sort, resolve dependencies, and " + "manage large mod lists easily."), + hero); + description->setObjectName(QStringLiteral("aboutDescription")); + description->setWordWrap(true); + identity->addWidget(description); + identity->addStretch(); + heroLayout->addLayout(identity, 1); + layout->addWidget(hero); - auto* section = new QWidget(this); - auto* sectionLayout = new QVBoxLayout(section); - sectionLayout->setContentsMargins(0, 0, 0, 0); - sectionLayout->setSpacing(6); - auto* heading = new QLabel(tr("THE PROJECT"), section); - heading->setObjectName(QStringLiteral("aboutSectionTitle")); - sectionLayout->addWidget(heading); - auto* story = new QLabel(tr( - "I used RimPy and RimSort for a long time myself, but with recent updates it kept getting " - "slower and worse — some features even broke my game 🙁

    " - "I initially started working on a PR to fix most of the parallelism issues in RimSort, " - "but realized it didn't make much sense. So I decided to try creating my own alternative " - "with the help of AI. The result is what you see on your screen.

    " - "This is all just my personal opinion based on my experience with RimSort at the time, " - "and things may have changed for the better since then (I hope so!).

    — PyXiion"), section); - story->setObjectName(QStringLiteral("aboutStory")); - story->setWordWrap(true); - story->setAlignment(Qt::AlignTop | Qt::AlignLeft); - auto* scroll = new QScrollArea(section); - scroll->setFrameShape(QFrame::NoFrame); - scroll->setWidgetResizable(true); - scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scroll->setWidget(story); - scroll->viewport()->setAutoFillBackground(false); - scroll->viewport()->setBackgroundRole(QPalette::NoRole); - story->setAutoFillBackground(false); - story->setBackgroundRole(QPalette::NoRole); - sectionLayout->addWidget(scroll, 1); - layout->addWidget(section, 1); + auto* section = new QWidget(this); + auto* sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(0, 0, 0, 0); + sectionLayout->setSpacing(6); + auto* heading = new QLabel(tr("THE PROJECT"), section); + heading->setObjectName(QStringLiteral("aboutSectionTitle")); + sectionLayout->addWidget(heading); + auto* story = new QLabel( + tr("I used RimPy and RimSort for a long time myself, but with recent " + "updates it kept getting " + "slower and worse — some features even broke my game 🙁

    " + "I initially started working on a PR to fix most of the parallelism " + "issues in RimSort, " + "but realized it didn't make much sense. So I decided to try creating " + "my own alternative " + "with the help of AI. The result is what you see on your " + "screen.

    " + "This is all just my personal opinion based on my experience with " + "RimSort at the time, " + "and things may have changed for the better since then (I hope " + "so!).

    — PyXiion"), + section); + story->setObjectName(QStringLiteral("aboutStory")); + story->setWordWrap(true); + story->setAlignment(Qt::AlignTop | Qt::AlignLeft); + auto* scroll = new QScrollArea(section); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(story); + scroll->viewport()->setAutoFillBackground(false); + scroll->viewport()->setBackgroundRole(QPalette::NoRole); + story->setAutoFillBackground(false); + story->setBackgroundRole(QPalette::NoRole); + sectionLayout->addWidget(scroll, 1); + layout->addWidget(section, 1); - auto* details = new QFrame(this); - details->setObjectName(QStringLiteral("aboutDetails")); - auto* grid = new QGridLayout(details); - grid->setContentsMargins(16, 14, 16, 14); - grid->setHorizontalSpacing(14); - grid->setVerticalSpacing(8); - const QList> rows{ - {tr("CREATED BY"), QStringLiteral("PyXiion")}, - {tr("LICENSE"), QStringLiteral("GPL-3.0-or-later")}, - {tr("INSPIRED BY"), QStringLiteral("RimSort")}, - {tr("ENVIRONMENT"), environment().join(QStringLiteral(" • "))}}; - for (qsizetype row = 0; row < rows.size(); ++row) { - auto* label = new QLabel(rows[row].first, details); - label->setObjectName(QStringLiteral("aboutMetaLabel")); - auto* value = new QLabel(rows[row].second, details); - value->setObjectName(QStringLiteral("aboutMetaValue")); - value->setWordWrap(true); - grid->addWidget(label, static_cast(row), 0); - grid->addWidget(value, static_cast(row), 1); - } - grid->setColumnStretch(1, 1); - layout->addWidget(details); + auto* details = new QFrame(this); + details->setObjectName(QStringLiteral("aboutDetails")); + auto* grid = new QGridLayout(details); + grid->setContentsMargins(16, 14, 16, 14); + grid->setHorizontalSpacing(14); + grid->setVerticalSpacing(8); + const QList> rows{ + {tr("CREATED BY"), QStringLiteral("PyXiion")}, + {tr("LICENSE"), QStringLiteral("GPL-3.0-or-later")}, + {tr("INSPIRED BY"), QStringLiteral("RimSort")}, + {tr("ENVIRONMENT"), environment().join(QStringLiteral(" • "))}}; + for (qsizetype row = 0; row < rows.size(); ++row) { + auto* label = new QLabel(rows[row].first, details); + label->setObjectName(QStringLiteral("aboutMetaLabel")); + auto* value = new QLabel(rows[row].second, details); + value->setObjectName(QStringLiteral("aboutMetaValue")); + value->setWordWrap(true); + grid->addWidget(label, static_cast(row), 0); + grid->addWidget(value, static_cast(row), 1); + } + grid->setColumnStretch(1, 1); + layout->addWidget(details); - auto* credits = new QDialog(this); - credits->setObjectName(QStringLiteral("creditsDialog")); - credits->setWindowTitle(tr("Open-Source Credits")); - credits->setModal(true); - credits->resize(560, 500); - auto* creditsLayout = new QVBoxLayout(credits); - creditsLayout->setContentsMargins(20, 20, 20, 16); - creditsLayout->setSpacing(14); - auto* creditsTitle = new QLabel(tr("Open-source credits"), credits); - creditsTitle->setObjectName(QStringLiteral("creditsTitle")); - creditsLayout->addWidget(creditsTitle); - auto* browser = new QTextBrowser(credits); - browser->setObjectName(QStringLiteral("creditsBrowser")); - browser->setOpenExternalLinks(true); - browser->setHtml(creditsHtml()); - creditsLayout->addWidget(browser); - auto* creditsFooter = new QHBoxLayout; - creditsFooter->addStretch(); - auto* creditsClose = new QPushButton(tr("Close"), credits); - creditsClose->setObjectName(QStringLiteral("appBtn")); - connect(creditsClose, &QPushButton::clicked, credits, &QDialog::reject); - creditsFooter->addWidget(creditsClose); - creditsLayout->addLayout(creditsFooter); - installDialogChrome(credits); + auto* credits = new QDialog(this); + credits->setObjectName(QStringLiteral("creditsDialog")); + credits->setWindowTitle(tr("Open-Source Credits")); + credits->setModal(true); + credits->resize(560, 500); + auto* creditsLayout = new QVBoxLayout(credits); + creditsLayout->setContentsMargins(20, 20, 20, 16); + creditsLayout->setSpacing(14); + auto* creditsTitle = new QLabel(tr("Open-source credits"), credits); + creditsTitle->setObjectName(QStringLiteral("creditsTitle")); + creditsLayout->addWidget(creditsTitle); + auto* browser = new QTextBrowser(credits); + browser->setObjectName(QStringLiteral("creditsBrowser")); + browser->setOpenExternalLinks(true); + browser->setHtml(creditsHtml()); + creditsLayout->addWidget(browser); + auto* creditsFooter = new QHBoxLayout; + creditsFooter->addStretch(); + auto* creditsClose = new QPushButton(tr("Close"), credits); + creditsClose->setObjectName(QStringLiteral("appBtn")); + connect(creditsClose, &QPushButton::clicked, credits, &QDialog::reject); + creditsFooter->addWidget(creditsClose); + creditsLayout->addLayout(creditsFooter); + installDialogChrome(credits); - auto* actions = new QHBoxLayout; - actions->setSpacing(8); - auto* github = new QPushButton(tr("GitHub"), this); - github->setObjectName(QStringLiteral("appBtn")); - connect(github, &QPushButton::clicked, this, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim"))); }); - actions->addWidget(github); - auto* issues = new QPushButton(tr("Report issue"), this); - issues->setObjectName(QStringLiteral("appBtn")); - connect(issues, &QPushButton::clicked, this, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); - actions->addWidget(issues); - auto* copy = new QPushButton(tr("Copy system info"), this); - copy->setObjectName(QStringLiteral("appBtn")); - connect(copy, &QPushButton::clicked, this, [this] { - QApplication::clipboard()->setText(systemInformation()); - m_toast.success(tr("System info copied")); - }); - actions->addWidget(copy); - m_upload = new QPushButton(tr("Upload log && system info…"), this); - m_upload->setObjectName(QStringLiteral("appBtn")); - connect(m_upload, &QPushButton::clicked, this, [this] { uploadReport(); }); - actions->addWidget(m_upload); - auto* showCredits = new QPushButton(tr("Open-source credits"), this); - showCredits->setObjectName(QStringLiteral("appBtn")); - connect(showCredits, &QPushButton::clicked, credits, &QDialog::open); - actions->addWidget(showCredits); - actions->addStretch(); - layout->addLayout(actions); - auto* disclaimer = new QLabel(tr("PxModRim is an unofficial fan-made tool. RimWorld is a trademark of Ludeon Studios."), this); - disclaimer->setObjectName(QStringLiteral("aboutDisclaimer")); - disclaimer->setWordWrap(true); - layout->addWidget(disclaimer); - auto* footer = new QHBoxLayout; - footer->addStretch(); - auto* close = new QPushButton(tr("Close"), this); - close->setObjectName(QStringLiteral("primaryAction")); - connect(close, &QPushButton::clicked, this, &QDialog::reject); - footer->addWidget(close); - layout->addLayout(footer); + auto* actions = new QHBoxLayout; + actions->setSpacing(8); + auto* github = new QPushButton(tr("GitHub"), this); + github->setObjectName(QStringLiteral("appBtn")); + connect(github, &QPushButton::clicked, this, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim"))); + }); + actions->addWidget(github); + auto* issues = new QPushButton(tr("Report issue"), this); + issues->setObjectName(QStringLiteral("appBtn")); + connect(issues, &QPushButton::clicked, this, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); + }); + actions->addWidget(issues); + auto* copy = new QPushButton(tr("Copy system info"), this); + copy->setObjectName(QStringLiteral("appBtn")); + connect(copy, &QPushButton::clicked, this, [this] { + QApplication::clipboard()->setText(systemInformation()); + m_toast.success(tr("System info copied")); + }); + actions->addWidget(copy); + m_upload = new QPushButton(tr("Upload log && system info…"), this); + m_upload->setObjectName(QStringLiteral("appBtn")); + connect(m_upload, &QPushButton::clicked, this, [this] { uploadReport(); }); + actions->addWidget(m_upload); + auto* showCredits = new QPushButton(tr("Open-source credits"), this); + showCredits->setObjectName(QStringLiteral("appBtn")); + connect(showCredits, &QPushButton::clicked, credits, &QDialog::open); + actions->addWidget(showCredits); + actions->addStretch(); + layout->addLayout(actions); + auto* disclaimer = + new QLabel(tr("PxModRim is an unofficial fan-made tool. RimWorld is a " + "trademark of Ludeon Studios."), + this); + disclaimer->setObjectName(QStringLiteral("aboutDisclaimer")); + disclaimer->setWordWrap(true); + layout->addWidget(disclaimer); + auto* footer = new QHBoxLayout; + footer->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + footer->addWidget(close); + layout->addLayout(footer); } QCoro::Task AboutPanel::uploadReport() { - QPointer button(m_upload); - m_upload->setEnabled(false); - co_await handleUploadReport(this, m_ctx, m_toast); - if (button) button->setEnabled(true); + QPointer button(m_upload); + m_upload->setEnabled(false); + co_await handleUploadReport(this, m_ctx, m_toast); + if (button) button->setEnabled(true); } } // namespace px::modrim diff --git a/src/ui/panels/about_panel.hpp b/src/ui/panels/about_panel.hpp index 17aff88..84bb37c 100644 --- a/src/ui/panels/about_panel.hpp +++ b/src/ui/panels/about_panel.hpp @@ -1,24 +1,27 @@ #pragma once -#include #include #include +#include + class QPushButton; -namespace px::modrim { class CoreContext; } +namespace px::modrim { +class CoreContext; +} namespace px::modrim { class Notifier; class AboutPanel : public QDialog { -public: - AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast); - static QStringList environment(); - QString systemInformation() const; - static QString creditsHtml(); + public: + AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast); + static QStringList environment(); + QString systemInformation() const; + static QString creditsHtml(); -private: - QCoro::Task uploadReport(); - CoreContext& m_ctx; - Notifier& m_toast; - QPushButton* m_upload; + private: + QCoro::Task uploadReport(); + CoreContext& m_ctx; + Notifier& m_toast; + QPushButton* m_upload; }; } // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.cpp b/src/ui/panels/keyboard_shortcuts_dialog.cpp index cd074f9..32a23ac 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.cpp +++ b/src/ui/panels/keyboard_shortcuts_dialog.cpp @@ -1,36 +1,40 @@ #include "ui/panels/keyboard_shortcuts_dialog.hpp" -#include #include +#include #include #include #include namespace px::modrim { -KeyboardShortcutsDialog::KeyboardShortcutsDialog(const QList>& rows, - QWidget* parent) : QDialog(parent) { - setWindowTitle(tr("Keyboard Shortcuts")); - resize(520, 600); - auto* layout = new QVBoxLayout(this); - auto* table = new QTableWidget(static_cast(rows.size()), 2, this); - table->setHorizontalHeaderLabels({tr("Shortcut"), tr("Action")}); - table->verticalHeader()->hide(); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionMode(QAbstractItemView::NoSelection); - table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); - for (qsizetype row = 0; row < rows.size(); ++row) { - table->setItem(static_cast(row), 0, new QTableWidgetItem(rows[row].first)); - table->setItem(static_cast(row), 1, new QTableWidgetItem(rows[row].second)); - } - layout->addWidget(table); - auto* buttons = new QHBoxLayout; - buttons->addStretch(); - auto* close = new QPushButton(tr("Close"), this); - close->setObjectName(QStringLiteral("primaryAction")); - close->setDefault(true); - connect(close, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(close); - layout->addLayout(buttons); +KeyboardShortcutsDialog::KeyboardShortcutsDialog( + const QList>& rows, QWidget* parent) + : QDialog(parent) { + setWindowTitle(tr("Keyboard Shortcuts")); + resize(520, 600); + auto* layout = new QVBoxLayout(this); + auto* table = new QTableWidget(static_cast(rows.size()), 2, this); + table->setHorizontalHeaderLabels({tr("Shortcut"), tr("Action")}); + table->verticalHeader()->hide(); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionMode(QAbstractItemView::NoSelection); + table->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + for (qsizetype row = 0; row < rows.size(); ++row) { + table->setItem(static_cast(row), 0, + new QTableWidgetItem(rows[row].first)); + table->setItem(static_cast(row), 1, + new QTableWidgetItem(rows[row].second)); + } + layout->addWidget(table); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + close->setDefault(true); + connect(close, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(close); + layout->addLayout(buttons); } } // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.hpp b/src/ui/panels/keyboard_shortcuts_dialog.hpp index 54a9bd0..f346ac2 100644 --- a/src/ui/panels/keyboard_shortcuts_dialog.hpp +++ b/src/ui/panels/keyboard_shortcuts_dialog.hpp @@ -8,14 +8,15 @@ namespace px::modrim { inline const QList> kQmlShortcuts{ {QStringLiteral("Up / Down"), QStringLiteral("Navigate mod list")}, - {QStringLiteral("Shift+Up / Shift+Down"), QStringLiteral("Extend mod selection")}, + {QStringLiteral("Shift+Up / Shift+Down"), + QStringLiteral("Extend mod selection")}, {QStringLiteral("Return / Space"), QStringLiteral("Toggle selected mods")}, {QStringLiteral("Ctrl+A"), QStringLiteral("Select all mods")}, }; class KeyboardShortcutsDialog : public QDialog { -public: - explicit KeyboardShortcutsDialog(const QList>& rows, - QWidget* parent = nullptr); + public: + explicit KeyboardShortcutsDialog(const QList>& rows, + QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp index 4d81269..49d7321 100644 --- a/src/ui/panels/mod_info_data.cpp +++ b/src/ui/panels/mod_info_data.cpp @@ -1,175 +1,259 @@ #include "ui/panels/mod_info_data.hpp" -#include "ui/components/unity_rich_text.hpp" -#include "ui/models/mod_list_model.hpp" -#include "core/downloads/manager.hpp" + #include #include #include #include #include +#include "core/downloads/manager.hpp" +#include "ui/components/unity_rich_text.hpp" +#include "ui/models/mod_list_model.hpp" + namespace px::modrim { namespace { -template QString substitute(const QString& text, const QRegularExpression& re, F replacement) { - QString out; - out.reserve(text.size()); - qsizetype end = 0; - auto matches = re.globalMatch(text); - while (matches.hasNext()) { - const auto match = matches.next(); - out += text.mid(end, match.capturedStart() - end); - out += replacement(match); - end = match.capturedEnd(); - } - out += text.mid(end); - return out; +template +QString substitute(const QString& text, const QRegularExpression& re, + F replacement) { + QString out; + out.reserve(text.size()); + qsizetype end = 0; + auto matches = re.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + out += text.mid(end, match.capturedStart() - end); + out += replacement(match); + end = match.capturedEnd(); + } + out += text.mid(end); + return out; } QRegularExpression insensitive(const QString& pattern) { - return QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); -} + return QRegularExpression(pattern, + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); } +} // namespace QString bbcodeImages(QString text) { - static const auto re = insensitive(R"(\[img\]((https?)://[^\s\[<"]+)\[/img\])"); - return substitute(text, re, [](const auto& match) { - const QString url = match.captured(1); - return match.captured(2).compare("https", Qt::CaseInsensitive) == 0 - ? "" - : "" + url + ""; - }); + static const auto re = + insensitive(R"(\[img\]((https?)://[^\s\[<"]+)\[/img\])"); + return substitute(text, re, [](const auto& match) { + const QString url = match.captured(1); + return match.captured(2).compare("https", Qt::CaseInsensitive) == 0 + ? "" + : "" + url + ""; + }); } QString restoreEntities(QString text) { - static const QRegularExpression re(R"(&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});)"); - return text.replace(re, "&\\1;"); + static const QRegularExpression re( + R"(&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});)"); + return text.replace(re, "&\\1;"); } QString bbcodeMarkup(QString text) { - static const auto namedUrl = insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); - static const auto bareUrl = insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); - static const auto quote = insensitive(R"(\[quote=([^\]]+)\])"); - static const auto table = insensitive(R"(\[table[^\]]*\])"); - static const auto strip = insensitive(R"(\[/?(?:spoiler|noparse)\])"); - static const auto hr = insensitive(R"(\[hr\])"); - static const auto paired = insensitive(R"(\[(/?)(b|i|u|s|strike|h1|h2|h3|code|quote|list|olist|table|tr|td|th)\])"); - static const QHash names{{"strike", "s"}, {"code", "pre"}, {"quote", "blockquote"}, {"list", "ul"}, {"olist", "ol"}}; - text.replace(namedUrl, "\\2"); - text.replace(bareUrl, "\\1"); - text.replace(quote, "
    \\1 wrote:
    "); - text.replace(table, ""); text.replace(strip, ""); text.replace(hr, "
    "); text.replace("[*]", "
  • "); - text = substitute(text, paired, [](const auto& match) { - const QString tag = match.captured(2).toLower(); - return "<" + match.captured(1) + names.value(tag, tag) + ">"; - }); - static const QRegularExpression before(R"((?:
    \s*)+(?=)(?:
    \s*)+)"); - text.replace(before, ""); - return text.replace(after, "\\1"); + static const auto namedUrl = + insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); + static const auto bareUrl = + insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); + static const auto quote = insensitive(R"(\[quote=([^\]]+)\])"); + static const auto table = insensitive(R"(\[table[^\]]*\])"); + static const auto strip = insensitive(R"(\[/?(?:spoiler|noparse)\])"); + static const auto hr = insensitive(R"(\[hr\])"); + static const auto paired = insensitive( + R"(\[(/?)(b|i|u|s|strike|h1|h2|h3|code|quote|list|olist|table|tr|td|th)\])"); + static const QHash names{{"strike", "s"}, + {"code", "pre"}, + {"quote", "blockquote"}, + {"list", "ul"}, + {"olist", "ol"}}; + text.replace(namedUrl, "\\2"); + text.replace(bareUrl, "\\1"); + text.replace(quote, "
    \\1 wrote:
    "); + text.replace(table, "
  • "); + text.replace(strip, ""); + text.replace(hr, "
    "); + text.replace("[*]", "
  • "); + text = substitute(text, paired, [](const auto& match) { + const QString tag = match.captured(2).toLower(); + return "<" + match.captured(1) + names.value(tag, tag) + ">"; + }); + static const QRegularExpression before( + R"((?:
    \s*)+(?=)(?:
    \s*)+)"); + text.replace(before, ""); + return text.replace(after, "\\1"); } QString autolink(const QString& text) { - static const QRegularExpression tags("<[^>]+>"); - static const QRegularExpression urls(R"(https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>"])+)"); - const auto linkText = [](const QString& part) { - return substitute(part, urls, [](const auto& match) { - const QString url = match.captured(); - QString stripped = url; - while (!stripped.isEmpty() && QStringLiteral(".,;:!?)").contains(stripped.back())) stripped.chop(1); - return "" + stripped + "" + url.mid(stripped.size()); - }); - }; - QString out; - qsizetype end = 0; - bool anchor = false; - auto matches = tags.globalMatch(text); - while (matches.hasNext()) { - const auto match = matches.next(); - const QString plain = text.mid(end, match.capturedStart() - end); - out += anchor ? plain : linkText(plain); - const QString tag = match.captured(), lowered = tag.toLower(); - if (lowered.startsWith("") anchor = true; - else if (lowered == "") anchor = false; - out += tag; - end = match.capturedEnd(); - } - const QString tail = text.mid(end); - return out + (anchor ? tail : linkText(tail)); + static const QRegularExpression tags("<[^>]+>"); + static const QRegularExpression urls( + R"(https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>"])+)"); + const auto linkText = [](const QString& part) { + return substitute(part, urls, [](const auto& match) { + const QString url = match.captured(); + QString stripped = url; + while (!stripped.isEmpty() && + QStringLiteral(".,;:!?)").contains(stripped.back())) + stripped.chop(1); + return "" + stripped + "" + + url.mid(stripped.size()); + }); + }; + QString out; + qsizetype end = 0; + bool anchor = false; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + const QString plain = text.mid(end, match.capturedStart() - end); + out += anchor ? plain : linkText(plain); + const QString tag = match.captured(), lowered = tag.toLower(); + if (lowered.startsWith("") + anchor = true; + else if (lowered == "") + anchor = false; + out += tag; + end = match.capturedEnd(); + } + const QString tail = text.mid(end); + return out + (anchor ? tail : linkText(tail)); } -QString descriptionToHtml(const QString& text, const QString& descriptionFormat) { - if (descriptionFormat != "bbcode") return escapeHtml(text).replace('\n', "
    "); - return autolink(bbcodeMarkup(bbcodeImages(restoreEntities(unityRichTextToHtml(text))))); +QString descriptionToHtml(const QString& text, + const QString& descriptionFormat) { + if (descriptionFormat != "bbcode") + return escapeHtml(text).replace('\n', "
    "); + return autolink( + bbcodeMarkup(bbcodeImages(restoreEntities(unityRichTextToHtml(text))))); } QString formatAge(double mtime, std::optional now) { - if (mtime <= 0) return {}; - const double delta = now.value_or(QDateTime::currentMSecsSinceEpoch() / 1000.0) - mtime; - static constexpr std::array, 5> kUnits{{{"year", 365 * 86400}, {"month", 30 * 86400}, {"week", 7 * 86400}, {"day", 86400}, {"hour", 3600}}}; - for (const auto& [name, seconds] : kUnits) if (delta >= seconds) { - const auto count = static_cast(delta / seconds); - return QString::number(count) + " " + name + (count == 1 ? "" : "s") + " ago"; + if (mtime <= 0) return {}; + const double delta = + now.value_or(QDateTime::currentMSecsSinceEpoch() / 1000.0) - mtime; + static constexpr std::array, 5> kUnits{ + {{"year", 365 * 86400}, + {"month", 30 * 86400}, + {"week", 7 * 86400}, + {"day", 86400}, + {"hour", 3600}}}; + for (const auto& [name, seconds] : kUnits) + if (delta >= seconds) { + const auto count = static_cast(delta / seconds); + return QString::number(count) + " " + name + (count == 1 ? "" : "s") + + " ago"; } - return "just now"; + return "just now"; } -QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, const QList& issues) { - const auto* about = dynamic_cast(&mod); - const auto& active = ctx.activeUuids(); - const auto position = active.indexOf(mod.uuid()); - QVariantList issueRows, needs, neededBy, details; - QStringList conflicts; - int errors = 0; - for (const auto& issue : issues) { - errors += issue.isError; - issueRows.append(QVariantMap{{"title", issue.categoryDisplayName}, {"detail", issue.detail.value_or(QString{})}, {"isError", issue.isError}}); - } - const auto warnings = issues.size() - errors; - const QString level = errors ? "error" : warnings ? "warning" : "ok"; - const auto count = errors ? errors : warnings; - const QString title = count ? QString::number(count) + (errors ? " error" : " warning") + (count == 1 ? "" : "s") : QStringLiteral("No issues"); - if (about) { - QHash index; - for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get())) - if (!index.contains(other->packageId.str())) index.insert(other->packageId.str(), other); - for (const auto& dep : about->aboutRules.dependencies) { - auto* found = index.value(dep.packageId.str(), nullptr); - if (!found) for (const auto& alternative : dep.alternativePackageIds) { - found = index.value(alternative.str(), nullptr); - if (found) break; - } - needs.append(QVariantMap{{"name", dep.name.isEmpty() ? dep.packageId.str() : dep.name}, - {"state", !found ? "missing" : active.contains(found->uuid()) ? "active" : "inactive"}, {"uuid", found ? found->uuid() : QString{}}}); +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, + const QList& issues) { + const auto* about = dynamic_cast(&mod); + const auto& active = ctx.activeUuids(); + const auto position = active.indexOf(mod.uuid()); + QVariantList issueRows, needs, neededBy, details; + QStringList conflicts; + int errors = 0; + for (const auto& issue : issues) { + errors += issue.isError; + issueRows.append(QVariantMap{{"title", issue.categoryDisplayName}, + {"detail", issue.detail.value_or(QString{})}, + {"isError", issue.isError}}); + } + const auto warnings = issues.size() - errors; + const QString level = errors ? "error" : warnings ? "warning" : "ok"; + const auto count = errors ? errors : warnings; + const QString title = count ? QString::number(count) + + (errors ? " error" : " warning") + + (count == 1 ? "" : "s") + : QStringLiteral("No issues"); + if (about) { + QHash index; + for (const auto& entry : ctx.allMods()) + if (const auto* other = dynamic_cast(entry.get())) + if (!index.contains(other->packageId.str())) + index.insert(other->packageId.str(), other); + for (const auto& dep : about->aboutRules.dependencies) { + auto* found = index.value(dep.packageId.str(), nullptr); + if (!found) + for (const auto& alternative : dep.alternativePackageIds) { + found = index.value(alternative.str(), nullptr); + if (found) break; } - for (const auto& pid : about->aboutRules.incompatibleWith) { - const auto* other = index.value(pid.str(), nullptr); - if (other && active.contains(other->uuid())) conflicts.append(other->name); - } - std::sort(conflicts.begin(), conflicts.end(), [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); - QList dependents; - for (const auto& entry : ctx.allMods()) if (const auto* other = dynamic_cast(entry.get()); other && other->uuid() != mod.uuid()) { - for (const auto& dep : other->aboutRules.dependencies) - if (dep.packageId == about->packageId || dep.alternativePackageIds.contains(about->packageId)) { dependents.append(other); break; } - } - std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { return a->name.toLower() < b->name.toLower(); }); - for (const auto* other : dependents) neededBy.append(QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + needs.append(QVariantMap{ + {"name", dep.name.isEmpty() ? dep.packageId.str() : dep.name}, + {"state", !found ? "missing" + : active.contains(found->uuid()) ? "active" + : "inactive"}, + {"uuid", found ? found->uuid() : QString{}}}); } - auto& downloads = ctx.downloads(); - auto* source = downloads.sourceOf(mod); - const auto row = [&details](const QString& label, const QString& value, bool copy = false) { - QVariantMap item{{"label", label}, {"value", value}}; - if (copy) item.insert("copy", true); - details.append(item); - }; - QString provider; - if (about) { - if (!about->authors.isEmpty()) row("Author", about->authors.join(", ")); - if (!about->modVersion.isEmpty()) row("Version", about->modVersion); - provider = providerLabel(mod.providerId, about->packageId.str()); - if (!provider.isEmpty()) row("Source", provider); - if (about->mtime > 0) row("Updated", formatAge(about->mtime)); - if (const auto synced = downloads.lastSynced(mod); synced && *synced != 0) row("Last synced", formatAge(*synced)); - row("Package ID", about->packageId.str(), true); + for (const auto& pid : about->aboutRules.incompatibleWith) { + const auto* other = index.value(pid.str(), nullptr); + if (other && active.contains(other->uuid())) + conflicts.append(other->name); } - if (mod.modPath()) row("Path", *mod.modPath(), true); - const QString target = ctx.targetVersion(); - const bool known = !mod.supportedVersions.isEmpty() && target != "Unknown"; - return {{"name", mod.name}, {"author", about ? about->authors.join(", ") : QString{}}, {"provider", provider}, - {"status", QVariantMap{{"level", level}, {"title", title}, {"active", position >= 0}, {"position", position >= 0 ? position + 1 : 0}, {"total", active.size()}, {"issues", issueRows}, {"obsolete", mod.obsolete}, {"valid", mod.valid}}}, - {"version", QVariantMap{{"known", known}, {"ok", known && mod.supportedVersions.contains(target)}, {"target", target}}}, - {"needs", needs}, {"conflicts", conflicts}, {"neededBy", neededBy}, {"description", descriptionToHtml(mod.description)}, {"details", details}, - {"canOpenFolder", mod.modPath() && QFileInfo::exists(*mod.modPath())}, {"canUpdate", source != nullptr}, {"updateSource", source ? source->label() : QString{}}, {"url", about ? about->url : QString{}}}; -} + std::sort( + conflicts.begin(), conflicts.end(), + [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); + QList dependents; + for (const auto& entry : ctx.allMods()) + if (const auto* other = dynamic_cast(entry.get()); + other && other->uuid() != mod.uuid()) { + for (const auto& dep : other->aboutRules.dependencies) + if (dep.packageId == about->packageId || + dep.alternativePackageIds.contains(about->packageId)) { + dependents.append(other); + break; + } + } + std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { + return a->name.toLower() < b->name.toLower(); + }); + for (const auto* other : dependents) + neededBy.append( + QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + } + auto& downloads = ctx.downloads(); + auto* source = downloads.sourceOf(mod); + const auto row = [&details](const QString& label, const QString& value, + bool copy = false) { + QVariantMap item{{"label", label}, {"value", value}}; + if (copy) item.insert("copy", true); + details.append(item); + }; + QString provider; + if (about) { + if (!about->authors.isEmpty()) row("Author", about->authors.join(", ")); + if (!about->modVersion.isEmpty()) row("Version", about->modVersion); + provider = providerLabel(mod.providerId, about->packageId.str()); + if (!provider.isEmpty()) row("Source", provider); + if (about->mtime > 0) row("Updated", formatAge(about->mtime)); + if (const auto synced = downloads.lastSynced(mod); synced && *synced != 0) + row("Last synced", formatAge(*synced)); + row("Package ID", about->packageId.str(), true); + } + if (mod.modPath()) row("Path", *mod.modPath(), true); + const QString target = ctx.targetVersion(); + const bool known = !mod.supportedVersions.isEmpty() && target != "Unknown"; + return {{"name", mod.name}, + {"author", about ? about->authors.join(", ") : QString{}}, + {"provider", provider}, + {"status", QVariantMap{{"level", level}, + {"title", title}, + {"active", position >= 0}, + {"position", position >= 0 ? position + 1 : 0}, + {"total", active.size()}, + {"issues", issueRows}, + {"obsolete", mod.obsolete}, + {"valid", mod.valid}}}, + {"version", + QVariantMap{{"known", known}, + {"ok", known && mod.supportedVersions.contains(target)}, + {"target", target}}}, + {"needs", needs}, + {"conflicts", conflicts}, + {"neededBy", neededBy}, + {"description", descriptionToHtml(mod.description)}, + {"details", details}, + {"canOpenFolder", mod.modPath() && QFileInfo::exists(*mod.modPath())}, + {"canUpdate", source != nullptr}, + {"updateSource", source ? source->label() : QString{}}, + {"url", about ? about->url : QString{}}}; } +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_data.hpp b/src/ui/panels/mod_info_data.hpp index bb7876a..59256e5 100644 --- a/src/ui/panels/mod_info_data.hpp +++ b/src/ui/panels/mod_info_data.hpp @@ -1,6 +1,7 @@ #pragma once #include #include + #include "core/context.hpp" #include "core/models/view/diagnostics.hpp" namespace px::modrim { @@ -9,7 +10,10 @@ QString bbcodeImages(QString text); QString restoreEntities(QString text); QString bbcodeMarkup(QString text); QString autolink(const QString& text); -QString descriptionToHtml(const QString& text, const QString& descriptionFormat = QStringLiteral("bbcode")); +QString descriptionToHtml( + const QString& text, + const QString& descriptionFormat = QStringLiteral("bbcode")); QString formatAge(double mtime, std::optional now = std::nullopt); -QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, const QList& issues); -} +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, + const QList& issues); +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp index c99781c..f7e7849 100644 --- a/src/ui/panels/mod_info_panel.cpp +++ b/src/ui/panels/mod_info_panel.cpp @@ -1,22 +1,12 @@ #include "ui/panels/mod_info_panel.hpp" -#include "ui/panels/mod_info_data.hpp" -#include "ui/panels/time_analytics_panel.hpp" -#include "ui/components/procedural_preview.hpp" -#include "ui/config.hpp" -#include "ui/models/impact.hpp" -#include "ui/theme/constants.hpp" -#include "ui/theme/palette.hpp" -#include "core/downloads/manager.hpp" -#include "core/mod_service.hpp" -#include "core/services/startup_impact_service/labels.hpp" -#include + #include #include #include #include -#include #include #include +#include #include #include #include @@ -24,189 +14,337 @@ #include #include #include -#include #include +#include +#include + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/startup_impact_service/labels.hpp" +#include "ui/components/procedural_preview.hpp" +#include "ui/config.hpp" +#include "ui/models/impact.hpp" +#include "ui/panels/mod_info_data.hpp" +#include "ui/panels/time_analytics_panel.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + namespace px::modrim { class ModInfoBanner final : public QWidget { -public: - explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } - bool hasHeightForWidth() const override { return true; } - int heightForWidth(int width) const override { return std::min(width * kBannerAspectH / kBannerAspectW, kBannerMaxHeight); } - QSize sizeHint() const override { const int w = width() ? width() : 300; return {w, heightForWidth(w)}; } - void setTitle(QString title, QString subtitle) { m_title = std::move(title); m_subtitle = std::move(subtitle); update(); } - void setImage(QImage image, bool overlay) { m_image = std::move(image); m_overlay = overlay; rescale(); update(); } -protected: - void resizeEvent(QResizeEvent* event) override { rescale(); QWidget::resizeEvent(event); } - void paintEvent(QPaintEvent*) override { - QPainter painter(this); - painter.setRenderHint(QPainter::SmoothPixmapTransform); - painter.setClipRect(rect()); - if (m_scaled.isNull()) painter.fillRect(rect(), panelBgQ()); - else painter.drawImage((width() - m_scaled.width()) / 2, (height() - m_scaled.height()) / 2, m_scaled); - if (m_overlay && !m_title.isEmpty()) { - QLinearGradient gradient(0, height() - kBannerOverlayHeight, 0, height()); - gradient.setColorAt(0, QColor(0, 0, 0, 0)); gradient.setColorAt(1, QColor(0, 0, 0, 180)); - painter.fillRect(0, height() - kBannerOverlayHeight, width(), kBannerOverlayHeight, gradient); - const int top = height() - kBannerOverlayHeight + 12; - int titleHeight = kBannerOverlayHeight - 24; - if (!m_subtitle.isEmpty()) titleHeight = titleHeight * 3 / 5; - auto font = painter.font(); font.setPointSize(18); font.setBold(true); - painter.setFont(font); painter.setPen(textMainQ()); - painter.drawText(QRect(16, top, width() - 32, titleHeight), Qt::AlignLeft | Qt::AlignBottom, m_title); - if (!m_subtitle.isEmpty()) { - font.setPointSize(12); font.setBold(false); painter.setFont(font); painter.setPen(textMutedQ()); - painter.drawText(QRect(16, top + titleHeight + 2, width() - 32, kBannerOverlayHeight - titleHeight - 26), Qt::AlignLeft | Qt::AlignTop, m_subtitle); - } - } - } -private: - void rescale() { - m_scaled = m_image.isNull() || width() <= 0 ? QImage{} : m_image.scaled(width(), heightForWidth(width()), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + public: + explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + } + bool hasHeightForWidth() const override { return true; } + int heightForWidth(int width) const override { + return std::min(width * kBannerAspectH / kBannerAspectW, kBannerMaxHeight); + } + QSize sizeHint() const override { + const int w = width() ? width() : 300; + return {w, heightForWidth(w)}; + } + void setTitle(QString title, QString subtitle) { + m_title = std::move(title); + m_subtitle = std::move(subtitle); + update(); + } + void setImage(QImage image, bool overlay) { + m_image = std::move(image); + m_overlay = overlay; + rescale(); + update(); + } + + protected: + void resizeEvent(QResizeEvent* event) override { + rescale(); + QWidget::resizeEvent(event); + } + void paintEvent(QPaintEvent*) override { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + if (m_scaled.isNull()) + painter.fillRect(rect(), panelBgQ()); + else + painter.drawImage((width() - m_scaled.width()) / 2, + (height() - m_scaled.height()) / 2, m_scaled); + if (m_overlay && !m_title.isEmpty()) { + QLinearGradient gradient(0, height() - kBannerOverlayHeight, 0, height()); + gradient.setColorAt(0, QColor(0, 0, 0, 0)); + gradient.setColorAt(1, QColor(0, 0, 0, 180)); + painter.fillRect(0, height() - kBannerOverlayHeight, width(), + kBannerOverlayHeight, gradient); + const int top = height() - kBannerOverlayHeight + 12; + int titleHeight = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) titleHeight = titleHeight * 3 / 5; + auto font = painter.font(); + font.setPointSize(18); + font.setBold(true); + painter.setFont(font); + painter.setPen(textMainQ()); + painter.drawText(QRect(16, top, width() - 32, titleHeight), + Qt::AlignLeft | Qt::AlignBottom, m_title); + if (!m_subtitle.isEmpty()) { + font.setPointSize(12); + font.setBold(false); + painter.setFont(font); + painter.setPen(textMutedQ()); + painter.drawText(QRect(16, top + titleHeight + 2, width() - 32, + kBannerOverlayHeight - titleHeight - 26), + Qt::AlignLeft | Qt::AlignTop, m_subtitle); + } } - QImage m_image, m_scaled; - QString m_title, m_subtitle; - bool m_overlay = true; + } + + private: + void rescale() { + m_scaled = m_image.isNull() || width() <= 0 + ? QImage{} + : m_image.scaled(width(), heightForWidth(width()), + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + } + QImage m_image, m_scaled; + QString m_title, m_subtitle; + bool m_overlay = true; }; namespace { QString firstSentence(QString text) { - static const QRegularExpression markup(R"(\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\])", QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); - text.replace(markup, " "); - text = QTextDocumentFragment::fromHtml(text).toPlainText().simplified(); - const auto end = text.indexOf('.'); - if (end >= 0) text.truncate(end + 1); - if (text.size() > 80) { - text.truncate(80); - const auto space = text.lastIndexOf(' '); - if (space >= 0) text.truncate(space); - text += QChar(0x2026); - } - return text; -} -} -ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent) - : QWidget(parent), m_ctx(ctx), m_engine(engine), m_prefs(prefs), m_banner(new ModInfoBanner(this)), - m_placeholder(new QLabel("Select a mod to view details", this)), m_qml(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - m_banner->hide(); layout->addWidget(m_banner, 0, Qt::AlignTop); - m_placeholder->setAlignment(Qt::AlignCenter); m_placeholder->setObjectName("placeholder"); - layout->addWidget(m_placeholder, 0, Qt::AlignTop); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setClearColor(paletteColor(u"ELEVATE_2")); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); - m_qml->hide(); layout->addWidget(m_qml, 1); - if (auto* root = m_qml->rootObject()) { - root->setProperty("descExpanded", m_prefs.desc_expanded); - connect(root, SIGNAL(openFolder()), this, SLOT(openFolder())); - connect(root, SIGNAL(openUrl()), this, SLOT(openUrl())); - connect(root, SIGNAL(selectMod(QString)), this, SIGNAL(modRequested(QString))); - connect(root, SIGNAL(copyText(QString)), this, SLOT(copyText(QString))); - connect(root, SIGNAL(openStartupDetails()), this, SLOT(openStartupDetails())); - connect(root, SIGNAL(descToggled(bool)), this, SLOT(descToggled(bool))); - connect(root, SIGNAL(updateMods()), this, SLOT(updateMod())); - } - setQml("downloadsBusy", m_ctx.downloads().isDownloading()); - m_busyConnection = m_ctx.downloads().busyChanged.connect([this](bool busy) { setQml("downloadsBusy", busy); syncUpdating(); }); -} -ModInfoPanel::~ModInfoPanel() { m_ctx.downloads().busyChanged.disconnect(m_busyConnection); } -void ModInfoPanel::setQml(const char* name, const QVariant& value) { if (auto* root = m_qml->rootObject()) root->setProperty(name, value); } -void ModInfoPanel::showMod(const ListedMod& mod, const QList& issues) { - const auto token = ++m_previewToken; - m_mod = m_ctx.allMods().value(mod.uuid()); - if (!m_mod) { - if (const auto* about = dynamic_cast(&mod)) m_mod = std::make_shared(*about); - else m_mod = std::make_shared(mod); - } - m_placeholder->hide(); m_banner->show(); m_qml->show(); - const auto* about = dynamic_cast(&mod); - m_banner->setTitle(mod.name, !mod.description.isEmpty() ? firstSentence(mod.description) : about ? about->packageId.str() : QString{}); - ++m_startupToken; - setQml("startup", QVariant{}); setIssues(issues); syncUpdating(); - if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, "resetScroll"); - loadPreview(mod.modPath(), mod.name, token); + static const QRegularExpression markup( + R"(\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\])", + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); + text.replace(markup, " "); + text = QTextDocumentFragment::fromHtml(text).toPlainText().simplified(); + const auto end = text.indexOf('.'); + if (end >= 0) text.truncate(end + 1); + if (text.size() > 80) { + text.truncate(80); + const auto space = text.lastIndexOf(' '); + if (space >= 0) text.truncate(space); + text += QChar(0x2026); + } + return text; +} +} // namespace +ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, + QWidget* parent) + : QWidget(parent), + m_ctx(ctx), + m_engine(engine), + m_prefs(prefs), + m_banner(new ModInfoBanner(this)), + m_placeholder(new QLabel("Select a mod to view details", this)), + m_qml(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_banner->hide(); + layout->addWidget(m_banner, 0, Qt::AlignTop); + m_placeholder->setAlignment(Qt::AlignCenter); + m_placeholder->setObjectName("placeholder"); + layout->addWidget(m_placeholder, 0, Qt::AlignTop); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); + m_qml->hide(); + layout->addWidget(m_qml, 1); + if (auto* root = m_qml->rootObject()) { + root->setProperty("descExpanded", m_prefs.desc_expanded); + connect(root, SIGNAL(openFolder()), this, SLOT(openFolder())); + connect(root, SIGNAL(openUrl()), this, SLOT(openUrl())); + connect(root, SIGNAL(selectMod(QString)), this, + SIGNAL(modRequested(QString))); + connect(root, SIGNAL(copyText(QString)), this, SLOT(copyText(QString))); + connect(root, SIGNAL(openStartupDetails()), this, + SLOT(openStartupDetails())); + connect(root, SIGNAL(descToggled(bool)), this, SLOT(descToggled(bool))); + connect(root, SIGNAL(updateMods()), this, SLOT(updateMod())); + } + setQml("downloadsBusy", m_ctx.downloads().isDownloading()); + m_busyConnection = m_ctx.downloads().busyChanged.connect([this](bool busy) { + setQml("downloadsBusy", busy); + syncUpdating(); + }); +} +ModInfoPanel::~ModInfoPanel() { + m_ctx.downloads().busyChanged.disconnect(m_busyConnection); +} +void ModInfoPanel::setQml(const char* name, const QVariant& value) { + if (auto* root = m_qml->rootObject()) root->setProperty(name, value); +} +void ModInfoPanel::showMod(const ListedMod& mod, + const QList& issues) { + const auto token = ++m_previewToken; + m_mod = m_ctx.allMods().value(mod.uuid()); + if (!m_mod) { + if (const auto* about = dynamic_cast(&mod)) + m_mod = std::make_shared(*about); + else + m_mod = std::make_shared(mod); + } + m_placeholder->hide(); + m_banner->show(); + m_qml->show(); + const auto* about = dynamic_cast(&mod); + m_banner->setTitle(mod.name, !mod.description.isEmpty() + ? firstSentence(mod.description) + : about ? about->packageId.str() + : QString{}); + ++m_startupToken; + setQml("startup", QVariant{}); + setIssues(issues); + syncUpdating(); + if (auto* root = m_qml->rootObject()) + QMetaObject::invokeMethod(root, "resetScroll"); + loadPreview(mod.modPath(), mod.name, token); } void ModInfoPanel::setIssues(const QList& issues) { - if (!m_mod) return; - auto data = buildModInfo(m_ctx, *m_mod, issues); - const QString description = data.take("description").toString(); - setQml("info", data); - if (description != m_description) { m_description = description; setQml("description", description); } + if (!m_mod) return; + auto data = buildModInfo(m_ctx, *m_mod, issues); + const QString description = data.take("description").toString(); + setQml("info", data); + if (description != m_description) { + m_description = description; + setQml("description", description); + } } void ModInfoPanel::clear() { - m_mod.reset(); ++m_previewToken; ++m_startupToken; - m_placeholder->show(); m_banner->hide(); m_qml->hide(); - m_description.clear(); m_startupPid.reset(); m_startupActivePids.clear(); - setQml("info", QVariant{}); setQml("description", QString{}); setQml("startup", QVariant{}); + m_mod.reset(); + ++m_previewToken; + ++m_startupToken; + m_placeholder->show(); + m_banner->hide(); + m_qml->hide(); + m_description.clear(); + m_startupPid.reset(); + m_startupActivePids.clear(); + setQml("info", QVariant{}); + setQml("description", QString{}); + setQml("startup", QVariant{}); } void ModInfoPanel::syncUpdating() { - const auto id = m_mod ? m_ctx.downloads().updatableId(*m_mod) : std::nullopt; - setQml("updatingThis", id && m_ctx.downloads().activeIds().contains(*id)); + const auto id = m_mod ? m_ctx.downloads().updatableId(*m_mod) : std::nullopt; + setQml("updatingThis", id && m_ctx.downloads().activeIds().contains(*id)); +} +void ModInfoPanel::openFolder() { + if (m_mod && m_mod->modPath()) + QDesktopServices::openUrl(QUrl::fromLocalFile(*m_mod->modPath())); } -void ModInfoPanel::openFolder() { if (m_mod && m_mod->modPath()) QDesktopServices::openUrl(QUrl::fromLocalFile(*m_mod->modPath())); } void ModInfoPanel::openUrl() { - const auto* about = dynamic_cast(m_mod.get()); - if (!about) return; - const QUrl url(about->url); - const auto scheme = url.scheme().toLower(); - if (scheme == "http" || scheme == "https") QDesktopServices::openUrl(url); -} -void ModInfoPanel::copyText(const QString& text) { QGuiApplication::clipboard()->setText(text); } -void ModInfoPanel::descToggled(bool expanded) { m_prefs.desc_expanded = expanded; if (m_ctx.hasConfigService()) saveUiPrefs(m_prefs, m_ctx.configService()); } + const auto* about = dynamic_cast(m_mod.get()); + if (!about) return; + const QUrl url(about->url); + const auto scheme = url.scheme().toLower(); + if (scheme == "http" || scheme == "https") QDesktopServices::openUrl(url); +} +void ModInfoPanel::copyText(const QString& text) { + QGuiApplication::clipboard()->setText(text); +} +void ModInfoPanel::descToggled(bool expanded) { + m_prefs.desc_expanded = expanded; + if (m_ctx.hasConfigService()) saveUiPrefs(m_prefs, m_ctx.configService()); +} void ModInfoPanel::updateMod() { - if (!m_mod) return; - if (auto id = m_ctx.downloads().updatableId(*m_mod)) performUpdate(*id); + if (!m_mod) return; + if (auto id = m_ctx.downloads().updatableId(*m_mod)) performUpdate(*id); } QCoro::Task ModInfoPanel::performUpdate(QString id) { - const auto result = co_await m_ctx.downloads().downloadMods({id}); - if (!result) qWarning().noquote() << "Mod update not started:" << result.error().message; -} -QCoro::Task ModInfoPanel::loadPreview(std::optional path, QString title, quint64 token) { - QPointer guard(this); - const int width = m_banner->width() ? m_banner->width() : 300; - const int height = m_banner->heightForWidth(width); - auto future = QtConcurrent::run([path = std::move(path), title = std::move(title), width, height] { + const auto result = co_await m_ctx.downloads().downloadMods({id}); + if (!result) + qWarning().noquote() << "Mod update not started:" << result.error().message; +} +QCoro::Task ModInfoPanel::loadPreview(std::optional path, + QString title, quint64 token) { + QPointer guard(this); + const int width = m_banner->width() ? m_banner->width() : 300; + const int height = m_banner->heightForWidth(width); + auto future = QtConcurrent::run( + [path = std::move(path), title = std::move(title), width, height] { QImage image; if (path) image.load(*path + "/About/Preview.png"); const bool overlay = image.isNull(); if (overlay) image = generatePreview(title, width, height); return std::pair{image, overlay}; - }); - auto [image, overlay] = co_await future; - if (guard && token == guard->m_previewToken) guard->m_banner->setImage(std::move(image), overlay); -} -QCoro::Task ModInfoPanel::setTimeAnalytics(const std::optional& pid, const QStringList& activePids) { - const auto token = ++m_startupToken; - m_startupPid = pid; m_startupActivePids = activePids; - if (!pid) { setQml("startup", QVariantMap{{"available", false}, {"message", "Startup impact is unavailable."}}); co_return; } - QPointer guard(this); - const auto selectedPid = *pid; - const auto active = activePids; - auto result = co_await m_ctx.modService().startupImpact().snapshot(active, selectedPid); - if (!guard || token != guard->m_startupToken) co_return; - if (!result || !result->report) { - guard->setQml("startup", QVariantMap{{"available", false}, {"message", result ? QStringLiteral("No startup data recorded yet. Launch the game to generate it.") : result.error().message}}); - co_return; - } - const auto& report = *result->report; - const auto* mod = report.find(selectedPid, std::nullopt); - const double own = result->selectedAvg; - if (!mod || own < .001) { guard->setQml("startup", QVariantMap{{"available", false}, {"message", "This mod wasn't measured in the last recorded launch."}}); co_return; } - int rank = 1; - for (const auto& entry : report.mods) rank += entry.totalImpactS > own; - QList> metrics; - for (auto it = mod->metrics.cbegin(); it != mod->metrics.cend(); ++it) metrics.append({it.key(), it.value()}); - std::stable_sort(metrics.begin(), metrics.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); - QVariantList rows; - for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) if (metrics[i].second >= .001) - rows.append(QVariantMap{{"label", startup_impact::metricLabel(metrics[i].first)}, {"value", formatDuration(metrics[i].second)}, {"fraction", std::min(1.0, metrics[i].second / own)}}); - guard->setQml("startup", QVariantMap{{"available", true}, {"own", formatDuration(own)}, {"rank", QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, - {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, {"color", impactColor(own)}, {"rows", rows}}); + }); + auto [image, overlay] = co_await future; + if (guard && token == guard->m_previewToken) + guard->m_banner->setImage(std::move(image), overlay); +} +QCoro::Task ModInfoPanel::setTimeAnalytics( + const std::optional& pid, const QStringList& activePids) { + const auto token = ++m_startupToken; + m_startupPid = pid; + m_startupActivePids = activePids; + if (!pid) { + setQml("startup", + QVariantMap{{"available", false}, + {"message", "Startup impact is unavailable."}}); + co_return; + } + QPointer guard(this); + const auto selectedPid = *pid; + const auto active = activePids; + auto result = + co_await m_ctx.modService().startupImpact().snapshot(active, selectedPid); + if (!guard || token != guard->m_startupToken) co_return; + if (!result || !result->report) { + guard->setQml( + "startup", + QVariantMap{{"available", false}, + {"message", + result ? QStringLiteral("No startup data recorded yet. " + "Launch the game to generate it.") + : result.error().message}}); + co_return; + } + const auto& report = *result->report; + const auto* mod = report.find(selectedPid, std::nullopt); + const double own = result->selectedAvg; + if (!mod || own < .001) { + guard->setQml( + "startup", + QVariantMap{{"available", false}, + {"message", + "This mod wasn't measured in the last recorded launch."}}); + co_return; + } + int rank = 1; + for (const auto& entry : report.mods) rank += entry.totalImpactS > own; + QList> metrics; + for (auto it = mod->metrics.cbegin(); it != mod->metrics.cend(); ++it) + metrics.append({it.key(), it.value()}); + std::stable_sort( + metrics.begin(), metrics.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + QVariantList rows; + for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) + if (metrics[i].second >= .001) + rows.append( + QVariantMap{{"label", startup_impact::metricLabel(metrics[i].first)}, + {"value", formatDuration(metrics[i].second)}, + {"fraction", std::min(1.0, metrics[i].second / own)}}); + guard->setQml( + "startup", + QVariantMap{ + {"available", true}, + {"own", formatDuration(own)}, + {"rank", + QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, + {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, + {"color", impactColor(own)}, + {"rows", rows}}); } void ModInfoPanel::openStartupDetails() { showStartupDetails(); } QCoro::Task ModInfoPanel::showStartupDetails() { - if (!m_startupDialog) m_startupDialog = new StartupImpactDialog(&m_ctx.modService().startupImpact(), m_engine, this); - m_startupDialog->show(); m_startupDialog->raise(); m_startupDialog->activateWindow(); - co_await m_startupDialog->panel->setData(m_startupPid, m_startupActivePids); -} + if (!m_startupDialog) + m_startupDialog = new StartupImpactDialog( + &m_ctx.modService().startupImpact(), m_engine, this); + m_startupDialog->show(); + m_startupDialog->raise(); + m_startupDialog->activateWindow(); + co_await m_startupDialog->panel->setData(m_startupPid, m_startupActivePids); } +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_panel.hpp b/src/ui/panels/mod_info_panel.hpp index 3eefced..1c9c5cb 100644 --- a/src/ui/panels/mod_info_panel.hpp +++ b/src/ui/panels/mod_info_panel.hpp @@ -1,7 +1,8 @@ #pragma once #include -#include #include +#include + #include "ui/mod_selection.hpp" #include "ui/ui_prefs.hpp" class QQmlEngine; @@ -11,42 +12,47 @@ namespace px::modrim { class ModInfoBanner; class StartupImpactDialog; class ModInfoPanel : public QWidget, public ModInfoSink { - Q_OBJECT -public: - ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, QWidget* parent = nullptr); - ~ModInfoPanel() override; - void showMod(const ListedMod& mod, const QList& issues) override; - void setIssues(const QList& issues); - void clear() override; - QCoro::Task setTimeAnalytics(const std::optional& pid, const QStringList& activePids) override; -signals: - void modRequested(const QString& uuid); -private slots: - void openFolder(); - void openUrl(); - void copyText(const QString& text); - void descToggled(bool expanded); - void updateMod(); - void openStartupDetails(); -private: - void setQml(const char* name, const QVariant& value); - void syncUpdating(); - QCoro::Task loadPreview(std::optional path, QString title, quint64 token); - QCoro::Task performUpdate(QString id); - QCoro::Task showStartupDetails(); - CoreContext& m_ctx; - QQmlEngine* m_engine; - UiPrefs& m_prefs; - ListedModPtr m_mod; - ModInfoBanner* m_banner; - QLabel* m_placeholder; - QQuickWidget* m_qml; - QString m_description; - quint64 m_previewToken = 0; - quint64 m_startupToken = 0; - std::optional m_startupPid; - QStringList m_startupActivePids; - QPointer m_startupDialog; - Event::Id m_busyConnection; + Q_OBJECT + public: + ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, + QWidget* parent = nullptr); + ~ModInfoPanel() override; + void showMod(const ListedMod& mod, + const QList& issues) override; + void setIssues(const QList& issues); + void clear() override; + QCoro::Task setTimeAnalytics(const std::optional& pid, + const QStringList& activePids) override; + signals: + void modRequested(const QString& uuid); + private slots: + void openFolder(); + void openUrl(); + void copyText(const QString& text); + void descToggled(bool expanded); + void updateMod(); + void openStartupDetails(); + + private: + void setQml(const char* name, const QVariant& value); + void syncUpdating(); + QCoro::Task loadPreview(std::optional path, QString title, + quint64 token); + QCoro::Task performUpdate(QString id); + QCoro::Task showStartupDetails(); + CoreContext& m_ctx; + QQmlEngine* m_engine; + UiPrefs& m_prefs; + ListedModPtr m_mod; + ModInfoBanner* m_banner; + QLabel* m_placeholder; + QQuickWidget* m_qml; + QString m_description; + quint64 m_previewToken = 0; + quint64 m_startupToken = 0; + std::optional m_startupPid; + QStringList m_startupActivePids; + QPointer m_startupDialog; + Event::Id m_busyConnection; }; -} +} // namespace px::modrim diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp index 4697899..7559d81 100644 --- a/src/ui/panels/mod_list_panel.cpp +++ b/src/ui/panels/mod_list_panel.cpp @@ -1,11 +1,5 @@ #include "ui/panels/mod_list_panel.hpp" -#include "core/context.hpp" -#include "core/mod_service.hpp" -#include "core/downloads/manager.hpp" -#include "ui/components/icons.hpp" -#include "ui/components/mod_activation.hpp" -#include "ui/components/mod_updates.hpp" -#include "ui/theme/palette.hpp" + #include #include #include @@ -18,134 +12,226 @@ #include #include #include + +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "ui/components/icons.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/theme/palette.hpp" namespace px::modrim { -ModListPanel::ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) - : QWidget(parent), m_ctx(ctx), m_model(new ModListModel({}, &ctx.diagnosticsService(), this)), - m_proxy(new ModListProxyModel(m_model, this)), m_qml(new QQuickWidget(engine, this)), - m_compactMode(ctx.config().compact_mod_list), m_highlightTimer(new QTimer(this)) { - auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); - auto* search = new QWidget(this); search->setObjectName(QStringLiteral("searchBox")); search->setFixedHeight(56); - auto* searchLayout = new QHBoxLayout(search); searchLayout->setContentsMargins(16,8,16,8); - searchInput = new QLineEdit(search); - searchInput->setObjectName(QStringLiteral("searchInput")); - searchInput->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); - searchInput->setClearButtonEnabled(true); - connect(searchInput, &QLineEdit::textChanged, this, &ModListPanel::setSearchFilter); - if (const auto searchIcon = icon(u"search",16,paletteValue(u"TEXT_DIM"))) searchInput->addAction(*searchIcon,QLineEdit::LeadingPosition); - searchLayout->addWidget(searchInput); layout->addWidget(search); - connect(m_model, &ModListModel::activeModsChanged, this, &ModListPanel::activeModsChanged); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setAttribute(Qt::WA_AlwaysStackOnTop,false); - m_qml->setClearColor(paletteColor(u"ELEVATE_1")); m_qml->setAcceptDrops(true); - m_qml->rootContext()->setContextProperty(QStringLiteral("modListPanel"),this); - m_qml->rootContext()->setContextProperty(QStringLiteral("modListModel"),m_proxy); - m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),false); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); - layout->addWidget(m_qml); - m_qml->setFocusPolicy(Qt::StrongFocus); m_qml->installEventFilter(this); - m_highlightTimer->setSingleShot(true); m_highlightTimer->setInterval(1200); - connect(m_highlightTimer, &QTimer::timeout, this, [this] { setHighlighted({}); }); - m_activeConnection = m_ctx.activeStateChanged().connect([this](const auto& active) { coreStateChanged(active); }); - m_configConnection = m_ctx.configChanged().connect([this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); - m_modsConnection = m_ctx.modService().modsChanged.connect([this](const auto&) { refresh(); }); +ModListPanel::ModListPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent) + : QWidget(parent), + m_ctx(ctx), + m_model(new ModListModel({}, &ctx.diagnosticsService(), this)), + m_proxy(new ModListProxyModel(m_model, this)), + m_qml(new QQuickWidget(engine, this)), + m_compactMode(ctx.config().compact_mod_list), + m_highlightTimer(new QTimer(this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + auto* search = new QWidget(this); + search->setObjectName(QStringLiteral("searchBox")); + search->setFixedHeight(56); + auto* searchLayout = new QHBoxLayout(search); + searchLayout->setContentsMargins(16, 8, 16, 8); + searchInput = new QLineEdit(search); + searchInput->setObjectName(QStringLiteral("searchInput")); + searchInput->setPlaceholderText( + QStringLiteral("Search mods, package ID, author…")); + searchInput->setClearButtonEnabled(true); + connect(searchInput, &QLineEdit::textChanged, this, + &ModListPanel::setSearchFilter); + if (const auto searchIcon = icon(u"search", 16, paletteValue(u"TEXT_DIM"))) + searchInput->addAction(*searchIcon, QLineEdit::LeadingPosition); + searchLayout->addWidget(searchInput); + layout->addWidget(search); + connect(m_model, &ModListModel::activeModsChanged, this, + &ModListPanel::activeModsChanged); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->setAcceptDrops(true); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListModel"), + m_proxy); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"), + false); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); + layout->addWidget(m_qml); + m_qml->setFocusPolicy(Qt::StrongFocus); + m_qml->installEventFilter(this); + m_highlightTimer->setSingleShot(true); + m_highlightTimer->setInterval(1200); + connect(m_highlightTimer, &QTimer::timeout, this, + [this] { setHighlighted({}); }); + m_activeConnection = m_ctx.activeStateChanged().connect( + [this](const auto& active) { coreStateChanged(active); }); + m_configConnection = m_ctx.configChanged().connect( + [this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); + m_modsConnection = m_ctx.modService().modsChanged.connect( + [this](const auto&) { refresh(); }); } ModListPanel::~ModListPanel() { - m_ctx.activeStateChanged().disconnect(m_activeConnection); - m_ctx.configChanged().disconnect(m_configConnection); - m_ctx.modService().modsChanged.disconnect(m_modsConnection); - // QML bindings refer to the models and panel during destruction. - delete m_qml; + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.configChanged().disconnect(m_configConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + // QML bindings refer to the models and panel during destruction. + delete m_qml; +} +void ModListPanel::loadMods(const QHash& mods, + const QStringList& active) { + m_model->loadMods(mods, active); } -void ModListPanel::loadMods(const QHash& mods, const QStringList& active) { m_model->loadMods(mods,active); } QStringList ModListPanel::activeUuids() const { return m_model->activeUuids(); } -void ModListPanel::setSidebarFilter(std::optional> uuids) { m_proxy->setSidebarFilter(std::move(uuids)); } -void ModListPanel::setSearchFilter(const QString& text) { m_proxy->setSearchFilter(text); } +void ModListPanel::setSidebarFilter(std::optional> uuids) { + m_proxy->setSidebarFilter(std::move(uuids)); +} +void ModListPanel::setSearchFilter(const QString& text) { + m_proxy->setSearchFilter(text); +} void ModListPanel::setCompactMode(bool enabled) { - if (m_compactMode == enabled) return; - m_compactMode = enabled; emit compactModeChanged(enabled); + if (m_compactMode == enabled) return; + m_compactMode = enabled; + emit compactModeChanged(enabled); +} +bool ModListPanel::isHighlighted(const QString& uuid) const { + return m_highlighted.contains(uuid); } -bool ModListPanel::isHighlighted(const QString& uuid) const { return m_highlighted.contains(uuid); } void ModListPanel::modSelected(const QString& uuid) { emit modPicked(uuid); } -int ModListPanel::proxyToSourceRow(int row) const { return m_proxy->mapToSource(m_proxy->index(row)).row(); } -QString ModListPanel::uuidAt(int row) const { const auto* item = m_model->getItem(proxyToSourceRow(row)); return item ? item->uuid : QString{}; } -int ModListPanel::rowForUuid(const QString& uuid) const { return m_proxy->proxyRowForUuid(uuid); } -bool ModListPanel::isChecked(int row) const { const auto* item = m_model->getItem(proxyToSourceRow(row)); return item && item->checked; } +int ModListPanel::proxyToSourceRow(int row) const { + return m_proxy->mapToSource(m_proxy->index(row)).row(); +} +QString ModListPanel::uuidAt(int row) const { + const auto* item = m_model->getItem(proxyToSourceRow(row)); + return item ? item->uuid : QString{}; +} +int ModListPanel::rowForUuid(const QString& uuid) const { + return m_proxy->proxyRowForUuid(uuid); +} +bool ModListPanel::isChecked(int row) const { + const auto* item = m_model->getItem(proxyToSourceRow(row)); + return item && item->checked; +} bool ModListPanel::selectUuid(const QString& uuid) { - const int row = rowForUuid(uuid); - auto* root = m_qml->rootObject(); - if (row < 0 || !root) return false; - return QMetaObject::invokeMethod(root,"revealRow",Qt::DirectConnection,Q_ARG(QVariant,QVariant(row)),Q_ARG(QVariant,QVariant(uuid))); + const int row = rowForUuid(uuid); + auto* root = m_qml->rootObject(); + if (row < 0 || !root) return false; + return QMetaObject::invokeMethod(root, "revealRow", Qt::DirectConnection, + Q_ARG(QVariant, QVariant(row)), + Q_ARG(QVariant, QVariant(uuid))); } void ModListPanel::clearSelection() { - auto* root = m_qml->rootObject(); - auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; - if (view) { - view->setProperty("currentIndex",-1); view->setProperty("anchorIndex",-1); - view->setProperty("selectedIndices",QVariantList{}); view->setProperty("selectedUuids",QVariantList{}); - view->setProperty("currentUuid",QString{}); view->setProperty("anchorUuid",QString{}); - } - emit selectionUpdated({}); emit modPicked({}); + auto* root = m_qml->rootObject(); + auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (view) { + view->setProperty("currentIndex", -1); + view->setProperty("anchorIndex", -1); + view->setProperty("selectedIndices", QVariantList{}); + view->setProperty("selectedUuids", QVariantList{}); + view->setProperty("currentUuid", QString{}); + view->setProperty("anchorUuid", QString{}); + } + emit selectionUpdated({}); + emit modPicked({}); } QCoro::Task ModListPanel::refresh() { - m_model->updateProviderColors(m_ctx.modService().providerColors()); - m_model->loadMods(m_ctx.allMods(),m_ctx.activeUuids()); - QPointer self(this); - const auto impacts = co_await m_ctx.modService().startupImpact().getAllAverages(); - if (self && impacts) self->m_model->setStartupImpact(*impacts); + m_model->updateProviderColors(m_ctx.modService().providerColors()); + m_model->loadMods(m_ctx.allMods(), m_ctx.activeUuids()); + QPointer self(this); + const auto impacts = + co_await m_ctx.modService().startupImpact().getAllAverages(); + if (self && impacts) self->m_model->setStartupImpact(*impacts); } QCoro::QmlTask ModListPanel::toggleChecked(const QVariantList& rows) { - QList selected; selected.reserve(rows.size()); - for (const auto& row : rows) selected.append(row.toInt()); - return toggleRows(std::move(selected)); + QList selected; + selected.reserve(rows.size()); + for (const auto& row : rows) selected.append(row.toInt()); + return toggleRows(std::move(selected)); } QCoro::Task ModListPanel::toggleRows(QList rows) { - QStringList selected; - for (const int row : rows) { const auto uuid = uuidAt(row); if (!uuid.isEmpty()) selected.append(uuid); } - if (selected.isEmpty()) co_return; - const QSet previous(m_ctx.activeUuids().begin(),m_ctx.activeUuids().end()); - QPointer self(this); - co_await toggleMods(m_ctx,this,selected); - if (!self) co_return; - QStringList newlyEnabled; - for (const auto& uuid : selected) if (!previous.contains(uuid) && m_ctx.activeUuids().contains(uuid)) newlyEnabled.append(uuid); - if (!newlyEnabled.isEmpty()) highlightRows(newlyEnabled); + QStringList selected; + for (const int row : rows) { + const auto uuid = uuidAt(row); + if (!uuid.isEmpty()) selected.append(uuid); + } + if (selected.isEmpty()) co_return; + const QSet previous(m_ctx.activeUuids().begin(), + m_ctx.activeUuids().end()); + QPointer self(this); + co_await toggleMods(m_ctx, this, selected); + if (!self) co_return; + QStringList newlyEnabled; + for (const auto& uuid : selected) + if (!previous.contains(uuid) && m_ctx.activeUuids().contains(uuid)) + newlyEnabled.append(uuid); + if (!newlyEnabled.isEmpty()) highlightRows(newlyEnabled); } void ModListPanel::setHighlighted(const QStringList& uuids) { - m_highlighted = QSet(uuids.begin(),uuids.end()); ++m_highlightRevision; emit highlightedChanged(); + m_highlighted = QSet(uuids.begin(), uuids.end()); + ++m_highlightRevision; + emit highlightedChanged(); } void ModListPanel::highlightRows(const QStringList& uuids) { - int last = -1; - for (const auto& uuid : uuids) if (const int row = rowForUuid(uuid); row >= 0) last = row; - auto* root = m_qml->rootObject(); - if (last < 0 || !root) return; - if (auto* view = root->findChild(QStringLiteral("listView"))) - QMetaObject::invokeMethod(view,"positionViewAtIndex",Qt::DirectConnection,Q_ARG(int,last),Q_ARG(int,4)); - setHighlighted(uuids); m_highlightTimer->start(); + int last = -1; + for (const auto& uuid : uuids) + if (const int row = rowForUuid(uuid); row >= 0) last = row; + auto* root = m_qml->rootObject(); + if (last < 0 || !root) return; + if (auto* view = root->findChild(QStringLiteral("listView"))) + QMetaObject::invokeMethod(view, "positionViewAtIndex", Qt::DirectConnection, + Q_ARG(int, last), Q_ARG(int, 4)); + setHighlighted(uuids); + m_highlightTimer->start(); } void ModListPanel::showContextMenu(const QVariantList& values) { - if (!m_ctx.downloads().available()) return; - QStringList uuids; uuids.reserve(values.size()); - for (const auto& value : values) uuids.append(value.toString()); - auto* menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); menu->setToolTipsVisible(true); - addUpdateAction(menu,m_ctx,uuids,[this,uuids] { updateMods(m_ctx,uuids,this); }); - menu->popup(QCursor::pos()); + if (!m_ctx.downloads().available()) return; + QStringList uuids; + uuids.reserve(values.size()); + for (const auto& value : values) uuids.append(value.toString()); + auto* menu = new QMenu(this); + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->setToolTipsVisible(true); + addUpdateAction(menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + menu->popup(QCursor::pos()); } void ModListPanel::coreStateChanged(const QStringList& uuids) { - const auto currentList = m_model->activeUuids(); - const QSet current(currentList.begin(),currentList.end()), next(uuids.begin(),uuids.end()); - m_model->setCheckable(next - current,true); m_model->setCheckable(current - next,false); m_model->commitOrder(uuids); + const auto currentList = m_model->activeUuids(); + const QSet current(currentList.begin(), currentList.end()), + next(uuids.begin(), uuids.end()); + m_model->setCheckable(next - current, true); + m_model->setCheckable(current - next, false); + m_model->commitOrder(uuids); +} +void ModListPanel::commitOrder(const QStringList& uuids) { + m_model->commitOrder(uuids); + m_ctx.setActive(m_model->activeUuids()); + emit orderChanged(); +} +void ModListPanel::moveRow(int source, int target) { + m_proxy->moveRow(source, target, true); +} +void ModListPanel::dragEnded() { + m_ctx.setActive(m_model->activeUuids()); + emit orderChanged(); } -void ModListPanel::commitOrder(const QStringList& uuids) { m_model->commitOrder(uuids); m_ctx.setActive(m_model->activeUuids()); emit orderChanged(); } -void ModListPanel::moveRow(int source, int target) { m_proxy->moveRow(source,target,true); } -void ModListPanel::dragEnded() { m_ctx.setActive(m_model->activeUuids()); emit orderChanged(); } void ModListPanel::selectionChanged(const QVariantList& values) { - QStringList uuids; - for (const auto& value : values) if (!value.toString().isEmpty()) uuids.append(value.toString()); - emit selectionUpdated(uuids); + QStringList uuids; + for (const auto& value : values) + if (!value.toString().isEmpty()) uuids.append(value.toString()); + emit selectionUpdated(uuids); } bool ModListPanel::eventFilter(QObject* object, QEvent* event) { - if (object == m_qml && (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) - m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"),event->type() == QEvent::FocusIn); - return QWidget::eventFilter(object,event); -} + if (object == m_qml && + (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"), + event->type() == QEvent::FocusIn); + return QWidget::eventFilter(object, event); } +} // namespace px::modrim diff --git a/src/ui/panels/mod_list_panel.hpp b/src/ui/panels/mod_list_panel.hpp index 28c6c73..0c187fd 100644 --- a/src/ui/panels/mod_list_panel.hpp +++ b/src/ui/panels/mod_list_panel.hpp @@ -1,71 +1,80 @@ #pragma once -#include -#include #include #include +#include #include + +#include + #include "ui/models/mod_list_proxy_model.hpp" class QQmlEngine; class QQuickWidget; class QLineEdit; -namespace px::modrim { class CoreContext; } +namespace px::modrim { +class CoreContext; +} namespace px::modrim { class ModListPanel : public QWidget { - Q_OBJECT - Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY compactModeChanged) - Q_PROPERTY(int highlightRevision READ highlightRevision NOTIFY highlightedChanged) -public: - ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); - ~ModListPanel() override; - void loadMods(const QHash& mods, const QStringList& active); - QStringList activeUuids() const; - ModListModel* model() const { return m_model; } - ModListProxyModel* proxyModel() const { return m_proxy; } - QQuickWidget* qmlWidget() const { return m_qml; } - void setSidebarFilter(std::optional> uuids); - void setSearchFilter(const QString& text); - bool selectUuid(const QString& uuid); - bool compactMode() const { return m_compactMode; } - int highlightRevision() const { return m_highlightRevision; } - void highlightRows(const QStringList& uuids); - QCoro::Task toggleRows(QList rows); - QCoro::Task refresh(); - QLineEdit* searchInput; - Q_INVOKABLE void setCompactMode(bool enabled); - Q_INVOKABLE bool isHighlighted(const QString& uuid) const; - Q_INVOKABLE void modSelected(const QString& uuid); - Q_INVOKABLE void clearSelection(); - Q_INVOKABLE QString uuidAt(int row) const; - Q_INVOKABLE int rowForUuid(const QString& uuid) const; - Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggleRows({row}); } - Q_INVOKABLE QCoro::QmlTask toggleChecked(const QVariantList& rows); - Q_INVOKABLE void showContextMenu(const QVariantList& uuids); - Q_INVOKABLE bool isChecked(int row) const; - Q_INVOKABLE void commitOrder(const QStringList& uuids); - Q_INVOKABLE void moveRow(int source, int target); - Q_INVOKABLE void dragEnded(); - Q_INVOKABLE void selectionChanged(const QVariantList& uuids); -signals: - void modPicked(const QString& uuid); - void activeModsChanged(); - void orderChanged(); - void selectionUpdated(const QStringList& uuids); - void compactModeChanged(bool enabled); - void highlightedChanged(); -protected: - bool eventFilter(QObject* object, QEvent* event) override; -private: - int proxyToSourceRow(int row) const; - void coreStateChanged(const QStringList& uuids); - void setHighlighted(const QStringList& uuids); - CoreContext& m_ctx; - ModListModel* m_model; - ModListProxyModel* m_proxy; - QQuickWidget* m_qml; - bool m_compactMode; - QSet m_highlighted; - int m_highlightRevision = 0; - QTimer* m_highlightTimer; - std::uint64_t m_activeConnection, m_configConnection, m_modsConnection; + Q_OBJECT + Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY + compactModeChanged) + Q_PROPERTY( + int highlightRevision READ highlightRevision NOTIFY highlightedChanged) + public: + ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~ModListPanel() override; + void loadMods(const QHash& mods, + const QStringList& active); + QStringList activeUuids() const; + ModListModel* model() const { return m_model; } + ModListProxyModel* proxyModel() const { return m_proxy; } + QQuickWidget* qmlWidget() const { return m_qml; } + void setSidebarFilter(std::optional> uuids); + void setSearchFilter(const QString& text); + bool selectUuid(const QString& uuid); + bool compactMode() const { return m_compactMode; } + int highlightRevision() const { return m_highlightRevision; } + void highlightRows(const QStringList& uuids); + QCoro::Task toggleRows(QList rows); + QCoro::Task refresh(); + QLineEdit* searchInput; + Q_INVOKABLE void setCompactMode(bool enabled); + Q_INVOKABLE bool isHighlighted(const QString& uuid) const; + Q_INVOKABLE void modSelected(const QString& uuid); + Q_INVOKABLE void clearSelection(); + Q_INVOKABLE QString uuidAt(int row) const; + Q_INVOKABLE int rowForUuid(const QString& uuid) const; + Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggleRows({row}); } + Q_INVOKABLE QCoro::QmlTask toggleChecked(const QVariantList& rows); + Q_INVOKABLE void showContextMenu(const QVariantList& uuids); + Q_INVOKABLE bool isChecked(int row) const; + Q_INVOKABLE void commitOrder(const QStringList& uuids); + Q_INVOKABLE void moveRow(int source, int target); + Q_INVOKABLE void dragEnded(); + Q_INVOKABLE void selectionChanged(const QVariantList& uuids); + signals: + void modPicked(const QString& uuid); + void activeModsChanged(); + void orderChanged(); + void selectionUpdated(const QStringList& uuids); + void compactModeChanged(bool enabled); + void highlightedChanged(); + + protected: + bool eventFilter(QObject* object, QEvent* event) override; + + private: + int proxyToSourceRow(int row) const; + void coreStateChanged(const QStringList& uuids); + void setHighlighted(const QStringList& uuids); + CoreContext& m_ctx; + ModListModel* m_model; + ModListProxyModel* m_proxy; + QQuickWidget* m_qml; + bool m_compactMode; + QSet m_highlighted; + int m_highlightRevision = 0; + QTimer* m_highlightTimer; + std::uint64_t m_activeConnection, m_configConnection, m_modsConnection; }; -} +} // namespace px::modrim diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp index 499796c..216873f 100644 --- a/src/ui/panels/restore_snapshot_dialog.cpp +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -1,7 +1,5 @@ #include "ui/panels/restore_snapshot_dialog.hpp" -#include - #include #include #include @@ -14,6 +12,7 @@ #include #include #include +#include #include "core/mods_config.hpp" @@ -24,174 +23,198 @@ namespace { constexpr QStringView kSnapshotPrefix = u"ModsConfig_"; QString snapshotStem(const QString& path) { - const QString name = QFileInfo(path).fileName(); - const qsizetype dot = name.lastIndexOf(u'.'); - return dot > 0 ? name.left(dot) : name; + const QString name = QFileInfo(path).fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + return dot > 0 ? name.left(dot) : name; } bool allAsciiDigits(QStringView text) { - return std::all_of(text.begin(), text.end(), [](QChar c) { return c >= u'0' && c <= u'9'; }); + return std::all_of(text.begin(), text.end(), + [](QChar c) { return c >= u'0' && c <= u'9'; }); } -// The UTC time encoded in a snapshot name such as ModsConfig_20260922_120000[_suffix].xml. -// Non-digit or out-of-range fields are rejected like Python's strptime rejected them. +// The UTC time encoded in a snapshot name such as +// ModsConfig_20260922_120000[_suffix].xml. Non-digit or out-of-range fields are +// rejected like Python's strptime rejected them. std::optional timestampFromStem(const QString& stem) { - if (!stem.startsWith(kSnapshotPrefix)) { - return std::nullopt; - } - const QStringList parts = stem.mid(kSnapshotPrefix.size()).split(u'_'); - if (parts.size() < 2 || parts[0].size() != 8 || parts[1].size() < 6) { - return std::nullopt; - } - const QString date = parts[0]; - const QString time = parts[1].left(6); - if (!allAsciiDigits(date) || !allAsciiDigits(time)) { - return std::nullopt; - } - const QDate day(date.left(4).toInt(), date.mid(4, 2).toInt(), date.mid(6, 2).toInt()); - const QTime timeOfDay(time.left(2).toInt(), time.mid(2, 2).toInt(), time.mid(4, 2).toInt()); - if (!day.isValid() || !timeOfDay.isValid()) { - return std::nullopt; - } - return QDateTime(day, timeOfDay, QTimeZone::utc()); + if (!stem.startsWith(kSnapshotPrefix)) { + return std::nullopt; + } + const QStringList parts = stem.mid(kSnapshotPrefix.size()).split(u'_'); + if (parts.size() < 2 || parts[0].size() != 8 || parts[1].size() < 6) { + return std::nullopt; + } + const QString date = parts[0]; + const QString time = parts[1].left(6); + if (!allAsciiDigits(date) || !allAsciiDigits(time)) { + return std::nullopt; + } + const QDate day(date.left(4).toInt(), date.mid(4, 2).toInt(), + date.mid(6, 2).toInt()); + const QTime timeOfDay(time.left(2).toInt(), time.mid(2, 2).toInt(), + time.mid(4, 2).toInt()); + if (!day.isValid() || !timeOfDay.isValid()) { + return std::nullopt; + } + return QDateTime(day, timeOfDay, QTimeZone::utc()); } QString formatUtc(const QDateTime& when) { - return when.toUTC().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) + QStringLiteral(" UTC"); + return when.toUTC().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) + + QStringLiteral(" UTC"); } -// The name's timestamp when it has one, otherwise the file's modification time, otherwise the stem. +// The name's timestamp when it has one, otherwise the file's modification time, +// otherwise the stem. QString snapshotTimestamp(const QString& path) { - const QString stem = snapshotStem(path); - if (const auto named = timestampFromStem(stem)) { - return formatUtc(*named); - } - const QDateTime modified = QFileInfo(path).lastModified(); - return modified.isValid() ? formatUtc(modified) : stem; + const QString stem = snapshotStem(path); + if (const auto named = timestampFromStem(stem)) { + return formatUtc(*named); + } + const QDateTime modified = QFileInfo(path).lastModified(); + return modified.isValid() ? formatUtc(modified) : stem; } } // namespace -RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, QWidget* parent) : QDialog(parent) { - setObjectName(QStringLiteral("restoreSnapshotDialog")); - setWindowTitle(QStringLiteral("Restore Mod List")); - resize(560, 420); - - m_snapshots.reserve(static_cast(snapshots.size())); - for (const QString& path : snapshots) { - m_snapshots.push_back(Snapshot{path, parseModsConfig(path)}); - } - - auto* layout = new QVBoxLayout(this); - - auto* instruction = new QLabel( - QStringLiteral("Choose a saved mod list. Your current list will be backed up before it is replaced."), this); - instruction->setWordWrap(true); - layout->addWidget(instruction); - - m_list = new QListWidget(this); - populateList(); - layout->addWidget(m_list); - - m_details = new QLabel(this); - m_details->setWordWrap(true); - layout->addWidget(m_details); - - auto* buttons = new QHBoxLayout(); - buttons->addStretch(); - - m_restore = new QPushButton(QStringLiteral("Restore"), this); - m_restore->setObjectName(QStringLiteral("primaryAction")); - m_restore->setEnabled(false); - connect(m_restore, &QPushButton::clicked, this, &RestoreSnapshotDialog::onRestoreClicked); - - auto* cancel = new QPushButton(QStringLiteral("Cancel"), this); - cancel->setObjectName(QStringLiteral("appBtn")); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - - buttons->addWidget(cancel); - buttons->addWidget(m_restore); - layout->addLayout(buttons); - - connect(m_list, &QListWidget::currentItemChanged, this, &RestoreSnapshotDialog::onCurrentItemChanged); - connect(m_list, &QListWidget::itemDoubleClicked, this, &RestoreSnapshotDialog::onRestoreClicked); - - // Connected before selecting, so the initial selection updates the details and the button. - if (m_list->count() > 0) { - m_list->setCurrentRow(0); - } +RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, + QWidget* parent) + : QDialog(parent) { + setObjectName(QStringLiteral("restoreSnapshotDialog")); + setWindowTitle(QStringLiteral("Restore Mod List")); + resize(560, 420); + + m_snapshots.reserve(static_cast(snapshots.size())); + for (const QString& path : snapshots) { + m_snapshots.push_back(Snapshot{path, parseModsConfig(path)}); + } + + auto* layout = new QVBoxLayout(this); + + auto* instruction = + new QLabel(QStringLiteral("Choose a saved mod list. Your current list " + "will be backed up before it is replaced."), + this); + instruction->setWordWrap(true); + layout->addWidget(instruction); + + m_list = new QListWidget(this); + populateList(); + layout->addWidget(m_list); + + m_details = new QLabel(this); + m_details->setWordWrap(true); + layout->addWidget(m_details); + + auto* buttons = new QHBoxLayout(); + buttons->addStretch(); + + m_restore = new QPushButton(QStringLiteral("Restore"), this); + m_restore->setObjectName(QStringLiteral("primaryAction")); + m_restore->setEnabled(false); + connect(m_restore, &QPushButton::clicked, this, + &RestoreSnapshotDialog::onRestoreClicked); + + auto* cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + + buttons->addWidget(cancel); + buttons->addWidget(m_restore); + layout->addLayout(buttons); + + connect(m_list, &QListWidget::currentItemChanged, this, + &RestoreSnapshotDialog::onCurrentItemChanged); + connect(m_list, &QListWidget::itemDoubleClicked, this, + &RestoreSnapshotDialog::onRestoreClicked); + + // Connected before selecting, so the initial selection updates the details + // and the button. + if (m_list->count() > 0) { + m_list->setCurrentRow(0); + } } std::optional RestoreSnapshotDialog::selectedSnapshot() const { - return m_selected; + return m_selected; } void RestoreSnapshotDialog::populateList() { - for (std::size_t i = 0; i < m_snapshots.size(); ++i) { - const Snapshot& snapshot = m_snapshots[i]; - auto* item = new QListWidgetItem(m_list); - const QString timestamp = snapshotTimestamp(snapshot.path); - - if (snapshot.parsed) { - const qsizetype active = snapshot.parsed->activeMods.size(); - const QString noun = active == 1 ? QStringLiteral("mod") : QStringLiteral("mods"); - item->setText(QStringLiteral(u"%1 \u2014 %2 active %3, RimWorld %4") - .arg(timestamp) - .arg(QString::number(active)) - .arg(noun) - .arg(snapshot.parsed->version)); - } else { - item->setText(QStringLiteral(u"%1 \u2014 Cannot be read").arg(timestamp)); - } - item->setData(Qt::UserRole, static_cast(i)); - item->setToolTip(QFileInfo(snapshot.path).fileName()); - } -} + for (std::size_t i = 0; i < m_snapshots.size(); ++i) { + const Snapshot& snapshot = m_snapshots[i]; + auto* item = new QListWidgetItem(m_list); + const QString timestamp = snapshotTimestamp(snapshot.path); -void RestoreSnapshotDialog::onCurrentItemChanged(QListWidgetItem* current) { - if (current == nullptr) { - m_selected.reset(); - m_restore->setEnabled(false); - m_details->clear(); - return; - } - - const Snapshot& snapshot = m_snapshots[static_cast(current->data(Qt::UserRole).toInt())]; - const QString name = QFileInfo(snapshot.path).fileName(); if (snapshot.parsed) { - m_selected = snapshot.path; - m_restore->setEnabled(true); - m_details->setText(QStringLiteral("File: %1
    Active mods: %2 | Game version: %3") - .arg(name) - .arg(QString::number(snapshot.parsed->activeMods.size())) - .arg(snapshot.parsed->version)); + const qsizetype active = snapshot.parsed->activeMods.size(); + const QString noun = + active == 1 ? QStringLiteral("mod") : QStringLiteral("mods"); + item->setText(QStringLiteral(u"%1 \u2014 %2 active %3, RimWorld %4") + .arg(timestamp) + .arg(QString::number(active)) + .arg(noun) + .arg(snapshot.parsed->version)); } else { - m_selected.reset(); - m_restore->setEnabled(false); - m_details->setText(QStringLiteral("This saved mod list cannot be read. %1 will not be restored.") - .arg(name)); + item->setText(QStringLiteral(u"%1 \u2014 Cannot be read").arg(timestamp)); } + item->setData(Qt::UserRole, static_cast(i)); + item->setToolTip(QFileInfo(snapshot.path).fileName()); + } +} + +void RestoreSnapshotDialog::onCurrentItemChanged(QListWidgetItem* current) { + if (current == nullptr) { + m_selected.reset(); + m_restore->setEnabled(false); + m_details->clear(); + return; + } + + const Snapshot& snapshot = m_snapshots[static_cast( + current->data(Qt::UserRole).toInt())]; + const QString name = QFileInfo(snapshot.path).fileName(); + if (snapshot.parsed) { + m_selected = snapshot.path; + m_restore->setEnabled(true); + m_details->setText( + QStringLiteral("File: %1
    Active mods: %2 | Game " + "version: %3") + .arg(name) + .arg(QString::number(snapshot.parsed->activeMods.size())) + .arg(snapshot.parsed->version)); + } else { + m_selected.reset(); + m_restore->setEnabled(false); + m_details->setText(QStringLiteral("This saved mod list cannot be " + "read. %1 will not be restored.") + .arg(name)); + } } void RestoreSnapshotDialog::onRestoreClicked() { - if (m_selected) { - accept(); - } + if (m_selected) { + accept(); + } } -ConfirmRestoreDialog::ConfirmRestoreDialog(const QString& snapshotName, QWidget* parent) : QMessageBox(parent) { - setIcon(QMessageBox::Question); - // The QMessageBox setter is a no-op on macOS; the QWidget setter applies there too. - QDialog::setWindowTitle(QStringLiteral("Restore Mod List")); - setText(QStringLiteral("Restore the mod list saved in %1?").arg(snapshotName)); - setInformativeText(QStringLiteral( - "This replaces your current active mod list. PxModRim will save a backup of the current list first.")); - setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); - setDefaultButton(QMessageBox::Cancel); - if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { - yes->setText(QStringLiteral("Restore")); - yes->setObjectName(QStringLiteral("primaryAction")); - } +ConfirmRestoreDialog::ConfirmRestoreDialog(const QString& snapshotName, + QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Question); + // The QMessageBox setter is a no-op on macOS; the QWidget setter applies + // there too. + QDialog::setWindowTitle(QStringLiteral("Restore Mod List")); + setText( + QStringLiteral("Restore the mod list saved in %1?").arg(snapshotName)); + setInformativeText( + QStringLiteral("This replaces your current active mod list. PxModRim " + "will save a backup of the current list first.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Restore")); + yes->setObjectName(QStringLiteral("primaryAction")); + } } } // namespace px::modrim diff --git a/src/ui/panels/restore_snapshot_dialog.hpp b/src/ui/panels/restore_snapshot_dialog.hpp index abcf7fe..ca5713f 100644 --- a/src/ui/panels/restore_snapshot_dialog.hpp +++ b/src/ui/panels/restore_snapshot_dialog.hpp @@ -1,12 +1,11 @@ #pragma once -#include -#include - #include #include #include #include +#include +#include #include "core/models/metadata/structures.hpp" @@ -17,41 +16,44 @@ class QPushButton; namespace px::modrim { -// Lists saved mod lists (ModsConfig snapshots, in the order given, newest first) and -// lets the user pick one to restore. Snapshots that cannot be parsed are listed but -// cannot be selected. +// Lists saved mod lists (ModsConfig snapshots, in the order given, newest +// first) and lets the user pick one to restore. Snapshots that cannot be parsed +// are listed but cannot be selected. class RestoreSnapshotDialog : public QDialog { - Q_OBJECT + Q_OBJECT -public: - explicit RestoreSnapshotDialog(const QStringList& snapshots, QWidget* parent = nullptr); + public: + explicit RestoreSnapshotDialog(const QStringList& snapshots, + QWidget* parent = nullptr); - // The selected snapshot path, or nullopt while the selection is unreadable or absent. - std::optional selectedSnapshot() const; + // The selected snapshot path, or nullopt while the selection is unreadable or + // absent. + std::optional selectedSnapshot() const; -private slots: - void onCurrentItemChanged(QListWidgetItem* current); - void onRestoreClicked(); + private slots: + void onCurrentItemChanged(QListWidgetItem* current); + void onRestoreClicked(); -private: - struct Snapshot { - QString path; - std::optional parsed; - }; + private: + struct Snapshot { + QString path; + std::optional parsed; + }; - void populateList(); + void populateList(); - std::vector m_snapshots; - std::optional m_selected; - QListWidget* m_list = nullptr; - QLabel* m_details = nullptr; - QPushButton* m_restore = nullptr; + std::vector m_snapshots; + std::optional m_selected; + QListWidget* m_list = nullptr; + QLabel* m_details = nullptr; + QPushButton* m_restore = nullptr; }; // Asks before the current mod list is replaced by a snapshot. class ConfirmRestoreDialog : public QMessageBox { -public: - explicit ConfirmRestoreDialog(const QString& snapshotName, QWidget* parent = nullptr); + public: + explicit ConfirmRestoreDialog(const QString& snapshotName, + QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp index ad92dc4..b668b88 100644 --- a/src/ui/panels/settings_panel.cpp +++ b/src/ui/panels/settings_panel.cpp @@ -1,4 +1,5 @@ #include "ui/panels/settings_panel.hpp" + #include #include #include @@ -9,6 +10,7 @@ #include #include #include + #include "core/mod_service.hpp" #include "core/sort/community_service.hpp" #include "ui/components/dialogs.hpp" @@ -17,143 +19,197 @@ namespace px::modrim { namespace { class FolderDialog : public QFileDialog { -public: - FolderDialog(QWidget* parent, const QString& title) : QFileDialog(parent, title) { - setFileMode(QFileDialog::Directory); - setOption(QFileDialog::ShowDirsOnly); - } + public: + FolderDialog(QWidget* parent, const QString& title) + : QFileDialog(parent, title) { + setFileMode(QFileDialog::Directory); + setOption(QFileDialog::ShowDirsOnly); + } }; -} -SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections) +} // namespace +SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections) : QObject(dialog), m_ctx(ctx), m_dialog(dialog) { - for (auto* section : sections) - m_sections.append(QVariantMap{{"title", section->title()}, {"source", section->source().toString()}, - {"section", QVariant::fromValue(static_cast(section))}}); - const auto& cfg = ctx.config(); - const auto text = [](const std::string& value) { return QString::fromStdString(value); }; - m_initial = {{"game", text(cfg.paths.game)}, {"local", text(cfg.paths.local)}, - {"workshop", text(cfg.paths.workshop)}, {"config", text(cfg.paths.config_folder)}, - {"compact", cfg.compact_mod_list}, {"launchArgs", text(cfg.launch_args)}, {"launchWrapper", text(cfg.launch_wrapper)}, - {"afterLaunch", static_cast(cfg.after_launch)}, {"confirmErrors", cfg.launch_confirm_errors}, - {"confirmUnsaved", cfg.launch_confirm_unsaved}, {"confirmRunning", cfg.launch_confirm_running}, - {"useAltIds", cfg.sort.use_alternative_package_ids}, {"checkMissing", cfg.sort.check_missing_dependencies}, - {"useCommunity", cfg.sort.use_community_rules}}; - const auto path = communityRulesFile(); - m_communityStatus = QFileInfo::exists(path) ? "Found: " + path : QStringLiteral("Not downloaded"); - m_cacheAvailable = !cfg.paths.config_folder.empty(); - m_cacheStatus = m_cacheAvailable ? QStringLiteral("Cache ready") : QStringLiteral("No config folder — not available"); + for (auto* section : sections) + m_sections.append(QVariantMap{ + {"title", section->title()}, + {"source", section->source().toString()}, + {"section", QVariant::fromValue(static_cast(section))}}); + const auto& cfg = ctx.config(); + const auto text = [](const std::string& value) { + return QString::fromStdString(value); + }; + m_initial = {{"game", text(cfg.paths.game)}, + {"local", text(cfg.paths.local)}, + {"workshop", text(cfg.paths.workshop)}, + {"config", text(cfg.paths.config_folder)}, + {"compact", cfg.compact_mod_list}, + {"launchArgs", text(cfg.launch_args)}, + {"launchWrapper", text(cfg.launch_wrapper)}, + {"afterLaunch", static_cast(cfg.after_launch)}, + {"confirmErrors", cfg.launch_confirm_errors}, + {"confirmUnsaved", cfg.launch_confirm_unsaved}, + {"confirmRunning", cfg.launch_confirm_running}, + {"useAltIds", cfg.sort.use_alternative_package_ids}, + {"checkMissing", cfg.sort.check_missing_dependencies}, + {"useCommunity", cfg.sort.use_community_rules}}; + const auto path = communityRulesFile(); + m_communityStatus = QFileInfo::exists(path) + ? "Found: " + path + : QStringLiteral("Not downloaded"); + m_cacheAvailable = !cfg.paths.config_folder.empty(); + m_cacheStatus = m_cacheAvailable + ? QStringLiteral("Cache ready") + : QStringLiteral("No config folder — not available"); } QCoro::Task SettingsBackend::browse(QString key) { - static const QMap titles{{"game", "Select RimWorld Game Folder"}, {"local", "Select Local Mods Folder"}, - {"workshop", "Select Workshop Mods Folder"}, {"config", "Select RimWorld Config Folder"}}; - if (!titles.contains(key)) co_return; - QPointer guard(this); - const auto [result, picker] = co_await awaitDialog(m_dialog, titles.value(key)); - const auto files = picker->selectedFiles(); - if (guard && result == QDialog::Accepted && !files.isEmpty()) emit guard->pathPicked(key, files.front()); + static const QMap titles{ + {"game", "Select RimWorld Game Folder"}, + {"local", "Select Local Mods Folder"}, + {"workshop", "Select Workshop Mods Folder"}, + {"config", "Select RimWorld Config Folder"}}; + if (!titles.contains(key)) co_return; + QPointer guard(this); + const auto [result, picker] = + co_await awaitDialog(m_dialog, titles.value(key)); + const auto files = picker->selectedFiles(); + if (guard && result == QDialog::Accepted && !files.isEmpty()) + emit guard->pathPicked(key, files.front()); } QString SettingsBackend::localCandidate(const QString& gamePath) const { - const auto path = QDir(gamePath).filePath(QStringLiteral("Mods")); - return QFileInfo(path).isDir() ? path : QString{}; + const auto path = QDir(gamePath).filePath(QStringLiteral("Mods")); + return QFileInfo(path).isDir() ? path : QString{}; } void SettingsBackend::autoDetect() { - const auto paths = detectGamePaths(); - const std::pair values[]{{"game", &paths.game}, {"local", &paths.local}, - {"workshop", &paths.workshop}, {"config", &paths.config_folder}}; - for (const auto& [key, value] : values) - if (!value->empty()) emit pathPicked(QString::fromLatin1(key), QString::fromStdString(*value)); + const auto paths = detectGamePaths(); + const std::pair values[]{ + {"game", &paths.game}, + {"local", &paths.local}, + {"workshop", &paths.workshop}, + {"config", &paths.config_folder}}; + for (const auto& [key, value] : values) + if (!value->empty()) + emit pathPicked(QString::fromLatin1(key), QString::fromStdString(*value)); } QCoro::Task SettingsBackend::downloadCommunityRules() { - m_communityStatus = QStringLiteral("Downloading…"); - m_communityBusy = true; + m_communityStatus = QStringLiteral("Downloading…"); + m_communityBusy = true; + emit communityChanged(); + if (!m_ctx.hasConfigService()) { + m_communityStatus = QStringLiteral("Download failed"); + m_communityBusy = false; emit communityChanged(); - if (!m_ctx.hasConfigService()) { - m_communityStatus = QStringLiteral("Download failed"); - m_communityBusy = false; - emit communityChanged(); - co_return; - } - LoadingState loading; - auto* progress = new QProgressDialog(QStringLiteral("Downloading community rules…"), QString{}, 0, 100, m_dialog); - progress->setWindowModality(Qt::WindowModal); - progress->setCancelButton(nullptr); - progress->setMinimumDuration(0); - connect(&loading, &LoadingState::changed, progress, - [progress](const QString& status, int percent, int, int) { progress->setLabelText(status); progress->setValue(percent); }); - progress->show(); - sort::CommunityRulesService service(m_ctx.configService(), m_ctx.network()); - QPointer guard(this); - QPointer progressGuard(progress); - const auto path = co_await service.ensureRules(loading, true); - if (progressGuard) { progressGuard->close(); progressGuard->deleteLater(); } - if (!guard) co_return; - guard->m_communityStatus = path ? "Downloaded: " + *path : QStringLiteral("Download failed"); - guard->m_communityBusy = false; - emit guard->communityChanged(); + co_return; + } + LoadingState loading; + auto* progress = + new QProgressDialog(QStringLiteral("Downloading community rules…"), + QString{}, 0, 100, m_dialog); + progress->setWindowModality(Qt::WindowModal); + progress->setCancelButton(nullptr); + progress->setMinimumDuration(0); + connect(&loading, &LoadingState::changed, progress, + [progress](const QString& status, int percent, int, int) { + progress->setLabelText(status); + progress->setValue(percent); + }); + progress->show(); + sort::CommunityRulesService service(m_ctx.configService(), m_ctx.network()); + QPointer guard(this); + QPointer progressGuard(progress); + const auto path = co_await service.ensureRules(loading, true); + if (progressGuard) { + progressGuard->close(); + progressGuard->deleteLater(); + } + if (!guard) co_return; + guard->m_communityStatus = + path ? "Downloaded: " + *path : QStringLiteral("Download failed"); + guard->m_communityBusy = false; + emit guard->communityChanged(); } QCoro::Task SettingsBackend::clearCache() { - m_cacheStatus = QStringLiteral("Clearing…"); - m_cacheBusy = true; - emit cacheChanged(); - QPointer guard(this); - const auto result = co_await m_ctx.modService().startupImpact().clear(); - if (!guard) co_return; - guard->m_cacheStatus = result ? QStringLiteral("Cache cleared") : "Error: " + result.error().message; - guard->m_cacheBusy = false; - emit guard->cacheChanged(); + m_cacheStatus = QStringLiteral("Clearing…"); + m_cacheBusy = true; + emit cacheChanged(); + QPointer guard(this); + const auto result = co_await m_ctx.modService().startupImpact().clear(); + if (!guard) co_return; + guard->m_cacheStatus = result ? QStringLiteral("Cache cleared") + : "Error: " + result.error().message; + guard->m_cacheBusy = false; + emit guard->cacheChanged(); } void SettingsBackend::reportSaveError(const QString& message) { - auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), message, QMessageBox::Ok, m_dialog); - box->setAttribute(Qt::WA_DeleteOnClose); - box->open(); + auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), + message, QMessageBox::Ok, m_dialog); + box->setAttribute(Qt::WA_DeleteOnClose); + box->open(); +} +SettingsPanel::SettingsPanel( + CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& factories) + : QDialog(parent), + m_config(ctx.config()), + m_backend(nullptr), + m_qml(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("settingsPanel")); + setWindowTitle(QStringLiteral("Settings")); + setModal(true); + resize(700, 560); + for (const auto& factory : factories) m_sections.push_back(factory(this)); + m_backend = new SettingsBackend(ctx, this, m_sections); + connect(m_backend, &SettingsBackend::saveRequested, this, + &SettingsPanel::save); + connect(m_backend, &SettingsBackend::cancelRequested, this, &QDialog::reject); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->rootContext()->setContextProperty(QStringLiteral("settings"), + m_backend); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); + layout->addWidget(m_qml); + // A QML click handler must finish before its root object is destroyed. + connect( + this, &QDialog::finished, this, [this] { m_qml->setSource(QUrl{}); }, + Qt::QueuedConnection); } -SettingsPanel::SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, - const std::vector& factories) - : QDialog(parent), m_config(ctx.config()), m_backend(nullptr), m_qml(new QQuickWidget(engine, this)) { - setObjectName(QStringLiteral("settingsPanel")); - setWindowTitle(QStringLiteral("Settings")); - setModal(true); - resize(700, 560); - for (const auto& factory : factories) m_sections.push_back(factory(this)); - m_backend = new SettingsBackend(ctx, this, m_sections); - connect(m_backend, &SettingsBackend::saveRequested, this, &SettingsPanel::save); - connect(m_backend, &SettingsBackend::cancelRequested, this, &QDialog::reject); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); - m_qml->setClearColor(paletteColor(u"ELEVATE_2")); - m_qml->rootContext()->setContextProperty(QStringLiteral("settings"), m_backend); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); - layout->addWidget(m_qml); - // A QML click handler must finish before its root object is destroyed. - connect(this, &QDialog::finished, this, [this] { m_qml->setSource(QUrl{}); }, Qt::QueuedConnection); +SettingsPanel::SettingsPanel(AppContext& app, QQmlEngine* engine, + QWidget* parent) + : SettingsPanel(app.coreContext(), engine, parent, app.settingsSections()) { } -SettingsPanel::SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent) - : SettingsPanel(app.coreContext(), engine, parent, app.settingsSections()) {} SettingsPanel::~SettingsPanel() { m_qml->setSource(QUrl{}); } void SettingsPanel::save(const QVariantMap& values) { - auto updated = m_config; - const auto text = [&](const char* key) { return values.value(QLatin1String(key)).toString().trimmed().toStdString(); }; - updated.paths.game = text("game"); - updated.paths.local = text("local"); - updated.paths.workshop = text("workshop"); - updated.paths.config_folder = text("config"); - updated.sort.use_alternative_package_ids = values.value("useAltIds").toBool(); - updated.sort.check_missing_dependencies = values.value("checkMissing").toBool(); - updated.sort.use_community_rules = values.value("useCommunity").toBool(); - updated.compact_mod_list = values.value("compact").toBool(); - updated.launch_args = text("launchArgs"); - updated.launch_wrapper = text("launchWrapper"); - updated.after_launch = static_cast(values.value("afterLaunch").toInt()); - updated.launch_confirm_errors = values.value("confirmErrors").toBool(); - updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); - updated.launch_confirm_running = values.value("confirmRunning").toBool(); - for (auto* section : m_sections) { - const auto result = section->apply(); - if (!result) { m_backend->reportSaveError(result.error().message); return; } + auto updated = m_config; + const auto text = [&](const char* key) { + return values.value(QLatin1String(key)).toString().trimmed().toStdString(); + }; + updated.paths.game = text("game"); + updated.paths.local = text("local"); + updated.paths.workshop = text("workshop"); + updated.paths.config_folder = text("config"); + updated.sort.use_alternative_package_ids = values.value("useAltIds").toBool(); + updated.sort.check_missing_dependencies = + values.value("checkMissing").toBool(); + updated.sort.use_community_rules = values.value("useCommunity").toBool(); + updated.compact_mod_list = values.value("compact").toBool(); + updated.launch_args = text("launchArgs"); + updated.launch_wrapper = text("launchWrapper"); + updated.after_launch = + static_cast(values.value("afterLaunch").toInt()); + updated.launch_confirm_errors = values.value("confirmErrors").toBool(); + updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); + updated.launch_confirm_running = values.value("confirmRunning").toBool(); + for (auto* section : m_sections) { + const auto result = section->apply(); + if (!result) { + m_backend->reportSaveError(result.error().message); + return; } - m_config = std::move(updated); - accept(); + } + m_config = std::move(updated); + accept(); } -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/panels/settings_panel.hpp b/src/ui/panels/settings_panel.hpp index ce27c43..0373025 100644 --- a/src/ui/panels/settings_panel.hpp +++ b/src/ui/panels/settings_panel.hpp @@ -1,8 +1,10 @@ #pragma once -#include -#include #include #include +#include + +#include + #include "core/context.hpp" #include "ui/settings_section.hpp" class QQmlEngine; @@ -10,58 +12,64 @@ class QQuickWidget; namespace px::modrim { class AppContext; class SettingsBackend : public QObject { - Q_OBJECT - Q_PROPERTY(QVariantMap initial READ initial CONSTANT) - Q_PROPERTY(QVariantList sections READ sections CONSTANT) - Q_PROPERTY(QString communityStatus READ communityStatus NOTIFY communityChanged) - Q_PROPERTY(bool communityBusy READ communityBusy NOTIFY communityChanged) - Q_PROPERTY(QString cacheStatus READ cacheStatus NOTIFY cacheChanged) - Q_PROPERTY(bool cacheAvailable READ cacheAvailable CONSTANT) - Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) -public: - SettingsBackend(CoreContext& ctx, QDialog* dialog, const std::vector& sections); - QVariantMap initial() const { return m_initial; } - QVariantList sections() const { return m_sections; } - QString communityStatus() const { return m_communityStatus; } - bool communityBusy() const { return m_communityBusy; } - QString cacheStatus() const { return m_cacheStatus; } - bool cacheAvailable() const { return m_cacheAvailable; } - bool cacheBusy() const { return m_cacheBusy; } - Q_INVOKABLE QCoro::Task browse(QString key); - Q_INVOKABLE QString localCandidate(const QString& gamePath) const; - Q_INVOKABLE void autoDetect(); - Q_INVOKABLE QCoro::Task downloadCommunityRules(); - Q_INVOKABLE QCoro::Task clearCache(); - Q_INVOKABLE void save(const QVariantMap& values) { emit saveRequested(values); } - Q_INVOKABLE void cancel() { emit cancelRequested(); } - void reportSaveError(const QString& message); -signals: - void pathPicked(const QString& key, const QString& path); - void saveRequested(const QVariantMap& values); - void cancelRequested(); - void communityChanged(); - void cacheChanged(); -private: - CoreContext& m_ctx; - QDialog* m_dialog; - QVariantMap m_initial; - QVariantList m_sections; - QString m_communityStatus, m_cacheStatus; - bool m_communityBusy = false, m_cacheBusy = false, m_cacheAvailable = false; + Q_OBJECT + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + Q_PROPERTY(QVariantList sections READ sections CONSTANT) + Q_PROPERTY( + QString communityStatus READ communityStatus NOTIFY communityChanged) + Q_PROPERTY(bool communityBusy READ communityBusy NOTIFY communityChanged) + Q_PROPERTY(QString cacheStatus READ cacheStatus NOTIFY cacheChanged) + Q_PROPERTY(bool cacheAvailable READ cacheAvailable CONSTANT) + Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) + public: + SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections); + QVariantMap initial() const { return m_initial; } + QVariantList sections() const { return m_sections; } + QString communityStatus() const { return m_communityStatus; } + bool communityBusy() const { return m_communityBusy; } + QString cacheStatus() const { return m_cacheStatus; } + bool cacheAvailable() const { return m_cacheAvailable; } + bool cacheBusy() const { return m_cacheBusy; } + Q_INVOKABLE QCoro::Task browse(QString key); + Q_INVOKABLE QString localCandidate(const QString& gamePath) const; + Q_INVOKABLE void autoDetect(); + Q_INVOKABLE QCoro::Task downloadCommunityRules(); + Q_INVOKABLE QCoro::Task clearCache(); + Q_INVOKABLE void save(const QVariantMap& values) { + emit saveRequested(values); + } + Q_INVOKABLE void cancel() { emit cancelRequested(); } + void reportSaveError(const QString& message); + signals: + void pathPicked(const QString& key, const QString& path); + void saveRequested(const QVariantMap& values); + void cancelRequested(); + void communityChanged(); + void cacheChanged(); + + private: + CoreContext& m_ctx; + QDialog* m_dialog; + QVariantMap m_initial; + QVariantList m_sections; + QString m_communityStatus, m_cacheStatus; + bool m_communityBusy = false, m_cacheBusy = false, m_cacheAvailable = false; }; class SettingsPanel : public QDialog { - Q_OBJECT -public: - SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, - const std::vector& sections = {}); - SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); - ~SettingsPanel() override; - const AppConfig& getConfig() const { return m_config; } -private: - void save(const QVariantMap& values); - AppConfig m_config; - std::vector m_sections; - SettingsBackend* m_backend; - QQuickWidget* m_qml; + Q_OBJECT + public: + SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& sections = {}); + SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); + ~SettingsPanel() override; + const AppConfig& getConfig() const { return m_config; } + + private: + void save(const QVariantMap& values); + AppConfig m_config; + std::vector m_sections; + SettingsBackend* m_backend; + QQuickWidget* m_qml; }; -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp index af4ed67..90d4edb 100644 --- a/src/ui/panels/sidebar_panel.cpp +++ b/src/ui/panels/sidebar_panel.cpp @@ -1,33 +1,51 @@ #include "ui/panels/sidebar_panel.hpp" + +#include + #include "core/context.hpp" #include "core/services/diagnostics_service.hpp" -#include namespace px::modrim { -SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent) - : FilterSidebar(engine, nullptr, parent), m_ctx(ctx), m_model(new SidebarModel(this)) { - if (rootObject()) rootObject()->setProperty("model", QVariant::fromValue(m_model)); - connect(this, &FilterSidebar::activated, this, &SidebarPanel::onActivated); - m_sidebarConnection = m_ctx.diagnosticsService().sidebarEntriesChanged.connect([this](const auto& entries) { setEntries(entries); }); +SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent) + : FilterSidebar(engine, nullptr, parent), + m_ctx(ctx), + m_model(new SidebarModel(this)) { + if (rootObject()) + rootObject()->setProperty("model", QVariant::fromValue(m_model)); + connect(this, &FilterSidebar::activated, this, &SidebarPanel::onActivated); + m_sidebarConnection = + m_ctx.diagnosticsService().sidebarEntriesChanged.connect( + [this](const auto& entries) { setEntries(entries); }); +} +SidebarPanel::~SidebarPanel() { + m_ctx.diagnosticsService().sidebarEntriesChanged.disconnect( + m_sidebarConnection); } -SidebarPanel::~SidebarPanel() { m_ctx.diagnosticsService().sidebarEntriesChanged.disconnect(m_sidebarConnection); } void SidebarPanel::setEntries(const QList& entries) { - const bool reset = entries.size() != m_entries.size(); - m_entries = entries; - m_model->updateEntries(entries); - if (reset) if (const auto current = currentEntry()) emit entrySelected(*current); + const bool reset = entries.size() != m_entries.size(); + m_entries = entries; + m_model->updateEntries(entries); + if (reset) + if (const auto current = currentEntry()) emit entrySelected(*current); } std::optional SidebarPanel::currentEntry() const { - const int selected = currentIndex(); - if (selected >= 0 && selected < m_entries.size()) return m_entries[selected]; - return std::nullopt; + const int selected = currentIndex(); + if (selected >= 0 && selected < m_entries.size()) return m_entries[selected]; + return std::nullopt; } void SidebarPanel::selectAllEntry() { - auto* root = rootObject(); - auto* view = root ? root->findChild(QStringLiteral("listView")) : nullptr; - if (!view) return; - for (int row = 0; row < m_entries.size(); ++row) if (m_entries[row].kind == view::SidebarEntry::Kind::kAll) { - view->setProperty("currentIndex", row); onActivated(row); return; + auto* root = rootObject(); + auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (!view) return; + for (int row = 0; row < m_entries.size(); ++row) + if (m_entries[row].kind == view::SidebarEntry::Kind::kAll) { + view->setProperty("currentIndex", row); + onActivated(row); + return; } } -void SidebarPanel::onActivated(int row) { if (row >= 0 && row < m_entries.size()) emit entrySelected(m_entries[row]); } +void SidebarPanel::onActivated(int row) { + if (row >= 0 && row < m_entries.size()) emit entrySelected(m_entries[row]); } +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.hpp b/src/ui/panels/sidebar_panel.hpp index 9c9596d..de603fe 100644 --- a/src/ui/panels/sidebar_panel.hpp +++ b/src/ui/panels/sidebar_panel.hpp @@ -1,25 +1,29 @@ #pragma once +#include + #include "ui/components/filter_sidebar.hpp" #include "ui/models/sidebar_model.hpp" -#include -namespace px::modrim { class CoreContext; } +namespace px::modrim { +class CoreContext; +} namespace px::modrim { class SidebarPanel : public FilterSidebar { - Q_OBJECT -public: - SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); - ~SidebarPanel() override; - void setEntries(const QList& entries); - std::optional currentEntry() const; - void selectAllEntry(); - SidebarModel* model() const { return m_model; } -signals: - void entrySelected(const px::modrim::view::SidebarEntry& entry); -private: - void onActivated(int row); - CoreContext& m_ctx; - SidebarModel* m_model; - QList m_entries; - std::uint64_t m_sidebarConnection; + Q_OBJECT + public: + SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~SidebarPanel() override; + void setEntries(const QList& entries); + std::optional currentEntry() const; + void selectAllEntry(); + SidebarModel* model() const { return m_model; } + signals: + void entrySelected(const px::modrim::view::SidebarEntry& entry); + + private: + void onActivated(int row); + CoreContext& m_ctx; + SidebarModel* m_model; + QList m_entries; + std::uint64_t m_sidebarConnection; }; -} +} // namespace px::modrim diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp index 5a6fbd7..6499be9 100644 --- a/src/ui/panels/startup_impact_data.cpp +++ b/src/ui/panels/startup_impact_data.cpp @@ -1,12 +1,13 @@ #include "ui/panels/startup_impact_data.hpp" +#include +#include #include #include #include #include #include -#include -#include + #include "core/services/startup_impact_service/labels.hpp" #include "core/services/startup_impact_service/phases.hpp" #include "ui/theme/palette.hpp" @@ -16,127 +17,203 @@ using namespace startup_impact; namespace { constexpr double kMinSeconds = 0.001; QString phaseColor(std::size_t i) { - static constexpr std::array kColors{ - "#b57bee", "#f06ba8", "#43b581", "#f0883e", "#4fb3e8", "#e8c547"}; - return i < kColors.size() ? QString::fromLatin1(kColors[i]) : paletteValue(u"TEXT_DIM"); + static constexpr std::array kColors{ + "#b57bee", "#f06ba8", "#43b581", "#f0883e", "#4fb3e8", "#e8c547"}; + return i < kColors.size() ? QString::fromLatin1(kColors[i]) + : paletteValue(u"TEXT_DIM"); } QString tooltip(const QMap& metrics) { - QList> sorted; - for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) sorted.append({it.key(), it.value()}); - std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); - QStringList lines; - for (qsizetype i = 0; i < std::min(8, sorted.size()); ++i) - if (sorted[i].second >= kMinSeconds) lines.append(sorted[i].first + ": " + formatDuration(sorted[i].second)); - if (sorted.size() > 8) lines.append(QStringLiteral("+ %1 more").arg(sorted.size() - 8)); - return lines.join('\n'); + QList> sorted; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) + sorted.append({it.key(), it.value()}); + std::stable_sort( + sorted.begin(), sorted.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + QStringList lines; + for (qsizetype i = 0; i < std::min(8, sorted.size()); ++i) + if (sorted[i].second >= kMinSeconds) + lines.append(sorted[i].first + ": " + formatDuration(sorted[i].second)); + if (sorted.size() > 8) + lines.append(QStringLiteral("+ %1 more").arg(sorted.size() - 8)); + return lines.join('\n'); } using Entry = std::tuple*>; -QVariantList phaseRows(const QList& entries, const std::optional& selected) { - using Contributor = std::tuple; - std::array, kPhaseCount> contributors; - std::array, kPhaseCount> metrics; - PhaseTotals sums{}; - for (const auto& [name, key, base, values] : entries) { - const auto totals = phaseTotals(*values); - for (std::size_t p = 0; p < kPhaseCount; ++p) { - if (totals[p] < kMinSeconds) continue; - contributors[p].append({totals[p], name, key, base}); - sums[p] += totals[p]; - } - for (auto it = values->cbegin(); it != values->cend(); ++it) - metrics[phaseIndex(metricPhase(it.key()))][metricLabel(it.key().section('|', 0, 0))] += it.value(); - } - double grand = 0; - for (double value : sums) grand += value; - const double maximum = *std::max_element(sums.begin(), sums.end()); - std::array order{0, 1, 2, 3, 4, 5, 6}; - std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return sums[a] > sums[b]; }); - QVariantList rows; - for (auto p : order) { - if (contributors[p].isEmpty()) continue; - auto& items = contributors[p]; - std::sort(items.begin(), items.end(), std::greater{}); - QVariantList mods; - const double top = std::get<0>(items.front()); - for (const auto& [seconds, name, key, base] : items) - mods.append(QVariantMap{{"name", name}, {"key", key}, {"value", formatDuration(seconds)}, - {"fraction", seconds / top}, {"base", base}, {"selected", selected && key == *selected}}); - rows.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}, - {"seconds", sums[p]}, {"value", formatDuration(sums[p])}, - {"share", grand > 0 ? QString::number(static_cast(std::nearbyint(100 * sums[p] / grand))) + '%' : QString{}}, - {"fraction", sums[p] / maximum}, {"tooltip", tooltip(metrics[p])}, {"mods", mods}}); +QVariantList phaseRows(const QList& entries, + const std::optional& selected) { + using Contributor = std::tuple; + std::array, kPhaseCount> contributors; + std::array, kPhaseCount> metrics; + PhaseTotals sums{}; + for (const auto& [name, key, base, values] : entries) { + const auto totals = phaseTotals(*values); + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; + contributors[p].append({totals[p], name, key, base}); + sums[p] += totals[p]; } - return rows; -} + for (auto it = values->cbegin(); it != values->cend(); ++it) + metrics[phaseIndex(metricPhase(it.key()))] + [metricLabel(it.key().section('|', 0, 0))] += it.value(); + } + double grand = 0; + for (double value : sums) grand += value; + const double maximum = *std::max_element(sums.begin(), sums.end()); + std::array order{0, 1, 2, 3, 4, 5, 6}; + std::stable_sort(order.begin(), order.end(), + [&](auto a, auto b) { return sums[a] > sums[b]; }); + QVariantList rows; + for (auto p : order) { + if (contributors[p].isEmpty()) continue; + auto& items = contributors[p]; + std::sort(items.begin(), items.end(), std::greater{}); + QVariantList mods; + const double top = std::get<0>(items.front()); + for (const auto& [seconds, name, key, base] : items) + mods.append(QVariantMap{{"name", name}, + {"key", key}, + {"value", formatDuration(seconds)}, + {"fraction", seconds / top}, + {"base", base}, + {"selected", selected && key == *selected}}); + rows.append(QVariantMap{ + {"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}, + {"seconds", sums[p]}, + {"value", formatDuration(sums[p])}, + {"share", grand > 0 ? QString::number(static_cast( + std::nearbyint(100 * sums[p] / grand))) + + '%' + : QString{}}, + {"fraction", sums[p] / maximum}, + {"tooltip", tooltip(metrics[p])}, + {"mods", mods}}); + } + return rows; } -QVariantMap buildStartupImpact(const StartupImpactReport& report, const QStringList& activePids, - const std::optional& selectedPid, double estimatedS) { - QSet active; - for (const auto& pid : activePids) active.insert(normalizePackageId(pid)); - const auto selected = selectedPid && !selectedPid->isEmpty() - ? std::optional{normalizePackageId(*selectedPid)} : std::nullopt; - const StartupImpactMod* base = nullptr; - QList mods; - double modsSeconds = 0, offSeconds = 0; - for (const auto& mod : report.mods) { - offSeconds += mod.offThreadTotalImpactS; - if (mod.packageId && *mod.packageId == kBaseGamePid) { if (!base) base = &mod; } - else { mods.append(&mod); modsSeconds += mod.totalImpactS; } +} // namespace +QVariantMap buildStartupImpact(const StartupImpactReport& report, + const QStringList& activePids, + const std::optional& selectedPid, + double estimatedS) { + QSet active; + for (const auto& pid : activePids) active.insert(normalizePackageId(pid)); + const auto selected = + selectedPid && !selectedPid->isEmpty() + ? std::optional{normalizePackageId(*selectedPid)} + : std::nullopt; + const StartupImpactMod* base = nullptr; + QList mods; + double modsSeconds = 0, offSeconds = 0; + for (const auto& mod : report.mods) { + offSeconds += mod.offThreadTotalImpactS; + if (mod.packageId && *mod.packageId == kBaseGamePid) { + if (!base) base = &mod; + } else { + mods.append(&mod); + modsSeconds += mod.totalImpactS; } - std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { return a->totalImpactS > b->totalImpactS; }); - const double baseSeconds = base ? base->totalImpactS : 0; - const double barTotal = baseSeconds + modsSeconds + offSeconds; - const double maxMod = mods.isEmpty() ? 0 : mods.front()->totalImpactS; - QVariantList rows, legend, summary; - QList entries; - int activeCount = 0; - QString selectedKey; - for (qsizetype index = 0; index < mods.size(); ++index) { - const auto& mod = *mods[index]; - const QString key = mod.packageId && !mod.packageId->isEmpty() ? *mod.packageId : mod.modName; - const bool isActive = mod.packageId && active.contains(*mod.packageId); - const bool isSelected = mod.packageId && selected && *mod.packageId == *selected; - if (isActive) ++activeCount; - if (isSelected && selectedKey.isEmpty()) selectedKey = key; - const auto totals = phaseTotals(mod.metrics); - std::array, kPhaseCount> grouped; - for (auto it = mod.metrics.cbegin(); it != mod.metrics.cend(); ++it) - grouped[phaseIndex(metricPhase(it.key()))][metricLabel(it.key())] += it.value(); - QList order; - QVariantList segments, phases; - for (std::size_t p = 0; p < kPhaseCount; ++p) { - if (totals[p] < kMinSeconds) continue; - order.append(p); - segments.append(QVariantMap{{"color", phaseColor(p)}, {"fraction", maxMod > 0 ? totals[p] / maxMod : 0}}); - } - std::stable_sort(order.begin(), order.end(), [&](auto a, auto b) { return totals[a] > totals[b]; }); - for (auto p : order) - phases.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}, - {"value", formatDuration(totals[p])}, {"fraction", totals[p] / totals[order.front()]}, {"tooltip", tooltip(grouped[p])}}); - rows.append(QVariantMap{{"key", key}, {"name", mod.modName}, {"rank", index + 1}, - {"seconds", mod.totalImpactS}, {"time", formatDuration(mod.totalImpactS)}, {"color", impactColor(mod.totalImpactS)}, - {"active", isActive}, {"selected", isSelected}, - {"off_thread", mod.offThreadTotalImpactS >= kMinSeconds ? formatDuration(mod.offThreadTotalImpactS) : QString{}}, - {"dominant", order.isEmpty() ? QString{} : phaseLabel(static_cast(order.front()))}, {"segments", segments}, {"phases", phases}}); - entries.append({mod.modName, key, false, &mod.metrics}); + } + std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { + return a->totalImpactS > b->totalImpactS; + }); + const double baseSeconds = base ? base->totalImpactS : 0; + const double barTotal = baseSeconds + modsSeconds + offSeconds; + const double maxMod = mods.isEmpty() ? 0 : mods.front()->totalImpactS; + QVariantList rows, legend, summary; + QList entries; + int activeCount = 0; + QString selectedKey; + for (qsizetype index = 0; index < mods.size(); ++index) { + const auto& mod = *mods[index]; + const QString key = mod.packageId && !mod.packageId->isEmpty() + ? *mod.packageId + : mod.modName; + const bool isActive = mod.packageId && active.contains(*mod.packageId); + const bool isSelected = + mod.packageId && selected && *mod.packageId == *selected; + if (isActive) ++activeCount; + if (isSelected && selectedKey.isEmpty()) selectedKey = key; + const auto totals = phaseTotals(mod.metrics); + std::array, kPhaseCount> grouped; + for (auto it = mod.metrics.cbegin(); it != mod.metrics.cend(); ++it) + grouped[phaseIndex(metricPhase(it.key()))][metricLabel(it.key())] += + it.value(); + QList order; + QVariantList segments, phases; + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; + order.append(p); + segments.append( + QVariantMap{{"color", phaseColor(p)}, + {"fraction", maxMod > 0 ? totals[p] / maxMod : 0}}); } - for (std::size_t p = 0; p < kPhaseCount; ++p) - legend.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, {"color", phaseColor(p)}}); - const std::array labels{QStringLiteral("Base game"), QStringLiteral("Mods, main thread"), QStringLiteral("Off-thread (parallel)")}; - const std::array values{baseSeconds, modsSeconds, offSeconds}; - for (std::size_t i = 0; i < values.size(); ++i) - if (values[i] >= kMinSeconds) summary.append(QVariantMap{{"label", labels[i]}, {"value", formatDuration(values[i])}, - {"fraction", barTotal > 0 ? values[i] / barTotal : 0}, {"color", paletteValue(i == 0 ? u"TEXT_DIM" : u"PRIMARY")}, {"parallel", i == 2}}); - const auto modsPhases = phaseRows(entries, selected); - const QMap empty; - entries.prepend({QStringLiteral("Base game"), QString(kBaseGamePid), true, base ? &base->metrics : &empty}); - auto timestamp = QDateTime::fromString(report.timestamp, Qt::ISODateWithMs); - if (!timestamp.isValid()) timestamp = QDateTime::fromString(report.timestamp, Qt::ISODate); - return {{"timestamp", timestamp.isValid() ? timestamp.toLocalTime().toString("yyyy-MM-dd HH:mm") : report.timestamp}, - {"last_launch", formatDuration(report.loadingTimeS != 0 ? report.loadingTimeS : baseSeconds + modsSeconds)}, - {"estimated", activePids.isEmpty() ? QString{} : formatDuration(estimatedS)}, {"summary", summary}, - {"base_value", formatDuration(baseSeconds)}, {"mods_value", formatDuration(modsSeconds)}, {"off_value", formatDuration(offSeconds)}, - {"legend", legend}, {"mods", rows}, {"active_count", activeCount}, {"selected_key", selectedKey}, - {"phases_with_base", phaseRows(entries, selected)}, {"phases_mods_only", modsPhases}}; + std::stable_sort(order.begin(), order.end(), + [&](auto a, auto b) { return totals[a] > totals[b]; }); + for (auto p : order) + phases.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}, + {"value", formatDuration(totals[p])}, + {"fraction", totals[p] / totals[order.front()]}, + {"tooltip", tooltip(grouped[p])}}); + rows.append(QVariantMap{ + {"key", key}, + {"name", mod.modName}, + {"rank", index + 1}, + {"seconds", mod.totalImpactS}, + {"time", formatDuration(mod.totalImpactS)}, + {"color", impactColor(mod.totalImpactS)}, + {"active", isActive}, + {"selected", isSelected}, + {"off_thread", mod.offThreadTotalImpactS >= kMinSeconds + ? formatDuration(mod.offThreadTotalImpactS) + : QString{}}, + {"dominant", order.isEmpty() + ? QString{} + : phaseLabel(static_cast(order.front()))}, + {"segments", segments}, + {"phases", phases}}); + entries.append({mod.modName, key, false, &mod.metrics}); + } + for (std::size_t p = 0; p < kPhaseCount; ++p) + legend.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}}); + const std::array labels{QStringLiteral("Base game"), + QStringLiteral("Mods, main thread"), + QStringLiteral("Off-thread (parallel)")}; + const std::array values{baseSeconds, modsSeconds, offSeconds}; + for (std::size_t i = 0; i < values.size(); ++i) + if (values[i] >= kMinSeconds) + summary.append(QVariantMap{ + {"label", labels[i]}, + {"value", formatDuration(values[i])}, + {"fraction", barTotal > 0 ? values[i] / barTotal : 0}, + {"color", paletteValue(i == 0 ? u"TEXT_DIM" : u"PRIMARY")}, + {"parallel", i == 2}}); + const auto modsPhases = phaseRows(entries, selected); + const QMap empty; + entries.prepend({QStringLiteral("Base game"), QString(kBaseGamePid), true, + base ? &base->metrics : &empty}); + auto timestamp = QDateTime::fromString(report.timestamp, Qt::ISODateWithMs); + if (!timestamp.isValid()) + timestamp = QDateTime::fromString(report.timestamp, Qt::ISODate); + return { + {"timestamp", timestamp.isValid() + ? timestamp.toLocalTime().toString("yyyy-MM-dd HH:mm") + : report.timestamp}, + {"last_launch", + formatDuration(report.loadingTimeS != 0 ? report.loadingTimeS + : baseSeconds + modsSeconds)}, + {"estimated", + activePids.isEmpty() ? QString{} : formatDuration(estimatedS)}, + {"summary", summary}, + {"base_value", formatDuration(baseSeconds)}, + {"mods_value", formatDuration(modsSeconds)}, + {"off_value", formatDuration(offSeconds)}, + {"legend", legend}, + {"mods", rows}, + {"active_count", activeCount}, + {"selected_key", selectedKey}, + {"phases_with_base", phaseRows(entries, selected)}, + {"phases_mods_only", modsPhases}}; } -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/panels/startup_impact_data.hpp b/src/ui/panels/startup_impact_data.hpp index 4e7520b..2457546 100644 --- a/src/ui/panels/startup_impact_data.hpp +++ b/src/ui/panels/startup_impact_data.hpp @@ -1,13 +1,15 @@ #pragma once -#include #include #include +#include + #include "core/services/startup_impact_service/models.hpp" #include "ui/models/impact.hpp" namespace px::modrim { -QVariantMap buildStartupImpact(const startup_impact::StartupImpactReport& report, - const QStringList& activePids, - const std::optional& selectedPid, double estimatedS); -} // namespace px::modrim +QVariantMap buildStartupImpact( + const startup_impact::StartupImpactReport& report, + const QStringList& activePids, const std::optional& selectedPid, + double estimatedS); +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp index 1806663..2e29ffd 100644 --- a/src/ui/panels/time_analytics_panel.cpp +++ b/src/ui/panels/time_analytics_panel.cpp @@ -1,48 +1,68 @@ #include "ui/panels/time_analytics_panel.hpp" + #include #include #include #include #include + #include "ui/components/dialog_chrome.hpp" #include "ui/panels/startup_impact_data.hpp" #include "ui/theme/palette.hpp" namespace px::modrim { -TimeAnalyticsPanel::TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) - : QWidget(parent), m_service(service), m_qml(new QQuickWidget(engine, this)) { - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); - m_qml->setClearColor(paletteColor(u"ELEVATE_1")); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); - layout->addWidget(m_qml); +TimeAnalyticsPanel::TimeAnalyticsPanel( + startup_impact::StartupImpactService* service, QQmlEngine* engine, + QWidget* parent) + : QWidget(parent), + m_service(service), + m_qml(new QQuickWidget(engine, this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); + layout->addWidget(m_qml); } -QCoro::Task TimeAnalyticsPanel::setData(std::optional pid, QStringList activePids) { - const auto token = ++m_requestToken; - QPointer root = m_qml->rootObject(); - if (!root) co_return; - if (!m_service) { root->setProperty("sourceData", QVariant{}); co_return; } - QPointer guard(this); - auto result = co_await m_service->snapshot(activePids); - if (!guard || !root || token != guard->m_requestToken) co_return; - if (!result || !result->report) { root->setProperty("sourceData", QVariant{}); co_return; } - double estimated = result->baseGameAvg; - for (const auto& total : result->totals) estimated += total.onThread + total.offThread; - root->setProperty("sourceData", buildStartupImpact(*result->report, activePids, pid, estimated)); +QCoro::Task TimeAnalyticsPanel::setData(std::optional pid, + QStringList activePids) { + const auto token = ++m_requestToken; + QPointer root = m_qml->rootObject(); + if (!root) co_return; + if (!m_service) { + root->setProperty("sourceData", QVariant{}); + co_return; + } + QPointer guard(this); + auto result = co_await m_service->snapshot(activePids); + if (!guard || !root || token != guard->m_requestToken) co_return; + if (!result || !result->report) { + root->setProperty("sourceData", QVariant{}); + co_return; + } + double estimated = result->baseGameAvg; + for (const auto& total : result->totals) + estimated += total.onThread + total.offThread; + root->setProperty( + "sourceData", + buildStartupImpact(*result->report, activePids, pid, estimated)); } void TimeAnalyticsPanel::clear() { - ++m_requestToken; - if (auto* root = m_qml->rootObject()) root->setProperty("sourceData", QVariant{}); + ++m_requestToken; + if (auto* root = m_qml->rootObject()) + root->setProperty("sourceData", QVariant{}); } -StartupImpactDialog::StartupImpactDialog(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent) +StartupImpactDialog::StartupImpactDialog( + startup_impact::StartupImpactService* service, QQmlEngine* engine, + QWidget* parent) : QDialog(parent), panel(new TimeAnalyticsPanel(service, engine, this)) { - setWindowTitle(QStringLiteral("Startup impact")); - setAttribute(Qt::WA_DeleteOnClose); - resize(900, 720); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(panel); - installDialogChrome(this); + setWindowTitle(QStringLiteral("Startup impact")); + setAttribute(Qt::WA_DeleteOnClose); + resize(900, 720); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(panel); + installDialogChrome(this); } -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.hpp b/src/ui/panels/time_analytics_panel.hpp index a6aa521..3d10dec 100644 --- a/src/ui/panels/time_analytics_panel.hpp +++ b/src/ui/panels/time_analytics_panel.hpp @@ -1,28 +1,33 @@ #pragma once -#include -#include #include #include #include +#include + +#include + #include "core/services/startup_impact_service/service.hpp" class QQmlEngine; class QQuickWidget; namespace px::modrim { class TimeAnalyticsPanel : public QWidget { - Q_OBJECT -public: - TimeAnalyticsPanel(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); - QCoro::Task setData(std::optional pid, QStringList activePids); - void clear(); -private: - startup_impact::StartupImpactService* m_service; - QQuickWidget* m_qml; - quint64 m_requestToken = 0; + Q_OBJECT + public: + TimeAnalyticsPanel(startup_impact::StartupImpactService* service, + QQmlEngine* engine, QWidget* parent); + QCoro::Task setData(std::optional pid, QStringList activePids); + void clear(); + + private: + startup_impact::StartupImpactService* m_service; + QQuickWidget* m_qml; + quint64 m_requestToken = 0; }; class StartupImpactDialog : public QDialog { - Q_OBJECT -public: - StartupImpactDialog(startup_impact::StartupImpactService* service, QQmlEngine* engine, QWidget* parent); - TimeAnalyticsPanel* panel; + Q_OBJECT + public: + StartupImpactDialog(startup_impact::StartupImpactService* service, + QQmlEngine* engine, QWidget* parent); + TimeAnalyticsPanel* panel; }; -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp index 1fc2977..aa7078c 100644 --- a/src/ui/panels/update_dialog.cpp +++ b/src/ui/panels/update_dialog.cpp @@ -10,43 +10,50 @@ namespace px::modrim { UpdateDialog::UpdateDialog(const services::ReleaseInfo& release, - const QString& currentVersion, QWidget* parent) : QDialog(parent) { - setWindowTitle(tr("Update Available")); - resize(560, 460); - auto* layout = new QVBoxLayout(this); - auto* title = new QLabel(tr("PxModRim %1 is available").arg(release.tag), this); - title->setStyleSheet(QStringLiteral("font-size: 16px; font-weight: 600;")); - layout->addWidget(title); - layout->addWidget(new QLabel(tr("You are running %1.").arg(currentVersion), this)); - auto* notes = new QTextBrowser(this); - notes->setOpenLinks(false); - notes->setOpenExternalLinks(false); - connect(notes, &QTextBrowser::anchorClicked, this, [](const QUrl& url) { - const QString scheme = url.scheme().toLower(); - if (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) - QDesktopServices::openUrl(url); - }); - notes->setMarkdown(release.notes.isEmpty() ? tr("No release notes.") : release.notes); - layout->addWidget(notes, 1); - auto* note = new QLabel(tr("Automatic updating may be added later. For now, downloading the release " - "yourself is the safest way to avoid update bugs."), this); - note->setWordWrap(true); - layout->addWidget(note); - auto* buttons = new QHBoxLayout; - auto* skip = new QPushButton(tr("Skip This Version"), this); - skip->setObjectName(QStringLiteral("appBtn")); - connect(skip, &QPushButton::clicked, this, [this] { done(kSkipResult); }); - auto* later = new QPushButton(tr("Later"), this); - later->setObjectName(QStringLiteral("appBtn")); - connect(later, &QPushButton::clicked, this, &QDialog::reject); - auto* download = new QPushButton(tr("Download"), this); - download->setObjectName(QStringLiteral("primaryAction")); - download->setDefault(true); - connect(download, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(skip); - buttons->addStretch(); - buttons->addWidget(later); - buttons->addWidget(download); - layout->addLayout(buttons); + const QString& currentVersion, QWidget* parent) + : QDialog(parent) { + setWindowTitle(tr("Update Available")); + resize(560, 460); + auto* layout = new QVBoxLayout(this); + auto* title = + new QLabel(tr("PxModRim %1 is available").arg(release.tag), this); + title->setStyleSheet(QStringLiteral("font-size: 16px; font-weight: 600;")); + layout->addWidget(title); + layout->addWidget( + new QLabel(tr("You are running %1.").arg(currentVersion), this)); + auto* notes = new QTextBrowser(this); + notes->setOpenLinks(false); + notes->setOpenExternalLinks(false); + connect(notes, &QTextBrowser::anchorClicked, this, [](const QUrl& url) { + const QString scheme = url.scheme().toLower(); + if (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) + QDesktopServices::openUrl(url); + }); + notes->setMarkdown(release.notes.isEmpty() ? tr("No release notes.") + : release.notes); + layout->addWidget(notes, 1); + auto* note = + new QLabel(tr("Automatic updating may be added later. For now, " + "downloading the release " + "yourself is the safest way to avoid update bugs."), + this); + note->setWordWrap(true); + layout->addWidget(note); + auto* buttons = new QHBoxLayout; + auto* skip = new QPushButton(tr("Skip This Version"), this); + skip->setObjectName(QStringLiteral("appBtn")); + connect(skip, &QPushButton::clicked, this, [this] { done(kSkipResult); }); + auto* later = new QPushButton(tr("Later"), this); + later->setObjectName(QStringLiteral("appBtn")); + connect(later, &QPushButton::clicked, this, &QDialog::reject); + auto* download = new QPushButton(tr("Download"), this); + download->setObjectName(QStringLiteral("primaryAction")); + download->setDefault(true); + connect(download, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(skip); + buttons->addStretch(); + buttons->addWidget(later); + buttons->addWidget(download); + layout->addLayout(buttons); } } // namespace px::modrim diff --git a/src/ui/panels/update_dialog.hpp b/src/ui/panels/update_dialog.hpp index 18113c2..794f46a 100644 --- a/src/ui/panels/update_dialog.hpp +++ b/src/ui/panels/update_dialog.hpp @@ -1,14 +1,15 @@ #pragma once #include + #include "core/services/update_service.hpp" namespace px::modrim { inline constexpr int kSkipResult = 2; class UpdateDialog : public QDialog { -public: - UpdateDialog(const services::ReleaseInfo& release, const QString& currentVersion, - QWidget* parent = nullptr); + public: + UpdateDialog(const services::ReleaseInfo& release, + const QString& currentVersion, QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp index 73178ae..0abb83f 100644 --- a/src/ui/panels/upload_report_dialog.cpp +++ b/src/ui/panels/upload_report_dialog.cpp @@ -18,132 +18,151 @@ namespace px::modrim { UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { - setObjectName(QStringLiteral("uploadConfirmDialog")); - setWindowTitle(tr("Upload Log & System Info")); - setModal(true); - resize(520, 320); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(20, 20, 20, 20); - layout->setSpacing(16); - auto* description = new QLabel(tr( - "This action generates and uploads an anonymized diagnostic report to help diagnose issues.\n\n" - "The report includes:\n" - " • Recent application log contents (last ~2 MB)\n" - " • System details (OS, architecture, Qt)\n" - " • RimWorld version and configured directory paths\n" - " • Mod collection count and active mod list in load order\n\n" - "Privacy & security:\n" - " • Your home directory path is replaced with '~'\n" - " • Your username in other user-directory paths is replaced with ''\n\n" - "Notice: The uploaded report will be publicly viewable by anyone with the link."), this); - description->setWordWrap(true); - layout->addWidget(description); - layout->addStretch(); - auto* buttons = new QHBoxLayout; - buttons->setSpacing(10); - auto* save = new QPushButton(tr("Save to file instead…"), this); - save->setObjectName(QStringLiteral("appBtn")); - connect(save, &QPushButton::clicked, this, [this] { done(static_cast(UploadChoice::kSaveToFile)); }); - buttons->addWidget(save); - buttons->addStretch(); - auto* cancel = new QPushButton(tr("Cancel"), this); - cancel->setObjectName(QStringLiteral("appBtn")); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - buttons->addWidget(cancel); - auto* upload = new QPushButton(tr("Upload"), this); - upload->setObjectName(QStringLiteral("primaryAction")); - connect(upload, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(upload); - layout->addLayout(buttons); + setObjectName(QStringLiteral("uploadConfirmDialog")); + setWindowTitle(tr("Upload Log & System Info")); + setModal(true); + resize(520, 320); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* description = new QLabel( + tr("This action generates and uploads an anonymized diagnostic report to " + "help diagnose issues.\n\n" + "The report includes:\n" + " • Recent application log contents (last ~2 MB)\n" + " • System details (OS, architecture, Qt)\n" + " • RimWorld version and configured directory paths\n" + " • Mod collection count and active mod list in load order\n\n" + "Privacy & security:\n" + " • Your home directory path is replaced with '~'\n" + " • Your username in other user-directory paths is replaced with " + "''\n\n" + "Notice: The uploaded report will be publicly viewable by anyone with " + "the link."), + this); + description->setWordWrap(true); + layout->addWidget(description); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->setSpacing(10); + auto* save = new QPushButton(tr("Save to file instead…"), this); + save->setObjectName(QStringLiteral("appBtn")); + connect(save, &QPushButton::clicked, this, + [this] { done(static_cast(UploadChoice::kSaveToFile)); }); + buttons->addWidget(save); + buttons->addStretch(); + auto* cancel = new QPushButton(tr("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(cancel); + auto* upload = new QPushButton(tr("Upload"), this); + upload->setObjectName(QStringLiteral("primaryAction")); + connect(upload, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(upload); + layout->addLayout(buttons); } -UploadFailedDialog::UploadFailedDialog(const QString& errorMessage, QWidget* parent) : QDialog(parent) { - setObjectName(QStringLiteral("uploadFailedDialog")); - setWindowTitle(tr("Upload Failed")); - setModal(true); - resize(460, 220); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(20, 20, 20, 20); - layout->setSpacing(16); - auto* title = new QLabel(tr("Couldn't upload the report"), this); - title->setObjectName(QStringLiteral("dialogTitle")); - layout->addWidget(title); - auto* message = new QLabel(tr("An error occurred while uploading:\n\n%1\n\n" - "Would you like to save the report to a file instead?").arg(errorMessage), this); - message->setTextFormat(Qt::PlainText); - message->setWordWrap(true); - layout->addWidget(message); - layout->addStretch(); - auto* buttons = new QHBoxLayout; - buttons->addStretch(); - auto* close = new QPushButton(tr("Close"), this); - close->setObjectName(QStringLiteral("appBtn")); - connect(close, &QPushButton::clicked, this, &QDialog::reject); - buttons->addWidget(close); - auto* save = new QPushButton(tr("Save to file…"), this); - save->setObjectName(QStringLiteral("primaryAction")); - connect(save, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(save); - layout->addLayout(buttons); +UploadFailedDialog::UploadFailedDialog(const QString& errorMessage, + QWidget* parent) + : QDialog(parent) { + setObjectName(QStringLiteral("uploadFailedDialog")); + setWindowTitle(tr("Upload Failed")); + setModal(true); + resize(460, 220); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* title = new QLabel(tr("Couldn't upload the report"), this); + title->setObjectName(QStringLiteral("dialogTitle")); + layout->addWidget(title); + auto* message = + new QLabel(tr("An error occurred while uploading:\n\n%1\n\n" + "Would you like to save the report to a file instead?") + .arg(errorMessage), + this); + message->setTextFormat(Qt::PlainText); + message->setWordWrap(true); + layout->addWidget(message); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("appBtn")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(close); + auto* save = new QPushButton(tr("Save to file…"), this); + save->setObjectName(QStringLiteral("primaryAction")); + connect(save, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(save); + layout->addLayout(buttons); } namespace { class SaveReportFileDialog : public QFileDialog { -public: - explicit SaveReportFileDialog(QWidget* parent) - : QFileDialog(parent, tr("Save Log & System Info"), QStringLiteral("pxmodrim-report.txt"), - tr("Text Files (*.txt);;All Files (*)")) { - setAcceptMode(QFileDialog::AcceptSave); - } + public: + explicit SaveReportFileDialog(QWidget* parent) + : QFileDialog(parent, tr("Save Log & System Info"), + QStringLiteral("pxmodrim-report.txt"), + tr("Text Files (*.txt);;All Files (*)")) { + setAcceptMode(QFileDialog::AcceptSave); + } }; QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { - auto [result, dialog] = co_await awaitDialog(parent); - const QStringList files = dialog->selectedFiles(); - if (result != QDialog::Accepted || files.isEmpty()) co_return; - const QString path = files.first(); - const QString error = co_await providers::runOnPool(nullptr, - [path, report = std::move(report)] { - QSaveFile file(path); - if (!file.open(QIODevice::WriteOnly)) return file.errorString(); - const QByteArray bytes = report.toUtf8(); - if (file.write(bytes) != bytes.size() || !file.commit()) return file.errorString(); - return QString{}; - }); - if (!error.isEmpty()) toast.error(QObject::tr("Failed to save file: %1").arg(error), 5000); - else toast.success(QObject::tr("Report saved to %1").arg(QFileInfo(path).fileName()), 3000); + auto [result, dialog] = co_await awaitDialog(parent); + const QStringList files = dialog->selectedFiles(); + if (result != QDialog::Accepted || files.isEmpty()) co_return; + const QString path = files.first(); + const QString error = co_await providers::runOnPool( + nullptr, [path, report = std::move(report)] { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) return file.errorString(); + const QByteArray bytes = report.toUtf8(); + if (file.write(bytes) != bytes.size() || !file.commit()) + return file.errorString(); + return QString{}; + }); + if (!error.isEmpty()) + toast.error(QObject::tr("Failed to save file: %1").arg(error), 5000); + else + toast.success( + QObject::tr("Report saved to %1").arg(QFileInfo(path).fileName()), + 3000); } } // namespace -QCoro::Task> handleUploadReport(QWidget* parent, CoreContext& ctx, - Notifier& toast) { - QPointer owner(parent); - auto [choice, dialog] = co_await awaitDialog(parent); - Q_UNUSED(dialog); - if (choice == static_cast(UploadChoice::kCancel)) co_return std::nullopt; - support::ReportSource source{ctx.gameVersion(), ctx.config().paths, - ctx.allMods(), ctx.activeUuids()}; - const QString report = co_await providers::runOnPool(nullptr, - [source = std::move(source)] { return support::buildReport(source); }); - if (parent && !owner) co_return std::nullopt; - if (choice == static_cast(UploadChoice::kSaveToFile)) { - co_await saveReport(owner, report, toast); - co_return std::nullopt; - } - toast.info(QObject::tr("Uploading log & system info…"), 2000); - QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); - if (endpoint.isEmpty()) endpoint = QString(support::kDefaultUploadEndpoint); - support::PasteUploader uploader(ctx.network(), std::move(endpoint)); - auto uploaded = co_await uploader.upload(report); - if (parent && !owner) co_return std::nullopt; - if (!uploaded) { - auto [result, failed] = co_await awaitDialog(uploaded.error().message, owner.data()); - Q_UNUSED(failed); - if (result == QDialog::Accepted) co_await saveReport(owner, report, toast); - co_return std::nullopt; - } - QGuiApplication::clipboard()->setText(*uploaded); - toast.success(QObject::tr("Link copied: %1").arg(*uploaded), 5000); - co_return *uploaded; +QCoro::Task> handleUploadReport(QWidget* parent, + CoreContext& ctx, + Notifier& toast) { + QPointer owner(parent); + auto [choice, dialog] = co_await awaitDialog(parent); + Q_UNUSED(dialog); + if (choice == static_cast(UploadChoice::kCancel)) co_return std::nullopt; + support::ReportSource source{ctx.gameVersion(), ctx.config().paths, + ctx.allMods(), ctx.activeUuids()}; + const QString report = co_await providers::runOnPool( + nullptr, + [source = std::move(source)] { return support::buildReport(source); }); + if (parent && !owner) co_return std::nullopt; + if (choice == static_cast(UploadChoice::kSaveToFile)) { + co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + toast.info(QObject::tr("Uploading log & system info…"), 2000); + QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); + if (endpoint.isEmpty()) endpoint = QString(support::kDefaultUploadEndpoint); + support::PasteUploader uploader(ctx.network(), std::move(endpoint)); + auto uploaded = co_await uploader.upload(report); + if (parent && !owner) co_return std::nullopt; + if (!uploaded) { + auto [result, failed] = co_await awaitDialog( + uploaded.error().message, owner.data()); + Q_UNUSED(failed); + if (result == QDialog::Accepted) co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + QGuiApplication::clipboard()->setText(*uploaded); + toast.success(QObject::tr("Link copied: %1").arg(*uploaded), 5000); + co_return *uploaded; } } // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.hpp b/src/ui/panels/upload_report_dialog.hpp index 0c87bf9..bc37054 100644 --- a/src/ui/panels/upload_report_dialog.hpp +++ b/src/ui/panels/upload_report_dialog.hpp @@ -1,21 +1,30 @@ #pragma once +#include #include + #include -#include -namespace px::modrim { class CoreContext; } +namespace px::modrim { +class CoreContext; +} namespace px::modrim { class Notifier; -enum class UploadChoice { kCancel = QDialog::Rejected, kUpload = QDialog::Accepted, kSaveToFile = 2 }; +enum class UploadChoice { + kCancel = QDialog::Rejected, + kUpload = QDialog::Accepted, + kSaveToFile = 2 +}; class UploadConfirmDialog : public QDialog { -public: - explicit UploadConfirmDialog(QWidget* parent = nullptr); + public: + explicit UploadConfirmDialog(QWidget* parent = nullptr); }; class UploadFailedDialog : public QDialog { -public: - explicit UploadFailedDialog(const QString& errorMessage, QWidget* parent = nullptr); + public: + explicit UploadFailedDialog(const QString& errorMessage, + QWidget* parent = nullptr); }; -QCoro::Task> handleUploadReport(QWidget* parent, CoreContext& ctx, - Notifier& toast); +QCoro::Task> handleUploadReport(QWidget* parent, + CoreContext& ctx, + Notifier& toast); } // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp index 5353f82..8dd6e96 100644 --- a/src/ui/plugins/downloads/model.cpp +++ b/src/ui/plugins/downloads/model.cpp @@ -3,200 +3,221 @@ namespace px::modrim { namespace { bool isActive(const QString& state) { - return state == QLatin1String("queued") || state == QLatin1String("checking") || state == QLatin1String("downloading"); + return state == QLatin1String("queued") || + state == QLatin1String("checking") || + state == QLatin1String("downloading"); } QString finalState(qint64 total) { - return total > 0 ? QStringLiteral("updated") : QStringLiteral("unchanged"); -} + return total > 0 ? QStringLiteral("updated") : QStringLiteral("unchanged"); } +} // namespace DownloadsModel::DownloadsModel(QObject* parent) : QAbstractListModel(parent), m_timer(this), m_speedTimer(this) { - m_timer.setSingleShot(true); - m_timer.setInterval(150); - connect(&m_timer, &QTimer::timeout, this, &DownloadsModel::flush); - m_speedTimer.setInterval(1000); - connect(&m_speedTimer, &QTimer::timeout, this, &DownloadsModel::sampleSpeed); -} - -void DownloadsModel::begin(const QStringList& ids, const QHash& titles) { - m_timer.stop(); - m_dirty.clear(); - m_bytes = m_sampledBytes = 0; - m_speed = 0; - m_history.clear(); - emit speedChanged(); - setPhase({}); - beginResetModel(); - m_rows.clear(); - m_byId.clear(); - m_rows.reserve(ids.size()); - for (const auto& id : ids) { - m_byId.insert(id, m_rows.size()); - m_rows.append(Row{id, titles.value(id, id)}); - } - recount(); - select(); - endResetModel(); - emit summaryChanged(); + m_timer.setSingleShot(true); + m_timer.setInterval(150); + connect(&m_timer, &QTimer::timeout, this, &DownloadsModel::flush); + m_speedTimer.setInterval(1000); + connect(&m_speedTimer, &QTimer::timeout, this, &DownloadsModel::sampleSpeed); +} + +void DownloadsModel::begin(const QStringList& ids, + const QHash& titles) { + m_timer.stop(); + m_dirty.clear(); + m_bytes = m_sampledBytes = 0; + m_speed = 0; + m_history.clear(); + emit speedChanged(); + setPhase({}); + beginResetModel(); + m_rows.clear(); + m_byId.clear(); + m_rows.reserve(ids.size()); + for (const auto& id : ids) { + m_byId.insert(id, m_rows.size()); + m_rows.append(Row{id, titles.value(id, id)}); + } + recount(); + select(); + endResetModel(); + emit summaryChanged(); } void DownloadsModel::apply(const DownloadItemStatus& status) { - const auto found = m_byId.constFind(status.modId); - if (found == m_byId.cend()) return; - auto& row = m_rows[*found]; - const auto done = status.status == QLatin1String("error") ? row.bytesDone : status.bytesDone; - m_bytes += done - row.bytesDone; - row.bytesDone = done; - if (status.status == QLatin1String("error")) { - row.state = QStringLiteral("failed"); - row.error = status.error; - row.progress = 0; - } else if (status.status == QLatin1String("success")) { - row.state = finalState(status.bytesTotal); - row.progress = 1; - } else if (status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { - row.state = QStringLiteral("downloading"); - row.progress = static_cast(status.bytesDone) / status.bytesTotal; - } else { - row.state = finalState(status.bytesTotal); - row.progress = 1; - } - mark(row); + const auto found = m_byId.constFind(status.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; + const auto done = status.status == QLatin1String("error") ? row.bytesDone + : status.bytesDone; + m_bytes += done - row.bytesDone; + row.bytesDone = done; + if (status.status == QLatin1String("error")) { + row.state = QStringLiteral("failed"); + row.error = status.error; + row.progress = 0; + } else if (status.status == QLatin1String("success")) { + row.state = finalState(status.bytesTotal); + row.progress = 1; + } else if (status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { + row.state = QStringLiteral("downloading"); + row.progress = static_cast(status.bytesDone) / status.bytesTotal; + } else { + row.state = finalState(status.bytesTotal); + row.progress = 1; + } + mark(row); } void DownloadsModel::setTitle(const DownloadItemTitle& item) { - const auto found = m_byId.constFind(item.modId); - if (found == m_byId.cend()) return; - auto& row = m_rows[*found]; - if (row.title == row.id) row.title = item.title; - if (row.state == QLatin1String("queued")) row.state = QStringLiteral("checking"); - mark(row); + const auto found = m_byId.constFind(item.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; + if (row.title == row.id) row.title = item.title; + if (row.state == QLatin1String("queued")) + row.state = QStringLiteral("checking"); + mark(row); } void DownloadsModel::setPhase(const QString& phase) { - if (m_phase == phase) return; - m_phase = phase; - emit phaseChanged(); + if (m_phase == phase) return; + m_phase = phase; + emit phaseChanged(); } void DownloadsModel::finish(const DownloadResult&) { - for (auto& row : m_rows) { - if (isActive(row.state)) { - row.state = QStringLiteral("cancelled"); - row.progress = 0; - m_dirty.insert(row.id); - } + for (auto& row : m_rows) { + if (isActive(row.state)) { + row.state = QStringLiteral("cancelled"); + row.progress = 0; + m_dirty.insert(row.id); } - m_timer.stop(); - flush(); + } + m_timer.stop(); + flush(); } void DownloadsModel::setBusy(bool busy) { - if (m_busy == busy) return; - m_busy = busy; - if (busy) { - m_sampledBytes = m_bytes; - m_speedTimer.start(); - } else { - m_speedTimer.stop(); - m_speed = 0; - emit speedChanged(); - } - emit summaryChanged(); + if (m_busy == busy) return; + m_busy = busy; + if (busy) { + m_sampledBytes = m_bytes; + m_speedTimer.start(); + } else { + m_speedTimer.stop(); + m_speed = 0; + emit speedChanged(); + } + emit summaryChanged(); } QStringList DownloadsModel::failedIds() const { - QStringList ids; - for (const auto& row : m_rows) { - if (row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled")) ids.append(row.id); - } - return ids; + QStringList ids; + for (const auto& row : m_rows) { + if (row.state == QLatin1String("failed") || + row.state == QLatin1String("cancelled")) + ids.append(row.id); + } + return ids; } void DownloadsModel::clear() { - if (!m_busy) begin({}, {}); + if (!m_busy) begin({}, {}); } void DownloadsModel::setFilter(const QString& name) { - if (m_filter == name) return; - m_filter = name; - beginResetModel(); - select(); - endResetModel(); - emit summaryChanged(); + if (m_filter == name) return; + m_filter = name; + beginResetModel(); + select(); + endResetModel(); + emit summaryChanged(); } void DownloadsModel::sampleSpeed() { - m_speed = static_cast(m_bytes - m_sampledBytes); - m_sampledBytes = m_bytes; - if (m_history.size() == kHistoryLength) m_history.removeFirst(); - m_history.append(m_speed); - emit speedChanged(); + m_speed = static_cast(m_bytes - m_sampledBytes); + m_sampledBytes = m_bytes; + if (m_history.size() == kHistoryLength) m_history.removeFirst(); + m_history.append(m_speed); + emit speedChanged(); } void DownloadsModel::mark(const Row& row) { - m_dirty.insert(row.id); - if (!m_timer.isActive()) m_timer.start(); + m_dirty.insert(row.id); + if (!m_timer.isActive()) m_timer.start(); } bool DownloadsModel::matches(const Row& row) const { - if (m_filter == QLatin1String("active")) return isActive(row.state); - if (m_filter == QLatin1String("updated")) return row.state == QLatin1String("updated"); - if (m_filter == QLatin1String("failed")) return row.state == QLatin1String("failed") || row.state == QLatin1String("cancelled"); - return true; + if (m_filter == QLatin1String("active")) return isActive(row.state); + if (m_filter == QLatin1String("updated")) + return row.state == QLatin1String("updated"); + if (m_filter == QLatin1String("failed")) + return row.state == QLatin1String("failed") || + row.state == QLatin1String("cancelled"); + return true; } void DownloadsModel::select() { - m_visible.clear(); - for (qsizetype pos = 0; pos < m_rows.size(); ++pos) { - if (matches(m_rows[pos])) m_visible.append(pos); - } + m_visible.clear(); + for (qsizetype pos = 0; pos < m_rows.size(); ++pos) { + if (matches(m_rows[pos])) m_visible.append(pos); + } } void DownloadsModel::recount() { - m_counts.clear(); - for (const auto& row : m_rows) ++m_counts[row.state]; + m_counts.clear(); + for (const auto& row : m_rows) ++m_counts[row.state]; } void DownloadsModel::flush() { - if (m_dirty.isEmpty()) return; - recount(); - if (m_filter == QLatin1String("all")) { - for (qsizetype pos = 0; pos < m_visible.size(); ++pos) { - if (m_dirty.contains(m_rows[m_visible[pos]].id)) { - const auto idx = index(static_cast(pos)); - emit dataChanged(idx, idx); - } - } - } else { - beginResetModel(); - select(); - endResetModel(); + if (m_dirty.isEmpty()) return; + recount(); + if (m_filter == QLatin1String("all")) { + for (qsizetype pos = 0; pos < m_visible.size(); ++pos) { + if (m_dirty.contains(m_rows[m_visible[pos]].id)) { + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx); + } } - m_dirty.clear(); - emit summaryChanged(); + } else { + beginResetModel(); + select(); + endResetModel(); + } + m_dirty.clear(); + emit summaryChanged(); } int DownloadsModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(m_visible.size()); + return parent.isValid() ? 0 : static_cast(m_visible.size()); } QVariant DownloadsModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_visible.size()) return {}; - const auto& row = m_rows[m_visible[index.row()]]; - switch (role) { - case kIdRole: return row.id; - case kTitleRole: return row.title; - case kStateRole: return row.state; - case kProgressRole: return row.progress; - case kErrorRole: return row.error; - default: return {}; - } + if (!index.isValid() || index.row() < 0 || index.row() >= m_visible.size()) + return {}; + const auto& row = m_rows[m_visible[index.row()]]; + switch (role) { + case kIdRole: + return row.id; + case kTitleRole: + return row.title; + case kStateRole: + return row.state; + case kProgressRole: + return row.progress; + case kErrorRole: + return row.error; + default: + return {}; + } } QHash DownloadsModel::roleNames() const { - return {{kIdRole, "id"}, {kTitleRole, "title"}, {kStateRole, "state"}, {kProgressRole, "progress"}, {kErrorRole, "error"}}; + return {{kIdRole, "id"}, + {kTitleRole, "title"}, + {kStateRole, "state"}, + {kProgressRole, "progress"}, + {kErrorRole, "error"}}; } } // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.hpp b/src/ui/plugins/downloads/model.hpp index d48d591..7c46411 100644 --- a/src/ui/plugins/downloads/model.hpp +++ b/src/ui/plugins/downloads/model.hpp @@ -11,88 +11,104 @@ namespace px::modrim { class DownloadsModel final : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int total READ total NOTIFY summaryChanged) - Q_PROPERTY(int finished READ finished NOTIFY summaryChanged) - Q_PROPERTY(int updated READ updated NOTIFY summaryChanged) - Q_PROPERTY(int unchanged READ unchanged NOTIFY summaryChanged) - Q_PROPERTY(int failed READ failed NOTIFY summaryChanged) - Q_PROPERTY(int active READ active NOTIFY summaryChanged) - Q_PROPERTY(int resolved READ resolved NOTIFY summaryChanged) - Q_PROPERTY(bool busy READ busy NOTIFY summaryChanged) - Q_PROPERTY(QString filter READ filter NOTIFY summaryChanged) - Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged) - Q_PROPERTY(double speed READ speed NOTIFY speedChanged) - Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speedChanged) - Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speedChanged) + Q_OBJECT + Q_PROPERTY(int total READ total NOTIFY summaryChanged) + Q_PROPERTY(int finished READ finished NOTIFY summaryChanged) + Q_PROPERTY(int updated READ updated NOTIFY summaryChanged) + Q_PROPERTY(int unchanged READ unchanged NOTIFY summaryChanged) + Q_PROPERTY(int failed READ failed NOTIFY summaryChanged) + Q_PROPERTY(int active READ active NOTIFY summaryChanged) + Q_PROPERTY(int resolved READ resolved NOTIFY summaryChanged) + Q_PROPERTY(bool busy READ busy NOTIFY summaryChanged) + Q_PROPERTY(QString filter READ filter NOTIFY summaryChanged) + Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged) + Q_PROPERTY(double speed READ speed NOTIFY speedChanged) + Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speedChanged) + Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speedChanged) -public: - enum Role { kIdRole = Qt::UserRole + 1, kTitleRole, kStateRole, kProgressRole, kErrorRole }; - static constexpr qsizetype kHistoryLength = 90; + public: + enum Role { + kIdRole = Qt::UserRole + 1, + kTitleRole, + kStateRole, + kProgressRole, + kErrorRole + }; + static constexpr qsizetype kHistoryLength = 90; - explicit DownloadsModel(QObject* parent = nullptr); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; + explicit DownloadsModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; - void begin(const QStringList& ids, const QHash& titles); - void apply(const DownloadItemStatus& status); - void setTitle(const DownloadItemTitle& item); - void setPhase(const QString& phase); - void finish(const DownloadResult& result); - void setBusy(bool busy); - QStringList failedIds() const; - void flush(); - Q_INVOKABLE void clear(); - Q_INVOKABLE void setFilter(const QString& name); + void begin(const QStringList& ids, const QHash& titles); + void apply(const DownloadItemStatus& status); + void setTitle(const DownloadItemTitle& item); + void setPhase(const QString& phase); + void finish(const DownloadResult& result); + void setBusy(bool busy); + QStringList failedIds() const; + void flush(); + Q_INVOKABLE void clear(); + Q_INVOKABLE void setFilter(const QString& name); - int total() const { return static_cast(m_rows.size()); } - int finished() const { return updated() + unchanged() + failed(); } - int updated() const { return m_counts.value(QStringLiteral("updated")); } - int unchanged() const { return m_counts.value(QStringLiteral("unchanged")); } - int failed() const { return m_counts.value(QStringLiteral("failed")) + m_counts.value(QStringLiteral("cancelled")); } - int active() const { return m_counts.value(QStringLiteral("queued")) + m_counts.value(QStringLiteral("checking")) + m_counts.value(QStringLiteral("downloading")); } - int resolved() const { return total() - m_counts.value(QStringLiteral("queued")); } - bool busy() const { return m_busy; } - QString filter() const { return m_filter; } - QString phase() const { return m_phase; } - double speed() const { return m_speed; } - double bytesDone() const { return static_cast(m_bytes); } - QVariantList speedHistory() const { return m_history; } + int total() const { return static_cast(m_rows.size()); } + int finished() const { return updated() + unchanged() + failed(); } + int updated() const { return m_counts.value(QStringLiteral("updated")); } + int unchanged() const { return m_counts.value(QStringLiteral("unchanged")); } + int failed() const { + return m_counts.value(QStringLiteral("failed")) + + m_counts.value(QStringLiteral("cancelled")); + } + int active() const { + return m_counts.value(QStringLiteral("queued")) + + m_counts.value(QStringLiteral("checking")) + + m_counts.value(QStringLiteral("downloading")); + } + int resolved() const { + return total() - m_counts.value(QStringLiteral("queued")); + } + bool busy() const { return m_busy; } + QString filter() const { return m_filter; } + QString phase() const { return m_phase; } + double speed() const { return m_speed; } + double bytesDone() const { return static_cast(m_bytes); } + QVariantList speedHistory() const { return m_history; } -signals: - void summaryChanged(); - void speedChanged(); - void phaseChanged(); + signals: + void summaryChanged(); + void speedChanged(); + void phaseChanged(); -private: - struct Row { - QString id; - QString title; - QString state = QStringLiteral("queued"); - double progress = 0; - QString error; - qint64 bytesDone = 0; - }; - void mark(const Row& row); - bool matches(const Row& row) const; - void select(); - void recount(); - void sampleSpeed(); - QList m_rows; - QHash m_byId; - QList m_visible; - QString m_filter = QStringLiteral("all"); - QString m_phase; - bool m_busy = false; - QHash m_counts; - QSet m_dirty; - QTimer m_timer; - QTimer m_speedTimer; - qint64 m_bytes = 0; - qint64 m_sampledBytes = 0; - double m_speed = 0; - QVariantList m_history; + private: + struct Row { + QString id; + QString title; + QString state = QStringLiteral("queued"); + double progress = 0; + QString error; + qint64 bytesDone = 0; + }; + void mark(const Row& row); + bool matches(const Row& row) const; + void select(); + void recount(); + void sampleSpeed(); + QList m_rows; + QHash m_byId; + QList m_visible; + QString m_filter = QStringLiteral("all"); + QString m_phase; + bool m_busy = false; + QHash m_counts; + QSet m_dirty; + QTimer m_timer; + QTimer m_speedTimer; + qint64 m_bytes = 0; + qint64 m_sampledBytes = 0; + double m_speed = 0; + QVariantList m_history; }; } // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.cpp b/src/ui/plugins/downloads/plugin.cpp index aa44b3d..319c24b 100644 --- a/src/ui/plugins/downloads/plugin.cpp +++ b/src/ui/plugins/downloads/plugin.cpp @@ -4,7 +4,10 @@ namespace px::modrim { -DownloadsUiPlugin::DownloadsUiPlugin() : UiPlugin(QStringLiteral("downloads_ui"), {QStringLiteral("downloads")}) {} -void DownloadsUiPlugin::setup(AppContext& ctx) { ctx.addRailView(); } +DownloadsUiPlugin::DownloadsUiPlugin() + : UiPlugin(QStringLiteral("downloads_ui"), {QStringLiteral("downloads")}) {} +void DownloadsUiPlugin::setup(AppContext& ctx) { + ctx.addRailView(); +} } // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.hpp b/src/ui/plugins/downloads/plugin.hpp index b2fd038..af993b2 100644 --- a/src/ui/plugins/downloads/plugin.hpp +++ b/src/ui/plugins/downloads/plugin.hpp @@ -5,9 +5,9 @@ namespace px::modrim { class DownloadsUiPlugin final : public UiPlugin { -public: - DownloadsUiPlugin(); - void setup(AppContext& ctx) override; + public: + DownloadsUiPlugin(); + void setup(AppContext& ctx) override; }; } // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp index 94d41fe..4611df1 100644 --- a/src/ui/plugins/downloads/view.cpp +++ b/src/ui/plugins/downloads/view.cpp @@ -2,43 +2,54 @@ #include #include + #include #include "ui/theme/palette.hpp" namespace px::modrim { -DownloadsViewPanel::DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx, engine, parent, app), m_downloads(ctx.downloads()), m_model(new DownloadsModel(this)) { - auto* qml = new QQuickWidget(engine, this); - qml->setObjectName(QStringLiteral("downloadsView")); - qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); - qml->rootContext()->setContextProperty(QStringLiteral("downloadsPanel"), this); - qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), m_model); - qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Downloads.qml"))); - m_root->addWidget(qml, 1); - m_model->setBusy(m_downloads.isDownloading()); - m_batchId = m_downloads.batchStarted.connect([this](const QStringList& ids) { - m_model->begin(ids, m_downloads.namesById(m_ctx.allMods().values())); - }); - m_statusId = m_downloads.downloadItemStatusChanged.connect([this](const DownloadItemStatus& status) { m_model->apply(status); }); - m_titleId = m_downloads.downloadItemTitled.connect([this](const DownloadItemTitle& item) { m_model->setTitle(item); }); - m_phaseId = m_downloads.downloadPhaseChanged.connect([this](const QString& phase) { m_model->setPhase(phase); }); - m_finishedId = m_downloads.downloadFinished.connect([this](const DownloadResult& result) { m_model->finish(result); }); - m_busyId = m_downloads.busyChanged.connect([this](bool busy) { m_model->setBusy(busy); }); +DownloadsViewPanel::DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), + m_downloads(ctx.downloads()), + m_model(new DownloadsModel(this)) { + auto* qml = new QQuickWidget(engine, this); + qml->setObjectName(QStringLiteral("downloadsView")); + qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsPanel"), + this); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), + m_model); + qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Downloads.qml"))); + m_root->addWidget(qml, 1); + m_model->setBusy(m_downloads.isDownloading()); + m_batchId = m_downloads.batchStarted.connect([this](const QStringList& ids) { + m_model->begin(ids, m_downloads.namesById(m_ctx.allMods().values())); + }); + m_statusId = m_downloads.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& status) { m_model->apply(status); }); + m_titleId = m_downloads.downloadItemTitled.connect( + [this](const DownloadItemTitle& item) { m_model->setTitle(item); }); + m_phaseId = m_downloads.downloadPhaseChanged.connect( + [this](const QString& phase) { m_model->setPhase(phase); }); + m_finishedId = m_downloads.downloadFinished.connect( + [this](const DownloadResult& result) { m_model->finish(result); }); + m_busyId = m_downloads.busyChanged.connect( + [this](bool busy) { m_model->setBusy(busy); }); } DownloadsViewPanel::~DownloadsViewPanel() { - // The QML bindings read downloadsModel, which is a child of this panel and would otherwise be - // destroyed before the view that binds to it. - delete findChild(QStringLiteral("downloadsView")); - m_downloads.batchStarted.disconnect(m_batchId); - m_downloads.downloadItemStatusChanged.disconnect(m_statusId); - m_downloads.downloadItemTitled.disconnect(m_titleId); - m_downloads.downloadPhaseChanged.disconnect(m_phaseId); - m_downloads.downloadFinished.disconnect(m_finishedId); - m_downloads.busyChanged.disconnect(m_busyId); + // The QML bindings read downloadsModel, which is a child of this panel and + // would otherwise be destroyed before the view that binds to it. + delete findChild(QStringLiteral("downloadsView")); + m_downloads.batchStarted.disconnect(m_batchId); + m_downloads.downloadItemStatusChanged.disconnect(m_statusId); + m_downloads.downloadItemTitled.disconnect(m_titleId); + m_downloads.downloadPhaseChanged.disconnect(m_phaseId); + m_downloads.downloadFinished.disconnect(m_finishedId); + m_downloads.busyChanged.disconnect(m_busyId); } void DownloadsViewPanel::stop() { m_downloads.cancel(); } @@ -46,10 +57,12 @@ void DownloadsViewPanel::stop() { m_downloads.cancel(); } QCoro::QmlTask DownloadsViewPanel::retryFailed() { return retryFailedAsync(); } QCoro::Task DownloadsViewPanel::retryFailedAsync() { - auto ids = m_model->failedIds(); - if (ids.isEmpty()) co_return; - auto result = co_await m_downloads.downloadMods(std::move(ids)); - if (!result) spdlog::warn("downloads retry not started: {}", result.error().message.toStdString()); + auto ids = m_model->failedIds(); + if (ids.isEmpty()) co_return; + auto result = co_await m_downloads.downloadMods(std::move(ids)); + if (!result) + spdlog::warn("downloads retry not started: {}", + result.error().message.toStdString()); } } // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.hpp b/src/ui/plugins/downloads/view.hpp index 25b7c29..b3fb8a9 100644 --- a/src/ui/plugins/downloads/view.hpp +++ b/src/ui/plugins/downloads/view.hpp @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include "core/downloads/manager.hpp" #include "ui/plugins/downloads/model.hpp" @@ -10,24 +10,27 @@ namespace px::modrim { class DownloadsViewPanel final : public BaseViewPanel { - Q_OBJECT -public: - static constexpr RailViewMeta kRailMeta{QLatin1StringView("downloads"), QLatin1StringView("download"), QLatin1StringView("Downloads"), true}; - DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - ~DownloadsViewPanel() override; - Q_INVOKABLE void stop(); - Q_INVOKABLE QCoro::QmlTask retryFailed(); + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("downloads"), + QLatin1StringView("download"), + QLatin1StringView("Downloads"), true}; + DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + ~DownloadsViewPanel() override; + Q_INVOKABLE void stop(); + Q_INVOKABLE QCoro::QmlTask retryFailed(); -private: - QCoro::Task retryFailedAsync(); - DownloadManager& m_downloads; - DownloadsModel* m_model; - Event::Id m_batchId; - Event::Id m_statusId; - Event::Id m_titleId; - Event::Id m_phaseId; - Event::Id m_finishedId; - Event::Id m_busyId; + private: + QCoro::Task retryFailedAsync(); + DownloadManager& m_downloads; + DownloadsModel* m_model; + Event::Id m_batchId; + Event::Id m_statusId; + Event::Id m_titleId; + Event::Id m_phaseId; + Event::Id m_finishedId; + Event::Id m_busyId; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp index c87bbb7..708e1dc 100644 --- a/src/ui/plugins/organizer/dialogs.cpp +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -19,126 +19,134 @@ namespace { using px::modrim::organizer::FolderNode; using px::modrim::organizer::kRootId; -QPushButton* makeButton(const QString& text, QWidget* parent, const QString& objectName) { - auto* button = new QPushButton(text, parent); - button->setObjectName(objectName); - button->setCursor(Qt::PointingHandCursor); - return button; +QPushButton* makeButton(const QString& text, QWidget* parent, + const QString& objectName) { + auto* button = new QPushButton(text, parent); + button->setObjectName(objectName); + button->setCursor(Qt::PointingHandCursor); + return button; } void addButtonRow(QVBoxLayout* layout, QWidget* cancel, QWidget* confirm) { - auto* row = new QHBoxLayout; - row->addStretch(1); - row->addWidget(cancel); - row->addWidget(confirm); - layout->addLayout(row); + auto* row = new QHBoxLayout; + row->addStretch(1); + row->addWidget(cancel); + row->addWidget(confirm); + layout->addLayout(row); } -void addFolder(QTreeWidgetItem* parent, const FolderNode& node, const QSet& allowed) { - auto* item = new QTreeWidgetItem(parent, QStringList{node.folder.name}); - item->setData(0, Qt::UserRole, node.folder.id); - if (!allowed.contains(node.folder.id)) { - item->setDisabled(true); - } - for (const FolderNode& child : node.children) { - addFolder(item, child, allowed); - } +void addFolder(QTreeWidgetItem* parent, const FolderNode& node, + const QSet& allowed) { + auto* item = new QTreeWidgetItem(parent, QStringList{node.folder.name}); + item->setData(0, Qt::UserRole, node.folder.id); + if (!allowed.contains(node.folder.id)) { + item->setDisabled(true); + } + for (const FolderNode& child : node.children) { + addFolder(item, child, allowed); + } } } // namespace -FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, QWidget* parent, - const QString& verb) +FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, + QWidget* parent, const QString& verb) : QDialog(parent) { - setWindowTitle(title); - setMinimumWidth(360); - auto* layout = new QVBoxLayout(this); - layout->addWidget(new QLabel(QStringLiteral("Folder name"), this)); - m_edit = new QLineEdit(initial, this); - layout->addWidget(m_edit); - - const QString primaryText = - verb.isEmpty() ? title.section(u' ', 0, 0, QString::SectionSkipEmpty) : verb; - auto* ok = makeButton(primaryText, this, QStringLiteral("primaryAction")); - ok->setDefault(true); - auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); - connect(ok, &QPushButton::clicked, this, &QDialog::accept); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - addButtonRow(layout, cancel, ok); - - auto validate = [ok](const QString& text) { ok->setEnabled(!text.trimmed().isEmpty()); }; - connect(m_edit, &QLineEdit::textChanged, this, validate); - connect(m_edit, &QLineEdit::returnPressed, ok, &QPushButton::click); - validate(initial); - m_edit->selectAll(); + setWindowTitle(title); + setMinimumWidth(360); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Folder name"), this)); + m_edit = new QLineEdit(initial, this); + layout->addWidget(m_edit); + + const QString primaryText = + verb.isEmpty() ? title.section(u' ', 0, 0, QString::SectionSkipEmpty) + : verb; + auto* ok = makeButton(primaryText, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + auto* cancel = + makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + connect(ok, &QPushButton::clicked, this, &QDialog::accept); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); + + auto validate = [ok](const QString& text) { + ok->setEnabled(!text.trimmed().isEmpty()); + }; + connect(m_edit, &QLineEdit::textChanged, this, validate); + connect(m_edit, &QLineEdit::returnPressed, ok, &QPushButton::click); + validate(initial); + m_edit->selectAll(); } -QString FolderNameDialog::textValue() const { - return m_edit->text().trimmed(); -} +QString FolderNameDialog::textValue() const { return m_edit->text().trimmed(); } DeleteFolderDialog::DeleteFolderDialog(const QString& name, QWidget* parent) : QMessageBox(parent) { - setIcon(QMessageBox::Warning); - setWindowTitle(QStringLiteral("Delete Folder")); - setText(QStringLiteral("Delete ‘%1’ and its subfolders?").arg(name)); - setInformativeText( - QStringLiteral("Their mods become ungrouped. Load order is not affected.")); - setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); - setDefaultButton(QMessageBox::Cancel); - setEscapeButton(QMessageBox::Cancel); - if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { - yes->setText(QStringLiteral("Delete")); - yes->setObjectName(QStringLiteral("primaryAction")); - } + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Delete Folder")); + setText(QStringLiteral("Delete ‘%1’ and its subfolders?").arg(name)); + setInformativeText(QStringLiteral( + "Their mods become ungrouped. Load order is not affected.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + setEscapeButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Delete")); + yes->setObjectName(QStringLiteral("primaryAction")); + } } -FolderPickerDialog::FolderPickerDialog(const FolderNode& tree, const QString& title, - const QSet& allowed, QWidget* parent, - const QString& verb) +FolderPickerDialog::FolderPickerDialog(const FolderNode& tree, + const QString& title, + const QSet& allowed, + QWidget* parent, const QString& verb) : QDialog(parent) { - setWindowTitle(title); - resize(380, 450); - auto* layout = new QVBoxLayout(this); - layout->addWidget(new QLabel(QStringLiteral("Destination folder"), this)); - m_tree = new QTreeWidget(this); - m_tree->setHeaderHidden(true); - m_tree->setRootIsDecorated(true); - layout->addWidget(m_tree); - - auto* top = new QTreeWidgetItem(m_tree, QStringList{QStringLiteral("Ungrouped (top level)")}); - top->setData(0, Qt::UserRole, kRootId); - if (!allowed.contains(kRootId)) { - top->setDisabled(true); - } - for (const FolderNode& folder : tree.children) { - addFolder(top, folder, allowed); + setWindowTitle(title); + resize(380, 450); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Destination folder"), this)); + m_tree = new QTreeWidget(this); + m_tree->setHeaderHidden(true); + m_tree->setRootIsDecorated(true); + layout->addWidget(m_tree); + + auto* top = new QTreeWidgetItem( + m_tree, QStringList{QStringLiteral("Ungrouped (top level)")}); + top->setData(0, Qt::UserRole, kRootId); + if (!allowed.contains(kRootId)) { + top->setDisabled(true); + } + for (const FolderNode& folder : tree.children) { + addFolder(top, folder, allowed); + } + m_tree->expandAll(); + + auto* ok = makeButton(verb, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + ok->setEnabled(false); + auto* cancel = + makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + + auto acceptValid = [this, ok] { + if (ok->isEnabled()) { + accept(); } - m_tree->expandAll(); - - auto* ok = makeButton(verb, this, QStringLiteral("primaryAction")); - ok->setDefault(true); - ok->setEnabled(false); - auto* cancel = makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); - - auto acceptValid = [this, ok] { - if (ok->isEnabled()) { - accept(); - } - }; - connect(m_tree, &QTreeWidget::currentItemChanged, this, [ok](QTreeWidgetItem* item) { - ok->setEnabled(item != nullptr && !item->isDisabled()); - }); - connect(m_tree, &QTreeWidget::itemDoubleClicked, this, acceptValid); - connect(ok, &QPushButton::clicked, this, acceptValid); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - addButtonRow(layout, cancel, ok); + }; + connect(m_tree, &QTreeWidget::currentItemChanged, this, + [ok](QTreeWidgetItem* item) { + ok->setEnabled(item != nullptr && !item->isDisabled()); + }); + connect(m_tree, &QTreeWidget::itemDoubleClicked, this, acceptValid); + connect(ok, &QPushButton::clicked, this, acceptValid); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); } qint64 FolderPickerDialog::folderId() const { - QTreeWidgetItem* item = m_tree->currentItem(); - Q_ASSERT(item != nullptr); - return item->data(0, Qt::UserRole).toLongLong(); + QTreeWidgetItem* item = m_tree->currentItem(); + Q_ASSERT(item != nullptr); + return item->data(0, Qt::UserRole).toLongLong(); } } // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.hpp b/src/ui/plugins/organizer/dialogs.hpp index f54262c..82e21f3 100644 --- a/src/ui/plugins/organizer/dialogs.hpp +++ b/src/ui/plugins/organizer/dialogs.hpp @@ -15,40 +15,44 @@ struct FolderNode; namespace px::modrim { -// Asks for a folder name. Enter confirms; the confirm button is disabled while the -// name is blank. Without `verb`, the first word of `title` names the confirm button. +// Asks for a folder name. Enter confirms; the confirm button is disabled while +// the name is blank. Without `verb`, the first word of `title` names the +// confirm button. class FolderNameDialog : public QDialog { -public: - FolderNameDialog(const QString& title, const QString& initial, QWidget* parent, - const QString& verb = {}); + public: + FolderNameDialog(const QString& title, const QString& initial, + QWidget* parent, const QString& verb = {}); - // The entered name with surrounding whitespace removed. - QString textValue() const; + // The entered name with surrounding whitespace removed. + QString textValue() const; -private: - QLineEdit* m_edit = nullptr; + private: + QLineEdit* m_edit = nullptr; }; -// Confirms deleting a folder. Its mods become ungrouped; load order is unchanged. -// Yes deletes; Cancel and Escape keep the folder. +// Confirms deleting a folder. Its mods become ungrouped; load order is +// unchanged. Yes deletes; Cancel and Escape keep the folder. class DeleteFolderDialog : public QMessageBox { -public: - DeleteFolderDialog(const QString& name, QWidget* parent); + public: + DeleteFolderDialog(const QString& name, QWidget* parent); }; -// Picks a destination folder from `tree`'s children, with a top-level "Ungrouped" -// entry for ROOT_ID. Folders not in `allowed` are shown but cannot be chosen. +// Picks a destination folder from `tree`'s children, with a top-level +// "Ungrouped" entry for ROOT_ID. Folders not in `allowed` are shown but cannot +// be chosen. class FolderPickerDialog : public QDialog { -public: - FolderPickerDialog(const px::modrim::organizer::FolderNode& tree, const QString& title, - const QSet& allowed, QWidget* parent, - const QString& verb = QStringLiteral("Move")); - - // The id of the highlighted folder. Only meaningful once the confirm button is enabled. - qint64 folderId() const; - -private: - QTreeWidget* m_tree = nullptr; + public: + FolderPickerDialog(const px::modrim::organizer::FolderNode& tree, + const QString& title, const QSet& allowed, + QWidget* parent, + const QString& verb = QStringLiteral("Move")); + + // The id of the highlighted folder. Only meaningful once the confirm button + // is enabled. + qint64 folderId() const; + + private: + QTreeWidget* m_tree = nullptr; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp index f0c4b04..4daaa52 100644 --- a/src/ui/plugins/organizer/filter_model.cpp +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -2,68 +2,80 @@ namespace px::modrim { -OrganizerFilterModel::OrganizerFilterModel(QObject* parent) : QAbstractListModel(parent) {} +OrganizerFilterModel::OrganizerFilterModel(QObject* parent) + : QAbstractListModel(parent) {} int OrganizerFilterModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(m_items.size()); + return parent.isValid() ? 0 : static_cast(m_items.size()); } QVariant OrganizerFilterModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; - const auto& entry = m_items[index.row()]; - const bool tag = entry.tagId.has_value() || entry.key.startsWith("tag:"); - switch (role) { - case Qt::DisplayRole: - case kLabelRole: return entry.label; - case kCountRole: return entry.count; - case kKeyRole: return entry.key; - case kSectionRole: - if (tag) return QStringLiteral("Tags"); - if (entry.providerId || entry.key.startsWith("provider:")) return QStringLiteral("Providers"); - return QStringLiteral("Status"); - case kIconRole: - if (tag) return QStringLiteral("tag"); - if (entry.key == "all") return QStringLiteral("grid"); - if (entry.key == "active") return QStringLiteral("check-circle"); - if (entry.key == "inactive") return QStringLiteral("ban"); - if (entry.providerId == "steam" || entry.providerId == "downloaded") return QStringLiteral("steam"); - if (entry.providerId == "core") return QStringLiteral("grid"); - if (entry.providerId == "git") return QStringLiteral("git"); - return QStringLiteral("folder"); - default: return {}; - } + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& entry = m_items[index.row()]; + const bool tag = entry.tagId.has_value() || entry.key.startsWith("tag:"); + switch (role) { + case Qt::DisplayRole: + case kLabelRole: + return entry.label; + case kCountRole: + return entry.count; + case kKeyRole: + return entry.key; + case kSectionRole: + if (tag) return QStringLiteral("Tags"); + if (entry.providerId || entry.key.startsWith("provider:")) + return QStringLiteral("Providers"); + return QStringLiteral("Status"); + case kIconRole: + if (tag) return QStringLiteral("tag"); + if (entry.key == "all") return QStringLiteral("grid"); + if (entry.key == "active") return QStringLiteral("check-circle"); + if (entry.key == "inactive") return QStringLiteral("ban"); + if (entry.providerId == "steam" || entry.providerId == "downloaded") + return QStringLiteral("steam"); + if (entry.providerId == "core") return QStringLiteral("grid"); + if (entry.providerId == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); + default: + return {}; + } } QHash OrganizerFilterModel::roleNames() const { - return {{Qt::DisplayRole, "filterLabel"}, {kCountRole, "count"}, - {kIconRole, "iconName"}, {kSectionRole, "sectionName"}, {kKeyRole, "key"}}; + return {{Qt::DisplayRole, "filterLabel"}, + {kCountRole, "count"}, + {kIconRole, "iconName"}, + {kSectionRole, "sectionName"}, + {kKeyRole, "key"}}; } void OrganizerFilterModel::update(const QList& entries) { - bool sameKeys = entries.size() == m_items.size(); - for (qsizetype i = 0; sameKeys && i < entries.size(); ++i) - sameKeys = entries[i].key == m_items[i].key; - if (!sameKeys) { - beginResetModel(); - m_items = entries; - endResetModel(); - } else { - m_items = entries; - if (!m_items.isEmpty()) - emit dataChanged(index(0), index(static_cast(m_items.size()) - 1), - {kCountRole, kLabelRole, Qt::DisplayRole}); - } + bool sameKeys = entries.size() == m_items.size(); + for (qsizetype i = 0; sameKeys && i < entries.size(); ++i) + sameKeys = entries[i].key == m_items[i].key; + if (!sameKeys) { + beginResetModel(); + m_items = entries; + endResetModel(); + } else { + m_items = entries; + if (!m_items.isEmpty()) + emit dataChanged(index(0), index(static_cast(m_items.size()) - 1), + {kCountRole, kLabelRole, Qt::DisplayRole}); + } } std::optional OrganizerFilterModel::keyAt(int row) const { - if (row < 0 || row >= m_items.size()) return std::nullopt; - return m_items[row].key; + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].key; } organizer::TreeFilter OrganizerFilterModel::forKey(const QString& key) const { - for (const auto& entry : m_items) if (entry.key == key) return entry; - if (!m_items.isEmpty()) return m_items.first(); - return {QStringLiteral("all"), QStringLiteral("All"), 0}; + for (const auto& entry : m_items) + if (entry.key == key) return entry; + if (!m_items.isEmpty()) return m_items.first(); + return {QStringLiteral("all"), QStringLiteral("All"), 0}; } } // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.hpp b/src/ui/plugins/organizer/filter_model.hpp index 693f084..91f7cf3 100644 --- a/src/ui/plugins/organizer/filter_model.hpp +++ b/src/ui/plugins/organizer/filter_model.hpp @@ -7,20 +7,27 @@ namespace px::modrim { class OrganizerFilterModel : public QAbstractListModel { - Q_OBJECT -public: - enum Role { kLabelRole = Qt::UserRole + 1, kCountRole, kIconRole, kSectionRole, kKeyRole }; - Q_ENUM(Role) - explicit OrganizerFilterModel(QObject* parent = nullptr); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - void update(const QList& entries); - std::optional keyAt(int row) const; - organizer::TreeFilter forKey(const QString& key) const; + Q_OBJECT + public: + enum Role { + kLabelRole = Qt::UserRole + 1, + kCountRole, + kIconRole, + kSectionRole, + kKeyRole + }; + Q_ENUM(Role) + explicit OrganizerFilterModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void update(const QList& entries); + std::optional keyAt(int row) const; + organizer::TreeFilter forKey(const QString& key) const; -private: - QList m_items; + private: + QList m_items; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/plugin.hpp b/src/ui/plugins/organizer/plugin.hpp index 1d1b1ba..c121db3 100644 --- a/src/ui/plugins/organizer/plugin.hpp +++ b/src/ui/plugins/organizer/plugin.hpp @@ -6,9 +6,13 @@ namespace px::modrim { class OrganizerUiPlugin final : public UiPlugin { -public: - OrganizerUiPlugin() : UiPlugin(QStringLiteral("organizer_ui"), {QStringLiteral("organizer")}) {} - void setup(AppContext& ctx) override { ctx.addRailView(0); } + public: + OrganizerUiPlugin() + : UiPlugin(QStringLiteral("organizer_ui"), + {QStringLiteral("organizer")}) {} + void setup(AppContext& ctx) override { + ctx.addRailView(0); + } }; -} // namespace px::modrim +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index 6bc3935..b313330 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -10,305 +10,380 @@ namespace { using namespace organizer; QString providerIcon(const QString& id) { - if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); - if (id == "core") return QStringLiteral("grid"); - if (id == "git") return QStringLiteral("git"); - return QStringLiteral("folder"); + if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); + if (id == "core") return QStringLiteral("grid"); + if (id == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); } int checkState(CheckState state) { - switch (state) { - case CheckState::kOn: return Qt::Checked; - case CheckState::kPartial: return Qt::PartiallyChecked; - case CheckState::kOff: return Qt::Unchecked; - } - return Qt::Unchecked; + switch (state) { + case CheckState::kOn: + return Qt::Checked; + case CheckState::kPartial: + return Qt::PartiallyChecked; + case CheckState::kOff: + return Qt::Unchecked; + } + return Qt::Unchecked; } -std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, bool filtering) { - auto top = std::make_unique(); - top->kind = QStringLiteral("root"); - auto addMods = [&](TreeNode* parent, const QList& leaves) { - for (const auto& leaf : leaves) { - auto item = std::make_unique(); - item->kind = QStringLiteral("mod"); - item->key = "m:" + leaf.uuid; - item->leaf = &leaf; - const auto it = mods.constFind(leaf.uuid); - if (it != mods.cend() && *it) { - const auto& mod = **it; - item->providerId = mod.providerId; - item->providerLabel = providerLabel(mod.providerId, modPackageId(mod).value_or(QString{})); - if (const auto* about = dynamic_cast(&mod)) - item->modVersion = about->modVersion; - } - parent->add(std::move(item)); - } - }; - auto addFolder = [&](auto&& self, TreeNode* parent, const FolderNode& folder) -> void { - auto item = std::make_unique(); - item->kind = QStringLiteral("folder"); - item->key = "f:" + QString::number(folder.folder.id); - item->folder = &folder; - TreeNode* ptr = item.get(); - parent->add(std::move(item)); - for (const auto& child : folder.children) self(self, ptr, child); - addMods(ptr, folder.mods); - }; - for (const auto& child : root.children) addFolder(addFolder, top.get(), child); - if (!root.mods.isEmpty() || !filtering) { - auto item = std::make_unique(); - item->kind = QStringLiteral("ungrouped"); - item->key = kUngroupedKey; - item->folder = &root; - addMods(item.get(), root.mods); - top->add(std::move(item)); +std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, + bool filtering) { + auto top = std::make_unique(); + top->kind = QStringLiteral("root"); + auto addMods = [&](TreeNode* parent, const QList& leaves) { + for (const auto& leaf : leaves) { + auto item = std::make_unique(); + item->kind = QStringLiteral("mod"); + item->key = "m:" + leaf.uuid; + item->leaf = &leaf; + const auto it = mods.constFind(leaf.uuid); + if (it != mods.cend() && *it) { + const auto& mod = **it; + item->providerId = mod.providerId; + item->providerLabel = providerLabel( + mod.providerId, modPackageId(mod).value_or(QString{})); + if (const auto* about = dynamic_cast(&mod)) + item->modVersion = about->modVersion; + } + parent->add(std::move(item)); } - return top; + }; + auto addFolder = [&](auto&& self, TreeNode* parent, + const FolderNode& folder) -> void { + auto item = std::make_unique(); + item->kind = QStringLiteral("folder"); + item->key = "f:" + QString::number(folder.folder.id); + item->folder = &folder; + TreeNode* ptr = item.get(); + parent->add(std::move(item)); + for (const auto& child : folder.children) self(self, ptr, child); + addMods(ptr, folder.mods); + }; + for (const auto& child : root.children) + addFolder(addFolder, top.get(), child); + if (!root.mods.isEmpty() || !filtering) { + auto item = std::make_unique(); + item->kind = QStringLiteral("ungrouped"); + item->key = kUngroupedKey; + item->folder = &root; + addMods(item.get(), root.mods); + top->add(std::move(item)); + } + return top; } bool sameShape(const TreeNode& a, const TreeNode& b) { - if (a.key != b.key || a.children.size() != b.children.size()) return false; - for (size_t i = 0; i < a.children.size(); ++i) - if (!sameShape(*a.children[i], *b.children[i])) return false; - return true; + if (a.key != b.key || a.children.size() != b.children.size()) return false; + for (size_t i = 0; i < a.children.size(); ++i) + if (!sameShape(*a.children[i], *b.children[i])) return false; + return true; } void copyPayload(TreeNode& dst, TreeNode& src) { - dst.folder = src.folder; - dst.leaf = src.leaf; - dst.providerId = std::move(src.providerId); - dst.providerLabel = std::move(src.providerLabel); - dst.modVersion = std::move(src.modVersion); - for (size_t i = 0; i < dst.children.size(); ++i) - copyPayload(*dst.children[i], *src.children[i]); + dst.folder = src.folder; + dst.leaf = src.leaf; + dst.providerId = std::move(src.providerId); + dst.providerLabel = std::move(src.providerLabel); + dst.modVersion = std::move(src.modVersion); + for (size_t i = 0; i < dst.children.size(); ++i) + copyPayload(*dst.children[i], *src.children[i]); } -const QList kDiagnosticRoles{ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, +const QList kDiagnosticRoles{ + ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, ModTreeModel::kHasWarningRole, ModTreeModel::kWarningTooltipRole}; } // namespace void TreeNode::add(std::unique_ptr child) { - child->parent = this; - child->row = static_cast(children.size()); - children.push_back(std::move(child)); + child->parent = this; + child->row = static_cast(children.size()); + children.push_back(std::move(child)); } QList TreeNode::walk() const { - QList result; - auto visit = [&](auto&& self, const TreeNode& current) -> void { - for (const auto& child : current.children) { - result.append(child.get()); - self(self, *child); - } - }; - visit(visit, *this); - return result; + QList result; + auto visit = [&](auto&& self, const TreeNode& current) -> void { + for (const auto& child : current.children) { + result.append(child.get()); + self(self, *child); + } + }; + visit(visit, *this); + return result; } TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, - const organizer::FolderNode* folder, const organizer::ModLeaf* leaf, - QString providerId) - : kind(std::move(kind)), key(std::move(key)), parent(parent), folder(folder), leaf(leaf), + const organizer::FolderNode* folder, + const organizer::ModLeaf* leaf, QString providerId) + : kind(std::move(kind)), + key(std::move(key)), + parent(parent), + folder(folder), + leaf(leaf), providerId(std::move(providerId)) {} - qint64 TreeNode::folderId() const { - if (kind == "folder" && folder) return folder->folder.id; - if (kind == "mod" && parent) return parent->folderId(); - return organizer::kRootId; + if (kind == "folder" && folder) return folder->folder.id; + if (kind == "mod" && parent) return parent->folderId(); + return organizer::kRootId; } -bool TreeNode::isContainer() const { return kind == "folder" || kind == "ungrouped"; } +bool TreeNode::isContainer() const { + return kind == "folder" || kind == "ungrouped"; +} ModTreeModel::ModTreeModel(SummaryFor summaryFor, QObject* parent) - : QAbstractItemModel(parent), m_summaryFor(std::move(summaryFor)), m_root(std::make_unique()) { - m_root->kind = QStringLiteral("root"); + : QAbstractItemModel(parent), + m_summaryFor(std::move(summaryFor)), + m_root(std::make_unique()) { + m_root->kind = QStringLiteral("root"); } TreeNode* ModTreeModel::node(const QModelIndex& index) const { - return index.isValid() ? static_cast(index.internalPointer()) : m_root.get(); + return index.isValid() ? static_cast(index.internalPointer()) + : m_root.get(); } -QModelIndex ModTreeModel::index(int row, int column, const QModelIndex& parent) const { - const auto* current = node(parent); - if (column != 0 || row < 0 || static_cast(row) >= current->children.size()) return {}; - return createIndex(row, 0, current->children[row].get()); +QModelIndex ModTreeModel::index(int row, int column, + const QModelIndex& parent) const { + const auto* current = node(parent); + if (column != 0 || row < 0 || + static_cast(row) >= current->children.size()) + return {}; + return createIndex(row, 0, current->children[row].get()); } QModelIndex ModTreeModel::parent(const QModelIndex& child) const { - if (!child.isValid()) return {}; - const auto* up = node(child)->parent; - return !up || up == m_root.get() ? QModelIndex{} : indexOf(up); + if (!child.isValid()) return {}; + const auto* up = node(child)->parent; + return !up || up == m_root.get() ? QModelIndex{} : indexOf(up); } int ModTreeModel::rowCount(const QModelIndex& parent) const { - return parent.column() > 0 ? 0 : static_cast(node(parent)->children.size()); + return parent.column() > 0 ? 0 + : static_cast(node(parent)->children.size()); } int ModTreeModel::columnCount(const QModelIndex&) const { return 1; } QVariant ModTreeModel::data(const QModelIndex& index, int role) const { - if (!index.isValid()) return {}; - const auto* item = node(index); - if (role == Qt::DisplayRole) { - if (item->kind == "ungrouped") return QStringLiteral("Ungrouped"); - if (item->kind == "folder" && item->folder) return item->folder->folder.name; - return item->leaf ? item->leaf->name : QString{}; - } - if (role == kKindRole) return item->kind; - if (role == kKeyRole) return item->key; - if (role == kSelectedRole) return m_selected.contains(item->key); - if (role == kFilteringRole) return m_filtering; - if (item->kind != "mod") { - if (!item->folder) return {}; - const auto& folder = *item->folder; - const bool own = item->kind == "ungrouped"; - switch (role) { - case kCheckStateRole: return checkState(own ? folder.ownCheck() : folder.check); - case kEnabledCountRole: return own ? folder.ownEnabled : folder.enabled; - case kTotalCountRole: return own ? folder.ownTotal : folder.total; - case kVisibleCountRole: return own ? static_cast(folder.mods.size()) : folder.visibleTotal; - case kHasRuleRole: return folder.hasRules && !own; - default: return {}; - } - } - if (!item->leaf) return {}; - const auto& leaf = *item->leaf; + if (!index.isValid()) return {}; + const auto* item = node(index); + if (role == Qt::DisplayRole) { + if (item->kind == "ungrouped") return QStringLiteral("Ungrouped"); + if (item->kind == "folder" && item->folder) + return item->folder->folder.name; + return item->leaf ? item->leaf->name : QString{}; + } + if (role == kKindRole) return item->kind; + if (role == kKeyRole) return item->key; + if (role == kSelectedRole) return m_selected.contains(item->key); + if (role == kFilteringRole) return m_filtering; + if (item->kind != "mod") { + if (!item->folder) return {}; + const auto& folder = *item->folder; + const bool own = item->kind == "ungrouped"; switch (role) { - case kUuidRole: return leaf.uuid; - case kPackageIdRole: return leaf.packageId.value_or(QString{}); - case kCheckStateRole: return leaf.enabled ? Qt::Checked : Qt::Unchecked; - case kProviderLabelRole: return item->providerLabel; - case kModVersionRole: return item->modVersion; - case kProviderIconRole: return providerIcon(item->providerId); - case kProviderColorRole: { - const auto it = m_providerColors.constFind(item->providerId); - return it != m_providerColors.cend() ? *it : paletteValue(u"TEXT_DIM"); - } - case kCanPlaceRole: return leaf.packageId.has_value(); - case kTagsRole: return m_tagRows.value(leaf.packageId.value_or(QString{})); - default: break; + case kCheckStateRole: + return checkState(own ? folder.ownCheck() : folder.check); + case kEnabledCountRole: + return own ? folder.ownEnabled : folder.enabled; + case kTotalCountRole: + return own ? folder.ownTotal : folder.total; + case kVisibleCountRole: + return own ? static_cast(folder.mods.size()) : folder.visibleTotal; + case kHasRuleRole: + return folder.hasRules && !own; + default: + return {}; } - if (kDiagnosticRoles.contains(role)) { - const auto summary = leaf.enabled ? m_summaryFor(leaf.uuid) : std::nullopt; - if (role == kHasErrorRole) return summary && summary->hasErrors; - if (role == kHasWarningRole) return summary && summary->hasWarnings; - if (role == kErrorTooltipRole) return summary ? summary->errorTooltip : QString{}; - return summary ? summary->warningTooltip : QString{}; + } + if (!item->leaf) return {}; + const auto& leaf = *item->leaf; + switch (role) { + case kUuidRole: + return leaf.uuid; + case kPackageIdRole: + return leaf.packageId.value_or(QString{}); + case kCheckStateRole: + return leaf.enabled ? Qt::Checked : Qt::Unchecked; + case kProviderLabelRole: + return item->providerLabel; + case kModVersionRole: + return item->modVersion; + case kProviderIconRole: + return providerIcon(item->providerId); + case kProviderColorRole: { + const auto it = m_providerColors.constFind(item->providerId); + return it != m_providerColors.cend() ? *it : paletteValue(u"TEXT_DIM"); } - return {}; + case kCanPlaceRole: + return leaf.packageId.has_value(); + case kTagsRole: + return m_tagRows.value(leaf.packageId.value_or(QString{})); + default: + break; + } + if (kDiagnosticRoles.contains(role)) { + const auto summary = leaf.enabled ? m_summaryFor(leaf.uuid) : std::nullopt; + if (role == kHasErrorRole) return summary && summary->hasErrors; + if (role == kHasWarningRole) return summary && summary->hasWarnings; + if (role == kErrorTooltipRole) + return summary ? summary->errorTooltip : QString{}; + return summary ? summary->warningTooltip : QString{}; + } + return {}; } QHash ModTreeModel::roleNames() const { - return {{Qt::DisplayRole, "name"}, {kKindRole, "kind"}, {kKeyRole, "key"}, - {kUuidRole, "uuid"}, {kPackageIdRole, "packageId"}, {kCheckStateRole, "checkState"}, - {kEnabledCountRole, "enabledCount"}, {kTotalCountRole, "totalCount"}, - {kVisibleCountRole, "visibleCount"}, {kFilteringRole, "filtering"}, {kHasRuleRole, "hasRule"}, - {kProviderIconRole, "providerIcon"}, {kProviderLabelRole, "providerLabel"}, - {kModVersionRole, "modVersion"}, {kProviderColorRole, "providerColor"}, - {kHasErrorRole, "hasError"}, {kErrorTooltipRole, "errorTooltip"}, - {kHasWarningRole, "hasWarning"}, {kWarningTooltipRole, "warningTooltip"}, - {kCanPlaceRole, "canPlace"}, {kSelectedRole, "selected"}, {kTagsRole, "tags"}}; + return {{Qt::DisplayRole, "name"}, + {kKindRole, "kind"}, + {kKeyRole, "key"}, + {kUuidRole, "uuid"}, + {kPackageIdRole, "packageId"}, + {kCheckStateRole, "checkState"}, + {kEnabledCountRole, "enabledCount"}, + {kTotalCountRole, "totalCount"}, + {kVisibleCountRole, "visibleCount"}, + {kFilteringRole, "filtering"}, + {kHasRuleRole, "hasRule"}, + {kProviderIconRole, "providerIcon"}, + {kProviderLabelRole, "providerLabel"}, + {kModVersionRole, "modVersion"}, + {kProviderColorRole, "providerColor"}, + {kHasErrorRole, "hasError"}, + {kErrorTooltipRole, "errorTooltip"}, + {kHasWarningRole, "hasWarning"}, + {kWarningTooltipRole, "warningTooltip"}, + {kCanPlaceRole, "canPlace"}, + {kSelectedRole, "selected"}, + {kTagsRole, "tags"}}; } bool ModTreeModel::filtering() const { return m_filtering; } void ModTreeModel::setTree(const organizer::FolderNode& root, - const organizer::ModsByUuid& mods, const QHash& providerColors, - bool filtering, const QHash& tags) { - auto payload = std::make_unique(root); - auto fresh = build(*payload, mods, filtering); - m_providerColors = providerColors; - m_tagRows.clear(); - for (const auto* item : fresh->walk()) { - if (!item->leaf || !item->leaf->packageId || item->leaf->packageId->isEmpty() || item->leaf->tagIds.isEmpty()) continue; - QList ids(item->leaf->tagIds.begin(), item->leaf->tagIds.end()); - std::sort(ids.begin(), ids.end()); - QVariantList rows; - for (qint64 id : ids) { - const auto tag = tags.constFind(id); - if (tag != tags.cend()) rows.append(QVariantMap{{"name", tag->name}, {"color", tag->color}}); - } - m_tagRows.insert(*item->leaf->packageId, rows); - } - if (m_filtering == filtering && sameShape(*m_root, *fresh)) { - copyPayload(*m_root, *fresh); - m_payload = std::move(payload); - emitAllChanged(m_root.get()); - } else { - beginResetModel(); - m_root = std::move(fresh); - m_payload = std::move(payload); - m_filtering = filtering; - endResetModel(); + const organizer::ModsByUuid& mods, + const QHash& providerColors, + bool filtering, + const QHash& tags) { + auto payload = std::make_unique(root); + auto fresh = build(*payload, mods, filtering); + m_providerColors = providerColors; + m_tagRows.clear(); + for (const auto* item : fresh->walk()) { + if (!item->leaf || !item->leaf->packageId || + item->leaf->packageId->isEmpty() || item->leaf->tagIds.isEmpty()) + continue; + QList ids(item->leaf->tagIds.begin(), item->leaf->tagIds.end()); + std::sort(ids.begin(), ids.end()); + QVariantList rows; + for (qint64 id : ids) { + const auto tag = tags.constFind(id); + if (tag != tags.cend()) + rows.append(QVariantMap{{"name", tag->name}, {"color", tag->color}}); } - QSet live; - for (const auto* item : m_root->walk()) live.insert(item->key); - m_selected.intersect(live); + m_tagRows.insert(*item->leaf->packageId, rows); + } + if (m_filtering == filtering && sameShape(*m_root, *fresh)) { + copyPayload(*m_root, *fresh); + m_payload = std::move(payload); + emitAllChanged(m_root.get()); + } else { + beginResetModel(); + m_root = std::move(fresh); + m_payload = std::move(payload); + m_filtering = filtering; + endResetModel(); + } + QSet live; + for (const auto* item : m_root->walk()) live.insert(item->key); + m_selected.intersect(live); } QModelIndex ModTreeModel::indexOf(const TreeNode* item) const { - return item == m_root.get() ? QModelIndex{} : createIndex(item->row, 0, const_cast(item)); + return item == m_root.get() + ? QModelIndex{} + : createIndex(item->row, 0, const_cast(item)); } void ModTreeModel::emitAllChanged(TreeNode* item, const QList& roles) { - if (item->children.empty()) return; - const auto parent = indexOf(item); - emit dataChanged(index(0, 0, parent), index(static_cast(item->children.size()) - 1, 0, parent), roles); - for (const auto& child : item->children) emitAllChanged(child.get(), roles); + if (item->children.empty()) return; + const auto parent = indexOf(item); + emit dataChanged( + index(0, 0, parent), + index(static_cast(item->children.size()) - 1, 0, parent), roles); + for (const auto& child : item->children) emitAllChanged(child.get(), roles); } -void ModTreeModel::refreshDiagnostics() { emitAllChanged(m_root.get(), kDiagnosticRoles); } -TreeNode* ModTreeModel::nodeAt(const QModelIndex& index) const { return index.isValid() ? node(index) : nullptr; } +void ModTreeModel::refreshDiagnostics() { + emitAllChanged(m_root.get(), kDiagnosticRoles); +} +TreeNode* ModTreeModel::nodeAt(const QModelIndex& index) const { + return index.isValid() ? node(index) : nullptr; +} QModelIndex ModTreeModel::indexForKey(const QString& key) const { - for (const auto* item : m_root->walk()) if (item->key == key) return indexOf(item); - return {}; + for (const auto* item : m_root->walk()) + if (item->key == key) return indexOf(item); + return {}; } QModelIndex ModTreeModel::indexForUuid(const QString& uuid) const { - for (const auto* item : m_root->walk()) - if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) return indexOf(item); - return {}; + for (const auto* item : m_root->walk()) + if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) + return indexOf(item); + return {}; } -QList ModTreeModel::expandedIndexes(const QModelIndex& parent) const { - QList result; - auto visit = [&](auto&& self, const TreeNode* item) -> void { - for (const auto& child : item->children) { - if (child->isContainer() && (m_filtering || (child->folder && !child->folder->folder.collapsed))) { - result.append(indexOf(child.get())); - self(self, child.get()); - } - } - }; - visit(visit, node(parent)); - return result; +QList ModTreeModel::expandedIndexes( + const QModelIndex& parent) const { + QList result; + auto visit = [&](auto&& self, const TreeNode* item) -> void { + for (const auto& child : item->children) { + if (child->isContainer() && + (m_filtering || + (child->folder && !child->folder->folder.collapsed))) { + result.append(indexOf(child.get())); + self(self, child.get()); + } + } + }; + visit(visit, node(parent)); + return result; } QList ModTreeModel::selectedNodes() const { - QList result; - for (auto* item : m_root->walk()) if (m_selected.contains(item->key)) result.append(item); - return result; + QList result; + for (auto* item : m_root->walk()) + if (m_selected.contains(item->key)) result.append(item); + return result; } bool ModTreeModel::isSelected(const QModelIndex& index) const { - const auto* item = nodeAt(index); - return item && m_selected.contains(item->key); + const auto* item = nodeAt(index); + return item && m_selected.contains(item->key); } -void ModTreeModel::select(const QList& indexes, const QString& mode) { - QSet keys; - for (const auto& index : indexes) if (const auto* item = nodeAt(index)) keys.insert(item->key); - const auto before = m_selected; - if (mode == "replace") m_selected = keys; - else if (mode == "toggle") m_selected = (m_selected - keys) | (keys - m_selected); - else m_selected.unite(keys); - notifySelection((before - m_selected) | (m_selected - before)); +void ModTreeModel::select(const QList& indexes, + const QString& mode) { + QSet keys; + for (const auto& index : indexes) + if (const auto* item = nodeAt(index)) keys.insert(item->key); + const auto before = m_selected; + if (mode == "replace") + m_selected = keys; + else if (mode == "toggle") + m_selected = (m_selected - keys) | (keys - m_selected); + else + m_selected.unite(keys); + notifySelection((before - m_selected) | (m_selected - before)); } void ModTreeModel::clearSelection() { - const auto before = m_selected; - m_selected.clear(); - notifySelection(before); + const auto before = m_selected; + m_selected.clear(); + notifySelection(before); } void ModTreeModel::notifySelection(const QSet& changed) { - if (changed.isEmpty()) return; - for (const auto* item : m_root->walk()) if (changed.contains(item->key)) { - const auto index = indexOf(item); - emit dataChanged(index, index, {kSelectedRole}); + if (changed.isEmpty()) return; + for (const auto* item : m_root->walk()) + if (changed.contains(item->key)) { + const auto index = indexOf(item); + emit dataChanged(index, index, {kSelectedRole}); } } diff --git a/src/ui/plugins/organizer/tree_model.hpp b/src/ui/plugins/organizer/tree_model.hpp index befc0be..ecf94a6 100644 --- a/src/ui/plugins/organizer/tree_model.hpp +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -1,12 +1,11 @@ #pragma once +#include +#include #include #include #include -#include -#include - #include "core/models/view/diagnostics.hpp" #include "core/organizer/resolve.hpp" @@ -15,73 +14,94 @@ namespace px::modrim { inline const QString kUngroupedKey = QStringLiteral("ungrouped"); struct TreeNode { - QString kind; - QString key; - TreeNode* parent = nullptr; - const organizer::FolderNode* folder = nullptr; - const organizer::ModLeaf* leaf = nullptr; - QString providerId; - QString providerLabel; - QString modVersion; - int row = 0; - std::vector> children; + QString kind; + QString key; + TreeNode* parent = nullptr; + const organizer::FolderNode* folder = nullptr; + const organizer::ModLeaf* leaf = nullptr; + QString providerId; + QString providerLabel; + QString modVersion; + int row = 0; + std::vector> children; - void add(std::unique_ptr child); - QList walk() const; - qint64 folderId() const; - TreeNode(QString kind = QStringLiteral("root"), QString key = {}, TreeNode* parent = nullptr, - const organizer::FolderNode* folder = nullptr, - const organizer::ModLeaf* leaf = nullptr, QString providerId = {}); - bool isContainer() const; + void add(std::unique_ptr child); + QList walk() const; + qint64 folderId() const; + TreeNode(QString kind = QStringLiteral("root"), QString key = {}, + TreeNode* parent = nullptr, + const organizer::FolderNode* folder = nullptr, + const organizer::ModLeaf* leaf = nullptr, QString providerId = {}); + bool isContainer() const; }; class ModTreeModel : public QAbstractItemModel { - Q_OBJECT -public: - enum Role { - kKindRole = Qt::UserRole + 1, kKeyRole, kUuidRole, kPackageIdRole, - kCheckStateRole, kEnabledCountRole, kTotalCountRole, kVisibleCountRole, - kFilteringRole, kHasRuleRole, kProviderIconRole, kProviderColorRole, - kHasErrorRole, kErrorTooltipRole, kCanPlaceRole, kSelectedRole, - kHasWarningRole, kWarningTooltipRole, kTagsRole, kProviderLabelRole, kModVersionRole - }; - Q_ENUM(Role) - using SummaryFor = std::function(const QString&)>; - explicit ModTreeModel(SummaryFor summaryFor, QObject* parent = nullptr); - QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; - QModelIndex parent(const QModelIndex& child) const override; - int rowCount(const QModelIndex& parent = {}) const override; - int columnCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; + Q_OBJECT + public: + enum Role { + kKindRole = Qt::UserRole + 1, + kKeyRole, + kUuidRole, + kPackageIdRole, + kCheckStateRole, + kEnabledCountRole, + kTotalCountRole, + kVisibleCountRole, + kFilteringRole, + kHasRuleRole, + kProviderIconRole, + kProviderColorRole, + kHasErrorRole, + kErrorTooltipRole, + kCanPlaceRole, + kSelectedRole, + kHasWarningRole, + kWarningTooltipRole, + kTagsRole, + kProviderLabelRole, + kModVersionRole + }; + Q_ENUM(Role) + using SummaryFor = + std::function(const QString&)>; + explicit ModTreeModel(SummaryFor summaryFor, QObject* parent = nullptr); + QModelIndex index(int row, int column, + const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; - bool filtering() const; - void setTree(const organizer::FolderNode& root, - const organizer::ModsByUuid& mods, - const QHash& providerColors, bool filtering, - const QHash& tags = {}); - void refreshDiagnostics(); - TreeNode* nodeAt(const QModelIndex& index) const; - QModelIndex indexForKey(const QString& key) const; - QModelIndex indexForUuid(const QString& uuid) const; - QList expandedIndexes(const QModelIndex& parent = {}) const; - QList selectedNodes() const; - bool isSelected(const QModelIndex& index) const; - void select(const QList& indexes, const QString& mode = QStringLiteral("replace")); - void clearSelection(); + bool filtering() const; + void setTree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, + const QHash& providerColors, bool filtering, + const QHash& tags = {}); + void refreshDiagnostics(); + TreeNode* nodeAt(const QModelIndex& index) const; + QModelIndex indexForKey(const QString& key) const; + QModelIndex indexForUuid(const QString& uuid) const; + QList expandedIndexes(const QModelIndex& parent = {}) const; + QList selectedNodes() const; + bool isSelected(const QModelIndex& index) const; + void select(const QList& indexes, + const QString& mode = QStringLiteral("replace")); + void clearSelection(); -private: - TreeNode* node(const QModelIndex& index) const; - QModelIndex indexOf(const TreeNode* node) const; - void emitAllChanged(TreeNode* node, const QList& roles = {}); - void notifySelection(const QSet& changed); - SummaryFor m_summaryFor; - std::unique_ptr m_root; - std::unique_ptr m_payload; - bool m_filtering = false; - QHash m_providerColors; - QHash m_tagRows; - QSet m_selected; + private: + TreeNode* node(const QModelIndex& index) const; + QModelIndex indexOf(const TreeNode* node) const; + void emitAllChanged(TreeNode* node, const QList& roles = {}); + void notifySelection(const QSet& changed); + SummaryFor m_summaryFor; + std::unique_ptr m_root; + std::unique_ptr m_payload; + bool m_filtering = false; + QHash m_providerColors; + QHash m_tagRows; + QSet m_selected; }; } // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp index 1fe8985..c27b8a1 100644 --- a/src/ui/plugins/organizer/view.cpp +++ b/src/ui/plugins/organizer/view.cpp @@ -1,8 +1,5 @@ #include "ui/plugins/organizer/view.hpp" -#include -#include -#include #include #include #include @@ -19,6 +16,11 @@ #include #include #include +#include + +#include +#include + #include "core/downloads/manager.hpp" #include "core/mod_service.hpp" #include "core/services/diagnostics_service.hpp" @@ -39,548 +41,825 @@ namespace px::modrim { using namespace organizer; namespace { QString plural(qsizetype count, const QString& word) { - return QStringLiteral("%1 %2%3").arg(count).arg(word, count == 1 ? QString{} : QStringLiteral("s")); + return QStringLiteral("%1 %2%3").arg(count).arg( + word, count == 1 ? QString{} : QStringLiteral("s")); } OrganizerService& organizerService(CoreContext& ctx) { - auto* service = dynamic_cast(ctx.plugins().get(QStringLiteral("organizer"))); - Q_ASSERT(service); - return *service; + auto* service = dynamic_cast( + ctx.plugins().get(QStringLiteral("organizer"))); + Q_ASSERT(service); + return *service; } QPushButton* button(const QString& text, QWidget* parent, QHBoxLayout* layout) { - auto* result = new QPushButton(text, parent); - result->setObjectName(QStringLiteral("appBtn")); - result->setCursor(Qt::PointingHandCursor); - layout->addWidget(result); - return result; -} + auto* result = new QPushButton(text, parent); + result->setObjectName(QStringLiteral("appBtn")); + result->setCursor(Qt::PointingHandCursor); + layout->addWidget(result); + return result; } +} // namespace -OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx, engine, parent, app), m_service(organizerService(ctx)), - m_model(new ModTreeModel([&ctx](const QString& uuid) { return ctx.diagnosticsService().summaryFor(uuid); }, this)), +OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), + m_service(organizerService(ctx)), + m_model(new ModTreeModel( + [&ctx](const QString& uuid) { + return ctx.diagnosticsService().summaryFor(uuid); + }, + this)), m_filters(new OrganizerFilterModel(this)) { - auto* content = new QWidget(this); - content->setObjectName(QStringLiteral("contentArea")); - auto* row = new QHBoxLayout(content); - row->setContentsMargins(0, 0, 0, 0); - row->setSpacing(0); - auto* sidebar = new FilterSidebar(engine, m_filters, content); - sidebar->setFixedWidth(kSidebarWidth); - row->addWidget(sidebar); - connect(sidebar, &FilterSidebar::activated, this, [this](int index) { - const auto key = m_filters->keyAt(index); - if (key && *key != m_filterKey) { m_filterKey = *key; invalidate(); } - }); - auto* main = new QWidget(content); - main->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - auto* layout = new QVBoxLayout(main); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - auto* toolbar = new QWidget(main); - toolbar->setObjectName(QStringLiteral("searchBox")); - toolbar->setFixedHeight(56); - auto* tools = new QHBoxLayout(toolbar); - tools->setContentsMargins(16, 8, 16, 8); - m_search = new QLineEdit(toolbar); - m_search->setPlaceholderText(QStringLiteral("Search mods, package ID, author…")); - m_search->setClearButtonEnabled(true); - tools->addWidget(m_search, 1); - auto* create = button(QStringLiteral("New folder"), toolbar, tools); - create->setObjectName(QStringLiteral("primaryAction")); - connect(create, &QPushButton::clicked, this, &OrganizerViewPanel::newFolder); - auto* rules = button(QStringLiteral("Auto-folder rules"), toolbar, tools); - rules->setObjectName(QStringLiteral("organizerRulesButton")); - connect(rules, &QPushButton::clicked, this, &OrganizerViewPanel::openRules); - connect(button(QStringLiteral("Expand all"), toolbar, tools), &QPushButton::clicked, this, [this] { treeCommand("expandAll"); }); - connect(button(QStringLiteral("Collapse all"), toolbar, tools), &QPushButton::clicked, this, [this] { treeCommand("collapseAll"); }); - layout->addWidget(toolbar); + auto* content = new QWidget(this); + content->setObjectName(QStringLiteral("contentArea")); + auto* row = new QHBoxLayout(content); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(0); + auto* sidebar = new FilterSidebar(engine, m_filters, content); + sidebar->setFixedWidth(kSidebarWidth); + row->addWidget(sidebar); + connect(sidebar, &FilterSidebar::activated, this, [this](int index) { + const auto key = m_filters->keyAt(index); + if (key && *key != m_filterKey) { + m_filterKey = *key; + invalidate(); + } + }); + auto* main = new QWidget(content); + main->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + auto* layout = new QVBoxLayout(main); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + auto* toolbar = new QWidget(main); + toolbar->setObjectName(QStringLiteral("searchBox")); + toolbar->setFixedHeight(56); + auto* tools = new QHBoxLayout(toolbar); + tools->setContentsMargins(16, 8, 16, 8); + m_search = new QLineEdit(toolbar); + m_search->setPlaceholderText( + QStringLiteral("Search mods, package ID, author…")); + m_search->setClearButtonEnabled(true); + tools->addWidget(m_search, 1); + auto* create = button(QStringLiteral("New folder"), toolbar, tools); + create->setObjectName(QStringLiteral("primaryAction")); + connect(create, &QPushButton::clicked, this, &OrganizerViewPanel::newFolder); + auto* rules = button(QStringLiteral("Auto-folder rules"), toolbar, tools); + rules->setObjectName(QStringLiteral("organizerRulesButton")); + connect(rules, &QPushButton::clicked, this, &OrganizerViewPanel::openRules); + connect(button(QStringLiteral("Expand all"), toolbar, tools), + &QPushButton::clicked, this, [this] { treeCommand("expandAll"); }); + connect(button(QStringLiteral("Collapse all"), toolbar, tools), + &QPushButton::clicked, this, [this] { treeCommand("collapseAll"); }); + layout->addWidget(toolbar); - m_qml = new QQuickWidget(engine, main); - m_qml->setObjectName(QStringLiteral("organizerTree")); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setClearColor(paletteColor(u"ELEVATE_1")); - m_qml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); - m_qml->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), m_model); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); - m_rulesDialog = new QDialog(this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); - m_rulesDialog->setObjectName(QStringLiteral("organizerRuleEditorWindow")); - m_rulesDialog->setWindowTitle(QStringLiteral("Auto-Folder Rules")); - m_rulesDialog->setWindowModality(Qt::WindowModal); - m_rulesDialog->setMinimumSize(720, 520); - m_rulesDialog->setSizeGripEnabled(true); - auto* rulesLayout = new QVBoxLayout(m_rulesDialog); - rulesLayout->setContentsMargins(0, 0, 0, 0); - m_rulesQml = new QQuickWidget(engine, m_rulesDialog); - m_rulesQml->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); - m_rulesQml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_rulesQml->setClearColor(paletteColor(u"ELEVATE_2")); - m_rulesQml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), this); - m_rulesQml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); - rulesLayout->addWidget(m_rulesQml); - installDialogChrome(m_rulesDialog); - connect(m_rulesDialog, &QDialog::finished, this, [this] { - if (auto* root = m_rulesQml->rootObject()) QMetaObject::invokeMethod(root, "closeEditor"); - }); - auto* hint = new QWidget(main); - hint->setObjectName(QStringLiteral("organizerHint")); - auto* hints = new QHBoxLayout(hint); - hints->setContentsMargins(12, 5, 12, 5); - auto* hintText = new QLabel(QStringLiteral("Folders and tags are for browsing only. Edit load order in Mods; checkboxes enable or disable the same mods."), hint); - hintText->setWordWrap(true); - hints->addWidget(hintText, 1); - auto* dismiss = button(QStringLiteral("×"), hint, hints); - dismiss->setToolTip(QStringLiteral("Dismiss")); - connect(dismiss, &QPushButton::clicked, hint, &QWidget::hide); - layout->addWidget(hint); - layout->addWidget(m_qml, 1); - m_selectionBar = new QWidget(main); - auto* bar = new QHBoxLayout(m_selectionBar); - bar->setContentsMargins(12, 6, 12, 6); - m_selectionLabel = new QLabel(m_selectionBar); - bar->addWidget(m_selectionLabel); - const QList> actions{{QStringLiteral("Enable"), QStringLiteral("enable")}, {QStringLiteral("Disable"), QStringLiteral("disable")}, {QStringLiteral("Move to folder…"), QStringLiteral("move")}, {QStringLiteral("Manage tags…"), QStringLiteral("tags")}, {QStringLiteral("Create folder from selection"), QStringLiteral("create")}, {QStringLiteral("Update"), QStringLiteral("update")}}; - for (const auto& [label, action] : actions) { - auto* item = button(label, m_selectionBar, bar); - if (action == QStringLiteral("update")) m_updateButton = item; - connect(item, &QPushButton::clicked, this, [this, action] { selectionAction(action); }); + m_qml = new QQuickWidget(engine, main); + m_qml->setObjectName(QStringLiteral("organizerTree")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), + m_model); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); + m_rulesDialog = new QDialog( + this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | + Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); + m_rulesDialog->setObjectName(QStringLiteral("organizerRuleEditorWindow")); + m_rulesDialog->setWindowTitle(QStringLiteral("Auto-Folder Rules")); + m_rulesDialog->setWindowModality(Qt::WindowModal); + m_rulesDialog->setMinimumSize(720, 520); + m_rulesDialog->setSizeGripEnabled(true); + auto* rulesLayout = new QVBoxLayout(m_rulesDialog); + rulesLayout->setContentsMargins(0, 0, 0, 0); + m_rulesQml = new QQuickWidget(engine, m_rulesDialog); + m_rulesQml->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); + m_rulesQml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_rulesQml->setClearColor(paletteColor(u"ELEVATE_2")); + m_rulesQml->rootContext()->setContextProperty( + QStringLiteral("organizerPanel"), this); + m_rulesQml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); + rulesLayout->addWidget(m_rulesQml); + installDialogChrome(m_rulesDialog); + connect(m_rulesDialog, &QDialog::finished, this, [this] { + if (auto* root = m_rulesQml->rootObject()) + QMetaObject::invokeMethod(root, "closeEditor"); + }); + auto* hint = new QWidget(main); + hint->setObjectName(QStringLiteral("organizerHint")); + auto* hints = new QHBoxLayout(hint); + hints->setContentsMargins(12, 5, 12, 5); + auto* hintText = new QLabel( + QStringLiteral("Folders and tags are for browsing only. Edit load order " + "in Mods; checkboxes enable or disable the same mods."), + hint); + hintText->setWordWrap(true); + hints->addWidget(hintText, 1); + auto* dismiss = button(QStringLiteral("×"), hint, hints); + dismiss->setToolTip(QStringLiteral("Dismiss")); + connect(dismiss, &QPushButton::clicked, hint, &QWidget::hide); + layout->addWidget(hint); + layout->addWidget(m_qml, 1); + m_selectionBar = new QWidget(main); + auto* bar = new QHBoxLayout(m_selectionBar); + bar->setContentsMargins(12, 6, 12, 6); + m_selectionLabel = new QLabel(m_selectionBar); + bar->addWidget(m_selectionLabel); + const QList> actions{ + {QStringLiteral("Enable"), QStringLiteral("enable")}, + {QStringLiteral("Disable"), QStringLiteral("disable")}, + {QStringLiteral("Move to folder…"), QStringLiteral("move")}, + {QStringLiteral("Manage tags…"), QStringLiteral("tags")}, + {QStringLiteral("Create folder from selection"), + QStringLiteral("create")}, + {QStringLiteral("Update"), QStringLiteral("update")}}; + for (const auto& [label, action] : actions) { + auto* item = button(label, m_selectionBar, bar); + if (action == QStringLiteral("update")) m_updateButton = item; + connect(item, &QPushButton::clicked, this, + [this, action] { selectionAction(action); }); + } + m_selectionBar->hide(); + layout->addWidget(m_selectionBar); + row->addWidget(main, 3); + m_info = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + m_info->setObjectName(QStringLiteral("modInfoPanel")); + m_info->setMinimumWidth(300); + row->addWidget(m_info, 2); + m_selection = std::make_unique(ctx, *m_info); + connect(m_info, &ModInfoPanel::modRequested, this, + [this](const QString& uuid) { + const auto index = m_model->indexForUuid(uuid); + if (index.isValid()) selectSingle(index); + }); + m_root->addWidget(content, 1); + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("organizerStatus")); + m_root->addWidget(m_status); + m_menu = new QMenu(this); + m_menu->setToolTipsVisible(true); + m_searchTimer = new QTimer(this); + m_searchTimer->setSingleShot(true); + m_searchTimer->setInterval(220); + connect(m_search, &QLineEdit::textChanged, this, + [this] { m_searchTimer->start(); }); + connect(m_searchTimer, &QTimer::timeout, this, [this] { + if (m_searchText != m_search->text()) { + m_searchText = m_search->text(); + invalidate(); } - m_selectionBar->hide(); - layout->addWidget(m_selectionBar); - row->addWidget(main, 3); - m_info = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); - m_info->setObjectName(QStringLiteral("modInfoPanel")); - m_info->setMinimumWidth(300); - row->addWidget(m_info, 2); - m_selection = std::make_unique(ctx, *m_info); - connect(m_info, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { - const auto index = m_model->indexForUuid(uuid); - if (index.isValid()) selectSingle(index); - }); - m_root->addWidget(content, 1); - m_status = new QLabel(this); - m_status->setObjectName(QStringLiteral("organizerStatus")); - m_root->addWidget(m_status); - m_menu = new QMenu(this); - m_menu->setToolTipsVisible(true); - m_searchTimer = new QTimer(this); - m_searchTimer->setSingleShot(true); - m_searchTimer->setInterval(220); - connect(m_search, &QLineEdit::textChanged, this, [this] { m_searchTimer->start(); }); - connect(m_searchTimer, &QTimer::timeout, this, [this] { - if (m_searchText != m_search->text()) { m_searchText = m_search->text(); invalidate(); } - }); - connect(m_model, &QAbstractItemModel::modelReset, this, [this] { m_selectionAnchor = QPersistentModelIndex{}; }); - m_changedId = m_service.changed.connect([this](const auto&) { invalidate(); Q_EMIT editorChanged(); }); - m_activeId = ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); - m_modsId = ctx.modService().modsChanged.connect([this](const auto&) { invalidate(); }); - m_diagnosticsId = ctx.diagnosticsService().diagnosticsSummaryChanged.connect([this](const auto&) { + }); + connect(m_model, &QAbstractItemModel::modelReset, this, + [this] { m_selectionAnchor = QPersistentModelIndex{}; }); + m_changedId = m_service.changed.connect([this](const auto&) { + invalidate(); + Q_EMIT editorChanged(); + }); + m_activeId = + ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); + m_modsId = ctx.modService().modsChanged.connect( + [this](const auto&) { invalidate(); }); + m_diagnosticsId = ctx.diagnosticsService().diagnosticsSummaryChanged.connect( + [this](const auto&) { m_model->refreshDiagnostics(); - if (!m_selectedUuid.isEmpty()) m_info->setIssues(m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); - }); - m_busyId = ctx.downloads().busyChanged.connect([this](bool) { syncInfo(); }); + if (!m_selectedUuid.isEmpty()) + m_info->setIssues( + m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); + }); + m_busyId = ctx.downloads().busyChanged.connect([this](bool) { syncInfo(); }); } OrganizerViewPanel::~OrganizerViewPanel() { - m_service.changed.disconnect(m_changedId); - m_ctx.activeStateChanged().disconnect(m_activeId); - m_ctx.modService().modsChanged.disconnect(m_modsId); - m_ctx.diagnosticsService().diagnosticsSummaryChanged.disconnect(m_diagnosticsId); - m_ctx.downloads().busyChanged.disconnect(m_busyId); + m_service.changed.disconnect(m_changedId); + m_ctx.activeStateChanged().disconnect(m_activeId); + m_ctx.modService().modsChanged.disconnect(m_modsId); + m_ctx.diagnosticsService().diagnosticsSummaryChanged.disconnect( + m_diagnosticsId); + m_ctx.downloads().busyChanged.disconnect(m_busyId); } bool OrganizerViewPanel::ready() const { return m_service.ready(); } void OrganizerViewPanel::showEvent(QShowEvent* event) { - BaseViewPanel::showEvent(event); - if (m_dirty) rebuild(); - Q_EMIT readyChanged(); - if (auto* root = m_rulesQml->rootObject()) QMetaObject::invokeMethod(root, "schedulePreview"); + BaseViewPanel::showEvent(event); + if (m_dirty) rebuild(); + Q_EMIT readyChanged(); + if (auto* root = m_rulesQml->rootObject()) + QMetaObject::invokeMethod(root, "schedulePreview"); +} +void OrganizerViewPanel::invalidate() { + m_dirty = true; + if (isVisible()) rebuild(); } -void OrganizerViewPanel::invalidate() { m_dirty = true; if (isVisible()) rebuild(); } void OrganizerViewPanel::rebuild() { - if (!ready()) return; - m_dirty = false; - m_filters->update(m_service.treeFilters()); - const auto query = m_filters->forKey(m_filterKey).query(m_searchText); - const auto tree = m_service.tree(query); - m_model->setTree(tree, m_ctx.allMods(), m_ctx.modService().providerColors(), !query.isEmpty(), m_service.state().tags); - const auto ungrouped = query.isEmpty() ? tree.mods.size() : m_service.tree().mods.size(); - m_status->setText(QStringLiteral("%1 · %2 active · %3 · %4 ungrouped").arg(plural(m_ctx.allMods().size(), QStringLiteral("mod"))).arg(m_ctx.activeUuids().size()).arg(plural(m_service.state().folders.size() - 1, QStringLiteral("folder"))).arg(ungrouped)); - syncInfo(true); - Q_EMIT readyChanged(); + if (!ready()) return; + m_dirty = false; + m_filters->update(m_service.treeFilters()); + const auto query = m_filters->forKey(m_filterKey).query(m_searchText); + const auto tree = m_service.tree(query); + m_model->setTree(tree, m_ctx.allMods(), m_ctx.modService().providerColors(), + !query.isEmpty(), m_service.state().tags); + const auto ungrouped = + query.isEmpty() ? tree.mods.size() : m_service.tree().mods.size(); + m_status->setText( + QStringLiteral("%1 · %2 active · %3 · %4 ungrouped") + .arg(plural(m_ctx.allMods().size(), QStringLiteral("mod"))) + .arg(m_ctx.activeUuids().size()) + .arg(plural(m_service.state().folders.size() - 1, + QStringLiteral("folder"))) + .arg(ungrouped)); + syncInfo(true); + Q_EMIT readyChanged(); } QStringList OrganizerViewPanel::selectedPackageIds() const { - QStringList result; - for (const auto* node : m_model->selectedNodes()) if (node->leaf && node->leaf->packageId && !result.contains(*node->leaf->packageId)) result.append(*node->leaf->packageId); - return result; + QStringList result; + for (const auto* node : m_model->selectedNodes()) + if (node->leaf && node->leaf->packageId && + !result.contains(*node->leaf->packageId)) + result.append(*node->leaf->packageId); + return result; } QStringList OrganizerViewPanel::selectedUuids() const { - QStringList result; - for (const auto* node : m_model->selectedNodes()) if (node->leaf) result.append(node->leaf->uuid); - return result; + QStringList result; + for (const auto* node : m_model->selectedNodes()) + if (node->leaf) result.append(node->leaf->uuid); + return result; } void OrganizerViewPanel::syncInfo(bool force) { - const auto nodes = m_model->selectedNodes(); - const auto uuids = selectedUuids(); - Q_EMIT editorChanged(); - m_selectionLabel->setText(plural(uuids.size(), QStringLiteral("mod")) + QStringLiteral(" selected")); - m_selectionBar->setVisible(uuids.size() > 1); - const auto state = updateState(m_ctx, uuids); - m_updateButton->setVisible(m_ctx.downloads().available()); - m_updateButton->setText(state.label); - m_updateButton->setEnabled(state.enabled); - m_updateButton->setToolTip(state.tooltip); - const QString uuid = nodes.size() == 1 && nodes.front()->leaf ? nodes.front()->leaf->uuid : QString{}; - if (uuid == m_selectedUuid && !force) return; - m_selectedUuid = uuid; - if (uuid.isEmpty()) m_selection->clear(); else m_selection->show(uuid); -} -int OrganizerViewPanel::selectedTagCount() const { return selectedPackageIds().size(); } + const auto nodes = m_model->selectedNodes(); + const auto uuids = selectedUuids(); + Q_EMIT editorChanged(); + m_selectionLabel->setText(plural(uuids.size(), QStringLiteral("mod")) + + QStringLiteral(" selected")); + m_selectionBar->setVisible(uuids.size() > 1); + const auto state = updateState(m_ctx, uuids); + m_updateButton->setVisible(m_ctx.downloads().available()); + m_updateButton->setText(state.label); + m_updateButton->setEnabled(state.enabled); + m_updateButton->setToolTip(state.tooltip); + const QString uuid = nodes.size() == 1 && nodes.front()->leaf + ? nodes.front()->leaf->uuid + : QString{}; + if (uuid == m_selectedUuid && !force) return; + m_selectedUuid = uuid; + if (uuid.isEmpty()) + m_selection->clear(); + else + m_selection->show(uuid); +} +int OrganizerViewPanel::selectedTagCount() const { + return selectedPackageIds().size(); +} QVariantList OrganizerViewPanel::tagRows() const { - QVariantList result; - if (!ready()) return result; - auto tags = m_service.state().tags.values(); - std::ranges::sort(tags, [](const Tag& a, const Tag& b) { return a.name.toCaseFolded() < b.name.toCaseFolded(); }); - const auto pids = selectedPackageIds(); - for (const auto& tag : tags) { - int matched = 0; - for (const auto& pid : pids) if (m_service.state().modTags.value(pid).contains(tag.id)) ++matched; - result.append(QVariantMap{{QStringLiteral("id"), tag.id}, {QStringLiteral("name"), tag.name}, {QStringLiteral("color"), tag.color}, {QStringLiteral("checkState"), matched > 0 && matched < pids.size() ? 2 : int(matched > 0)}}); - } - return result; + QVariantList result; + if (!ready()) return result; + auto tags = m_service.state().tags.values(); + std::ranges::sort(tags, [](const Tag& a, const Tag& b) { + return a.name.toCaseFolded() < b.name.toCaseFolded(); + }); + const auto pids = selectedPackageIds(); + for (const auto& tag : tags) { + int matched = 0; + for (const auto& pid : pids) + if (m_service.state().modTags.value(pid).contains(tag.id)) ++matched; + result.append(QVariantMap{ + {QStringLiteral("id"), tag.id}, + {QStringLiteral("name"), tag.name}, + {QStringLiteral("color"), tag.color}, + {QStringLiteral("checkState"), + matched > 0 && matched < pids.size() ? 2 : int(matched > 0)}}); + } + return result; } QVariantList OrganizerViewPanel::ruleRows() const { - QVariantList result; - if (!ready()) return result; - for (const auto& rule : m_service.state().rules) result.append(QVariantMap{{QStringLiteral("field"), QString(ruleFieldName(rule.field))}, {QStringLiteral("op"), QString(ruleOpName(rule.op))}, {QStringLiteral("pattern"), rule.pattern}, {QStringLiteral("folder_id"), rule.folderId}}); - return result; + QVariantList result; + if (!ready()) return result; + for (const auto& rule : m_service.state().rules) + result.append(QVariantMap{ + {QStringLiteral("field"), QString(ruleFieldName(rule.field))}, + {QStringLiteral("op"), QString(ruleOpName(rule.op))}, + {QStringLiteral("pattern"), rule.pattern}, + {QStringLiteral("folder_id"), rule.folderId}}); + return result; } QVariantList OrganizerViewPanel::ruleFolders() const { - QVariantList result; - if (!ready()) return result; - const auto& folders = m_service.state().folders; - QList> paths; - paths.reserve(folders.size() - 1); - for (const auto& folder : folders) { - if (folder.id == kRootId) continue; - QStringList names; - qint64 id = folder.id; - while (id != kRootId) { - const auto& ancestor = *folders.constFind(id); - names.prepend(ancestor.name); - id = *ancestor.parentId; - } - paths.append({names.join(QStringLiteral(" / ")), folder.id}); + QVariantList result; + if (!ready()) return result; + const auto& folders = m_service.state().folders; + QList> paths; + paths.reserve(folders.size() - 1); + for (const auto& folder : folders) { + if (folder.id == kRootId) continue; + QStringList names; + qint64 id = folder.id; + while (id != kRootId) { + const auto& ancestor = *folders.constFind(id); + names.prepend(ancestor.name); + id = *ancestor.parentId; } - std::ranges::sort(paths, [](const auto& a, const auto& b) { - return QString::compare(a.first, b.first, Qt::CaseInsensitive) < 0; - }); - result.reserve(paths.size()); - for (const auto& [name, id] : paths) - result.append(QVariantMap{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); - return result; -} -Result> OrganizerViewPanel::ruleSpecs(const QVariantList& rows) const { - QList specs; - specs.reserve(rows.size()); - for (const auto& value : rows) { - const auto row = value.toMap(); - const auto field = parseRuleField(row.value(QStringLiteral("field")).toString()); - const auto op = parseRuleOp(row.value(QStringLiteral("op")).toString()); - if (!field || !op) return std::unexpected(Error{QStringLiteral("Invalid rule field or operator")}); - specs.append(RuleSpec{*field, *op, row.value(QStringLiteral("pattern")).toString(), row.value(QStringLiteral("folder_id")).toLongLong()}); - } - return specs; -} -QCoro::QmlTask OrganizerViewPanel::requestRulePreview(const QVariantList& rows, int revision) { return calculateRulePreview(rows, revision); } -QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, int revision) { - if (!ready()) co_return; - const auto specs = ruleSpecs(rows); - if (!specs) co_return; - const QPointer alive(this); - auto future = QtConcurrent::run([state = m_service.state(), mods = m_ctx.allMods(), specs = *specs] { return previewRuleMatches(state, mods, specs); }); - const auto preview = co_await future; - if (!alive) co_return; - QVariantList counts; - counts.reserve(preview.counts.size()); - for (int count : preview.counts) counts.append(count); - QVariantMap result; - result.insert(QStringLiteral("counts"), counts); - result.insert(QStringLiteral("assignable"), preview.assignable); - Q_EMIT rulePreviewReady(revision, result); + paths.append({names.join(QStringLiteral(" / ")), folder.id}); + } + std::ranges::sort(paths, [](const auto& a, const auto& b) { + return QString::compare(a.first, b.first, Qt::CaseInsensitive) < 0; + }); + result.reserve(paths.size()); + for (const auto& [name, id] : paths) + result.append(QVariantMap{{QStringLiteral("id"), id}, + {QStringLiteral("name"), name}}); + return result; +} +Result> OrganizerViewPanel::ruleSpecs( + const QVariantList& rows) const { + QList specs; + specs.reserve(rows.size()); + for (const auto& value : rows) { + const auto row = value.toMap(); + const auto field = + parseRuleField(row.value(QStringLiteral("field")).toString()); + const auto op = parseRuleOp(row.value(QStringLiteral("op")).toString()); + if (!field || !op) + return std::unexpected( + Error{QStringLiteral("Invalid rule field or operator")}); + specs.append(RuleSpec{*field, *op, + row.value(QStringLiteral("pattern")).toString(), + row.value(QStringLiteral("folder_id")).toLongLong()}); + } + return specs; +} +QCoro::QmlTask OrganizerViewPanel::requestRulePreview(const QVariantList& rows, + int revision) { + return calculateRulePreview(rows, revision); +} +QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, + int revision) { + if (!ready()) co_return; + const auto specs = ruleSpecs(rows); + if (!specs) co_return; + const QPointer alive(this); + auto future = QtConcurrent::run( + [state = m_service.state(), mods = m_ctx.allMods(), specs = *specs] { + return previewRuleMatches(state, mods, specs); + }); + const auto preview = co_await future; + if (!alive) co_return; + QVariantList counts; + counts.reserve(preview.counts.size()); + for (int count : preview.counts) counts.append(count); + QVariantMap result; + result.insert(QStringLiteral("counts"), counts); + result.insert(QStringLiteral("assignable"), preview.assignable); + Q_EMIT rulePreviewReady(revision, result); } void OrganizerViewPanel::setEditorError(QString message) { - if (message != m_editorError) { m_editorError = std::move(message); Q_EMIT editorChanged(); } + if (message != m_editorError) { + m_editorError = std::move(message); + Q_EMIT editorChanged(); + } } void OrganizerViewPanel::reportError(const Error& error) { - if (auto* toasts = m_app.toasts()) toasts->error(error.message); - else spdlog::warn("organizer: {}", error.message.toStdString()); + if (auto* toasts = m_app.toasts()) + toasts->error(error.message); + else + spdlog::warn("organizer: {}", error.message.toStdString()); +} +void OrganizerViewPanel::treeCommand(const char* method) { + if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, method); +} +void OrganizerViewPanel::openTags() { + setEditorError(); + treeCommand("openTags"); } -void OrganizerViewPanel::treeCommand(const char* method) { if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, method); } -void OrganizerViewPanel::openTags() { setEditorError(); treeCommand("openTags"); } void OrganizerViewPanel::openRules() { - setEditorError(); - auto* root = m_rulesQml->rootObject(); - if (!root) return; - auto* host = window(); - if (!m_rulesOpenedOnce) { - const auto available = host->screen()->availableGeometry(); - m_rulesDialog->resize(std::min({960, std::max(800, host->width() - 48), available.width() - 32}), std::min({760, std::max(600, host->height() - 80), available.height() - 32})); - m_rulesOpenedOnce = true; - } - m_rulesDialog->move(host->frameGeometry().center() - m_rulesDialog->rect().center()); - m_rulesDialog->open(); - m_rulesDialog->activateWindow(); - m_rulesQml->setFocus(Qt::OtherFocusReason); - QMetaObject::invokeMethod(root, "prepareForOpen"); + setEditorError(); + auto* root = m_rulesQml->rootObject(); + if (!root) return; + auto* host = window(); + if (!m_rulesOpenedOnce) { + const auto available = host->screen()->availableGeometry(); + m_rulesDialog->resize(std::min({960, std::max(800, host->width() - 48), + available.width() - 32}), + std::min({760, std::max(600, host->height() - 80), + available.height() - 32})); + m_rulesOpenedOnce = true; + } + m_rulesDialog->move(host->frameGeometry().center() - + m_rulesDialog->rect().center()); + m_rulesDialog->open(); + m_rulesDialog->activateWindow(); + m_rulesQml->setFocus(Qt::OtherFocusReason); + QMetaObject::invokeMethod(root, "prepareForOpen"); } void OrganizerViewPanel::closeRules() { m_rulesDialog->close(); } -QCoro::QmlTask OrganizerViewPanel::createTag(QString name, QString color) { return mutateTag(QStringLiteral("create"), 0, std::move(name), std::move(color)); } -QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, QString color) { return mutateTag(QStringLiteral("update"), id, std::move(name), std::move(color)); } -QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { return mutateTag(QStringLiteral("delete"), id); } -QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { return mutateTag(QStringLiteral("assign"), id, {}, {}, add); } -QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, QString name, QString color, bool add) { - const QPointer alive(this); - if (operation == QStringLiteral("create")) { - const auto result = co_await m_service.createTag(std::move(name), std::move(color)); - if (!alive) co_return; - if (!result) { setEditorError(result.error().message); co_return; } - Q_EMIT tagCreated(); - } else { - Result result; - if (operation == QStringLiteral("update")) result = co_await m_service.updateTag(id, std::move(name), std::move(color)); - else if (operation == QStringLiteral("delete")) result = co_await m_service.deleteTag(id); - else result = co_await m_service.setModTags(selectedPackageIds(), add ? QSet{id} : QSet{}, add ? QSet{} : QSet{id}); - if (!alive) co_return; - if (!result) { setEditorError(result.error().message); co_return; } - if (operation == QStringLiteral("update")) Q_EMIT tagUpdated(id); +QCoro::QmlTask OrganizerViewPanel::createTag(QString name, QString color) { + return mutateTag(QStringLiteral("create"), 0, std::move(name), + std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, + QString color) { + return mutateTag(QStringLiteral("update"), id, std::move(name), + std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { + return mutateTag(QStringLiteral("delete"), id); +} +QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { + return mutateTag(QStringLiteral("assign"), id, {}, {}, add); +} +QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, + QString name, QString color, + bool add) { + const QPointer alive(this); + if (operation == QStringLiteral("create")) { + const auto result = + co_await m_service.createTag(std::move(name), std::move(color)); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; } - setEditorError(); + Q_EMIT tagCreated(); + } else { + Result result; + if (operation == QStringLiteral("update")) + result = + co_await m_service.updateTag(id, std::move(name), std::move(color)); + else if (operation == QStringLiteral("delete")) + result = co_await m_service.deleteTag(id); + else + result = co_await m_service.setModTags( + selectedPackageIds(), add ? QSet{id} : QSet{}, + add ? QSet{} : QSet{id}); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + if (operation == QStringLiteral("update")) Q_EMIT tagUpdated(id); + } + setEditorError(); +} +QCoro::QmlTask OrganizerViewPanel::saveRules(QVariantList rows) { + return persistRules(std::move(rows)); } -QCoro::QmlTask OrganizerViewPanel::saveRules(QVariantList rows) { return persistRules(std::move(rows)); } QCoro::Task OrganizerViewPanel::persistRules(QVariantList rows) { - const QPointer alive(this); - auto specs = ruleSpecs(rows); - if (!specs) { setEditorError(specs.error().message); co_return; } - const auto result = co_await m_service.setRules(std::move(*specs)); - if (!alive) co_return; - if (!result) { setEditorError(result.error().message); co_return; } - setEditorError(); - Q_EMIT rulesSaved(); + const QPointer alive(this); + auto specs = ruleSpecs(rows); + if (!specs) { + setEditorError(specs.error().message); + co_return; + } + const auto result = co_await m_service.setRules(std::move(*specs)); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + setEditorError(); + Q_EMIT rulesSaved(); +} +QCoro::QmlTask OrganizerViewPanel::addStandardRules() { + return standardRules(); } -QCoro::QmlTask OrganizerViewPanel::addStandardRules() { return standardRules(); } QCoro::Task OrganizerViewPanel::standardRules() { - const QPointer alive(this); - const auto result = co_await m_service.addStandardRules(); - if (!alive) co_return; - setEditorError(result ? QString{} : result.error().message); + const QPointer alive(this); + const auto result = co_await m_service.addStandardRules(); + if (!alive) co_return; + setEditorError(result ? QString{} : result.error().message); } QVariantList OrganizerViewPanel::expandedIndexes() const { - QVariantList result; - for (const auto& index : m_model->expandedIndexes()) result.append(QVariant::fromValue(index)); - return result; -} -void OrganizerViewPanel::selectRow(const QModelIndex& index, int modifiers, const QVariantList& range) { - if ((modifiers & Qt::ShiftModifier) && m_selectionAnchor.isValid()) { - QList indexes; - for (const auto& value : range) indexes.append(value.value()); - m_model->select(indexes); - } else { - m_model->select({index}, modifiers & Qt::ControlModifier ? QStringLiteral("toggle") : QStringLiteral("replace")); - m_selectionAnchor = index; - } - syncInfo(); + QVariantList result; + for (const auto& index : m_model->expandedIndexes()) + result.append(QVariant::fromValue(index)); + return result; +} +void OrganizerViewPanel::selectRow(const QModelIndex& index, int modifiers, + const QVariantList& range) { + if ((modifiers & Qt::ShiftModifier) && m_selectionAnchor.isValid()) { + QList indexes; + for (const auto& value : range) indexes.append(value.value()); + m_model->select(indexes); + } else { + m_model->select({index}, modifiers & Qt::ControlModifier + ? QStringLiteral("toggle") + : QStringLiteral("replace")); + m_selectionAnchor = index; + } + syncInfo(); } void OrganizerViewPanel::selectSingle(const QModelIndex& index) { - if (!m_model->isSelected(index)) m_model->select({index}); - m_selectionAnchor = index; - syncInfo(); + if (!m_model->isSelected(index)) m_model->select({index}); + m_selectionAnchor = index; + syncInfo(); } -QCoro::QmlTask OrganizerViewPanel::persistAllCollapsed(bool collapsed) { return persistAllCollapse(collapsed); } -QCoro::Task OrganizerViewPanel::persistAllCollapse(bool collapsed) { - const QPointer alive(this); - const auto result = co_await m_service.setAllCollapsed(collapsed); - if (!alive) co_return; - if (!result) reportError(result.error()); +QCoro::QmlTask OrganizerViewPanel::persistAllCollapsed(bool collapsed) { + return persistAllCollapse(collapsed); } -QCoro::QmlTask OrganizerViewPanel::persistCollapsed(const QModelIndex& index, bool collapsed) { return persistCollapse(index, collapsed); } -QCoro::Task OrganizerViewPanel::persistCollapse(QModelIndex index, bool collapsed) { - const auto* node = m_model->nodeAt(index); - if (!node || !node->isContainer() || m_model->filtering()) co_return; - const QPointer alive(this); - const auto result = co_await m_service.setCollapsed(node->folderId(), collapsed); - if (!alive) co_return; - if (!result) reportError(result.error()); +QCoro::Task OrganizerViewPanel::persistAllCollapse(bool collapsed) { + const QPointer alive(this); + const auto result = co_await m_service.setAllCollapsed(collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::persistCollapsed(const QModelIndex& index, + bool collapsed) { + return persistCollapse(index, collapsed); +} +QCoro::Task OrganizerViewPanel::persistCollapse(QModelIndex index, + bool collapsed) { + const auto* node = m_model->nodeAt(index); + if (!node || !node->isContainer() || m_model->filtering()) co_return; + const QPointer alive(this); + const auto result = + co_await m_service.setCollapsed(node->folderId(), collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { + return toggle(index); } -QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { return toggle(index); } QCoro::Task OrganizerViewPanel::toggle(QModelIndex index) { - const auto* node = m_model->nodeAt(index); - if (!node) co_return; - if (node->leaf) co_await toggleMods(m_ctx, this, {node->leaf->uuid}); - else if (node->isContainer()) { - const auto change = m_service.folderToggle(node->folderId(), node->kind != QStringLiteral("ungrouped")); - if (!change) { reportError(change.error()); co_return; } - if (!change->enable.isEmpty() || !change->disable.isEmpty()) co_await applyActivation(m_ctx, this, change->enable, change->disable); + const auto* node = m_model->nodeAt(index); + if (!node) co_return; + if (node->leaf) + co_await toggleMods(m_ctx, this, {node->leaf->uuid}); + else if (node->isContainer()) { + const auto change = m_service.folderToggle( + node->folderId(), node->kind != QStringLiteral("ungrouped")); + if (!change) { + reportError(change.error()); + co_return; } + if (!change->enable.isEmpty() || !change->disable.isEmpty()) + co_await applyActivation(m_ctx, this, change->enable, change->disable); + } +} +QCoro::QmlTask OrganizerViewPanel::selectionAction(const QString& action) { + return runSelectionAction(action); } -QCoro::QmlTask OrganizerViewPanel::selectionAction(const QString& action) { return runSelectionAction(action); } QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { - const auto uuids = selectedUuids(); - if (uuids.isEmpty()) co_return; - if (action == QStringLiteral("tags")) openTags(); - else if (action == QStringLiteral("move") || action == QStringLiteral("create")) { - const auto pids = selectedPackageIds(); - if (!pids.isEmpty()) co_await editFolder(action, kRootId, pids); - } else if (action == QStringLiteral("update")) co_await updateMods(m_ctx, uuids, this); - else if (action == QStringLiteral("enable")) co_await applyActivation(m_ctx, this, uuids); - else if (action == QStringLiteral("disable")) co_await applyActivation(m_ctx, this, {}, uuids); -} -QCoro::QmlTask OrganizerViewPanel::newFolder() { return editFolder(QStringLiteral("new")); } -QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, QStringList pids) { - const QPointer alive(this); - Result result; - if (action == QStringLiteral("new") || action == QStringLiteral("create") || action == QStringLiteral("rename")) { - const bool rename = action == QStringLiteral("rename"); - const QString initial = rename ? m_service.state().folders.value(id).name : QString{}; - QString title; - if (rename) title = QStringLiteral("Rename Folder"); - else if (action == QStringLiteral("create")) title = QStringLiteral("Create Folder from Selection"); - else title = QStringLiteral("New Folder"); - const auto nameResult = co_await awaitDialog(title, initial, this, rename ? QStringLiteral("Rename") : QStringLiteral("Create")); - const int code = nameResult.first; - auto* const dialog = nameResult.second; - if (!alive) co_return; - if (code != QDialog::Accepted) co_return; - const auto name = dialog->textValue(); - if (rename) result = co_await m_service.renameFolder(id, name); - else { - Result created; - if (action == QStringLiteral("create")) created = co_await m_service.createFolderFrom(name, pids); - else created = co_await m_service.createFolder(name, id); - if (!alive) co_return; - if (!created) reportError(created.error()); - co_return; - } - } else if (action == QStringLiteral("move") || action == QStringLiteral("moveFolder")) { - QSet allowed; - if (action == QStringLiteral("moveFolder")) { - const auto targets = m_service.folderMoveTargets(id); - if (!targets) { reportError(targets.error()); co_return; } - allowed = *targets; - } else { - for (auto it = m_service.state().folders.cbegin(); it != m_service.state().folders.cend(); ++it) allowed.insert(it.key()); - } - const auto pickerResult = co_await awaitDialog(m_service.tree(), action == QStringLiteral("move") ? QStringLiteral("Move to Folder") : QStringLiteral("Move Folder"), allowed, this); - const int code = pickerResult.first; - auto* const dialog = pickerResult.second; - if (!alive) co_return; - if (code != QDialog::Accepted) co_return; - const auto destination = dialog->folderId(); - if (action == QStringLiteral("moveFolder")) result = co_await m_service.moveFolder(id, destination); - else { co_await placeMods(pids, destination); co_return; } - } else if (action == QStringLiteral("delete")) { - const auto deleteResult = co_await awaitDialog(m_service.state().folders.value(id).name, this); - const int code = deleteResult.first; - if (!alive) co_return; - if (code != QMessageBox::Yes) co_return; - result = co_await m_service.deleteFolder(id); + const auto uuids = selectedUuids(); + if (uuids.isEmpty()) co_return; + if (action == QStringLiteral("tags")) + openTags(); + else if (action == QStringLiteral("move") || + action == QStringLiteral("create")) { + const auto pids = selectedPackageIds(); + if (!pids.isEmpty()) co_await editFolder(action, kRootId, pids); + } else if (action == QStringLiteral("update")) + co_await updateMods(m_ctx, uuids, this); + else if (action == QStringLiteral("enable")) + co_await applyActivation(m_ctx, this, uuids); + else if (action == QStringLiteral("disable")) + co_await applyActivation(m_ctx, this, {}, uuids); +} +QCoro::QmlTask OrganizerViewPanel::newFolder() { + return editFolder(QStringLiteral("new")); +} +QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, + QStringList pids) { + const QPointer alive(this); + Result result; + if (action == QStringLiteral("new") || action == QStringLiteral("create") || + action == QStringLiteral("rename")) { + const bool rename = action == QStringLiteral("rename"); + const QString initial = + rename ? m_service.state().folders.value(id).name : QString{}; + QString title; + if (rename) + title = QStringLiteral("Rename Folder"); + else if (action == QStringLiteral("create")) + title = QStringLiteral("Create Folder from Selection"); + else + title = QStringLiteral("New Folder"); + const auto nameResult = co_await awaitDialog( + title, initial, this, + rename ? QStringLiteral("Rename") : QStringLiteral("Create")); + const int code = nameResult.first; + auto* const dialog = nameResult.second; + if (!alive) co_return; + if (code != QDialog::Accepted) co_return; + const auto name = dialog->textValue(); + if (rename) + result = co_await m_service.renameFolder(id, name); + else { + Result created; + if (action == QStringLiteral("create")) + created = co_await m_service.createFolderFrom(name, pids); + else + created = co_await m_service.createFolder(name, id); + if (!alive) co_return; + if (!created) reportError(created.error()); + co_return; } + } else if (action == QStringLiteral("move") || + action == QStringLiteral("moveFolder")) { + QSet allowed; + if (action == QStringLiteral("moveFolder")) { + const auto targets = m_service.folderMoveTargets(id); + if (!targets) { + reportError(targets.error()); + co_return; + } + allowed = *targets; + } else { + for (auto it = m_service.state().folders.cbegin(); + it != m_service.state().folders.cend(); ++it) + allowed.insert(it.key()); + } + const auto pickerResult = co_await awaitDialog( + m_service.tree(), + action == QStringLiteral("move") ? QStringLiteral("Move to Folder") + : QStringLiteral("Move Folder"), + allowed, this); + const int code = pickerResult.first; + auto* const dialog = pickerResult.second; if (!alive) co_return; - if (!result) reportError(result.error()); -} -QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, qint64 destination) { - const QPointer alive(this); - Result result; - if (destination == kRootId) result = co_await m_service.ungroup(pids); - else result = co_await m_service.place(pids, destination); + if (code != QDialog::Accepted) co_return; + const auto destination = dialog->folderId(); + if (action == QStringLiteral("moveFolder")) + result = co_await m_service.moveFolder(id, destination); + else { + co_await placeMods(pids, destination); + co_return; + } + } else if (action == QStringLiteral("delete")) { + const auto deleteResult = co_await awaitDialog( + m_service.state().folders.value(id).name, this); + const int code = deleteResult.first; if (!alive) co_return; - if (!result) reportError(result.error()); + if (code != QMessageBox::Yes) co_return; + result = co_await m_service.deleteFolder(id); + } + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, + qint64 destination) { + const QPointer alive(this); + Result result; + if (destination == kRootId) + result = co_await m_service.ungroup(pids); + else + result = co_await m_service.place(pids, destination); + if (!alive) co_return; + if (!result) reportError(result.error()); } QCoro::Task OrganizerViewPanel::resetRules(QStringList pids) { - const QPointer alive(this); - const auto result = co_await m_service.resetToRules(pids); - if (!alive) co_return; - if (!result) reportError(result.error()); + const QPointer alive(this); + const auto result = co_await m_service.resetToRules(pids); + if (!alive) co_return; + if (!result) reportError(result.error()); } void OrganizerViewPanel::contextMenu(const QModelIndex& index) { - const auto* node = m_model->nodeAt(index); - m_menu->clear(); - if (!node) m_menu->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); - else if (node->leaf) { selectSingle(index); modMenu(); } - else { m_model->select({index}); folderMenu(*node); } - syncInfo(); - m_menu->popup(QCursor::pos()); + const auto* node = m_model->nodeAt(index); + m_menu->clear(); + if (!node) + m_menu->addAction(QStringLiteral("New folder…"), this, + &OrganizerViewPanel::newFolder); + else if (node->leaf) { + selectSingle(index); + modMenu(); + } else { + m_model->select({index}); + folderMenu(*node); + } + syncInfo(); + m_menu->popup(QCursor::pos()); } void OrganizerViewPanel::modMenu() { - const auto nodes = m_model->selectedNodes(); - const auto uuids = selectedUuids(); - bool inactive = false, active = false; - for (const auto& uuid : uuids) { if (m_ctx.activeUuids().contains(uuid)) active = true; else inactive = true; } - if (inactive) m_menu->addAction(QStringLiteral("Enable"), this, [this, uuids] { applyActivation(m_ctx, this, uuids); }); - if (active) m_menu->addAction(QStringLiteral("Disable"), this, [this, uuids] { applyActivation(m_ctx, this, {}, uuids); }); - addUpdateAction(m_menu, m_ctx, uuids, [this, uuids] { updateMods(m_ctx, uuids, this); }); - m_menu->addSeparator(); - const auto pids = selectedPackageIds(); - bool placeable = !pids.isEmpty(), grouped = false; - QStringList manual; - for (const auto* node : nodes) if (node->leaf) { - placeable &= node->leaf->packageId.has_value(); - grouped |= node->folderId() != kRootId; - if (node->leaf->packageId && node->leaf->placement == Placement::kManual && !manual.contains(*node->leaf->packageId)) manual.append(*node->leaf->packageId); + const auto nodes = m_model->selectedNodes(); + const auto uuids = selectedUuids(); + bool inactive = false, active = false; + for (const auto& uuid : uuids) { + if (m_ctx.activeUuids().contains(uuid)) + active = true; + else + inactive = true; + } + if (inactive) + m_menu->addAction(QStringLiteral("Enable"), this, + [this, uuids] { applyActivation(m_ctx, this, uuids); }); + if (active) + m_menu->addAction(QStringLiteral("Disable"), this, [this, uuids] { + applyActivation(m_ctx, this, {}, uuids); + }); + addUpdateAction(m_menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); + const auto pids = selectedPackageIds(); + bool placeable = !pids.isEmpty(), grouped = false; + QStringList manual; + for (const auto* node : nodes) + if (node->leaf) { + placeable &= node->leaf->packageId.has_value(); + grouped |= node->folderId() != kRootId; + if (node->leaf->packageId && + node->leaf->placement == Placement::kManual && + !manual.contains(*node->leaf->packageId)) + manual.append(*node->leaf->packageId); } - auto* move = m_menu->addAction(QStringLiteral("Move to folder…"), this, [this, pids] { editFolder(QStringLiteral("move"), kRootId, pids); }); - auto* remove = m_menu->addAction(QStringLiteral("Remove from folder"), this, [this, pids] { placeMods(pids, kRootId); }); - auto* create = m_menu->addAction(QStringLiteral("Create folder from selection…"), this, [this, pids] { editFolder(QStringLiteral("create"), kRootId, pids); }); - for (auto* action : {move, remove, create}) { action->setEnabled(placeable); action->setToolTip(placeable ? QString{} : QStringLiteral("Mods without a package ID cannot be placed in folders")); } - remove->setEnabled(placeable && grouped); - m_menu->addAction(QStringLiteral("Manage tags…"), this, &OrganizerViewPanel::openTags)->setEnabled(placeable); - if (placeable && !manual.isEmpty()) m_menu->addAction(QStringLiteral("Use auto rules"), this, [this, manual] { resetRules(manual); }); + auto* move = m_menu->addAction( + QStringLiteral("Move to folder…"), this, + [this, pids] { editFolder(QStringLiteral("move"), kRootId, pids); }); + auto* remove = m_menu->addAction(QStringLiteral("Remove from folder"), this, + [this, pids] { placeMods(pids, kRootId); }); + auto* create = m_menu->addAction( + QStringLiteral("Create folder from selection…"), this, + [this, pids] { editFolder(QStringLiteral("create"), kRootId, pids); }); + for (auto* action : {move, remove, create}) { + action->setEnabled(placeable); + action->setToolTip( + placeable + ? QString{} + : QStringLiteral( + "Mods without a package ID cannot be placed in folders")); + } + remove->setEnabled(placeable && grouped); + m_menu + ->addAction(QStringLiteral("Manage tags…"), this, + &OrganizerViewPanel::openTags) + ->setEnabled(placeable); + if (placeable && !manual.isEmpty()) + m_menu->addAction(QStringLiteral("Use auto rules"), this, + [this, manual] { resetRules(manual); }); } void OrganizerViewPanel::folderMenu(const TreeNode& node) { - const auto id = node.folderId(); - const auto result = m_service.folderModUuids(id, node.kind != QStringLiteral("ungrouped")); - if (!result) { reportError(result.error()); return; } - const auto uuids = *result; - m_menu->addAction(QStringLiteral("Enable all"), this, [this, uuids] { applyActivation(m_ctx, this, uuids); })->setEnabled(!uuids.isEmpty()); - m_menu->addAction(QStringLiteral("Disable all"), this, [this, uuids] { applyActivation(m_ctx, this, {}, uuids); })->setEnabled(!uuids.isEmpty()); - addUpdateAction(m_menu, m_ctx, uuids, [this, uuids] { updateMods(m_ctx, uuids, this); }); - m_menu->addSeparator(); - if (node.kind == QStringLiteral("ungrouped")) { m_menu->addAction(QStringLiteral("New folder…"), this, &OrganizerViewPanel::newFolder); return; } - if (node.folder && node.folder->depth < kMaxDepth) m_menu->addAction(QStringLiteral("New subfolder…"), this, [this, id] { editFolder(QStringLiteral("new"), id); }); - m_menu->addAction(QStringLiteral("Rename…"), this, [this, id] { editFolder(QStringLiteral("rename"), id); }); - const auto targets = m_service.folderMoveTargets(id); - m_menu->addAction(QStringLiteral("Move to…"), this, [this, id] { editFolder(QStringLiteral("moveFolder"), id); })->setEnabled(targets && !targets->isEmpty()); - m_menu->addAction(QStringLiteral("Delete…"), this, [this, id] { editFolder(QStringLiteral("delete"), id); }); -} -bool OrganizerViewPanel::canDrop(const QModelIndex& source, const QModelIndex& target) const { - const auto* src = m_model->nodeAt(source); - const auto* dst = m_model->nodeAt(target); - if (!src || !dst || !dst->isContainer() || src == dst) return false; - if (src->leaf) { - if (!m_model->isSelected(source)) return src->leaf->packageId.has_value(); - bool any = false; - for (const auto* node : m_model->selectedNodes()) { - if (!node->leaf) continue; - any = true; - if (!node->leaf->packageId) return false; - } - return any; - } - if (src->kind == QStringLiteral("folder")) { const auto targets = m_service.folderMoveTargets(src->folderId()); return targets && targets->contains(dst->folderId()); } - return false; -} -QCoro::QmlTask OrganizerViewPanel::dropOn(const QModelIndex& source, const QModelIndex& target) { return performDrop(source, target); } -QCoro::Task OrganizerViewPanel::performDrop(QModelIndex source, QModelIndex target) { - const QPointer alive(this); - if (!canDrop(source, target)) co_return; - const auto* src = m_model->nodeAt(source); - const auto* dst = m_model->nodeAt(target); - const auto destination = dst->folderId(); - if (src->kind == QStringLiteral("folder")) { - const auto result = co_await m_service.moveFolder(src->folderId(), destination); - if (!alive) co_return; - if (!result) reportError(result.error()); - } else { - const auto pids = m_model->isSelected(source) ? selectedPackageIds() : QStringList{*src->leaf->packageId}; - co_await placeMods(pids, destination); + const auto id = node.folderId(); + const auto result = + m_service.folderModUuids(id, node.kind != QStringLiteral("ungrouped")); + if (!result) { + reportError(result.error()); + return; + } + const auto uuids = *result; + m_menu + ->addAction(QStringLiteral("Enable all"), this, + [this, uuids] { applyActivation(m_ctx, this, uuids); }) + ->setEnabled(!uuids.isEmpty()); + m_menu + ->addAction(QStringLiteral("Disable all"), this, + [this, uuids] { applyActivation(m_ctx, this, {}, uuids); }) + ->setEnabled(!uuids.isEmpty()); + addUpdateAction(m_menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); + if (node.kind == QStringLiteral("ungrouped")) { + m_menu->addAction(QStringLiteral("New folder…"), this, + &OrganizerViewPanel::newFolder); + return; + } + if (node.folder && node.folder->depth < kMaxDepth) + m_menu->addAction(QStringLiteral("New subfolder…"), this, + [this, id] { editFolder(QStringLiteral("new"), id); }); + m_menu->addAction(QStringLiteral("Rename…"), this, + [this, id] { editFolder(QStringLiteral("rename"), id); }); + const auto targets = m_service.folderMoveTargets(id); + m_menu + ->addAction(QStringLiteral("Move to…"), this, + [this, id] { editFolder(QStringLiteral("moveFolder"), id); }) + ->setEnabled(targets && !targets->isEmpty()); + m_menu->addAction(QStringLiteral("Delete…"), this, + [this, id] { editFolder(QStringLiteral("delete"), id); }); +} +bool OrganizerViewPanel::canDrop(const QModelIndex& source, + const QModelIndex& target) const { + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + if (!src || !dst || !dst->isContainer() || src == dst) return false; + if (src->leaf) { + if (!m_model->isSelected(source)) return src->leaf->packageId.has_value(); + bool any = false; + for (const auto* node : m_model->selectedNodes()) { + if (!node->leaf) continue; + any = true; + if (!node->leaf->packageId) return false; } -} -} + return any; + } + if (src->kind == QStringLiteral("folder")) { + const auto targets = m_service.folderMoveTargets(src->folderId()); + return targets && targets->contains(dst->folderId()); + } + return false; +} +QCoro::QmlTask OrganizerViewPanel::dropOn(const QModelIndex& source, + const QModelIndex& target) { + return performDrop(source, target); +} +QCoro::Task OrganizerViewPanel::performDrop(QModelIndex source, + QModelIndex target) { + const QPointer alive(this); + if (!canDrop(source, target)) co_return; + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + const auto destination = dst->folderId(); + if (src->kind == QStringLiteral("folder")) { + const auto result = + co_await m_service.moveFolder(src->folderId(), destination); + if (!alive) co_return; + if (!result) reportError(result.error()); + } else { + const auto pids = m_model->isSelected(source) + ? selectedPackageIds() + : QStringList{*src->leaf->packageId}; + co_await placeMods(pids, destination); + } +} +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.hpp b/src/ui/plugins/organizer/view.hpp index 8573859..1f21ce9 100644 --- a/src/ui/plugins/organizer/view.hpp +++ b/src/ui/plugins/organizer/view.hpp @@ -1,12 +1,14 @@ #pragma once -#include -#include #include #include #include +#include +#include + #include #include + #include "core/organizer/service.hpp" #include "ui/views/base.hpp" @@ -27,104 +29,112 @@ class ModSelectionPresenter; struct TreeNode; class OrganizerViewPanel final : public BaseViewPanel { - Q_OBJECT - Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) - Q_PROPERTY(int selectedTagCount READ selectedTagCount NOTIFY editorChanged) - Q_PROPERTY(QVariantList tagRows READ tagRows NOTIFY editorChanged) - Q_PROPERTY(QVariantList ruleRows READ ruleRows NOTIFY editorChanged) - Q_PROPERTY(QVariantList ruleFolders READ ruleFolders NOTIFY editorChanged) - Q_PROPERTY(QString editorError READ editorError NOTIFY editorChanged) -public: - static constexpr RailViewMeta kRailMeta{QLatin1StringView("organizer"), QLatin1StringView("folder"), QLatin1StringView("Organizer")}; - OrganizerViewPanel(CoreContext&, QQmlEngine*, QWidget*, AppContext&); - ~OrganizerViewPanel() override; - bool ready() const; - int selectedTagCount() const; - QVariantList tagRows() const; - QVariantList ruleRows() const; - QVariantList ruleFolders() const; - QString editorError() const { return m_editorError; } - Q_INVOKABLE QCoro::QmlTask requestRulePreview(const QVariantList&, int revision); - Q_INVOKABLE void openTags(); - Q_INVOKABLE void openRules(); - Q_INVOKABLE void closeRules(); - Q_INVOKABLE QCoro::QmlTask createTag(QString name, QString color); - Q_INVOKABLE QCoro::QmlTask updateTag(qint64 id, QString name, QString color); - Q_INVOKABLE QCoro::QmlTask deleteTag(qint64 id); - Q_INVOKABLE QCoro::QmlTask assignTag(qint64 id, bool add); - Q_INVOKABLE QCoro::QmlTask saveRules(QVariantList rows); - Q_INVOKABLE QCoro::QmlTask addStandardRules(); - Q_INVOKABLE QVariantList expandedIndexes() const; - Q_INVOKABLE void selectRow(const QModelIndex&, int modifiers, const QVariantList& range); - Q_INVOKABLE void selectSingle(const QModelIndex&); - Q_INVOKABLE QCoro::QmlTask persistAllCollapsed(bool); - Q_INVOKABLE QCoro::QmlTask persistCollapsed(const QModelIndex&, bool); - Q_INVOKABLE QCoro::QmlTask toggleCheck(const QModelIndex&); - Q_INVOKABLE void contextMenu(const QModelIndex&); - Q_INVOKABLE QCoro::QmlTask selectionAction(const QString&); - Q_INVOKABLE QCoro::QmlTask newFolder(); - Q_INVOKABLE bool canDrop(const QModelIndex&, const QModelIndex&) const; - Q_INVOKABLE QCoro::QmlTask dropOn(const QModelIndex&, const QModelIndex&); - void rebuild(); -Q_SIGNALS: - void readyChanged(); - void editorChanged(); - void tagCreated(); - void tagUpdated(qint64 id); - void rulesSaved(); - void rulePreviewReady(int revision, const QVariantMap& result); -protected: - void showEvent(QShowEvent*) override; -private: - void invalidate(); - void syncInfo(bool force = false); - void treeCommand(const char*); - void setEditorError(QString message = {}); - void reportError(const Error&); - QStringList selectedPackageIds() const; - QStringList selectedUuids() const; - Result> ruleSpecs(const QVariantList&) const; - QCoro::Task calculateRulePreview(QVariantList, int); - QCoro::Task mutateTag(QString operation, qint64 id, QString name = {}, QString color = {}, bool add = false); - QCoro::Task persistRules(QVariantList); - QCoro::Task standardRules(); - QCoro::Task persistCollapse(QModelIndex, bool); - QCoro::Task persistAllCollapse(bool); - QCoro::Task toggle(QModelIndex); - QCoro::Task runSelectionAction(QString); - QCoro::Task editFolder(QString action, qint64 id = organizer::kRootId, QStringList pids = {}); - QCoro::Task placeMods(QStringList, qint64); - QCoro::Task resetRules(QStringList); - QCoro::Task performDrop(QModelIndex, QModelIndex); - void modMenu(); - void folderMenu(const TreeNode&); + Q_OBJECT + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(int selectedTagCount READ selectedTagCount NOTIFY editorChanged) + Q_PROPERTY(QVariantList tagRows READ tagRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleRows READ ruleRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleFolders READ ruleFolders NOTIFY editorChanged) + Q_PROPERTY(QString editorError READ editorError NOTIFY editorChanged) + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("organizer"), + QLatin1StringView("folder"), + QLatin1StringView("Organizer")}; + OrganizerViewPanel(CoreContext&, QQmlEngine*, QWidget*, AppContext&); + ~OrganizerViewPanel() override; + bool ready() const; + int selectedTagCount() const; + QVariantList tagRows() const; + QVariantList ruleRows() const; + QVariantList ruleFolders() const; + QString editorError() const { return m_editorError; } + Q_INVOKABLE QCoro::QmlTask requestRulePreview(const QVariantList&, + int revision); + Q_INVOKABLE void openTags(); + Q_INVOKABLE void openRules(); + Q_INVOKABLE void closeRules(); + Q_INVOKABLE QCoro::QmlTask createTag(QString name, QString color); + Q_INVOKABLE QCoro::QmlTask updateTag(qint64 id, QString name, QString color); + Q_INVOKABLE QCoro::QmlTask deleteTag(qint64 id); + Q_INVOKABLE QCoro::QmlTask assignTag(qint64 id, bool add); + Q_INVOKABLE QCoro::QmlTask saveRules(QVariantList rows); + Q_INVOKABLE QCoro::QmlTask addStandardRules(); + Q_INVOKABLE QVariantList expandedIndexes() const; + Q_INVOKABLE void selectRow(const QModelIndex&, int modifiers, + const QVariantList& range); + Q_INVOKABLE void selectSingle(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask persistAllCollapsed(bool); + Q_INVOKABLE QCoro::QmlTask persistCollapsed(const QModelIndex&, bool); + Q_INVOKABLE QCoro::QmlTask toggleCheck(const QModelIndex&); + Q_INVOKABLE void contextMenu(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask selectionAction(const QString&); + Q_INVOKABLE QCoro::QmlTask newFolder(); + Q_INVOKABLE bool canDrop(const QModelIndex&, const QModelIndex&) const; + Q_INVOKABLE QCoro::QmlTask dropOn(const QModelIndex&, const QModelIndex&); + void rebuild(); + Q_SIGNALS: + void readyChanged(); + void editorChanged(); + void tagCreated(); + void tagUpdated(qint64 id); + void rulesSaved(); + void rulePreviewReady(int revision, const QVariantMap& result); + + protected: + void showEvent(QShowEvent*) override; + + private: + void invalidate(); + void syncInfo(bool force = false); + void treeCommand(const char*); + void setEditorError(QString message = {}); + void reportError(const Error&); + QStringList selectedPackageIds() const; + QStringList selectedUuids() const; + Result> ruleSpecs(const QVariantList&) const; + QCoro::Task calculateRulePreview(QVariantList, int); + QCoro::Task mutateTag(QString operation, qint64 id, QString name = {}, + QString color = {}, bool add = false); + QCoro::Task persistRules(QVariantList); + QCoro::Task standardRules(); + QCoro::Task persistCollapse(QModelIndex, bool); + QCoro::Task persistAllCollapse(bool); + QCoro::Task toggle(QModelIndex); + QCoro::Task runSelectionAction(QString); + QCoro::Task editFolder(QString action, qint64 id = organizer::kRootId, + QStringList pids = {}); + QCoro::Task placeMods(QStringList, qint64); + QCoro::Task resetRules(QStringList); + QCoro::Task performDrop(QModelIndex, QModelIndex); + void modMenu(); + void folderMenu(const TreeNode&); - organizer::OrganizerService& m_service; - ModTreeModel* m_model; - OrganizerFilterModel* m_filters; - ModInfoPanel* m_info; - std::unique_ptr m_selection; - QQuickWidget* m_qml; - QQuickWidget* m_rulesQml; - QDialog* m_rulesDialog; - QLineEdit* m_search; - QLabel* m_status; - QLabel* m_selectionLabel; - QWidget* m_selectionBar; - QPushButton* m_updateButton; - QMenu* m_menu; - QTimer* m_searchTimer; - QPersistentModelIndex m_selectionAnchor; - QString m_selectedUuid; - QString m_filterKey = QStringLiteral("all"); - QString m_searchText; - QString m_editorError; - bool m_dirty = true; - bool m_rulesOpenedOnce = false; - std::uint64_t m_changedId = 0; - std::uint64_t m_activeId = 0; - std::uint64_t m_modsId = 0; - std::uint64_t m_diagnosticsId = 0; - std::uint64_t m_busyId = 0; + organizer::OrganizerService& m_service; + ModTreeModel* m_model; + OrganizerFilterModel* m_filters; + ModInfoPanel* m_info; + std::unique_ptr m_selection; + QQuickWidget* m_qml; + QQuickWidget* m_rulesQml; + QDialog* m_rulesDialog; + QLineEdit* m_search; + QLabel* m_status; + QLabel* m_selectionLabel; + QWidget* m_selectionBar; + QPushButton* m_updateButton; + QMenu* m_menu; + QTimer* m_searchTimer; + QPersistentModelIndex m_selectionAnchor; + QString m_selectedUuid; + QString m_filterKey = QStringLiteral("all"); + QString m_searchText; + QString m_editorError; + bool m_dirty = true; + bool m_rulesOpenedOnce = false; + std::uint64_t m_changedId = 0; + std::uint64_t m_activeId = 0; + std::uint64_t m_modsId = 0; + std::uint64_t m_diagnosticsId = 0; + std::uint64_t m_busyId = 0; }; -} +} // namespace px::modrim diff --git a/src/ui/plugins/steam_downloader/plugin.cpp b/src/ui/plugins/steam_downloader/plugin.cpp index 6f73674..503fdaf 100644 --- a/src/ui/plugins/steam_downloader/plugin.cpp +++ b/src/ui/plugins/steam_downloader/plugin.cpp @@ -3,17 +3,26 @@ #ifdef PX_HAS_STEAM namespace px::modrim { -SteamSettingsSection::SteamSettingsSection(PluginConfig& settings, QObject* parent) +SteamSettingsSection::SteamSettingsSection( + PluginConfig& settings, QObject* parent) : PluginConfigSection(settings, parent) {} -QString SteamSettingsSection::title() const { return QStringLiteral("Steam Workshop"); } -QUrl SteamSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamSettings.qml")); } +QString SteamSettingsSection::title() const { + return QStringLiteral("Steam Workshop"); +} +QUrl SteamSettingsSection::source() const { + return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamSettings.qml")); +} SteamDownloaderUiPlugin::SteamDownloaderUiPlugin() - : UiPlugin(QStringLiteral("steam_downloader_ui"), {QStringLiteral("steam_downloader")}) {} + : UiPlugin(QStringLiteral("steam_downloader_ui"), + {QStringLiteral("steam_downloader")}) {} void SteamDownloaderUiPlugin::setup(AppContext& ctx) { - auto& downloader = *static_cast(ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); - ctx.addSettingsSection([&downloader](QObject* parent) { return new SteamSettingsSection(downloader.settings(), parent); }); + auto& downloader = *static_cast( + ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); + ctx.addSettingsSection([&downloader](QObject* parent) { + return new SteamSettingsSection(downloader.settings(), parent); + }); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_downloader/plugin.hpp b/src/ui/plugins/steam_downloader/plugin.hpp index dd065cc..019d100 100644 --- a/src/ui/plugins/steam_downloader/plugin.hpp +++ b/src/ui/plugins/steam_downloader/plugin.hpp @@ -9,16 +9,16 @@ namespace px::modrim { class SteamSettingsSection final : public PluginConfigSection { -public: - SteamSettingsSection(PluginConfig& settings, QObject* parent); - QString title() const override; - QUrl source() const override; + public: + SteamSettingsSection(PluginConfig& settings, QObject* parent); + QString title() const override; + QUrl source() const override; }; class SteamDownloaderUiPlugin final : public UiPlugin { -public: - SteamDownloaderUiPlugin(); - void setup(AppContext& ctx) override; + public: + SteamDownloaderUiPlugin(); + void setup(AppContext& ctx) override; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp index 13b0375..b8c3b13 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.cpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -2,97 +2,120 @@ #ifdef PX_HAS_STEAM -#include - #include #include #include #include #include +#include #include "ui/plugins/steam_workshop/plugin.hpp" namespace px::modrim { namespace { bool isPublishedFileId(const QJsonValue& value) { - if (!value.isString()) return false; - const auto text = value.toString(); - return !text.isEmpty() && std::ranges::all_of(text, [](QChar c) { return c >= QLatin1Char('0') && c <= QLatin1Char('9'); }); -} + if (!value.isString()) return false; + const auto text = value.toString(); + return !text.isEmpty() && std::ranges::all_of(text, [](QChar c) { + return c >= QLatin1Char('0') && c <= QLatin1Char('9'); + }); } +} // namespace PxModRimBridge::PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent) : QObject(parent), m_plugin(plugin) {} -void PxModRimBridge::call(const QString& requestId, const QString& method, const QString& payload) { - handle(requestId, method, payload); +void PxModRimBridge::call(const QString& requestId, const QString& method, + const QString& payload) { + handle(requestId, method, payload); } -void PxModRimBridge::respond(const QString& requestId, bool ok, const QJsonValue& result, const QString& error) { - QJsonObject response{{QStringLiteral("ok"), ok}}; - if (ok) response.insert(QStringLiteral("result"), result); - else response.insert(QStringLiteral("error"), error); - emit resultReady(requestId, QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Compact))); +void PxModRimBridge::respond(const QString& requestId, bool ok, + const QJsonValue& result, const QString& error) { + QJsonObject response{{QStringLiteral("ok"), ok}}; + if (ok) + response.insert(QStringLiteral("result"), result); + else + response.insert(QStringLiteral("error"), error); + emit resultReady(requestId, QString::fromUtf8(QJsonDocument(response).toJson( + QJsonDocument::Compact))); } -QCoro::Task PxModRimBridge::handle(QString requestId, QString method, QString payload) { - QJsonParseError parseError; - const auto document = QJsonDocument::fromJson(payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) { - respond(requestId, false, {}, QStringLiteral("Invalid request payload")); - co_return; +QCoro::Task PxModRimBridge::handle(QString requestId, QString method, + QString payload) { + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson( + payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + respond(requestId, false, {}, QStringLiteral("Invalid request payload")); + co_return; + } + const auto params = document.object(); + const auto modId = params.value(QStringLiteral("mod_id")); + if (method == QLatin1String("init_ready")) { + m_plugin.syncAll(); + } else if (method == QLatin1String("toggle_download_checked")) { + if (isPublishedFileId(modId)) + m_plugin.toggleDownloadChecked( + modId.toString(), params.value(QStringLiteral("title")).toString(), + params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("batch_toggle_download_checked")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + const auto titles = params.value(QStringLiteral("titles")); + if (!ids.isArray() || !titles.isArray() || + ids.toArray().size() != titles.toArray().size()) { + respond(requestId, false, {}, + QStringLiteral("mod_ids and titles must have equal lengths")); + co_return; + } + const auto idArray = ids.toArray(); + const auto titleArray = titles.toArray(); + QStringList validIds; + QStringList validTitles; + for (qsizetype pos = 0; pos < idArray.size(); ++pos) { + if (!isPublishedFileId(idArray[pos])) continue; + validIds.append(idArray[pos].toString()); + validTitles.append(titleArray[pos].toString()); + } + if (!validIds.isEmpty()) + m_plugin.batchToggleDownloadChecked( + validIds, validTitles, + params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("toggle_active")) { + if (isPublishedFileId(modId)) + m_plugin.toggleActive(modId.toString(), + params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("batch_toggle_active")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + if (!ids.isArray()) { + respond(requestId, false, {}, QStringLiteral("mod_ids must be an array")); + co_return; + } + QStringList validIds; + for (const auto& id : ids.toArray()) { + if (isPublishedFileId(id)) validIds.append(id.toString()); } - const auto params = document.object(); - const auto modId = params.value(QStringLiteral("mod_id")); - if (method == QLatin1String("init_ready")) { - m_plugin.syncAll(); - } else if (method == QLatin1String("toggle_download_checked")) { - if (isPublishedFileId(modId)) m_plugin.toggleDownloadChecked(modId.toString(), params.value(QStringLiteral("title")).toString(), params.value(QStringLiteral("checked")).toBool()); - } else if (method == QLatin1String("batch_toggle_download_checked")) { - const auto ids = params.value(QStringLiteral("mod_ids")); - const auto titles = params.value(QStringLiteral("titles")); - if (!ids.isArray() || !titles.isArray() || ids.toArray().size() != titles.toArray().size()) { - respond(requestId, false, {}, QStringLiteral("mod_ids and titles must have equal lengths")); - co_return; - } - const auto idArray = ids.toArray(); - const auto titleArray = titles.toArray(); - QStringList validIds; - QStringList validTitles; - for (qsizetype pos = 0; pos < idArray.size(); ++pos) { - if (!isPublishedFileId(idArray[pos])) continue; - validIds.append(idArray[pos].toString()); - validTitles.append(titleArray[pos].toString()); - } - if (!validIds.isEmpty()) m_plugin.batchToggleDownloadChecked(validIds, validTitles, params.value(QStringLiteral("checked")).toBool()); - } else if (method == QLatin1String("toggle_active")) { - if (isPublishedFileId(modId)) m_plugin.toggleActive(modId.toString(), params.value(QStringLiteral("active")).toBool()); - } else if (method == QLatin1String("batch_toggle_active")) { - const auto ids = params.value(QStringLiteral("mod_ids")); - if (!ids.isArray()) { - respond(requestId, false, {}, QStringLiteral("mod_ids must be an array")); - co_return; - } - QStringList validIds; - for (const auto& id : ids.toArray()) { - if (isPublishedFileId(id)) validIds.append(id.toString()); - } - if (!validIds.isEmpty()) m_plugin.batchToggleActive(validIds, params.value(QStringLiteral("active")).toBool()); - } else if (method == QLatin1String("fetch_mod_deps")) { - if (isPublishedFileId(modId)) { - QPointer guard(this); - auto result = co_await m_plugin.fetchModDeps(modId.toString()); - if (guard) { - if (result) guard->respond(requestId, true, *result); - else guard->respond(requestId, false, {}, result.error().message); - } - co_return; - } - } else { - respond(requestId, false, {}, QStringLiteral("Unknown method: %1").arg(method)); - co_return; + if (!validIds.isEmpty()) + m_plugin.batchToggleActive( + validIds, params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("fetch_mod_deps")) { + if (isPublishedFileId(modId)) { + QPointer guard(this); + auto result = co_await m_plugin.fetchModDeps(modId.toString()); + if (guard) { + if (result) + guard->respond(requestId, true, *result); + else + guard->respond(requestId, false, {}, result.error().message); + } + co_return; } - respond(requestId, true); + } else { + respond(requestId, false, {}, + QStringLiteral("Unknown method: %1").arg(method)); + co_return; + } + respond(requestId, true); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/bridge_obj.hpp b/src/ui/plugins/steam_workshop/bridge_obj.hpp index 049b474..4d70f01 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.hpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.hpp @@ -2,25 +2,30 @@ #ifdef PX_HAS_STEAM -#include #include #include +#include + namespace px::modrim { class SteamWorkshopUiPlugin; class PxModRimBridge final : public QObject { - Q_OBJECT -public: - PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); - Q_INVOKABLE void call(const QString& requestId, const QString& method, const QString& payload); -signals: - void resultReady(const QString& requestId, const QString& payload); -private: - QCoro::Task handle(QString requestId, QString method, QString payload); - void respond(const QString& requestId, bool ok, const QJsonValue& result = QJsonValue(QJsonValue::Null), const QString& error = {}); - SteamWorkshopUiPlugin& m_plugin; + Q_OBJECT + public: + PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); + Q_INVOKABLE void call(const QString& requestId, const QString& method, + const QString& payload); + signals: + void resultReady(const QString& requestId, const QString& payload); + + private: + QCoro::Task handle(QString requestId, QString method, QString payload); + void respond(const QString& requestId, bool ok, + const QJsonValue& result = QJsonValue(QJsonValue::Null), + const QString& error = {}); + SteamWorkshopUiPlugin& m_plugin; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp index 27879f3..5c4f90e 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.cpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -2,50 +2,66 @@ namespace px::modrim { -DownloadQueueModel::DownloadQueueModel(QObject* parent) : QAbstractListModel(parent) {} +DownloadQueueModel::DownloadQueueModel(QObject* parent) + : QAbstractListModel(parent) {} int DownloadQueueModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : static_cast(m_items.size()); + return parent.isValid() ? 0 : static_cast(m_items.size()); } QVariant DownloadQueueModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return {}; - const auto& item = m_items[index.row()]; - switch (role) { - case kIdRole: return item.id; - case kTitleRole: return item.title; - case kDisplayRole: return item.display; - case kStatusRole: return item.status; - case kProgressRole: return item.progress; - default: return {}; - } + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& item = m_items[index.row()]; + switch (role) { + case kIdRole: + return item.id; + case kTitleRole: + return item.title; + case kDisplayRole: + return item.display; + case kStatusRole: + return item.status; + case kProgressRole: + return item.progress; + default: + return {}; + } } QHash DownloadQueueModel::roleNames() const { - return {{kIdRole, "id"}, {kTitleRole, "title"}, {kDisplayRole, "display"}, {kStatusRole, "status"}, {kProgressRole, "progress"}}; + return {{kIdRole, "id"}, + {kTitleRole, "title"}, + {kDisplayRole, "display"}, + {kStatusRole, "status"}, + {kProgressRole, "progress"}}; } -void DownloadQueueModel::syncFrom(const CheckedDownloads& checked, const QHash& statuses) { - beginResetModel(); - m_items.clear(); - m_items.reserve(checked.size()); - for (const auto& [id, title] : checked) { - m_items.append(Item{id, title.isEmpty() ? id : title, - title.isEmpty() ? id : QStringLiteral("%1 (%2)").arg(title, id), statuses.value(id)}); - } - endResetModel(); +void DownloadQueueModel::syncFrom(const CheckedDownloads& checked, + const QHash& statuses) { + beginResetModel(); + m_items.clear(); + m_items.reserve(checked.size()); + for (const auto& [id, title] : checked) { + m_items.append( + Item{id, title.isEmpty() ? id : title, + title.isEmpty() ? id : QStringLiteral("%1 (%2)").arg(title, id), + statuses.value(id)}); + } + endResetModel(); } -void DownloadQueueModel::updateStatus(const QString& id, const QString& status, qint64 done, qint64 total) { - for (qsizetype pos = 0; pos < m_items.size(); ++pos) { - auto& item = m_items[pos]; - if (item.id != id) continue; - item.status = status; - item.progress = total ? static_cast(done) / total : 0; - const auto idx = index(static_cast(pos)); - emit dataChanged(idx, idx, {kStatusRole, kProgressRole}); - return; - } +void DownloadQueueModel::updateStatus(const QString& id, const QString& status, + qint64 done, qint64 total) { + for (qsizetype pos = 0; pos < m_items.size(); ++pos) { + auto& item = m_items[pos]; + if (item.id != id) continue; + item.status = status; + item.progress = total ? static_cast(done) / total : 0; + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx, {kStatusRole, kProgressRole}); + return; + } } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_queue_model.hpp b/src/ui/plugins/steam_workshop/download_queue_model.hpp index b0e2c6c..b40a23d 100644 --- a/src/ui/plugins/steam_workshop/download_queue_model.hpp +++ b/src/ui/plugins/steam_workshop/download_queue_model.hpp @@ -11,25 +11,34 @@ namespace px::modrim { using CheckedDownloads = QList>; class DownloadQueueModel final : public QAbstractListModel { - Q_OBJECT -public: - enum Role { kIdRole = Qt::UserRole + 1, kTitleRole, kDisplayRole, kStatusRole, kProgressRole }; - explicit DownloadQueueModel(QObject* parent = nullptr); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - void syncFrom(const CheckedDownloads& checked, const QHash& statuses = {}); - void updateStatus(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); + Q_OBJECT + public: + enum Role { + kIdRole = Qt::UserRole + 1, + kTitleRole, + kDisplayRole, + kStatusRole, + kProgressRole + }; + explicit DownloadQueueModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void syncFrom(const CheckedDownloads& checked, + const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, + qint64 total = 0); -private: - struct Item { - QString id; - QString title; - QString display; - QString status; - double progress = 0; - }; - QList m_items; + private: + struct Item { + QString id; + QString title; + QString display; + QString status; + double progress = 0; + }; + QList m_items; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp index d8a87d1..81b8083 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.cpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -11,18 +11,23 @@ namespace px::modrim { DownloadSidebar::DownloadSidebar(QQmlEngine* engine, QWidget* parent) - : QWidget(parent), m_model(new DownloadQueueModel(this)), m_qml(new QQuickWidget(engine, this)) { - setObjectName(QStringLiteral("downloadSidebar")); - setFixedWidth(kSidebarWidth); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); - m_qml->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), this); - m_qml->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), m_model); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); - layout->addWidget(m_qml); + : QWidget(parent), + m_model(new DownloadQueueModel(this)), + m_qml(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("downloadSidebar")); + setFixedWidth(kSidebarWidth); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), + m_model); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); + layout->addWidget(m_qml); } void DownloadSidebar::downloadRequested() { emit downloadTriggered(); } @@ -30,13 +35,16 @@ void DownloadSidebar::stopRequested() { emit stopTriggered(); } void DownloadSidebar::removeItem(const QString& id) { emit itemRemoved(id); } void DownloadSidebar::clearQueue() { emit clearRequested(); } void DownloadSidebar::setDownloadEnabled(bool enabled) { - if (auto* root = m_qml->rootObject()) root->setProperty("downloadEnabled", enabled); + if (auto* root = m_qml->rootObject()) + root->setProperty("downloadEnabled", enabled); } -void DownloadSidebar::syncFrom(const CheckedDownloads& checked, const QHash& statuses) { - m_model->syncFrom(checked, statuses); +void DownloadSidebar::syncFrom(const CheckedDownloads& checked, + const QHash& statuses) { + m_model->syncFrom(checked, statuses); } -void DownloadSidebar::updateStatus(const QString& id, const QString& status, qint64 done, qint64 total) { - m_model->updateStatus(id, status, done, total); +void DownloadSidebar::updateStatus(const QString& id, const QString& status, + qint64 done, qint64 total) { + m_model->updateStatus(id, status, done, total); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.hpp b/src/ui/plugins/steam_workshop/download_sidebar.hpp index 078ded8..6092810 100644 --- a/src/ui/plugins/steam_workshop/download_sidebar.hpp +++ b/src/ui/plugins/steam_workshop/download_sidebar.hpp @@ -10,24 +10,27 @@ class QQuickWidget; namespace px::modrim { class DownloadSidebar final : public QWidget { - Q_OBJECT -public: - DownloadSidebar(QQmlEngine* engine, QWidget* parent); - Q_INVOKABLE void downloadRequested(); - Q_INVOKABLE void stopRequested(); - Q_INVOKABLE void removeItem(const QString& id); - Q_INVOKABLE void clearQueue(); - void setDownloadEnabled(bool enabled); - void syncFrom(const CheckedDownloads& checked, const QHash& statuses = {}); - void updateStatus(const QString& id, const QString& status, qint64 done = 0, qint64 total = 0); -signals: - void downloadTriggered(); - void stopTriggered(); - void itemRemoved(const QString& id); - void clearRequested(); -private: - DownloadQueueModel* m_model; - QQuickWidget* m_qml; + Q_OBJECT + public: + DownloadSidebar(QQmlEngine* engine, QWidget* parent); + Q_INVOKABLE void downloadRequested(); + Q_INVOKABLE void stopRequested(); + Q_INVOKABLE void removeItem(const QString& id); + Q_INVOKABLE void clearQueue(); + void setDownloadEnabled(bool enabled); + void syncFrom(const CheckedDownloads& checked, + const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, + qint64 total = 0); + signals: + void downloadTriggered(); + void stopTriggered(); + void itemRemoved(const QString& id); + void clearRequested(); + + private: + DownloadQueueModel* m_model; + QQuickWidget* m_qml; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp index c7eaf27..ca147e3 100644 --- a/src/ui/plugins/steam_workshop/plugin.cpp +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -2,16 +2,16 @@ #ifdef PX_HAS_STEAM -#include -#include - -#include #include #include #include #include #include #include +#include +#include + +#include #include "core/mod_service.hpp" #include "ui/navigation.hpp" @@ -20,217 +20,252 @@ namespace px::modrim { SteamWorkshopUiPlugin::SteamWorkshopUiPlugin() - : UiPlugin(QStringLiteral("steamworkshop"), {QStringLiteral("steam_downloader")}) {} + : UiPlugin(QStringLiteral("steamworkshop"), + {QStringLiteral("steam_downloader")}) {} SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnectEvents(); } void SteamWorkshopUiPlugin::setup(AppContext& ctx) { - m_core = &ctx.coreContext(); - m_app = &ctx; - m_service = static_cast(m_core->plugins().get(QStringLiteral("steam_downloader"))); - ctx.addRailView(); - m_modsId = m_core->modService().modsChanged.connect([this](const std::monostate&) { - refreshCachedIds(); - badgesRefreshRequested.notify(m_installedIds.values()); - activeRefreshRequested.notify(m_activeIds.values()); - }); - m_activeId = m_core->activeStateChanged().connect([this](const QStringList&) { + m_core = &ctx.coreContext(); + m_app = &ctx; + m_service = static_cast( + m_core->plugins().get(QStringLiteral("steam_downloader"))); + ctx.addRailView(); + m_modsId = + m_core->modService().modsChanged.connect([this](const std::monostate&) { refreshCachedIds(); badgesRefreshRequested.notify(m_installedIds.values()); activeRefreshRequested.notify(m_activeIds.values()); - }); - m_statusId = m_service->downloadItemStatusChanged.connect([this](const DownloadItemStatus& status) { onItemStatus(status); }); - m_finishedId = m_service->downloadFinished.connect([this](const DownloadResult& result) { onDownloadFinished(result); }); - m_busyId = m_service->busyChanged.connect([this](bool busy) { downloadBusyChanged.notify(busy); }); + }); + m_activeId = m_core->activeStateChanged().connect([this](const QStringList&) { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + }); + m_statusId = m_service->downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& status) { onItemStatus(status); }); + m_finishedId = m_service->downloadFinished.connect( + [this](const DownloadResult& result) { onDownloadFinished(result); }); + m_busyId = m_service->busyChanged.connect( + [this](bool busy) { downloadBusyChanged.notify(busy); }); } QCoro::Task SteamWorkshopUiPlugin::init(AppContext&) { - refreshCachedIds(); - co_return; + refreshCachedIds(); + co_return; } QCoro::Task SteamWorkshopUiPlugin::shutdown() { - disconnectEvents(); - co_return; + disconnectEvents(); + co_return; } void SteamWorkshopUiPlugin::disconnectEvents() { - if (!m_core) return; - m_core->modService().modsChanged.disconnect(m_modsId); - m_core->activeStateChanged().disconnect(m_activeId); - m_service->downloadItemStatusChanged.disconnect(m_statusId); - m_service->downloadFinished.disconnect(m_finishedId); - m_service->busyChanged.disconnect(m_busyId); - m_core = nullptr; - m_app = nullptr; -} - -void SteamWorkshopUiPlugin::toggleDownloadChecked(const QString& id, const QString& title, bool checked) { - const auto it = std::ranges::find(m_checkedIds, id, &CheckedDownloads::value_type::first); + if (!m_core) return; + m_core->modService().modsChanged.disconnect(m_modsId); + m_core->activeStateChanged().disconnect(m_activeId); + m_service->downloadItemStatusChanged.disconnect(m_statusId); + m_service->downloadFinished.disconnect(m_finishedId); + m_service->busyChanged.disconnect(m_busyId); + m_core = nullptr; + m_app = nullptr; +} + +void SteamWorkshopUiPlugin::toggleDownloadChecked(const QString& id, + const QString& title, + bool checked) { + const auto it = + std::ranges::find(m_checkedIds, id, &CheckedDownloads::value_type::first); + if (checked) { + if (it == m_checkedIds.end()) + m_checkedIds.append({id, title}); + else + it->second = title; + } else if (it != m_checkedIds.end()) { + m_checkedIds.erase(it); + } + notifySidebar(); +} + +void SteamWorkshopUiPlugin::batchToggleDownloadChecked( + const QStringList& ids, const QStringList& titles, bool checked) { + for (qsizetype pos = 0; pos < ids.size(); ++pos) { + const auto it = std::ranges::find(m_checkedIds, ids[pos], + &CheckedDownloads::value_type::first); if (checked) { - if (it == m_checkedIds.end()) m_checkedIds.append({id, title}); - else it->second = title; + if (it == m_checkedIds.end()) + m_checkedIds.append({ids[pos], titles[pos]}); + else + it->second = titles[pos]; } else if (it != m_checkedIds.end()) { - m_checkedIds.erase(it); - } - notifySidebar(); -} - -void SteamWorkshopUiPlugin::batchToggleDownloadChecked(const QStringList& ids, const QStringList& titles, bool checked) { - for (qsizetype pos = 0; pos < ids.size(); ++pos) { - const auto it = std::ranges::find(m_checkedIds, ids[pos], &CheckedDownloads::value_type::first); - if (checked) { - if (it == m_checkedIds.end()) m_checkedIds.append({ids[pos], titles[pos]}); - else it->second = titles[pos]; - } else if (it != m_checkedIds.end()) { - m_checkedIds.erase(it); - } + m_checkedIds.erase(it); } - if (!ids.isEmpty()) notifySidebar(); + } + if (!ids.isEmpty()) notifySidebar(); } void SteamWorkshopUiPlugin::toggleActive(const QString& id, bool active) { - if (!m_core) return; - for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); ++it) { - if (it.value()->publishedFileId() != id) continue; - auto current = m_core->activeUuids(); - if (active) { - if (!current.contains(it.key())) current.append(it.key()); - } else { - current.removeAll(it.key()); - } - m_core->setActive(std::move(current)); - return; + if (!m_core) return; + for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); + ++it) { + if (it.value()->publishedFileId() != id) continue; + auto current = m_core->activeUuids(); + if (active) { + if (!current.contains(it.key())) current.append(it.key()); + } else { + current.removeAll(it.key()); } + m_core->setActive(std::move(current)); + return; + } } -void SteamWorkshopUiPlugin::batchToggleActive(const QStringList& ids, bool active) { - if (!m_core) return; - QHash byPublishedId; - for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); ++it) { - if (auto id = it.value()->publishedFileId()) byPublishedId.insert(*id, it.key()); - } - auto current = m_core->activeUuids(); - for (const auto& id : ids) { - const auto it = byPublishedId.constFind(id); - if (it == byPublishedId.cend()) continue; - if (active) { - if (!current.contains(*it)) current.append(*it); - } else { - current.removeAll(*it); - } +void SteamWorkshopUiPlugin::batchToggleActive(const QStringList& ids, + bool active) { + if (!m_core) return; + QHash byPublishedId; + for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); + ++it) { + if (auto id = it.value()->publishedFileId()) + byPublishedId.insert(*id, it.key()); + } + auto current = m_core->activeUuids(); + for (const auto& id : ids) { + const auto it = byPublishedId.constFind(id); + if (it == byPublishedId.cend()) continue; + if (active) { + if (!current.contains(*it)) current.append(*it); + } else { + current.removeAll(*it); } - m_core->setActive(std::move(current)); + } + m_core->setActive(std::move(current)); } QCoro::Task SteamWorkshopUiPlugin::requestDownload() { - if (m_checkedIds.isEmpty()) co_return; - if (!m_core || m_core->config().paths.local.empty()) { - m_service->statusMessageChanged.notify(QStringLiteral("Local mods path is not configured")); - co_return; - } - if (m_service->isDownloading()) { - m_service->statusMessageChanged.notify(QStringLiteral("A download is already running")); - co_return; - } - QStringList ids; - ids.reserve(m_checkedIds.size()); - for (const auto& [id, title] : m_checkedIds) { - ids.append(id); - m_statuses.insert(id, QStringLiteral("queued")); - } - m_ownDownload = true; - if (m_app) m_app->navigate(buildRoute(QStringLiteral("downloads"))); - notifySidebar(); - auto result = co_await m_service->downloadMods(std::move(ids)); - m_ownDownload = false; - if (!result) m_service->statusMessageChanged.notify(result.error().message); + if (m_checkedIds.isEmpty()) co_return; + if (!m_core || m_core->config().paths.local.empty()) { + m_service->statusMessageChanged.notify( + QStringLiteral("Local mods path is not configured")); + co_return; + } + if (m_service->isDownloading()) { + m_service->statusMessageChanged.notify( + QStringLiteral("A download is already running")); + co_return; + } + QStringList ids; + ids.reserve(m_checkedIds.size()); + for (const auto& [id, title] : m_checkedIds) { + ids.append(id); + m_statuses.insert(id, QStringLiteral("queued")); + } + m_ownDownload = true; + if (m_app) m_app->navigate(buildRoute(QStringLiteral("downloads"))); + notifySidebar(); + auto result = co_await m_service->downloadMods(std::move(ids)); + m_ownDownload = false; + if (!result) m_service->statusMessageChanged.notify(result.error().message); } void SteamWorkshopUiPlugin::stopDownload() { m_service->cancel(); } -bool SteamWorkshopUiPlugin::isDownloading() const { return m_service->isDownloading(); } +bool SteamWorkshopUiPlugin::isDownloading() const { + return m_service->isDownloading(); +} void SteamWorkshopUiPlugin::removeItem(const QString& id) { - m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); - m_statuses.remove(id); - notifySidebar(); - uncheckModRequested.notify(id); + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); + notifySidebar(); + uncheckModRequested.notify(id); } void SteamWorkshopUiPlugin::clearQueue() { - m_checkedIds.clear(); - m_statuses.clear(); - notifySidebar(); - clearCheckedRequested.notify(std::monostate{}); + m_checkedIds.clear(); + m_statuses.clear(); + notifySidebar(); + clearCheckedRequested.notify(std::monostate{}); } -void SteamWorkshopUiPlugin::notifySidebar() { sidebarSyncRequested.notify(SidebarSync{m_checkedIds, m_statuses}); } +void SteamWorkshopUiPlugin::notifySidebar() { + sidebarSyncRequested.notify(SidebarSync{m_checkedIds, m_statuses}); +} void SteamWorkshopUiPlugin::syncAll() { - refreshCachedIds(); - badgesRefreshRequested.notify(m_installedIds.values()); - activeRefreshRequested.notify(m_activeIds.values()); - notifySidebar(); + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + notifySidebar(); } void SteamWorkshopUiPlugin::onItemStatus(const DownloadItemStatus& status) { - if (!m_ownDownload) return; - m_statuses.insert(status.modId, status.status); - itemStatusChanged.notify(status); + if (!m_ownDownload) return; + m_statuses.insert(status.modId, status.status); + itemStatusChanged.notify(status); } void SteamWorkshopUiPlugin::onDownloadFinished(const DownloadResult& result) { - if (!m_ownDownload) return; - for (const auto& id : result.succeeded) { - m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); - m_statuses.remove(id); - } - for (auto it = m_statuses.begin(); it != m_statuses.end();) { - if (it.value() == QLatin1String("queued") || it.value() == QLatin1String("downloading")) it = m_statuses.erase(it); - else ++it; - } - notifySidebar(); + if (!m_ownDownload) return; + for (const auto& id : result.succeeded) { + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); + } + for (auto it = m_statuses.begin(); it != m_statuses.end();) { + if (it.value() == QLatin1String("queued") || + it.value() == QLatin1String("downloading")) + it = m_statuses.erase(it); + else + ++it; + } + notifySidebar(); } void SteamWorkshopUiPlugin::refreshCachedIds() { - if (!m_core) return; - m_installedIds.clear(); - m_activeIds.clear(); - const QSet active(m_core->activeUuids().cbegin(), m_core->activeUuids().cend()); - for (const auto& mod : m_core->allMods()) { - if (auto id = mod->publishedFileId()) { - m_installedIds.insert(*id); - if (active.contains(mod->uuid())) m_activeIds.insert(*id); - } + if (!m_core) return; + m_installedIds.clear(); + m_activeIds.clear(); + const QSet active(m_core->activeUuids().cbegin(), + m_core->activeUuids().cend()); + for (const auto& mod : m_core->allMods()) { + if (auto id = mod->publishedFileId()) { + m_installedIds.insert(*id); + if (active.contains(mod->uuid())) m_activeIds.insert(*id); } + } } -QCoro::Task> SteamWorkshopUiPlugin::fetchModDeps(QString id) { - QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); - QUrlQuery query; - query.addQueryItem(QStringLiteral("id"), id); - url.setQuery(query); - QNetworkRequest request(url); - request.setRawHeader("User-Agent", "PxModRim/1.0"); - request.setTransferTimeout(30000); - auto* reply = m_core->network().get(request); - if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { - reply->abort(); - reply->deleteLater(); - co_return std::unexpected(Error{QStringLiteral("Timeout")}); - } - const auto error = reply->error(); - const auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const auto bytes = reply->readAll(); - const auto message = reply->errorString(); +QCoro::Task> SteamWorkshopUiPlugin::fetchModDeps( + QString id) { + QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("id"), id); + url.setQuery(query); + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "PxModRim/1.0"); + request.setTransferTimeout(30000); + auto* reply = m_core->network().get(request); + if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { + reply->abort(); reply->deleteLater(); - if (error == QNetworkReply::TimeoutError) co_return std::unexpected(Error{QStringLiteral("Timeout")}); - if (status && status != 200) co_return QJsonValue(QJsonValue::Null); - if (error != QNetworkReply::NoError) co_return std::unexpected(Error{message}); - if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); - QJsonParseError parseError; - const auto document = QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parseError); - if (parseError.error != QJsonParseError::NoError) co_return std::unexpected(Error{parseError.errorString()}); - co_return document.array().at(0); + co_return std::unexpected(Error{QStringLiteral("Timeout")}); + } + const auto error = reply->error(); + const auto status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const auto bytes = reply->readAll(); + const auto message = reply->errorString(); + reply->deleteLater(); + if (error == QNetworkReply::TimeoutError) + co_return std::unexpected(Error{QStringLiteral("Timeout")}); + if (status && status != 200) co_return QJsonValue(QJsonValue::Null); + if (error != QNetworkReply::NoError) + co_return std::unexpected(Error{message}); + if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); + QJsonParseError parseError; + const auto document = + QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parseError); + if (parseError.error != QJsonParseError::NoError) + co_return std::unexpected(Error{parseError.errorString()}); + co_return document.array().at(0); } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/plugin.hpp b/src/ui/plugins/steam_workshop/plugin.hpp index 8c3f805..d28021b 100644 --- a/src/ui/plugins/steam_workshop/plugin.hpp +++ b/src/ui/plugins/steam_workshop/plugin.hpp @@ -2,10 +2,9 @@ #ifdef PX_HAS_STEAM -#include - #include #include +#include #include "core/downloads/steam.hpp" #include "ui/context.hpp" @@ -14,57 +13,59 @@ namespace px::modrim { struct SidebarSync { - CheckedDownloads checkedIds; - QHash statuses; + CheckedDownloads checkedIds; + QHash statuses; }; class SteamWorkshopUiPlugin final : public UiPlugin { -public: - SteamWorkshopUiPlugin(); - ~SteamWorkshopUiPlugin() override; - void setup(AppContext& ctx) override; - QCoro::Task init(AppContext& ctx) override; - QCoro::Task shutdown() override; + public: + SteamWorkshopUiPlugin(); + ~SteamWorkshopUiPlugin() override; + void setup(AppContext& ctx) override; + QCoro::Task init(AppContext& ctx) override; + QCoro::Task shutdown() override; - void toggleDownloadChecked(const QString& id, const QString& title, bool checked); - void batchToggleDownloadChecked(const QStringList& ids, const QStringList& titles, bool checked); - void toggleActive(const QString& id, bool active); - void batchToggleActive(const QStringList& ids, bool active); - QCoro::Task requestDownload(); - void stopDownload(); - void removeItem(const QString& id); - void clearQueue(); - void syncAll(); - bool isDownloading() const; - QCoro::Task> fetchModDeps(QString id); + void toggleDownloadChecked(const QString& id, const QString& title, + bool checked); + void batchToggleDownloadChecked(const QStringList& ids, + const QStringList& titles, bool checked); + void toggleActive(const QString& id, bool active); + void batchToggleActive(const QStringList& ids, bool active); + QCoro::Task requestDownload(); + void stopDownload(); + void removeItem(const QString& id); + void clearQueue(); + void syncAll(); + bool isDownloading() const; + QCoro::Task> fetchModDeps(QString id); - Event badgesRefreshRequested; - Event activeRefreshRequested; - Event sidebarSyncRequested; - Event itemStatusChanged; - Event downloadBusyChanged; - Event uncheckModRequested; - Event clearCheckedRequested; + Event badgesRefreshRequested; + Event activeRefreshRequested; + Event sidebarSyncRequested; + Event itemStatusChanged; + Event downloadBusyChanged; + Event uncheckModRequested; + Event clearCheckedRequested; -private: - void disconnectEvents(); - void refreshCachedIds(); - void notifySidebar(); - void onItemStatus(const DownloadItemStatus& status); - void onDownloadFinished(const DownloadResult& result); - CoreContext* m_core = nullptr; - AppContext* m_app = nullptr; - SteamDownloader* m_service = nullptr; - QSet m_installedIds; - QSet m_activeIds; - CheckedDownloads m_checkedIds; - QHash m_statuses; - bool m_ownDownload = false; - Event::Id m_modsId = 0; - Event::Id m_activeId = 0; - Event::Id m_statusId = 0; - Event::Id m_finishedId = 0; - Event::Id m_busyId = 0; + private: + void disconnectEvents(); + void refreshCachedIds(); + void notifySidebar(); + void onItemStatus(const DownloadItemStatus& status); + void onDownloadFinished(const DownloadResult& result); + CoreContext* m_core = nullptr; + AppContext* m_app = nullptr; + SteamDownloader* m_service = nullptr; + QSet m_installedIds; + QSet m_activeIds; + CheckedDownloads m_checkedIds; + QHash m_statuses; + bool m_ownDownload = false; + Event::Id m_modsId = 0; + Event::Id m_activeId = 0; + Event::Id m_statusId = 0; + Event::Id m_finishedId = 0; + Event::Id m_busyId = 0; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/ts/bridge.ts b/src/ui/plugins/steam_workshop/ts/bridge.ts index dce12ee..c55c05a 100644 --- a/src/ui/plugins/steam_workshop/ts/bridge.ts +++ b/src/ui/plugins/steam_workshop/ts/bridge.ts @@ -54,7 +54,7 @@ export class PxModRimRPC { constructor(channel: any) { this.bridge = channel.objects.bridge; - this.bridge.result_ready.connect((id: string, json: string) => { + this.bridge.resultReady.connect((id: string, json: string) => { const resolve = this.pending.get(id); if (resolve) { this.pending.delete(id); diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index 68df0a4..c0234c7 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -11,6 +11,7 @@ #include #include #include + #include #include "ui/plugins/steam_workshop/bridge_obj.hpp" @@ -19,145 +20,187 @@ namespace px::modrim { namespace { -const QUrl kWorkshopUrl(QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); +const QUrl kWorkshopUrl( + QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); QString jsonArgument(const QJsonValue& value) { - const auto json = QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); - return QString::fromUtf8(json.constData() + 1, json.size() - 2); + const auto json = + QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); + return QString::fromUtf8(json.constData() + 1, json.size() - 2); } QString injection() { - QFile js(QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.js")); - if (!js.open(QIODevice::ReadOnly)) { - spdlog::warn("Steam Workshop injection bundle is absent; browser download controls are unavailable"); - return {}; - } - QFile css(QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.css")); - QString styles; - if (css.open(QIODevice::ReadOnly)) styles = QString::fromUtf8(css.readAll()); - return QStringLiteral("var CSS_STYLES = %1;\n%2").arg(jsonArgument(styles), QString::fromUtf8(js.readAll())); -} + QFile js( + QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.js")); + if (!js.open(QIODevice::ReadOnly)) { + spdlog::warn( + "Steam Workshop injection bundle is absent; browser download controls " + "are unavailable"); + return {}; + } + QFile css( + QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.css")); + QString styles; + if (css.open(QIODevice::ReadOnly)) styles = QString::fromUtf8(css.readAll()); + return QStringLiteral("var CSS_STYLES = %1;\n%2") + .arg(jsonArgument(styles), QString::fromUtf8(js.readAll())); } +} // namespace -SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, + QQmlEngine* engine, + QWidget* parent, AppContext& app) : BaseViewPanel(ctx, engine, parent, app) { - auto* content = new QWidget(this); - auto* layout = new QHBoxLayout(content); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - m_sidebar = new DownloadSidebar(engine, content); - layout->addWidget(m_sidebar); - m_qml = new QQuickWidget(engine, content); - m_qml->setObjectName(QStringLiteral("workshopView")); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); + auto* content = new QWidget(this); + auto* layout = new QHBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_sidebar = new DownloadSidebar(engine, content); + layout->addWidget(m_sidebar); + m_qml = new QQuickWidget(engine, content); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); #ifdef PX_HAS_WEBENGINE - layout->addWidget(m_qml, 1); + layout->addWidget(m_qml, 1); #else - qml_->hide(); - auto* unavailable = new QLabel(QStringLiteral("This build does not include the Steam Workshop web browser."), content); - unavailable->setAlignment(Qt::AlignCenter); - unavailable->setWordWrap(true); - layout->addWidget(unavailable, 1); + qml_->hide(); + auto* unavailable = new QLabel( + QStringLiteral( + "This build does not include the Steam Workshop web browser."), + content); + unavailable->setAlignment(Qt::AlignCenter); + unavailable->setWordWrap(true); + layout->addWidget(unavailable, 1); #endif - m_root->addWidget(content, 1); + m_root->addWidget(content, 1); } SteamWorkshopViewPanel::~SteamWorkshopViewPanel() { teardown(); } void SteamWorkshopViewPanel::ensureInitialized() { - if (m_initialized) return; - m_initialized = true; - m_plugin = static_cast(m_app.plugins().get(QStringLiteral("steamworkshop"))); - if (m_plugin) { - connect(m_sidebar, &DownloadSidebar::downloadTriggered, this, [this] { m_plugin->requestDownload(); }); - connect(m_sidebar, &DownloadSidebar::stopTriggered, this, [this] { m_plugin->stopDownload(); }); - connect(m_sidebar, &DownloadSidebar::itemRemoved, this, [this](const QString& id) { m_plugin->removeItem(id); }); - connect(m_sidebar, &DownloadSidebar::clearRequested, this, [this] { m_plugin->clearQueue(); }); - m_badgesId = m_plugin->badgesRefreshRequested.connect([this](const QStringList& ids) { - runJs(QStringLiteral("if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)").arg(jsonArgument(QJsonArray::fromStringList(ids)))); + if (m_initialized) return; + m_initialized = true; + m_plugin = static_cast( + m_app.plugins().get(QStringLiteral("steamworkshop"))); + if (m_plugin) { + connect(m_sidebar, &DownloadSidebar::downloadTriggered, this, + [this] { m_plugin->requestDownload(); }); + connect(m_sidebar, &DownloadSidebar::stopTriggered, this, + [this] { m_plugin->stopDownload(); }); + connect(m_sidebar, &DownloadSidebar::itemRemoved, this, + [this](const QString& id) { m_plugin->removeItem(id); }); + connect(m_sidebar, &DownloadSidebar::clearRequested, this, + [this] { m_plugin->clearQueue(); }); + m_badgesId = m_plugin->badgesRefreshRequested.connect( + [this](const QStringList& ids) { + runJs( + QStringLiteral( + "if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)") + .arg(jsonArgument(QJsonArray::fromStringList(ids)))); + }); + m_activeId = m_plugin->activeRefreshRequested.connect( + [this](const QStringList& ids) { + runJs(QStringLiteral( + "if (window.__pxmSetActive) window.__pxmSetActive(%1)") + .arg(jsonArgument(QJsonArray::fromStringList(ids)))); }); - m_activeId = m_plugin->activeRefreshRequested.connect([this](const QStringList& ids) { - runJs(QStringLiteral("if (window.__pxmSetActive) window.__pxmSetActive(%1)").arg(jsonArgument(QJsonArray::fromStringList(ids)))); + m_sidebarId = + m_plugin->sidebarSyncRequested.connect([this](const SidebarSync& sync) { + m_sidebar->syncFrom(sync.checkedIds, sync.statuses); }); - m_sidebarId = m_plugin->sidebarSyncRequested.connect([this](const SidebarSync& sync) { m_sidebar->syncFrom(sync.checkedIds, sync.statuses); }); - m_statusId = m_plugin->itemStatusChanged.connect([this](const DownloadItemStatus& status) { - m_sidebar->updateStatus(status.modId, status.status, status.bytesDone, status.bytesTotal); + m_statusId = m_plugin->itemStatusChanged.connect( + [this](const DownloadItemStatus& status) { + m_sidebar->updateStatus(status.modId, status.status, status.bytesDone, + status.bytesTotal); }); - m_busyId = m_plugin->downloadBusyChanged.connect([this](bool busy) { m_sidebar->setDownloadEnabled(!busy); }); - m_uncheckId = m_plugin->uncheckModRequested.connect([this](const QString& id) { - runJs(QStringLiteral("if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);").arg(jsonArgument(id))); + m_busyId = m_plugin->downloadBusyChanged.connect( + [this](bool busy) { m_sidebar->setDownloadEnabled(!busy); }); + m_uncheckId = + m_plugin->uncheckModRequested.connect([this](const QString& id) { + runJs(QStringLiteral( + "if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);") + .arg(jsonArgument(id))); }); - m_clearId = m_plugin->clearCheckedRequested.connect([this](const std::monostate&) { - runJs(QStringLiteral("if (window.__pxmClearChecked) window.__pxmClearChecked();")); + m_clearId = + m_plugin->clearCheckedRequested.connect([this](const std::monostate&) { + runJs(QStringLiteral( + "if (window.__pxmClearChecked) window.__pxmClearChecked();")); }); - m_sidebar->setDownloadEnabled(!m_plugin->isDownloading()); - m_plugin->syncAll(); - } + m_sidebar->setDownloadEnabled(!m_plugin->isDownloading()); + m_plugin->syncAll(); + } #ifdef PX_HAS_WEBENGINE - auto* bridge = m_plugin ? new PxModRimBridge(*m_plugin, m_qml) : nullptr; - if (bridge) bridge->setObjectName(QStringLiteral("workshopBridge")); - auto* context = m_qml->rootContext(); - context->setContextProperty(QStringLiteral("steamWorkshopPanel"), this); - context->setContextProperty(QStringLiteral("_bridge"), bridge); - context->setContextProperty(QStringLiteral("_injectCode"), injection()); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); + auto* bridge = m_plugin ? new PxModRimBridge(*m_plugin, m_qml) : nullptr; + if (bridge) bridge->setObjectName(QStringLiteral("workshopBridge")); + auto* context = m_qml->rootContext(); + context->setContextProperty(QStringLiteral("steamWorkshopPanel"), this); + context->setContextProperty(QStringLiteral("_bridge"), bridge); + context->setContextProperty(QStringLiteral("_injectCode"), injection()); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); #endif } QObject* SteamWorkshopViewPanel::web() const { #ifdef PX_HAS_WEBENGINE - if (m_initialized && m_qml->rootObject()) return m_qml->rootObject()->findChild(QStringLiteral("workshopWeb")); + if (m_initialized && m_qml->rootObject()) + return m_qml->rootObject()->findChild( + QStringLiteral("workshopWeb")); #endif - return nullptr; + return nullptr; } void SteamWorkshopViewPanel::runJs(const QString& code) { #ifdef PX_HAS_WEBENGINE - if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); + if (auto* root = m_qml->rootObject()) + QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); #else - Q_UNUSED(code); + Q_UNUSED(code); #endif } void SteamWorkshopViewPanel::navigateHome() { - if (auto* browser = web()) browser->setProperty("url", kWorkshopUrl); + if (auto* browser = web()) browser->setProperty("url", kWorkshopUrl); } void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { - if (text.isEmpty()) return; - auto url = QUrl::fromUserInput(text); - if (url.scheme() == QLatin1String("http")) url.setScheme(QStringLiteral("https")); - if (!url.isValid()) return; - if (auto* browser = web()) browser->setProperty("url", url); + if (text.isEmpty()) return; + auto url = QUrl::fromUserInput(text); + if (url.scheme() == QLatin1String("http")) + url.setScheme(QStringLiteral("https")); + if (!url.isValid()) return; + if (auto* browser = web()) browser->setProperty("url", url); } void SteamWorkshopViewPanel::preload() { ensureInitialized(); } void SteamWorkshopViewPanel::showEvent(QShowEvent* event) { - BaseViewPanel::showEvent(event); - ensureInitialized(); + BaseViewPanel::showEvent(event); + ensureInitialized(); } void SteamWorkshopViewPanel::teardown() { - if (!m_initialized) return; - if (auto* browser = web()) { - QMetaObject::invokeMethod(browser, "stop"); - browser->setProperty("url", QUrl(QStringLiteral("about:blank"))); - } - m_qml->setSource({}); - if (auto* bridge = m_qml->findChild(QStringLiteral("workshopBridge"))) delete bridge; - disconnect(m_sidebar, nullptr, this, nullptr); - if (m_plugin) { - m_plugin->badgesRefreshRequested.disconnect(m_badgesId); - m_plugin->activeRefreshRequested.disconnect(m_activeId); - m_plugin->sidebarSyncRequested.disconnect(m_sidebarId); - m_plugin->itemStatusChanged.disconnect(m_statusId); - m_plugin->downloadBusyChanged.disconnect(m_busyId); - m_plugin->uncheckModRequested.disconnect(m_uncheckId); - m_plugin->clearCheckedRequested.disconnect(m_clearId); - } - m_plugin = nullptr; - m_initialized = false; + if (!m_initialized) return; + if (auto* browser = web()) { + QMetaObject::invokeMethod(browser, "stop"); + browser->setProperty("url", QUrl(QStringLiteral("about:blank"))); + } + m_qml->setSource({}); + if (auto* bridge = + m_qml->findChild(QStringLiteral("workshopBridge"))) + delete bridge; + disconnect(m_sidebar, nullptr, this, nullptr); + if (m_plugin) { + m_plugin->badgesRefreshRequested.disconnect(m_badgesId); + m_plugin->activeRefreshRequested.disconnect(m_activeId); + m_plugin->sidebarSyncRequested.disconnect(m_sidebarId); + m_plugin->itemStatusChanged.disconnect(m_statusId); + m_plugin->downloadBusyChanged.disconnect(m_busyId); + m_plugin->uncheckModRequested.disconnect(m_uncheckId); + m_plugin->clearCheckedRequested.disconnect(m_clearId); + } + m_plugin = nullptr; + m_initialized = false; } } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/view.hpp b/src/ui/plugins/steam_workshop/view.hpp index 806e537..0f2f49b 100644 --- a/src/ui/plugins/steam_workshop/view.hpp +++ b/src/ui/plugins/steam_workshop/view.hpp @@ -13,32 +13,37 @@ namespace px::modrim { class DownloadSidebar; class SteamWorkshopViewPanel final : public BaseViewPanel { - Q_OBJECT -public: - static constexpr RailViewMeta kRailMeta{QLatin1StringView("steam_workshop"), QLatin1StringView("steam"), QLatin1StringView("Steam Workshop")}; - SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - ~SteamWorkshopViewPanel() override; - void preload(); - void teardown(); - Q_INVOKABLE void navigateHome(); - Q_INVOKABLE void navigateToUrl(const QString& text); -protected: - void showEvent(QShowEvent* event) override; -private: - void ensureInitialized(); - QObject* web() const; - void runJs(const QString& code); - DownloadSidebar* m_sidebar; - QQuickWidget* m_qml; - SteamWorkshopUiPlugin* m_plugin = nullptr; - bool m_initialized = false; - Event::Id m_badgesId = 0; - Event::Id m_activeId = 0; - Event::Id m_sidebarId = 0; - Event::Id m_statusId = 0; - Event::Id m_busyId = 0; - Event::Id m_uncheckId = 0; - Event::Id m_clearId = 0; + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("steam_workshop"), + QLatin1StringView("steam"), + QLatin1StringView("Steam Workshop")}; + SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + ~SteamWorkshopViewPanel() override; + void preload(); + void teardown(); + Q_INVOKABLE void navigateHome(); + Q_INVOKABLE void navigateToUrl(const QString& text); + + protected: + void showEvent(QShowEvent* event) override; + + private: + void ensureInitialized(); + QObject* web() const; + void runJs(const QString& code); + DownloadSidebar* m_sidebar; + QQuickWidget* m_qml; + SteamWorkshopUiPlugin* m_plugin = nullptr; + bool m_initialized = false; + Event::Id m_badgesId = 0; + Event::Id m_activeId = 0; + Event::Id m_sidebarId = 0; + Event::Id m_statusId = 0; + Event::Id m_busyId = 0; + Event::Id m_uncheckId = 0; + Event::Id m_clearId = 0; }; } // namespace px::modrim diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp index aeb072d..09eac7a 100644 --- a/src/ui/plugins/workshop/details.cpp +++ b/src/ui/plugins/workshop/details.cpp @@ -3,57 +3,80 @@ namespace px::modrim::workshop { namespace { QString joined(const std::vector& ids) { - QStringList values; - values.reserve(static_cast(ids.size())); - for (const auto& id : ids) values.append(QString::fromStdString(id)); - return values.join(QStringLiteral(", ")); -} + QStringList values; + values.reserve(static_cast(ids.size())); + for (const auto& id : ids) values.append(QString::fromStdString(id)); + return values.join(QStringLiteral(", ")); } +} // namespace QCoro::Task> fetchDetail( workshop::WorkshopCatalog& catalog, QString itemId, QString kind) { - using namespace workshop; - if (kind == QStringLiteral("collection")) { - auto result = co_await catalog.collection(itemId); - if (!result) co_return std::unexpected(result.error()); - if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This collection is no longer available."), 404}); - auto& value = **result; - QString warning; - if (!value.is_complete) - warning = QStringLiteral("This collection is incomplete. Some members are unavailable or not listed."); - if (!value.unavailable_ids.empty()) warning += QStringLiteral(" Unavailable: ") + joined(value.unavailable_ids); - co_return Detail{std::move(value.collection), std::move(value.members), warning.toStdString(), value.is_complete}; - } - auto result = co_await catalog.mod(itemId); + using namespace workshop; + if (kind == QStringLiteral("collection")) { + auto result = co_await catalog.collection(itemId); if (!result) co_return std::unexpected(result.error()); - if (!*result) co_return std::unexpected(CatalogError{QStringLiteral("This mod is no longer available."), 404}); - auto mod = std::move(**result); - std::vector members; - QStringList missing; - // Start every dependency request before waiting for the first one. - std::vector>>> tasks; - tasks.reserve(mod.dependencies.size()); - for (const auto& id : mod.dependencies) tasks.push_back(catalog.mod(QString::fromStdString(id))); - for (std::size_t index = 0; index < tasks.size(); ++index) { - auto dependency = co_await tasks[index]; - if (!dependency) co_return std::unexpected(dependency.error()); - if (*dependency) members.emplace_back(std::move(**dependency)); - else missing.append(QString::fromStdString(mod.dependencies[index])); - } - const auto warning = missing.isEmpty() ? QString{} : QStringLiteral("Unavailable dependencies: ") + missing.join(QStringLiteral(", ")); - co_return Detail{std::move(mod), std::move(members), warning.toStdString(), missing.isEmpty()}; + if (!*result) + co_return std::unexpected(CatalogError{ + QStringLiteral("This collection is no longer available."), 404}); + auto& value = **result; + QString warning; + if (!value.is_complete) + warning = QStringLiteral( + "This collection is incomplete. Some members are unavailable or not " + "listed."); + if (!value.unavailable_ids.empty()) + warning += + QStringLiteral(" Unavailable: ") + joined(value.unavailable_ids); + co_return Detail{std::move(value.collection), std::move(value.members), + warning.toStdString(), value.is_complete}; + } + auto result = co_await catalog.mod(itemId); + if (!result) co_return std::unexpected(result.error()); + if (!*result) + co_return std::unexpected( + CatalogError{QStringLiteral("This mod is no longer available."), 404}); + auto mod = std::move(**result); + std::vector members; + QStringList missing; + // Start every dependency request before waiting for the first one. + std::vector>>> tasks; + tasks.reserve(mod.dependencies.size()); + for (const auto& id : mod.dependencies) + tasks.push_back(catalog.mod(QString::fromStdString(id))); + for (std::size_t index = 0; index < tasks.size(); ++index) { + auto dependency = co_await tasks[index]; + if (!dependency) co_return std::unexpected(dependency.error()); + if (*dependency) + members.emplace_back(std::move(**dependency)); + else + missing.append(QString::fromStdString(mod.dependencies[index])); + } + const auto warning = missing.isEmpty() + ? QString{} + : QStringLiteral("Unavailable dependencies: ") + + missing.join(QStringLiteral(", ")); + co_return Detail{std::move(mod), std::move(members), warning.toStdString(), + missing.isEmpty()}; } QString incompletePlanNotice(const workshop::DownloadPlan& plan) { - QStringList parts{QStringLiteral("The download plan is incomplete.")}; - if (!plan.unavailable_ids.isEmpty()) parts.append(QStringLiteral("Unavailable: ") + plan.unavailable_ids.join(QStringLiteral(", "))); - if (!plan.incomplete_collection_ids.isEmpty()) parts.append(QStringLiteral("Incomplete collections: ") + plan.incomplete_collection_ids.join(QStringLiteral(", "))); - return parts.join(QLatin1Char(' ')); + QStringList parts{QStringLiteral("The download plan is incomplete.")}; + if (!plan.unavailable_ids.isEmpty()) + parts.append(QStringLiteral("Unavailable: ") + + plan.unavailable_ids.join(QStringLiteral(", "))); + if (!plan.incomplete_collection_ids.isEmpty()) + parts.append(QStringLiteral("Incomplete collections: ") + + plan.incomplete_collection_ids.join(QStringLiteral(", "))); + return parts.join(QLatin1Char(' ')); } QString queuedNotice(qsizetype count) { - return QStringLiteral("Queued %1 mod%2. Progress is shown in the header and results in Downloads. Mods are not activated.") - .arg(count).arg(count == 1 ? QString{} : QStringLiteral("s")); + return QStringLiteral( + "Queued %1 mod%2. Progress is shown in the header and results in " + "Downloads. Mods are not activated.") + .arg(count) + .arg(count == 1 ? QString{} : QStringLiteral("s")); } } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/details.hpp b/src/ui/plugins/workshop/details.hpp index ddacc0d..dbd5e51 100644 --- a/src/ui/plugins/workshop/details.hpp +++ b/src/ui/plugins/workshop/details.hpp @@ -5,12 +5,12 @@ namespace px::modrim::workshop { struct Detail { - workshop::CatalogEntry item; - std::vector members; - std::string warning; - bool complete = false; + workshop::CatalogEntry item; + std::vector members; + std::string warning; + bool complete = false; - bool operator==(const Detail&) const = default; + bool operator==(const Detail&) const = default; }; QCoro::Task> fetchDetail( diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp index 4e10bb0..039f981 100644 --- a/src/ui/plugins/workshop/installed.cpp +++ b/src/ui/plugins/workshop/installed.cpp @@ -1,60 +1,60 @@ #include "ui/plugins/workshop/installed.hpp" +#include #include #include -#include - namespace px::modrim::workshop { void InstalledList::replace(std::vector mods) { - m_mods = std::move(mods); - rematch(); + m_mods = std::move(mods); + rematch(); } void InstalledList::search(QString query) { - m_query = std::move(query); - resetPaging(); - rematch(); + m_query = std::move(query); + resetPaging(); + rematch(); } -void InstalledList::resetPaging() { - m_shown = kPage; -} +void InstalledList::resetPaging() { m_shown = kPage; } std::vector InstalledList::visible() const { - return slice(0, std::min(m_shown, m_matches.size())); + return slice(0, std::min(m_shown, m_matches.size())); } std::vector InstalledList::nextPage() { - const auto start = m_shown; - m_shown = std::min(start + kPage, m_matches.size()); - return slice(start, m_shown); + const auto start = m_shown; + m_shown = std::min(start + kPage, m_matches.size()); + return slice(start, m_shown); } -std::vector InstalledList::slice(std::size_t start, std::size_t end) const { - std::vector result; - if (start >= end) return result; - result.reserve(end - start); - for (auto i = start; i < end; ++i) result.push_back(m_mods[m_matches[i]]); - return result; +std::vector InstalledList::slice(std::size_t start, + std::size_t end) const { + std::vector result; + if (start >= end) return result; + result.reserve(end - start); + for (auto i = start; i < end; ++i) result.push_back(m_mods[m_matches[i]]); + return result; } void InstalledList::rematch() { - m_matches.clear(); - const QString needle = m_query.toCaseFolded(); - for (std::size_t i = 0; i < m_mods.size(); ++i) { - const auto& mod = m_mods[i]; - if (needle.isEmpty()) { - m_matches.push_back(i); - continue; - } - QStringList fields{QString::fromStdString(mod.title), - mod.author.name ? QString::fromStdString(*mod.author.name) : QString{}, - QString::fromStdString(mod.id)}; - for (const auto& tag : mod.tags) fields.append(QString::fromStdString(tag)); - if (fields.join(' ').toCaseFolded().contains(needle)) m_matches.push_back(i); + m_matches.clear(); + const QString needle = m_query.toCaseFolded(); + for (std::size_t i = 0; i < m_mods.size(); ++i) { + const auto& mod = m_mods[i]; + if (needle.isEmpty()) { + m_matches.push_back(i); + continue; } + QStringList fields{ + QString::fromStdString(mod.title), + mod.author.name ? QString::fromStdString(*mod.author.name) : QString{}, + QString::fromStdString(mod.id)}; + for (const auto& tag : mod.tags) fields.append(QString::fromStdString(tag)); + if (fields.join(' ').toCaseFolded().contains(needle)) + m_matches.push_back(i); + } } } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.hpp b/src/ui/plugins/workshop/installed.hpp index 1c62a98..1390ba4 100644 --- a/src/ui/plugins/workshop/installed.hpp +++ b/src/ui/plugins/workshop/installed.hpp @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include - #include "core/workshop/types.hpp" namespace px::modrim::workshop { @@ -12,26 +11,27 @@ namespace px::modrim::workshop { inline constexpr int kPage = 48; class InstalledList { -public: - const std::vector& mods() const { return m_mods; } - bool loaded() const { return !m_mods.empty(); } - int total() const { return static_cast(m_matches.size()); } - bool hasMore() const { return m_shown < m_matches.size(); } - - void replace(std::vector mods); - void search(QString query); - void resetPaging(); - std::vector visible() const; - std::vector nextPage(); - -private: - void rematch(); - std::vector slice(std::size_t start, std::size_t end) const; - - std::vector m_mods; - std::vector m_matches; - QString m_query; - std::size_t m_shown = kPage; + public: + const std::vector& mods() const { return m_mods; } + bool loaded() const { return !m_mods.empty(); } + int total() const { return static_cast(m_matches.size()); } + bool hasMore() const { return m_shown < m_matches.size(); } + + void replace(std::vector mods); + void search(QString query); + void resetPaging(); + std::vector visible() const; + std::vector nextPage(); + + private: + void rematch(); + std::vector slice(std::size_t start, + std::size_t end) const; + + std::vector m_mods; + std::vector m_matches; + QString m_query; + std::size_t m_shown = kPage; }; } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp index 1a13634..9ac98ec 100644 --- a/src/ui/plugins/workshop/models.cpp +++ b/src/ui/plugins/workshop/models.cpp @@ -1,14 +1,13 @@ #include "ui/plugins/workshop/models.hpp" -#include -#include -#include -#include - #include #include #include #include +#include +#include +#include +#include #include "ui/panels/mod_info_data.hpp" @@ -17,209 +16,288 @@ namespace { using namespace workshop; constexpr std::array kRoles = { - "itemId", "kind", "title", "author", "previewUrl", "collage", "sourceLabel", "versions", "tags", - "state", "active", "stateLabel", "actionLabel", "incompatible", "memberCount", "description", - "workshopUrl", "fileSize", "votes", "votesUp", "votesDown", "queued", "favourite", "summary", "compatLabel"}; + "itemId", "kind", "title", "author", "previewUrl", + "collage", "sourceLabel", "versions", "tags", "state", + "active", "stateLabel", "actionLabel", "incompatible", "memberCount", + "description", "workshopUrl", "fileSize", "votes", "votesUp", + "votesDown", "queued", "favourite", "summary", "compatLabel"}; QString sizeLabel(const std::optional& bytes) { - return bytes ? QString::number(QString::fromStdString(*bytes).toDouble() / 1048576.0, 'f', 1) + " MB" - : QStringLiteral("Unknown"); + return bytes ? QString::number( + QString::fromStdString(*bytes).toDouble() / 1048576.0, 'f', + 1) + + " MB" + : QStringLiteral("Unknown"); } -QString compatLabel(const QSet& versions, const QString& game, bool incompatible, bool noCommon) { - if (game.isEmpty()) return {}; - if (noCommon) return QStringLiteral("No common version"); - if (incompatible) return "Not for " + game; - if (versions.isEmpty()) return QStringLiteral("Version not stated"); - return "Works with " + game; +QString compatLabel(const QSet& versions, const QString& game, + bool incompatible, bool noCommon) { + if (game.isEmpty()) return {}; + if (noCommon) return QStringLiteral("No common version"); + if (incompatible) return "Not for " + game; + if (versions.isEmpty()) return QStringLiteral("Version not stated"); + return "Works with " + game; } } // namespace QString safeUrl(const QString& value) { - for (const QChar ch : value) { - if (ch.isSpace() || ch.unicode() < 32) return {}; - } - const QUrl url(value); - const QString scheme = url.scheme().toLower(); - return url.isValid() && (scheme == "http" || scheme == "https") && !url.authority().isEmpty() ? value : QString{}; + for (const QChar ch : value) { + if (ch.isSpace() || ch.unicode() < 32) return {}; + } + const QUrl url(value); + const QString scheme = url.scheme().toLower(); + return url.isValid() && (scheme == "http" || scheme == "https") && + !url.authority().isEmpty() + ? value + : QString{}; } QString summarize(const QString& text, const QString& format) { - static const QRegularExpression media(QStringLiteral(R"(\[(img|video|previewimage)[^\]]*\].*?\[/\1\])"), - QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); - static const QRegularExpression tags(QStringLiteral(R"(\[/?[a-zA-Z*][^\]]*\])")); - QString result = text; - if (format == "bbcode") result.replace(media, " ").replace(tags, " "); - result = result.simplified(); - const auto codepoints = result.toUcs4(); - if (codepoints.size() <= 140) return result; - result = QString::fromUcs4(codepoints.constData(), 140); - const auto space = result.lastIndexOf(' '); - if (space >= 0) result.truncate(space); - const QString punctuation = QStringLiteral(".,;:-–— "); - while (!result.isEmpty() && punctuation.contains(result.back())) result.chop(1); - return result + QChar(0x2026); + static const QRegularExpression media( + QStringLiteral(R"(\[(img|video|previewimage)[^\]]*\].*?\[/\1\])"), + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); + static const QRegularExpression tags( + QStringLiteral(R"(\[/?[a-zA-Z*][^\]]*\])")); + QString result = text; + if (format == "bbcode") result.replace(media, " ").replace(tags, " "); + result = result.simplified(); + const auto codepoints = result.toUcs4(); + if (codepoints.size() <= 140) return result; + result = QString::fromUcs4(codepoints.constData(), 140); + const auto space = result.lastIndexOf(' '); + if (space >= 0) result.truncate(space); + const QString punctuation = QStringLiteral(".,;:-–— "); + while (!result.isEmpty() && punctuation.contains(result.back())) + result.chop(1); + return result + QChar(0x2026); } -QVariantMap itemRow(const CatalogEntry& entry, - const std::function& installState, - const QString& gameVersion, bool detail) { - return std::visit([&](const auto& item) { +QVariantMap itemRow( + const CatalogEntry& entry, + const std::function& installState, + const QString& gameVersion, bool detail) { + return std::visit( + [&](const auto& item) { using Item = std::decay_t; constexpr bool kCollection = std::is_same_v; QSet versions; - for (const auto& version : item.supported_versions) versions.insert(QString::fromStdString(version)); + for (const auto& version : item.supported_versions) + versions.insert(QString::fromStdString(version)); if constexpr (kCollection) { - static const QRegularExpression versionTag(QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); - for (const auto& tag : item.tags) { - const QString value = QString::fromStdString(tag); - if (versionTag.match(value).hasMatch()) versions.insert(value); - } + static const QRegularExpression versionTag( + QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); + for (const auto& tag : item.tags) { + const QString value = QString::fromStdString(tag); + if (versionTag.match(value).hasMatch()) versions.insert(value); + } } - bool incompatible = !gameVersion.isEmpty() && !versions.isEmpty() && !versions.contains(gameVersion); + bool incompatible = !gameVersion.isEmpty() && !versions.isEmpty() && + !versions.contains(gameVersion); bool noCommon = false; InstallState state = InstallState::kMissing; - QString source, action, size, votes = QStringLiteral("Unrated"), up = "0", down = "0"; + QString source, action, size, votes = QStringLiteral("Unrated"), + up = "0", down = "0"; QString workshopUrl, format = QStringLiteral("bbcode"); QStringList collage; qlonglong memberCount = 0; if constexpr (kCollection) { - source = item.source == CollectionSource::kPicked ? "PxModRim pick" : "Steam collection"; - action = QStringLiteral("Download all"); - memberCount = item.member_count; - size = sizeLabel(item.total_size); - noCommon = item.no_common_version && versions.isEmpty(); - incompatible = incompatible || noCommon; - if (item.workshop_url) workshopUrl = QString::fromStdString(*item.workshop_url); - if (item.description_format == CollectionDescriptionFormat::kText) format = QStringLiteral("text"); - for (const auto& preview : item.member_previews) { - const QString url = safeUrl(QString::fromStdString(preview)); - if (!url.isEmpty()) collage.append(url); - } + source = item.source == CollectionSource::kPicked + ? "PxModRim pick" + : "Steam collection"; + action = QStringLiteral("Download all"); + memberCount = item.member_count; + size = sizeLabel(item.total_size); + noCommon = item.no_common_version && versions.isEmpty(); + incompatible = incompatible || noCommon; + if (item.workshop_url) + workshopUrl = QString::fromStdString(*item.workshop_url); + if (item.description_format == CollectionDescriptionFormat::kText) + format = QStringLiteral("text"); + for (const auto& preview : item.member_previews) { + const QString url = safeUrl(QString::fromStdString(preview)); + if (!url.isEmpty()) collage.append(url); + } } else { - state = installState(item); - incompatible = incompatible || item.incompatible; - source = QStringLiteral("Steam Workshop"); - switch (state) { - case InstallState::kMissing: action = QStringLiteral("Download"); break; - case InstallState::kInstalled: action = QStringLiteral("Installed"); break; - case InstallState::kOutdated: action = QStringLiteral("Update"); break; - } - size = sizeLabel(item.file_size); - workshopUrl = QString::fromStdString(item.workshop_url); - if (item.votes) { - const QLocale locale(QLocale::English, QLocale::UnitedStates); - up = locale.toString(static_cast(item.votes->up)); - down = locale.toString(static_cast(item.votes->down)); - const double total = static_cast(item.votes->up) + static_cast(item.votes->down); - if (total != 0) votes = QString::number(std::nearbyint(100.0 * item.votes->up / total), 'f', 0) + '%'; - } + state = installState(item); + incompatible = incompatible || item.incompatible; + source = QStringLiteral("Steam Workshop"); + switch (state) { + case InstallState::kMissing: + action = QStringLiteral("Download"); + break; + case InstallState::kInstalled: + action = QStringLiteral("Installed"); + break; + case InstallState::kOutdated: + action = QStringLiteral("Update"); + break; + } + size = sizeLabel(item.file_size); + workshopUrl = QString::fromStdString(item.workshop_url); + if (item.votes) { + const QLocale locale(QLocale::English, QLocale::UnitedStates); + up = locale.toString(static_cast(item.votes->up)); + down = locale.toString(static_cast(item.votes->down)); + const double total = static_cast(item.votes->up) + + static_cast(item.votes->down); + if (total != 0) + votes = + QString::number( + std::nearbyint(100.0 * item.votes->up / total), 'f', 0) + + '%'; + } } QString stateName, stateLabel; switch (state) { - case InstallState::kMissing: stateName = "missing"; stateLabel = "Not installed"; break; - case InstallState::kInstalled: stateName = "installed"; stateLabel = "Installed"; break; - case InstallState::kOutdated: stateName = "outdated"; stateLabel = "Update available"; break; + case InstallState::kMissing: + stateName = "missing"; + stateLabel = "Not installed"; + break; + case InstallState::kInstalled: + stateName = "installed"; + stateLabel = "Installed"; + break; + case InstallState::kOutdated: + stateName = "outdated"; + stateLabel = "Update available"; + break; } - QString preview = item.preview_url ? QString::fromStdString(*item.preview_url) : QString{}; + QString preview = item.preview_url + ? QString::fromStdString(*item.preview_url) + : QString{}; if (preview.isEmpty()) { - for (const auto& image : item.previews) { - if (image.type == PreviewType::kImage) { - preview = QString::fromStdString(image.url); - break; - } + for (const auto& image : item.previews) { + if (image.type == PreviewType::kImage) { + preview = QString::fromStdString(image.url); + break; } + } } QString author; - if (item.author.name) author = QString::fromStdString(*item.author.name); - if (author.isEmpty() && item.author.id) author = QString::fromStdString(*item.author.id); + if (item.author.name) + author = QString::fromStdString(*item.author.name); + if (author.isEmpty() && item.author.id) + author = QString::fromStdString(*item.author.id); if (author.isEmpty()) author = QStringLiteral("Unknown author"); std::vector, QString>> sorted; sorted.reserve(versions.size()); for (const auto& version : versions) { - std::vector parts; - for (const auto& part : version.split('.')) parts.push_back(part.toInt()); - sorted.emplace_back(std::move(parts), version); + std::vector parts; + for (const auto& part : version.split('.')) + parts.push_back(part.toInt()); + sorted.emplace_back(std::move(parts), version); } std::sort(sorted.begin(), sorted.end()); QStringList versionLabels, tagLabels; for (const auto& version : sorted) versionLabels.append(version.second); for (const auto& tag : item.tags) { - const QString value = QString::fromStdString(tag); - if (!versions.contains(value)) tagLabels.append(value); + const QString value = QString::fromStdString(tag); + if (!versions.contains(value)) tagLabels.append(value); } const QString description = QString::fromStdString(item.description); return QVariantMap{ - {"itemId", QString::fromStdString(item.id)}, {"kind", kCollection ? "collection" : "mod"}, - {"title", QString::fromStdString(item.title)}, {"author", author}, {"previewUrl", safeUrl(preview)}, - {"collage", collage}, {"sourceLabel", source}, - {"versions", versionLabels.isEmpty() ? QStringLiteral("Not specified") : versionLabels.join(", ")}, - {"tags", tagLabels.join(QStringLiteral(" · "))}, {"state", stateName}, {"active", false}, - {"queued", false}, {"favourite", false}, {"stateLabel", stateLabel}, {"actionLabel", action}, - {"incompatible", incompatible}, {"memberCount", memberCount}, - {"description", detail ? px::modrim::descriptionToHtml(description, format) : QString{}}, - {"workshopUrl", safeUrl(workshopUrl)}, {"fileSize", size}, {"votes", votes}, - {"votesUp", up}, {"votesDown", down}, {"summary", summarize(description, format)}, - {"compatLabel", compatLabel(versions, gameVersion, incompatible, noCommon)}}; - }, entry); + {"itemId", QString::fromStdString(item.id)}, + {"kind", kCollection ? "collection" : "mod"}, + {"title", QString::fromStdString(item.title)}, + {"author", author}, + {"previewUrl", safeUrl(preview)}, + {"collage", collage}, + {"sourceLabel", source}, + {"versions", versionLabels.isEmpty() + ? QStringLiteral("Not specified") + : versionLabels.join(", ")}, + {"tags", tagLabels.join(QStringLiteral(" · "))}, + {"state", stateName}, + {"active", false}, + {"queued", false}, + {"favourite", false}, + {"stateLabel", stateLabel}, + {"actionLabel", action}, + {"incompatible", incompatible}, + {"memberCount", memberCount}, + {"description", + detail ? px::modrim::descriptionToHtml(description, format) + : QString{}}, + {"workshopUrl", safeUrl(workshopUrl)}, + {"fileSize", size}, + {"votes", votes}, + {"votesUp", up}, + {"votesDown", down}, + {"summary", summarize(description, format)}, + {"compatLabel", + compatLabel(versions, gameVersion, incompatible, noCommon)}}; + }, + entry); } -CatalogListModel::CatalogListModel(QObject* parent) : QAbstractListModel(parent) {} - -void CatalogListModel::setPage(QList rows, int total, std::optional cursor, bool append) { - if (append) { - QSet known; - for (const auto& row : m_rows) known.insert(row.value("itemId").toString()); - QList additions; - for (auto& row : rows) { - const QString id = row.value("itemId").toString(); - if (!known.contains(id)) { - known.insert(id); - additions.append(std::move(row)); - } - } - if (!additions.isEmpty()) { - beginInsertRows({}, count(), count() + static_cast(additions.size()) - 1); - m_rows.append(std::move(additions)); - endInsertRows(); - } - } else if (rows.size() == m_rows.size() && std::equal(rows.cbegin(), rows.cend(), m_rows.cbegin(), - [](const auto& a, const auto& b) { return a.value("itemId") == b.value("itemId"); })) { - for (qsizetype i = 0; i < rows.size(); ++i) { - if (rows.at(i) != m_rows.at(i)) { - m_rows[i] = std::move(rows[i]); - emit dataChanged(index(static_cast(i)), index(static_cast(i))); - } - } - } else { - beginResetModel(); - m_rows = std::move(rows); - endResetModel(); +CatalogListModel::CatalogListModel(QObject* parent) + : QAbstractListModel(parent) {} + +void CatalogListModel::setPage(QList rows, int total, + std::optional cursor, bool append) { + if (append) { + QSet known; + for (const auto& row : m_rows) known.insert(row.value("itemId").toString()); + QList additions; + for (auto& row : rows) { + const QString id = row.value("itemId").toString(); + if (!known.contains(id)) { + known.insert(id); + additions.append(std::move(row)); + } + } + if (!additions.isEmpty()) { + beginInsertRows({}, count(), + count() + static_cast(additions.size()) - 1); + m_rows.append(std::move(additions)); + endInsertRows(); + } + } else if (rows.size() == m_rows.size() && + std::equal(rows.cbegin(), rows.cend(), m_rows.cbegin(), + [](const auto& a, const auto& b) { + return a.value("itemId") == b.value("itemId"); + })) { + for (qsizetype i = 0; i < rows.size(); ++i) { + if (rows.at(i) != m_rows.at(i)) { + m_rows[i] = std::move(rows[i]); + emit dataChanged(index(static_cast(i)), + index(static_cast(i))); + } } - m_total = total; - m_cursor = std::move(cursor); - emit changed(); + } else { + beginResetModel(); + m_rows = std::move(rows); + endResetModel(); + } + m_total = total; + m_cursor = std::move(cursor); + emit changed(); } -void CatalogListModel::refreshRows(const std::function& convert) { - for (auto& row : m_rows) row = convert(row); - if (!m_rows.isEmpty()) emit dataChanged(index(0), index(count() - 1)); +void CatalogListModel::refreshRows( + const std::function& convert) { + for (auto& row : m_rows) row = convert(row); + if (!m_rows.isEmpty()) emit dataChanged(index(0), index(count() - 1)); } int CatalogListModel::rowCount(const QModelIndex& parent) const { - return parent.isValid() ? 0 : count(); + return parent.isValid() ? 0 : count(); } QVariant CatalogListModel::data(const QModelIndex& index, int role) const { - const int offset = role - Qt::UserRole - 1; - if (!index.isValid() || index.row() < 0 || index.row() >= count() || offset < 0 || offset >= static_cast(kRoles.size())) return {}; - return m_rows.at(index.row()).value(QString::fromLatin1(kRoles[offset])); + const int offset = role - Qt::UserRole - 1; + if (!index.isValid() || index.row() < 0 || index.row() >= count() || + offset < 0 || offset >= static_cast(kRoles.size())) + return {}; + return m_rows.at(index.row()).value(QString::fromLatin1(kRoles[offset])); } QHash CatalogListModel::roleNames() const { - QHash names; - for (std::size_t i = 0; i < kRoles.size(); ++i) names.insert(Qt::UserRole + static_cast(i) + 1, kRoles[i]); - return names; + QHash names; + for (std::size_t i = 0; i < kRoles.size(); ++i) + names.insert(Qt::UserRole + static_cast(i) + 1, kRoles[i]); + return names; } } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.hpp b/src/ui/plugins/workshop/models.hpp index 6e42c59..ac5ac6f 100644 --- a/src/ui/plugins/workshop/models.hpp +++ b/src/ui/plugins/workshop/models.hpp @@ -1,10 +1,9 @@ #pragma once -#include -#include - #include #include +#include +#include #include "core/workshop/types.hpp" @@ -13,39 +12,45 @@ namespace px::modrim::workshop { using workshop::CatalogEntry; QString safeUrl(const QString& value); -QString summarize(const QString& text, const QString& format = QStringLiteral("bbcode")); -QVariantMap itemRow(const CatalogEntry& item, - const std::function& installState, - const QString& gameVersion, bool detail = false); +QString summarize(const QString& text, + const QString& format = QStringLiteral("bbcode")); +QVariantMap itemRow( + const CatalogEntry& item, + const std::function& + installState, + const QString& gameVersion, bool detail = false); class CatalogListModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) - Q_PROPERTY(int count READ count NOTIFY changed) - Q_PROPERTY(int total READ total NOTIFY changed) - -public: - explicit CatalogListModel(QObject* parent = nullptr); - - void setPage(QList rows, int total, std::optional cursor, bool append = false); - void refreshRows(const std::function& convert); - int rowCount(const QModelIndex& parent = {}) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - const std::optional& cursor() const { return m_cursor; } - bool hasMore() const { return m_cursor.has_value(); } - int count() const { return static_cast(m_rows.size()); } - int total() const { return m_total; } - const QList& rows() const { return m_rows; } - -signals: - void changed(); - -private: - QList m_rows; - std::optional m_cursor; - int m_total = 0; + Q_OBJECT + Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) + Q_PROPERTY(int count READ count NOTIFY changed) + Q_PROPERTY(int total READ total NOTIFY changed) + + public: + explicit CatalogListModel(QObject* parent = nullptr); + + void setPage(QList rows, int total, + std::optional cursor, bool append = false); + void refreshRows( + const std::function& convert); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + const std::optional& cursor() const { return m_cursor; } + bool hasMore() const { return m_cursor.has_value(); } + int count() const { return static_cast(m_rows.size()); } + int total() const { return m_total; } + const QList& rows() const { return m_rows; } + + signals: + void changed(); + + private: + QList m_rows; + std::optional m_cursor; + int m_total = 0; }; } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp index caaf0bd..e3b875e 100644 --- a/src/ui/plugins/workshop/plugin.cpp +++ b/src/ui/plugins/workshop/plugin.cpp @@ -4,21 +4,35 @@ namespace px::modrim::workshop { -CatalogSettingsSection::CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent) - : PluginConfigSectionBase({{QStringLiteral("url"), QString::fromStdString(catalog.settings().url)}}, parent), m_catalog(catalog) {} -QString CatalogSettingsSection::title() const { return QStringLiteral("Workshop catalog"); } -QUrl CatalogSettingsSection::source() const { return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); } +CatalogSettingsSection::CatalogSettingsSection( + workshop::WorkshopCatalog& catalog, QObject* parent) + : PluginConfigSectionBase( + {{QStringLiteral("url"), + QString::fromStdString(catalog.settings().url)}}, + parent), + m_catalog(catalog) {} +QString CatalogSettingsSection::title() const { + return QStringLiteral("Workshop catalog"); +} +QUrl CatalogSettingsSection::source() const { + return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); +} Result CatalogSettingsSection::commit(const QJsonObject& merged) { - m_catalog.setBaseUrl(merged.value(QStringLiteral("url")).toString()); - return {}; + m_catalog.setBaseUrl(merged.value(QStringLiteral("url")).toString()); + return {}; } WorkshopUiPlugin::WorkshopUiPlugin() - : UiPlugin(QStringLiteral("workshop_ui"), {QStringLiteral("workshop_catalog"), QStringLiteral("downloads")}) {} + : UiPlugin( + QStringLiteral("workshop_ui"), + {QStringLiteral("workshop_catalog"), QStringLiteral("downloads")}) {} void WorkshopUiPlugin::setup(AppContext& ctx) { - ctx.addRailView(); - auto* catalog = static_cast(ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); - ctx.addSettingsSection([catalog](QObject* parent) { return new CatalogSettingsSection(*catalog, parent); }); + ctx.addRailView(); + auto* catalog = static_cast( + ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); + ctx.addSettingsSection([catalog](QObject* parent) { + return new CatalogSettingsSection(*catalog, parent); + }); } } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.hpp b/src/ui/plugins/workshop/plugin.hpp index 4580acb..2c056e9 100644 --- a/src/ui/plugins/workshop/plugin.hpp +++ b/src/ui/plugins/workshop/plugin.hpp @@ -7,22 +7,22 @@ namespace px::modrim::workshop { class CatalogSettingsSection : public PluginConfigSectionBase { -public: - CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent); - QString title() const override; - QUrl source() const override; + public: + CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent); + QString title() const override; + QUrl source() const override; -protected: - Result commit(const QJsonObject& merged) override; + protected: + Result commit(const QJsonObject& merged) override; -private: - workshop::WorkshopCatalog& m_catalog; + private: + workshop::WorkshopCatalog& m_catalog; }; class WorkshopUiPlugin : public UiPlugin { -public: - WorkshopUiPlugin(); - void setup(AppContext& ctx) override; + public: + WorkshopUiPlugin(); + void setup(AppContext& ctx) override; }; } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index 6206a74..cc006c6 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -1,13 +1,14 @@ #include "ui/plugins/workshop/view.hpp" -#include #include +#include #include #include -#include #include #include #include +#include + #include #include "ui/components/mod_activation.hpp" @@ -18,439 +19,657 @@ namespace px::modrim::workshop { using namespace workshop; namespace { QString idOf(const CatalogEntry& item) { - return std::visit([](const auto& value) { return QString::fromStdString(value.id); }, item); + return std::visit( + [](const auto& value) { return QString::fromStdString(value.id); }, item); } QSet idsOf(const std::vector& ids) { - QSet result; - for (const auto& id : ids) result.insert(QString::fromStdString(id)); - return result; + QSet result; + for (const auto& id : ids) result.insert(QString::fromStdString(id)); + return result; } std::optional cursorOf(const std::optional& cursor) { - return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; + return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; } WorkshopCatalog& catalogOf(CoreContext& ctx) { - return *static_cast(ctx.plugins().get(QStringLiteral("workshop_catalog"))); -} + return *static_cast( + ctx.plugins().get(QStringLiteral("workshop_catalog"))); } +} // namespace -WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) : WorkshopViewPanel(ctx, engine, parent, app, catalogOf(ctx)) {} -WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, - WorkshopCatalog& catalog) - : BaseViewPanel(ctx, engine, parent, app), modsModel(new CatalogListModel(this)), - collectionsModel(new CatalogListModel(this)), membersModel(new CatalogListModel(this)), - m_catalog(catalog), m_downloads(ctx.downloads()), m_favourites(catalog.favourites()) { - if (!gameVersion().isEmpty()) m_criteria.version = gameVersion(); +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app, + WorkshopCatalog& catalog) + : BaseViewPanel(ctx, engine, parent, app), + modsModel(new CatalogListModel(this)), + collectionsModel(new CatalogListModel(this)), + membersModel(new CatalogListModel(this)), + m_catalog(catalog), + m_downloads(ctx.downloads()), + m_favourites(catalog.favourites()) { + if (!gameVersion().isEmpty()) m_criteria.version = gameVersion(); + refreshModIds(); + m_qml = new QQuickWidget(engine, this); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); + m_qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), + modsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), + collectionsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), + membersModel); + m_busyId = m_downloads.busyChanged.connect([this](bool) { + refreshRows(); + emit changed(); + }); + m_queueId = m_catalog.queueChanged.connect([this](const std::monostate&) { + refreshRows(); + emit changed(); + }); + m_urlId = m_catalog.catalogUrlChanged.connect( + [this](const QString&) { urlChanged(); }); + m_installedId = m_catalog.installedChanged.connect( + [this](const std::monostate&) { installedChanged(); }); + m_favouritesId = + m_favourites.changed().connect([this](const FavouritesState&) { + if (m_tab == QStringLiteral("Favourites") && !m_detailData) + showFavourites(); + else { + refreshRows(); + emit changed(); + } + }); + m_activeId = m_ctx.activeStateChanged().connect([this](const QStringList&) { refreshModIds(); - m_qml = new QQuickWidget(engine, this); - m_qml->setObjectName(QStringLiteral("workshopView")); - m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_qml->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); - m_qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), this); - m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), modsModel); - m_qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), collectionsModel); - m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), membersModel); - m_busyId = m_downloads.busyChanged.connect([this](bool) { refreshRows(); emit changed(); }); - m_queueId = m_catalog.queueChanged.connect([this](const std::monostate&) { refreshRows(); emit changed(); }); - m_urlId = m_catalog.catalogUrlChanged.connect([this](const QString&) { urlChanged(); }); - m_installedId = m_catalog.installedChanged.connect([this](const std::monostate&) { installedChanged(); }); - m_favouritesId = m_favourites.changed().connect([this](const FavouritesState&) { - if (m_tab == QStringLiteral("Favourites") && !m_detailData) showFavourites(); - else { refreshRows(); emit changed(); } - }); - m_activeId = m_ctx.activeStateChanged().connect([this](const QStringList&) { - refreshModIds(); refreshRows(); emit changed(); - }); - m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); - m_root->addWidget(m_qml, 1); + refreshRows(); + emit changed(); + }); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + m_root->addWidget(m_qml, 1); } WorkshopViewPanel::~WorkshopViewPanel() { - teardown(); - // Workshop.qml bindings read workshopPanel; the view must die while this panel is still alive. - delete m_qml; + teardown(); + // Workshop.qml bindings read workshopPanel; the view must die while this + // panel is still alive. + delete m_qml; } void WorkshopViewPanel::teardown() { - if (m_tornDown) return; - m_tornDown = true; - ++m_generation; - m_retry = {}; - m_downloads.busyChanged.disconnect(m_busyId); - m_catalog.queueChanged.disconnect(m_queueId); - m_catalog.catalogUrlChanged.disconnect(m_urlId); - m_catalog.installedChanged.disconnect(m_installedId); - m_favourites.changed().disconnect(m_favouritesId); - m_ctx.activeStateChanged().disconnect(m_activeId); + if (m_tornDown) return; + m_tornDown = true; + ++m_generation; + m_retry = {}; + m_downloads.busyChanged.disconnect(m_busyId); + m_catalog.queueChanged.disconnect(m_queueId); + m_catalog.catalogUrlChanged.disconnect(m_urlId); + m_catalog.installedChanged.disconnect(m_installedId); + m_favourites.changed().disconnect(m_favouritesId); + m_ctx.activeStateChanged().disconnect(m_activeId); } QString WorkshopViewPanel::gameVersion() const { - const auto version = m_ctx.targetVersion(); - return version == QStringLiteral("Unknown") ? QString{} : version; + const auto version = m_ctx.targetVersion(); + return version == QStringLiteral("Unknown") ? QString{} : version; } void WorkshopViewPanel::refreshModIds() { - m_installedIds.clear(); m_activeIds.clear(); - const auto& mods = m_ctx.allMods(); - for (const auto& mod : mods) if (auto id = mod->publishedFileId()) m_installedIds.insert(*id); - for (const auto& uuid : m_ctx.activeUuids()) { - auto mod = mods.value(uuid); - if (mod) if (auto id = mod->publishedFileId()) m_activeIds.insert(*id); - } + m_installedIds.clear(); + m_activeIds.clear(); + const auto& mods = m_ctx.allMods(); + for (const auto& mod : mods) + if (auto id = mod->publishedFileId()) m_installedIds.insert(*id); + for (const auto& uuid : m_ctx.activeUuids()) { + auto mod = mods.value(uuid); + if (mod) + if (auto id = mod->publishedFileId()) m_activeIds.insert(*id); + } } QVariantMap WorkshopViewPanel::row(const CatalogEntry& item, bool detail) { - const auto id = idOf(item); - m_items.insert(id, item); - std::visit([this](const auto& value) { + const auto id = idOf(item); + m_items.insert(id, item); + std::visit( + [this](const auto& value) { auto tags = idsOf(value.tags); tags.subtract(idsOf(value.supported_versions)); m_knownTags.unite(tags); - }, item); - auto result = itemRow(item, [this](const CatalogMod& mod) { return m_catalog.installState(mod); }, gameVersion(), detail); - const auto* mod = std::get_if(&item); - const auto* collection = std::get_if(&item); - result[QStringLiteral("active")] = mod && m_activeIds.contains(id); - if (collection) { - if (!detail) { - qsizetype count = 0; - for (const auto& member : collection->member_ids) count += m_installedIds.contains(QString::fromStdString(member)); - if (!collection->member_ids.empty() && count == static_cast(collection->member_ids.size())) { - result[QStringLiteral("state")] = QStringLiteral("installed"); - result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); - result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); - } else if (count) result[QStringLiteral("actionLabel")] = QStringLiteral("Complete download"); - } - if (m_favourites.contains(collection->id)) { - result[QStringLiteral("favourite")] = true; - m_favourites.remember(*collection); - } + }, + item); + auto result = itemRow( + item, + [this](const CatalogMod& mod) { return m_catalog.installState(mod); }, + gameVersion(), detail); + const auto* mod = std::get_if(&item); + const auto* collection = std::get_if(&item); + result[QStringLiteral("active")] = mod && m_activeIds.contains(id); + if (collection) { + if (!detail) { + qsizetype count = 0; + for (const auto& member : collection->member_ids) + count += m_installedIds.contains(QString::fromStdString(member)); + if (!collection->member_ids.empty() && + count == static_cast(collection->member_ids.size())) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + } else if (count) + result[QStringLiteral("actionLabel")] = + QStringLiteral("Complete download"); } - if (collection || result.value(QStringLiteral("state")).toString() != QStringLiteral("installed")) { - if (m_preparing.contains(id)) { - result[QStringLiteral("queued")] = true; - result[QStringLiteral("actionLabel")] = QStringLiteral("Preparing…"); - } else { - QSet ids = mod ? QSet{id} : idsOf(collection->member_ids) | m_collectionMods.value(id); - if (collection && m_detailData && idOf(m_detailData->item) == id) ids.unite(m_packIds); - if (ids.intersects(m_downloads.activeIds())) { - result[QStringLiteral("queued")] = true; - result[QStringLiteral("actionLabel")] = QStringLiteral("Downloading"); - } else if (ids.intersects(m_catalog.queuedIds())) { - result[QStringLiteral("queued")] = true; - result[QStringLiteral("actionLabel")] = QStringLiteral("Queued"); - } - } + if (m_favourites.contains(collection->id)) { + result[QStringLiteral("favourite")] = true; + m_favourites.remember(*collection); + } + } + if (collection || result.value(QStringLiteral("state")).toString() != + QStringLiteral("installed")) { + if (m_preparing.contains(id)) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Preparing…"); + } else { + QSet ids = + mod ? QSet{id} + : idsOf(collection->member_ids) | m_collectionMods.value(id); + if (collection && m_detailData && idOf(m_detailData->item) == id) + ids.unite(m_packIds); + if (ids.intersects(m_downloads.activeIds())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Downloading"); + } else if (ids.intersects(m_catalog.queuedIds())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Queued"); + } } - return result; + } + return result; } QVariantMap WorkshopViewPanel::detailRow(const Detail& detail) { - auto result = row(detail.item, true); - result[QStringLiteral("complete")] = detail.complete; - result[QStringLiteral("warning")] = QString::fromStdString(detail.warning); - if (std::holds_alternative(detail.item)) { - auto collage = result.value(QStringLiteral("collage")).toStringList(); - if (result.value(QStringLiteral("previewUrl")).toString().isEmpty() && collage.isEmpty()) { - for (const auto& member : detail.members) { - const auto* mod = std::get_if(&member); - if (!mod) continue; - QString preview = QString::fromStdString(mod->preview_url.value_or("")); - if (preview.isEmpty()) for (const auto& image : mod->previews) { - if (image.type == PreviewType::kImage) { preview = QString::fromStdString(image.url); break; } - } - preview = safeUrl(preview); - if (!preview.isEmpty()) collage.append(preview); - if (collage.size() == 4) break; + auto result = row(detail.item, true); + result[QStringLiteral("complete")] = detail.complete; + result[QStringLiteral("warning")] = QString::fromStdString(detail.warning); + if (std::holds_alternative(detail.item)) { + auto collage = result.value(QStringLiteral("collage")).toStringList(); + if (result.value(QStringLiteral("previewUrl")).toString().isEmpty() && + collage.isEmpty()) { + for (const auto& member : detail.members) { + const auto* mod = std::get_if(&member); + if (!mod) continue; + QString preview = QString::fromStdString(mod->preview_url.value_or("")); + if (preview.isEmpty()) + for (const auto& image : mod->previews) { + if (image.type == PreviewType::kImage) { + preview = QString::fromStdString(image.url); + break; } - result[QStringLiteral("collage")] = collage; - } - bool started = false; - for (const auto& member : detail.members) if (auto* mod = std::get_if(&member)) - started |= m_catalog.installState(*mod) != InstallState::kMissing; - if (!result.value(QStringLiteral("queued")).toBool()) - result[QStringLiteral("actionLabel")] = started ? QStringLiteral("Complete download") : QStringLiteral("Download all"); - if (auto copies = packCopies()) { - result[QStringLiteral("state")] = QStringLiteral("installed"); - result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); - if (!result.value(QStringLiteral("queued")).toBool()) result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); - result[QStringLiteral("active")] = std::ranges::all_of(*copies, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); - } + } + preview = safeUrl(preview); + if (!preview.isEmpty()) collage.append(preview); + if (collage.size() == 4) break; + } + result[QStringLiteral("collage")] = collage; + } + bool started = false; + for (const auto& member : detail.members) + if (auto* mod = std::get_if(&member)) + started |= m_catalog.installState(*mod) != InstallState::kMissing; + if (!result.value(QStringLiteral("queued")).toBool()) + result[QStringLiteral("actionLabel")] = + started ? QStringLiteral("Complete download") + : QStringLiteral("Download all"); + if (auto copies = packCopies()) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + if (!result.value(QStringLiteral("queued")).toBool()) + result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("active")] = + std::ranges::all_of(*copies, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); } - return result; + } + return result; } void WorkshopViewPanel::refreshRows() { - for (auto* model : {modsModel, collectionsModel, membersModel}) model->refreshRows([this](const QVariantMap& value) { - return row(m_items.value(value.value(QStringLiteral("itemId")).toString())); + for (auto* model : {modsModel, collectionsModel, membersModel}) + model->refreshRows([this](const QVariantMap& value) { + return row( + m_items.value(value.value(QStringLiteral("itemId")).toString())); }); - if (m_detailData) m_detail = detailRow(*m_detailData); + if (m_detailData) m_detail = detailRow(*m_detailData); } void WorkshopViewPanel::clearDetail() { - m_detail.clear(); m_detailData.reset(); m_current.reset(); m_trail.clear(); m_packIds.clear(); + m_detail.clear(); + m_detailData.reset(); + m_current.reset(); + m_trail.clear(); + m_packIds.clear(); } std::optional WorkshopViewPanel::packCopies() const { - if (m_tornDown || m_packIds.isEmpty()) return std::nullopt; - QHash installed; - for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) - if (auto id = it.value()->publishedFileId(); id && m_packIds.contains(*id)) installed[*id].append(it.key()); - if (installed.size() < m_packIds.size()) return std::nullopt; - QStringList copies; - for (const auto& uuids : installed) { - auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); - copies.append(active == uuids.cend() ? uuids.front() : *active); - } - return copies; + if (m_tornDown || m_packIds.isEmpty()) return std::nullopt; + QHash installed; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) + if (auto id = it.value()->publishedFileId(); id && m_packIds.contains(*id)) + installed[*id].append(it.key()); + if (installed.size() < m_packIds.size()) return std::nullopt; + QStringList copies; + for (const auto& uuids : installed) { + auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + copies.append(active == uuids.cend() ? uuids.front() : *active); + } + return copies; } QCoro::Task WorkshopViewPanel::loadPack() { - if (!m_detailData || m_tornDown) co_return; - QPointer self(this); - const int generation = m_generation; - auto result = co_await m_catalog.plan({}, {idOf(m_detailData->item)}); - if (!self || self->m_tornDown || generation != self->m_generation) co_return; - if (!result) { - spdlog::warn("[workshop] cannot resolve collection mods: {}", result.error().message.toStdString()); - co_return; - } - self->m_packIds.clear(); - if (result->is_complete) { - self->m_packIds = QSet(result->to_download.cbegin(), result->to_download.cend()); - self->m_packIds.unite(QSet(result->already_current.cbegin(), result->already_current.cend())); - } - self->refreshRows(); emit self->changed(); + if (!m_detailData || m_tornDown) co_return; + QPointer self(this); + const int generation = m_generation; + auto result = co_await m_catalog.plan({}, {idOf(m_detailData->item)}); + if (!self || self->m_tornDown || generation != self->m_generation) co_return; + if (!result) { + spdlog::warn("[workshop] cannot resolve collection mods: {}", + result.error().message.toStdString()); + co_return; + } + self->m_packIds.clear(); + if (result->is_complete) { + self->m_packIds = + QSet(result->to_download.cbegin(), result->to_download.cend()); + self->m_packIds.unite(QSet(result->already_current.cbegin(), + result->already_current.cend())); + } + self->refreshRows(); + emit self->changed(); } QCoro::Task WorkshopViewPanel::load(bool append) { - if (m_tornDown) co_return; - if (!configured()) { ++m_generation; m_busy = false; emit changed(); co_return; } - clearDetail(); - auto* catalog = &m_catalog; - if (m_tab == QStringLiteral("Discover")) { - co_await runCached(QStringLiteral("discover"), [catalog] { return catalog->discover(); }, [this](const Discover& value) { - modsModel->setPage(rows(value.mods.items), static_cast(value.mods.total), std::nullopt); - collectionsModel->setPage(rows(value.collections.items), static_cast(value.collections.total), std::nullopt); + if (m_tornDown) co_return; + if (!configured()) { + ++m_generation; + m_busy = false; + emit changed(); + co_return; + } + clearDetail(); + auto* catalog = &m_catalog; + if (m_tab == QStringLiteral("Discover")) { + co_await runCached( + QStringLiteral("discover"), [catalog] { return catalog->discover(); }, + [this](const Discover& value) { + modsModel->setPage(rows(value.mods.items), + static_cast(value.mods.total), std::nullopt); + collectionsModel->setPage(rows(value.collections.items), + static_cast(value.collections.total), + std::nullopt); }); - } else if (m_tab == QStringLiteral("Favourites")) { - ++m_generation; m_busy = false; m_error.clear(); showFavourites(); - } else if (m_tab == QStringLiteral("Installed")) { - if (append) { - modsModel->setPage(rows(m_installed.nextPage()), m_installed.total(), installedCursor(), true); - co_return; - } - m_installedDirty = false; - auto ids = m_installedIds.values(); - std::ranges::sort(ids); - const auto fingerprint = QCryptographicHash::hash(ids.join(QLatin1Char(',')).toUtf8(), QCryptographicHash::Sha256).toHex(); - co_await runCached>(QStringLiteral("installed|") + QString::fromLatin1(fingerprint), - [catalog] { return catalog->installedWithUpdates(); }, [this](const std::vector& value) { - m_installed.replace(value); recount(); showInstalled(); - }); + } else if (m_tab == QStringLiteral("Favourites")) { + ++m_generation; + m_busy = false; + m_error.clear(); + showFavourites(); + } else if (m_tab == QStringLiteral("Installed")) { + if (append) { + modsModel->setPage(rows(m_installed.nextPage()), m_installed.total(), + installedCursor(), true); + co_return; + } + m_installedDirty = false; + auto ids = m_installedIds.values(); + std::ranges::sort(ids); + const auto fingerprint = + QCryptographicHash::hash(ids.join(QLatin1Char(',')).toUtf8(), + QCryptographicHash::Sha256) + .toHex(); + co_await runCached>( + QStringLiteral("installed|") + QString::fromLatin1(fingerprint), + [catalog] { return catalog->installedWithUpdates(); }, + [this](const std::vector& value) { + m_installed.replace(value); + recount(); + showInstalled(); + }); + } else { + const bool collections = m_tab == QStringLiteral("Collections"); + auto* model = collections ? collectionsModel : modsModel; + auto query = m_criteria; + query.cursor = append ? model->cursor() : std::nullopt; + const QString key = + m_tab + QLatin1Char('|') + + QString::fromLatin1( + QCryptographicHash::hash( + QJsonDocument(QJsonArray{query.query, + query.tag.value_or(QString{}), + query.version.value_or(QString{}), + query.sort, query.limit, query.source}) + .toJson(QJsonDocument::Compact), + QCryptographicHash::Sha256) + .toHex()); + if (collections) { + co_await runCached>( + key, [catalog, query] { return catalog->collections(query); }, + [this, model, append](const CatalogPage& value) { + model->setPage(rows(value.items), static_cast(value.total), + cursorOf(value.next_cursor), append); + }, + !append); } else { - const bool collections = m_tab == QStringLiteral("Collections"); - auto* model = collections ? collectionsModel : modsModel; - auto query = m_criteria; - query.cursor = append ? model->cursor() : std::nullopt; - const QString key = m_tab + QLatin1Char('|') + QString::fromLatin1(QCryptographicHash::hash( - QJsonDocument(QJsonArray{query.query, query.tag.value_or(QString{}), query.version.value_or(QString{}), - query.sort, query.limit, query.source}).toJson(QJsonDocument::Compact), QCryptographicHash::Sha256).toHex()); - if (collections) { - co_await runCached>(key, [catalog, query] { return catalog->collections(query); }, - [this, model, append](const CatalogPage& value) { - model->setPage(rows(value.items), static_cast(value.total), cursorOf(value.next_cursor), append); - }, !append); - } else { - co_await runCached>(key, [catalog, query] { return catalog->mods(query); }, - [this, model, append](const CatalogPage& value) { - model->setPage(rows(value.items), static_cast(value.total), cursorOf(value.next_cursor), append); - }, !append); - } + co_await runCached>( + key, [catalog, query] { return catalog->mods(query); }, + [this, model, append](const CatalogPage& value) { + model->setPage(rows(value.items), static_cast(value.total), + cursorOf(value.next_cursor), append); + }, + !append); } + } } void WorkshopViewPanel::showFavourites() { - std::vector matches; - for (const auto& item : m_favourites.items()) - if (QString::fromStdString(item.title).toCaseFolded().contains(m_criteria.query.toCaseFolded())) matches.push_back(item); - collectionsModel->setPage(rows(matches), static_cast(matches.size()), std::nullopt); - emit changed(); + std::vector matches; + for (const auto& item : m_favourites.items()) + if (QString::fromStdString(item.title) + .toCaseFolded() + .contains(m_criteria.query.toCaseFolded())) + matches.push_back(item); + collectionsModel->setPage(rows(matches), static_cast(matches.size()), + std::nullopt); + emit changed(); } std::optional WorkshopViewPanel::installedCursor() const { - return m_installed.hasMore() ? std::optional(QStringLiteral("more")) : std::nullopt; + return m_installed.hasMore() ? std::optional(QStringLiteral("more")) + : std::nullopt; } void WorkshopViewPanel::showInstalled() { - clearDetail(); - modsModel->setPage(rows(m_installed.visible()), m_installed.total(), installedCursor()); - emit changed(); + clearDetail(); + modsModel->setPage(rows(m_installed.visible()), m_installed.total(), + installedCursor()); + emit changed(); } void WorkshopViewPanel::recount() { - m_updateCount = 0; - for (const auto& mod : m_installed.mods()) m_updateCount += m_catalog.installState(mod) == InstallState::kOutdated; + m_updateCount = 0; + for (const auto& mod : m_installed.mods()) + m_updateCount += m_catalog.installState(mod) == InstallState::kOutdated; } QCoro::Task WorkshopViewPanel::selectTabAsync(QString tab) { - m_tab = std::move(tab); m_notice.clear(); m_pending.reset(); clearDetail(); m_installed.resetPaging(); - co_await load(); -} -QCoro::Task WorkshopViewPanel::filterItems(QString query, QString version, QString source, QString sort, QString tag) { - m_criteria = CatalogQuery{.query = query, .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), - .version = version.isEmpty() ? std::nullopt : std::optional(version), .sort = sort, .source = source}; - m_installed.search(query); - if (m_tab == QStringLiteral("Installed") && m_installed.loaded()) { showInstalled(); co_return; } - if (m_tab == QStringLiteral("Discover")) m_tab = QStringLiteral("Mods"); - co_await load(); + m_tab = std::move(tab); + m_notice.clear(); + m_pending.reset(); + clearDetail(); + m_installed.resetPaging(); + co_await load(); +} +QCoro::Task WorkshopViewPanel::filterItems(QString query, QString version, + QString source, QString sort, + QString tag) { + m_criteria = CatalogQuery{ + .query = query, + .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), + .version = version.isEmpty() ? std::nullopt : std::optional(version), + .sort = sort, + .source = source}; + m_installed.search(query); + if (m_tab == QStringLiteral("Installed") && m_installed.loaded()) { + showInstalled(); + co_return; + } + if (m_tab == QStringLiteral("Discover")) m_tab = QStringLiteral("Mods"); + co_await load(); } QCoro::Task WorkshopViewPanel::retryLoad() { - if (m_retry && !m_tornDown) { - auto action = m_retry; - co_await action(); - } - co_return; + if (m_retry && !m_tornDown) { + auto action = m_retry; + co_await action(); + } + co_return; +} +void WorkshopViewPanel::go(const QStringList& path) { + m_app.navigate(buildRoute(QStringLiteral("workshop"), path)); } -void WorkshopViewPanel::go(const QStringList& path) { m_app.navigate(buildRoute(QStringLiteral("workshop"), path)); } void WorkshopViewPanel::selectTab(const QString& tab) { go({tab.toLower()}); } -void WorkshopViewPanel::openItem(const QString& itemId, const QString& kind) { go({kind, itemId}); } -void WorkshopViewPanel::openRoute(const QStringList& path) { followRoute(path); } +void WorkshopViewPanel::openItem(const QString& itemId, const QString& kind) { + go({kind, itemId}); +} +void WorkshopViewPanel::openRoute(const QStringList& path) { + followRoute(path); +} QCoro::Task WorkshopViewPanel::followRoute(QStringList path) { - if (m_tornDown || path.isEmpty()) co_return; - const QStringList tabs{QStringLiteral("Discover"), QStringLiteral("Mods"), QStringLiteral("Collections"), QStringLiteral("Favourites"), QStringLiteral("Installed")}; - if (path.size() == 1) { - for (const auto& tab : tabs) if (tab.compare(path.front(), Qt::CaseInsensitive) == 0) { - co_await selectTabAsync(tab); co_return; + if (m_tornDown || path.isEmpty()) co_return; + const QStringList tabs{QStringLiteral("Discover"), QStringLiteral("Mods"), + QStringLiteral("Collections"), + QStringLiteral("Favourites"), + QStringLiteral("Installed")}; + if (path.size() == 1) { + for (const auto& tab : tabs) + if (tab.compare(path.front(), Qt::CaseInsensitive) == 0) { + co_await selectTabAsync(tab); + co_return; + } + } else if (path.size() == 2 && + (path.front() == QStringLiteral("mod") || + path.front() == QStringLiteral("collection"))) { + co_await openItemAsync(path[1], path[0]); + co_return; + } + spdlog::warn("[workshop] unknown route {}", + buildRoute(QStringLiteral("workshop"), path).toStdString()); +} +QCoro::Task WorkshopViewPanel::openItemAsync(QString itemId, QString kind, + bool record) { + m_packIds.clear(); + QPointer self(this); + auto* catalog = &m_catalog; + co_await runCached( + QStringLiteral("detail|") + kind + QLatin1Char('|') + itemId, + [catalog, itemId, kind] { return fetchDetail(*catalog, itemId, kind); }, + [this, itemId, kind, record](const Detail& value) { + const auto ref = std::pair(kind, itemId); + if (m_current != ref) { + if (m_detail.isEmpty()) + m_trail.clear(); + else if (record && m_current) + m_trail.emplace_back( + m_current->first, m_current->second, + m_detail.value(QStringLiteral("title")).toString()); } - } else if (path.size() == 2 && (path.front() == QStringLiteral("mod") || path.front() == QStringLiteral("collection"))) { - co_await openItemAsync(path[1], path[0]); co_return; - } - spdlog::warn("[workshop] unknown route {}", buildRoute(QStringLiteral("workshop"), path).toStdString()); -} -QCoro::Task WorkshopViewPanel::openItemAsync(QString itemId, QString kind, bool record) { - m_packIds.clear(); - QPointer self(this); - auto* catalog = &m_catalog; - co_await runCached(QStringLiteral("detail|") + kind + QLatin1Char('|') + itemId, - [catalog, itemId, kind] { return fetchDetail(*catalog, itemId, kind); }, - [this, itemId, kind, record](const Detail& value) { - const auto ref = std::pair(kind, itemId); - if (m_current != ref) { - if (m_detail.isEmpty()) m_trail.clear(); - else if (record && m_current) m_trail.emplace_back(m_current->first, m_current->second, m_detail.value(QStringLiteral("title")).toString()); - } - m_current = ref; - auto members = rows(value.members); - m_detailData = value; - m_detail = detailRow(value); - membersModel->setPage(std::move(members), static_cast(value.members.size()), std::nullopt); - }); - if (self && !self->m_tornDown && kind == QStringLiteral("collection") && self->m_current == std::pair(kind, itemId)) - co_await self->loadPack(); + m_current = ref; + auto members = rows(value.members); + m_detailData = value; + m_detail = detailRow(value); + membersModel->setPage(std::move(members), + static_cast(value.members.size()), + std::nullopt); + }); + if (self && !self->m_tornDown && kind == QStringLiteral("collection") && + self->m_current == std::pair(kind, itemId)) + co_await self->loadPack(); } QCoro::Task WorkshopViewPanel::goBack() { - if (m_tornDown) co_return; - if (!m_trail.empty()) { - auto [kind, item_id, title] = m_trail.back(); - QPointer self(this); - co_await openItemAsync(item_id, kind, false); - if (self && !self->m_tornDown && self->m_current == std::pair(kind, item_id) && !self->m_trail.empty()) { - self->m_trail.pop_back(); emit self->changed(); - } - co_return; + if (m_tornDown) co_return; + if (!m_trail.empty()) { + auto [kind, item_id, title] = m_trail.back(); + QPointer self(this); + co_await openItemAsync(item_id, kind, false); + if (self && !self->m_tornDown && + self->m_current == std::pair(kind, item_id) && !self->m_trail.empty()) { + self->m_trail.pop_back(); + emit self->changed(); } - ++m_generation; clearDetail(); m_error.clear(); m_busy = false; emit changed(); - if (m_tab == QStringLiteral("Favourites")) showFavourites(); - else if (m_tab == QStringLiteral("Installed") && m_installedDirty) co_await load(); + co_return; + } + ++m_generation; + clearDetail(); + m_error.clear(); + m_busy = false; + emit changed(); + if (m_tab == QStringLiteral("Favourites")) + showFavourites(); + else if (m_tab == QStringLiteral("Installed") && m_installedDirty) + co_await load(); } QCoro::Task WorkshopViewPanel::toggleActivationAsync(QString itemId) { - if (m_tornDown) co_return; - QStringList copies, active; - for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) { - if (it.value()->publishedFileId() != itemId) continue; - copies.append(it.key()); - if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); - } - if (copies.isEmpty()) co_return; - if (active.isEmpty()) co_await applyActivation(m_ctx, this, {copies.front()}, {}); - else co_await applyActivation(m_ctx, this, {}, active); + if (m_tornDown) co_return; + QStringList copies, active; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) { + if (it.value()->publishedFileId() != itemId) continue; + copies.append(it.key()); + if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); + } + if (copies.isEmpty()) co_return; + if (active.isEmpty()) + co_await applyActivation(m_ctx, this, {copies.front()}, {}); + else + co_await applyActivation(m_ctx, this, {}, active); } QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { - auto copies = packCopies(); - if (!copies) co_return; - QStringList disable; - const bool allActive = std::ranges::all_of(*copies, [this](const auto& uuid) { return m_ctx.activeUuids().contains(uuid); }); - for (const auto& uuid : m_ctx.activeUuids()) { - auto mod = m_ctx.allMods().value(uuid); - if (!mod) continue; - auto id = mod->publishedFileId(); - if (id && ((only && !copies->contains(uuid)) || (!only && allActive && m_packIds.contains(*id)))) disable.append(uuid); - } - if (!only && allActive) co_await applyActivation(m_ctx, this, {}, disable); - else co_await applyActivation(m_ctx, this, *copies, disable); -} -QCoro::Task WorkshopViewPanel::downloadItemAsync(QString itemId, QString kind) { - co_await download(kind == QStringLiteral("mod") ? QStringList{itemId} : QStringList{}, - kind == QStringLiteral("collection") ? QStringList{itemId} : QStringList{}); + auto copies = packCopies(); + if (!copies) co_return; + QStringList disable; + const bool allActive = std::ranges::all_of(*copies, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + for (const auto& uuid : m_ctx.activeUuids()) { + auto mod = m_ctx.allMods().value(uuid); + if (!mod) continue; + auto id = mod->publishedFileId(); + if (id && ((only && !copies->contains(uuid)) || + (!only && allActive && m_packIds.contains(*id)))) + disable.append(uuid); + } + if (!only && allActive) + co_await applyActivation(m_ctx, this, {}, disable); + else + co_await applyActivation(m_ctx, this, *copies, disable); +} +QCoro::Task WorkshopViewPanel::downloadItemAsync(QString itemId, + QString kind) { + co_await download( + kind == QStringLiteral("mod") ? QStringList{itemId} : QStringList{}, + kind == QStringLiteral("collection") ? QStringList{itemId} + : QStringList{}); } QCoro::Task WorkshopViewPanel::updateAllAsync() { - QStringList ids; - for (const auto& mod : m_installed.mods()) if (m_catalog.installState(mod) == InstallState::kOutdated) ids.append(QString::fromStdString(mod.id)); - if (!ids.isEmpty()) co_await download(ids, {}); -} -void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { m_catalog.enqueue(plan); m_notice = queuedNotice(plan.to_download.size()); } -QCoro::Task WorkshopViewPanel::download(QStringList ids, QStringList collections) { - if (m_tornDown) co_return; - QPointer self(this); - m_notice = QStringLiteral("Preparing download…"); m_pending.reset(); ++m_planning; - auto requested = QSet(ids.cbegin(), ids.cend()); - requested.unite(QSet(collections.cbegin(), collections.cend())); - m_preparing.unite(requested); refreshRows(); emit changed(); - auto result = co_await m_catalog.plan(ids, collections); - if (!self || self->m_tornDown) co_return; - --self->m_planning; self->m_preparing.subtract(requested); self->refreshRows(); emit self->changed(); - if (!result) { self->m_notice = QStringLiteral("Could not prepare the download: ") + result.error().message; emit self->changed(); co_return; } - for (const auto& collection : collections) - self->m_collectionMods[collection] = QSet(result->to_download.cbegin(), result->to_download.cend()); - if (!result->is_complete) { self->m_pending = *result; self->m_notice = incompletePlanNotice(*result); } - else if (result->to_download.isEmpty()) self->m_notice = QStringLiteral("All available mods are already installed and up to date."); - else self->queuePlan(*result); + QStringList ids; + for (const auto& mod : m_installed.mods()) + if (m_catalog.installState(mod) == InstallState::kOutdated) + ids.append(QString::fromStdString(mod.id)); + if (!ids.isEmpty()) co_await download(ids, {}); +} +void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { + m_catalog.enqueue(plan); + m_notice = queuedNotice(plan.to_download.size()); +} +QCoro::Task WorkshopViewPanel::download(QStringList ids, + QStringList collections) { + if (m_tornDown) co_return; + QPointer self(this); + m_notice = QStringLiteral("Preparing download…"); + m_pending.reset(); + ++m_planning; + auto requested = QSet(ids.cbegin(), ids.cend()); + requested.unite(QSet(collections.cbegin(), collections.cend())); + m_preparing.unite(requested); + refreshRows(); + emit changed(); + auto result = co_await m_catalog.plan(ids, collections); + if (!self || self->m_tornDown) co_return; + --self->m_planning; + self->m_preparing.subtract(requested); + self->refreshRows(); + emit self->changed(); + if (!result) { + self->m_notice = QStringLiteral("Could not prepare the download: ") + + result.error().message; emit self->changed(); + co_return; + } + for (const auto& collection : collections) + self->m_collectionMods[collection] = + QSet(result->to_download.cbegin(), result->to_download.cend()); + if (!result->is_complete) { + self->m_pending = *result; + self->m_notice = incompletePlanNotice(*result); + } else if (result->to_download.isEmpty()) + self->m_notice = QStringLiteral( + "All available mods are already installed and up to date."); + else + self->queuePlan(*result); + emit self->changed(); } void WorkshopViewPanel::downloadAvailable() { - if (m_tornDown) return; - auto plan = std::exchange(m_pending, std::nullopt); - if (plan && !plan->to_download.isEmpty()) queuePlan(*plan); - emit changed(); + if (m_tornDown) return; + auto plan = std::exchange(m_pending, std::nullopt); + if (plan && !plan->to_download.isEmpty()) queuePlan(*plan); + emit changed(); +} +void WorkshopViewPanel::dismissPlan() { + m_pending.reset(); + m_notice.clear(); + emit changed(); +} +void WorkshopViewPanel::openLink(const QString& url) { + if (!safeUrl(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); +} +void WorkshopViewPanel::openSettings() { + m_app.navigate(buildRoute(QString(kSettingsViewId))); } -void WorkshopViewPanel::dismissPlan() { m_pending.reset(); m_notice.clear(); emit changed(); } -void WorkshopViewPanel::openLink(const QString& url) { if (!safeUrl(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); } -void WorkshopViewPanel::openSettings() { m_app.navigate(buildRoute(QString(kSettingsViewId))); } void WorkshopViewPanel::toggleFavourite(const QString& itemId) { - const auto found = m_items.constFind(itemId); - if (found != m_items.cend()) if (const auto* collection = std::get_if(&*found)) - m_favourites.set(*collection, !m_favourites.contains(collection->id)); + const auto found = m_items.constFind(itemId); + if (found != m_items.cend()) + if (const auto* collection = std::get_if(&*found)) + m_favourites.set(*collection, !m_favourites.contains(collection->id)); } QCoro::Task WorkshopViewPanel::urlChanged() { - if (m_tornDown) co_return; - clearDetail(); m_error.clear(); m_notice.clear(); m_pending.reset(); m_items.clear(); m_knownTags.clear(); - for (auto* model : {modsModel, collectionsModel, membersModel}) model->setPage({}, 0, std::nullopt); - co_await load(); + if (m_tornDown) co_return; + clearDetail(); + m_error.clear(); + m_notice.clear(); + m_pending.reset(); + m_items.clear(); + m_knownTags.clear(); + for (auto* model : {modsModel, collectionsModel, membersModel}) + model->setPage({}, 0, std::nullopt); + co_await load(); } QCoro::Task WorkshopViewPanel::installedChanged() { - if (m_tornDown) co_return; - m_installedDirty = true; refreshModIds(); recount(); refreshRows(); emit changed(); - if (m_tab == QStringLiteral("Installed") && !m_busy && !m_detailData) co_await load(); + if (m_tornDown) co_return; + m_installedDirty = true; + refreshModIds(); + recount(); + refreshRows(); + emit changed(); + if (m_tab == QStringLiteral("Installed") && !m_busy && !m_detailData) + co_await load(); } QString WorkshopViewPanel::url() const { - return buildRoute(QStringLiteral("workshop"), m_current ? QStringList{m_current->first, m_current->second} : QStringList{m_tab.toLower()}); + return buildRoute(QStringLiteral("workshop"), + m_current ? QStringList{m_current->first, m_current->second} + : QStringList{m_tab.toLower()}); } QString WorkshopViewPanel::backLabel() const { - return QStringLiteral("Back to ") + (m_trail.empty() ? m_tab : std::get<2>(m_trail.back())); + return QStringLiteral("Back to ") + + (m_trail.empty() ? m_tab : std::get<2>(m_trail.back())); } QStringList WorkshopViewPanel::tagOptions() const { - auto tags = m_knownTags.values(); std::ranges::sort(tags); tags.prepend(QStringLiteral("All tags")); return tags; + auto tags = m_knownTags.values(); + std::ranges::sort(tags); + tags.prepend(QStringLiteral("All tags")); + return tags; } QStringList WorkshopViewPanel::versionOptions() const { - QStringList versions{QStringLiteral("All versions")}; - if (m_criteria.version) versions.append(*m_criteria.version); - for (const auto& version : {QStringLiteral("1.6"), QStringLiteral("1.5"), QStringLiteral("1.4"), QStringLiteral("1.3")}) - if (!versions.contains(version)) versions.append(version); - return versions; + QStringList versions{QStringLiteral("All versions")}; + if (m_criteria.version) versions.append(*m_criteria.version); + for (const auto& version : {QStringLiteral("1.6"), QStringLiteral("1.5"), + QStringLiteral("1.4"), QStringLiteral("1.3")}) + if (!versions.contains(version)) versions.append(version); + return versions; } } // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.hpp b/src/ui/plugins/workshop/view.hpp index 7f009bc..b80e563 100644 --- a/src/ui/plugins/workshop/view.hpp +++ b/src/ui/plugins/workshop/view.hpp @@ -1,13 +1,13 @@ #pragma once +#include +#include +#include #include #include #include #include -#include -#include -#include #include "ui/plugins/workshop/details.hpp" #include "ui/plugins/workshop/installed.hpp" @@ -19,184 +19,213 @@ class QQuickWidget; namespace px::modrim::workshop { class WorkshopViewPanel : public BaseViewPanel { - Q_OBJECT - Q_PROPERTY(QString tab READ tab NOTIFY changed) - Q_PROPERTY(QString url READ url NOTIFY changed) - Q_PROPERTY(QString backLabel READ backLabel NOTIFY changed) - Q_PROPERTY(bool configured READ configured NOTIFY changed) - Q_PROPERTY(bool busy READ busy NOTIFY changed) - Q_PROPERTY(QString error READ error NOTIFY changed) - Q_PROPERTY(QString notice READ notice NOTIFY changed) - Q_PROPERTY(QVariantMap detail READ detail NOTIFY changed) - Q_PROPERTY(bool hasDetail READ hasDetail NOTIFY changed) - Q_PROPERTY(bool pendingPlan READ pendingPlan NOTIFY changed) - Q_PROPERTY(bool canDownloadAvailable READ canDownloadAvailable NOTIFY changed) - Q_PROPERTY(QString searchQuery READ searchQuery NOTIFY changed) - Q_PROPERTY(QStringList tagOptions READ tagOptions NOTIFY changed) - Q_PROPERTY(QString gameVersion READ gameVersion CONSTANT) - Q_PROPERTY(QStringList versionOptions READ versionOptions CONSTANT) - Q_PROPERTY(int updateCount READ updateCount NOTIFY changed) - Q_PROPERTY(bool planning READ planning NOTIFY changed) + Q_OBJECT + Q_PROPERTY(QString tab READ tab NOTIFY changed) + Q_PROPERTY(QString url READ url NOTIFY changed) + Q_PROPERTY(QString backLabel READ backLabel NOTIFY changed) + Q_PROPERTY(bool configured READ configured NOTIFY changed) + Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(QString error READ error NOTIFY changed) + Q_PROPERTY(QString notice READ notice NOTIFY changed) + Q_PROPERTY(QVariantMap detail READ detail NOTIFY changed) + Q_PROPERTY(bool hasDetail READ hasDetail NOTIFY changed) + Q_PROPERTY(bool pendingPlan READ pendingPlan NOTIFY changed) + Q_PROPERTY(bool canDownloadAvailable READ canDownloadAvailable NOTIFY changed) + Q_PROPERTY(QString searchQuery READ searchQuery NOTIFY changed) + Q_PROPERTY(QStringList tagOptions READ tagOptions NOTIFY changed) + Q_PROPERTY(QString gameVersion READ gameVersion CONSTANT) + Q_PROPERTY(QStringList versionOptions READ versionOptions CONSTANT) + Q_PROPERTY(int updateCount READ updateCount NOTIFY changed) + Q_PROPERTY(bool planning READ planning NOTIFY changed) -public: - static constexpr RailViewMeta kRailMeta{QLatin1StringView("workshop"), QLatin1StringView("steam"), QLatin1StringView("Workshop")}; - WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app, - workshop::WorkshopCatalog& catalog); - ~WorkshopViewPanel() override; - void openRoute(const QStringList& path) override; - Q_INVOKABLE void teardown() override; - QCoro::Task followRoute(QStringList path); - QCoro::Task load(bool append = false); - QCoro::Task selectTabAsync(QString tab); - QCoro::Task openItemAsync(QString itemId, QString kind, bool record = true); - QCoro::Task filterItems(QString query, QString version, QString source, QString sort, QString tag); - QCoro::Task goBack(); - QCoro::Task retryLoad(); - QCoro::Task downloadItemAsync(QString itemId, QString kind); - QCoro::Task toggleActivationAsync(QString itemId); - QCoro::Task toggleCollectionAsync(bool only = false); - QCoro::Task updateAllAsync(); + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("workshop"), + QLatin1StringView("steam"), + QLatin1StringView("Workshop")}; + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app, workshop::WorkshopCatalog& catalog); + ~WorkshopViewPanel() override; + void openRoute(const QStringList& path) override; + Q_INVOKABLE void teardown() override; + QCoro::Task followRoute(QStringList path); + QCoro::Task load(bool append = false); + QCoro::Task selectTabAsync(QString tab); + QCoro::Task openItemAsync(QString itemId, QString kind, + bool record = true); + QCoro::Task filterItems(QString query, QString version, QString source, + QString sort, QString tag); + QCoro::Task goBack(); + QCoro::Task retryLoad(); + QCoro::Task downloadItemAsync(QString itemId, QString kind); + QCoro::Task toggleActivationAsync(QString itemId); + QCoro::Task toggleCollectionAsync(bool only = false); + QCoro::Task updateAllAsync(); - Q_INVOKABLE QCoro::QmlTask refresh() { return load(); } - Q_INVOKABLE void selectTab(const QString& tab); - Q_INVOKABLE QCoro::QmlTask filter(QString query, QString version, QString source, QString sort, QString tag) { - return filterItems(std::move(query), std::move(version), std::move(source), std::move(sort), std::move(tag)); - } - Q_INVOKABLE QCoro::QmlTask loadMore() { return load(true); } - Q_INVOKABLE QCoro::QmlTask retry() { return retryLoad(); } - Q_INVOKABLE void openItem(const QString& itemId, const QString& kind); - Q_INVOKABLE QCoro::QmlTask back() { return goBack(); } - Q_INVOKABLE QCoro::QmlTask downloadItem(QString itemId, QString kind) { return downloadItemAsync(std::move(itemId), std::move(kind)); } - Q_INVOKABLE QCoro::QmlTask toggleActivation(QString itemId) { return toggleActivationAsync(std::move(itemId)); } - Q_INVOKABLE QCoro::QmlTask toggleCollection() { return toggleCollectionAsync(); } - Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { return toggleCollectionAsync(true); } - Q_INVOKABLE QCoro::QmlTask updateAll() { return updateAllAsync(); } - Q_INVOKABLE void downloadAvailable(); - Q_INVOKABLE void dismissPlan(); - Q_INVOKABLE void openLink(const QString& url); - Q_INVOKABLE void openSettings(); - Q_INVOKABLE void toggleFavourite(const QString& itemId); + Q_INVOKABLE QCoro::QmlTask refresh() { return load(); } + Q_INVOKABLE void selectTab(const QString& tab); + Q_INVOKABLE QCoro::QmlTask filter(QString query, QString version, + QString source, QString sort, QString tag) { + return filterItems(std::move(query), std::move(version), std::move(source), + std::move(sort), std::move(tag)); + } + Q_INVOKABLE QCoro::QmlTask loadMore() { return load(true); } + Q_INVOKABLE QCoro::QmlTask retry() { return retryLoad(); } + Q_INVOKABLE void openItem(const QString& itemId, const QString& kind); + Q_INVOKABLE QCoro::QmlTask back() { return goBack(); } + Q_INVOKABLE QCoro::QmlTask downloadItem(QString itemId, QString kind) { + return downloadItemAsync(std::move(itemId), std::move(kind)); + } + Q_INVOKABLE QCoro::QmlTask toggleActivation(QString itemId) { + return toggleActivationAsync(std::move(itemId)); + } + Q_INVOKABLE QCoro::QmlTask toggleCollection() { + return toggleCollectionAsync(); + } + Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { + return toggleCollectionAsync(true); + } + Q_INVOKABLE QCoro::QmlTask updateAll() { return updateAllAsync(); } + Q_INVOKABLE void downloadAvailable(); + Q_INVOKABLE void dismissPlan(); + Q_INVOKABLE void openLink(const QString& url); + Q_INVOKABLE void openSettings(); + Q_INVOKABLE void toggleFavourite(const QString& itemId); - QString tab() const { return m_tab; } - QString url() const; - QString backLabel() const; - bool configured() const { return m_catalog.configured(); } - bool busy() const { return m_busy; } - QString error() const { return m_error; } - QString notice() const { return m_notice; } - QVariantMap detail() const { return m_detail; } - bool hasDetail() const { return !m_detail.isEmpty(); } - bool pendingPlan() const { return m_pending.has_value(); } - bool canDownloadAvailable() const { return m_pending && !m_pending->to_download.isEmpty(); } - QString searchQuery() const { return m_criteria.query; } - QStringList tagOptions() const; - QString gameVersion() const; - QStringList versionOptions() const; - int updateCount() const { return m_updateCount; } - bool planning() const { return m_planning > 0; } + QString tab() const { return m_tab; } + QString url() const; + QString backLabel() const; + bool configured() const { return m_catalog.configured(); } + bool busy() const { return m_busy; } + QString error() const { return m_error; } + QString notice() const { return m_notice; } + QVariantMap detail() const { return m_detail; } + bool hasDetail() const { return !m_detail.isEmpty(); } + bool pendingPlan() const { return m_pending.has_value(); } + bool canDownloadAvailable() const { + return m_pending && !m_pending->to_download.isEmpty(); + } + QString searchQuery() const { return m_criteria.query; } + QStringList tagOptions() const; + QString gameVersion() const; + QStringList versionOptions() const; + int updateCount() const { return m_updateCount; } + bool planning() const { return m_planning > 0; } - CatalogListModel* modsModel; - CatalogListModel* collectionsModel; - CatalogListModel* membersModel; + CatalogListModel* modsModel; + CatalogListModel* collectionsModel; + CatalogListModel* membersModel; -signals: - void changed(); + signals: + void changed(); -private: - template - QCoro::Task runCached(QString key, - std::function>()> produce, - std::function apply, bool cached = true); - QCoro::Task loadPack(); - QCoro::Task download(QStringList ids, QStringList collections); - QCoro::Task installedChanged(); - QCoro::Task urlChanged(); - void refreshModIds(); - QVariantMap row(const workshop::CatalogEntry& item, bool detail = false); - QVariantMap detailRow(const Detail& detail); - template QList rows(const std::vector& items) { - refreshModIds(); - QList result; - result.reserve(static_cast(items.size())); - for (const auto& item : items) result.append(row(item)); - return result; - } - void refreshRows(); - void clearDetail(); - std::optional packCopies() const; - void showFavourites(); - void showInstalled(); - void recount(); - std::optional installedCursor() const; - void queuePlan(const workshop::DownloadPlan& plan); - void go(const QStringList& path); + private: + template + QCoro::Task runCached( + QString key, + std::function>()> produce, + std::function apply, bool cached = true); + QCoro::Task loadPack(); + QCoro::Task download(QStringList ids, QStringList collections); + QCoro::Task installedChanged(); + QCoro::Task urlChanged(); + void refreshModIds(); + QVariantMap row(const workshop::CatalogEntry& item, bool detail = false); + QVariantMap detailRow(const Detail& detail); + template + QList rows(const std::vector& items) { + refreshModIds(); + QList result; + result.reserve(static_cast(items.size())); + for (const auto& item : items) result.append(row(item)); + return result; + } + void refreshRows(); + void clearDetail(); + std::optional packCopies() const; + void showFavourites(); + void showInstalled(); + void recount(); + std::optional installedCursor() const; + void queuePlan(const workshop::DownloadPlan& plan); + void go(const QStringList& path); - workshop::WorkshopCatalog& m_catalog; - DownloadManager& m_downloads; - workshop::FavouriteCollections& m_favourites; - QQuickWidget* m_qml = nullptr; - QString m_tab = QStringLiteral("Discover"); - workshop::CatalogQuery m_criteria; - bool m_busy = false; - bool m_tornDown = false; - bool m_installedDirty = false; - int m_generation = 0; - int m_planning = 0; - int m_updateCount = 0; - QString m_error, m_notice; - std::function()> m_retry; - QVariantMap m_detail; - std::optional m_detailData; - InstalledList m_installed; - std::optional> m_current; - std::vector> m_trail; - std::optional m_pending; - QSet m_preparing, m_packIds, m_installedIds, m_activeIds, m_knownTags; - QHash> m_collectionMods; - QHash m_items; - Event::Id m_busyId = 0; - Event::Id m_queueId = 0, m_installedId = 0; - Event::Id m_urlId = 0; - Event::Id m_favouritesId = 0; - Event::Id m_activeId = 0; + workshop::WorkshopCatalog& m_catalog; + DownloadManager& m_downloads; + workshop::FavouriteCollections& m_favourites; + QQuickWidget* m_qml = nullptr; + QString m_tab = QStringLiteral("Discover"); + workshop::CatalogQuery m_criteria; + bool m_busy = false; + bool m_tornDown = false; + bool m_installedDirty = false; + int m_generation = 0; + int m_planning = 0; + int m_updateCount = 0; + QString m_error, m_notice; + std::function()> m_retry; + QVariantMap m_detail; + std::optional m_detailData; + InstalledList m_installed; + std::optional> m_current; + std::vector> m_trail; + std::optional m_pending; + QSet m_preparing, m_packIds, m_installedIds, m_activeIds, + m_knownTags; + QHash> m_collectionMods; + QHash m_items; + Event::Id m_busyId = 0; + Event::Id m_queueId = 0, m_installedId = 0; + Event::Id m_urlId = 0; + Event::Id m_favouritesId = 0; + Event::Id m_activeId = 0; }; -template -QCoro::Task WorkshopViewPanel::runCached(QString key, +template +QCoro::Task WorkshopViewPanel::runCached( + QString key, std::function>()> produce, std::function apply, bool cached) { - if (m_tornDown) co_return; - QPointer self(this); - const int generation = ++m_generation; - m_busy = true; - m_error.clear(); - m_retry = [self, key, produce, apply, cached] { - return self->runCached(key, produce, apply, cached); - }; - emit changed(); - bool shown = false; - auto show = [self, generation, apply, &shown](const T& value) { - if (!self || self->m_tornDown || generation != self->m_generation) return; - apply(value); - shown = true; - self->m_busy = false; - emit self->changed(); - }; - workshop::CatalogResult result; - if (cached) result = co_await m_catalog.cached(key, std::move(produce), show, workshop::kFreshSeconds, true); - else { - auto fetched = co_await produce(); - if (fetched) show(*fetched); - else result = std::unexpected(fetched.error()); - } - if (!self || self->m_tornDown || generation != self->m_generation) co_return; - if (!result && shown) spdlog::warn("[workshop] background refresh failed: {}", result.error().message.toStdString()); - else if (!result) self->m_error = result.error().message; + if (m_tornDown) co_return; + QPointer self(this); + const int generation = ++m_generation; + m_busy = true; + m_error.clear(); + m_retry = [self, key, produce, apply, cached] { + return self->runCached(key, produce, apply, cached); + }; + emit changed(); + bool shown = false; + auto show = [self, generation, apply, &shown](const T& value) { + if (!self || self->m_tornDown || generation != self->m_generation) return; + apply(value); + shown = true; self->m_busy = false; emit self->changed(); - if (self->m_installedDirty && self->m_tab == QStringLiteral("Installed") && !self->m_detailData) - co_await self->load(); + }; + workshop::CatalogResult result; + if (cached) + result = co_await m_catalog.cached(key, std::move(produce), show, + workshop::kFreshSeconds, true); + else { + auto fetched = co_await produce(); + if (fetched) + show(*fetched); + else + result = std::unexpected(fetched.error()); + } + if (!self || self->m_tornDown || generation != self->m_generation) co_return; + if (!result && shown) + spdlog::warn("[workshop] background refresh failed: {}", + result.error().message.toStdString()); + else if (!result) + self->m_error = result.error().message; + self->m_busy = false; + emit self->changed(); + if (self->m_installedDirty && self->m_tab == QStringLiteral("Installed") && + !self->m_detailData) + co_await self->load(); } } // namespace px::modrim::workshop diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp index ea53c76..3c80e4d 100644 --- a/src/ui/settings_section.cpp +++ b/src/ui/settings_section.cpp @@ -5,28 +5,29 @@ namespace px::modrim { -PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, QObject* parent) +PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, + QObject* parent) : SettingsSection(parent), m_initial(std::move(initial)) {} QVariantMap PluginConfigSectionBase::initial() const { return m_initial; } bool PluginConfigSectionBase::set(const QString& key, const QVariant& value) { - if (!m_initial.contains(key)) { - return false; - } - m_draft.insert(key, value); - return true; + if (!m_initial.contains(key)) { + return false; + } + m_draft.insert(key, value); + return true; } Result PluginConfigSectionBase::apply() { - if (m_draft.isEmpty()) { - return {}; - } - QVariantMap merged = m_initial; - for (auto it = m_draft.cbegin(); it != m_draft.cend(); ++it) { - merged.insert(it.key(), it.value()); - } - return commit(QJsonObject::fromVariantMap(merged)); + if (m_draft.isEmpty()) { + return {}; + } + QVariantMap merged = m_initial; + for (auto it = m_draft.cbegin(); it != m_draft.cend(); ++it) { + merged.insert(it.key(), it.value()); + } + return commit(QJsonObject::fromVariantMap(merged)); } } // namespace px::modrim diff --git a/src/ui/settings_section.hpp b/src/ui/settings_section.hpp index 8ad19f3..9a7d283 100644 --- a/src/ui/settings_section.hpp +++ b/src/ui/settings_section.hpp @@ -1,11 +1,5 @@ #pragma once -#include -#include -#include -#include - -#include #include #include #include @@ -14,6 +8,12 @@ #include #include #include +#include +#include +#include +#include + +#include #include #include "core/config.hpp" @@ -22,20 +22,20 @@ namespace px::modrim { -// A plugin's group on the Settings dialog. `source()` is loaded into a group titled -// `title()` with this object as its required `section` property. Edits stay on the -// object until the dialog is saved, when `apply()` commits them; a cancelled dialog -// just drops it. +// A plugin's group on the Settings dialog. `source()` is loaded into a group +// titled `title()` with this object as its required `section` property. Edits +// stay on the object until the dialog is saved, when `apply()` commits them; a +// cancelled dialog just drops it. class SettingsSection : public QObject { - Q_OBJECT + Q_OBJECT -public: - using QObject::QObject; + public: + using QObject::QObject; - virtual QString title() const = 0; - virtual QUrl source() const = 0; + virtual QString title() const = 0; + virtual QUrl source() const = 0; - virtual Result apply() = 0; + virtual Result apply() = 0; }; // Builds a plugin's section for one dialog, parented to that dialog. @@ -45,58 +45,64 @@ using SettingsSectionFactory = std::function; // QML reads `initial` and reports edits via `set`, and apply() hands the merged // values to the typed commit(). class PluginConfigSectionBase : public SettingsSection { - Q_OBJECT + Q_OBJECT - Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) -public: - QVariantMap initial() const; + public: + QVariantMap initial() const; - // Returns false when `key` is not a field of the settings, so QML can tell the edit was dropped. - Q_INVOKABLE bool set(const QString& key, const QVariant& value); + // Returns false when `key` is not a field of the settings, so QML can tell + // the edit was dropped. + Q_INVOKABLE bool set(const QString& key, const QVariant& value); - Result apply() final; + Result apply() final; -protected: - PluginConfigSectionBase(QVariantMap initial, QObject* parent); + protected: + PluginConfigSectionBase(QVariantMap initial, QObject* parent); - // Commits the merged settings; an error leaves the stored config unchanged. - virtual Result commit(const QJsonObject& merged) = 0; + // Commits the merged settings; an error leaves the stored config unchanged. + virtual Result commit(const QJsonObject& merged) = 0; -private: - QVariantMap m_initial; - QVariantMap m_draft; + private: + QVariantMap m_initial; + QVariantMap m_draft; }; // Edits a PluginConfig. Keys and values follow T's JSON layout. template class PluginConfigSection : public PluginConfigSectionBase { -public: - PluginConfigSection(PluginConfig& config, QObject* parent) - : PluginConfigSectionBase(toVariantMap(config.value()), parent), m_config(config) {} - -protected: - Result commit(const QJsonObject& merged) override { - const std::string text = QJsonDocument(merged).toJson(QJsonDocument::Compact).toStdString(); - T value{}; - if (auto error = glz::read(value, text)) { - return std::unexpected(Error{QString::fromStdString(glz::format_error(error, text))}); - } - m_config.update(std::move(value)); - return {}; + public: + PluginConfigSection(PluginConfig& config, QObject* parent) + : PluginConfigSectionBase(toVariantMap(config.value()), parent), + m_config(config) {} + + protected: + Result commit(const QJsonObject& merged) override { + const std::string text = + QJsonDocument(merged).toJson(QJsonDocument::Compact).toStdString(); + T value{}; + if (auto error = glz::read(value, text)) { + return std::unexpected( + Error{QString::fromStdString(glz::format_error(error, text))}); } - -private: - static QVariantMap toVariantMap(const T& value) { - auto text = glz::write_json(value); - if (!text) { - spdlog::warn("cannot encode plugin settings for the settings dialog"); - return {}; - } - return QJsonDocument::fromJson(QByteArray::fromStdString(*text)).object().toVariantMap(); + m_config.update(std::move(value)); + return {}; + } + + private: + static QVariantMap toVariantMap(const T& value) { + auto text = glz::write_json(value); + if (!text) { + spdlog::warn("cannot encode plugin settings for the settings dialog"); + return {}; } + return QJsonDocument::fromJson(QByteArray::fromStdString(*text)) + .object() + .toVariantMap(); + } - PluginConfig& m_config; + PluginConfig& m_config; }; } // namespace px::modrim diff --git a/src/ui/theme/constants.hpp b/src/ui/theme/constants.hpp index 1c5a321..e6d5122 100644 --- a/src/ui/theme/constants.hpp +++ b/src/ui/theme/constants.hpp @@ -6,7 +6,8 @@ namespace px::modrim { // Animation inline constexpr int kAccordionAnimationDurationMs = 200; -inline constexpr QEasingCurve::Type kAccordionEasing = QEasingCurve::Type::OutCubic; +inline constexpr QEasingCurve::Type kAccordionEasing = + QEasingCurve::Type::OutCubic; // Banner inline constexpr int kBannerMaxHeight = 260; diff --git a/src/ui/theme/palette.cpp b/src/ui/theme/palette.cpp index f6317d7..f5edbb6 100644 --- a/src/ui/theme/palette.cpp +++ b/src/ui/theme/palette.cpp @@ -1,11 +1,11 @@ #include "ui/theme/palette.hpp" - #include #include #include #include #include + #include namespace px::modrim { @@ -13,95 +13,103 @@ namespace px::modrim { namespace { struct PaletteEntry { - QLatin1StringView name; - QLatin1StringView value; + QLatin1StringView name; + QLatin1StringView value; }; constexpr PaletteEntry kPaletteEntries[]{ #include "ui/theme/palette_table.inc" }; -// Mirrors the legacy string.Template.safe_substitute: a `$NAME` token is replaced -// only when NAME is a palette key; anything else stays as written. +// Mirrors the legacy string.Template.safe_substitute: a `$NAME` token is +// replaced only when NAME is a palette key; anything else stays as written. QString expandTokens(const QString& raw) { - static const QRegularExpression token(QStringLiteral(R"(\$([A-Z_][A-Z0-9_]*))")); - QString out; - out.reserve(raw.size()); - qsizetype last = 0; - auto it = token.globalMatch(raw); - while (it.hasNext()) { - const auto match = it.next(); - out.append(QStringView(raw).mid(last, match.capturedStart() - last)); - const QString value = paletteValue(match.capturedView(1)); - out.append(value.isEmpty() ? match.captured(0) : value); - last = match.capturedEnd(); - } - out.append(QStringView(raw).mid(last)); - return out; + static const QRegularExpression token( + QStringLiteral(R"(\$([A-Z_][A-Z0-9_]*))")); + QString out; + out.reserve(raw.size()); + qsizetype last = 0; + auto it = token.globalMatch(raw); + while (it.hasNext()) { + const auto match = it.next(); + out.append(QStringView(raw).mid(last, match.capturedStart() - last)); + const QString value = paletteValue(match.capturedView(1)); + out.append(value.isEmpty() ? match.captured(0) : value); + last = match.capturedEnd(); + } + out.append(QStringView(raw).mid(last)); + return out; } } // namespace QString paletteValue(QStringView name) { - for (const auto& entry : kPaletteEntries) { - if (QString(entry.name) == name) { - return QString(entry.value); - } + for (const auto& entry : kPaletteEntries) { + if (QString(entry.name) == name) { + return QString(entry.value); } - return {}; + } + return {}; } QColor paletteColor(QStringView name) { - const QString val = paletteValue(name); - return val.isEmpty() ? QColor{} : QColor(val); + const QString val = paletteValue(name); + return val.isEmpty() ? QColor{} : QColor(val); } const QColor& panelBgQ() { - static const QColor color = paletteColor(QStringView{u"ELEVATE_2"}); - return color; + static const QColor color = paletteColor(QStringView{u"ELEVATE_2"}); + return color; } const QColor& textMainQ() { - static const QColor color = paletteColor(QStringView{u"TEXT_MAIN"}); - return color; + static const QColor color = paletteColor(QStringView{u"TEXT_MAIN"}); + return color; } const QColor& textMutedQ() { - static const QColor color = paletteColor(QStringView{u"TEXT_MUTED"}); - return color; + static const QColor color = paletteColor(QStringView{u"TEXT_MUTED"}); + return color; } QString getStylesheet() { - QFile file(QStringLiteral(":/qt/qml/PxModRim/theme/style.qss")); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - spdlog::warn("failed to open the application stylesheet resource"); - return {}; - } - return expandTokens(QString::fromUtf8(file.readAll())); + QFile file(QStringLiteral(":/qt/qml/PxModRim/theme/style.qss")); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + spdlog::warn("failed to open the application stylesheet resource"); + return {}; + } + return expandTokens(QString::fromUtf8(file.readAll())); } void applyAppTheme(QApplication& app) { - app.setStyle(QStringLiteral("Fusion")); - - QPalette palette; - palette.setColor(QPalette::Window, paletteColor(QStringView{u"ELEVATE_0"})); - palette.setColor(QPalette::WindowText, paletteColor(QStringView{u"TEXT_MAIN"})); - palette.setColor(QPalette::Base, paletteColor(QStringView{u"ELEVATE_1"})); - palette.setColor(QPalette::AlternateBase, paletteColor(QStringView{u"ELEVATE_2"})); - palette.setColor(QPalette::Text, paletteColor(QStringView{u"TEXT_MAIN"})); - palette.setColor(QPalette::Button, paletteColor(QStringView{u"ELEVATE_2"})); - palette.setColor(QPalette::ButtonText, paletteColor(QStringView{u"TEXT_MAIN"})); - palette.setColor(QPalette::BrightText, paletteColor(QStringView{u"TEXT_MAIN"})); - palette.setColor(QPalette::Highlight, paletteColor(QStringView{u"PRIMARY"})); - palette.setColor(QPalette::HighlightedText, paletteColor(QStringView{u"ELEVATE_0"})); - palette.setColor(QPalette::Link, paletteColor(QStringView{u"PRIMARY"})); - palette.setColor(QPalette::ToolTipBase, paletteColor(QStringView{u"ELEVATE_2"})); - palette.setColor(QPalette::ToolTipText, paletteColor(QStringView{u"TEXT_MAIN"})); - app.setPalette(palette); - - const QString qss = getStylesheet(); - if (!qss.isEmpty()) { - app.setStyleSheet(qss); - } + app.setStyle(QStringLiteral("Fusion")); + + QPalette palette; + palette.setColor(QPalette::Window, paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::WindowText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Base, paletteColor(QStringView{u"ELEVATE_1"})); + palette.setColor(QPalette::AlternateBase, + paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::Text, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Button, paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ButtonText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::BrightText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Highlight, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::HighlightedText, + paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::Link, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::ToolTipBase, + paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ToolTipText, + paletteColor(QStringView{u"TEXT_MAIN"})); + app.setPalette(palette); + + const QString qss = getStylesheet(); + if (!qss.isEmpty()) { + app.setStyleSheet(qss); + } } } // namespace px::modrim diff --git a/src/ui/theme/palette.hpp b/src/ui/theme/palette.hpp index 5f755b2..b9e10d4 100644 --- a/src/ui/theme/palette.hpp +++ b/src/ui/theme/palette.hpp @@ -15,7 +15,8 @@ QString paletteValue(QStringView name); // Same as paletteValue, parsed into a QColor. QColor paletteColor(QStringView name); -// Pre-constructed QColors matching legacy panel_bg_q, text_main_q, text_muted_q. +// Pre-constructed QColors matching legacy panel_bg_q, text_main_q, +// text_muted_q. const QColor& panelBgQ(); const QColor& textMainQ(); const QColor& textMutedQ(); diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp index f4805d6..d0af2dd 100644 --- a/src/ui/theme/qml_theme.cpp +++ b/src/ui/theme/qml_theme.cpp @@ -3,6 +3,7 @@ #include #include #include + #include #include "ui/theme/constants.hpp" @@ -16,27 +17,29 @@ const QString kLogoDir = QStringLiteral(":/qt/qml/PxModRim/assets/"); // Reads the four embedded logo variants and encodes each as an SVG data URI. QStringList loadLogoVariants() { - QStringList uris; - for (int n = 1; n <= 4; ++n) { - const QString path = kLogoDir + QStringLiteral("logo_nobg_%1.svg").arg(n); - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - spdlog::error("missing embedded logo resource {}", path.toStdString()); - continue; - } - uris.append(QStringLiteral("data:image/svg+xml;base64,") + - QString::fromLatin1(file.readAll().toBase64())); + QStringList uris; + for (int n = 1; n <= 4; ++n) { + const QString path = kLogoDir + QStringLiteral("logo_nobg_%1.svg").arg(n); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::error("missing embedded logo resource {}", path.toStdString()); + continue; } - return uris; + uris.append(QStringLiteral("data:image/svg+xml;base64,") + + QString::fromLatin1(file.readAll().toBase64())); + } + return uris; } } // namespace -Theme::Theme(QObject* parent) : QObject(parent), m_logoVariants(loadLogoVariants()) { - if (!m_logoVariants.isEmpty()) { - const int pick = QRandomGenerator::global()->bounded(static_cast(m_logoVariants.size())); - m_logoDataUri = m_logoVariants.at(pick); - } +Theme::Theme(QObject* parent) + : QObject(parent), m_logoVariants(loadLogoVariants()) { + if (!m_logoVariants.isEmpty()) { + const int pick = QRandomGenerator::global()->bounded( + static_cast(m_logoVariants.size())); + m_logoDataUri = m_logoVariants.at(pick); + } } QString Theme::elevate0() const { return paletteValue(u"ELEVATE_0"); } @@ -64,8 +67,12 @@ QString Theme::danger() const { return paletteValue(u"DANGER"); } QString Theme::dangerHover() const { return paletteValue(u"DANGER_HOVER"); } QString Theme::dangerBg() const { return paletteValue(u"DANGER_BG_QML"); } -QString Theme::fontFamily() const { return QStringLiteral("Source Sans 3, Liberation Sans, sans-serif"); } -QString Theme::fontMono() const { return QStringLiteral("DejaVu Sans Mono, Liberation Mono, monospace"); } +QString Theme::fontFamily() const { + return QStringLiteral("Source Sans 3, Liberation Sans, sans-serif"); +} +QString Theme::fontMono() const { + return QStringLiteral("DejaVu Sans Mono, Liberation Mono, monospace"); +} int Theme::fontSizeXs() const { return 10; } int Theme::fontSizeSm() const { return 11; } int Theme::fontSizeMd() const { return 13; } diff --git a/src/ui/theme/qml_theme.hpp b/src/ui/theme/qml_theme.hpp index ae2afa0..3a668c0 100644 --- a/src/ui/theme/qml_theme.hpp +++ b/src/ui/theme/qml_theme.hpp @@ -10,113 +10,113 @@ namespace px::modrim { // createQmlEngine). Color values come from the same PALETTE as the stylesheet; // *Bg values are #AARRGGBB because QML rejects rgba() strings. class Theme : public QObject { - Q_OBJECT - - Q_PROPERTY(QString elevate0 READ elevate0 CONSTANT) - Q_PROPERTY(QString elevate1 READ elevate1 CONSTANT) - Q_PROPERTY(QString elevate2 READ elevate2 CONSTANT) - Q_PROPERTY(QString elevate3 READ elevate3 CONSTANT) - Q_PROPERTY(QString elevate4 READ elevate4 CONSTANT) - Q_PROPERTY(QString border READ border CONSTANT) - - Q_PROPERTY(QString textMain READ textMain CONSTANT) - Q_PROPERTY(QString textMuted READ textMuted CONSTANT) - Q_PROPERTY(QString textDim READ textDim CONSTANT) - Q_PROPERTY(QString onAccent READ onAccent CONSTANT) - Q_PROPERTY(QString neutral READ neutral CONSTANT) - - Q_PROPERTY(QString primary READ primary CONSTANT) - Q_PROPERTY(QString primaryHover READ primaryHover CONSTANT) - Q_PROPERTY(QString primaryBg READ primaryBg CONSTANT) - Q_PROPERTY(QString success READ success CONSTANT) - Q_PROPERTY(QString successHover READ successHover CONSTANT) - Q_PROPERTY(QString successBg READ successBg CONSTANT) - Q_PROPERTY(QString warning READ warning CONSTANT) - Q_PROPERTY(QString warningBg READ warningBg CONSTANT) - Q_PROPERTY(QString danger READ danger CONSTANT) - Q_PROPERTY(QString dangerHover READ dangerHover CONSTANT) - Q_PROPERTY(QString dangerBg READ dangerBg CONSTANT) - - Q_PROPERTY(QString fontFamily READ fontFamily CONSTANT) - Q_PROPERTY(QString fontMono READ fontMono CONSTANT) - Q_PROPERTY(int fontSizeXs READ fontSizeXs CONSTANT) - Q_PROPERTY(int fontSizeSm READ fontSizeSm CONSTANT) - Q_PROPERTY(int fontSizeMd READ fontSizeMd CONSTANT) - Q_PROPERTY(int fontSizeLg READ fontSizeLg CONSTANT) - Q_PROPERTY(int fontSizeXl READ fontSizeXl CONSTANT) - Q_PROPERTY(int radiusXs READ radiusXs CONSTANT) - Q_PROPERTY(int radiusSm READ radiusSm CONSTANT) - Q_PROPERTY(int radiusMd READ radiusMd CONSTANT) - Q_PROPERTY(int radiusLg READ radiusLg CONSTANT) - Q_PROPERTY(int radiusPill READ radiusPill CONSTANT) - Q_PROPERTY(int scrollbarWidth READ scrollbarWidth CONSTANT) - Q_PROPERTY(int tooltipDelay READ tooltipDelay CONSTANT) - Q_PROPERTY(double disabledOpacity READ disabledOpacity CONSTANT) - Q_PROPERTY(QString overlay READ overlay CONSTANT) - Q_PROPERTY(int railMinWidth READ railMinWidth CONSTANT) - Q_PROPERTY(int railMaxWidth READ railMaxWidth CONSTANT) - Q_PROPERTY(int railCollapseWidth READ railCollapseWidth CONSTANT) - Q_PROPERTY(int sidebarWidth READ sidebarWidth CONSTANT) - - Q_PROPERTY(QStringList logoVariants READ logoVariants CONSTANT) - Q_PROPERTY(QString logoFileDataUri READ logoFileDataUri CONSTANT) - -public: - // Picks one of the four logo variants at random, as the legacy Theme did. - explicit Theme(QObject* parent = nullptr); - - QString elevate0() const; - QString elevate1() const; - QString elevate2() const; - QString elevate3() const; - QString elevate4() const; - QString border() const; - - QString textMain() const; - QString textMuted() const; - QString textDim() const; - QString onAccent() const; - QString neutral() const; - - QString primary() const; - QString primaryHover() const; - QString primaryBg() const; - QString success() const; - QString successHover() const; - QString successBg() const; - QString warning() const; - QString warningBg() const; - QString danger() const; - QString dangerHover() const; - QString dangerBg() const; - - QString fontFamily() const; - QString fontMono() const; - int fontSizeXs() const; - int fontSizeSm() const; - int fontSizeMd() const; - int fontSizeLg() const; - int fontSizeXl() const; - int radiusXs() const; - int radiusSm() const; - int radiusMd() const; - int radiusLg() const; - int radiusPill() const; - int scrollbarWidth() const; - int tooltipDelay() const; - double disabledOpacity() const; - QString overlay() const; - int railMinWidth() const; - int railMaxWidth() const; - int railCollapseWidth() const; - int sidebarWidth() const; - - QStringList logoVariants() const; - QString logoFileDataUri() const; - -private: - QStringList m_logoVariants; - QString m_logoDataUri; + Q_OBJECT + + Q_PROPERTY(QString elevate0 READ elevate0 CONSTANT) + Q_PROPERTY(QString elevate1 READ elevate1 CONSTANT) + Q_PROPERTY(QString elevate2 READ elevate2 CONSTANT) + Q_PROPERTY(QString elevate3 READ elevate3 CONSTANT) + Q_PROPERTY(QString elevate4 READ elevate4 CONSTANT) + Q_PROPERTY(QString border READ border CONSTANT) + + Q_PROPERTY(QString textMain READ textMain CONSTANT) + Q_PROPERTY(QString textMuted READ textMuted CONSTANT) + Q_PROPERTY(QString textDim READ textDim CONSTANT) + Q_PROPERTY(QString onAccent READ onAccent CONSTANT) + Q_PROPERTY(QString neutral READ neutral CONSTANT) + + Q_PROPERTY(QString primary READ primary CONSTANT) + Q_PROPERTY(QString primaryHover READ primaryHover CONSTANT) + Q_PROPERTY(QString primaryBg READ primaryBg CONSTANT) + Q_PROPERTY(QString success READ success CONSTANT) + Q_PROPERTY(QString successHover READ successHover CONSTANT) + Q_PROPERTY(QString successBg READ successBg CONSTANT) + Q_PROPERTY(QString warning READ warning CONSTANT) + Q_PROPERTY(QString warningBg READ warningBg CONSTANT) + Q_PROPERTY(QString danger READ danger CONSTANT) + Q_PROPERTY(QString dangerHover READ dangerHover CONSTANT) + Q_PROPERTY(QString dangerBg READ dangerBg CONSTANT) + + Q_PROPERTY(QString fontFamily READ fontFamily CONSTANT) + Q_PROPERTY(QString fontMono READ fontMono CONSTANT) + Q_PROPERTY(int fontSizeXs READ fontSizeXs CONSTANT) + Q_PROPERTY(int fontSizeSm READ fontSizeSm CONSTANT) + Q_PROPERTY(int fontSizeMd READ fontSizeMd CONSTANT) + Q_PROPERTY(int fontSizeLg READ fontSizeLg CONSTANT) + Q_PROPERTY(int fontSizeXl READ fontSizeXl CONSTANT) + Q_PROPERTY(int radiusXs READ radiusXs CONSTANT) + Q_PROPERTY(int radiusSm READ radiusSm CONSTANT) + Q_PROPERTY(int radiusMd READ radiusMd CONSTANT) + Q_PROPERTY(int radiusLg READ radiusLg CONSTANT) + Q_PROPERTY(int radiusPill READ radiusPill CONSTANT) + Q_PROPERTY(int scrollbarWidth READ scrollbarWidth CONSTANT) + Q_PROPERTY(int tooltipDelay READ tooltipDelay CONSTANT) + Q_PROPERTY(double disabledOpacity READ disabledOpacity CONSTANT) + Q_PROPERTY(QString overlay READ overlay CONSTANT) + Q_PROPERTY(int railMinWidth READ railMinWidth CONSTANT) + Q_PROPERTY(int railMaxWidth READ railMaxWidth CONSTANT) + Q_PROPERTY(int railCollapseWidth READ railCollapseWidth CONSTANT) + Q_PROPERTY(int sidebarWidth READ sidebarWidth CONSTANT) + + Q_PROPERTY(QStringList logoVariants READ logoVariants CONSTANT) + Q_PROPERTY(QString logoFileDataUri READ logoFileDataUri CONSTANT) + + public: + // Picks one of the four logo variants at random, as the legacy Theme did. + explicit Theme(QObject* parent = nullptr); + + QString elevate0() const; + QString elevate1() const; + QString elevate2() const; + QString elevate3() const; + QString elevate4() const; + QString border() const; + + QString textMain() const; + QString textMuted() const; + QString textDim() const; + QString onAccent() const; + QString neutral() const; + + QString primary() const; + QString primaryHover() const; + QString primaryBg() const; + QString success() const; + QString successHover() const; + QString successBg() const; + QString warning() const; + QString warningBg() const; + QString danger() const; + QString dangerHover() const; + QString dangerBg() const; + + QString fontFamily() const; + QString fontMono() const; + int fontSizeXs() const; + int fontSizeSm() const; + int fontSizeMd() const; + int fontSizeLg() const; + int fontSizeXl() const; + int radiusXs() const; + int radiusSm() const; + int radiusMd() const; + int radiusLg() const; + int radiusPill() const; + int scrollbarWidth() const; + int tooltipDelay() const; + double disabledOpacity() const; + QString overlay() const; + int railMinWidth() const; + int railMaxWidth() const; + int railCollapseWidth() const; + int sidebarWidth() const; + + QStringList logoVariants() const; + QString logoFileDataUri() const; + + private: + QStringList m_logoVariants; + QString m_logoDataUri; }; } // namespace px::modrim diff --git a/src/ui/ui_prefs.hpp b/src/ui/ui_prefs.hpp index f8dbac3..ca7db86 100644 --- a/src/ui/ui_prefs.hpp +++ b/src/ui/ui_prefs.hpp @@ -7,14 +7,15 @@ namespace px::modrim { // Persisted UI state (ui_prefs.json). Field names are the JSON keys. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct UiPrefs { - bool desc_expanded = false; - LaunchStrategy launch_strategy = LaunchStrategy::kDirect; - std::string skipped_update_tag; - bool rail_collapsed = false; + bool desc_expanded = false; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; + std::string skipped_update_tag; + bool rail_collapsed = false; - bool operator==(const UiPrefs&) const = default; + bool operator==(const UiPrefs&) const = default; }; // NOLINTEND(readability-identifier-naming) diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp index 72f391f..360053e 100644 --- a/src/ui/views/base.cpp +++ b/src/ui/views/base.cpp @@ -7,19 +7,20 @@ namespace px::modrim { -BaseViewPanel::BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) +BaseViewPanel::BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) : RailView(ctx, engine, parent, app) { - setAutoFillBackground(true); - m_root = new QVBoxLayout(this); - m_root->setContentsMargins(0, 0, 0, 0); - m_root->setSpacing(0); - setClearColor(paletteValue(u"ELEVATE_0")); + setAutoFillBackground(true); + m_root = new QVBoxLayout(this); + m_root->setContentsMargins(0, 0, 0, 0); + m_root->setSpacing(0); + setClearColor(paletteValue(u"ELEVATE_0")); } void BaseViewPanel::setClearColor(const QString& color) { - QPalette palette = this->palette(); - palette.setColor(QPalette::Window, QColor(color)); - setPalette(palette); + QPalette palette = this->palette(); + palette.setColor(QPalette::Window, QColor(color)); + setPalette(palette); } } // namespace px::modrim diff --git a/src/ui/views/base.hpp b/src/ui/views/base.hpp index 37f74f9..365fd09 100644 --- a/src/ui/views/base.hpp +++ b/src/ui/views/base.hpp @@ -7,18 +7,20 @@ namespace px::modrim { -// Whole-window view contributed to the left icon rail. Core never includes this: -// views are discovered entirely within ui/. Subclasses declare `kRailMeta`. +// Whole-window view contributed to the left icon rail. Core never includes +// this: views are discovered entirely within ui/. Subclasses declare +// `kRailMeta`. class BaseViewPanel : public RailView { - Q_OBJECT + Q_OBJECT -public: - BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); + public: + BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); -protected: - void setClearColor(const QString& color); + protected: + void setClearColor(const QString& color); - QVBoxLayout* m_root = nullptr; + QVBoxLayout* m_root = nullptr; }; } // namespace px::modrim diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp index 89efcfa..ef8acdc 100644 --- a/src/ui/views/mods_view.cpp +++ b/src/ui/views/mods_view.cpp @@ -1,26 +1,43 @@ #include "ui/views/mods_view.hpp" -#include "ui/theme/constants.hpp" + #include + +#include "ui/theme/constants.hpp" namespace px::modrim { -ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) - : BaseViewPanel(ctx,engine,parent,app) { - auto* content = new QWidget(this); content->setObjectName(QStringLiteral("contentArea")); - auto* layout = new QHBoxLayout(content); layout->setContentsMargins(0,0,0,0); layout->setSpacing(0); - sidebar = new SidebarPanel(ctx,engine,content); sidebar->setObjectName(QStringLiteral("sidebarPanel")); sidebar->setFixedWidth(kSidebarWidth); - connect(sidebar,&SidebarPanel::entrySelected,this,&ModsViewPanel::entrySelected); layout->addWidget(sidebar); - modList = new ModListPanel(ctx,engine,content); modList->setObjectName(QStringLiteral("modListPanel")); - connect(modList,&ModListPanel::modPicked,this,&ModsViewPanel::modSelected); - connect(modList,&ModListPanel::activeModsChanged,this,&ModsViewPanel::activeModsChanged,Qt::QueuedConnection); - connect(modList,&ModListPanel::orderChanged,this,&ModsViewPanel::orderChanged,Qt::QueuedConnection); - layout->addWidget(modList,3); - modInfo = new ModInfoPanel(ctx,engine,app.uiPrefs(),content); modInfo->setObjectName(QStringLiteral("modInfoPanel")); modInfo->setMinimumWidth(300); - layout->addWidget(modInfo,2); m_root->addWidget(content,1); +ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app) { + auto* content = new QWidget(this); + content->setObjectName(QStringLiteral("contentArea")); + auto* layout = new QHBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + sidebar = new SidebarPanel(ctx, engine, content); + sidebar->setObjectName(QStringLiteral("sidebarPanel")); + sidebar->setFixedWidth(kSidebarWidth); + connect(sidebar, &SidebarPanel::entrySelected, this, + &ModsViewPanel::entrySelected); + layout->addWidget(sidebar); + modList = new ModListPanel(ctx, engine, content); + modList->setObjectName(QStringLiteral("modListPanel")); + connect(modList, &ModListPanel::modPicked, this, &ModsViewPanel::modSelected); + connect(modList, &ModListPanel::activeModsChanged, this, + &ModsViewPanel::activeModsChanged, Qt::QueuedConnection); + connect(modList, &ModListPanel::orderChanged, this, + &ModsViewPanel::orderChanged, Qt::QueuedConnection); + layout->addWidget(modList, 3); + modInfo = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + modInfo->setObjectName(QStringLiteral("modInfoPanel")); + modInfo->setMinimumWidth(300); + layout->addWidget(modInfo, 2); + m_root->addWidget(content, 1); } void ModsViewPanel::applySidebarEntry(const view::SidebarEntry& entry) { - modList->clearSelection(); - modList->setSidebarFilter(entry.visibleUuids); + modList->clearSelection(); + modList->setSidebarFilter(entry.visibleUuids); } void ModsViewPanel::applyCurrentSidebarFilter() { - if (const auto entry = sidebar->currentEntry()) modList->setSidebarFilter(entry->visibleUuids); -} + if (const auto entry = sidebar->currentEntry()) + modList->setSidebarFilter(entry->visibleUuids); } +} // namespace px::modrim diff --git a/src/ui/views/mods_view.hpp b/src/ui/views/mods_view.hpp index 6ab6ac6..6d7074e 100644 --- a/src/ui/views/mods_view.hpp +++ b/src/ui/views/mods_view.hpp @@ -1,23 +1,26 @@ #pragma once -#include "ui/views/base.hpp" -#include "ui/panels/sidebar_panel.hpp" -#include "ui/panels/mod_list_panel.hpp" #include "ui/panels/mod_info_panel.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/panels/sidebar_panel.hpp" +#include "ui/views/base.hpp" namespace px::modrim { class ModsViewPanel : public BaseViewPanel { - Q_OBJECT -public: - static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), QLatin1StringView("mods"), QLatin1StringView("Mods")}; - ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app); - void applySidebarEntry(const view::SidebarEntry& entry); - void applyCurrentSidebarFilter(); - SidebarPanel* sidebar; - ModListPanel* modList; - ModInfoPanel* modInfo; -signals: - void entrySelected(const px::modrim::view::SidebarEntry& entry); - void modSelected(const QString& uuid); - void activeModsChanged(); - void orderChanged(); + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), + QLatin1StringView("mods"), + QLatin1StringView("Mods")}; + ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + void applySidebarEntry(const view::SidebarEntry& entry); + void applyCurrentSidebarFilter(); + SidebarPanel* sidebar; + ModListPanel* modList; + ModInfoPanel* modInfo; + signals: + void entrySelected(const px::modrim::view::SidebarEntry& entry); + void modSelected(const QString& uuid); + void activeModsChanged(); + void orderChanged(); }; -} +} // namespace px::modrim diff --git a/src/ui/views/views.hpp b/src/ui/views/views.hpp index 247bbb7..f642330 100644 --- a/src/ui/views/views.hpp +++ b/src/ui/views/views.hpp @@ -1,5 +1,5 @@ #pragma once -// Aggregate of the built-in whole-window views. Each view header is added here as its -// port lands; registration happens in src/app/plugins.cpp. +// Aggregate of the built-in whole-window views. Each view header is added here +// as its port lands; registration happens in src/app/plugins.cpp. #include "ui/views/base.hpp" diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp index 81f91b1..46d6fca 100644 --- a/src/ui/window/actions.cpp +++ b/src/ui/window/actions.cpp @@ -7,60 +7,102 @@ namespace px::modrim { QString ActionSpec::tooltip() const { - return shortcut.isEmpty() ? label - : QStringLiteral("%1 (%2)").arg(label, QKeySequence(shortcut).toString(QKeySequence::NativeText)); + return shortcut.isEmpty() + ? label + : QStringLiteral("%1 (%2)").arg( + label, + QKeySequence(shortcut).toString(QKeySequence::NativeText)); } const std::array& actionSpecs() { - static const std::array specs{{ - {ActionId::kSave, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, - {ActionId::kRestore, "restore", "&Restore Mod List…", "Restore mod list", {}}, - {ActionId::kSettings, "settings", "&Settings…", "Settings", "Ctrl+,"}, - {ActionId::kQuit, "quit", "&Quit", "Quit", "Ctrl+Q"}, - {ActionId::kRefresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, - {ActionId::kFullRescan, "full_rescan", "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R"}, - {ActionId::kAutoSort, "auto_sort", "&Auto-Sort Mods", "Auto-sort mods", {}}, - {ActionId::kUpdateMods, "update_mods", "&Update Mods", "Update downloaded mods", {}}, - {ActionId::kFocusSearch, "focus_search", "Focus &Search", "Focus search", "Ctrl+F"}, - {ActionId::kNextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, - {ActionId::kPrevView, "prev_view", "&Previous View", "Previous view", "Ctrl+Shift+Tab"}, - {ActionId::kFullscreen, "fullscreen", "Toggle &Fullscreen", "Toggle fullscreen", "F11"}, - {ActionId::kReportIssue, "report_issue", "&Report Issue", "Report issue", {}}, - {ActionId::kUploadLogs, "upload_logs", "Log && System &Info…", "Log & system info", {}}, - {ActionId::kOpenLogs, "open_logs", "Open &Logs Folder", "Open logs folder", {}}, - {ActionId::kShortcuts, "shortcuts", "&Keyboard Shortcuts", "Keyboard shortcuts", {}}, - {ActionId::kCheckUpdates, "check_updates", "Check for &Updates…", "Check for updates", {}}, - {ActionId::kAbout, "about", "&About PxModRim", "About PxModRim", "F1"}, - }}; - return specs; + static const std::array specs{{ + {ActionId::kSave, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, + {ActionId::kRestore, + "restore", + "&Restore Mod List…", + "Restore mod list", + {}}, + {ActionId::kSettings, "settings", "&Settings…", "Settings", "Ctrl+,"}, + {ActionId::kQuit, "quit", "&Quit", "Quit", "Ctrl+Q"}, + {ActionId::kRefresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, + {ActionId::kFullRescan, "full_rescan", "&Full Mod Rescan", + "Full mod rescan", "Ctrl+Shift+R"}, + {ActionId::kAutoSort, + "auto_sort", + "&Auto-Sort Mods", + "Auto-sort mods", + {}}, + {ActionId::kUpdateMods, + "update_mods", + "&Update Mods", + "Update downloaded mods", + {}}, + {ActionId::kFocusSearch, "focus_search", "Focus &Search", "Focus search", + "Ctrl+F"}, + {ActionId::kNextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, + {ActionId::kPrevView, "prev_view", "&Previous View", "Previous view", + "Ctrl+Shift+Tab"}, + {ActionId::kFullscreen, "fullscreen", "Toggle &Fullscreen", + "Toggle fullscreen", "F11"}, + {ActionId::kReportIssue, + "report_issue", + "&Report Issue", + "Report issue", + {}}, + {ActionId::kUploadLogs, + "upload_logs", + "Log && System &Info…", + "Log & system info", + {}}, + {ActionId::kOpenLogs, + "open_logs", + "Open &Logs Folder", + "Open logs folder", + {}}, + {ActionId::kShortcuts, + "shortcuts", + "&Keyboard Shortcuts", + "Keyboard shortcuts", + {}}, + {ActionId::kCheckUpdates, + "check_updates", + "Check for &Updates…", + "Check for updates", + {}}, + {ActionId::kAbout, "about", "&About PxModRim", "About PxModRim", "F1"}, + }}; + return specs; } const ActionSpec& actionSpec(ActionId id) { - return actionSpecs()[static_cast(id)]; + return actionSpecs()[static_cast(id)]; } ActionMap createActions(QWidget& window) { - ActionMap actions; - for (const auto& spec : actionSpecs()) { - auto* action = new QAction(spec.menuText, &window); - if (!spec.shortcut.isEmpty()) action->setShortcut(QKeySequence(spec.shortcut)); - window.addAction(action); - actions.emplace(spec.id, action); - } - return actions; + ActionMap actions; + for (const auto& spec : actionSpecs()) { + auto* action = new QAction(spec.menuText, &window); + if (!spec.shortcut.isEmpty()) + action->setShortcut(QKeySequence(spec.shortcut)); + window.addAction(action); + actions.emplace(spec.id, action); + } + return actions; } QList> shortcutRows() { - QList> rows; - for (const auto& spec : actionSpecs()) { - if (!spec.shortcut.isEmpty()) { - rows.append({QKeySequence(spec.shortcut).toString(QKeySequence::NativeText), spec.label}); - } + QList> rows; + for (const auto& spec : actionSpecs()) { + if (!spec.shortcut.isEmpty()) { + rows.append( + {QKeySequence(spec.shortcut).toString(QKeySequence::NativeText), + spec.label}); } - rows.append({QStringLiteral("Ctrl+1–9"), QStringLiteral("Switch to view")}); - rows.append({QStringLiteral("Alt"), QStringLiteral("Show or hide menu bar")}); - rows.append({QStringLiteral("Esc"), QStringLiteral("Hide menu bar")}); - return rows; + } + rows.append({QStringLiteral("Ctrl+1–9"), QStringLiteral("Switch to view")}); + rows.append({QStringLiteral("Alt"), QStringLiteral("Show or hide menu bar")}); + rows.append({QStringLiteral("Esc"), QStringLiteral("Hide menu bar")}); + return rows; } } // namespace px::modrim diff --git a/src/ui/window/actions.hpp b/src/ui/window/actions.hpp index 6b705ff..4628313 100644 --- a/src/ui/window/actions.hpp +++ b/src/ui/window/actions.hpp @@ -1,32 +1,46 @@ #pragma once +#include +#include +#include #include #include #include #include -#include -#include -#include - class QAction; class QWidget; namespace px::modrim { enum class ActionId { - kSave, kRestore, kSettings, kQuit, kRefresh, kFullRescan, kAutoSort, kUpdateMods, - kFocusSearch, kNextView, kPrevView, kFullscreen, kReportIssue, kUploadLogs, - kOpenLogs, kShortcuts, kCheckUpdates, kAbout, + kSave, + kRestore, + kSettings, + kQuit, + kRefresh, + kFullRescan, + kAutoSort, + kUpdateMods, + kFocusSearch, + kNextView, + kPrevView, + kFullscreen, + kReportIssue, + kUploadLogs, + kOpenLogs, + kShortcuts, + kCheckUpdates, + kAbout, }; struct ActionSpec { - ActionId id; - QString key; - QString menuText; - QString label; - QString shortcut; - QString tooltip() const; + ActionId id; + QString key; + QString menuText; + QString label; + QString shortcut; + QString tooltip() const; }; using ActionMap = std::map; diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index 8bee650..c32e42b 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -1,19 +1,18 @@ #include "ui/window/main_window.hpp" -#include -#include #include +#include #include #include #include #include #include -#include -#include #include -#include +#include #include +#include #include +#include #include #include #include @@ -23,6 +22,8 @@ #include #include #include + +#include #include #include "core/downloads/manager.hpp" @@ -55,520 +56,736 @@ namespace px::modrim { -UnsavedChangesDialog::UnsavedChangesDialog(QWidget* parent) : QMessageBox(parent) { - setIcon(Question); - setWindowTitle(QStringLiteral("Unsaved Changes")); - setText(QStringLiteral("The active mod list has unsaved changes.")); - setInformativeText(QStringLiteral("Save before closing, discard the changes, or cancel closing.")); - setStandardButtons(Save | Discard | Cancel); - setDefaultButton(Save); +UnsavedChangesDialog::UnsavedChangesDialog(QWidget* parent) + : QMessageBox(parent) { + setIcon(Question); + setWindowTitle(QStringLiteral("Unsaved Changes")); + setText(QStringLiteral("The active mod list has unsaved changes.")); + setInformativeText(QStringLiteral( + "Save before closing, discard the changes, or cancel closing.")); + setStandardButtons(Save | Discard | Cancel); + setDefaultButton(Save); } LaunchConfirmDialog::LaunchConfirmDialog(QWidget* parent, const QString& title, - const QString& text, const QString& info, const QString& accept) : QMessageBox(parent) { - setIcon(Warning); - setWindowTitle(title); - setText(text); - setInformativeText(info); - setStandardButtons(Ok | Cancel); - button(Ok)->setText(accept); - setDefaultButton(Cancel); - setCheckBox(new QCheckBox(QStringLiteral("Don't ask again"), this)); + const QString& text, + const QString& info, + const QString& accept) + : QMessageBox(parent) { + setIcon(Warning); + setWindowTitle(title); + setText(text); + setInformativeText(info); + setStandardButtons(Ok | Cancel); + button(Ok)->setText(accept); + setDefaultButton(Cancel); + setCheckBox(new QCheckBox(QStringLiteral("Don't ask again"), this)); } -bool LaunchConfirmDialog::dontAskAgain() const { return checkBox()->isChecked(); } +bool LaunchConfirmDialog::dontAskAgain() const { + return checkBox()->isChecked(); +} MainWindow::MainWindow(AppContext& app, QWidget* parent) - : QMainWindow(parent), m_app(app), m_ctx(app.coreContext()), - m_engine(createQmlEngine(this)), m_savedActive(m_ctx.loadedActiveUuids()), + : QMainWindow(parent), + m_app(app), + m_ctx(app.coreContext()), + m_engine(createQmlEngine(this)), + m_savedActive(m_ctx.loadedActiveUuids()), m_updates(appVersion(), m_ctx.network()) { - setWindowTitle(QStringLiteral("PxModRim[*]")); - setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); - resize(1400, 850); - setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint - | Qt::WindowMaximizeButtonHint | Qt::WindowCloseButtonHint); - setFocusPolicy(Qt::ClickFocus); - m_engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(m_engine)); - - const QVariantMap tooltips{ - {"refresh", actionSpec(ActionId::kRefresh).tooltip()}, - {"sort", actionSpec(ActionId::kAutoSort).tooltip()}, - {"save", actionSpec(ActionId::kSave).tooltip()}, - {"update_mods", actionSpec(ActionId::kUpdateMods).tooltip()}, - }; - m_headerController = new HeaderController(windowFlags().testFlag(Qt::FramelessWindowHint), - static_cast(m_app.uiPrefs().launch_strategy), appVersion(), tooltips, - m_ctx.downloads().available(), this); - m_header = new HeaderPanel(*m_headerController, m_engine, this); - setupActions(); - m_menu = new MenuBar(m_actions, this); - m_menu->hide(); - connect(m_headerController, &HeaderController::refreshRequested, this, [this] { reloadMods(); }); - connect(m_headerController, &HeaderController::sortRequested, this, [this] { autoSort(); }); - connect(m_headerController, &HeaderController::saveRequested, this, [this] { saveActiveMods(); }); - connect(m_headerController, &HeaderController::launchRequested, this, [this] { launchGame(); }); - connect(m_headerController, &HeaderController::updateModsRequested, this, [this] { updateMods(); }); - connect(m_headerController, &HeaderController::downloadsRequested, this, &MainWindow::openDownloads); - connect(m_headerController, &HeaderController::minimizeRequested, this, &QWidget::showMinimized); - connect(m_headerController, &HeaderController::maximizeRequested, this, [this] { - isMaximized() ? showNormal() : showMaximized(); - }); - connect(m_headerController, &HeaderController::closeRequested, this, &QWidget::close); - connect(m_headerController, &HeaderController::dragStartRequested, this, [this] { - if (windowHandle()) windowHandle()->startSystemMove(); - }); - connect(m_headerController, &HeaderController::strategyChanged, this, [this](int index) { - const auto strategy = static_cast(index); - if (m_app.uiPrefs().launch_strategy != strategy) { - m_app.uiPrefs().launch_strategy = strategy; - savePrefs(); - } - }); - - auto* outer = new QWidget(this); - outer->setObjectName(QStringLiteral("outerContainer")); - auto* layout = new QVBoxLayout(outer); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - layout->addWidget(m_header); - layout->addWidget(m_menu); - QVariantList tabs; - for (const auto& view : m_app.railViews()) { - tabs.append(QVariantMap{{"viewId", QString(view.meta.viewId)}, - {"icon", QString(view.meta.iconName)}, {"label", QString(view.meta.label)}}); - } - QVariantList help; - for (const auto id : {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, - ActionId::kShortcuts, ActionId::kCheckUpdates, ActionId::kAbout}) { - const auto& spec = actionSpec(id); - help.append(QVariantMap{{"id", spec.key}, {"label", spec.label}}); - } - m_splitter = new QSplitter(Qt::Horizontal, outer); - m_splitter->setContentsMargins(0, 0, 0, 0); - m_splitter->setHandleWidth(1); - m_rail = new ViewRailPanel(tabs, help, m_engine, m_splitter); - m_rail->setObjectName(QStringLiteral("viewRail")); - m_rail->setMinimumWidth(kRailMinWidth); - m_rail->setMaximumWidth(kRailMaxWidth); - m_stack = new QStackedWidget(m_splitter); - m_stack->setObjectName(QStringLiteral("viewStack")); - m_splitter->addWidget(m_rail); - m_splitter->addWidget(m_stack); - m_splitter->setStretchFactor(0, 0); - m_splitter->setStretchFactor(1, 1); - m_splitter->setCollapsible(0, false); - m_splitter->setCollapsible(1, false); - const int railWidth = m_app.uiPrefs().rail_collapsed ? kRailMinWidth : kRailMaxWidth; - m_splitter->setSizes({railWidth, width() - railWidth}); - connect(m_rail, &ViewRailPanel::currentChanged, this, &MainWindow::selectView); - connect(m_rail, &ViewRailPanel::hovered, this, &MainWindow::preloadView); - connect(m_rail, &ViewRailPanel::settingsRequested, this, [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); - connect(m_rail, &ViewRailPanel::helpActionRequested, this, [this](const QString& key) { - for (const auto& spec : actionSpecs()) if (spec.key == key) m_actions.at(spec.id)->trigger(); - }); - connect(m_splitter, &QSplitter::splitterMoved, this, [this] { snapRail(); }); - for (const auto& spec : m_app.railViews()) { - auto* view = spec.create(m_ctx, m_engine, m_stack, m_app); - m_views.push_back(view); - m_stack->addWidget(view); - if (spec.meta.viewId == QLatin1StringView("mods")) m_modsView = qobject_cast(view); - } - if (m_modsView) { - m_selection = std::make_unique(m_ctx, *m_modsView->modInfo); - connect(m_modsView, &ModsViewPanel::entrySelected, this, [this](const view::SidebarEntry& entry) { - m_modsView->applySidebarEntry(entry); - }); - connect(m_modsView, &ModsViewPanel::modSelected, this, [this](const QString& uuid) { selectMod(uuid); }); - connect(m_modsView->modInfo, &ModInfoPanel::modRequested, this, [this](const QString& uuid) { requestMod(uuid); }); - } - layout->addWidget(m_splitter, 1); - setCentralWidget(outer); - m_toasts = new ToastManager(outer); - m_toasts->resizeToParent(); - m_app.setToasts(m_toasts); - m_app.setNavigator([this](const QString& url) { openRoute(url); }); - m_activeConnection = m_ctx.activeStateChanged().connect([this](const auto&) { refreshUnsavedState(); }); - m_modsConnection = m_ctx.modService().modsChanged.connect([this](const auto&) { onModsReloaded(); }); - auto& diagnostics = m_ctx.diagnosticsService(); - m_diagnosticsConnection = diagnostics.diagnosticsSummaryChanged.connect([this](const auto&) { + setWindowTitle(QStringLiteral("PxModRim[*]")); + setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + resize(1400, 850); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint | + Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint | + Qt::WindowCloseButtonHint); + setFocusPolicy(Qt::ClickFocus); + m_engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new Theme(m_engine)); + + const QVariantMap tooltips{ + {"refresh", actionSpec(ActionId::kRefresh).tooltip()}, + {"sort", actionSpec(ActionId::kAutoSort).tooltip()}, + {"save", actionSpec(ActionId::kSave).tooltip()}, + {"update_mods", actionSpec(ActionId::kUpdateMods).tooltip()}, + }; + m_headerController = new HeaderController( + windowFlags().testFlag(Qt::FramelessWindowHint), + static_cast(m_app.uiPrefs().launch_strategy), appVersion(), tooltips, + m_ctx.downloads().available(), this); + m_header = new HeaderPanel(*m_headerController, m_engine, this); + setupActions(); + m_menu = new MenuBar(m_actions, this); + m_menu->hide(); + connect(m_headerController, &HeaderController::refreshRequested, this, + [this] { reloadMods(); }); + connect(m_headerController, &HeaderController::sortRequested, this, + [this] { autoSort(); }); + connect(m_headerController, &HeaderController::saveRequested, this, + [this] { saveActiveMods(); }); + connect(m_headerController, &HeaderController::launchRequested, this, + [this] { launchGame(); }); + connect(m_headerController, &HeaderController::updateModsRequested, this, + [this] { updateMods(); }); + connect(m_headerController, &HeaderController::downloadsRequested, this, + &MainWindow::openDownloads); + connect(m_headerController, &HeaderController::minimizeRequested, this, + &QWidget::showMinimized); + connect(m_headerController, &HeaderController::maximizeRequested, this, + [this] { isMaximized() ? showNormal() : showMaximized(); }); + connect(m_headerController, &HeaderController::closeRequested, this, + &QWidget::close); + connect(m_headerController, &HeaderController::dragStartRequested, this, + [this] { + if (windowHandle()) windowHandle()->startSystemMove(); + }); + connect(m_headerController, &HeaderController::strategyChanged, this, + [this](int index) { + const auto strategy = static_cast(index); + if (m_app.uiPrefs().launch_strategy != strategy) { + m_app.uiPrefs().launch_strategy = strategy; + savePrefs(); + } + }); + + auto* outer = new QWidget(this); + outer->setObjectName(QStringLiteral("outerContainer")); + auto* layout = new QVBoxLayout(outer); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(m_header); + layout->addWidget(m_menu); + QVariantList tabs; + for (const auto& view : m_app.railViews()) { + tabs.append(QVariantMap{{"viewId", QString(view.meta.viewId)}, + {"icon", QString(view.meta.iconName)}, + {"label", QString(view.meta.label)}}); + } + QVariantList help; + for (const auto id : + {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, + ActionId::kShortcuts, ActionId::kCheckUpdates, ActionId::kAbout}) { + const auto& spec = actionSpec(id); + help.append(QVariantMap{{"id", spec.key}, {"label", spec.label}}); + } + m_splitter = new QSplitter(Qt::Horizontal, outer); + m_splitter->setContentsMargins(0, 0, 0, 0); + m_splitter->setHandleWidth(1); + m_rail = new ViewRailPanel(tabs, help, m_engine, m_splitter); + m_rail->setObjectName(QStringLiteral("viewRail")); + m_rail->setMinimumWidth(kRailMinWidth); + m_rail->setMaximumWidth(kRailMaxWidth); + m_stack = new QStackedWidget(m_splitter); + m_stack->setObjectName(QStringLiteral("viewStack")); + m_splitter->addWidget(m_rail); + m_splitter->addWidget(m_stack); + m_splitter->setStretchFactor(0, 0); + m_splitter->setStretchFactor(1, 1); + m_splitter->setCollapsible(0, false); + m_splitter->setCollapsible(1, false); + const int railWidth = + m_app.uiPrefs().rail_collapsed ? kRailMinWidth : kRailMaxWidth; + m_splitter->setSizes({railWidth, width() - railWidth}); + connect(m_rail, &ViewRailPanel::currentChanged, this, + &MainWindow::selectView); + connect(m_rail, &ViewRailPanel::hovered, this, &MainWindow::preloadView); + connect(m_rail, &ViewRailPanel::settingsRequested, this, + [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + connect(m_rail, &ViewRailPanel::helpActionRequested, this, + [this](const QString& key) { + for (const auto& spec : actionSpecs()) + if (spec.key == key) m_actions.at(spec.id)->trigger(); + }); + connect(m_splitter, &QSplitter::splitterMoved, this, [this] { snapRail(); }); + for (const auto& spec : m_app.railViews()) { + auto* view = spec.create(m_ctx, m_engine, m_stack, m_app); + m_views.push_back(view); + m_stack->addWidget(view); + if (spec.meta.viewId == QLatin1StringView("mods")) + m_modsView = qobject_cast(view); + } + if (m_modsView) { + m_selection = + std::make_unique(m_ctx, *m_modsView->modInfo); + connect(m_modsView, &ModsViewPanel::entrySelected, this, + [this](const view::SidebarEntry& entry) { + m_modsView->applySidebarEntry(entry); + }); + connect(m_modsView, &ModsViewPanel::modSelected, this, + [this](const QString& uuid) { selectMod(uuid); }); + connect(m_modsView->modInfo, &ModInfoPanel::modRequested, this, + [this](const QString& uuid) { requestMod(uuid); }); + } + layout->addWidget(m_splitter, 1); + setCentralWidget(outer); + m_toasts = new ToastManager(outer); + m_toasts->resizeToParent(); + m_app.setToasts(m_toasts); + m_app.setNavigator([this](const QString& url) { openRoute(url); }); + m_activeConnection = m_ctx.activeStateChanged().connect( + [this](const auto&) { refreshUnsavedState(); }); + m_modsConnection = m_ctx.modService().modsChanged.connect( + [this](const auto&) { onModsReloaded(); }); + auto& diagnostics = m_ctx.diagnosticsService(); + m_diagnosticsConnection = + diagnostics.diagnosticsSummaryChanged.connect([this](const auto&) { if (!m_modsView) return; m_modsView->applyCurrentSidebarFilter(); - if (!m_selectedUuid.isEmpty()) m_modsView->modInfo->setIssues(m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); + if (!m_selectedUuid.isEmpty()) + m_modsView->modInfo->setIssues( + m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); + }); + m_statusConnection = diagnostics.statusMessageChanged.connect( + [this](const QString& message) { m_toasts->info(message); }); + m_taskConnection = diagnostics.backgroundTaskChanged.connect( + [this](const QString& text) { m_headerController->setTaskText(text); }); + auto& downloads = m_ctx.downloads(); + if (downloads.available()) { + m_downloadBusyConnection = downloads.busyChanged.connect([this](bool busy) { + m_headerController->setDownloadsBusy(busy); + m_actions.at(ActionId::kUpdateMods)->setEnabled(!busy); + if (busy) + m_headerController->setDownloadsProgress( + QStringLiteral("Updating mods…"), 0, 0); }); - m_statusConnection = diagnostics.statusMessageChanged.connect([this](const QString& message) { m_toasts->info(message); }); - m_taskConnection = diagnostics.backgroundTaskChanged.connect([this](const QString& text) { m_headerController->setTaskText(text); }); - auto& downloads = m_ctx.downloads(); - if (downloads.available()) { - m_downloadBusyConnection = downloads.busyChanged.connect([this](bool busy) { - m_headerController->setDownloadsBusy(busy); - m_actions.at(ActionId::kUpdateMods)->setEnabled(!busy); - if (busy) m_headerController->setDownloadsProgress(QStringLiteral("Updating mods…"), 0, 0); + m_downloadProgressConnection = downloads.downloadProgress.connect( + [this](const DownloadProgress& progress) { + m_headerController->setDownloadsProgress( + QStringLiteral("Updating mods: %1 / %2 · %3 MB") + .arg(progress.completed) + .arg(progress.total) + .arg(progress.bytesDone / 1048576.0, 0, 'f', 0), + progress.completed, progress.total); }); - m_downloadProgressConnection = downloads.downloadProgress.connect([this](const DownloadProgress& progress) { - m_headerController->setDownloadsProgress( - QStringLiteral("Updating mods: %1 / %2 · %3 MB").arg(progress.completed).arg(progress.total) - .arg(progress.bytesDone / 1048576.0, 0, 'f', 0), progress.completed, progress.total); - }); - m_downloadFinishedConnection = downloads.downloadFinished.connect([this](const auto& result) { onDownloadsFinished(result); }); - m_downloadStatusConnection = downloads.statusMessageChanged.connect([this](const QString& message) { m_toasts->info(message); }); - } - qApp->installEventFilter(this); - if (!m_views.empty()) showView(0); - refreshUnsavedState(); + m_downloadFinishedConnection = downloads.downloadFinished.connect( + [this](const auto& result) { onDownloadsFinished(result); }); + m_downloadStatusConnection = downloads.statusMessageChanged.connect( + [this](const QString& message) { m_toasts->info(message); }); + } + qApp->installEventFilter(this); + if (!m_views.empty()) showView(0); + refreshUnsavedState(); } MainWindow::~MainWindow() { - qApp->removeEventFilter(this); - m_app.setNavigator({}); - m_app.setToasts(nullptr); - m_ctx.activeStateChanged().disconnect(m_activeConnection); - m_ctx.modService().modsChanged.disconnect(m_modsConnection); - auto& diagnostics = m_ctx.diagnosticsService(); - diagnostics.diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); - diagnostics.statusMessageChanged.disconnect(m_statusConnection); - diagnostics.backgroundTaskChanged.disconnect(m_taskConnection); - auto& downloads = m_ctx.downloads(); - downloads.busyChanged.disconnect(m_downloadBusyConnection); - downloads.downloadProgress.disconnect(m_downloadProgressConnection); - downloads.downloadFinished.disconnect(m_downloadFinishedConnection); - downloads.statusMessageChanged.disconnect(m_downloadStatusConnection); - const auto dialogs = findChildren(QString(), Qt::FindDirectChildrenOnly); - for (auto* dialog : dialogs) delete dialog; - // QuickWidgets must release their scenes before the shared engine is destroyed. - delete takeCentralWidget(); - delete m_engine; + qApp->removeEventFilter(this); + m_app.setNavigator({}); + m_app.setToasts(nullptr); + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + auto& diagnostics = m_ctx.diagnosticsService(); + diagnostics.diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); + diagnostics.statusMessageChanged.disconnect(m_statusConnection); + diagnostics.backgroundTaskChanged.disconnect(m_taskConnection); + auto& downloads = m_ctx.downloads(); + downloads.busyChanged.disconnect(m_downloadBusyConnection); + downloads.downloadProgress.disconnect(m_downloadProgressConnection); + downloads.downloadFinished.disconnect(m_downloadFinishedConnection); + downloads.statusMessageChanged.disconnect(m_downloadStatusConnection); + const auto dialogs = + findChildren(QString(), Qt::FindDirectChildrenOnly); + for (auto* dialog : dialogs) delete dialog; + // QuickWidgets must release their scenes before the shared engine is + // destroyed. + delete takeCentralWidget(); + delete m_engine; } void MainWindow::setupActions() { - m_actions = createActions(*this); - const auto bind = [this](ActionId id, auto handler) { connect(m_actions.at(id), &QAction::triggered, this, handler); }; - bind(ActionId::kSave, [this] { saveActiveMods(); }); - bind(ActionId::kRestore, [this] { restoreSnapshot(); }); - bind(ActionId::kSettings, [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); - bind(ActionId::kQuit, [this] { close(); }); - bind(ActionId::kRefresh, [this] { reloadMods(); }); - bind(ActionId::kFullRescan, [this] { reloadMods(true); }); - bind(ActionId::kAutoSort, [this] { autoSort(); }); - bind(ActionId::kUpdateMods, [this] { updateMods(); }); - bind(ActionId::kFocusSearch, [this] { focusSearch(); }); - bind(ActionId::kNextView, [this] { cycleView(1); }); - bind(ActionId::kPrevView, [this] { cycleView(-1); }); - bind(ActionId::kFullscreen, [this] { isFullScreen() ? showNormal() : showFullScreen(); }); - bind(ActionId::kReportIssue, [] { QDesktopServices::openUrl(QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); }); - bind(ActionId::kOpenLogs, [] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir(configDir()).filePath(QStringLiteral("logs")))); }); - bind(ActionId::kUploadLogs, [this] { uploadReport(); }); - bind(ActionId::kShortcuts, [this] { showShortcuts(); }); - bind(ActionId::kCheckUpdates, [this] { checkUpdates(true); }); - bind(ActionId::kAbout, [this] { showAbout(); }); - m_actions.at(ActionId::kUpdateMods)->setEnabled(m_ctx.downloads().available()); - for (int index = 0; index < 9; ++index) { - auto* action = new QAction(this); - action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(index + 1))); - connect(action, &QAction::triggered, this, [this, index] { selectView(index); }); - addAction(action); - } + m_actions = createActions(*this); + const auto bind = [this](ActionId id, auto handler) { + connect(m_actions.at(id), &QAction::triggered, this, handler); + }; + bind(ActionId::kSave, [this] { saveActiveMods(); }); + bind(ActionId::kRestore, [this] { restoreSnapshot(); }); + bind(ActionId::kSettings, + [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + bind(ActionId::kQuit, [this] { close(); }); + bind(ActionId::kRefresh, [this] { reloadMods(); }); + bind(ActionId::kFullRescan, [this] { reloadMods(true); }); + bind(ActionId::kAutoSort, [this] { autoSort(); }); + bind(ActionId::kUpdateMods, [this] { updateMods(); }); + bind(ActionId::kFocusSearch, [this] { focusSearch(); }); + bind(ActionId::kNextView, [this] { cycleView(1); }); + bind(ActionId::kPrevView, [this] { cycleView(-1); }); + bind(ActionId::kFullscreen, + [this] { isFullScreen() ? showNormal() : showFullScreen(); }); + bind(ActionId::kReportIssue, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); + }); + bind(ActionId::kOpenLogs, [] { + QDesktopServices::openUrl(QUrl::fromLocalFile( + QDir(configDir()).filePath(QStringLiteral("logs")))); + }); + bind(ActionId::kUploadLogs, [this] { uploadReport(); }); + bind(ActionId::kShortcuts, [this] { showShortcuts(); }); + bind(ActionId::kCheckUpdates, [this] { checkUpdates(true); }); + bind(ActionId::kAbout, [this] { showAbout(); }); + m_actions.at(ActionId::kUpdateMods) + ->setEnabled(m_ctx.downloads().available()); + for (int index = 0; index < 9; ++index) { + auto* action = new QAction(this); + action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(index + 1))); + connect(action, &QAction::triggered, this, + [this, index] { selectView(index); }); + addAction(action); + } +} + +void MainWindow::setAppQuitCallback(std::function callback) { + m_quit = std::move(callback); } -void MainWindow::setAppQuitCallback(std::function callback) { m_quit = std::move(callback); } - void MainWindow::openRoute(const QString& url) { - const auto route = parseRoute(url); - if (!route) return; - if (route->viewId == kSettingsViewId) { openSettings(); return; } - const auto& specs = m_app.railViews(); - for (std::size_t i = 0; i < m_views.size(); ++i) { - if (route->viewId == specs[i].meta.viewId) { - showView(static_cast(i)); - m_views[i]->openRoute(route->path); - return; - } + const auto route = parseRoute(url); + if (!route) return; + if (route->viewId == kSettingsViewId) { + openSettings(); + return; + } + const auto& specs = m_app.railViews(); + for (std::size_t i = 0; i < m_views.size(); ++i) { + if (route->viewId == specs[i].meta.viewId) { + showView(static_cast(i)); + m_views[i]->openRoute(route->path); + return; } + } } void MainWindow::selectView(int index) { - if (index >= 0 && index < m_stack->count()) m_app.navigate(buildRoute(QString(m_app.railViews()[index].meta.viewId))); + if (index >= 0 && index < m_stack->count()) + m_app.navigate(buildRoute(QString(m_app.railViews()[index].meta.viewId))); } void MainWindow::showView(int index) { - m_rail->setCurrent(index); - m_stack->setCurrentIndex(index); - m_headerController->setDownloadsProgressShown(!m_app.railViews()[index].meta.listsDownloads); - preloadView(index - 1); - preloadView(index + 1); + m_rail->setCurrent(index); + m_stack->setCurrentIndex(index); + m_headerController->setDownloadsProgressShown( + !m_app.railViews()[index].meta.listsDownloads); + preloadView(index - 1); + preloadView(index + 1); } void MainWindow::preloadView(int index) { - if (index >= 0 && index < static_cast(m_views.size())) m_views[index]->preload(); + if (index >= 0 && index < static_cast(m_views.size())) + m_views[index]->preload(); } void MainWindow::cycleView(int direction) { - const int count = m_stack->count(); - if (count) selectView(((m_stack->currentIndex() + direction) % count + count) % count); + const int count = m_stack->count(); + if (count) + selectView(((m_stack->currentIndex() + direction) % count + count) % count); } void MainWindow::openDownloads() { - for (const auto& view : m_app.railViews()) { - if (view.meta.listsDownloads) { m_app.navigate(buildRoute(QString(view.meta.viewId))); return; } + for (const auto& view : m_app.railViews()) { + if (view.meta.listsDownloads) { + m_app.navigate(buildRoute(QString(view.meta.viewId))); + return; } + } } void MainWindow::focusSearch() { - auto* search = m_stack->currentWidget() ? m_stack->currentWidget()->findChild(QStringLiteral("searchInput")) : nullptr; - if (search) search->setFocus(); - else if (m_modsView) m_modsView->modList->searchInput->setFocus(); + auto* search = m_stack->currentWidget() + ? m_stack->currentWidget()->findChild( + QStringLiteral("searchInput")) + : nullptr; + if (search) + search->setFocus(); + else if (m_modsView) + m_modsView->modList->searchInput->setFocus(); } void MainWindow::savePrefs() { - if (m_ctx.hasConfigService()) { - const auto result = saveUiPrefs(m_app.uiPrefs(), m_ctx.configService()); - if (!result) m_toasts->error(result.error().message); - } + if (m_ctx.hasConfigService()) { + const auto result = saveUiPrefs(m_app.uiPrefs(), m_ctx.configService()); + if (!result) m_toasts->error(result.error().message); + } } void MainWindow::snapRail() { - const int target = m_rail->width() >= kRailCollapseWidth ? kRailMaxWidth : kRailMinWidth; - m_splitter->setSizes({target, m_splitter->width() - target}); - const bool collapsed = target == kRailMinWidth; - if (m_app.uiPrefs().rail_collapsed != collapsed) { m_app.uiPrefs().rail_collapsed = collapsed; savePrefs(); } + const int target = + m_rail->width() >= kRailCollapseWidth ? kRailMaxWidth : kRailMinWidth; + m_splitter->setSizes({target, m_splitter->width() - target}); + const bool collapsed = target == kRailMinWidth; + if (m_app.uiPrefs().rail_collapsed != collapsed) { + m_app.uiPrefs().rail_collapsed = collapsed; + savePrefs(); + } } void MainWindow::refreshUnsavedState() { - m_unsaved = m_ctx.activeUuids() != m_savedActive; - m_headerController->setUnsavedChanges(m_unsaved); - setWindowModified(m_unsaved); + m_unsaved = m_ctx.activeUuids() != m_savedActive; + m_headerController->setUnsavedChanges(m_unsaved); + setWindowModified(m_unsaved); } -void MainWindow::onModsReloaded() { m_savedActive = m_ctx.loadedActiveUuids(); refreshUnsavedState(); } +void MainWindow::onModsReloaded() { + m_savedActive = m_ctx.loadedActiveUuids(); + refreshUnsavedState(); +} bool MainWindow::eventFilter(QObject* object, QEvent* event) { - if (event->type() == QEvent::KeyPress) { - auto* widget = qobject_cast(object); - if (widget && widget->window() == this) { - const int key = static_cast(event)->key(); - if (key == Qt::Key_Alt) { - m_menu->setVisible(!m_menu->isVisible()); - if (m_menu->isVisible()) m_menu->setFocus(); - return true; - } - if (key == Qt::Key_Escape && m_menu->isVisible()) { m_menu->hide(); return true; } - } + if (event->type() == QEvent::KeyPress) { + auto* widget = qobject_cast(object); + if (widget && widget->window() == this) { + const int key = static_cast(event)->key(); + if (key == Qt::Key_Alt) { + m_menu->setVisible(!m_menu->isVisible()); + if (m_menu->isVisible()) m_menu->setFocus(); + return true; + } + if (key == Qt::Key_Escape && m_menu->isVisible()) { + m_menu->hide(); + return true; + } } - return QMainWindow::eventFilter(object, event); + } + return QMainWindow::eventFilter(object, event); } void MainWindow::closeEvent(QCloseEvent* event) { - if (m_unsaved && !m_closeConfirmed) { - event->ignore(); - if (!m_closePromptOpen) { m_closePromptOpen = true; confirmClose(); } - return; + if (m_unsaved && !m_closeConfirmed) { + event->ignore(); + if (!m_closePromptOpen) { + m_closePromptOpen = true; + confirmClose(); } - m_closing = true; - for (auto* view : m_views) view->teardown(); - event->accept(); - if (m_quit) m_quit(); + return; + } + m_closing = true; + for (auto* view : m_views) view->teardown(); + event->accept(); + if (m_quit) m_quit(); } QCoro::Task MainWindow::confirmClose() { - const auto [result, dialog] = co_await awaitDialog(this); - m_closePromptOpen = false; - if (result == QMessageBox::Save) { - if (!co_await saveActiveMods()) co_return; - } else if (result != QMessageBox::Discard) co_return; - m_closeConfirmed = true; - close(); + const auto [result, dialog] = + co_await awaitDialog(this); + m_closePromptOpen = false; + if (result == QMessageBox::Save) { + if (!co_await saveActiveMods()) co_return; + } else if (result != QMessageBox::Discard) + co_return; + m_closeConfirmed = true; + close(); } void MainWindow::resizeEvent(QResizeEvent* event) { - QMainWindow::resizeEvent(event); - if (m_toasts) m_toasts->resizeToParent(); + QMainWindow::resizeEvent(event); + if (m_toasts) m_toasts->resizeToParent(); } void MainWindow::changeEvent(QEvent* event) { - QMainWindow::changeEvent(event); - if (event->type() == QEvent::WindowStateChange && m_headerController) m_headerController->setMaximized(isMaximized()); + QMainWindow::changeEvent(event); + if (event->type() == QEvent::WindowStateChange && m_headerController) + m_headerController->setMaximized(isMaximized()); } QCoro::Task MainWindow::reloadMods(bool full) { - m_toasts->info(full ? QStringLiteral("Rescanning all mods…") : QStringLiteral("Refreshing mods…")); - const auto count = co_await m_app.refreshMods(full); - if (!count) { m_toasts->error(count.error().message); co_return; } - if (!*count) { m_toasts->warning(QStringLiteral("No mods found")); co_return; } - m_toasts->success((full ? QStringLiteral("Rescanned %1 mods") : QStringLiteral("Reloaded %1 mods")).arg(*count)); + m_toasts->info(full ? QStringLiteral("Rescanning all mods…") + : QStringLiteral("Refreshing mods…")); + const auto count = co_await m_app.refreshMods(full); + if (!count) { + m_toasts->error(count.error().message); + co_return; + } + if (!*count) { + m_toasts->warning(QStringLiteral("No mods found")); + co_return; + } + m_toasts->success((full ? QStringLiteral("Rescanned %1 mods") + : QStringLiteral("Reloaded %1 mods")) + .arg(*count)); } QCoro::Task MainWindow::autoSort() { - const auto [count, elapsed] = co_await m_ctx.autoSort(); - m_toasts->success(QStringLiteral("Sorted %1 mods in %2ms").arg(count).arg(elapsed, 0, 'f', 0)); + const auto [count, elapsed] = co_await m_ctx.autoSort(); + m_toasts->success(QStringLiteral("Sorted %1 mods in %2ms") + .arg(count) + .arg(elapsed, 0, 'f', 0)); } QCoro::Task MainWindow::updateMods() { - auto& downloads = m_ctx.downloads(); - if (!downloads.available()) co_return; - if (downloads.isDownloading()) { downloads.cancel(); co_return; } - const auto ids = downloads.updatableIds(m_ctx.allMods().values()); - if (ids.isEmpty()) { m_toasts->info(QStringLiteral("No downloaded mods to update")); co_return; } - if (!co_await confirmUpdate(ids.size(), this)) co_return; - m_toasts->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); - const auto result = co_await downloads.downloadMods(ids); - if (!result) m_toasts->error(result.error().message); + auto& downloads = m_ctx.downloads(); + if (!downloads.available()) co_return; + if (downloads.isDownloading()) { + downloads.cancel(); + co_return; + } + const auto ids = downloads.updatableIds(m_ctx.allMods().values()); + if (ids.isEmpty()) { + m_toasts->info(QStringLiteral("No downloaded mods to update")); + co_return; + } + if (!co_await confirmUpdate(ids.size(), this)) co_return; + m_toasts->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); + const auto result = co_await downloads.downloadMods(ids); + if (!result) m_toasts->error(result.error().message); } void MainWindow::onDownloadsFinished(const DownloadResult& result) { - const auto ok = result.succeeded.size(), failed = result.failed.size(), changed = result.changed.size(); - if (!ok && !failed) { m_toasts->info(QStringLiteral("Download cancelled")); return; } - const QString summary = QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); - if (failed) { - const auto names = m_ctx.downloads().namesById(m_ctx.allMods().values()); - QStringList shown; - for (const auto& id : result.failed.mid(0, 3)) shown.append(names.value(id, id)); - m_toasts->warning(QStringLiteral("%1, %2 failed: %3%4").arg(summary).arg(failed).arg(shown.join(QStringLiteral(", ")), failed > 3 ? QStringLiteral("…") : QString())); - } else m_toasts->success(summary); - if (changed) m_app.refreshMods(); + const auto ok = result.succeeded.size(), failed = result.failed.size(), + changed = result.changed.size(); + if (!ok && !failed) { + m_toasts->info(QStringLiteral("Download cancelled")); + return; + } + const QString summary = + QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); + if (failed) { + const auto names = m_ctx.downloads().namesById(m_ctx.allMods().values()); + QStringList shown; + for (const auto& id : result.failed.mid(0, 3)) + shown.append(names.value(id, id)); + m_toasts->warning(QStringLiteral("%1, %2 failed: %3%4") + .arg(summary) + .arg(failed) + .arg(shown.join(QStringLiteral(", ")), + failed > 3 ? QStringLiteral("…") : QString())); + } else + m_toasts->success(summary); + if (changed) m_app.refreshMods(); } QCoro::Task MainWindow::openSettings() { - if (m_settingsOpen) co_return; - m_settingsOpen = true; - const auto [result, dialog] = co_await awaitDialog(m_app, m_engine, this); - m_settingsOpen = false; - if (result != QDialog::Accepted) co_return; - auto config = dialog->getConfig(); - const auto saved = m_ctx.configService().save(QStringLiteral("config.json"), config); - if (!saved) { m_toasts->error(saved.error().message); co_return; } - m_ctx.updateConfig(config); - m_ctx.resetProviders(config.paths); - const auto reloaded = co_await m_ctx.modService().reload(); - if (!reloaded) { m_toasts->error(reloaded.error().message); co_return; } - m_toasts->success(QStringLiteral("Settings saved")); -} - -QCoro::Task MainWindow::showAbout() { co_await awaitDialog(this, m_ctx, *m_toasts); } -QCoro::Task MainWindow::showShortcuts() { co_await awaitDialog(shortcutRows() + kQmlShortcuts, this); } -QCoro::Task MainWindow::uploadReport() { co_await handleUploadReport(this, m_ctx, *m_toasts); } + if (m_settingsOpen) co_return; + m_settingsOpen = true; + const auto [result, dialog] = + co_await awaitDialog(m_app, m_engine, this); + m_settingsOpen = false; + if (result != QDialog::Accepted) co_return; + auto config = dialog->getConfig(); + const auto saved = + m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { + m_toasts->error(saved.error().message); + co_return; + } + m_ctx.updateConfig(config); + m_ctx.resetProviders(config.paths); + const auto reloaded = co_await m_ctx.modService().reload(); + if (!reloaded) { + m_toasts->error(reloaded.error().message); + co_return; + } + m_toasts->success(QStringLiteral("Settings saved")); +} + +QCoro::Task MainWindow::showAbout() { + co_await awaitDialog(this, m_ctx, *m_toasts); +} +QCoro::Task MainWindow::showShortcuts() { + co_await awaitDialog(shortcutRows() + kQmlShortcuts, + this); +} +QCoro::Task MainWindow::uploadReport() { + co_await handleUploadReport(this, m_ctx, *m_toasts); +} QCoro::Task MainWindow::restoreSnapshot() { - const auto snapshots = m_ctx.modService().getSnapshots(); - if (snapshots.isEmpty()) { m_toasts->warning(QStringLiteral("No saved mod lists are available")); co_return; } - const auto [result, dialog] = co_await awaitDialog(snapshots, this); - if (result != QDialog::Accepted) co_return; - const auto chosen = dialog->selectedSnapshot(); - if (!chosen) co_return; - const auto [confirmed, confirmation] = co_await awaitDialog(QFileInfo(*chosen).fileName(), this); - if (confirmed != QMessageBox::Yes) co_return; - if (co_await m_ctx.modService().restoreSnapshot(*chosen)) m_toasts->success(QStringLiteral("Restored mod list from %1").arg(QFileInfo(*chosen).fileName())); - else m_toasts->error(QStringLiteral("The saved mod list is invalid or cannot be read")); + const auto snapshots = m_ctx.modService().getSnapshots(); + if (snapshots.isEmpty()) { + m_toasts->warning(QStringLiteral("No saved mod lists are available")); + co_return; + } + const auto [result, dialog] = + co_await awaitDialog(snapshots, this); + if (result != QDialog::Accepted) co_return; + const auto chosen = dialog->selectedSnapshot(); + if (!chosen) co_return; + const auto [confirmed, confirmation] = + co_await awaitDialog(QFileInfo(*chosen).fileName(), + this); + if (confirmed != QMessageBox::Yes) co_return; + if (co_await m_ctx.modService().restoreSnapshot(*chosen)) + m_toasts->success(QStringLiteral("Restored mod list from %1") + .arg(QFileInfo(*chosen).fileName())); + else + m_toasts->error( + QStringLiteral("The saved mod list is invalid or cannot be read")); } void MainWindow::startStartupUpdateCheck() { checkUpdates(false); } QCoro::Task MainWindow::checkUpdates(bool manual) { - if (m_checkingUpdates) co_return; - m_checkingUpdates = true; - const QPointer guard(this); - const auto release = co_await m_updates.check(); - if (!guard || m_closing) co_return; - if (!release) { - m_checkingUpdates = false; - if (manual) m_toasts->error(QStringLiteral("Could not check for updates")); - co_return; - } - if (!release->has_value()) { - m_checkingUpdates = false; - if (manual) m_toasts->success(QStringLiteral("PxModRim is up to date")); - co_return; - } - const auto info = **release; - if (!manual && info.tag.toStdString() == m_app.uiPrefs().skipped_update_tag) { m_checkingUpdates = false; co_return; } - const auto [result, dialog] = co_await awaitDialog(info, m_updates.currentVersion(), this); - if (!guard || m_closing) co_return; + if (m_checkingUpdates) co_return; + m_checkingUpdates = true; + const QPointer guard(this); + const auto release = co_await m_updates.check(); + if (!guard || m_closing) co_return; + if (!release) { + m_checkingUpdates = false; + if (manual) m_toasts->error(QStringLiteral("Could not check for updates")); + co_return; + } + if (!release->has_value()) { m_checkingUpdates = false; - if (result == QDialog::Accepted) QDesktopServices::openUrl(QUrl(info.url)); - else if (result == kSkipResult) { m_app.uiPrefs().skipped_update_tag = info.tag.toStdString(); savePrefs(); } + if (manual) m_toasts->success(QStringLiteral("PxModRim is up to date")); + co_return; + } + const auto info = **release; + if (!manual && info.tag.toStdString() == m_app.uiPrefs().skipped_update_tag) { + m_checkingUpdates = false; + co_return; + } + const auto [result, dialog] = co_await awaitDialog( + info, m_updates.currentVersion(), this); + if (!guard || m_closing) co_return; + m_checkingUpdates = false; + if (result == QDialog::Accepted) + QDesktopServices::openUrl(QUrl(info.url)); + else if (result == kSkipResult) { + m_app.uiPrefs().skipped_update_tag = info.tag.toStdString(); + savePrefs(); + } } QCoro::Task> MainWindow::writeActiveLayout() { - const auto active = m_ctx.activeUuids(); - if (!co_await m_ctx.modService().saveActiveLayout(active)) co_return std::nullopt; - m_savedActive = active; - refreshUnsavedState(); - co_return active; + const auto active = m_ctx.activeUuids(); + if (!co_await m_ctx.modService().saveActiveLayout(active)) + co_return std::nullopt; + m_savedActive = active; + refreshUnsavedState(); + co_return active; } QCoro::Task MainWindow::saveActiveMods() { - const auto saved = co_await writeActiveLayout(); - if (!saved) { m_toasts->warning(QStringLiteral("Config folder not set")); co_return false; } - m_toasts->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); - co_return true; -} - -QCoro::Task MainWindow::confirmLaunch(bool AppConfig::* setting, const QString& title, - const QString& text, const QString& info, const QString& accept) { - const auto [result, dialog] = co_await awaitDialog(this, title, text, info, accept); - if (result != QMessageBox::Ok) co_return false; - if (dialog->dontAskAgain()) { - auto config = m_ctx.config(); - config.*setting = false; - const auto saved = m_ctx.configService().save(QStringLiteral("config.json"), config); - if (!saved) { m_toasts->error(saved.error().message); co_return false; } - m_ctx.updateConfig(std::move(config)); + const auto saved = co_await writeActiveLayout(); + if (!saved) { + m_toasts->warning(QStringLiteral("Config folder not set")); + co_return false; + } + m_toasts->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); + co_return true; +} + +QCoro::Task MainWindow::confirmLaunch(bool AppConfig::* setting, + const QString& title, + const QString& text, + const QString& info, + const QString& accept) { + const auto [result, dialog] = co_await awaitDialog( + this, title, text, info, accept); + if (result != QMessageBox::Ok) co_return false; + if (dialog->dontAskAgain()) { + auto config = m_ctx.config(); + config.*setting = false; + const auto saved = + m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { + m_toasts->error(saved.error().message); + co_return false; } - co_return true; + m_ctx.updateConfig(std::move(config)); + } + co_return true; } QCoro::Task MainWindow::launchGame() { - if (m_headerController->launchState() != QStringLiteral("idle")) co_return; - m_headerController->setLaunchState(QStringLiteral("launching")); - const QPointer guard(this); - auto& launcher = m_ctx.gameLauncher(); - const auto config = m_ctx.config(); - const bool running = config.launch_confirm_running && co_await QtConcurrent::run([&launcher] { return launcher.isRunning(); }); - if (!guard || m_closing) co_return; - const int errors = m_ctx.diagnosticsService().activeErrorCount(); - const auto abort = [this] { m_headerController->setLaunchState(QStringLiteral("idle")); }; - if (running && !co_await confirmLaunch(&AppConfig::launch_confirm_running, - QStringLiteral("Game Already Running"), QStringLiteral("RimWorld is already running."), - QStringLiteral("A second copy can overwrite the first one's saves and settings."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } - if (config.launch_confirm_errors && errors > 0 && !co_await confirmLaunch(&AppConfig::launch_confirm_errors, - QStringLiteral("Mod List Has Errors"), QStringLiteral("%1 active mod(s) have errors.").arg(errors), - QStringLiteral("Missing dependencies or wrong load order can crash the game."), QStringLiteral("Launch Anyway"))) { abort(); co_return; } - if (config.launch_confirm_unsaved && m_unsaved && !co_await confirmLaunch(&AppConfig::launch_confirm_unsaved, - QStringLiteral("Unsaved Changes"), QStringLiteral("The active mod list has unsaved changes."), - QStringLiteral("The list is saved to ModsConfig.xml before the game starts."), QStringLiteral("Save and Launch"))) { abort(); co_return; } - if (!co_await writeActiveLayout()) m_toasts->warning(QStringLiteral("Config folder not set — mod list won't be saved")); - const auto result = co_await launcher.launch(m_app.uiPrefs().launch_strategy); - if (!guard || m_closing) co_return; - if (!result) { m_toasts->warning(result.error().message); abort(); co_return; } - m_toasts->success(*result); - const auto after = m_ctx.config().after_launch; - if (!co_await launcher.waitForStart()) { - if (guard && !m_closing) { m_toasts->warning(QStringLiteral("Game did not start")); abort(); } - co_return; - } - if (!guard || m_closing) co_return; - m_headerController->setLaunchState(QStringLiteral("running")); - if (after == AfterLaunch::kClose && !m_unsaved) { m_closeConfirmed = true; close(); co_return; } - if (after == AfterLaunch::kMinimize) showMinimized(); - const auto code = co_await launcher.waitForExit(); - if (!guard || m_closing) co_return; - if (code && *code) m_toasts->warning(QStringLiteral("Game exited with code %1").arg(*code)); - else m_toasts->info(QStringLiteral("Game closed")); - if (after == AfterLaunch::kMinimize) { showNormal(); activateWindow(); } + if (m_headerController->launchState() != QStringLiteral("idle")) co_return; + m_headerController->setLaunchState(QStringLiteral("launching")); + const QPointer guard(this); + auto& launcher = m_ctx.gameLauncher(); + const auto config = m_ctx.config(); + const bool running = + config.launch_confirm_running && + co_await QtConcurrent::run([&launcher] { return launcher.isRunning(); }); + if (!guard || m_closing) co_return; + const int errors = m_ctx.diagnosticsService().activeErrorCount(); + const auto abort = [this] { + m_headerController->setLaunchState(QStringLiteral("idle")); + }; + if (running && + !co_await confirmLaunch(&AppConfig::launch_confirm_running, + QStringLiteral("Game Already Running"), + QStringLiteral("RimWorld is already running."), + QStringLiteral("A second copy can overwrite the " + "first one's saves and settings."), + QStringLiteral("Launch Anyway"))) { + abort(); + co_return; + } + if (config.launch_confirm_errors && errors > 0 && + !co_await confirmLaunch( + &AppConfig::launch_confirm_errors, + QStringLiteral("Mod List Has Errors"), + QStringLiteral("%1 active mod(s) have errors.").arg(errors), + QStringLiteral( + "Missing dependencies or wrong load order can crash the game."), + QStringLiteral("Launch Anyway"))) { abort(); + co_return; + } + if (config.launch_confirm_unsaved && m_unsaved && + !co_await confirmLaunch( + &AppConfig::launch_confirm_unsaved, QStringLiteral("Unsaved Changes"), + QStringLiteral("The active mod list has unsaved changes."), + QStringLiteral( + "The list is saved to ModsConfig.xml before the game starts."), + QStringLiteral("Save and Launch"))) { + abort(); + co_return; + } + if (!co_await writeActiveLayout()) + m_toasts->warning( + QStringLiteral("Config folder not set — mod list won't be saved")); + const auto result = co_await launcher.launch(m_app.uiPrefs().launch_strategy); + if (!guard || m_closing) co_return; + if (!result) { + m_toasts->warning(result.error().message); + abort(); + co_return; + } + m_toasts->success(*result); + const auto after = m_ctx.config().after_launch; + if (!co_await launcher.waitForStart()) { + if (guard && !m_closing) { + m_toasts->warning(QStringLiteral("Game did not start")); + abort(); + } + co_return; + } + if (!guard || m_closing) co_return; + m_headerController->setLaunchState(QStringLiteral("running")); + if (after == AfterLaunch::kClose && !m_unsaved) { + m_closeConfirmed = true; + close(); + co_return; + } + if (after == AfterLaunch::kMinimize) showMinimized(); + const auto code = co_await launcher.waitForExit(); + if (!guard || m_closing) co_return; + if (code && *code) + m_toasts->warning(QStringLiteral("Game exited with code %1").arg(*code)); + else + m_toasts->info(QStringLiteral("Game closed")); + if (after == AfterLaunch::kMinimize) { + showNormal(); + activateWindow(); + } + abort(); } QCoro::Task MainWindow::selectMod(QString uuid) { - m_selectedUuid = uuid; - if (!m_selection) co_return; - if (uuid.isEmpty()) m_selection->clear(); - else co_await m_selection->show(std::move(uuid)); + m_selectedUuid = uuid; + if (!m_selection) co_return; + if (uuid.isEmpty()) + m_selection->clear(); + else + co_await m_selection->show(std::move(uuid)); } QCoro::Task MainWindow::requestMod(QString uuid) { - if (!m_modsView) co_return; - if (m_modsView->modList->selectUuid(uuid)) co_return; - m_modsView->modList->searchInput->clear(); - m_modsView->sidebar->selectAllEntry(); - if (!m_modsView->modList->selectUuid(uuid)) co_await selectMod(std::move(uuid)); + if (!m_modsView) co_return; + if (m_modsView->modList->selectUuid(uuid)) co_return; + m_modsView->modList->searchInput->clear(); + m_modsView->sidebar->selectAllEntry(); + if (!m_modsView->modList->selectUuid(uuid)) + co_await selectMod(std::move(uuid)); } } // namespace px::modrim diff --git a/src/ui/window/main_window.hpp b/src/ui/window/main_window.hpp index 920acd1..0cd5df5 100644 --- a/src/ui/window/main_window.hpp +++ b/src/ui/window/main_window.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include #include @@ -7,9 +10,6 @@ #include #include -#include -#include -#include #include "core/downloads/types.hpp" #include "core/services/update_service.hpp" @@ -31,99 +31,101 @@ class ToastManager; class ViewRailPanel; class UnsavedChangesDialog : public QMessageBox { -public: - explicit UnsavedChangesDialog(QWidget* parent); + public: + explicit UnsavedChangesDialog(QWidget* parent); }; class LaunchConfirmDialog : public QMessageBox { -public: - LaunchConfirmDialog(QWidget* parent, const QString& title, const QString& text, - const QString& info, const QString& accept); - bool dontAskAgain() const; + public: + LaunchConfirmDialog(QWidget* parent, const QString& title, + const QString& text, const QString& info, + const QString& accept); + bool dontAskAgain() const; }; class MainWindow : public QMainWindow { - Q_OBJECT -public: - explicit MainWindow(AppContext& app, QWidget* parent = nullptr); - ~MainWindow() override; + Q_OBJECT + public: + explicit MainWindow(AppContext& app, QWidget* parent = nullptr); + ~MainWindow() override; - QQmlEngine* qmlEngine() const noexcept { return m_engine; } - void setAppQuitCallback(std::function callback); - void startStartupUpdateCheck(); - void openRoute(const QString& url); - void selectView(int index); - void cycleView(int direction); - void refreshUnsavedState(); - bool hasUnsavedChanges() const noexcept { return m_unsaved; } - QCoro::Task saveActiveMods(); + QQmlEngine* qmlEngine() const noexcept { return m_engine; } + void setAppQuitCallback(std::function callback); + void startStartupUpdateCheck(); + void openRoute(const QString& url); + void selectView(int index); + void cycleView(int direction); + void refreshUnsavedState(); + bool hasUnsavedChanges() const noexcept { return m_unsaved; } + QCoro::Task saveActiveMods(); -protected: - bool eventFilter(QObject* object, QEvent* event) override; - void closeEvent(QCloseEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - void changeEvent(QEvent* event) override; + protected: + bool eventFilter(QObject* object, QEvent* event) override; + void closeEvent(QCloseEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void changeEvent(QEvent* event) override; -private: - void setupActions(); - void showView(int index); - void preloadView(int index); - void openDownloads(); - void focusSearch(); - void snapRail(); - void savePrefs(); - void onModsReloaded(); - void onDownloadsFinished(const DownloadResult& result); - QCoro::Task confirmClose(); - QCoro::Task reloadMods(bool full = false); - QCoro::Task autoSort(); - QCoro::Task updateMods(); - QCoro::Task openSettings(); - QCoro::Task showAbout(); - QCoro::Task showShortcuts(); - QCoro::Task uploadReport(); - QCoro::Task restoreSnapshot(); - QCoro::Task checkUpdates(bool manual); - QCoro::Task> writeActiveLayout(); - QCoro::Task confirmLaunch(bool AppConfig::* setting, const QString& title, - const QString& text, const QString& info, const QString& accept); - QCoro::Task launchGame(); - QCoro::Task selectMod(QString uuid); - QCoro::Task requestMod(QString uuid); + private: + void setupActions(); + void showView(int index); + void preloadView(int index); + void openDownloads(); + void focusSearch(); + void snapRail(); + void savePrefs(); + void onModsReloaded(); + void onDownloadsFinished(const DownloadResult& result); + QCoro::Task confirmClose(); + QCoro::Task reloadMods(bool full = false); + QCoro::Task autoSort(); + QCoro::Task updateMods(); + QCoro::Task openSettings(); + QCoro::Task showAbout(); + QCoro::Task showShortcuts(); + QCoro::Task uploadReport(); + QCoro::Task restoreSnapshot(); + QCoro::Task checkUpdates(bool manual); + QCoro::Task> writeActiveLayout(); + QCoro::Task confirmLaunch(bool AppConfig::* setting, + const QString& title, const QString& text, + const QString& info, const QString& accept); + QCoro::Task launchGame(); + QCoro::Task selectMod(QString uuid); + QCoro::Task requestMod(QString uuid); - AppContext& m_app; - CoreContext& m_ctx; - QQmlEngine* m_engine; - HeaderController* m_headerController = nullptr; - HeaderPanel* m_header = nullptr; - MenuBar* m_menu = nullptr; - ViewRailPanel* m_rail = nullptr; - QStackedWidget* m_stack = nullptr; - QSplitter* m_splitter = nullptr; - ToastManager* m_toasts = nullptr; - ModsViewPanel* m_modsView = nullptr; - std::vector m_views; - std::unique_ptr m_selection; - QString m_selectedUuid; - QStringList m_savedActive; - ActionMap m_actions; - std::function m_quit; - services::UpdateService m_updates; - bool m_unsaved = false; - bool m_closePromptOpen = false; - bool m_closeConfirmed = false; - bool m_closing = false; - bool m_settingsOpen = false; - bool m_checkingUpdates = false; - std::uint64_t m_activeConnection = 0; - std::uint64_t m_modsConnection = 0; - std::uint64_t m_diagnosticsConnection = 0; - std::uint64_t m_statusConnection = 0; - std::uint64_t m_taskConnection = 0; - std::uint64_t m_downloadBusyConnection = 0; - std::uint64_t m_downloadProgressConnection = 0; - std::uint64_t m_downloadFinishedConnection = 0; - std::uint64_t m_downloadStatusConnection = 0; + AppContext& m_app; + CoreContext& m_ctx; + QQmlEngine* m_engine; + HeaderController* m_headerController = nullptr; + HeaderPanel* m_header = nullptr; + MenuBar* m_menu = nullptr; + ViewRailPanel* m_rail = nullptr; + QStackedWidget* m_stack = nullptr; + QSplitter* m_splitter = nullptr; + ToastManager* m_toasts = nullptr; + ModsViewPanel* m_modsView = nullptr; + std::vector m_views; + std::unique_ptr m_selection; + QString m_selectedUuid; + QStringList m_savedActive; + ActionMap m_actions; + std::function m_quit; + services::UpdateService m_updates; + bool m_unsaved = false; + bool m_closePromptOpen = false; + bool m_closeConfirmed = false; + bool m_closing = false; + bool m_settingsOpen = false; + bool m_checkingUpdates = false; + std::uint64_t m_activeConnection = 0; + std::uint64_t m_modsConnection = 0; + std::uint64_t m_diagnosticsConnection = 0; + std::uint64_t m_statusConnection = 0; + std::uint64_t m_taskConnection = 0; + std::uint64_t m_downloadBusyConnection = 0; + std::uint64_t m_downloadProgressConnection = 0; + std::uint64_t m_downloadFinishedConnection = 0; + std::uint64_t m_downloadStatusConnection = 0; }; } // namespace px::modrim diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp index 8cc9a2f..76df52d 100644 --- a/src/ui/window/menu_bar.cpp +++ b/src/ui/window/menu_bar.cpp @@ -5,24 +5,34 @@ namespace px::modrim { const std::array& menuLayout() { - static const std::array layout{{ - {"&File", {ActionId::kSave, ActionId::kRestore, ActionId::kSettings, std::nullopt, ActionId::kQuit}}, - {"&Mods", {ActionId::kRefresh, ActionId::kFullRescan, ActionId::kAutoSort, std::nullopt, ActionId::kUpdateMods}}, - {"&View", {ActionId::kFocusSearch, ActionId::kNextView, ActionId::kPrevView, std::nullopt, ActionId::kFullscreen}}, - {"&Help", {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, ActionId::kShortcuts, - ActionId::kCheckUpdates, std::nullopt, ActionId::kAbout}}, - }}; - return layout; + static const std::array layout{{ + {"&File", + {ActionId::kSave, ActionId::kRestore, ActionId::kSettings, std::nullopt, + ActionId::kQuit}}, + {"&Mods", + {ActionId::kRefresh, ActionId::kFullRescan, ActionId::kAutoSort, + std::nullopt, ActionId::kUpdateMods}}, + {"&View", + {ActionId::kFocusSearch, ActionId::kNextView, ActionId::kPrevView, + std::nullopt, ActionId::kFullscreen}}, + {"&Help", + {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, + ActionId::kShortcuts, ActionId::kCheckUpdates, std::nullopt, + ActionId::kAbout}}, + }}; + return layout; } MenuBar::MenuBar(const ActionMap& actions, QWidget* parent) : QMenuBar(parent) { - for (const auto& spec : menuLayout()) { - auto* menu = addMenu(spec.title); - for (auto id : spec.actions) { - if (id) menu->addAction(actions.at(*id)); - else menu->addSeparator(); - } + for (const auto& spec : menuLayout()) { + auto* menu = addMenu(spec.title); + for (auto id : spec.actions) { + if (id) + menu->addAction(actions.at(*id)); + else + menu->addSeparator(); } + } } } // namespace px::modrim diff --git a/src/ui/window/menu_bar.hpp b/src/ui/window/menu_bar.hpp index e26159b..fabf951 100644 --- a/src/ui/window/menu_bar.hpp +++ b/src/ui/window/menu_bar.hpp @@ -7,16 +7,16 @@ namespace px::modrim { struct MenuSpec { - QString title; - std::vector> actions; + QString title; + std::vector> actions; }; const std::array& menuLayout(); class MenuBar : public QMenuBar { - Q_OBJECT -public: - explicit MenuBar(const ActionMap& actions, QWidget* parent = nullptr); + Q_OBJECT + public: + explicit MenuBar(const ActionMap& actions, QWidget* parent = nullptr); }; } // namespace px::modrim diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp index 87258c3..c84a685 100644 --- a/tests/core/checker/test_checker.cpp +++ b/tests/core/checker/test_checker.cpp @@ -1,14 +1,13 @@ -#include - +#include +#include +#include +#include #include #include #include #include -#include -#include -#include -#include +#include #include "core/checker/checker.hpp" #include "core/checker/graph.hpp" @@ -22,215 +21,224 @@ using namespace px::modrim::checker; namespace { -std::shared_ptr makeMod(const QString& packageId, const QString& name = {}) { - auto m = std::make_shared(); - m->packageId = CaseInsensitiveStr(packageId); - m->name = name.isEmpty() ? packageId : name; - return m; +std::shared_ptr makeMod(const QString& packageId, + const QString& name = {}) { + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = name.isEmpty() ? packageId : name; + return m; } sort::SortSettings settings() { - sort::SortSettings s; - s.use_community_rules = false; - s.use_alternative_package_ids = true; - s.check_missing_dependencies = true; - return s; + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; } -QHash asListed(const std::vector>& mods) { - QHash result; - for (std::size_t i = 0; i < mods.size(); ++i) { - result.insert(QString::number(i), mods[i]); - } - return result; +QHash asListed( + const std::vector>& mods) { + QHash result; + for (std::size_t i = 0; i < mods.size(); ++i) { + result.insert(QString::number(i), mods[i]); + } + return result; } QString uuidFor(const QHash& mods, const QString& name) { - for (auto it = mods.cbegin(); it != mods.cend(); ++it) { - if (it.value()->name == name) { - return it.key(); - } + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + if (it.value()->name == name) { + return it.key(); } - return {}; + } + return {}; } std::vector> defaultCheckers() { - std::vector> checkers; - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - return checkers; + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; } } // namespace TEST_CASE("ModChecker basic rebuild") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.c")); - a->aboutRules.dependencies.append(dep); - - const auto mods = asListed({a, b}); - const QStringList activeUuids{ - uuidFor(mods, QStringLiteral("mod.a")), - uuidFor(mods, QStringLiteral("mod.b")), - }; - - DiagnosticsByUuid collected; - auto onChange = [&](const DiagnosticsByUuid& diags) { - for (auto it = diags.cbegin(); it != diags.cend(); ++it) { - collected.insert(it.key(), it.value()); - } - }; - - ModChecker checker(defaultCheckers(), settings(), QStringLiteral("1.5"), onChange); - checker.rebuild(mods, activeUuids); - - const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); - const auto* diagA = checker.diagnosticsFor(aUuid); - REQUIRE(diagA != nullptr); - CHECK(diagA->hasErrors()); - REQUIRE(diagA->errors.size() == 1); - CHECK(diagA->errors[0].category == QStringLiteral("missing_dependency")); - - const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); - const auto* diagB = checker.diagnosticsFor(bUuid); - REQUIRE(diagB != nullptr); - CHECK(!diagB->hasErrors()); - CHECK(!diagB->hasWarnings()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.c")); + a->aboutRules.dependencies.append(dep); + + const auto mods = asListed({a, b}); + const QStringList activeUuids{ + uuidFor(mods, QStringLiteral("mod.a")), + uuidFor(mods, QStringLiteral("mod.b")), + }; + + DiagnosticsByUuid collected; + auto onChange = [&](const DiagnosticsByUuid& diags) { + for (auto it = diags.cbegin(); it != diags.cend(); ++it) { + collected.insert(it.key(), it.value()); + } + }; + + ModChecker checker(defaultCheckers(), settings(), QStringLiteral("1.5"), + onChange); + checker.rebuild(mods, activeUuids); + + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const auto* diagA = checker.diagnosticsFor(aUuid); + REQUIRE(diagA != nullptr); + CHECK(diagA->hasErrors()); + REQUIRE(diagA->errors.size() == 1); + CHECK(diagA->errors[0].category == QStringLiteral("missing_dependency")); + + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + const auto* diagB = checker.diagnosticsFor(bUuid); + REQUIRE(diagB != nullptr); + CHECK(!diagB->hasErrors()); + CHECK(!diagB->hasWarnings()); } TEST_CASE("ModChecker toggle mod rebuilds diagnostics and constraints") { - std::vector> checkers; - checkers.push_back(std::make_unique()); - checkers.push_back(std::make_unique()); - - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->aboutRules.dependencies.append(dep); - b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); - - const auto mods = asListed({a, b}); - const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); - const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); - - ModChecker checker(std::move(checkers), settings()); - checker.rebuild(mods, {aUuid, bUuid}); - - checker.toggleMod(*mods[bUuid], {aUuid}); - const auto* diagDisabled = checker.diagnosticsFor(aUuid); - REQUIRE(diagDisabled != nullptr); - CHECK(diagDisabled->hasErrors()); - CHECK(diagDisabled->errors[0].category == QStringLiteral("missing_dependency")); - - checker.toggleMod(*mods[bUuid], {aUuid, bUuid}); - const auto* diagEnabled = checker.diagnosticsFor(aUuid); - REQUIRE(diagEnabled != nullptr); - CHECK(!diagEnabled->hasErrors()); - - QSet dependencySources; - for (const auto& edge : - checker.graph().incomingOfType(PackageId(QStringLiteral("mod.b")), EdgeType::kDependency)) { - dependencySources.insert(edge.source); - } - CHECK(dependencySources == QSet{PackageId(QStringLiteral("mod.a"))}); - - QSet loadAfterTargets; - for (const auto& edge : - checker.graph().edgesOfType(PackageId(QStringLiteral("mod.b")), EdgeType::kLoadAfter)) { - loadAfterTargets.insert(edge.target); - } - CHECK(loadAfterTargets == QSet{PackageId(QStringLiteral("mod.a"))}); + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + + const auto mods = asListed({a, b}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {aUuid, bUuid}); + + checker.toggleMod(*mods[bUuid], {aUuid}); + const auto* diagDisabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagDisabled != nullptr); + CHECK(diagDisabled->hasErrors()); + CHECK(diagDisabled->errors[0].category == + QStringLiteral("missing_dependency")); + + checker.toggleMod(*mods[bUuid], {aUuid, bUuid}); + const auto* diagEnabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagEnabled != nullptr); + CHECK(!diagEnabled->hasErrors()); + + QSet dependencySources; + for (const auto& edge : checker.graph().incomingOfType( + PackageId(QStringLiteral("mod.b")), EdgeType::kDependency)) { + dependencySources.insert(edge.source); + } + CHECK(dependencySources == + QSet{PackageId(QStringLiteral("mod.a"))}); + + QSet loadAfterTargets; + for (const auto& edge : checker.graph().edgesOfType( + PackageId(QStringLiteral("mod.b")), EdgeType::kLoadAfter)) { + loadAfterTargets.insert(edge.target); + } + CHECK(loadAfterTargets == + QSet{PackageId(QStringLiteral("mod.a"))}); } TEST_CASE("ModChecker move mod updates load order") { - std::vector> checkers; - checkers.push_back(std::make_unique()); + std::vector> checkers; + checkers.push_back(std::make_unique()); - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - // b first, then a -> satisfies load_after - const auto mods = asListed({b, a}); - const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); - const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + // b first, then a -> satisfies load_after + const auto mods = asListed({b, a}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); - ModChecker checker(std::move(checkers), settings()); - checker.rebuild(mods, {bUuid, aUuid}); + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {bUuid, aUuid}); - const auto* initial = checker.diagnosticsFor(aUuid); - CHECK((initial == nullptr || !initial->hasWarnings())); + const auto* initial = checker.diagnosticsFor(aUuid); + CHECK((initial == nullptr || !initial->hasWarnings())); - // Move a before b (old_index 1 -> new_index 0) - checker.moveMod(aUuid, 1, 0); + // Move a before b (old_index 1 -> new_index 0) + checker.moveMod(aUuid, 1, 0); - const auto* afterMove = checker.diagnosticsFor(aUuid); - REQUIRE(afterMove != nullptr); - CHECK(afterMove->hasWarnings()); - CHECK(afterMove->warnings[0].category == QStringLiteral("load_order")); + const auto* afterMove = checker.diagnosticsFor(aUuid); + REQUIRE(afterMove != nullptr); + CHECK(afterMove->hasWarnings()); + CHECK(afterMove->warnings[0].category == QStringLiteral("load_order")); } TEST_CASE("ModChecker reorder matches full rebuild") { - std::mt19937 rng(0); - constexpr int kModCount = 12; - std::vector> modsList; - modsList.reserve(kModCount); - for (int i = 0; i < kModCount; ++i) { - modsList.push_back(makeMod(QStringLiteral("mod.%1").arg(i))); + std::mt19937 rng(0); + constexpr int kModCount = 12; + std::vector> modsList; + modsList.reserve(kModCount); + for (int i = 0; i < kModCount; ++i) { + modsList.push_back(makeMod(QStringLiteral("mod.%1").arg(i))); + } + + for (int i = 0; i < kModCount; ++i) { + std::vector others; + for (int j = 0; j < kModCount; ++j) { + if (j != i) { + others.push_back(j); + } } - - for (int i = 0; i < kModCount; ++i) { - std::vector others; - for (int j = 0; j < kModCount; ++j) { - if (j != i) { - others.push_back(j); - } - } - std::ranges::shuffle(others, rng); - modsList[i]->aboutRules.loadAfter.add(QStringLiteral("mod.%1").arg(others[0])); - modsList[i]->aboutRules.loadAfter.add(QStringLiteral("mod.%1").arg(others[1])); - modsList[i]->aboutRules.loadBefore.add(QStringLiteral("mod.%1").arg(others[2])); + std::ranges::shuffle(others, rng); + modsList[i]->aboutRules.loadAfter.add( + QStringLiteral("mod.%1").arg(others[0])); + modsList[i]->aboutRules.loadAfter.add( + QStringLiteral("mod.%1").arg(others[1])); + modsList[i]->aboutRules.loadBefore.add( + QStringLiteral("mod.%1").arg(others[2])); + } + + const auto mods = asListed(modsList); + QStringList order; + for (int i = 0; i < kModCount; ++i) { + order.append(QString::number(i)); + } + + ModChecker incremental(defaultCheckers(), settings()); + incremental.rebuild(mods, order); + + for (int step = 0; step < 30; ++step) { + if (std::uniform_real_distribution(0.0, 1.0)(rng) < 0.5) { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype i = pick(rng); + const qsizetype j = pick(rng); + order.swapItemsAt(i, j); + } else { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype from = pick(rng); + const qsizetype to = pick(rng); + order.insert(to, order.takeAt(from)); } + incremental.reorder(order); - const auto mods = asListed(modsList); - QStringList order; - for (int i = 0; i < kModCount; ++i) { - order.append(QString::number(i)); - } + ModChecker full(defaultCheckers(), settings()); + full.rebuild(mods, order); + + for (const QString& uuid : order) { + const auto* got = incremental.diagnosticsFor(uuid); + const auto* want = full.diagnosticsFor(uuid); + REQUIRE(got != nullptr); + REQUIRE(want != nullptr); - ModChecker incremental(defaultCheckers(), settings()); - incremental.rebuild(mods, order); - - for (int step = 0; step < 30; ++step) { - if (std::uniform_real_distribution(0.0, 1.0)(rng) < 0.5) { - std::uniform_int_distribution pick(0, order.size() - 1); - const qsizetype i = pick(rng); - const qsizetype j = pick(rng); - order.swapItemsAt(i, j); - } else { - std::uniform_int_distribution pick(0, order.size() - 1); - const qsizetype from = pick(rng); - const qsizetype to = pick(rng); - order.insert(to, order.takeAt(from)); - } - incremental.reorder(order); - - ModChecker full(defaultCheckers(), settings()); - full.rebuild(mods, order); - - for (const QString& uuid : order) { - const auto* got = incremental.diagnosticsFor(uuid); - const auto* want = full.diagnosticsFor(uuid); - REQUIRE(got != nullptr); - REQUIRE(want != nullptr); - - CHECK(got->errors == want->errors); - CHECK(got->warnings == want->warnings); - } + CHECK(got->errors == want->errors); + CHECK(got->warnings == want->warnings); } + } } diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp index 8fc7388..e0fd664 100644 --- a/tests/core/checker/test_databases.cpp +++ b/tests/core/checker/test_databases.cpp @@ -1,16 +1,14 @@ -#include - -#include -#include -#include - -#include - #include #include #include #include #include +#include +#include +#include +#include + +#include #include "core/checker/databases.hpp" #include "core/config.hpp" @@ -21,104 +19,111 @@ using namespace px::modrim::checker; namespace { std::string gzipCompress(std::string_view data) { - z_stream stream{}; - deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY); - stream.next_in = reinterpret_cast(const_cast(data.data())); - stream.avail_in = static_cast(data.size()); - - std::string out; - std::vector buffer(16384); - int status = Z_OK; - do { - stream.next_out = reinterpret_cast(buffer.data()); - stream.avail_out = static_cast(buffer.size()); - status = deflate(&stream, Z_FINISH); - out.append(buffer.data(), buffer.size() - stream.avail_out); - } while (status != Z_STREAM_END); - deflateEnd(&stream); - return out; + z_stream stream{}; + deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, + Z_DEFAULT_STRATEGY); + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::vector buffer(16384); + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = deflate(&stream, Z_FINISH); + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + deflateEnd(&stream); + return out; } void writeFile(const QString& path, std::string_view content) { - QFile file(path); - REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); - file.write(content.data(), static_cast(content.size())); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(content.data(), static_cast(content.size())); } } // namespace -TEST_CASE("NoVersionWarningService load_if_exists on missing file returns empty") { - QTemporaryDir dir; - ConfigService config(dir.path()); - QNetworkAccessManager network; - NoVersionWarningService service(config, network); +TEST_CASE( + "NoVersionWarningService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); - CHECK(!service.cacheAgeS().has_value()); - CHECK(service.loadIfExists().isEmpty()); + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); } TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { - QTemporaryDir dir; - const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - writeFile(xmlPath, - "" - "
  • brrainz.harmony
  • " - "
  • unlimitedhugs.hugslib
  • " - ""); - - ConfigService config(dir.path()); - QNetworkAccessManager network; - NoVersionWarningService service(config, network); - - CHECK(service.cacheAgeS().has_value()); - CHECK(*service.cacheAgeS() >= 0.0); - - const auto pids = service.loadIfExists(); - CHECK(pids.size() == 2); - CHECK(pids.contains(PackageId(QStringLiteral("brrainz.harmony")))); - CHECK(pids.contains(PackageId(QStringLiteral("unlimitedhugs.hugslib")))); + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, + "" + "
  • brrainz.harmony
  • " + "
  • unlimitedhugs.hugslib
  • " + "
    "); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(service.cacheAgeS().has_value()); + CHECK(*service.cacheAgeS() >= 0.0); + + const auto pids = service.loadIfExists(); + CHECK(pids.size() == 2); + CHECK(pids.contains(PackageId(QStringLiteral("brrainz.harmony")))); + CHECK(pids.contains(PackageId(QStringLiteral("unlimitedhugs.hugslib")))); } TEST_CASE("NoVersionWarningService parses single-item li") { - QTemporaryDir dir; - const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - writeFile(xmlPath, "
  • lone.mod
  • "); - - ConfigService config(dir.path()); - QNetworkAccessManager network; - NoVersionWarningService service(config, network); - - const auto pids = service.loadIfExists(); - CHECK(pids.size() == 1); - CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "
  • lone.mod
  • "); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + const auto pids = service.loadIfExists(); + CHECK(pids.size() == 1); + CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); } TEST_CASE("NoVersionWarningService handles malformed XML gracefully") { - QTemporaryDir dir; - const QString xmlPath = QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); - writeFile(xmlPath, "not xml at all"); + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "not xml at all"); - ConfigService config(dir.path()); - QNetworkAccessManager network; - NoVersionWarningService service(config, network); + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); - CHECK(service.loadIfExists().isEmpty()); + CHECK(service.loadIfExists().isEmpty()); } -TEST_CASE("UseThisInsteadService load_if_exists on missing file returns empty") { - QTemporaryDir dir; - ConfigService config(dir.path()); - QNetworkAccessManager network; - UseThisInsteadService service(config, network); +TEST_CASE( + "UseThisInsteadService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); - CHECK(!service.cacheAgeS().has_value()); - CHECK(service.loadIfExists().isEmpty()); + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); } TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { - QTemporaryDir dir; - const QString gzPath = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); - const std::string json = R"({ + QTemporaryDir dir; + const QString gzPath = + QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + const std::string json = R"({ "rules": [ { "oldWorkshopId": "123456", @@ -130,34 +135,36 @@ TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { } ] })"; - writeFile(gzPath, gzipCompress(json)); - - ConfigService config(dir.path()); - QNetworkAccessManager network; - UseThisInsteadService service(config, network); - - CHECK(service.cacheAgeS().has_value()); - const auto entries = service.loadIfExists(); - REQUIRE(entries.size() == 1); - REQUIRE(entries.contains(QStringLiteral("123456"))); - - const ReplacementInfo& info = entries.value(QStringLiteral("123456")); - CHECK(info.name == QStringLiteral("New Mod")); - CHECK(info.author == QStringLiteral("Author Guy")); - CHECK(info.packageid == QStringLiteral("author.newmod")); - CHECK(info.pfid == QStringLiteral("654321")); - CHECK(info.supportedversions == QStringList{QStringLiteral("1.4"), QStringLiteral("1.5")}); - CHECK(info.source == QStringLiteral("database")); + writeFile(gzPath, gzipCompress(json)); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(service.cacheAgeS().has_value()); + const auto entries = service.loadIfExists(); + REQUIRE(entries.size() == 1); + REQUIRE(entries.contains(QStringLiteral("123456"))); + + const ReplacementInfo& info = entries.value(QStringLiteral("123456")); + CHECK(info.name == QStringLiteral("New Mod")); + CHECK(info.author == QStringLiteral("Author Guy")); + CHECK(info.packageid == QStringLiteral("author.newmod")); + CHECK(info.pfid == QStringLiteral("654321")); + CHECK(info.supportedversions == + QStringList{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(info.source == QStringLiteral("database")); } TEST_CASE("UseThisInsteadService handles corrupt gzip gracefully") { - QTemporaryDir dir; - const QString gzPath = QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); - writeFile(gzPath, "not gzip data"); + QTemporaryDir dir; + const QString gzPath = + QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + writeFile(gzPath, "not gzip data"); - ConfigService config(dir.path()); - QNetworkAccessManager network; - UseThisInsteadService service(config, network); + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); - CHECK(service.loadIfExists().isEmpty()); + CHECK(service.loadIfExists().isEmpty()); } diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp index 560e4ec..33c0b7e 100644 --- a/tests/core/checker/test_graph.cpp +++ b/tests/core/checker/test_graph.cpp @@ -1,11 +1,10 @@ -#include - -#include - #include #include #include #include +#include + +#include #include "core/checker/graph.hpp" #include "core/models/metadata/structures.hpp" @@ -17,299 +16,340 @@ using namespace px::modrim::checker; namespace { std::shared_ptr makeMod(const QString& packageId) { - auto m = std::make_shared(); - m->packageId = CaseInsensitiveStr(packageId); - m->name = packageId; - return m; + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; + return m; } sort::SortSettings settings(bool useAlternativePackageIds = false) { - sort::SortSettings s; - s.use_community_rules = false; - s.use_alternative_package_ids = useAlternativePackageIds; - return s; + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = useAlternativePackageIds; + return s; } } // namespace TEST_CASE("ConstraintGraph empty graph") { - ConstraintGraph g; - CHECK(g.nodes().isEmpty()); + ConstraintGraph g; + CHECK(g.nodes().isEmpty()); } TEST_CASE("ConstraintGraph build no edges") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.hasNode(PackageId(QStringLiteral("mod.a")))); - CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); - CHECK(g.nodes().size() == 2); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.a")))); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); + CHECK(g.nodes().size() == 2); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); } TEST_CASE("ConstraintGraph dependency edge") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->aboutRules.dependencies.append(dep); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kDependency); - REQUIRE(edges.size() == 1); - const ConstraintEdge edge = *edges.cbegin(); - CHECK(edge.source == PackageId(QStringLiteral("mod.a"))); - CHECK(edge.target == PackageId(QStringLiteral("mod.b"))); - CHECK(edge.type == EdgeType::kDependency); - CHECK(edge.origin == EdgeOrigin::kAboutXml); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kDependency); + REQUIRE(edges.size() == 1); + const ConstraintEdge edge = *edges.cbegin(); + CHECK(edge.source == PackageId(QStringLiteral("mod.a"))); + CHECK(edge.target == PackageId(QStringLiteral("mod.b"))); + CHECK(edge.type == EdgeType::kDependency); + CHECK(edge.origin == EdgeOrigin::kAboutXml); } -TEST_CASE("ConstraintGraph dependency alternative resolution respects setting") { - ConstraintGraph g; - auto consumer = makeMod(QStringLiteral("mod.consumer")); - auto alternative = makeMod(QStringLiteral("mod.alternative")); - DependencyMod dependency; - dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.dependency")); - dependency.alternativePackageIds.add(QStringLiteral("mod.alternative")); - consumer->aboutRules.dependencies.append(dependency); - - QHash> mods{ - {PackageId(QStringLiteral("mod.consumer")), consumer}, - {PackageId(QStringLiteral("mod.alternative")), alternative}, - }; - const QList orderedPids{ - PackageId(QStringLiteral("mod.consumer")), - PackageId(QStringLiteral("mod.alternative")), - }; - - g.build(mods, orderedPids, settings(true)); - QSet enabledTargets; - for (const auto& edge : g.edgesOfType(PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { - enabledTargets.insert(edge.target); - } - CHECK(enabledTargets == QSet{PackageId(QStringLiteral("mod.alternative"))}); - - g.build(mods, orderedPids, settings(false)); - QSet disabledTargets; - for (const auto& edge : g.edgesOfType(PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { - disabledTargets.insert(edge.target); - } - CHECK(disabledTargets == QSet{PackageId(QStringLiteral("mod.dependency"))}); +TEST_CASE( + "ConstraintGraph dependency alternative resolution respects setting") { + ConstraintGraph g; + auto consumer = makeMod(QStringLiteral("mod.consumer")); + auto alternative = makeMod(QStringLiteral("mod.alternative")); + DependencyMod dependency; + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.dependency")); + dependency.alternativePackageIds.add(QStringLiteral("mod.alternative")); + consumer->aboutRules.dependencies.append(dependency); + + QHash> mods{ + {PackageId(QStringLiteral("mod.consumer")), consumer}, + {PackageId(QStringLiteral("mod.alternative")), alternative}, + }; + const QList orderedPids{ + PackageId(QStringLiteral("mod.consumer")), + PackageId(QStringLiteral("mod.alternative")), + }; + + g.build(mods, orderedPids, settings(true)); + QSet enabledTargets; + for (const auto& edge : g.edgesOfType( + PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + enabledTargets.insert(edge.target); + } + CHECK(enabledTargets == + QSet{PackageId(QStringLiteral("mod.alternative"))}); + + g.build(mods, orderedPids, settings(false)); + QSet disabledTargets; + for (const auto& edge : g.edgesOfType( + PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + disabledTargets.insert(edge.target); + } + CHECK(disabledTargets == + QSet{PackageId(QStringLiteral("mod.dependency"))}); } TEST_CASE("ConstraintGraph incompatibility edge") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kIncompatibility); - REQUIRE(edges.size() == 1); - CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), + EdgeType::kIncompatibility); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph load after edge") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadAfter); - REQUIRE(edges.size() == 1); - CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadAfter); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph load before edge") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadBefore); - REQUIRE(edges.size() == 1); - CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadBefore); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); } TEST_CASE("ConstraintGraph neighbors") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto nbrs = g.neighbors(PackageId(QStringLiteral("mod.a"))); - CHECK(nbrs.contains(PackageId(QStringLiteral("mod.b")))); - auto nbrsB = g.neighbors(PackageId(QStringLiteral("mod.b"))); - CHECK(nbrsB.contains(PackageId(QStringLiteral("mod.a")))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto nbrs = g.neighbors(PackageId(QStringLiteral("mod.a"))); + CHECK(nbrs.contains(PackageId(QStringLiteral("mod.b")))); + auto nbrsB = g.neighbors(PackageId(QStringLiteral("mod.b"))); + CHECK(nbrsB.contains(PackageId(QStringLiteral("mod.a")))); } TEST_CASE("ConstraintGraph incoming") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - - auto inc = g.incoming(PackageId(QStringLiteral("mod.b"))); - REQUIRE(inc.size() == 1); - CHECK(inc.cbegin()->source == PackageId(QStringLiteral("mod.a"))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto inc = g.incoming(PackageId(QStringLiteral("mod.b"))); + REQUIRE(inc.size() == 1); + CHECK(inc.cbegin()->source == PackageId(QStringLiteral("mod.a"))); } TEST_CASE("ConstraintGraph remove mod") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); - - g.removeMod(PackageId(QStringLiteral("mod.b"))); - CHECK(!g.hasNode(PackageId(QStringLiteral("mod.b")))); - CHECK(g.outgoing(PackageId(QStringLiteral("mod.a"))).isEmpty()); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); + + g.removeMod(PackageId(QStringLiteral("mod.b"))); + CHECK(!g.hasNode(PackageId(QStringLiteral("mod.b")))); + CHECK(g.outgoing(PackageId(QStringLiteral("mod.a"))).isEmpty()); } TEST_CASE("ConstraintGraph update order") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); - - g.updateOrder({PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 1); - CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 0); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); + + g.updateOrder( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 1); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 0); } TEST_CASE("ConstraintGraph cycle detection") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto c = makeMod(QStringLiteral("mod.c")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); - c->aboutRules.loadAfter.add(QStringLiteral("mod.a")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - {PackageId(QStringLiteral("mod.c")), c}, - }; - const QList order{ - PackageId(QStringLiteral("mod.a")), - PackageId(QStringLiteral("mod.b")), - PackageId(QStringLiteral("mod.c")), - }; - g.build(mods, order, settings()); - auto cycles = g.findCycles(); - REQUIRE(cycles.size() >= 1); - QSet cyclePids; - for (const auto& cycle : cycles) { - for (const auto& p : cycle) { - cyclePids.insert(p); - } - } - for (const auto& name : {QStringLiteral("mod.a"), QStringLiteral("mod.b"), QStringLiteral("mod.c")}) { - CHECK(cyclePids.contains(PackageId(name))); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + auto cycles = g.findCycles(); + REQUIRE(cycles.size() >= 1); + QSet cyclePids; + for (const auto& cycle : cycles) { + for (const auto& p : cycle) { + cyclePids.insert(p); } + } + for (const auto& name : {QStringLiteral("mod.a"), QStringLiteral("mod.b"), + QStringLiteral("mod.c")}) { + CHECK(cyclePids.contains(PackageId(name))); + } } TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto c = makeMod(QStringLiteral("mod.c")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); - b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); - c->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - {PackageId(QStringLiteral("mod.c")), c}, - }; - const QList order{ - PackageId(QStringLiteral("mod.a")), - PackageId(QStringLiteral("mod.b")), - PackageId(QStringLiteral("mod.c")), - }; - g.build(mods, order, settings()); - - auto cycles = g.findCycles(); - REQUIRE(cycles.size() == 1); - CHECK(QSet(cycles[0].cbegin(), cycles[0].cend()) == - QSet(order.cbegin(), order.cend())); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + + auto cycles = g.findCycles(); + REQUIRE(cycles.size() == 1); + CHECK(QSet(cycles[0].cbegin(), cycles[0].cend()) == + QSet(order.cbegin(), order.cend())); } TEST_CASE("ConstraintGraph no cycle") { - ConstraintGraph g; - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - QHash> mods{ - {PackageId(QStringLiteral("mod.a")), a}, - {PackageId(QStringLiteral("mod.b")), b}, - }; - g.build(mods, {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, settings()); - auto cycles = g.findCycles(); - CHECK(cycles.isEmpty()); + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + auto cycles = g.findCycles(); + CHECK(cycles.isEmpty()); } TEST_CASE("ConstraintGraph cycle detection handles long chain") { - constexpr int kCount = 5000; - QList nodes; - nodes.reserve(kCount); - QHash> mods; - for (int i = 0; i < kCount; ++i) { - PackageId pid(QStringLiteral("mod.%1").arg(i)); - nodes.append(pid); - mods.insert(pid, makeMod(pid.str())); - } - for (int i = 0; i < kCount - 1; ++i) { - mods[nodes[i]]->aboutRules.loadAfter.add(nodes[i + 1].str()); - } - ConstraintGraph g; - g.build(mods, nodes, settings()); - CHECK(g.findCycles().isEmpty()); + constexpr int kCount = 5000; + QList nodes; + nodes.reserve(kCount); + QHash> mods; + for (int i = 0; i < kCount; ++i) { + PackageId pid(QStringLiteral("mod.%1").arg(i)); + nodes.append(pid); + mods.insert(pid, makeMod(pid.str())); + } + for (int i = 0; i < kCount - 1; ++i) { + mods[nodes[i]]->aboutRules.loadAfter.add(nodes[i + 1].str()); + } + ConstraintGraph g; + g.build(mods, nodes, settings()); + CHECK(g.findCycles().isEmpty()); } diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp index 0cc8f95..9aa8efe 100644 --- a/tests/core/checker/test_issues.cpp +++ b/tests/core/checker/test_issues.cpp @@ -1,11 +1,10 @@ -#include - -#include - #include #include #include #include +#include + +#include #include "core/checker/graph.hpp" #include "core/checker/issues.hpp" @@ -20,288 +19,301 @@ using namespace px::modrim::checker; namespace { std::shared_ptr makeMod(const QString& packageId) { - auto m = std::make_shared(); - m->packageId = CaseInsensitiveStr(packageId); - m->name = packageId; - return m; + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; + return m; } sort::SortSettings defaultSettings() { - sort::SortSettings s; - s.use_community_rules = false; - s.use_alternative_package_ids = true; - s.check_missing_dependencies = true; - return s; + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; } -// Holds the storage referenced by a CheckContext so tests can construct one simply. +// Holds the storage referenced by a CheckContext so tests can construct one +// simply. struct ContextStorage { - QHash> activeMods; - QList orderedPids; - QHash pidToIndex; - ConstraintGraph graph; - sort::SortSettings settings = defaultSettings(); - QString targetVersion = QStringLiteral("1.5"); - QSet noVersionWarning; - QHash useThisInstead; - QList> cycles; - - CheckContext ctx() const { - return CheckContext{ - .activeMods = activeMods, - .orderedPids = orderedPids, - .pidToIndex = pidToIndex, - .graph = graph, - .settings = settings, - .targetVersion = targetVersion, - .noVersionWarning = noVersionWarning, - .useThisInstead = useThisInstead, - .cycles = cycles, - }; - } + QHash> activeMods; + QList orderedPids; + QHash pidToIndex; + ConstraintGraph graph; + sort::SortSettings settings = defaultSettings(); + QString targetVersion = QStringLiteral("1.5"); + QSet noVersionWarning; + QHash useThisInstead; + QList> cycles; + + CheckContext ctx() const { + return CheckContext{ + .activeMods = activeMods, + .orderedPids = orderedPids, + .pidToIndex = pidToIndex, + .graph = graph, + .settings = settings, + .targetVersion = targetVersion, + .noVersionWarning = noVersionWarning, + .useThisInstead = useThisInstead, + .cycles = cycles, + }; + } }; -ContextStorage makeStorage(QHash> mods, - std::optional> ordered = std::nullopt, - const sort::SortSettings* settings = nullptr, - const sort::CommunityRules* communityRules = nullptr) { - ContextStorage storage; - storage.activeMods = std::move(mods); - storage.orderedPids = ordered ? *ordered : storage.activeMods.keys(); - if (settings != nullptr) { - storage.settings = *settings; - } - for (qsizetype i = 0; i < storage.orderedPids.size(); ++i) { - storage.pidToIndex.insert(storage.orderedPids[i], static_cast(i)); - } - storage.graph.build(storage.activeMods, storage.orderedPids, storage.settings, communityRules); - return storage; +ContextStorage makeStorage( + QHash> mods, + std::optional> ordered = std::nullopt, + const sort::SortSettings* settings = nullptr, + const sort::CommunityRules* communityRules = nullptr) { + ContextStorage storage; + storage.activeMods = std::move(mods); + storage.orderedPids = ordered ? *ordered : storage.activeMods.keys(); + if (settings != nullptr) { + storage.settings = *settings; + } + for (qsizetype i = 0; i < storage.orderedPids.size(); ++i) { + storage.pidToIndex.insert(storage.orderedPids[i], static_cast(i)); + } + storage.graph.build(storage.activeMods, storage.orderedPids, storage.settings, + communityRules); + return storage; } } // namespace TEST_CASE("DependencyIssueChecker no issues when mod has no dependencies") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); - DependencyIssueChecker checker; - CHECK(!checker.shouldCheck(*a, storage.ctx())); - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + DependencyIssueChecker checker; + CHECK(!checker.shouldCheck(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("DependencyIssueChecker reports missing dependency") { - auto a = makeMod(QStringLiteral("mod.a")); - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->aboutRules.dependencies.append(dep); - auto storage = makeStorage({{a->packageId, a}}); - - DependencyIssueChecker checker; - CHECK(checker.shouldCheck(*a, storage.ctx())); - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("missing_dependency")); - CHECK(issues[0].severity == IssueSeverity::kError); + auto a = makeMod(QStringLiteral("mod.a")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}}); + + DependencyIssueChecker checker; + CHECK(checker.shouldCheck(*a, storage.ctx())); + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("missing_dependency")); + CHECK(issues[0].severity == IssueSeverity::kError); } TEST_CASE("DependencyIssueChecker satisfied dependency yields no issues") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - DependencyMod dep; - dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); - a->aboutRules.dependencies.append(dep); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); - - DependencyIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + + DependencyIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("IncompatibilityIssueChecker no incompatibilities") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); - IncompatibilityIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + IncompatibilityIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("IncompatibilityIssueChecker self-declared incompatibility") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); - - IncompatibilityIssueChecker checker; - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("incompatibility")); - CHECK(issues[0].severity == IssueSeverity::kError); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].severity == IssueSeverity::kError); } TEST_CASE("IncompatibilityIssueChecker reverse-declared incompatibility") { - auto declarer = makeMod(QStringLiteral("mod.declarer")); - auto target = makeMod(QStringLiteral("mod.target")); - declarer->aboutRules.incompatibleWith.add(QStringLiteral("mod.target")); - auto storage = makeStorage({{declarer->packageId, declarer}, {target->packageId, target}}); - - IncompatibilityIssueChecker checker; - auto issues = checker.check(*target, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("incompatibility")); - CHECK(issues[0].relatedPackageIds == QList{PackageId(QStringLiteral("mod.declarer"))}); + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(QStringLiteral("mod.target")); + auto storage = makeStorage( + {{declarer->packageId, declarer}, {target->packageId, target}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].relatedPackageIds == + QList{PackageId(QStringLiteral("mod.declarer"))}); } -TEST_CASE("IncompatibilityIssueChecker reverse-declared deduplicated across origins") { - auto declarer = makeMod(QStringLiteral("mod.declarer")); - auto target = makeMod(QStringLiteral("mod.target")); - const PackageId declarerPid(QStringLiteral("mod.declarer")); - const PackageId targetPid(QStringLiteral("mod.target")); - declarer->aboutRules.incompatibleWith.add(targetPid.str()); - - sort::SortSettings settings; - settings.use_community_rules = true; - settings.use_alternative_package_ids = true; - settings.check_missing_dependencies = true; - - sort::CommunityRule rule; - rule.packageId = declarerPid; - rule.incompatibleWith.add(targetPid.str()); - sort::CommunityRules cr{{declarerPid, rule}}; - - auto storage = makeStorage({{declarerPid, declarer}, {targetPid, target}}, - std::nullopt, - &settings, - &cr); - - auto incomingEdges = storage.graph.incomingOfType(targetPid, EdgeType::kIncompatibility); - QSet origins; - for (const auto& edge : incomingEdges) { - origins.insert(edge.origin); - } - CHECK(origins == QSet{EdgeOrigin::kAboutXml, EdgeOrigin::kCommunityRules}); - - auto issues = IncompatibilityIssueChecker().check(*target, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].relatedPackageIds == QList{declarerPid}); +TEST_CASE( + "IncompatibilityIssueChecker reverse-declared deduplicated across " + "origins") { + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + const PackageId declarerPid(QStringLiteral("mod.declarer")); + const PackageId targetPid(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(targetPid.str()); + + sort::SortSettings settings; + settings.use_community_rules = true; + settings.use_alternative_package_ids = true; + settings.check_missing_dependencies = true; + + sort::CommunityRule rule; + rule.packageId = declarerPid; + rule.incompatibleWith.add(targetPid.str()); + sort::CommunityRules cr{{declarerPid, rule}}; + + auto storage = makeStorage({{declarerPid, declarer}, {targetPid, target}}, + std::nullopt, &settings, &cr); + + auto incomingEdges = + storage.graph.incomingOfType(targetPid, EdgeType::kIncompatibility); + QSet origins; + for (const auto& edge : incomingEdges) { + origins.insert(edge.origin); + } + CHECK(origins == + QSet{EdgeOrigin::kAboutXml, EdgeOrigin::kCommunityRules}); + + auto issues = IncompatibilityIssueChecker().check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].relatedPackageIds == QList{declarerPid}); } TEST_CASE("LoadOrderIssueChecker no violations when order satisfies rules") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); - - LoadOrderIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.a"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("LoadOrderIssueChecker load-after violation reported") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); - const QList ordered{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}; - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); - - LoadOrderIssueChecker checker; - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("load_order")); - CHECK(issues[0].severity == IssueSeverity::kWarning); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("LoadOrderIssueChecker load-before violation reported") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); - // A should load before B, but appears after - const QList ordered{PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}; - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); - - LoadOrderIssueChecker checker; - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("load_order")); - CHECK(issues[0].severity == IssueSeverity::kWarning); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); + // A should load before B, but appears after + const QList ordered{PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.a"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("CycleIssueChecker no cycles") { - auto a = makeMod(QStringLiteral("mod.a")); - auto storage = makeStorage({{a->packageId, a}}); - CycleIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); + CycleIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("CycleIssueChecker reports cycle membership") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); - storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; - - CycleIssueChecker checker; - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("cycle")); - CHECK(issues[0].severity == IssueSeverity::kWarning); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + storage.cycles = { + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("cycle")); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("CycleIssueChecker non-member gets no issue") { - auto a = makeMod(QStringLiteral("mod.a")); - auto b = makeMod(QStringLiteral("mod.b")); - auto c = makeMod(QStringLiteral("mod.c")); - auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}, {c->packageId, c}}); - storage.cycles = {{PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; - - CycleIssueChecker checker; - CHECK(checker.check(*c, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + auto storage = + makeStorage({{a->packageId, a}, {b->packageId, b}, {c->packageId, c}}); + storage.cycles = { + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + CHECK(checker.check(*c, storage.ctx()).isEmpty()); } TEST_CASE("GameVersionIssueChecker no versions declared is not checked") { - auto a = makeMod(QStringLiteral("mod.a")); - a->supportedVersions.clear(); - auto storage = makeStorage({{a->packageId, a}}); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions.clear(); + auto storage = makeStorage({{a->packageId, a}}); - GameVersionIssueChecker checker; - CHECK(!checker.shouldCheck(*a, storage.ctx())); - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + GameVersionIssueChecker checker; + CHECK(!checker.shouldCheck(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("GameVersionIssueChecker matching version yields no issues") { - auto a = makeMod(QStringLiteral("mod.a")); - a->supportedVersions = {QStringLiteral("1.5")}; - auto storage = makeStorage({{a->packageId, a}}); - storage.targetVersion = QStringLiteral("1.5"); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.5")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); - GameVersionIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("GameVersionIssueChecker mismatch reported as warning") { - auto a = makeMod(QStringLiteral("mod.a")); - a->supportedVersions = {QStringLiteral("1.4")}; - auto storage = makeStorage({{a->packageId, a}}); - storage.targetVersion = QStringLiteral("1.5"); - - GameVersionIssueChecker checker; - auto issues = checker.check(*a, storage.ctx()); - REQUIRE(issues.size() == 1); - CHECK(issues[0].category == QStringLiteral("version_mismatch")); - CHECK(issues[0].severity == IssueSeverity::kWarning); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + + GameVersionIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("version_mismatch")); + CHECK(issues[0].severity == IssueSeverity::kWarning); } TEST_CASE("GameVersionIssueChecker suppressed by no_version_warning") { - auto a = makeMod(QStringLiteral("mod.a")); - a->supportedVersions = {QStringLiteral("1.4")}; - auto storage = makeStorage({{a->packageId, a}}); - storage.targetVersion = QStringLiteral("1.5"); - storage.noVersionWarning.insert(PackageId(QStringLiteral("mod.a"))); - - GameVersionIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + storage.noVersionWarning.insert(PackageId(QStringLiteral("mod.a"))); + + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } TEST_CASE("ReplacementIssueChecker no replacement without published file id") { - auto a = makeMod(QStringLiteral("mod.a")); - auto storage = makeStorage({{a->packageId, a}}); - ReplacementIssueChecker checker; - CHECK(checker.check(*a, storage.ctx()).isEmpty()); + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); + ReplacementIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); } diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp index 1111ea0..0d7285c 100644 --- a/tests/core/downloads/test_manager.cpp +++ b/tests/core/downloads/test_manager.cpp @@ -1,22 +1,22 @@ -#include - +#include +#include +#include +#include +#include #include #include #include #include #include -#include -#include -#include -#include -#include +#include + +#include "../test_support.hpp" #include "core/downloads/downloader.hpp" #include "core/downloads/manager.hpp" #include "core/downloads/types.hpp" #include "core/models/metadata/structures.hpp" -#include "../test_support.hpp" namespace { @@ -28,146 +28,161 @@ using px::modrim::ListedModPtr; using px::modrim::Result; using test_support::runSync; -// Claims ids that start with `prefix`, and records each batch it was asked to download. +// Claims ids that start with `prefix`, and records each batch it was asked to +// download. class FakeDownloader : public Downloader { -public: - FakeDownloader(QString name, QString label, QString prefix, DownloadManager& manager) - : Downloader(std::move(name), std::move(label), manager), m_prefix(std::move(prefix)) {} - - bool isDownloading() const override { return m_busy; } - QSet activeIds() const override { return {}; } - bool accepts(const QString& modId) const override { return modId.startsWith(m_prefix); } - - std::optional updatableId(const ListedMod& mod) const override { - const auto& path = mod.modPath(); - if (!path) { - return std::nullopt; - } - const QString id = QFileInfo(*path).fileName(); - return accepts(id) ? std::optional(id) : std::nullopt; - } - - std::optional lastSynced(const ListedMod& mod) const override { - return updatableId(mod) ? std::optional(1.0) : std::nullopt; + public: + FakeDownloader(QString name, QString label, QString prefix, + DownloadManager& manager) + : Downloader(std::move(name), std::move(label), manager), + m_prefix(std::move(prefix)) {} + + bool isDownloading() const override { return m_busy; } + QSet activeIds() const override { return {}; } + bool accepts(const QString& modId) const override { + return modId.startsWith(m_prefix); + } + + std::optional updatableId(const ListedMod& mod) const override { + const auto& path = mod.modPath(); + if (!path) { + return std::nullopt; } - - QCoro::Task> downloadMods(QStringList modIds) override { - calls.push_back(modIds); - m_busy = true; - busyChanged.notify(true); - m_busy = false; - busyChanged.notify(false); - DownloadResult result{.succeeded = modIds, .failed = {}, .changed = {}}; - downloadFinished.notify(result); - co_return result; - } - - void cancel() override { cancelled = true; } - - std::vector calls; - bool cancelled = false; - -private: - QString m_prefix; - bool m_busy = false; + const QString id = QFileInfo(*path).fileName(); + return accepts(id) ? std::optional(id) : std::nullopt; + } + + std::optional lastSynced(const ListedMod& mod) const override { + return updatableId(mod) ? std::optional(1.0) : std::nullopt; + } + + QCoro::Task> downloadMods( + QStringList modIds) override { + calls.push_back(modIds); + m_busy = true; + busyChanged.notify(true); + m_busy = false; + busyChanged.notify(false); + DownloadResult result{.succeeded = modIds, .failed = {}, .changed = {}}; + downloadFinished.notify(result); + co_return result; + } + + void cancel() override { cancelled = true; } + + std::vector calls; + bool cancelled = false; + + private: + QString m_prefix; + bool m_busy = false; }; class FakeSteam : public FakeDownloader { -public: - explicit FakeSteam(DownloadManager& manager) : FakeDownloader("steam", "Steam", "s", manager) {} + public: + explicit FakeSteam(DownloadManager& manager) + : FakeDownloader("steam", "Steam", "s", manager) {} }; class FakeGithub : public FakeDownloader { -public: - explicit FakeGithub(DownloadManager& manager) : FakeDownloader("github", "Github", "g", manager) {} + public: + explicit FakeGithub(DownloadManager& manager) + : FakeDownloader("github", "Github", "g", manager) {} }; ListedModPtr makeMod(const QString& folder) { - auto mod = std::make_shared(); - REQUIRE(mod->setModPath(QStringLiteral("/mods/%1").arg(folder)).has_value()); - return mod; + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(QStringLiteral("/mods/%1").arg(folder)).has_value()); + return mod; } } // namespace TEST_CASE("empty manager offers nothing", "[downloads][manager]") { - DownloadManager manager; - REQUIRE_FALSE(manager.available()); - REQUIRE(manager.updatableIds({makeMod("s1")}).isEmpty()); - REQUIRE(manager.sourceOf(*makeMod("s1")) == nullptr); + DownloadManager manager; + REQUIRE_FALSE(manager.available()); + REQUIRE(manager.updatableIds({makeMod("s1")}).isEmpty()); + REQUIRE(manager.sourceOf(*makeMod("s1")) == nullptr); } -TEST_CASE("download routes ids to the accepting downloader", "[downloads][manager]") { - DownloadManager manager; - FakeSteam steam(manager); - FakeGithub github(manager); - manager.registerDownloader(steam); - manager.registerDownloader(github); - - auto result = runSync(manager.downloadMods({"s1", "g1", "s2", "s1"})); - - REQUIRE(result.has_value()); - REQUIRE(steam.calls == std::vector{{"s1", "s2"}}); - REQUIRE(github.calls == std::vector{{"g1"}}); - QStringList succeeded = result->succeeded; - succeeded.sort(); - REQUIRE(succeeded == QStringList{"g1", "s1", "s2"}); +TEST_CASE("download routes ids to the accepting downloader", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + + auto result = runSync(manager.downloadMods({"s1", "g1", "s2", "s1"})); + + REQUIRE(result.has_value()); + REQUIRE(steam.calls == std::vector{{"s1", "s2"}}); + REQUIRE(github.calls == std::vector{{"g1"}}); + QStringList succeeded = result->succeeded; + succeeded.sort(); + REQUIRE(succeeded == QStringList{"g1", "s1", "s2"}); } -TEST_CASE("unclaimed id is rejected before anything starts", "[downloads][manager]") { - DownloadManager manager; - FakeSteam steam(manager); - manager.registerDownloader(steam); +TEST_CASE("unclaimed id is rejected before anything starts", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + manager.registerDownloader(steam); - auto result = runSync(manager.downloadMods({"s1", "x1"})); + auto result = runSync(manager.downloadMods({"s1", "x1"})); - REQUIRE_FALSE(result.has_value()); - REQUIRE(result.error().message.contains("x1")); - REQUIRE(steam.calls.empty()); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("x1")); + REQUIRE(steam.calls.empty()); } -TEST_CASE("source and ids come from the owning downloader", "[downloads][manager]") { - DownloadManager manager; - FakeSteam steam(manager); - FakeGithub github(manager); - manager.registerDownloader(steam); - manager.registerDownloader(github); - const auto g1 = makeMod("g1"); - const auto other = makeMod("other"); - const QList mods{makeMod("s1"), g1, other}; - - const Downloader* source = manager.sourceOf(*g1); - REQUIRE(source != nullptr); - REQUIRE(source->label() == "Github"); - REQUIRE(manager.updatableIds(mods) == QStringList{"s1", "g1"}); - REQUIRE_FALSE(manager.lastSynced(*other).has_value()); +TEST_CASE("source and ids come from the owning downloader", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + const auto g1 = makeMod("g1"); + const auto other = makeMod("other"); + const QList mods{makeMod("s1"), g1, other}; + + const Downloader* source = manager.sourceOf(*g1); + REQUIRE(source != nullptr); + REQUIRE(source->label() == "Github"); + REQUIRE(manager.updatableIds(mods) == QStringList{"s1", "g1"}); + REQUIRE_FALSE(manager.lastSynced(*other).has_value()); } -TEST_CASE("busy reflects any downloader and cancel stops the rest", "[downloads][manager]") { - DownloadManager manager; - FakeSteam steam(manager); - FakeGithub github(manager); - manager.registerDownloader(steam); - manager.registerDownloader(github); - std::vector busy; - manager.busyChanged.connect([&busy](const bool& value) { busy.push_back(value); }); - - steam.downloadFinished.connect([&manager](const DownloadResult&) { manager.cancel(); }); - auto result = runSync(manager.downloadMods({"s1", "g1"})); - - REQUIRE(result.has_value()); - REQUIRE(github.calls.empty()); - REQUIRE(busy == std::vector{true, false}); +TEST_CASE("busy reflects any downloader and cancel stops the rest", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + std::vector busy; + manager.busyChanged.connect( + [&busy](const bool& value) { busy.push_back(value); }); + + steam.downloadFinished.connect( + [&manager](const DownloadResult&) { manager.cancel(); }); + auto result = runSync(manager.downloadMods({"s1", "g1"})); + + REQUIRE(result.has_value()); + REQUIRE(github.calls.empty()); + REQUIRE(busy == std::vector{true, false}); } -TEST_CASE("names are keyed by source id, not workshop id", "[downloads][manager]") { - DownloadManager manager; - FakeGithub github(manager); - manager.registerDownloader(github); - const auto mod = makeMod("g1"); - mod->name = QStringLiteral("Git Mod"); +TEST_CASE("names are keyed by source id, not workshop id", + "[downloads][manager]") { + DownloadManager manager; + FakeGithub github(manager); + manager.registerDownloader(github); + const auto mod = makeMod("g1"); + mod->name = QStringLiteral("Git Mod"); - const QHash names = manager.namesById({mod, makeMod("s1")}); + const QHash names = manager.namesById({mod, makeMod("s1")}); - REQUIRE(names == QHash{{"g1", "Git Mod"}}); + REQUIRE(names == QHash{{"g1", "Git Mod"}}); } diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp index 68a78f1..3aeefd3 100644 --- a/tests/core/downloads/test_steam.cpp +++ b/tests/core/downloads/test_steam.cpp @@ -1,7 +1,14 @@ #ifdef PX_HAS_STEAM -#include - +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -15,17 +22,10 @@ #include #include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include + +#include +#include #include "../test_support.hpp" #include "core/config.hpp" @@ -57,647 +57,680 @@ using test_support::TestApplication; // Thread-safe flag with a timeout-aware wait. class Flag { -public: - void set() { - { - std::lock_guard lock(m_mutex); - m_value = true; - } - m_cv.notify_all(); - } - - void clear() { - std::lock_guard lock(m_mutex); - m_value = false; - } - - bool isSet() const { - std::lock_guard lock(m_mutex); - return m_value; - } - - bool waitFor(std::chrono::milliseconds timeout) { - std::unique_lock lock(m_mutex); - return m_cv.wait_for(lock, timeout, [this] { return m_value; }); + public: + void set() { + { + std::lock_guard lock(m_mutex); + m_value = true; } - -private: - mutable std::mutex m_mutex; - std::condition_variable m_cv; - bool m_value = false; + m_cv.notify_all(); + } + + void clear() { + std::lock_guard lock(m_mutex); + m_value = false; + } + + bool isSet() const { + std::lock_guard lock(m_mutex); + return m_value; + } + + bool waitFor(std::chrono::milliseconds timeout) { + std::unique_lock lock(m_mutex); + return m_cv.wait_for(lock, timeout, [this] { return m_value; }); + } + + private: + mutable std::mutex m_mutex; + std::condition_variable m_cv; + bool m_value = false; }; // Drives an event loop until `task` co_returns. Fails if `timeout` expires. template -QCoro::Task drive(QCoro::Task task, std::optional& out, QEventLoop& loop) { - out.emplace(co_await std::move(task)); - loop.quit(); +QCoro::Task drive(QCoro::Task task, std::optional& out, + QEventLoop& loop) { + out.emplace(co_await std::move(task)); + loop.quit(); } template T runLoop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { - std::optional out; - QEventLoop loop; - QTimer timer; - timer.setSingleShot(true); - QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); - timer.start(timeout); - - auto driver = drive(std::move(task), out, loop); - if (!out.has_value()) { - loop.exec(); - } - REQUIRE(out.has_value()); - return std::move(*out); + std::optional out; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); + + auto driver = drive(std::move(task), out, loop); + if (!out.has_value()) { + loop.exec(); + } + REQUIRE(out.has_value()); + return std::move(*out); } -inline QCoro::Task driveVoid(QCoro::Task task, bool& done, QEventLoop& loop) { - co_await std::move(task); - done = true; - loop.quit(); +inline QCoro::Task driveVoid(QCoro::Task task, bool& done, + QEventLoop& loop) { + co_await std::move(task); + done = true; + loop.quit(); } -inline void runLoopVoid(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { - bool done = false; - QEventLoop loop; - QTimer timer; - timer.setSingleShot(true); - QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); - timer.start(timeout); +inline void runLoopVoid(QCoro::Task task, + std::chrono::milliseconds timeout = 10s) { + bool done = false; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); - auto driver = driveVoid(std::move(task), done, loop); - if (!done) { - loop.exec(); - } - REQUIRE(done); + auto driver = driveVoid(std::move(task), done, loop); + if (!done) { + loop.exec(); + } + REQUIRE(done); } class FakeClient : public WorkshopClient { -public: - struct Call { - std::vector ids; - QString root; - int workers = 0; - }; + public: + struct Call { + std::vector ids; + QString root; + int workers = 0; + }; + + QHash errors; + QHash> gates; + QSet networkFailures; + std::shared_ptr blocked = std::make_shared(); + + std::vector calls; + + std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + calls.push_back(Call{ + .ids = std::vector(ids.begin(), ids.end()), + .root = QString::fromStdWString(root.wstring()), + .workers = + static_cast(options.parallel_items * options.threads_per_item), + }); - QHash errors; - QHash> gates; - QSet networkFailures; - std::shared_ptr blocked = std::make_shared(); - - std::vector calls; - - std::vector download(std::span ids, const std::filesystem::path& root, - const pxsteamdl::Options& options) override { - calls.push_back(Call{ - .ids = std::vector(ids.begin(), ids.end()), - .root = QString::fromStdWString(root.wstring()), - .workers = static_cast(options.parallel_items * options.threads_per_item), - }); - - std::vector results; - results.reserve(ids.size()); - for (const auto id : ids) { - if (options.on_resolved) { - pxsteamdl::ItemInfo info; - info.item_id = id; - info.title = "Title " + std::to_string(id); - options.on_resolved(info); - } - if (options.on_progress) { - pxsteamdl::Progress p; - p.item_id = id; - p.unpacked_bytes = 5; - p.unpacked_total = 10; - options.on_progress(p); - } - if (auto gate = gates.value(id)) { - blocked->set(); - gate->waitFor(5s); - } - - const bool cancelled = options.stop.stop_requested(); - const QString errorText = cancelled ? QStringLiteral("cancelled") : errors.value(id); - if (errorText.isEmpty()) { - std::error_code ec; - std::filesystem::create_directories(root / std::to_string(id), ec); - } - - pxsteamdl::ErrorKind kind = pxsteamdl::ErrorKind::kNone; - if (networkFailures.contains(id)) { - kind = pxsteamdl::ErrorKind::kNetwork; - } else if (cancelled) { - kind = pxsteamdl::ErrorKind::kCancelled; - } else if (!errorText.isEmpty()) { - kind = pxsteamdl::ErrorKind::kOther; - } - - results.push_back(pxsteamdl::Result{ - .item_id = id, - .title = "Title " + std::to_string(id), - .path = root / std::to_string(id), - .error = errorText.toStdString(), - .error_kind = kind, - .downloaded_bytes = 0, - .unpacked_bytes = 0, - }); - } - return results; + std::vector results; + results.reserve(ids.size()); + for (const auto id : ids) { + if (options.on_resolved) { + pxsteamdl::ItemInfo info; + info.item_id = id; + info.title = "Title " + std::to_string(id); + options.on_resolved(info); + } + if (options.on_progress) { + pxsteamdl::Progress p; + p.item_id = id; + p.unpacked_bytes = 5; + p.unpacked_total = 10; + options.on_progress(p); + } + if (auto gate = gates.value(id)) { + blocked->set(); + gate->waitFor(5s); + } + + const bool cancelled = options.stop.stop_requested(); + const QString errorText = + cancelled ? QStringLiteral("cancelled") : errors.value(id); + if (errorText.isEmpty()) { + std::error_code ec; + std::filesystem::create_directories(root / std::to_string(id), ec); + } + + pxsteamdl::ErrorKind kind = pxsteamdl::ErrorKind::kNone; + if (networkFailures.contains(id)) { + kind = pxsteamdl::ErrorKind::kNetwork; + } else if (cancelled) { + kind = pxsteamdl::ErrorKind::kCancelled; + } else if (!errorText.isEmpty()) { + kind = pxsteamdl::ErrorKind::kOther; + } + + results.push_back(pxsteamdl::Result{ + .item_id = id, + .title = "Title " + std::to_string(id), + .path = root / std::to_string(id), + .error = errorText.toStdString(), + .error_kind = kind, + .downloaded_bytes = 0, + .unpacked_bytes = 0, + }); } + return results; + } }; class DyingClient : public WorkshopClient { -public: - std::vector download(std::span, const std::filesystem::path&, - const pxsteamdl::Options&) override { - throw std::runtime_error("session lost"); - } + public: + std::vector download(std::span, + const std::filesystem::path&, + const pxsteamdl::Options&) override { + throw std::runtime_error("session lost"); + } }; struct Service { - explicit Service(std::deque> clients, bool local = true) - : configDir(), - config(configDir.path()), - manager(), - localPath(local ? configDir.path() + "/Mods" : QString()), - clients(std::move(clients)) { - SteamDownloaderDeps deps{ - .manager = manager, - .config = config, - .localModsPath = [this] { return localPath; }, - .installedMods = [this] { return mods; }, - .clientFactory = [this](LoginOptions opts) { return login(std::move(opts)); }, - }; - steam = std::make_unique(std::move(deps)); - manager.registerDownloader(*steam); - } - - QCoro::Task>> login(LoginOptions opts) { - logins.push_back(opts); - if (clients.empty()) { - co_return std::unexpected(px::modrim::Error{QStringLiteral("logon denied")}); - } - auto client = clients.front(); - clients.pop_front(); - co_return client; + explicit Service(std::deque> clients, + bool local = true) + : configDir(), + config(configDir.path()), + manager(), + localPath(local ? configDir.path() + "/Mods" : QString()), + clients(std::move(clients)) { + SteamDownloaderDeps deps{ + .manager = manager, + .config = config, + .localModsPath = [this] { return localPath; }, + .installedMods = [this] { return mods; }, + .clientFactory = + [this](LoginOptions opts) { return login(std::move(opts)); }, + }; + steam = std::make_unique(std::move(deps)); + manager.registerDownloader(*steam); + } + + QCoro::Task>> login( + LoginOptions opts) { + logins.push_back(opts); + if (clients.empty()) { + co_return std::unexpected( + px::modrim::Error{QStringLiteral("logon denied")}); } - - QTemporaryDir configDir; - ConfigService config; - DownloadManager manager; - QString localPath; - QList mods; - - std::vector logins; - std::deque> clients; - std::unique_ptr steam; + auto client = clients.front(); + clients.pop_front(); + co_return client; + } + + QTemporaryDir configDir; + ConfigService config; + DownloadManager manager; + QString localPath; + QList mods; + + std::vector logins; + std::deque> clients; + std::unique_ptr steam; }; ListedModPtr makeModAt(const QString& path, const QString& pfid = {}) { - QDir().mkpath(path); - if (!pfid.isEmpty()) { - QDir().mkpath(path + "/About"); - test_support::writeBytes(path + "/About/PublishedFileId.txt", pfid.toStdString()); - } - auto mod = std::make_shared(); - REQUIRE(mod->setModPath(path).has_value()); - return mod; + QDir().mkpath(path); + if (!pfid.isEmpty()) { + QDir().mkpath(path + "/About"); + test_support::writeBytes(path + "/About/PublishedFileId.txt", + pfid.toStdString()); + } + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(path).has_value()); + return mod; } std::optional syncedOf(Service& svc, const QString& pid) { - auto mod = std::make_shared(); - REQUIRE(mod->setModPath(svc.localPath + "/" + pid).has_value()); - return svc.steam->lastSynced(*mod); + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(svc.localPath + "/" + pid).has_value()); + return svc.steam->lastSynced(*mod); } } // namespace TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client, client}); - - REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); - svc.steam->settings().update(SteamSettings{ - .auto_update_hours = 0, - .parallel_items = 8, - .threads_per_item = 1, - .proxy = " socks5h://host:1080 ", - .connect_timeout = 3, - .stall_timeout = 7, - }); - REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); - - const std::vector expected{ - LoginOptions{.proxy = std::nullopt, .connectTimeout = 10, .stallTimeout = 30}, - LoginOptions{.proxy = "socks5h://host:1080", .connectTimeout = 3, .stallTimeout = 7}, - }; - REQUIRE(svc.logins == expected); + TestApplication app; + auto client = std::make_shared(); + Service svc({client, client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = " socks5h://host:1080 ", + .connect_timeout = 3, + .stall_timeout = 7, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + + const std::vector expected{ + LoginOptions{ + .proxy = std::nullopt, .connectTimeout = 10, .stallTimeout = 30}, + LoginOptions{.proxy = "socks5h://host:1080", + .connectTimeout = 3, + .stallTimeout = 7}, + }; + REQUIRE(svc.logins == expected); } TEST_CASE("network failure triggers new login", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - client->networkFailures.insert(111); - Service svc({client, client}); - - REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); - REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); - - REQUIRE(svc.logins.size() == 2); -} - -TEST_CASE("downloads into local mods and splits results", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - client->errors.insert(222, QStringLiteral("item not found")); - Service svc({client}); - - std::vector statuses; - std::vector finished; - svc.steam->downloadItemStatusChanged.connect([&](const auto& s) { statuses.push_back(s); }); - svc.steam->downloadFinished.connect([&](const auto& r) { finished.push_back(r); }); - - auto result = runLoop(svc.steam->downloadMods({"111", "222", "111"})); - - REQUIRE(result.has_value()); - REQUIRE(result->succeeded == QStringList{"111"}); - REQUIRE(result->failed == QStringList{"222"}); - REQUIRE(finished.size() == 1); - REQUIRE(finished[0] == *result); - REQUIRE(QFileInfo::exists(svc.localPath + "/111")); - REQUIRE(client->calls.size() == 1); - REQUIRE(client->calls[0].ids == std::vector{111, 222}); - REQUIRE(client->calls[0].root == svc.localPath); - - QHash finalStatus; - for (const auto& s : statuses) { - if (s.status != "downloading") { - finalStatus.insert(s.modId, s); - } + TestApplication app; + auto client = std::make_shared(); + client->networkFailures.insert(111); + Service svc({client, client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("downloads into local mods and splits results", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("item not found")); + Service svc({client}); + + std::vector statuses; + std::vector finished; + svc.steam->downloadItemStatusChanged.connect( + [&](const auto& s) { statuses.push_back(s); }); + svc.steam->downloadFinished.connect( + [&](const auto& r) { finished.push_back(r); }); + + auto result = runLoop(svc.steam->downloadMods({"111", "222", "111"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed == QStringList{"222"}); + REQUIRE(finished.size() == 1); + REQUIRE(finished[0] == *result); + REQUIRE(QFileInfo::exists(svc.localPath + "/111")); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].ids == std::vector{111, 222}); + REQUIRE(client->calls[0].root == svc.localPath); + + QHash finalStatus; + for (const auto& s : statuses) { + if (s.status != "downloading") { + finalStatus.insert(s.modId, s); } - REQUIRE(finalStatus.value("111").status == "success"); - REQUIRE(finalStatus.value("222").status == "error"); - REQUIRE(finalStatus.value("222").error == "item not found"); + } + REQUIRE(finalStatus.value("111").status == "success"); + REQUIRE(finalStatus.value("222").status == "error"); + REQUIRE(finalStatus.value("222").error == "item not found"); } TEST_CASE("rejects invalid ids", "[downloads][steam]") { - TestApplication app; - const std::vector badInputs{ - {"12a"}, {"\u0661\u0662\u0663"}, {"1 2"}, {}, {"18446744073709551616"}}; - for (const auto& ids : badInputs) { - Service svc({std::make_shared()}); - auto result = runLoop(svc.steam->downloadMods(ids)); - REQUIRE_FALSE(result.has_value()); - REQUIRE(svc.logins.empty()); - } + TestApplication app; + const std::vector badInputs{ + {"12a"}, {"\u0661\u0662\u0663"}, {"1 2"}, {}, {"18446744073709551616"}}; + for (const auto& ids : badInputs) { + Service svc({std::make_shared()}); + auto result = runLoop(svc.steam->downloadMods(ids)); + REQUIRE_FALSE(result.has_value()); + REQUIRE(svc.logins.empty()); + } } TEST_CASE("requires local mods path", "[downloads][steam]") { - TestApplication app; - Service svc({std::make_shared()}, /*local=*/false); + TestApplication app; + Service svc({std::make_shared()}, /*local=*/false); - auto result = runLoop(svc.steam->downloadMods({"111"})); + auto result = runLoop(svc.steam->downloadMods({"111"})); - REQUIRE_FALSE(result.has_value()); - REQUIRE(result.error().message.contains("Local mods path")); - REQUIRE(svc.logins.empty()); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Local mods path")); + REQUIRE(svc.logins.empty()); } TEST_CASE("accepts uint64 max id", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); - auto result = runLoop(svc.steam->downloadMods({"18446744073709551615"})); + auto result = runLoop(svc.steam->downloadMods({"18446744073709551615"})); - REQUIRE(result.has_value()); - REQUIRE(result->succeeded == QStringList{"18446744073709551615"}); - REQUIRE(client->calls[0].ids == std::vector{UINT64_MAX}); + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"18446744073709551615"}); + REQUIRE(client->calls[0].ids == std::vector{UINT64_MAX}); } TEST_CASE("unwritable local path fails every item", "[downloads][steam]") { - TestApplication app; - Service svc({std::make_shared()}); - test_support::writeBytes(svc.localPath, "not a directory"); + TestApplication app; + Service svc({std::make_shared()}); + test_support::writeBytes(svc.localPath, "not a directory"); - auto result = runLoop(svc.steam->downloadMods({"111", "222"})); + auto result = runLoop(svc.steam->downloadMods({"111", "222"})); - REQUIRE(result.has_value()); - QStringList failed = result->failed; - failed.sort(); - REQUIRE(failed == QStringList{"111", "222"}); - REQUIRE(svc.logins.empty()); + REQUIRE(result.has_value()); + QStringList failed = result->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); + REQUIRE(svc.logins.empty()); } -TEST_CASE("login failure fails every item and retries next time", "[downloads][steam]") { - TestApplication app; - Service svc({}); - std::vector messages; - svc.steam->statusMessageChanged.connect([&](const QString& m) { messages.push_back(m); }); +TEST_CASE("login failure fails every item and retries next time", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + std::vector messages; + svc.steam->statusMessageChanged.connect( + [&](const QString& m) { messages.push_back(m); }); - auto first = runLoop(svc.steam->downloadMods({"111", "222"})); - auto second = runLoop(svc.steam->downloadMods({"111"})); + auto first = runLoop(svc.steam->downloadMods({"111", "222"})); + auto second = runLoop(svc.steam->downloadMods({"111"})); - REQUIRE(first.has_value()); - REQUIRE(first->succeeded.isEmpty()); - QStringList failed = first->failed; - failed.sort(); - REQUIRE(failed == QStringList{"111", "222"}); + REQUIRE(first.has_value()); + REQUIRE(first->succeeded.isEmpty()); + QStringList failed = first->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); - const bool hasDenied = std::ranges::any_of(messages, [](const auto& m) { return m.contains("logon denied"); }); - REQUIRE(hasDenied); - REQUIRE(svc.logins.size() == 2); + const bool hasDenied = std::ranges::any_of( + messages, [](const auto& m) { return m.contains("logon denied"); }); + REQUIRE(hasDenied); + REQUIRE(svc.logins.size() == 2); } TEST_CASE("client session is reused", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); - REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); - REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); - REQUIRE(svc.logins.size() == 1); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + REQUIRE(svc.logins.size() == 1); } TEST_CASE("uses at most eight download workers", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); - QStringList ids; - for (int i = 1; i <= 20; ++i) { - ids.append(QString::number(i)); - } + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QStringList ids; + for (int i = 1; i <= 20; ++i) { + ids.append(QString::number(i)); + } - REQUIRE(runLoop(svc.steam->downloadMods(ids)).has_value()); - REQUIRE(client->calls.size() == 1); - REQUIRE(client->calls[0].workers <= 8); + REQUIRE(runLoop(svc.steam->downloadMods(ids)).has_value()); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].workers <= 8); } TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - auto gate = std::make_shared(); - client->gates.insert(222, gate); - Service svc({client}); - - QTimer poll; - QObject::connect(&poll, &QTimer::timeout, &poll, [&] { - if (client->blocked->isSet()) { - svc.steam->cancel(); - gate->set(); - poll.stop(); - } - }); - poll.start(10ms); + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(222, gate); + Service svc({client}); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (client->blocked->isSet()) { + svc.steam->cancel(); + gate->set(); + poll.stop(); + } + }); + poll.start(10ms); - auto result = runLoop(svc.steam->downloadMods({"111", "222", "333"})); + auto result = runLoop(svc.steam->downloadMods({"111", "222", "333"})); - REQUIRE(result.has_value()); - REQUIRE(result->succeeded == QStringList{"111"}); - REQUIRE(result->failed.isEmpty()); - REQUIRE_FALSE(svc.steam->isDownloading()); + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed.isEmpty()); + REQUIRE_FALSE(svc.steam->isDownloading()); } TEST_CASE("second download while running is rejected", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - auto gate = std::make_shared(); - client->gates.insert(111, gate); - Service svc({client}); - - std::optional> firstOut; - std::optional> secondOut; - QEventLoop loop; - - auto firstTask = test_support::awaitInto(svc.steam->downloadMods({"111"}), firstOut); - - QTimer poll; - QObject::connect(&poll, &QTimer::timeout, &poll, [&] { - if (svc.steam->isDownloading()) { - poll.stop(); - auto secondTask = test_support::awaitInto(svc.steam->downloadMods({"222"}), secondOut); - gate->set(); - loop.quit(); - } - }); - poll.start(10ms); - loop.exec(); + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(111, gate); + Service svc({client}); + + std::optional> firstOut; + std::optional> secondOut; + QEventLoop loop; + + auto firstTask = + test_support::awaitInto(svc.steam->downloadMods({"111"}), firstOut); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (svc.steam->isDownloading()) { + poll.stop(); + auto secondTask = + test_support::awaitInto(svc.steam->downloadMods({"222"}), secondOut); + gate->set(); + loop.quit(); + } + }); + poll.start(10ms); + loop.exec(); - REQUIRE(secondOut.has_value()); - REQUIRE_FALSE(secondOut->has_value()); - REQUIRE(secondOut->error().message.contains("already running")); + REQUIRE(secondOut.has_value()); + REQUIRE_FALSE(secondOut->has_value()); + REQUIRE(secondOut->error().message.contains("already running")); } -TEST_CASE("updatable_id only for downloads in local mods", "[downloads][steam]") { - TestApplication app; - Service svc({}); - const QString root = svc.localPath; - const auto m1 = makeModAt(root + "/111", "111"); - const auto m2 = makeModAt(root + "/Renamed", "222"); - const auto m3 = makeModAt(root + "/333"); - const auto m4 = makeModAt(svc.configDir.path() + "/Workshop/444", "444"); - svc.mods = {m1, m2, m3, m4}; +TEST_CASE("updatable_id only for downloads in local mods", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + const QString root = svc.localPath; + const auto m1 = makeModAt(root + "/111", "111"); + const auto m2 = makeModAt(root + "/Renamed", "222"); + const auto m3 = makeModAt(root + "/333"); + const auto m4 = makeModAt(svc.configDir.path() + "/Workshop/444", "444"); + svc.mods = {m1, m2, m3, m4}; - REQUIRE(svc.steam->updatableId(*m1) == std::optional("111")); - REQUIRE(svc.steam->updatableId(*m2) == std::nullopt); - REQUIRE(svc.steam->updatableId(*m3) == std::optional("333")); - REQUIRE(svc.steam->updatableId(*m4) == std::nullopt); + REQUIRE(svc.steam->updatableId(*m1) == std::optional("111")); + REQUIRE(svc.steam->updatableId(*m2) == std::nullopt); + REQUIRE(svc.steam->updatableId(*m3) == std::optional("333")); + REQUIRE(svc.steam->updatableId(*m4) == std::nullopt); - REQUIRE(svc.steam->updatableIds(svc.mods) == QStringList{"111", "333"}); + REQUIRE(svc.steam->updatableIds(svc.mods) == QStringList{"111", "333"}); } TEST_CASE("updatable_id none without local path", "[downloads][steam]") { - TestApplication app; - Service svc({}, /*local=*/false); - const auto mod = makeModAt(svc.configDir.path() + "/Mods/111", "111"); - REQUIRE(svc.steam->updatableId(*mod) == std::nullopt); + TestApplication app; + Service svc({}, /*local=*/false); + const auto mod = makeModAt(svc.configDir.path() + "/Mods/111", "111"); + REQUIRE(svc.steam->updatableId(*mod) == std::nullopt); } TEST_CASE("busy changed brackets download", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); - std::vector events; - svc.steam->busyChanged.connect([&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); - svc.steam->downloadFinished.connect([&](const auto&) { events.push_back("finished"); }); + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + std::vector events; + svc.steam->busyChanged.connect( + [&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); + svc.steam->downloadFinished.connect( + [&](const auto&) { events.push_back("finished"); }); - REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); - const std::vector expected{"busy:1", "busy:0", "finished"}; - REQUIRE(events == expected); + const std::vector expected{"busy:1", "busy:0", "finished"}; + REQUIRE(events == expected); } TEST_CASE("invalid ids do not toggle busy", "[downloads][steam]") { - TestApplication app; - Service svc({std::make_shared()}); - std::vector busy; - svc.steam->busyChanged.connect([&](bool b) { busy.push_back(b); }); + TestApplication app; + Service svc({std::make_shared()}); + std::vector busy; + svc.steam->busyChanged.connect([&](bool b) { busy.push_back(b); }); - auto result = runLoop(svc.steam->downloadMods({"12a"})); - REQUIRE_FALSE(result.has_value()); - REQUIRE(busy.empty()); + auto result = runLoop(svc.steam->downloadMods({"12a"})); + REQUIRE_FALSE(result.has_value()); + REQUIRE(busy.empty()); } -TEST_CASE("progress counts complete and up to date items", "[downloads][steam]") { - SteamBatch batch(3); - batch.bytes.insert("1", {10, 10}); - batch.bytes.insert("2", {5, 10}); - batch.bytes.insert("3", {0, 0}); - REQUIRE(batch.progress().completed == 2); +TEST_CASE("progress counts complete and up to date items", + "[downloads][steam]") { + SteamBatch batch(3); + batch.bytes.insert("1", {10, 10}); + batch.bytes.insert("2", {5, 10}); + batch.bytes.insert("3", {0, 0}); + REQUIRE(batch.progress().completed == 2); - batch.succeeded.append("1"); - REQUIRE(batch.progress().completed == 2); + batch.succeeded.append("1"); + REQUIRE(batch.progress().completed == 2); } -TEST_CASE("updatable_ids limited to given mods and deduplicated", "[downloads][steam]") { - TestApplication app; - Service svc({}); - const auto a = makeModAt(svc.localPath + "/111", "111"); - const auto b = makeModAt(svc.localPath + "/222", "222"); - const auto other = makeModAt(svc.configDir.path() + "/Other/9", "9"); +TEST_CASE("updatable_ids limited to given mods and deduplicated", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + const auto a = makeModAt(svc.localPath + "/111", "111"); + const auto b = makeModAt(svc.localPath + "/222", "222"); + const auto other = makeModAt(svc.configDir.path() + "/Other/9", "9"); - REQUIRE(svc.steam->updatableIds({b, b, other}) == QStringList{"222"}); + REQUIRE(svc.steam->updatableIds({b, b, other}) == QStringList{"222"}); } TEST_CASE("result changed excludes up to date items", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); - auto result = runLoop(svc.steam->downloadMods({"111"})); + auto result = runLoop(svc.steam->downloadMods({"111"})); - REQUIRE(result.has_value()); - REQUIRE(result->changed == QStringList{"111"}); - REQUIRE(svc.steam->activeIds().isEmpty()); + REQUIRE(result.has_value()); + REQUIRE(result->changed == QStringList{"111"}); + REQUIRE(svc.steam->activeIds().isEmpty()); } TEST_CASE("batch changed ignores zero byte items", "[downloads][steam]") { - SteamBatch batch(2); - batch.succeeded = {"1", "2"}; - batch.bytes.insert("1", {0, 0}); - batch.bytes.insert("2", {5, 5}); - REQUIRE(batch.changed() == QStringList{"2"}); + SteamBatch batch(2); + batch.succeeded = {"1", "2"}; + batch.bytes.insert("1", {0, 0}); + batch.bytes.insert("2", {5, 5}); + REQUIRE(batch.changed() == QStringList{"2"}); } TEST_CASE("success records sync time and persists", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - client->errors.insert(222, QStringLiteral("gone")); - Service svc({client}); - - REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); - REQUIRE(syncedOf(svc, "111").has_value()); - REQUIRE_FALSE(syncedOf(svc, "222").has_value()); - - auto reloaded = svc.config.load(px::modrim::kWorkshopSyncFile); - REQUIRE(reloaded.has_value()); - std::vector keys; - for (const auto& [k, _] : reloaded->synced) { - keys.push_back(k); - } - REQUIRE(keys == std::vector{"111"}); + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("gone")); + Service svc({client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); + REQUIRE(syncedOf(svc, "111").has_value()); + REQUIRE_FALSE(syncedOf(svc, "222").has_value()); + + auto reloaded = + svc.config.load(px::modrim::kWorkshopSyncFile); + REQUIRE(reloaded.has_value()); + std::vector keys; + for (const auto& [k, _] : reloaded->synced) { + keys.push_back(k); + } + REQUIRE(keys == std::vector{"111"}); } TEST_CASE("stale ids never synced first then oldest", "[downloads][steam]") { - TestApplication app; - Service svc({}); - for (const char* name : {"1", "2", "3", "4"}) { - svc.mods.push_back(makeModAt(svc.localPath + "/" + name, name)); - } - WorkshopSyncState seed; - seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; - REQUIRE(svc.config.save(px::modrim::kWorkshopSyncFile, seed).has_value()); - runLoopVoid(svc.steam->loadSyncState()); - - REQUIRE(svc.steam->staleIds(3600.0, 10000.0) == QStringList{"4", "3", "1"}); -} - -TEST_CASE("auto update only syncs stale mods when enabled", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); - svc.mods.push_back(makeModAt(svc.localPath + "/1", "1")); - - runLoopVoid(svc.steam->autoUpdateOnce()); - REQUIRE_FALSE(syncedOf(svc, "1").has_value()); - - svc.steam->settings().update(SteamSettings{ - .auto_update_hours = 6, - .parallel_items = 8, - .threads_per_item = 1, - .proxy = {}, - .connect_timeout = 10, - .stall_timeout = 30, - }); - runLoopVoid(svc.steam->autoUpdateOnce()); - REQUIRE(syncedOf(svc, "1").has_value()); - const double firstSynced = *syncedOf(svc, "1"); - - runLoopVoid(svc.steam->autoUpdateOnce()); - REQUIRE(*syncedOf(svc, "1") == firstSynced); + TestApplication app; + Service svc({}); + for (const char* name : {"1", "2", "3", "4"}) { + svc.mods.push_back(makeModAt(svc.localPath + "/" + name, name)); + } + WorkshopSyncState seed; + seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; + REQUIRE(svc.config.save(px::modrim::kWorkshopSyncFile, seed).has_value()); + runLoopVoid(svc.steam->loadSyncState()); + + REQUIRE(svc.steam->staleIds(3600.0, 10000.0) == QStringList{"4", "3", "1"}); +} + +TEST_CASE("auto update only syncs stale mods when enabled", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + svc.mods.push_back(makeModAt(svc.localPath + "/1", "1")); + + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE_FALSE(syncedOf(svc, "1").has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 6, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(syncedOf(svc, "1").has_value()); + const double firstSynced = *syncedOf(svc, "1"); + + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(*syncedOf(svc, "1") == firstSynced); } TEST_CASE("titles from steam are emitted", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); - QHash titles; - svc.steam->downloadItemTitled.connect([&](const auto& t) { titles.insert(t.modId, t.title); }); - std::vector phases; - svc.steam->downloadPhaseChanged.connect([&](const auto& p) { phases.push_back(p); }); + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QHash titles; + svc.steam->downloadItemTitled.connect( + [&](const auto& t) { titles.insert(t.modId, t.title); }); + std::vector phases; + svc.steam->downloadPhaseChanged.connect( + [&](const auto& p) { phases.push_back(p); }); - REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); - REQUIRE(phases == std::vector{"login", "query", "run"}); - REQUIRE(titles.value("111") == "Title 111"); - REQUIRE(titles.value("222") == "Title 222"); + REQUIRE(phases == std::vector{"login", "query", "run"}); + REQUIRE(titles.value("111") == "Title 111"); + REQUIRE(titles.value("222") == "Title 222"); } TEST_CASE("client is relogged after download raises", "[downloads][steam]") { - TestApplication app; - auto dying = std::make_shared(); - auto fresh = std::make_shared(); - Service svc({dying, fresh}); - - auto first = runLoop(svc.steam->downloadMods({"1"})); - REQUIRE(first.has_value()); - REQUIRE(first->failed == QStringList{"1"}); - - auto second = runLoop(svc.steam->downloadMods({"2"})); - REQUIRE(second.has_value()); - REQUIRE(second->succeeded == QStringList{"2"}); - REQUIRE(svc.logins.size() == 2); -} - -TEST_CASE("download uses configured parallelism clamped", "[downloads][steam]") { - TestApplication app; - auto client = std::make_shared(); - Service svc({client}); - - svc.steam->settings().update(SteamSettings{ - .auto_update_hours = 0, - .parallel_items = 3, - .threads_per_item = 5, - .proxy = {}, - .connect_timeout = 10, - .stall_timeout = 30, - }); - REQUIRE(runLoop(svc.steam->downloadMods({"1"})).has_value()); - - svc.steam->settings().update(SteamSettings{ - .auto_update_hours = 0, - .parallel_items = 0, - .threads_per_item = 999, - .proxy = {}, - .connect_timeout = 10, - .stall_timeout = 30, - }); - REQUIRE(runLoop(svc.steam->downloadMods({"2"})).has_value()); - - REQUIRE(client->calls.size() == 2); - REQUIRE(client->calls[0].workers == 15); - REQUIRE(client->calls[1].workers == 16); + TestApplication app; + auto dying = std::make_shared(); + auto fresh = std::make_shared(); + Service svc({dying, fresh}); + + auto first = runLoop(svc.steam->downloadMods({"1"})); + REQUIRE(first.has_value()); + REQUIRE(first->failed == QStringList{"1"}); + + auto second = runLoop(svc.steam->downloadMods({"2"})); + REQUIRE(second.has_value()); + REQUIRE(second->succeeded == QStringList{"2"}); + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("download uses configured parallelism clamped", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 3, + .threads_per_item = 5, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"1"})).has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 0, + .threads_per_item = 999, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"2"})).has_value()); + + REQUIRE(client->calls.size() == 2); + REQUIRE(client->calls[0].workers == 15); + REQUIRE(client->calls[1].workers == 16); } #endif // PX_HAS_STEAM diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp index 003b6ff..ef98c00 100644 --- a/tests/core/models/metadata/test_parsing.cpp +++ b/tests/core/models/metadata/test_parsing.cpp @@ -1,8 +1,8 @@ -#include - #include #include #include + +#include #include #include "core/models/metadata/parsing.hpp" @@ -17,25 +17,28 @@ using px::modrim::ListedMod; namespace { // Parses a `` document and builds the mod from its root element. -std::shared_ptr aboutFromXml(const char* xml, const char* targetVersion = "1.5") { - pugi::xml_document doc; - REQUIRE(doc.load_string(xml)); - return createAboutModFromElement(doc.document_element(), QString::fromUtf8(targetVersion)); +std::shared_ptr aboutFromXml(const char* xml, + const char* targetVersion = "1.5") { + pugi::xml_document doc; + REQUIRE(doc.load_string(xml)); + return createAboutModFromElement(doc.document_element(), + QString::fromUtf8(targetVersion)); } -// Writes `content` as About/About.xml under a fresh mod directory inside `root`. +// Writes `content` as About/About.xml under a fresh mod directory inside +// `root`. QString writeAboutXml(const QString& root, const QByteArray& content) { - REQUIRE(QDir(root).mkpath(QStringLiteral("About"))); - QFile file(QDir(root).filePath(QStringLiteral("About/About.xml"))); - REQUIRE(file.open(QIODevice::WriteOnly)); - file.write(content); - return root; + REQUIRE(QDir(root).mkpath(QStringLiteral("About"))); + QFile file(QDir(root).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); + return root; } } // namespace TEST_CASE("basic About.xml fields are parsed") { - const auto mod = aboutFromXml(R"( + const auto mod = aboutFromXml(R"( Test Mod Tester test.mod @@ -43,28 +46,30 @@ TEST_CASE("basic About.xml fields are parsed") {
  • 1.5
  • )"); - CHECK(mod->valid); - CHECK(mod->name == QStringLiteral("Test Mod")); - CHECK(mod->authors == QStringList{QStringLiteral("Tester")}); - CHECK(mod->packageId.str() == QStringLiteral("test.mod")); - CHECK(mod->description == QStringLiteral("A test mod.")); - CHECK(mod->supportedVersions == QSet{QStringLiteral("1.5")}); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Test Mod")); + CHECK(mod->authors == QStringList{QStringLiteral("Tester")}); + CHECK(mod->packageId.str() == QStringLiteral("test.mod")); + CHECK(mod->description == QStringLiteral("A test mod.")); + CHECK(mod->supportedVersions == QSet{QStringLiteral("1.5")}); } TEST_CASE("About.xml without packageId is invalid") { - const auto mod = aboutFromXml("No ID Mod"); - CHECK_FALSE(mod->valid); - CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); + const auto mod = + aboutFromXml("No ID Mod"); + CHECK_FALSE(mod->valid); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); } TEST_CASE("About.xml with empty packageId is invalid") { - const auto mod = aboutFromXml(""); - CHECK_FALSE(mod->valid); - CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); + const auto mod = + aboutFromXml(""); + CHECK_FALSE(mod->valid); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); } TEST_CASE("versioned entries must match the version key exactly") { - const auto wrong = aboutFromXml(R"(x.y + const auto wrong = aboutFromXml(R"(x.y
  • wrong
  • wrong
  • @@ -73,42 +78,44 @@ TEST_CASE("versioned entries must match the version key exactly") {
  • wrong.dep
  • )"); - CHECK(wrong->aboutRules.loadAfter.size() == 0); - CHECK(wrong->aboutRules.dependencies.isEmpty()); + CHECK(wrong->aboutRules.loadAfter.size() == 0); + CHECK(wrong->aboutRules.dependencies.isEmpty()); - const auto right = aboutFromXml(R"(x.y + const auto right = aboutFromXml(R"(x.y
  • right
  • )"); - CHECK(right->aboutRules.loadAfter.contains(QStringLiteral("right"))); + CHECK(right->aboutRules.loadAfter.contains(QStringLiteral("right"))); } TEST_CASE("versioned dependencies replace the unversioned list") { - const auto mod = aboutFromXml(R"(x.y + const auto mod = aboutFromXml(R"(x.y
  • plain.dep
  • versioned.dep
  • )"); - REQUIRE(mod->aboutRules.dependencies.size() == 1); - CHECK(mod->aboutRules.dependencies.first().packageId == - CaseInsensitiveStr(QStringLiteral("versioned.dep"))); + REQUIRE(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.dependencies.first().packageId == + CaseInsensitiveStr(QStringLiteral("versioned.dep"))); } TEST_CASE("preferVersioned=false ignores versioned entries") { - pugi::xml_document doc; - REQUIRE(doc.load_string(R"(x.y + pugi::xml_document doc; + REQUIRE(doc.load_string(R"(x.y
  • versioned.dep
  • )")); - const auto mod = createAboutModFromElement(doc.document_element(), QStringLiteral("1.5"), - /*preferVersioned=*/false); - CHECK(mod->aboutRules.dependencies.isEmpty()); + const auto mod = + createAboutModFromElement(doc.document_element(), QStringLiteral("1.5"), + /*preferVersioned=*/false); + CHECK(mod->aboutRules.dependencies.isEmpty()); } -TEST_CASE("dependencies keep declaration order, skip duplicates and null entries") { - const auto mod = aboutFromXml(R"(x.y +TEST_CASE( + "dependencies keep declaration order, skip duplicates and null entries") { + const auto mod = aboutFromXml(R"(x.y
  • Dep.OneDep One https://example.test/one @@ -120,17 +127,17 @@ TEST_CASE("dependencies keep declaration order, skip duplicates and null entries )"); - const auto& deps = mod->aboutRules.dependencies; - REQUIRE(deps.size() == 2); - CHECK(deps[0].packageId == CaseInsensitiveStr(QStringLiteral("dep.one"))); - CHECK(deps[0].name == QStringLiteral("Dep One")); - CHECK(deps[0].workshopUrl == QStringLiteral("https://example.test/one")); - CHECK(deps[0].alternativePackageIds.contains(QStringLiteral("alt.one"))); - CHECK(deps[1].packageId == CaseInsensitiveStr(QStringLiteral("dep.two"))); + const auto& deps = mod->aboutRules.dependencies; + REQUIRE(deps.size() == 2); + CHECK(deps[0].packageId == CaseInsensitiveStr(QStringLiteral("dep.one"))); + CHECK(deps[0].name == QStringLiteral("Dep One")); + CHECK(deps[0].workshopUrl == QStringLiteral("https://example.test/one")); + CHECK(deps[0].alternativePackageIds.contains(QStringLiteral("alt.one"))); + CHECK(deps[1].packageId == CaseInsensitiveStr(QStringLiteral("dep.two"))); } TEST_CASE("force-load entries are merged into the load-order sets") { - const auto mod = aboutFromXml(R"(x.y + const auto mod = aboutFromXml(R"(x.y
  • before.a
  • before.b
  • after.a
  • @@ -138,82 +145,86 @@ TEST_CASE("force-load entries are merged into the load-order sets") {
  • bad.one
  • )"); - CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.a"))); - CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.b"))); - CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.a"))); - CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.b"))); - CHECK(mod->aboutRules.incompatibleWith.contains(QStringLiteral("bad.one"))); + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.a"))); + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.b"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.a"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.b"))); + CHECK(mod->aboutRules.incompatibleWith.contains(QStringLiteral("bad.one"))); } -TEST_CASE("descriptionsByVersion selects the description for the target version") { - const auto mod = aboutFromXml(R"(x.y +TEST_CASE( + "descriptionsByVersion selects the description for the target version") { + const auto mod = aboutFromXml(R"(x.y Old textText for 1.5 )"); - CHECK(mod->description == QStringLiteral("Text for 1.5")); + CHECK(mod->description == QStringLiteral("Text for 1.5")); } TEST_CASE("explicit steamAppId is read") { - const auto mod = aboutFromXml(R"(x.y + const auto mod = aboutFromXml(R"(x.y 1234)"); - CHECK(mod->steamAppId == 1234); + CHECK(mod->steamAppId == 1234); } -TEST_CASE("DLC package ids depend on the RimWorld base game and take the DLC name") { - const auto mod = aboutFromXml( - "ludeon.rimworld.royalty"); - CHECK(mod->valid); - CHECK(mod->name == QStringLiteral("Royalty")); - - const auto* base = mod->aboutRules.findDependency( - CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))); - REQUIRE(base != nullptr); - CHECK(base->name == QStringLiteral("RimWorld")); +TEST_CASE( + "DLC package ids depend on the RimWorld base game and take the DLC name") { + const auto mod = aboutFromXml( + "ludeon.rimworld.royalty"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Royalty")); + + const auto* base = mod->aboutRules.findDependency( + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))); + REQUIRE(base != nullptr); + CHECK(base->name == QStringLiteral("RimWorld")); } TEST_CASE("official mods without a name use the package id mapping") { - struct Case { - const char* packageId; - const char* expectedName; - }; - const Case cases[] = { - {"Ludeon.RimWorld", "Core"}, - {"Ludeon.RimWorld.Royalty", "Royalty"}, - {"Ludeon.RimWorld.Ideology", "Ideology"}, - {"Ludeon.RimWorld.Biotech", "Biotech"}, - {"Ludeon.RimWorld.Anomaly", "Anomaly"}, - {"Ludeon.RimWorld.Odyssey", "Odyssey"}, - }; - QTemporaryDir root; - REQUIRE(root.isValid()); - - for (const Case& test : cases) { - const QString modDir = root.filePath(QString::fromUtf8(test.packageId)); - writeAboutXml(modDir, - QStringLiteral("%1" - "Ludeon Studios") - .arg(QString::fromUtf8(test.packageId)) - .toUtf8()); - - const auto result = createListedModFromPath(modDir, QStringLiteral("1.6")); - REQUIRE(result.has_value()); - CHECK((*result)->valid); - CHECK((*result)->name == QString::fromUtf8(test.expectedName)); - } + struct Case { + const char* packageId; + const char* expectedName; + }; + const Case cases[] = { + {"Ludeon.RimWorld", "Core"}, + {"Ludeon.RimWorld.Royalty", "Royalty"}, + {"Ludeon.RimWorld.Ideology", "Ideology"}, + {"Ludeon.RimWorld.Biotech", "Biotech"}, + {"Ludeon.RimWorld.Anomaly", "Anomaly"}, + {"Ludeon.RimWorld.Odyssey", "Odyssey"}, + }; + QTemporaryDir root; + REQUIRE(root.isValid()); + + for (const Case& test : cases) { + const QString modDir = root.filePath(QString::fromUtf8(test.packageId)); + writeAboutXml( + modDir, QStringLiteral("%1" + "Ludeon Studios") + .arg(QString::fromUtf8(test.packageId)) + .toUtf8()); + + const auto result = createListedModFromPath(modDir, QStringLiteral("1.6")); + REQUIRE(result.has_value()); + CHECK((*result)->valid); + CHECK((*result)->name == QString::fromUtf8(test.expectedName)); + } } TEST_CASE("explicit name wins for official mods") { - const auto mod = aboutFromXml(R"( + const auto mod = aboutFromXml(R"( ludeon.rimworld.royalty Custom official name)", - "1.6"); - CHECK(mod->valid); - CHECK(mod->name == QStringLiteral("Custom official name")); + "1.6"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Custom official name")); } TEST_CASE("createListedModFromPath parses a valid mod directory") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modPath = writeAboutXml(root.path(), R"( + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = + writeAboutXml(root.path(), R"( Test Mod TestAuthor @@ -225,53 +236,58 @@ TEST_CASE("createListedModFromPath parses a valid mod directory") { )"); - const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); - REQUIRE(result.has_value()); - const auto& mod = *result; - CHECK(mod->valid); - const auto* about = dynamic_cast(mod.get()); - REQUIRE(about != nullptr); - CHECK(about->name == QStringLiteral("Test Mod")); - CHECK(about->authors == QStringList{QStringLiteral("TestAuthor")}); - CHECK(about->packageId.str() == QStringLiteral("test.mod")); - CHECK(about->modVersion == QStringLiteral("1.0.0")); - CHECK(about->aboutRules.loadAfter.contains(QStringLiteral("ludeon.rimworld"))); - CHECK(mod->modPath() == modPath); - CHECK(mod->providerId.isEmpty()); - CHECK(about->mtime > 0.0); + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK(mod->valid); + const auto* about = dynamic_cast(mod.get()); + REQUIRE(about != nullptr); + CHECK(about->name == QStringLiteral("Test Mod")); + CHECK(about->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(about->packageId.str() == QStringLiteral("test.mod")); + CHECK(about->modVersion == QStringLiteral("1.0.0")); + CHECK( + about->aboutRules.loadAfter.contains(QStringLiteral("ludeon.rimworld"))); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId.isEmpty()); + CHECK(about->mtime > 0.0); } TEST_CASE("createListedModFromPath rejects non-directory paths") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString file = writeAboutXml(root.path(), ""); - - const auto result = createListedModFromPath( - QDir(file).filePath(QStringLiteral("About/About.xml")), QStringLiteral("1.5")); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message.startsWith(QStringLiteral("Path must be a directory"))); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString file = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath( + QDir(file).filePath(QStringLiteral("About/About.xml")), + QStringLiteral("1.5")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith( + QStringLiteral("Path must be a directory"))); } -TEST_CASE("malformed About.xml yields an invalid AboutXmlMod with the mod path") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modPath = writeAboutXml(root.path(), ""); - - const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); - REQUIRE(result.has_value()); - const auto& mod = *result; - CHECK_FALSE(mod->valid); - CHECK(dynamic_cast(mod.get()) != nullptr); - CHECK(mod->modPath() == modPath); - CHECK(mod->uuid() == modPath); +TEST_CASE( + "malformed About.xml yields an invalid AboutXmlMod with the mod path") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK_FALSE(mod->valid); + CHECK(dynamic_cast(mod.get()) != nullptr); + CHECK(mod->modPath() == modPath); + CHECK(mod->uuid() == modPath); } TEST_CASE("directory without About.xml yields an invalid plain ListedMod") { - QTemporaryDir root; - REQUIRE(root.isValid()); - - const auto result = createListedModFromPath(root.path(), QStringLiteral("1.5")); - REQUIRE(result.has_value()); - CHECK_FALSE((*result)->valid); - CHECK(dynamic_cast(result->get()) == nullptr); + QTemporaryDir root; + REQUIRE(root.isValid()); + + const auto result = + createListedModFromPath(root.path(), QStringLiteral("1.5")); + REQUIRE(result.has_value()); + CHECK_FALSE((*result)->valid); + CHECK(dynamic_cast(result->get()) == nullptr); } diff --git a/tests/core/models/metadata/test_schemas.cpp b/tests/core/models/metadata/test_schemas.cpp index 82c16d3..625c5f8 100644 --- a/tests/core/models/metadata/test_schemas.cpp +++ b/tests/core/models/metadata/test_schemas.cpp @@ -1,7 +1,6 @@ -#include - #include +#include #include #include "core/models/metadata/structures.hpp" @@ -9,18 +8,18 @@ using namespace px::modrim; TEST_CASE("CaseInsensitiveStr serializes as a lowercased JSON string") { - const CaseInsensitiveStr pid(QStringLiteral("Ludeon.RimWorld")); - const auto written = glz::write_json(pid); - REQUIRE(written.has_value()); - CHECK(*written == R"("ludeon.rimworld")"); + const CaseInsensitiveStr pid(QStringLiteral("Ludeon.RimWorld")); + const auto written = glz::write_json(pid); + REQUIRE(written.has_value()); + CHECK(*written == R"("ludeon.rimworld")"); - CaseInsensitiveStr parsed; - REQUIRE_FALSE(glz::read_json(parsed, R"("Test.Mod")")); - CHECK(parsed == CaseInsensitiveStr(QStringLiteral("test.mod"))); + CaseInsensitiveStr parsed; + REQUIRE_FALSE(glz::read_json(parsed, R"("Test.Mod")")); + CHECK(parsed == CaseInsensitiveStr(QStringLiteral("test.mod"))); } TEST_CASE("community rules schema accepts single-value and list comments") { - const std::string json = R"({ + const std::string json = R"({ "timestamp": 42, "rules": { "pkg.a": { @@ -32,24 +31,24 @@ TEST_CASE("community rules schema accepts single-value and list comments") { } })"; - ExternalRulesSchema schema; - const auto error = glz::read_json(schema, json); - REQUIRE_FALSE(error); - CHECK(schema.timestamp == 42); + ExternalRulesSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.timestamp == 42); - const ExternalRule& rule = schema.rules.at("pkg.a"); - CHECK(rule.loadTop.value); - CHECK(std::get(rule.loadTop.comment) == "top"); + const ExternalRule& rule = schema.rules.at("pkg.a"); + CHECK(rule.loadTop.value); + CHECK(std::get(rule.loadTop.comment) == "top"); - const SubExternalRule& after = rule.loadAfter.at("other"); - CHECK(std::get(after.name) == "Other"); - CHECK(std::get>(after.comment) == - std::vector{"first", "second"}); - CHECK(rule.loadBottom.value == false); + const SubExternalRule& after = rule.loadAfter.at("other"); + CHECK(std::get(after.name) == "Other"); + CHECK(std::get>(after.comment) == + std::vector{"first", "second"}); + CHECK(rule.loadBottom.value == false); } TEST_CASE("steam workshop database accepts null, list, and object unions") { - const std::string json = R"({ + const std::string json = R"({ "version": 3, "database": { "123": { @@ -66,22 +65,23 @@ TEST_CASE("steam workshop database accepts null, list, and object unions") { } })"; - SteamDbSchema schema; - const auto error = glz::read_json(schema, json); - REQUIRE_FALSE(error); - CHECK(schema.version == 3); + SteamDbSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.version == 3); - const SteamDbEntry& entry = schema.database.at("123"); - CHECK(entry.packageId == "author.mod"); - CHECK(std::holds_alternative(entry.gameVersions)); - CHECK(std::get(entry.authors) == "Solo Author"); - CHECK(std::get>(entry.dependencies.at("dep.list")) == - std::vector{"alt.a"}); - const auto& object = std::get(entry.dependencies.at("dep.object")); - CHECK(object.name == "Dep"); - CHECK(object.url == "https://example.test"); - REQUIRE(entry.tags.size() == 1); - CHECK(entry.tags.front().at("key") == "value"); - CHECK(entry.blacklist.value); - CHECK(entry.blacklist.comment == "outdated"); + const SteamDbEntry& entry = schema.database.at("123"); + CHECK(entry.packageId == "author.mod"); + CHECK(std::holds_alternative(entry.gameVersions)); + CHECK(std::get(entry.authors) == "Solo Author"); + CHECK(std::get>(entry.dependencies.at("dep.list")) == + std::vector{"alt.a"}); + const auto& object = + std::get(entry.dependencies.at("dep.object")); + CHECK(object.name == "Dep"); + CHECK(object.url == "https://example.test"); + REQUIRE(entry.tags.size() == 1); + CHECK(entry.tags.front().at("key") == "value"); + CHECK(entry.blacklist.value); + CHECK(entry.blacklist.comment == "outdated"); } diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp index dec66a7..6f26b7e 100644 --- a/tests/core/models/metadata/test_structures.cpp +++ b/tests/core/models/metadata/test_structures.cpp @@ -1,8 +1,8 @@ -#include - #include #include +#include + #include "core/models/metadata/structures.hpp" using px::modrim::AboutXmlMod; @@ -12,156 +12,164 @@ using px::modrim::ListedMod; using px::modrim::ModsConfig; TEST_CASE("CaseInsensitiveStr stores the lowercased package id") { - const CaseInsensitiveStr pid(QStringLiteral("Test.Mod.PackageId")); - CHECK(pid.str() == QStringLiteral("test.mod.packageid")); + const CaseInsensitiveStr pid(QStringLiteral("Test.Mod.PackageId")); + CHECK(pid.str() == QStringLiteral("test.mod.packageid")); } TEST_CASE("CaseInsensitiveStr compares ignoring case") { - const CaseInsensitiveStr a(QStringLiteral("Test.Mod")); - const CaseInsensitiveStr b(QStringLiteral("test.mod")); - CHECK(a == b); + const CaseInsensitiveStr a(QStringLiteral("Test.Mod")); + const CaseInsensitiveStr b(QStringLiteral("test.mod")); + CHECK(a == b); } TEST_CASE("CaseInsensitiveStr finds hash entries regardless of case") { - QHash map; - map.insert(CaseInsensitiveStr(QStringLiteral("Test.Mod")), QStringLiteral("value")); - CHECK(map.value(CaseInsensitiveStr(QStringLiteral("test.mod"))) == QStringLiteral("value")); + QHash map; + map.insert(CaseInsensitiveStr(QStringLiteral("Test.Mod")), + QStringLiteral("value")); + CHECK(map.value(CaseInsensitiveStr(QStringLiteral("test.mod"))) == + QStringLiteral("value")); } TEST_CASE("CaseInsensitiveSet membership ignores case") { - CaseInsensitiveSet set; - set.add(QStringLiteral("Test.Mod")); - CHECK(set.contains(QStringLiteral("test.mod"))); - CHECK(set.contains(QStringLiteral("Test.Mod"))); + CaseInsensitiveSet set; + set.add(QStringLiteral("Test.Mod")); + CHECK(set.contains(QStringLiteral("test.mod"))); + CHECK(set.contains(QStringLiteral("Test.Mod"))); } TEST_CASE("CaseInsensitiveSet discard removes regardless of case") { - CaseInsensitiveSet set(QStringList{QStringLiteral("Test.Mod")}); - set.discard(QStringLiteral("test.mod")); - CHECK(set.size() == 0); + CaseInsensitiveSet set(QStringList{QStringLiteral("Test.Mod")}); + set.discard(QStringLiteral("test.mod")); + CHECK(set.size() == 0); } TEST_CASE("CaseInsensitiveSet union keeps elements of both operands") { - const CaseInsensitiveSet a(QStringList{QStringLiteral("mod.a")}); - const CaseInsensitiveSet b(QStringList{QStringLiteral("Mod.B")}); - const CaseInsensitiveSet c = a | b; - CHECK(c.contains(QStringLiteral("mod.a"))); - CHECK(c.contains(QStringLiteral("mod.b"))); + const CaseInsensitiveSet a(QStringList{QStringLiteral("mod.a")}); + const CaseInsensitiveSet b(QStringList{QStringLiteral("Mod.B")}); + const CaseInsensitiveSet c = a | b; + CHECK(c.contains(QStringLiteral("mod.a"))); + CHECK(c.contains(QStringLiteral("mod.b"))); } TEST_CASE("empty CaseInsensitiveSets compare equal") { - CHECK(CaseInsensitiveSet() == CaseInsensitiveSet()); + CHECK(CaseInsensitiveSet() == CaseInsensitiveSet()); } TEST_CASE("ListedMod has the invalid-mod defaults") { - const ListedMod mod; - CHECK(mod.name == QStringLiteral("Unknown Mod Name")); - CHECK(mod.valid); - CHECK(mod.providerId.isEmpty()); - CHECK_FALSE(mod.modPath().has_value()); + const ListedMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.valid); + CHECK(mod.providerId.isEmpty()); + CHECK_FALSE(mod.modPath().has_value()); } TEST_CASE("ListedMod provider_id is assignable") { - ListedMod mod; - mod.providerId = QStringLiteral("local"); - CHECK(mod.providerId == QStringLiteral("local")); + ListedMod mod; + mod.providerId = QStringLiteral("local"); + CHECK(mod.providerId == QStringLiteral("local")); } TEST_CASE("pathless ListedMod uuids are unique") { - const ListedMod a; - const ListedMod b; - CHECK(a.uuid() != b.uuid()); + const ListedMod a; + const ListedMod b; + CHECK(a.uuid() != b.uuid()); } TEST_CASE("ListedMod mod_path can be set only once") { - ListedMod mod; - REQUIRE(mod.setModPath(QStringLiteral("/mods/a"))); - CHECK(mod.uuid() == QStringLiteral("/mods/a")); + ListedMod mod; + REQUIRE(mod.setModPath(QStringLiteral("/mods/a"))); + CHECK(mod.uuid() == QStringLiteral("/mods/a")); - const auto second = mod.setModPath(QStringLiteral("/mods/b")); - REQUIRE_FALSE(second); - CHECK(second.error().message == QStringLiteral("Mod path already set. Cannot override.")); - CHECK(mod.modPath() == QStringLiteral("/mods/a")); + const auto second = mod.setModPath(QStringLiteral("/mods/b")); + REQUIRE_FALSE(second); + CHECK(second.error().message == + QStringLiteral("Mod path already set. Cannot override.")); + CHECK(mod.modPath() == QStringLiteral("/mods/a")); } TEST_CASE("AboutXmlMod has the About.xml defaults") { - const AboutXmlMod mod; - CHECK(mod.name == QStringLiteral("Unknown Mod Name")); - CHECK(mod.authors.isEmpty()); - CHECK(mod.modVersion.isEmpty()); - CHECK(mod.packageId == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); + const AboutXmlMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.authors.isEmpty()); + CHECK(mod.modVersion.isEmpty()); + CHECK(mod.packageId == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); } TEST_CASE("AboutXmlMod reports the DLC name for DLC package ids") { - AboutXmlMod mod; - mod.packageId = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); - REQUIRE(mod.getDlcName().has_value()); - CHECK(*mod.getDlcName() == QStringLiteral("RimWorld - Royalty")); + AboutXmlMod mod; + mod.packageId = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); + REQUIRE(mod.getDlcName().has_value()); + CHECK(*mod.getDlcName() == QStringLiteral("RimWorld - Royalty")); } TEST_CASE("ModsConfig normalizes package ids to lowercase") { - const ModsConfig config{ - .version = QStringLiteral("1.5"), - .activeMods = {CaseInsensitiveStr(QStringLiteral("Test.UPPERCASE.Mod"))}, - .knownExpansions = {}, - }; - CHECK(config.activeMods.first() == CaseInsensitiveStr(QStringLiteral("test.uppercase.mod"))); + const ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("Test.UPPERCASE.Mod"))}, + .knownExpansions = {}, + }; + CHECK(config.activeMods.first() == + CaseInsensitiveStr(QStringLiteral("test.uppercase.mod"))); } TEST_CASE("ModsConfig clear_active_mods keeps expansions") { - ModsConfig config{ - .version = QStringLiteral("1.5"), - .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, - .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, - }; - config.clearActiveMods(); - CHECK(config.activeMods.isEmpty()); - CHECK(config.knownExpansions.size() == 1); + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr( + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clearActiveMods(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.size() == 1); } TEST_CASE("ModsConfig clear_all empties both lists") { - ModsConfig config{ - .version = QStringLiteral("1.5"), - .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, - .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty"))}, - }; - config.clearAll(); - CHECK(config.activeMods.isEmpty()); - CHECK(config.knownExpansions.isEmpty()); + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr( + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clearAll(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.isEmpty()); } TEST_CASE("ModsConfig detects duplicate active mods ignoring case") { - const ModsConfig duplicated{ - .version = QStringLiteral("1.5"), - .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), - CaseInsensitiveStr(QStringLiteral("Mod.A"))}, - .knownExpansions = {}, - }; - CHECK(duplicated.checkActiveDuplicates()); - - const ModsConfig distinct{ - .version = QStringLiteral("1.5"), - .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), - CaseInsensitiveStr(QStringLiteral("mod.b"))}, - .knownExpansions = {}, - }; - CHECK_FALSE(distinct.checkActiveDuplicates()); + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("Mod.A"))}, + .knownExpansions = {}, + }; + CHECK(duplicated.checkActiveDuplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("mod.b"))}, + .knownExpansions = {}, + }; + CHECK_FALSE(distinct.checkActiveDuplicates()); } TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { - const ModsConfig duplicated{ - .version = QStringLiteral("1.5"), - .activeMods = {}, - .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), - CaseInsensitiveStr(QStringLiteral("Ludeon.RimWorld.Royalty"))}, - }; - CHECK(duplicated.checkExpansionsDuplicates()); - - const ModsConfig distinct{ - .version = QStringLiteral("1.5"), - .activeMods = {}, - .knownExpansions = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), - CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.ideology"))}, - }; - CHECK_FALSE(distinct.checkExpansionsDuplicates()); + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = + {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("Ludeon.RimWorld.Royalty"))}, + }; + CHECK(duplicated.checkExpansionsDuplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = + {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.ideology"))}, + }; + CHECK_FALSE(distinct.checkExpansionsDuplicates()); } diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp index 2ec8036..ff1841d 100644 --- a/tests/core/organizer/test_db.cpp +++ b/tests/core/organizer/test_db.cpp @@ -1,9 +1,9 @@ -#include - #include #include #include +#include + #include "core/organizer/db.hpp" #include "core/organizer/defaults.hpp" #include "core/organizer/models.hpp" @@ -18,314 +18,373 @@ using test_support::TestApplication; namespace { QString organizerFile(const QTemporaryDir& dir) { - return dir.filePath(QStringLiteral("organizer.db")); + return dir.filePath(QStringLiteral("organizer.db")); } } // namespace TEST_CASE("a fresh database holds only the root folder") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const OrganizerState state = expect(wait(db.load())); - REQUIRE(state.folders.size() == 1); - const Folder root = state.folders.value(kRootId); - CHECK(root.id == kRootId); - CHECK_FALSE(root.parentId.has_value()); - CHECK(root.name == QStringLiteral("Root")); - CHECK_FALSE(root.collapsed); - CHECK(state.placements.isEmpty()); - CHECK(state.tags.isEmpty()); - CHECK(state.modTags.isEmpty()); - CHECK(state.rules.isEmpty()); - - expectError(wait(db.deleteFolder(kRootId))); - expectError(wait(db.renameFolder(kRootId, QStringLiteral("CustomRoot")))); - expectError(wait(db.setParent(kRootId, 2))); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const OrganizerState state = expect(wait(db.load())); + REQUIRE(state.folders.size() == 1); + const Folder root = state.folders.value(kRootId); + CHECK(root.id == kRootId); + CHECK_FALSE(root.parentId.has_value()); + CHECK(root.name == QStringLiteral("Root")); + CHECK_FALSE(root.collapsed); + CHECK(state.placements.isEmpty()); + CHECK(state.tags.isEmpty()); + CHECK(state.modTags.isEmpty()); + CHECK(state.rules.isEmpty()); + + expectError(wait(db.deleteFolder(kRootId))); + expectError(wait(db.renameFolder(kRootId, QStringLiteral("CustomRoot")))); + expectError(wait(db.setParent(kRootId, 2))); } TEST_CASE("sibling names are unique case-insensitively, per parent") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder fA = expect(wait(db.createFolder(kRootId, QStringLiteral("FolderA")))); - CHECK(fA.id > kRootId); - CHECK(fA.parentId == kRootId); - CHECK(fA.name == QStringLiteral("FolderA")); - - expectError(wait(db.createFolder(kRootId, QStringLiteral("foldera")))); - - const Folder fB = expect(wait(db.createFolder(kRootId, QStringLiteral("FolderB")))); - expectError(wait(db.renameFolder(fB.id, QStringLiteral("foldera")))); - - // The same name is fine under a different parent. - const Folder fSub = expect(wait(db.createFolder(fA.id, QStringLiteral("FolderB")))); - CHECK(fSub.parentId == fA.id); - CHECK(fSub.name == QStringLiteral("FolderB")); - - expectError(wait(db.setParent(fSub.id, kRootId))); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder fA = + expect(wait(db.createFolder(kRootId, QStringLiteral("FolderA")))); + CHECK(fA.id > kRootId); + CHECK(fA.parentId == kRootId); + CHECK(fA.name == QStringLiteral("FolderA")); + + expectError(wait(db.createFolder(kRootId, QStringLiteral("foldera")))); + + const Folder fB = + expect(wait(db.createFolder(kRootId, QStringLiteral("FolderB")))); + expectError(wait(db.renameFolder(fB.id, QStringLiteral("foldera")))); + + // The same name is fine under a different parent. + const Folder fSub = + expect(wait(db.createFolder(fA.id, QStringLiteral("FolderB")))); + CHECK(fSub.parentId == fA.id); + CHECK(fSub.name == QStringLiteral("FolderB")); + + expectError(wait(db.setParent(fSub.id, kRootId))); } -TEST_CASE("placing lowercases package ids and upserts; unplacing removes rows") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder f1 = expect(wait(db.createFolder(kRootId, QStringLiteral("Folder1")))); - const Folder f2 = expect(wait(db.createFolder(kRootId, QStringLiteral("Folder2")))); - - expect(wait(db.place({}, 9999))); - expect(wait(db.place({QStringLiteral("Package.One"), QStringLiteral("PACKAGE.TWO")}, f1.id))); - OrganizerState st = expect(wait(db.load())); - CHECK(st.placements.value(QStringLiteral("package.one")) == f1.id); - CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); - - // Upsert: moving an existing placement. - expect(wait(db.place({QStringLiteral("package.ONE")}, f2.id))); - st = expect(wait(db.load())); - CHECK(st.placements.value(QStringLiteral("package.one")) == f2.id); - CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); - - expect(wait(db.unplace({QStringLiteral("PACKAGE.ONE")}))); - st = expect(wait(db.load())); - CHECK_FALSE(st.placements.contains(QStringLiteral("package.one"))); - CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); - - expectError(wait(db.place({QStringLiteral("package.three")}, 9999))); +TEST_CASE( + "placing lowercases package ids and upserts; unplacing removes rows") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f1 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Folder1")))); + const Folder f2 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Folder2")))); + + expect(wait(db.place({}, 9999))); + expect(wait(db.place( + {QStringLiteral("Package.One"), QStringLiteral("PACKAGE.TWO")}, f1.id))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f1.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + // Upsert: moving an existing placement. + expect(wait(db.place({QStringLiteral("package.ONE")}, f2.id))); + st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f2.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expect(wait(db.unplace({QStringLiteral("PACKAGE.ONE")}))); + st = expect(wait(db.load())); + CHECK_FALSE(st.placements.contains(QStringLiteral("package.one"))); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expectError(wait(db.place({QStringLiteral("package.three")}, 9999))); } -TEST_CASE("create_folder_with places its mods and rolls back on a duplicate name") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder f = expect(wait(db.createFolderWith( - kRootId, QStringLiteral("CoreMods"), - {QStringLiteral("Package.One"), QStringLiteral("package.two"), QStringLiteral("PACKAGE.THREE")}))); - CHECK(f.id > kRootId); - CHECK(f.name == QStringLiteral("CoreMods")); - CHECK(f.parentId == kRootId); - - const OrganizerState st = expect(wait(db.load())); - CHECK(st.folders.contains(f.id)); - CHECK(st.placements.value(QStringLiteral("package.one")) == f.id); - CHECK(st.placements.value(QStringLiteral("package.two")) == f.id); - CHECK(st.placements.value(QStringLiteral("package.three")) == f.id); - - expectError(wait(db.createFolderWith(kRootId, QStringLiteral("coremods"), - {QStringLiteral("package.four"), QStringLiteral("package.five")}))); - - const OrganizerState after = expect(wait(db.load())); - CHECK_FALSE(after.placements.contains(QStringLiteral("package.four"))); - CHECK_FALSE(after.placements.contains(QStringLiteral("package.five"))); - CHECK(after.folders.size() == st.folders.size()); +TEST_CASE( + "create_folder_with places its mods and rolls back on a duplicate name") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f = expect(wait(db.createFolderWith( + kRootId, QStringLiteral("CoreMods"), + {QStringLiteral("Package.One"), QStringLiteral("package.two"), + QStringLiteral("PACKAGE.THREE")}))); + CHECK(f.id > kRootId); + CHECK(f.name == QStringLiteral("CoreMods")); + CHECK(f.parentId == kRootId); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.folders.contains(f.id)); + CHECK(st.placements.value(QStringLiteral("package.one")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.three")) == f.id); + + expectError(wait(db.createFolderWith( + kRootId, QStringLiteral("coremods"), + {QStringLiteral("package.four"), QStringLiteral("package.five")}))); + + const OrganizerState after = expect(wait(db.load())); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.four"))); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.five"))); + CHECK(after.folders.size() == st.folders.size()); } -TEST_CASE("deleting a folder re-homes its subtree's placements and drops its descendants") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder fParent = expect(wait(db.createFolder(kRootId, QStringLiteral("Parent")))); - const Folder fChild = expect(wait(db.createFolder(fParent.id, QStringLiteral("Child")))); - const Folder fGrandchild = expect(wait(db.createFolder(fChild.id, QStringLiteral("Grandchild")))); - const Folder fOther = expect(wait(db.createFolder(kRootId, QStringLiteral("Other")))); - - expect(wait(db.place({QStringLiteral("mod.parent")}, fParent.id))); - expect(wait(db.place({QStringLiteral("mod.child")}, fChild.id))); - expect(wait(db.place({QStringLiteral("mod.grandchild")}, fGrandchild.id))); - expect(wait(db.place({QStringLiteral("mod.other")}, fOther.id))); - expect(wait(db.place({QStringLiteral("mod.root")}, kRootId))); - - expect(wait(db.deleteFolder(fParent.id))); - - const OrganizerState st = expect(wait(db.load())); - CHECK_FALSE(st.folders.contains(fParent.id)); - CHECK_FALSE(st.folders.contains(fChild.id)); - CHECK_FALSE(st.folders.contains(fGrandchild.id)); - CHECK(st.folders.contains(fOther.id)); - - CHECK(st.placements.value(QStringLiteral("mod.parent")) == kRootId); - CHECK(st.placements.value(QStringLiteral("mod.child")) == kRootId); - CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == kRootId); - CHECK(st.placements.value(QStringLiteral("mod.other")) == fOther.id); - CHECK(st.placements.value(QStringLiteral("mod.root")) == kRootId); - - expectError(wait(db.deleteFolder(9999))); +TEST_CASE( + "deleting a folder re-homes its subtree's placements and drops its " + "descendants") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder fParent = + expect(wait(db.createFolder(kRootId, QStringLiteral("Parent")))); + const Folder fChild = + expect(wait(db.createFolder(fParent.id, QStringLiteral("Child")))); + const Folder fGrandchild = + expect(wait(db.createFolder(fChild.id, QStringLiteral("Grandchild")))); + const Folder fOther = + expect(wait(db.createFolder(kRootId, QStringLiteral("Other")))); + + expect(wait(db.place({QStringLiteral("mod.parent")}, fParent.id))); + expect(wait(db.place({QStringLiteral("mod.child")}, fChild.id))); + expect(wait(db.place({QStringLiteral("mod.grandchild")}, fGrandchild.id))); + expect(wait(db.place({QStringLiteral("mod.other")}, fOther.id))); + expect(wait(db.place({QStringLiteral("mod.root")}, kRootId))); + + expect(wait(db.deleteFolder(fParent.id))); + + const OrganizerState st = expect(wait(db.load())); + CHECK_FALSE(st.folders.contains(fParent.id)); + CHECK_FALSE(st.folders.contains(fChild.id)); + CHECK_FALSE(st.folders.contains(fGrandchild.id)); + CHECK(st.folders.contains(fOther.id)); + + CHECK(st.placements.value(QStringLiteral("mod.parent")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.child")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.other")) == fOther.id); + CHECK(st.placements.value(QStringLiteral("mod.root")) == kRootId); + + expectError(wait(db.deleteFolder(9999))); } -TEST_CASE("tags: create, rename, unique names, mod tagging and cascade on delete") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Tag t1 = expect(wait(db.createTag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); - const Tag t2 = expect(wait(db.createTag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); - CHECK(t1.name == QStringLiteral("Tag1")); - CHECK(t1.color == QStringLiteral("#ff0000")); - - expectError(wait(db.createTag(QStringLiteral("tag1"), QStringLiteral("#123456")))); - - expect(wait(db.setModTags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, {t1.id, t2.id}, {}))); - OrganizerState st = expect(wait(db.load())); - CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t1.id, t2.id}); - CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id, t2.id}); - - expect(wait(db.setModTags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); - st = expect(wait(db.load())); - CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); - - expectError(wait(db.setModTags({QStringLiteral("mod.alpha")}, {9999}, {}))); - - expect(wait(db.updateTag(t1.id, QStringLiteral("Tag1Updated"), QStringLiteral("#aaaaaa")))); - st = expect(wait(db.load())); - CHECK(st.tags.value(t1.id).name == QStringLiteral("Tag1Updated")); - - expectError(wait(db.updateTag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); - - expect(wait(db.deleteTag(t2.id))); - st = expect(wait(db.load())); - CHECK_FALSE(st.tags.contains(t2.id)); - CHECK((!st.modTags.contains(QStringLiteral("mod.alpha")) || - st.modTags.value(QStringLiteral("mod.alpha")).isEmpty())); - CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); - - expectError(wait(db.deleteTag(9999))); +TEST_CASE( + "tags: create, rename, unique names, mod tagging and cascade on delete") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Tag t1 = expect( + wait(db.createTag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); + const Tag t2 = expect( + wait(db.createTag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); + CHECK(t1.name == QStringLiteral("Tag1")); + CHECK(t1.color == QStringLiteral("#ff0000")); + + expectError( + wait(db.createTag(QStringLiteral("tag1"), QStringLiteral("#123456")))); + + expect(wait( + db.setModTags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, + {t1.id, t2.id}, {}))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == + QSet{t1.id, t2.id}); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == + QSet{t1.id, t2.id}); + + expect(wait(db.setModTags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); + st = expect(wait(db.load())); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); + + expectError(wait(db.setModTags({QStringLiteral("mod.alpha")}, {9999}, {}))); + + expect(wait(db.updateTag(t1.id, QStringLiteral("Tag1Updated"), + QStringLiteral("#aaaaaa")))); + st = expect(wait(db.load())); + CHECK(st.tags.value(t1.id).name == QStringLiteral("Tag1Updated")); + + expectError(wait( + db.updateTag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); + + expect(wait(db.deleteTag(t2.id))); + st = expect(wait(db.load())); + CHECK_FALSE(st.tags.contains(t2.id)); + CHECK((!st.modTags.contains(QStringLiteral("mod.alpha")) || + st.modTags.value(QStringLiteral("mod.alpha")).isEmpty())); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); + + expectError(wait(db.deleteTag(9999))); } -TEST_CASE("replacing rules numbers them by position and deleting a folder drops its rules") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder f1 = expect(wait(db.createFolder(kRootId, QStringLiteral("Target1")))); - const Folder f2 = expect(wait(db.createFolder(kRootId, QStringLiteral("Target2")))); - - const QList specs{ - RuleSpec{.field = RuleField::kName, .op = RuleOp::kPrefix, .pattern = QStringLiteral("Vanilla"), .folderId = f1.id}, - RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kContains, .pattern = QStringLiteral("Oskar"), .folderId = f2.id}, - RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kEquals, .pattern = QStringLiteral("ludeon.rimworld"), .folderId = f1.id}, - }; - const QList rules = expect(wait(db.replaceRules(specs))); - REQUIRE(rules.size() == 3); - CHECK(rules[0].position == 0); - CHECK(rules[1].position == 1); - CHECK(rules[2].position == 2); - CHECK(rules[0].field == RuleField::kName); - CHECK(rules[1].field == RuleField::kAuthor); - CHECK(rules[2].field == RuleField::kPackageId); - - const OrganizerState st = expect(wait(db.load())); - CHECK(st.rules == rules); - - // A rule pointing at a missing folder fails the whole replacement. - expectError(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kPrefix, - .pattern = QStringLiteral("x"), .folderId = 9999}}))); - - // Deleting Target1 drops its rules; the rule for Target2 remains. - expect(wait(db.deleteFolder(f1.id))); - const OrganizerState after = expect(wait(db.load())); - REQUIRE(after.rules.size() == 1); - CHECK(after.rules[0].id == rules[1].id); - CHECK(after.rules[0].folderId == f2.id); +TEST_CASE( + "replacing rules numbers them by position and deleting a folder drops its " + "rules") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f1 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Target1")))); + const Folder f2 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Target2")))); + + const QList specs{ + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("Vanilla"), + .folderId = f1.id}, + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kContains, + .pattern = QStringLiteral("Oskar"), + .folderId = f2.id}, + RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("ludeon.rimworld"), + .folderId = f1.id}, + }; + const QList rules = expect(wait(db.replaceRules(specs))); + REQUIRE(rules.size() == 3); + CHECK(rules[0].position == 0); + CHECK(rules[1].position == 1); + CHECK(rules[2].position == 2); + CHECK(rules[0].field == RuleField::kName); + CHECK(rules[1].field == RuleField::kAuthor); + CHECK(rules[2].field == RuleField::kPackageId); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.rules == rules); + + // A rule pointing at a missing folder fails the whole replacement. + expectError(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("x"), + .folderId = 9999}}))); + + // Deleting Target1 drops its rules; the rule for Target2 remains. + expect(wait(db.deleteFolder(f1.id))); + const OrganizerState after = expect(wait(db.load())); + REQUIRE(after.rules.size() == 1); + CHECK(after.rules[0].id == rules[1].id); + CHECK(after.rules[0].folderId == f2.id); } TEST_CASE("state survives closing and reopening the database") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString file = organizerFile(dir); - - Folder folder; - Tag tag; - { - OrganizerDb db1(file); - folder = expect(wait(db1.createFolder(kRootId, QStringLiteral("PersistFolder")))); - expect(wait(db1.setCollapsed(folder.id, true))); - expect(wait(db1.place({QStringLiteral("persist.pkg")}, folder.id))); - tag = expect(wait(db1.createTag(QStringLiteral("PersistTag"), QStringLiteral("#abcdef")))); - expect(wait(db1.setModTags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); - expect(wait(db1.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, - .pattern = QStringLiteral("mod"), .folderId = folder.id}}))); - wait(db1.close()); - } - - OrganizerDb db2(file); - const OrganizerState st = expect(wait(db2.load())); - REQUIRE(st.folders.contains(folder.id)); - CHECK(st.folders.value(folder.id).name == QStringLiteral("PersistFolder")); - CHECK(st.folders.value(folder.id).collapsed); - CHECK(st.placements.value(QStringLiteral("persist.pkg")) == folder.id); - REQUIRE(st.tags.contains(tag.id)); - CHECK(st.tags.value(tag.id).name == QStringLiteral("PersistTag")); - CHECK(st.modTags.value(QStringLiteral("persist.pkg")) == QSet{tag.id}); - REQUIRE(st.rules.size() == 1); - CHECK(st.rules[0].pattern == QStringLiteral("mod")); - CHECK(st.rules[0].folderId == folder.id); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = organizerFile(dir); + + Folder folder; + Tag tag; + { + OrganizerDb db1(file); + folder = expect( + wait(db1.createFolder(kRootId, QStringLiteral("PersistFolder")))); + expect(wait(db1.setCollapsed(folder.id, true))); + expect(wait(db1.place({QStringLiteral("persist.pkg")}, folder.id))); + tag = expect(wait(db1.createTag(QStringLiteral("PersistTag"), + QStringLiteral("#abcdef")))); + expect(wait(db1.setModTags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); + expect(wait(db1.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("mod"), + .folderId = folder.id}}))); + wait(db1.close()); + } + + OrganizerDb db2(file); + const OrganizerState st = expect(wait(db2.load())); + REQUIRE(st.folders.contains(folder.id)); + CHECK(st.folders.value(folder.id).name == QStringLiteral("PersistFolder")); + CHECK(st.folders.value(folder.id).collapsed); + CHECK(st.placements.value(QStringLiteral("persist.pkg")) == folder.id); + REQUIRE(st.tags.contains(tag.id)); + CHECK(st.tags.value(tag.id).name == QStringLiteral("PersistTag")); + CHECK(st.modTags.value(QStringLiteral("persist.pkg")) == + QSet{tag.id}); + REQUIRE(st.rules.size() == 1); + CHECK(st.rules[0].pattern == QStringLiteral("mod")); + CHECK(st.rules[0].folderId == folder.id); } -TEST_CASE("append_named_rules is atomic and resolves folders case-insensitively") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - OrganizerDb db(organizerFile(dir)); - - const Folder existing = expect(wait(db.createFolder(kRootId, QStringLiteral("Existing")))); - expect(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, - .pattern = QStringLiteral("a"), .folderId = existing.id}}))); - const OrganizerState before = expect(wait(db.load())); - - expectError(wait(db.appendNamedRules( - kRootId, {QStringLiteral("New")}, - {StandardRule{.folder = QStringLiteral("New"), .field = RuleField::kName, - .op = RuleOp::kContains, .pattern = QStringLiteral("b")}, - StandardRule{.folder = QStringLiteral("Missing"), .field = RuleField::kName, - .op = RuleOp::kContains, .pattern = QStringLiteral("c")}})), - QStringLiteral("Missing")); - CHECK(expect(wait(db.load())) == before); - - expect(wait(db.appendNamedRules( - kRootId, {QStringLiteral("New")}, - {StandardRule{.folder = QStringLiteral("existing"), .field = RuleField::kName, - .op = RuleOp::kContains, .pattern = QStringLiteral("b")}, - StandardRule{.folder = QStringLiteral("NEW"), .field = RuleField::kName, - .op = RuleOp::kContains, .pattern = QStringLiteral("c")}}))); - - const OrganizerState st = expect(wait(db.load())); - qint64 newId = -1; - for (const Folder& folder : st.folders) { - if (folder.name == QStringLiteral("New")) { - newId = folder.id; - } +TEST_CASE( + "append_named_rules is atomic and resolves folders case-insensitively") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder existing = + expect(wait(db.createFolder(kRootId, QStringLiteral("Existing")))); + expect(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("a"), + .folderId = existing.id}}))); + const OrganizerState before = expect(wait(db.load())); + + expectError(wait(db.appendNamedRules( + kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("New"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("Missing"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("c")}})), + QStringLiteral("Missing")); + CHECK(expect(wait(db.load())) == before); + + expect(wait( + db.appendNamedRules(kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("existing"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("NEW"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("c")}}))); + + const OrganizerState st = expect(wait(db.load())); + qint64 newId = -1; + for (const Folder& folder : st.folders) { + if (folder.name == QStringLiteral("New")) { + newId = folder.id; } - REQUIRE(st.rules.size() == 3); - CHECK(st.rules[0].position == 0); - CHECK(st.rules[0].pattern == QStringLiteral("a")); - CHECK(st.rules[0].folderId == existing.id); - CHECK(st.rules[1].position == 1); - CHECK(st.rules[1].pattern == QStringLiteral("b")); - CHECK(st.rules[1].folderId == existing.id); - CHECK(st.rules[2].position == 2); - CHECK(st.rules[2].pattern == QStringLiteral("c")); - CHECK(st.rules[2].folderId == newId); + } + REQUIRE(st.rules.size() == 3); + CHECK(st.rules[0].position == 0); + CHECK(st.rules[0].pattern == QStringLiteral("a")); + CHECK(st.rules[0].folderId == existing.id); + CHECK(st.rules[1].position == 1); + CHECK(st.rules[1].pattern == QStringLiteral("b")); + CHECK(st.rules[1].folderId == existing.id); + CHECK(st.rules[2].position == 2); + CHECK(st.rules[2].pattern == QStringLiteral("c")); + CHECK(st.rules[2].folderId == newId); } TEST_CASE("the database file is created in a missing directory on first use") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString file = dir.filePath(QStringLiteral("nested/config/organizer.db")); - OrganizerDb db(file); - - expect(wait(db.createFolder(kRootId, QStringLiteral("Made")))); - CHECK(QFile::exists(file)); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = + dir.filePath(QStringLiteral("nested/config/organizer.db")); + OrganizerDb db(file); + + expect(wait(db.createFolder(kRootId, QStringLiteral("Made")))); + CHECK(QFile::exists(file)); } diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp index fe8283b..4c8de6e 100644 --- a/tests/core/organizer/test_resolve.cpp +++ b/tests/core/organizer/test_resolve.cpp @@ -1,8 +1,8 @@ -#include - #include #include +#include + #include "core/models/metadata/structures.hpp" #include "core/organizer/models.hpp" #include "core/organizer/resolve.hpp" @@ -13,532 +13,649 @@ using namespace px::modrim::organizer; namespace { ListedModPtr makeMod([[maybe_unused]] const QString& uuid, const QString& name, - const std::optional& pid = std::nullopt, - const QString& author = {}, const QString& provider = QStringLiteral("local")) { - if (!pid) { - auto mod = std::make_shared(); - mod->name = name; - mod->providerId = provider; - return mod; - } - auto mod = std::make_shared(); + const std::optional& pid = std::nullopt, + const QString& author = {}, + const QString& provider = QStringLiteral("local")) { + if (!pid) { + auto mod = std::make_shared(); mod->name = name; mod->providerId = provider; - mod->packageId = CaseInsensitiveStr(QStringView(*pid)); - if (!author.isEmpty()) { - mod->authors.append(author); - } return mod; + } + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(*pid)); + if (!author.isEmpty()) { + mod->authors.append(author); + } + return mod; } Folder folder(qint64 id, std::optional parentId, QString name) { - Folder f; - f.id = id; - f.parentId = parentId; - f.name = std::move(name); - f.collapsed = false; - return f; + Folder f; + f.id = id; + f.parentId = parentId; + f.name = std::move(name); + f.collapsed = false; + return f; } struct RuleSeed { - RuleField field; - RuleOp op; - QString pattern; - qint64 folderId; + RuleField field; + RuleOp op; + QString pattern; + qint64 folderId; }; -OrganizerState makeState(QList folders = {}, QHash placements = {}, - QList rules = {}, QHash> modTags = {}, - QList tags = {}) { - OrganizerState state; - state.folders.insert(kRootId, folder(kRootId, std::nullopt, QStringLiteral(""))); - for (const Folder& f : folders) { - state.folders.insert(f.id, f); - } - state.placements = std::move(placements); - state.modTags = std::move(modTags); - for (const Tag& tag : tags) { - state.tags.insert(tag.id, tag); - } - for (qsizetype i = 0; i < rules.size(); ++i) { - const auto& [fld, op, pat, fid] = rules[i]; - state.rules.append(Rule{.id = static_cast(i + 1), - .position = static_cast(i), - .field = fld, - .op = op, - .pattern = pat, - .folderId = fid}); - } - return state; +OrganizerState makeState(QList folders = {}, + QHash placements = {}, + QList rules = {}, + QHash> modTags = {}, + QList tags = {}) { + OrganizerState state; + state.folders.insert(kRootId, + folder(kRootId, std::nullopt, QStringLiteral(""))); + for (const Folder& f : folders) { + state.folders.insert(f.id, f); + } + state.placements = std::move(placements); + state.modTags = std::move(modTags); + for (const Tag& tag : tags) { + state.tags.insert(tag.id, tag); + } + for (qsizetype i = 0; i < rules.size(); ++i) { + const auto& [fld, op, pat, fid] = rules[i]; + state.rules.append(Rule{.id = static_cast(i + 1), + .position = static_cast(i), + .field = fld, + .op = op, + .pattern = pat, + .folderId = fid}); + } + return state; } const FolderNode* findNode(const FolderNode& root, qint64 folderId) { - for (const FolderNode* node : root.walk()) { - if (node->folder.id == folderId) { - return node; - } + for (const FolderNode* node : root.walk()) { + if (node->folder.id == folderId) { + return node; } - return nullptr; + } + return nullptr; } } // namespace -TEST_CASE("preview_rule_matches counts first matches and assignable ungrouped mods") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, - {{QStringLiteral("manual.mod"), 3}, {QStringLiteral("root.mod"), kRootId}}, - {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("old."), 2}}); - - const ModsByUuid mods{ - {QStringLiteral("a"), makeMod(QStringLiteral("a"), QStringLiteral("Core Alpha"), QStringLiteral("new.alpha"), QStringLiteral("Author"))}, - {QStringLiteral("b"), makeMod(QStringLiteral("b"), QStringLiteral("Core Beta"), QStringLiteral("old.beta"), QStringLiteral("Other"))}, - {QStringLiteral("c"), makeMod(QStringLiteral("c"), QStringLiteral("Core Manual"), QStringLiteral("manual.mod"), QStringLiteral("Author"))}, - {QStringLiteral("d"), makeMod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), QStringLiteral("root.mod"), QStringLiteral("Author"))}, - {QStringLiteral("e"), makeMod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, - }; - - const QList draft{ - RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kEquals, .pattern = QStringLiteral("author"), .folderId = 3}, - RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, .pattern = QStringLiteral("core"), .folderId = 2}, - }; - - RulePreview preview = previewRuleMatches(state, mods, draft); - CHECK(preview.counts == QList{3, 1}); - CHECK(preview.assignable == 1); - - const QList reversed{draft[1], draft[0]}; - preview = previewRuleMatches(state, mods, reversed); - CHECK(preview.counts == QList{4, 0}); - CHECK(preview.assignable == 1); - - QList withBlank{ - RuleSpec{.field = RuleField::kName, .op = RuleOp::kContains, .pattern = QStringLiteral(" "), .folderId = 2}, - draft[0], - draft[1], - }; - preview = previewRuleMatches(state, mods, withBlank); - CHECK(preview.counts == QList{0, 3, 1}); - CHECK(preview.assignable == 1); +TEST_CASE( + "preview_rule_matches counts first matches and assignable ungrouped mods") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {{QStringLiteral("manual.mod"), 3}, + {QStringLiteral("root.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("old."), 2}}); + + const ModsByUuid mods{ + {QStringLiteral("a"), + makeMod(QStringLiteral("a"), QStringLiteral("Core Alpha"), + QStringLiteral("new.alpha"), QStringLiteral("Author"))}, + {QStringLiteral("b"), + makeMod(QStringLiteral("b"), QStringLiteral("Core Beta"), + QStringLiteral("old.beta"), QStringLiteral("Other"))}, + {QStringLiteral("c"), + makeMod(QStringLiteral("c"), QStringLiteral("Core Manual"), + QStringLiteral("manual.mod"), QStringLiteral("Author"))}, + {QStringLiteral("d"), + makeMod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), + QStringLiteral("root.mod"), QStringLiteral("Author"))}, + {QStringLiteral("e"), + makeMod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, + }; + + const QList draft{ + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("author"), + .folderId = 3}, + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("core"), + .folderId = 2}, + }; + + RulePreview preview = previewRuleMatches(state, mods, draft); + CHECK(preview.counts == QList{3, 1}); + CHECK(preview.assignable == 1); + + const QList reversed{draft[1], draft[0]}; + preview = previewRuleMatches(state, mods, reversed); + CHECK(preview.counts == QList{4, 0}); + CHECK(preview.assignable == 1); + + QList withBlank{ + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral(" "), + .folderId = 2}, + draft[0], + draft[1], + }; + preview = previewRuleMatches(state, mods, withBlank); + CHECK(preview.counts == QList{0, 3, 1}); + CHECK(preview.assignable == 1); } -TEST_CASE("folder_for manual placement beats rules, and root placement blocks rules") { - SECTION("manual beats rule") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, - {{QStringLiteral("x.mod"), 3}}, - {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); - const Resolution res = folderFor(state, QStringLiteral("X.Mod"), QStringLiteral("Mod"), QStringLiteral("")); - CHECK(res.folderId == 3); - CHECK(res.placement == Placement::kManual); - } - - SECTION("root placement blocks rules") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A"))}, - {{QStringLiteral("x.mod"), kRootId}}, - {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); - const Resolution res = folderFor(state, QStringLiteral("x.mod"), QStringLiteral("Mod"), QStringLiteral("")); - CHECK(res.folderId == kRootId); - CHECK(res.placement == Placement::kManual); - } - - SECTION("first rule by position wins") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A")), folder(3, kRootId, QStringLiteral("B"))}, {}, - {{RuleField::kAuthor, RuleOp::kEquals, QStringLiteral("OSKAR"), 3}, - {RuleField::kName, RuleOp::kContains, QStringLiteral("core"), 2}}); - const Resolution res1 = folderFor(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), QStringLiteral("Oskar")); - CHECK(res1.folderId == 3); - CHECK(res1.placement == Placement::kRule); - const Resolution res2 = folderFor(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), QStringLiteral("Someone")); - CHECK(res2.folderId == 2); - CHECK(res2.placement == Placement::kRule); - } - - SECTION("ops are case-insensitive") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A"))}, {}, - {{RuleField::kName, RuleOp::kEquals, QStringLiteral("hello world"), 2}}); - CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), QStringLiteral("")).folderId == 2); - CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World!"), QStringLiteral("")).folderId == kRootId); - } - - SECTION("no package id or no match is ungrouped") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A"))}, {}, - {{RuleField::kName, RuleOp::kContains, QStringLiteral(""), 2}}); - CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).folderId == kRootId); - CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), QStringLiteral("")).placement == Placement::kNone); - - const OrganizerState state2 = makeState( - {folder(2, kRootId, QStringLiteral("A"))}, {}, - {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("zzz"), 2}}); - CHECK(folderFor(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), QStringLiteral("")).folderId == kRootId); - } +TEST_CASE( + "folder_for manual placement beats rules, and root placement blocks " + "rules") { + SECTION("manual beats rule") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {{QStringLiteral("x.mod"), 3}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("X.Mod"), + QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == 3); + CHECK(res.placement == Placement::kManual); + } + + SECTION("root placement blocks rules") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, + {{QStringLiteral("x.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("x.mod"), + QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == kRootId); + CHECK(res.placement == Placement::kManual); + } + + SECTION("first rule by position wins") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {}, + {{RuleField::kAuthor, RuleOp::kEquals, QStringLiteral("OSKAR"), 3}, + {RuleField::kName, RuleOp::kContains, QStringLiteral("core"), 2}}); + const Resolution res1 = + folderFor(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), + QStringLiteral("Oskar")); + CHECK(res1.folderId == 3); + CHECK(res1.placement == Placement::kRule); + const Resolution res2 = + folderFor(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), + QStringLiteral("Someone")); + CHECK(res2.folderId == 2); + CHECK(res2.placement == Placement::kRule); + } + + SECTION("ops are case-insensitive") { + const OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kEquals, + QStringLiteral("hello world"), 2}}); + CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), + QStringLiteral("")) + .folderId == 2); + CHECK(folderFor(state, QStringLiteral("a.b"), + QStringLiteral("Hello World!"), QStringLiteral("")) + .folderId == kRootId); + } + + SECTION("no package id or no match is ungrouped") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kContains, QStringLiteral(""), 2}}); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), + QStringLiteral("")) + .folderId == kRootId); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), + QStringLiteral("")) + .placement == Placement::kNone); + + const OrganizerState state2 = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("zzz"), 2}}); + CHECK(folderFor(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), + QStringLiteral("")) + .folderId == kRootId); + } } namespace { struct NestedFixture { - OrganizerState state; - ModsByUuid mods; + OrganizerState state; + ModsByUuid mods; }; NestedFixture makeNested() { - OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("Zeta")), folder(3, 2, QStringLiteral("inner")), - folder(4, kRootId, QStringLiteral("alpha"))}, - {{QStringLiteral("a.one"), 2}, - {QStringLiteral("a.two"), 3}, - {QStringLiteral("a.three"), 3}, - {QStringLiteral("b.one"), 4}}); - ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("two"), QStringLiteral("a.two"))}, - {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"))}, - {QStringLiteral("u4"), makeMod(QStringLiteral("u4"), QStringLiteral("B One"), QStringLiteral("b.one"))}, - {QStringLiteral("u5"), makeMod(QStringLiteral("u5"), QStringLiteral("loose"))}, - }; - return {std::move(state), std::move(mods)}; + OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("Zeta")), + folder(3, 2, QStringLiteral("inner")), + folder(4, kRootId, QStringLiteral("alpha"))}, + {{QStringLiteral("a.one"), 2}, + {QStringLiteral("a.two"), 3}, + {QStringLiteral("a.three"), 3}, + {QStringLiteral("b.one"), 4}}); + ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("two"), + QStringLiteral("a.two"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three"))}, + {QStringLiteral("u4"), + makeMod(QStringLiteral("u4"), QStringLiteral("B One"), + QStringLiteral("b.one"))}, + {QStringLiteral("u5"), + makeMod(QStringLiteral("u5"), QStringLiteral("loose"))}, + }; + return {std::move(state), std::move(mods)}; } } // namespace TEST_CASE("build_tree computes tri-state checks across nested folders") { - auto [state, mods] = makeNested(); - const FolderNode root = buildTree(state, mods, {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); - const FolderNode* zeta = findNode(root, 2); - const FolderNode* inner = findNode(root, 3); - const FolderNode* alpha = findNode(root, 4); - REQUIRE((zeta && inner && alpha)); - - CHECK(inner->total == 2); - CHECK(inner->enabled == 2); - CHECK(inner->check == CheckState::kOn); - - CHECK(zeta->total == 3); - CHECK(zeta->enabled == 3); - CHECK(zeta->check == CheckState::kOn); - - CHECK(alpha->total == 1); - CHECK(alpha->enabled == 0); - CHECK(alpha->check == CheckState::kOff); - - CHECK(root.total == 5); - CHECK(root.enabled == 3); - CHECK(root.check == CheckState::kPartial); - - const FolderNode root2 = buildTree(state, mods, {QStringLiteral("u2")}); - CHECK(findNode(root2, 3)->check == CheckState::kPartial); - CHECK(findNode(root2, 2)->check == CheckState::kPartial); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree( + state, mods, + {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); + const FolderNode* alpha = findNode(root, 4); + REQUIRE((zeta && inner && alpha)); + + CHECK(inner->total == 2); + CHECK(inner->enabled == 2); + CHECK(inner->check == CheckState::kOn); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 3); + CHECK(zeta->check == CheckState::kOn); + + CHECK(alpha->total == 1); + CHECK(alpha->enabled == 0); + CHECK(alpha->check == CheckState::kOff); + + CHECK(root.total == 5); + CHECK(root.enabled == 3); + CHECK(root.check == CheckState::kPartial); + + const FolderNode root2 = buildTree(state, mods, {QStringLiteral("u2")}); + CHECK(findNode(root2, 3)->check == CheckState::kPartial); + CHECK(findNode(root2, 2)->check == CheckState::kPartial); } TEST_CASE("build_tree reports depths and places ungrouped mods at root") { - auto [state, mods] = makeNested(); - const FolderNode root = buildTree(state, mods, {}); - CHECK(root.depth == 0); - CHECK(findNode(root, 2)->depth == 1); - CHECK(findNode(root, 3)->depth == 2); - - REQUIRE(root.mods.size() == 1); - CHECK(root.mods[0].uuid == QStringLiteral("u5")); - CHECK(root.mods[0].placement == Placement::kNone); - CHECK_FALSE(root.mods[0].packageId.has_value()); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); + CHECK(root.depth == 0); + CHECK(findNode(root, 2)->depth == 1); + CHECK(findNode(root, 3)->depth == 2); + + REQUIRE(root.mods.size() == 1); + CHECK(root.mods[0].uuid == QStringLiteral("u5")); + CHECK(root.mods[0].placement == Placement::kNone); + CHECK_FALSE(root.mods[0].packageId.has_value()); } TEST_CASE("build_tree sorts siblings and mods by name casefolded") { - auto [state, mods] = makeNested(); - const FolderNode root = buildTree(state, mods, {}); - REQUIRE(root.children.size() == 2); - CHECK(root.children[0].folder.name == QStringLiteral("alpha")); - CHECK(root.children[1].folder.name == QStringLiteral("Zeta")); - - const FolderNode* inner = findNode(root, 3); - REQUIRE(inner->mods.size() == 2); - CHECK(inner->mods[0].name == QStringLiteral("Three")); - CHECK(inner->mods[1].name == QStringLiteral("two")); + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); + REQUIRE(root.children.size() == 2); + CHECK(root.children[0].folder.name == QStringLiteral("alpha")); + CHECK(root.children[1].folder.name == QStringLiteral("Zeta")); + + const FolderNode* inner = findNode(root, 3); + REQUIRE(inner->mods.size() == 2); + CHECK(inner->mods[0].name == QStringLiteral("Three")); + CHECK(inner->mods[1].name == QStringLiteral("two")); } TEST_CASE("build_tree query prunes nodes but counts cover all mods") { - auto [state, mods] = makeNested(); - TreeQuery query; - query.text = QStringLiteral("TWO"); - - const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, query); - REQUIRE(root.children.size() == 1); - CHECK(root.children[0].folder.id == 2); - - const FolderNode* zeta = findNode(root, 2); - const FolderNode* inner = findNode(root, 3); - REQUIRE((zeta && inner)); - CHECK(zeta->mods.isEmpty()); - REQUIRE(inner->mods.size() == 1); - CHECK(inner->mods[0].uuid == QStringLiteral("u2")); - - CHECK(zeta->total == 3); - CHECK(zeta->enabled == 1); - CHECK(zeta->visibleTotal == 1); - CHECK(root.total == 5); - CHECK(root.visibleTotal == 1); - CHECK(root.mods.isEmpty()); + auto [state, mods] = makeNested(); + TreeQuery query; + query.text = QStringLiteral("TWO"); + + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, query); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); + REQUIRE((zeta && inner)); + CHECK(zeta->mods.isEmpty()); + REQUIRE(inner->mods.size() == 1); + CHECK(inner->mods[0].uuid == QStringLiteral("u2")); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 1); + CHECK(zeta->visibleTotal == 1); + CHECK(root.total == 5); + CHECK(root.visibleTotal == 1); + CHECK(root.mods.isEmpty()); } TEST_CASE("an empty query does not prune empty folders") { - const OrganizerState state = makeState({folder(2, kRootId, QStringLiteral("Empty"))}); - const FolderNode root = buildTree(state, {}, {}, TreeQuery{}); - REQUIRE(root.children.size() == 1); - CHECK(root.children[0].folder.id == 2); - CHECK(findNode(root, 2)->check == CheckState::kOff); + const OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("Empty"))}); + const FolderNode root = buildTree(state, {}, {}, TreeQuery{}); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + CHECK(findNode(root, 2)->check == CheckState::kOff); } TEST_CASE("build_tree filters by status, tags, provider and text") { - const OrganizerState state = makeState( - {}, {}, {}, - {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}); - - const ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, - {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"))}, - }; - - const auto visible = [&](const TreeQuery& q) { - const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, q); - QSet uuids; - for (const ModLeaf& leaf : root.allMods()) { - uuids.insert(leaf.uuid); - } - return uuids; - }; - - TreeQuery activeQ; - activeQ.status = StatusFilter::kActive; - CHECK(visible(activeQ) == QSet{QStringLiteral("u1")}); - - TreeQuery inactiveQ; - inactiveQ.status = StatusFilter::kInactive; - CHECK(visible(inactiveQ) == QSet{QStringLiteral("u2"), QStringLiteral("u3")}); - - TreeQuery tag1Q; - tag1Q.tagIds = {1}; - CHECK(visible(tag1Q) == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); - - TreeQuery tag12Q; - tag12Q.tagIds = {1, 2}; - CHECK(visible(tag12Q) == QSet{QStringLiteral("u1")}); - - TreeQuery steamQ; - steamQ.providerIds = {QStringLiteral("steam")}; - CHECK(visible(steamQ) == QSet{QStringLiteral("u1")}); - - TreeQuery textQ; - textQ.text = QStringLiteral("ludeon"); - CHECK(visible(textQ) == QSet{QStringLiteral("u2")}); - - TreeQuery pidQ; - pidQ.text = QStringLiteral("a.one"); - CHECK(visible(pidQ) == QSet{QStringLiteral("u1")}); + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"))}, + }; + + const auto visible = [&](const TreeQuery& q) { + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, q); + QSet uuids; + for (const ModLeaf& leaf : root.allMods()) { + uuids.insert(leaf.uuid); + } + return uuids; + }; + + TreeQuery activeQ; + activeQ.status = StatusFilter::kActive; + CHECK(visible(activeQ) == QSet{QStringLiteral("u1")}); + + TreeQuery inactiveQ; + inactiveQ.status = StatusFilter::kInactive; + CHECK(visible(inactiveQ) == + QSet{QStringLiteral("u2"), QStringLiteral("u3")}); + + TreeQuery tag1Q; + tag1Q.tagIds = {1}; + CHECK(visible(tag1Q) == + QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + TreeQuery tag12Q; + tag12Q.tagIds = {1, 2}; + CHECK(visible(tag12Q) == QSet{QStringLiteral("u1")}); + + TreeQuery steamQ; + steamQ.providerIds = {QStringLiteral("steam")}; + CHECK(visible(steamQ) == QSet{QStringLiteral("u1")}); + + TreeQuery textQ; + textQ.text = QStringLiteral("ludeon"); + CHECK(visible(textQ) == QSet{QStringLiteral("u2")}); + + TreeQuery pidQ; + pidQ.text = QStringLiteral("a.one"); + CHECK(visible(pidQ) == QSet{QStringLiteral("u1")}); } TEST_CASE("mods sharing a package id share their placement and tags") { - const OrganizerState state = makeState( - {folder(2, kRootId, QStringLiteral("A"))}, - {{QStringLiteral("dup.mod"), 2}}, {}, - {{QStringLiteral("dup.mod"), {7}}}); - - const ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Copy 1"), QStringLiteral("Dup.Mod"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Copy 2"), QStringLiteral("dup.mod"))}, - }; - - const FolderNode root = buildTree(state, mods, {}); - const FolderNode* a = findNode(root, 2); - REQUIRE(a != nullptr); - REQUIRE(a->mods.size() == 2); - CHECK(a->mods[0].uuid == QStringLiteral("u1")); - CHECK(a->mods[1].uuid == QStringLiteral("u2")); - CHECK(a->mods[0].tagIds == QSet{7}); - CHECK(a->mods[1].tagIds == QSet{7}); + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, + {{QStringLiteral("dup.mod"), 2}}, {}, {{QStringLiteral("dup.mod"), {7}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Copy 1"), + QStringLiteral("Dup.Mod"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Copy 2"), + QStringLiteral("dup.mod"))}, + }; + + const FolderNode root = buildTree(state, mods, {}); + const FolderNode* a = findNode(root, 2); + REQUIRE(a != nullptr); + REQUIRE(a->mods.size() == 2); + CHECK(a->mods[0].uuid == QStringLiteral("u1")); + CHECK(a->mods[1].uuid == QStringLiteral("u2")); + CHECK(a->mods[0].tagIds == QSet{7}); + CHECK(a->mods[1].tagIds == QSet{7}); } TEST_CASE("build_tree computes own counts and flags folders that have rules") { - auto [base_state, mods] = makeNested(); - QList nestedFolders; - for (const Folder& f : base_state.folders) { - if (f.id != kRootId) { - nestedFolders.append(f); - } + auto [baseState, mods] = makeNested(); + QList nestedFolders; + for (const Folder& f : baseState.folders) { + if (f.id != kRootId) { + nestedFolders.append(f); } - const OrganizerState state = makeState( - nestedFolders, base_state.placements, - {{RuleField::kName, RuleOp::kContains, QStringLiteral("zzz"), 3}}); - - TreeQuery query; - query.text = QStringLiteral("nothing"); - const FolderNode root = buildTree(state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); - CHECK(root.ownTotal == 1); - CHECK(root.ownEnabled == 1); - CHECK(root.ownCheck() == CheckState::kOn); - CHECK(root.total == 5); - CHECK(root.check == CheckState::kPartial); - - const FolderNode full = buildTree(state, mods, {}); - QList ruledIds; - for (const FolderNode* node : full.walk()) { - if (node->hasRules) { - ruledIds.append(node->folder.id); - } + } + const OrganizerState state = makeState( + nestedFolders, baseState.placements, + {{RuleField::kName, RuleOp::kContains, QStringLiteral("zzz"), 3}}); + + TreeQuery query; + query.text = QStringLiteral("nothing"); + const FolderNode root = buildTree( + state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); + CHECK(root.ownTotal == 1); + CHECK(root.ownEnabled == 1); + CHECK(root.ownCheck() == CheckState::kOn); + CHECK(root.total == 5); + CHECK(root.check == CheckState::kPartial); + + const FolderNode full = buildTree(state, mods, {}); + QList ruledIds; + for (const FolderNode* node : full.walk()) { + if (node->hasRules) { + ruledIds.append(node->folder.id); } - CHECK(ruledIds == QList{3}); - const FolderNode* zeta = findNode(full, 2); - REQUIRE(zeta != nullptr); - CHECK(zeta->ownTotal == 1); + } + CHECK(ruledIds == QList{3}); + const FolderNode* zeta = findNode(full, 2); + REQUIRE(zeta != nullptr); + CHECK(zeta->ownTotal == 1); } TEST_CASE("TreeFilter::query builds a query matching its preset") { - TreeFilter tf; - tf.key = QStringLiteral("tag:10"); - tf.label = QStringLiteral("Medieval"); - tf.count = 3; - tf.tagId = 10; - - const TreeQuery q = tf.query(QStringLiteral("sword")); - CHECK(q.tagIds == QSet{10}); - CHECK(q.text == QStringLiteral("sword")); - CHECK(q.status == StatusFilter::kAll); - CHECK(q.providerIds.isEmpty()); - CHECK_FALSE(q.isEmpty()); + TreeFilter tf; + tf.key = QStringLiteral("tag:10"); + tf.label = QStringLiteral("Medieval"); + tf.count = 3; + tf.tagId = 10; + + const TreeQuery q = tf.query(QStringLiteral("sword")); + CHECK(q.tagIds == QSet{10}); + CHECK(q.text == QStringLiteral("sword")); + CHECK(q.status == StatusFilter::kAll); + CHECK(q.providerIds.isEmpty()); + CHECK_FALSE(q.isEmpty()); } -TEST_CASE("tree_filters preserves order, counts status and counts tags across mods") { - const Tag t1{.id = 1, .name = QStringLiteral("Medieval"), .color = QStringLiteral("#5eead4")}; - const Tag t2{.id = 2, .name = QStringLiteral("Combat"), .color = QStringLiteral("#fb923c")}; - const Tag t3{.id = 3, .name = QStringLiteral("Magic"), .color = QStringLiteral("#a3e635")}; - - const OrganizerState state = makeState( - {}, {}, {}, - {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}, - {t1, t2, t3}); - - const ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two"), {}, QStringLiteral("local"))}, - {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, - }; - - const QList filters = treeFilters(mods, {QStringLiteral("u1")}, &state); - REQUIRE(filters.size() >= 3); - CHECK(filters[0].key == QStringLiteral("all")); - CHECK(filters[0].count == 3); - CHECK(filters[1].key == QStringLiteral("active")); - CHECK(filters[1].count == 1); - CHECK(filters[2].key == QStringLiteral("inactive")); - CHECK(filters[2].count == 2); - - QList providers; - for (const TreeFilter& f : filters) { - if (f.providerId) { - providers.append(f); - } +TEST_CASE( + "tree_filters preserves order, counts status and counts tags across mods") { + const Tag t1{.id = 1, + .name = QStringLiteral("Medieval"), + .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, + .name = QStringLiteral("Combat"), + .color = QStringLiteral("#fb923c")}; + const Tag t3{.id = 3, + .name = QStringLiteral("Magic"), + .color = QStringLiteral("#a3e635")}; + + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2, t3}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, + }; + + const QList filters = + treeFilters(mods, {QStringLiteral("u1")}, &state); + REQUIRE(filters.size() >= 3); + CHECK(filters[0].key == QStringLiteral("all")); + CHECK(filters[0].count == 3); + CHECK(filters[1].key == QStringLiteral("active")); + CHECK(filters[1].count == 1); + CHECK(filters[2].key == QStringLiteral("inactive")); + CHECK(filters[2].count == 2); + + QList providers; + for (const TreeFilter& f : filters) { + if (f.providerId) { + providers.append(f); } - REQUIRE(providers.size() == 2); - CHECK(providers[0].key == QStringLiteral("provider:local")); - CHECK(providers[0].count == 1); - CHECK(providers[1].key == QStringLiteral("provider:steam")); - CHECK(providers[1].count == 2); - - QList tags; - for (const TreeFilter& f : filters) { - if (f.tagId) { - tags.append(f); - } + } + REQUIRE(providers.size() == 2); + CHECK(providers[0].key == QStringLiteral("provider:local")); + CHECK(providers[0].count == 1); + CHECK(providers[1].key == QStringLiteral("provider:steam")); + CHECK(providers[1].count == 2); + + QList tags; + for (const TreeFilter& f : filters) { + if (f.tagId) { + tags.append(f); } - REQUIRE(tags.size() == 3); - CHECK(tags[0].key == QStringLiteral("tag:2")); - CHECK(tags[0].label == QStringLiteral("Combat")); - CHECK(tags[0].count == 1); - - CHECK(tags[1].key == QStringLiteral("tag:3")); - CHECK(tags[1].label == QStringLiteral("Magic")); - CHECK(tags[1].count == 0); - - CHECK(tags[2].key == QStringLiteral("tag:1")); - CHECK(tags[2].label == QStringLiteral("Medieval")); - CHECK(tags[2].count == 2); + } + REQUIRE(tags.size() == 3); + CHECK(tags[0].key == QStringLiteral("tag:2")); + CHECK(tags[0].label == QStringLiteral("Combat")); + CHECK(tags[0].count == 1); + + CHECK(tags[1].key == QStringLiteral("tag:3")); + CHECK(tags[1].label == QStringLiteral("Magic")); + CHECK(tags[1].count == 0); + + CHECK(tags[2].key == QStringLiteral("tag:1")); + CHECK(tags[2].label == QStringLiteral("Medieval")); + CHECK(tags[2].count == 2); } TEST_CASE("tree_filters counts all copies sharing a package id") { - const Tag t5{.id = 5, .name = QStringLiteral("Overhaul"), .color = QStringLiteral("#5eead4")}; - const OrganizerState state = makeState({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); - - const ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, - {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, - }; - - const QList filters = treeFilters(mods, {}, &state); - const auto it = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:5"); }); - REQUIRE(it != filters.cend()); - CHECK(it->count == 2); - CHECK(it->label == QStringLiteral("Overhaul")); - CHECK(it->tagId == 5); + const Tag t5{.id = 5, + .name = QStringLiteral("Overhaul"), + .color = QStringLiteral("#5eead4")}; + const OrganizerState state = + makeState({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), + QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Local Copy"), + QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Other Mod"), + QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, + }; + + const QList filters = treeFilters(mods, {}, &state); + const auto it = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:5"); }); + REQUIRE(it != filters.cend()); + CHECK(it->count == 2); + CHECK(it->label == QStringLiteral("Overhaul")); + CHECK(it->tagId == 5); } -TEST_CASE("tree_filters without state or without tags only produces presets and providers") { - const ModsByUuid mods{{QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one"))}}; - - const QList withoutState = treeFilters(mods, {}, nullptr); - QStringList keys; - for (const TreeFilter& f : withoutState) { - keys.append(f.key); - } - CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), QStringLiteral("inactive"), QStringLiteral("provider:local")}); - - const OrganizerState emptyState = makeState(); - const QList withEmptyState = treeFilters(mods, {}, &emptyState); - QStringList emptyKeys; - for (const TreeFilter& f : withEmptyState) { - emptyKeys.append(f.key); - } - CHECK(emptyKeys == keys); +TEST_CASE( + "tree_filters without state or without tags only produces presets and " + "providers") { + const ModsByUuid mods{{QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"))}}; + + const QList withoutState = treeFilters(mods, {}, nullptr); + QStringList keys; + for (const TreeFilter& f : withoutState) { + keys.append(f.key); + } + CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), + QStringLiteral("inactive"), + QStringLiteral("provider:local")}); + + const OrganizerState emptyState = makeState(); + const QList withEmptyState = treeFilters(mods, {}, &emptyState); + QStringList emptyKeys; + for (const TreeFilter& f : withEmptyState) { + emptyKeys.append(f.key); + } + CHECK(emptyKeys == keys); } TEST_CASE("filtering tree with a tag query selects only tagged mods") { - const Tag t1{.id = 1, .name = QStringLiteral("Tagged"), .color = QStringLiteral("#5eead4")}; - const Tag t2{.id = 2, .name = QStringLiteral("Empty"), .color = QStringLiteral("#fb923c")}; - const OrganizerState state = makeState( - {}, {}, {}, - {{QStringLiteral("a.one"), {1}}, {QStringLiteral("a.two"), {1}}}, - {t1, t2}); - - const ModsByUuid mods{ - {QStringLiteral("u1"), makeMod(QStringLiteral("u1"), QStringLiteral("Mod One"), QStringLiteral("a.one"))}, - {QStringLiteral("u2"), makeMod(QStringLiteral("u2"), QStringLiteral("Mod Two"), QStringLiteral("a.two"))}, - {QStringLiteral("u3"), makeMod(QStringLiteral("u3"), QStringLiteral("Mod Three"), QStringLiteral("a.three"))}, - }; - - const QList filters = treeFilters(mods, {}, &state); - const auto it1 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:1"); }); - REQUIRE(it1 != filters.cend()); - - const FolderNode tree1 = buildTree(state, mods, {}, it1->query()); - QSet visible1; - for (const ModLeaf& leaf : tree1.allMods()) { - visible1.insert(leaf.uuid); - } - CHECK(visible1 == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); - - const auto it2 = std::find_if(filters.cbegin(), filters.cend(), [](const TreeFilter& f) { return f.key == QStringLiteral("tag:2"); }); - REQUIRE(it2 != filters.cend()); - const FolderNode emptyTree = buildTree(state, mods, {}, it2->query()); - CHECK(emptyTree.allMods().isEmpty()); - CHECK(emptyTree.visibleTotal == 0); - - const FolderNode narrowed = buildTree(state, mods, {}, it1->query(QStringLiteral("Two"))); - QSet visibleNarrowed; - for (const ModLeaf& leaf : narrowed.allMods()) { - visibleNarrowed.insert(leaf.uuid); - } - CHECK(visibleNarrowed == QSet{QStringLiteral("u2")}); + const Tag t1{.id = 1, + .name = QStringLiteral("Tagged"), + .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, + .name = QStringLiteral("Empty"), + .color = QStringLiteral("#fb923c")}; + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Mod One"), + QStringLiteral("a.one"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Mod Two"), + QStringLiteral("a.two"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Mod Three"), + QStringLiteral("a.three"))}, + }; + + const QList filters = treeFilters(mods, {}, &state); + const auto it1 = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:1"); }); + REQUIRE(it1 != filters.cend()); + + const FolderNode tree1 = buildTree(state, mods, {}, it1->query()); + QSet visible1; + for (const ModLeaf& leaf : tree1.allMods()) { + visible1.insert(leaf.uuid); + } + CHECK(visible1 == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + const auto it2 = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:2"); }); + REQUIRE(it2 != filters.cend()); + const FolderNode emptyTree = buildTree(state, mods, {}, it2->query()); + CHECK(emptyTree.allMods().isEmpty()); + CHECK(emptyTree.visibleTotal == 0); + + const FolderNode narrowed = + buildTree(state, mods, {}, it1->query(QStringLiteral("Two"))); + QSet visibleNarrowed; + for (const ModLeaf& leaf : narrowed.allMods()) { + visibleNarrowed.insert(leaf.uuid); + } + CHECK(visibleNarrowed == QSet{QStringLiteral("u2")}); } diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp index 6b18b00..50ff519 100644 --- a/tests/core/organizer/test_service.cpp +++ b/tests/core/organizer/test_service.cpp @@ -1,10 +1,9 @@ -#include - +#include +#include #include #include -#include -#include +#include #include "core/models/metadata/structures.hpp" #include "core/organizer/db.hpp" @@ -24,513 +23,609 @@ using test_support::TestApplication; namespace { struct FakeContext { - QHash allMods; - QStringList activeUuids; - - void add(const QString& uuid, const QString& name, const QString& pid, - const QString& provider = QStringLiteral("local")) { - auto mod = std::make_shared(); - mod->name = name; - mod->providerId = provider; - mod->packageId = CaseInsensitiveStr(QStringView(pid)); - allMods.insert(uuid, mod); - } + QHash allMods; + QStringList activeUuids; + + void add(const QString& uuid, const QString& name, const QString& pid, + const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(pid)); + allMods.insert(uuid, mod); + } }; struct ChangeCounter { - int count = 0; - void record(const std::monostate&) { ++count; } + int count = 0; + void record(const std::monostate&) { ++count; } }; struct TestHarness { - TestApplication app; - QTemporaryDir dir; - FakeContext ctx; - OrganizerDb db; - OrganizerService svc; - ChangeCounter changes; - - explicit TestHarness() - : dir(), - db(dir.filePath(QStringLiteral("organizer.db"))), - svc([this]() -> const auto& { return ctx.allMods; }, - [this]() -> const auto& { return ctx.activeUuids; }, db) { - REQUIRE(dir.isValid()); - svc.changed.connect([this](const std::monostate& m) { changes.record(m); }); - wait(svc.init()); - // Reset counter after init notification so tests count mutations only. - changes.count = 0; - } + TestApplication app; + QTemporaryDir dir; + FakeContext ctx; + OrganizerDb db; + OrganizerService svc; + ChangeCounter changes; + + explicit TestHarness() + : dir(), + db(dir.filePath(QStringLiteral("organizer.db"))), + svc([this]() -> const auto& { return ctx.allMods; }, + [this]() -> const auto& { return ctx.activeUuids; }, db) { + REQUIRE(dir.isValid()); + svc.changed.connect([this](const std::monostate& m) { changes.record(m); }); + wait(svc.init()); + // Reset counter after init notification so tests count mutations only. + changes.count = 0; + } - ~TestHarness() { - wait(svc.shutdown()); - } + ~TestHarness() { wait(svc.shutdown()); } }; } // namespace TEST_CASE("bulk folder expansion and collapse persists") { - TestHarness h; - const Folder parent = expect(wait(h.svc.createFolder(QStringLiteral("Parent")))); - const Folder child = expect(wait(h.svc.createFolder(QStringLiteral("Child"), parent.id))); - const Folder other = expect(wait(h.svc.createFolder(QStringLiteral("Other")))); - - expect(wait(h.svc.setAllCollapsed(true))); - OrganizerState persisted = expect(wait(h.db.load())); - CHECK(persisted.folders.value(parent.id).collapsed); - CHECK(persisted.folders.value(child.id).collapsed); - CHECK(persisted.folders.value(other.id).collapsed); - - expect(wait(h.svc.setCollapsed(child.id, false))); - expect(wait(h.svc.setAllCollapsed(false))); - persisted = expect(wait(h.db.load())); - CHECK_FALSE(persisted.folders.value(parent.id).collapsed); - CHECK_FALSE(persisted.folders.value(child.id).collapsed); - CHECK_FALSE(persisted.folders.value(other.id).collapsed); + TestHarness h; + const Folder parent = + expect(wait(h.svc.createFolder(QStringLiteral("Parent")))); + const Folder child = + expect(wait(h.svc.createFolder(QStringLiteral("Child"), parent.id))); + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + + expect(wait(h.svc.setAllCollapsed(true))); + OrganizerState persisted = expect(wait(h.db.load())); + CHECK(persisted.folders.value(parent.id).collapsed); + CHECK(persisted.folders.value(child.id).collapsed); + CHECK(persisted.folders.value(other.id).collapsed); + + expect(wait(h.svc.setCollapsed(child.id, false))); + expect(wait(h.svc.setAllCollapsed(false))); + persisted = expect(wait(h.db.load())); + CHECK_FALSE(persisted.folders.value(parent.id).collapsed); + CHECK_FALSE(persisted.folders.value(child.id).collapsed); + CHECK_FALSE(persisted.folders.value(other.id).collapsed); } TEST_CASE("folder depth cannot exceed maximum depth") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); - CHECK(h.changes.count == 3); - - expectError(wait(h.svc.createFolder(QStringLiteral("D"), c.id)), QStringLiteral("at most 3 levels")); - CHECK(h.changes.count == 3); - CHECK(h.svc.state().folders.size() == 4); + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); + CHECK(h.changes.count == 3); + + expectError(wait(h.svc.createFolder(QStringLiteral("D"), c.id)), + QStringLiteral("at most 3 levels")); + CHECK(h.changes.count == 3); + CHECK(h.svc.state().folders.size() == 4); } TEST_CASE("moving a folder respects subtree height") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); - const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); - - expectError(wait(h.svc.moveFolder(x.id, b.id)), QStringLiteral("at most 3 levels")); - - expect(wait(h.svc.moveFolder(x.id, a.id))); - CHECK(h.svc.state().folders.value(x.id).parentId == a.id); - CHECK(h.svc.state().folders.value(y.id).parentId == x.id); - CHECK(h.changes.count == 5); + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); + + expectError(wait(h.svc.moveFolder(x.id, b.id)), + QStringLiteral("at most 3 levels")); + + expect(wait(h.svc.moveFolder(x.id, a.id))); + CHECK(h.svc.state().folders.value(x.id).parentId == a.id); + CHECK(h.svc.state().folders.value(y.id).parentId == x.id); + CHECK(h.changes.count == 5); } TEST_CASE("moving a folder into its own subtree or moving root is rejected") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - - expectError(wait(h.svc.moveFolder(a.id, a.id)), QStringLiteral("into itself")); - expectError(wait(h.svc.moveFolder(a.id, b.id)), QStringLiteral("into itself")); - expectError(wait(h.svc.moveFolder(kRootId, a.id))); - - CHECK(h.changes.count == 2); - CHECK(h.svc.state().folders.value(a.id).parentId == kRootId); + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + + expectError(wait(h.svc.moveFolder(a.id, a.id)), + QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(a.id, b.id)), + QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(kRootId, a.id))); + + CHECK(h.changes.count == 2); + CHECK(h.svc.state().folders.value(a.id).parentId == kRootId); } -TEST_CASE("folder creation and rename reject duplicate names case-insensitively") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("Graphics")))); - expectError(wait(h.svc.createFolder(QStringLiteral(" graphics "))), QStringLiteral("already exists")); - - const Folder other = expect(wait(h.svc.createFolder(QStringLiteral("Other")))); - expectError(wait(h.svc.renameFolder(other.id, QStringLiteral("GRAPHICS"))), QStringLiteral("already exists")); - - const Folder inOther = expect(wait(h.svc.createFolder(QStringLiteral("Graphics"), other.id))); - CHECK(inOther.parentId == other.id); - - expect(wait(h.svc.renameFolder(a.id, QStringLiteral("graphics")))); - CHECK(h.svc.state().folders.value(a.id).name == QStringLiteral("graphics")); - CHECK(h.changes.count == 4); +TEST_CASE( + "folder creation and rename reject duplicate names case-insensitively") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("Graphics")))); + expectError(wait(h.svc.createFolder(QStringLiteral(" graphics "))), + QStringLiteral("already exists")); + + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + expectError(wait(h.svc.renameFolder(other.id, QStringLiteral("GRAPHICS"))), + QStringLiteral("already exists")); + + const Folder inOther = + expect(wait(h.svc.createFolder(QStringLiteral("Graphics"), other.id))); + CHECK(inOther.parentId == other.id); + + expect(wait(h.svc.renameFolder(a.id, QStringLiteral("graphics")))); + CHECK(h.svc.state().folders.value(a.id).name == QStringLiteral("graphics")); + CHECK(h.changes.count == 4); } TEST_CASE("empty folder name is rejected") { - TestHarness h; - expectError(wait(h.svc.createFolder(QStringLiteral(" "))), QStringLiteral("cannot be empty")); - CHECK(h.changes.count == 0); + TestHarness h; + expectError(wait(h.svc.createFolder(QStringLiteral(" "))), + QStringLiteral("cannot be empty")); + CHECK(h.changes.count == 0); } TEST_CASE("create_folder_from emits once and rolls back on duplicate") { - TestHarness h; - const Folder f = expect(wait(h.svc.createFolderFrom( - QStringLiteral("Core"), {QStringLiteral("mod.a"), QStringLiteral("mod.b")}))); - CHECK(h.changes.count == 1); - CHECK(f.name == QStringLiteral("Core")); - CHECK(h.svc.state().placements.value(QStringLiteral("mod.a")) == f.id); - CHECK(h.svc.state().placements.value(QStringLiteral("mod.b")) == f.id); - - const OrganizerState stateBefore = h.svc.state(); - const int changesBefore = h.changes.count; - - expectError(wait(h.svc.createFolderFrom(QStringLiteral("core"), {QStringLiteral("mod.c")}))); - CHECK(h.changes.count == changesBefore); - CHECK(h.svc.state() == stateBefore); - - const OrganizerState dbState = expect(wait(h.db.load())); - CHECK_FALSE(dbState.placements.contains(QStringLiteral("mod.c"))); - CHECK(dbState.folders.size() == stateBefore.folders.size()); + TestHarness h; + const Folder f = expect(wait(h.svc.createFolderFrom( + QStringLiteral("Core"), + {QStringLiteral("mod.a"), QStringLiteral("mod.b")}))); + CHECK(h.changes.count == 1); + CHECK(f.name == QStringLiteral("Core")); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.a")) == f.id); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.b")) == f.id); + + const OrganizerState stateBefore = h.svc.state(); + const int changesBefore = h.changes.count; + + expectError(wait(h.svc.createFolderFrom(QStringLiteral("core"), + {QStringLiteral("mod.c")}))); + CHECK(h.changes.count == changesBefore); + CHECK(h.svc.state() == stateBefore); + + const OrganizerState dbState = expect(wait(h.db.load())); + CHECK_FALSE(dbState.placements.contains(QStringLiteral("mod.c"))); + CHECK(dbState.folders.size() == stateBefore.folders.size()); } TEST_CASE("deleting a folder ungroups mods to root manual placement") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.activeUuids = {QStringLiteral("u1")}; - - const Folder a = expect(wait(h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("A.One")}))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); - expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kPrefix, - .pattern = QStringLiteral("a."), .folderId = a.id}}))); - CHECK(h.changes.count == 4); - - QStringList uuids = expect(h.svc.folderModUuids(a.id)); - std::sort(uuids.begin(), uuids.end()); - CHECK(uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - - expect(wait(h.svc.deleteFolder(a.id))); - CHECK(h.changes.count == 5); - CHECK(h.svc.state().folders.keys() == QList{kRootId}); - CHECK(h.svc.state().rules.isEmpty()); - - const FolderNode root = h.svc.tree(); - QStringList rootUuids; - for (const ModLeaf& leaf : root.mods) { - rootUuids.append(leaf.uuid); - CHECK(leaf.placement == Placement::kManual); - } - std::sort(rootUuids.begin(), rootUuids.end()); - CHECK(rootUuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - CHECK(root.total == 2); - CHECK(root.enabled == 1); - CHECK(root.check == CheckState::kPartial); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.activeUuids = {QStringLiteral("u1")}; + + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("A.One")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("a."), + .folderId = a.id}}))); + CHECK(h.changes.count == 4); + + QStringList uuids = expect(h.svc.folderModUuids(a.id)); + std::sort(uuids.begin(), uuids.end()); + CHECK(uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + expect(wait(h.svc.deleteFolder(a.id))); + CHECK(h.changes.count == 5); + CHECK(h.svc.state().folders.keys() == QList{kRootId}); + CHECK(h.svc.state().rules.isEmpty()); + + const FolderNode root = h.svc.tree(); + QStringList rootUuids; + for (const ModLeaf& leaf : root.mods) { + rootUuids.append(leaf.uuid); + CHECK(leaf.placement == Placement::kManual); + } + std::sort(rootUuids.begin(), rootUuids.end()); + CHECK(rootUuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(root.total == 2); + CHECK(root.enabled == 1); + CHECK(root.check == CheckState::kPartial); } TEST_CASE("place, ungroup and reset_to_rules alter placement source") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, - .pattern = QStringLiteral(" one "), .folderId = a.id}}))); - CHECK(h.svc.state().rules[0].pattern == QStringLiteral("one")); - CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); - - expect(wait(h.svc.ungroup({QStringLiteral("a.one")}))); - CHECK(h.svc.tree().mods[0].placement == Placement::kManual); - - expect(wait(h.svc.resetToRules({QStringLiteral("a.one")}))); - CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); - CHECK(h.changes.count == 4); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral(" one "), + .folderId = a.id}}))); + CHECK(h.svc.state().rules[0].pattern == QStringLiteral("one")); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); + + expect(wait(h.svc.ungroup({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().mods[0].placement == Placement::kManual); + + expect(wait(h.svc.resetToRules({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); + CHECK(h.changes.count == 4); } -TEST_CASE("rules validation rejects root target, empty pattern or missing folder") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - - expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, - .pattern = QStringLiteral("x"), .folderId = kRootId}})), - QStringLiteral("root")); - - expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, - .pattern = QStringLiteral(" "), .folderId = a.id}})), - QStringLiteral("pattern cannot be empty")); - - expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, .op = RuleOp::kEquals, - .pattern = QStringLiteral("x"), .folderId = 999}}))); - CHECK(h.changes.count == 1); +TEST_CASE( + "rules validation rejects root target, empty pattern or missing folder") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), + .folderId = kRootId}})), + QStringLiteral("root")); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral(" "), + .folderId = a.id}})), + QStringLiteral("pattern cannot be empty")); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), + .folderId = 999}}))); + CHECK(h.changes.count == 1); } TEST_CASE("tags lifecycle, validation and removal cascade") { - TestHarness h; - const Tag tag = expect(wait(h.svc.createTag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); - - expectError(wait(h.svc.createTag(QStringLiteral("qol"), QStringLiteral("#000000"))), QStringLiteral("already exists")); - expectError(wait(h.svc.createTag(QStringLiteral("Other"), QStringLiteral("red"))), QStringLiteral("not a valid color")); - - expect(wait(h.svc.setModTags({QStringLiteral("A.Mod")}, {tag.id}, {}))); - CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")) == QSet{tag.id}); - - expectError(wait(h.svc.setModTags({QStringLiteral("a.mod")}, {999}, {}))); - - expect(wait(h.svc.deleteTag(tag.id))); - CHECK(h.svc.state().tags.isEmpty()); - CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")).isEmpty()); - CHECK(h.changes.count == 3); + TestHarness h; + const Tag tag = expect( + wait(h.svc.createTag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); + + expectError( + wait(h.svc.createTag(QStringLiteral("qol"), QStringLiteral("#000000"))), + QStringLiteral("already exists")); + expectError( + wait(h.svc.createTag(QStringLiteral("Other"), QStringLiteral("red"))), + QStringLiteral("not a valid color")); + + expect(wait(h.svc.setModTags({QStringLiteral("A.Mod")}, {tag.id}, {}))); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")) == + QSet{tag.id}); + + expectError(wait(h.svc.setModTags({QStringLiteral("a.mod")}, {999}, {}))); + + expect(wait(h.svc.deleteTag(tag.id))); + CHECK(h.svc.state().tags.isEmpty()); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")).isEmpty()); + CHECK(h.changes.count == 3); } TEST_CASE("service state persists across instances") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString dbFile = dir.filePath(QStringLiteral("organizer.db")); - FakeContext ctx; - - Folder folder; - { - OrganizerDb db1(dbFile); - OrganizerService svc1([&]() -> const auto& { return ctx.allMods; }, - [&]() -> const auto& { return ctx.activeUuids; }, db1); - wait(svc1.init()); - folder = expect(wait(svc1.createFolder(QStringLiteral("Kept")))); - wait(svc1.shutdown()); - } - - { - OrganizerDb db2(dbFile); - OrganizerService svc2([&]() -> const auto& { return ctx.allMods; }, - [&]() -> const auto& { return ctx.activeUuids; }, db2); - wait(svc2.init()); - CHECK(svc2.state().folders.contains(folder.id)); - CHECK(svc2.state().folders.value(folder.id).name == QStringLiteral("Kept")); - wait(svc2.shutdown()); - } + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString dbFile = dir.filePath(QStringLiteral("organizer.db")); + FakeContext ctx; + + Folder folder; + { + OrganizerDb db1(dbFile); + OrganizerService svc1([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, + db1); + wait(svc1.init()); + folder = expect(wait(svc1.createFolder(QStringLiteral("Kept")))); + wait(svc1.shutdown()); + } + + { + OrganizerDb db2(dbFile); + OrganizerService svc2([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, + db2); + wait(svc2.init()); + CHECK(svc2.state().folders.contains(folder.id)); + CHECK(svc2.state().folders.value(folder.id).name == QStringLiteral("Kept")); + wait(svc2.shutdown()); + } } TEST_CASE("init signals readiness and emits changed") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - FakeContext ctx; - OrganizerDb db(dir.filePath(QStringLiteral("organizer.db"))); - OrganizerService svc([&]() -> const auto& { return ctx.allMods; }, - [&]() -> const auto& { return ctx.activeUuids; }, db); - ChangeCounter counter; - svc.changed.connect([&](const std::monostate& m) { counter.record(m); }); - - CHECK_FALSE(svc.ready()); - wait(svc.init()); - CHECK(svc.ready()); - CHECK(counter.count == 1); - wait(svc.shutdown()); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + FakeContext ctx; + OrganizerDb db(dir.filePath(QStringLiteral("organizer.db"))); + OrganizerService svc([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, db); + ChangeCounter counter; + svc.changed.connect([&](const std::monostate& m) { counter.record(m); }); + + CHECK_FALSE(svc.ready()); + wait(svc.init()); + CHECK(svc.ready()); + CHECK(counter.count == 1); + wait(svc.shutdown()); } TEST_CASE("folder_toggle enables partial/off and disables full") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.add(QStringLiteral("u3"), QStringLiteral("Loose"), QStringLiteral("loose.mod")); - - const Folder a = expect(wait(h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); - - h.ctx.activeUuids = {QStringLiteral("u1")}; - FolderToggle toggle = expect(h.svc.folderToggle(a.id)); - toggle.enable.sort(); - CHECK(toggle.enable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - CHECK(toggle.disable.isEmpty()); - - h.ctx.activeUuids = {QStringLiteral("u1"), QStringLiteral("u2")}; - toggle = expect(h.svc.folderToggle(a.id)); - toggle.disable.sort(); - CHECK(toggle.enable.isEmpty()); - CHECK(toggle.disable == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); - - toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); - CHECK(toggle.enable == QStringList{QStringLiteral("u3")}); - CHECK(toggle.disable.isEmpty()); - - h.ctx.activeUuids = {QStringLiteral("u3")}; - toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); - CHECK(toggle.enable.isEmpty()); - CHECK(toggle.disable == QStringList{QStringLiteral("u3")}); - - CHECK(expect(h.svc.folderModUuids(kRootId, /*recursive=*/false)) == QStringList{QStringLiteral("u3")}); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Loose"), + QStringLiteral("loose.mod")); + + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + + h.ctx.activeUuids = {QStringLiteral("u1")}; + FolderToggle toggle = expect(h.svc.folderToggle(a.id)); + toggle.enable.sort(); + CHECK(toggle.enable == + QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.activeUuids = {QStringLiteral("u1"), QStringLiteral("u2")}; + toggle = expect(h.svc.folderToggle(a.id)); + toggle.disable.sort(); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == + QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); + CHECK(toggle.enable == QStringList{QStringLiteral("u3")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.activeUuids = {QStringLiteral("u3")}; + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == QStringList{QStringLiteral("u3")}); + + CHECK(expect(h.svc.folderModUuids(kRootId, /*recursive=*/false)) == + QStringList{QStringLiteral("u3")}); } TEST_CASE("folder_move_targets respects subtree depth and duplicate names") { - TestHarness h; - const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); - const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); - const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); - const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); - const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); - const Folder clash = expect(wait(h.svc.createFolder(QStringLiteral("Clash")))); - const Folder innerX = expect(wait(h.svc.createFolder(QStringLiteral("X"), clash.id))); - - CHECK(expect(h.svc.folderMoveTargets(b.id)) == QSet{x.id, clash.id, kRootId}); - CHECK(expect(h.svc.folderMoveTargets(x.id)) == QSet{a.id}); - CHECK(expect(h.svc.folderMoveTargets(c.id)) == QSet{a.id, x.id, y.id, clash.id, innerX.id, kRootId}); - expectError(h.svc.folderMoveTargets(kRootId)); + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); + const Folder clash = + expect(wait(h.svc.createFolder(QStringLiteral("Clash")))); + const Folder innerX = + expect(wait(h.svc.createFolder(QStringLiteral("X"), clash.id))); + + CHECK(expect(h.svc.folderMoveTargets(b.id)) == + QSet{x.id, clash.id, kRootId}); + CHECK(expect(h.svc.folderMoveTargets(x.id)) == QSet{a.id}); + CHECK(expect(h.svc.folderMoveTargets(c.id)) == + QSet{a.id, x.id, y.id, clash.id, innerX.id, kRootId}); + expectError(h.svc.folderMoveTargets(kRootId)); } TEST_CASE("service tree_filters counts status and providers") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.activeUuids = {QStringLiteral("u1")}; - - QHash counts; - for (const TreeFilter& f : h.svc.treeFilters()) { - counts[f.key] = f.count; - } - CHECK(counts.value(QStringLiteral("all")) == 2); - CHECK(counts.value(QStringLiteral("active")) == 1); - CHECK(counts.value(QStringLiteral("inactive")) == 1); - CHECK(counts.value(QStringLiteral("provider:local")) == 2); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.activeUuids = {QStringLiteral("u1")}; + + QHash counts; + for (const TreeFilter& f : h.svc.treeFilters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("all")) == 2); + CHECK(counts.value(QStringLiteral("active")) == 1); + CHECK(counts.value(QStringLiteral("inactive")) == 1); + CHECK(counts.value(QStringLiteral("provider:local")) == 2); } TEST_CASE("service tree_filters with persisted tags and empty tag") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), QStringLiteral("a.one")); - h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), QStringLiteral("a.two")); - h.ctx.add(QStringLiteral("u3"), QStringLiteral("Three"), QStringLiteral("a.three")); - - const Tag t1 = expect(wait(h.svc.createTag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); - const Tag t2 = expect(wait(h.svc.createTag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); - const Tag t3 = expect(wait(h.svc.createTag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); - - expect(wait(h.svc.setModTags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); - expect(wait(h.svc.setModTags({QStringLiteral("a.two")}, {t1.id}, {}))); - - QHash filterMap; - for (const TreeFilter& f : h.svc.treeFilters()) { - filterMap[f.key] = f; - } - - const QString k1 = QStringLiteral("tag:%1").arg(t1.id); - const QString k2 = QStringLiteral("tag:%1").arg(t2.id); - const QString k3 = QStringLiteral("tag:%1").arg(t3.id); - - REQUIRE(filterMap.contains(k1)); - CHECK(filterMap[k1].count == 2); - CHECK(filterMap[k1].label == QStringLiteral("Medieval")); - - REQUIRE(filterMap.contains(k2)); - CHECK(filterMap[k2].count == 1); - CHECK(filterMap[k2].label == QStringLiteral("Combat")); - - REQUIRE(filterMap.contains(k3)); - CHECK(filterMap[k3].count == 0); - CHECK(filterMap[k3].label == QStringLiteral("Empty")); - - expect(wait(h.svc.deleteTag(t2.id))); - filterMap.clear(); - for (const TreeFilter& f : h.svc.treeFilters()) { - filterMap[f.key] = f; - } - CHECK_FALSE(filterMap.contains(k2)); - CHECK(filterMap.contains(k1)); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three")); + + const Tag t1 = expect(wait( + h.svc.createTag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); + const Tag t2 = expect(wait( + h.svc.createTag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); + const Tag t3 = expect(wait( + h.svc.createTag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); + + expect(wait(h.svc.setModTags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); + expect(wait(h.svc.setModTags({QStringLiteral("a.two")}, {t1.id}, {}))); + + QHash filterMap; + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; + } + + const QString k1 = QStringLiteral("tag:%1").arg(t1.id); + const QString k2 = QStringLiteral("tag:%1").arg(t2.id); + const QString k3 = QStringLiteral("tag:%1").arg(t3.id); + + REQUIRE(filterMap.contains(k1)); + CHECK(filterMap[k1].count == 2); + CHECK(filterMap[k1].label == QStringLiteral("Medieval")); + + REQUIRE(filterMap.contains(k2)); + CHECK(filterMap[k2].count == 1); + CHECK(filterMap[k2].label == QStringLiteral("Combat")); + + REQUIRE(filterMap.contains(k3)); + CHECK(filterMap[k3].count == 0); + CHECK(filterMap[k3].label == QStringLiteral("Empty")); + + expect(wait(h.svc.deleteTag(t2.id))); + filterMap.clear(); + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; + } + CHECK_FALSE(filterMap.contains(k2)); + CHECK(filterMap.contains(k1)); } TEST_CASE("service tree_filters counts shared package ids across copies") { - TestHarness h; - h.ctx.add(QStringLiteral("u1"), QStringLiteral("Steam Copy"), QStringLiteral("shared.pkg"), QStringLiteral("steam")); - h.ctx.add(QStringLiteral("u2"), QStringLiteral("Local Copy"), QStringLiteral("shared.pkg"), QStringLiteral("local")); - h.ctx.add(QStringLiteral("u3"), QStringLiteral("Other Mod"), QStringLiteral("other.pkg"), QStringLiteral("local")); - - const Tag tag = expect(wait(h.svc.createTag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); - expect(wait(h.svc.setModTags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); - - QHash counts; - for (const TreeFilter& f : h.svc.treeFilters()) { - counts[f.key] = f.count; - } - CHECK(counts.value(QStringLiteral("tag:%1").arg(tag.id)) == 2); + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("Steam Copy"), + QStringLiteral("shared.pkg"), QStringLiteral("steam")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Local Copy"), + QStringLiteral("shared.pkg"), QStringLiteral("local")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Other Mod"), + QStringLiteral("other.pkg"), QStringLiteral("local")); + + const Tag tag = expect(wait( + h.svc.createTag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); + expect(wait(h.svc.setModTags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); + + QHash counts; + for (const TreeFilter& f : h.svc.treeFilters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("tag:%1").arg(tag.id)) == 2); } TEST_CASE("add_standard_rules is fresh and idempotent") { - TestHarness h; - const int total = static_cast(standardRules().size()); - QSet names; - for (const StandardRule& r : standardRules()) { - names.insert(r.folder); - } - - CHECK(expect(wait(h.svc.addStandardRules())) == total); - CHECK(h.changes.count == 1); - - QSet topFolders; - for (const Folder& f : h.svc.state().folders) { - if (f.parentId == kRootId) { - topFolders.insert(f.name); - } + TestHarness h; + const int total = static_cast(standardRules().size()); + QSet names; + for (const StandardRule& r : standardRules()) { + names.insert(r.folder); + } + + CHECK(expect(wait(h.svc.addStandardRules())) == total); + CHECK(h.changes.count == 1); + + QSet topFolders; + for (const Folder& f : h.svc.state().folders) { + if (f.parentId == kRootId) { + topFolders.insert(f.name); } - CHECK(topFolders == names); - CHECK(h.svc.state().folders.size() == names.size() + 1); - CHECK(h.svc.state().rules.size() == total); - - CHECK(expect(wait(h.svc.addStandardRules())) == 0); - CHECK(h.changes.count == 1); - CHECK(h.svc.state().rules.size() == total); - CHECK(h.svc.state().folders.size() == names.size() + 1); + } + CHECK(topFolders == names); + CHECK(h.svc.state().folders.size() == names.size() + 1); + CHECK(h.svc.state().rules.size() == total); + + CHECK(expect(wait(h.svc.addStandardRules())) == 0); + CHECK(h.changes.count == 1); + CHECK(h.svc.state().rules.size() == total); + CHECK(h.svc.state().folders.size() == names.size() + 1); } -TEST_CASE("add_standard_rules reuses existing folders and preserves user rules") { - TestHarness h; - const Folder official = expect(wait(h.svc.createFolder(QStringLiteral("OFFICIAL")))); - const Folder mine = expect(wait(h.svc.createFolder(QStringLiteral("Mine")))); - expect(wait(h.svc.setRules({ - RuleSpec{.field = RuleField::kAuthor, .op = RuleOp::kContains, .pattern = QStringLiteral("me"), .folderId = mine.id}, - RuleSpec{.field = RuleField::kPackageId, .op = RuleOp::kPrefix, .pattern = QStringLiteral("SARG."), .folderId = mine.id}, - }))); - h.changes.count = 0; - - const int total = static_cast(standardRules().size()); - CHECK(expect(wait(h.svc.addStandardRules())) == total - 1); - CHECK(h.changes.count == 1); - - QStringList names; - for (const Folder& f : h.svc.state().folders) { - names.append(f.name); - } - CHECK_FALSE(names.contains(QStringLiteral("Official"))); - CHECK_FALSE(names.contains(QStringLiteral("Alpha Mods"))); - CHECK(names.count(QStringLiteral("OFFICIAL")) == 1); - - const auto& rules = h.svc.state().rules; - REQUIRE(rules.size() >= 3); - CHECK(h.svc.state().folders.value(rules[0].folderId).name == QStringLiteral("Mine")); - CHECK(rules[0].pattern == QStringLiteral("me")); - CHECK(h.svc.state().folders.value(rules[1].folderId).name == QStringLiteral("Mine")); - CHECK(rules[1].pattern == QStringLiteral("SARG.")); - - // The first standard rule for Official got re-targeted to the existing OFFICIAL folder. - CHECK(rules[2].folderId == official.id); +TEST_CASE( + "add_standard_rules reuses existing folders and preserves user rules") { + TestHarness h; + const Folder official = + expect(wait(h.svc.createFolder(QStringLiteral("OFFICIAL")))); + const Folder mine = expect(wait(h.svc.createFolder(QStringLiteral("Mine")))); + expect(wait(h.svc.setRules({ + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kContains, + .pattern = QStringLiteral("me"), + .folderId = mine.id}, + RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("SARG."), + .folderId = mine.id}, + }))); + h.changes.count = 0; + + const int total = static_cast(standardRules().size()); + CHECK(expect(wait(h.svc.addStandardRules())) == total - 1); + CHECK(h.changes.count == 1); + + QStringList names; + for (const Folder& f : h.svc.state().folders) { + names.append(f.name); + } + CHECK_FALSE(names.contains(QStringLiteral("Official"))); + CHECK_FALSE(names.contains(QStringLiteral("Alpha Mods"))); + CHECK(names.count(QStringLiteral("OFFICIAL")) == 1); + + const auto& rules = h.svc.state().rules; + REQUIRE(rules.size() >= 3); + CHECK(h.svc.state().folders.value(rules[0].folderId).name == + QStringLiteral("Mine")); + CHECK(rules[0].pattern == QStringLiteral("me")); + CHECK(h.svc.state().folders.value(rules[1].folderId).name == + QStringLiteral("Mine")); + CHECK(rules[1].pattern == QStringLiteral("SARG.")); + + // The first standard rule for Official got re-targeted to the existing + // OFFICIAL folder. + CHECK(rules[2].folderId == official.id); } TEST_CASE("standard rules resolve mods correctly") { - TestHarness h; - expect(wait(h.svc.addStandardRules())); - const OrganizerState& state = h.svc.state(); - - const auto folderName = [&](const QString& pid, const QString& name = QStringLiteral("Mod"), - const QString& author = {}) { - const auto [fid, _] = folderFor(state, pid, name, author); - return state.folders.value(fid).name; - }; - - CHECK(folderName(QStringLiteral("oskarpotocki.vfe.core")) == QStringLiteral("Vanilla Expanded")); - CHECK(folderName(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), QStringLiteral("Oskar Potocki, Taranchuk")) == - QStringLiteral("Vanilla Expanded")); - CHECK(folderName(QStringLiteral("ludeon.rimworld.biotech"), QStringLiteral("Biotech")) == QStringLiteral("Official")); - CHECK(folderName(QStringLiteral("brrainz.harmony"), QStringLiteral("Harmony")) == QStringLiteral("Frameworks & Libraries")); - CHECK(folderName(QStringLiteral("ceteam.combatextended"), QStringLiteral("Combat Extended")) == QStringLiteral("Combat Extended")); - CHECK(folderName(QStringLiteral("sarg.alphagenes")) == QStringLiteral("Alpha Mods")); - CHECK(folderName(QStringLiteral("bs.performance"), QStringLiteral("Performance Fish")) == QStringLiteral("Performance")); - CHECK(folderName(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == QStringLiteral("Frameworks & Libraries")); - CHECK(folderName(QStringLiteral("vanillaracesexpanded.sanguophage")) == QStringLiteral("Vanilla Expanded")); - CHECK(folderName(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == QStringLiteral("Performance")); - CHECK(folderName(QStringLiteral("dubwise.rimatomics")) == QStringLiteral("Dubs Mods")); - CHECK(folderName(QStringLiteral("brrainz.achtung"), QStringLiteral("Achtung!")) == QStringLiteral("Quality of Life")); - CHECK(folderName(QStringLiteral("brrainz.jobsofopportunity")) == QStringLiteral("Root")); - CHECK(folderName(QStringLiteral("smartkar.athena.framework"), QStringLiteral("Athena Framework")) == QStringLiteral("Root")); - CHECK(folderName(QStringLiteral("someone.perf"), QStringLiteral("Performance Tweaks")) == QStringLiteral("Root")); - CHECK(folderName(QStringLiteral("someone.other"), QStringLiteral("Other")) == QStringLiteral("Root")); + TestHarness h; + expect(wait(h.svc.addStandardRules())); + const OrganizerState& state = h.svc.state(); + + const auto folderName = [&](const QString& pid, + const QString& name = QStringLiteral("Mod"), + const QString& author = {}) { + const auto [fid, _] = folderFor(state, pid, name, author); + return state.folders.value(fid).name; + }; + + CHECK(folderName(QStringLiteral("oskarpotocki.vfe.core")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), + QStringLiteral("Oskar Potocki, Taranchuk")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("ludeon.rimworld.biotech"), + QStringLiteral("Biotech")) == QStringLiteral("Official")); + CHECK(folderName(QStringLiteral("brrainz.harmony"), + QStringLiteral("Harmony")) == + QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("ceteam.combatextended"), + QStringLiteral("Combat Extended")) == + QStringLiteral("Combat Extended")); + CHECK(folderName(QStringLiteral("sarg.alphagenes")) == + QStringLiteral("Alpha Mods")); + CHECK(folderName(QStringLiteral("bs.performance"), + QStringLiteral("Performance Fish")) == + QStringLiteral("Performance")); + CHECK( + folderName(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == + QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("vanillaracesexpanded.sanguophage")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == + QStringLiteral("Performance")); + CHECK(folderName(QStringLiteral("dubwise.rimatomics")) == + QStringLiteral("Dubs Mods")); + CHECK(folderName(QStringLiteral("brrainz.achtung"), + QStringLiteral("Achtung!")) == + QStringLiteral("Quality of Life")); + CHECK(folderName(QStringLiteral("brrainz.jobsofopportunity")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("smartkar.athena.framework"), + QStringLiteral("Athena Framework")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.perf"), + QStringLiteral("Performance Tweaks")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.other"), QStringLiteral("Other")) == + QStringLiteral("Root")); } TEST_CASE("standard package_id rules are not shadowed") { - TestHarness h; - expect(wait(h.svc.addStandardRules())); - const OrganizerState& state = h.svc.state(); - - for (const StandardRule& rule : standardRules()) { - if (rule.field != RuleField::kPackageId) { - continue; - } - const QString probe = rule.op == RuleOp::kEquals ? rule.pattern : rule.pattern + QStringLiteral("x"); - const auto [fid, _] = folderFor(state, probe, QStringLiteral("Mod"), {}); - CHECK(state.folders.value(fid).name == rule.folder); + TestHarness h; + expect(wait(h.svc.addStandardRules())); + const OrganizerState& state = h.svc.state(); + + for (const StandardRule& rule : standardRules()) { + if (rule.field != RuleField::kPackageId) { + continue; } + const QString probe = rule.op == RuleOp::kEquals + ? rule.pattern + : rule.pattern + QStringLiteral("x"); + const auto [fid, _] = folderFor(state, probe, QStringLiteral("Mod"), {}); + CHECK(state.folders.value(fid).name == rule.folder); + } } diff --git a/tests/core/organizer/test_util.hpp b/tests/core/organizer/test_util.hpp index a5a5ff3..699b7eb 100644 --- a/tests/core/organizer/test_util.hpp +++ b/tests/core/organizer/test_util.hpp @@ -1,38 +1,40 @@ #pragma once +#include #include -#include #include -#include -#include "core/error.hpp" +#include + #include "../test_support.hpp" +#include "core/error.hpp" namespace organizer_test { -// Runs a coroutine to completion on the Qt event loop. Needs a test_support::TestApplication in scope. +// Runs a coroutine to completion on the Qt event loop. Needs a +// test_support::TestApplication in scope. template T wait(QCoro::Task task) { - return QCoro::waitFor(std::move(task)); + return QCoro::waitFor(std::move(task)); } template T expect(px::modrim::Result result) { - REQUIRE(result.has_value()); - return std::move(*result); + REQUIRE(result.has_value()); + return std::move(*result); } inline void expect(const px::modrim::Result& result) { - REQUIRE(result.has_value()); + REQUIRE(result.has_value()); } - template -void expectError(const px::modrim::Result& result, const QString& fragment = {}) { - REQUIRE_FALSE(result.has_value()); - if (!fragment.isEmpty()) { - CHECK(result.error().message.contains(fragment)); - } +void expectError(const px::modrim::Result& result, + const QString& fragment = {}) { + REQUIRE_FALSE(result.has_value()); + if (!fragment.isEmpty()) { + CHECK(result.error().message.contains(fragment)); + } } } // namespace organizer_test diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp index d190510..9ee90ad 100644 --- a/tests/core/providers/test_factory.cpp +++ b/tests/core/providers/test_factory.cpp @@ -1,8 +1,8 @@ -#include - #include #include +#include + #include "core/config.hpp" #include "core/providers/factory.hpp" #include "core/providers/local.hpp" @@ -12,33 +12,35 @@ using px::modrim::providers::createProviders; using px::modrim::providers::SteamWorkshopModProvider; TEST_CASE("no configured paths produce no providers") { - CHECK(createProviders(PathConfig{}).empty()); + CHECK(createProviders(PathConfig{}).empty()); } -TEST_CASE("each configured path yields its providers in core, local, downloaded, steam order") { - PathConfig paths; - paths.game = "/games/rimworld"; - paths.local = "/mods"; - paths.workshop = "/steam/workshop"; +TEST_CASE( + "each configured path yields its providers in core, local, downloaded, " + "steam order") { + PathConfig paths; + paths.game = "/games/rimworld"; + paths.local = "/mods"; + paths.workshop = "/steam/workshop"; - const auto providers = createProviders(paths); - QStringList ids; - for (const auto& provider : providers) { - ids.append(provider->providerId()); - } - CHECK(ids == QStringList{"core", "local", "downloaded", "steam"}); - CHECK(providers[0]->path() == QStringLiteral("/games/rimworld")); - CHECK(providers[1]->path() == QStringLiteral("/mods")); - CHECK(providers[2]->path() == QStringLiteral("/mods")); - CHECK(providers[3]->path() == QStringLiteral("/steam/workshop")); - CHECK(dynamic_cast(providers[3].get()) != nullptr); + const auto providers = createProviders(paths); + QStringList ids; + for (const auto& provider : providers) { + ids.append(provider->providerId()); + } + CHECK(ids == QStringList{"core", "local", "downloaded", "steam"}); + CHECK(providers[0]->path() == QStringLiteral("/games/rimworld")); + CHECK(providers[1]->path() == QStringLiteral("/mods")); + CHECK(providers[2]->path() == QStringLiteral("/mods")); + CHECK(providers[3]->path() == QStringLiteral("/steam/workshop")); + CHECK(dynamic_cast(providers[3].get()) != nullptr); } TEST_CASE("only the configured paths produce providers") { - PathConfig paths; - paths.local = "/mods"; - const auto providers = createProviders(paths); - REQUIRE(providers.size() == 2); - CHECK(providers[0]->providerId() == QStringLiteral("local")); - CHECK(providers[1]->providerId() == QStringLiteral("downloaded")); + PathConfig paths; + paths.local = "/mods"; + const auto providers = createProviders(paths); + REQUIRE(providers.size() == 2); + CHECK(providers[0]->providerId() == QStringLiteral("local")); + CHECK(providers[1]->providerId() == QStringLiteral("downloaded")); } diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp index 982de5b..62c4963 100644 --- a/tests/core/providers/test_providers.cpp +++ b/tests/core/providers/test_providers.cpp @@ -1,13 +1,13 @@ -#include - -#include - #include -#include #include #include #include #include +#include + +#include + +#include #include "core/providers/core.hpp" #include "core/providers/local.hpp" @@ -19,212 +19,229 @@ using px::modrim::Result; namespace { QCoreApplication& eventLoop() { - static int argc = 0; - static QCoreApplication app(argc, nullptr); - return app; + static int argc = 0; + static QCoreApplication app(argc, nullptr); + return app; } // Runs a discover() coroutine to completion on the Qt event loop. template T runToCompletion(QCoro::Task task) { - QCoreApplication& app = eventLoop(); - std::optional result; - auto driver = [](QCoro::Task inner, std::optional* out) -> QCoro::Task { - *out = co_await std::move(inner); - QCoreApplication::quit(); - }; - driver(std::move(task), &result); - // A discover() that never awaits finishes before the loop starts. - if (!result) { - app.exec(); - } - REQUIRE(result.has_value()); - return *result; + QCoreApplication& app = eventLoop(); + std::optional result; + auto driver = [](QCoro::Task inner, + std::optional* out) -> QCoro::Task { + *out = co_await std::move(inner); + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + // A discover() that never awaits finishes before the loop starts. + if (!result) { + app.exec(); + } + REQUIRE(result.has_value()); + return *result; } -// Writes a mod directory with About/About.xml, and optionally About/PublishedFileId.txt. -QString makeMod(const QString& root, const QString& folder, const QString& packageId, - bool withPublishedFileId) { - const QString modPath = QDir(root).filePath(folder); - REQUIRE(QDir(modPath).mkpath(QStringLiteral("About"))); - - QFile about(QDir(modPath).filePath(QStringLiteral("About/About.xml"))); - REQUIRE(about.open(QIODevice::WriteOnly)); - about.write(QStringLiteral("%1%1" - "
  • 1.5
  • " - "
    ") - .arg(packageId) - .toUtf8()); - - if (withPublishedFileId) { - QFile pfid(QDir(modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); - REQUIRE(pfid.open(QIODevice::WriteOnly)); - pfid.write("123456"); - } - return modPath; +// Writes a mod directory with About/About.xml, and optionally +// About/PublishedFileId.txt. +QString makeMod(const QString& root, const QString& folder, + const QString& packageId, bool withPublishedFileId) { + const QString modPath = QDir(root).filePath(folder); + REQUIRE(QDir(modPath).mkpath(QStringLiteral("About"))); + + QFile about(QDir(modPath).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(about.open(QIODevice::WriteOnly)); + about.write( + QStringLiteral("%1%1" + "
  • 1.5
  • " + "
    ") + .arg(packageId) + .toUtf8()); + + if (withPublishedFileId) { + QFile pfid( + QDir(modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); + REQUIRE(pfid.open(QIODevice::WriteOnly)); + pfid.write("123456"); + } + return modPath; } -// In-memory MetadataCacheStore. `serveCached` makes every candidate a cache hit. +// In-memory MetadataCacheStore. `serveCached` makes every candidate a cache +// hit. class FakeCache final : public MetadataCacheStore { -public: - bool serveCached = false; - ListedModPtr cachedMod; - int getCalls = 0; - QList stored; - - QCoro::Task getMods( - QList candidates, QString /*target_version*/) override { - ++getCalls; - px::modrim::providers::CacheLookup lookup; - if (serveCached) { - lookup.cached.append(cachedMod); - } else { - lookup.missing = candidates; - } - co_return lookup; - } - - QCoro::Task putMods(QList entries, QString /*target_version*/) override { - stored.append(entries); - co_return; + public: + bool serveCached = false; + ListedModPtr cachedMod; + int getCalls = 0; + QList stored; + + QCoro::Task getMods( + QList candidates, QString /*target_version*/) override { + ++getCalls; + px::modrim::providers::CacheLookup lookup; + if (serveCached) { + lookup.cached.append(cachedMod); + } else { + lookup.missing = candidates; } + co_return lookup; + } + + QCoro::Task putMods(QList entries, + QString /*target_version*/) override { + stored.append(entries); + co_return; + } }; } // namespace TEST_CASE("workshop provider discovers mods that carry a published file id") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString workshop = root.filePath(QStringLiteral("workshop")); - const QString modPath = makeMod(workshop, QStringLiteral("123456"), - QStringLiteral("author.workshop"), true); - - SteamWorkshopModProvider provider(workshop); - CHECK(provider.providerId() == QStringLiteral("steam")); - CHECK(provider.path() == workshop); - - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(discovered.has_value()); - REQUIRE(discovered->size() == 1); - const ListedModPtr mod = discovered->constBegin().value(); - CHECK(mod->modPath() == modPath); - CHECK(mod->providerId == QStringLiteral("steam")); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString workshop = root.filePath(QStringLiteral("workshop")); + const QString modPath = makeMod(workshop, QStringLiteral("123456"), + QStringLiteral("author.workshop"), true); + + SteamWorkshopModProvider provider(workshop); + CHECK(provider.providerId() == QStringLiteral("steam")); + CHECK(provider.path() == workshop); + + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + const ListedModPtr mod = discovered->constBegin().value(); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId == QStringLiteral("steam")); } -TEST_CASE("local and downloaded providers split mods on the published file id") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString local = root.path(); - const QString plain = makeMod(local, QStringLiteral("plain"), QStringLiteral("local.mod"), false); - const QString downloaded = - makeMod(local, QStringLiteral("downloaded"), QStringLiteral("dl.mod"), true); - - LocalModProvider localProvider(local); - DownloadedModProvider downloadedProvider(local); - CHECK(localProvider.providerId() == QStringLiteral("local")); - CHECK(downloadedProvider.providerId() == QStringLiteral("downloaded")); - - const auto localMods = runToCompletion(localProvider.discover(QStringLiteral("1.5"))); - REQUIRE(localMods.has_value()); - REQUIRE(localMods->size() == 1); - CHECK(localMods->contains(plain)); - - const auto downloadedMods = - runToCompletion(downloadedProvider.discover(QStringLiteral("1.5"))); - REQUIRE(downloadedMods.has_value()); - REQUIRE(downloadedMods->size() == 1); - CHECK(downloadedMods->contains(downloaded)); +TEST_CASE( + "local and downloaded providers split mods on the published file id") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString local = root.path(); + const QString plain = makeMod(local, QStringLiteral("plain"), + QStringLiteral("local.mod"), false); + const QString downloaded = makeMod(local, QStringLiteral("downloaded"), + QStringLiteral("dl.mod"), true); + + LocalModProvider localProvider(local); + DownloadedModProvider downloadedProvider(local); + CHECK(localProvider.providerId() == QStringLiteral("local")); + CHECK(downloadedProvider.providerId() == QStringLiteral("downloaded")); + + const auto localMods = + runToCompletion(localProvider.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(localMods->size() == 1); + CHECK(localMods->contains(plain)); + + const auto downloadedMods = + runToCompletion(downloadedProvider.discover(QStringLiteral("1.5"))); + REQUIRE(downloadedMods.has_value()); + REQUIRE(downloadedMods->size() == 1); + CHECK(downloadedMods->contains(downloaded)); } TEST_CASE("providers parse several mods through the thread pool") { - QTemporaryDir root; - REQUIRE(root.isValid()); - for (const char* folder : {"a", "b", "c"}) { - makeMod(root.path(), QString::fromUtf8(folder), - QStringLiteral("pool.%1").arg(QString::fromUtf8(folder)), false); - } - - QThreadPool pool; - LocalModProvider provider(root.path(), &pool); - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(discovered.has_value()); - CHECK(discovered->size() == 3); - for (const ListedModPtr& mod : *discovered) { - CHECK(mod->valid); - } + QTemporaryDir root; + REQUIRE(root.isValid()); + for (const char* folder : {"a", "b", "c"}) { + makeMod(root.path(), QString::fromUtf8(folder), + QStringLiteral("pool.%1").arg(QString::fromUtf8(folder)), false); + } + + QThreadPool pool; + LocalModProvider provider(root.path(), &pool); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->size() == 3); + for (const ListedModPtr& mod : *discovered) { + CHECK(mod->valid); + } } TEST_CASE("core provider reads mods from the game's Data directory") { - QTemporaryDir game; - REQUIRE(game.isValid()); - CoreModProvider provider(game.path()); - CHECK(provider.providerId() == QStringLiteral("core")); - CHECK(provider.color() == QStringLiteral("#e67e22")); - - // No Data/ directory: nothing to discover. - const auto missing = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(missing.has_value()); - CHECK(missing->isEmpty()); - - makeMod(QDir(game.path()).filePath(QStringLiteral("Data")), QStringLiteral("ludeon.rimworld"), - QStringLiteral("ludeon.rimworld"), false); - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(discovered.has_value()); - REQUIRE(discovered->size() == 1); - CHECK(discovered->constBegin().value()->name == QStringLiteral("Core")); + QTemporaryDir game; + REQUIRE(game.isValid()); + CoreModProvider provider(game.path()); + CHECK(provider.providerId() == QStringLiteral("core")); + CHECK(provider.color() == QStringLiteral("#e67e22")); + + // No Data/ directory: nothing to discover. + const auto missing = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(missing.has_value()); + CHECK(missing->isEmpty()); + + makeMod(QDir(game.path()).filePath(QStringLiteral("Data")), + QStringLiteral("ludeon.rimworld"), QStringLiteral("ludeon.rimworld"), + false); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value()->name == QStringLiteral("Core")); } TEST_CASE("cached metadata is returned without parsing") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString plain = makeMod(root.path(), QStringLiteral("plain"), - QStringLiteral("cached.mod"), false); - - FakeCache cache; - cache.serveCached = true; - cache.cachedMod = std::make_shared(); - REQUIRE(cache.cachedMod->setModPath(plain)); - - LocalModProvider provider(root.path(), nullptr, &cache); - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(discovered.has_value()); - REQUIRE(discovered->size() == 1); - CHECK(discovered->constBegin().value() == cache.cachedMod); - CHECK(cache.cachedMod->providerId == QStringLiteral("local")); - CHECK(cache.stored.isEmpty()); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("cached.mod"), false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + REQUIRE(cache.cachedMod->setModPath(plain)); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value() == cache.cachedMod); + CHECK(cache.cachedMod->providerId == QStringLiteral("local")); + CHECK(cache.stored.isEmpty()); } TEST_CASE("newly parsed About.xml data is stored in the cache") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString plain = makeMod(root.path(), QStringLiteral("plain"), - QStringLiteral("parsed.mod"), false); - - FakeCache cache; - LocalModProvider provider(root.path(), nullptr, &cache); - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"))); - REQUIRE(discovered.has_value()); - CHECK(discovered->contains(plain)); - - REQUIRE(cache.stored.size() == 1); - CHECK(cache.stored.first().candidate.modPath == plain); - CHECK(cache.stored.first().mod->valid); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("parsed.mod"), false); + + FakeCache cache; + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->contains(plain)); + + REQUIRE(cache.stored.size() == 1); + CHECK(cache.stored.first().candidate.modPath == plain); + CHECK(cache.stored.first().mod->valid); } TEST_CASE("force_reparse bypasses the cache lookup") { - QTemporaryDir root; - REQUIRE(root.isValid()); - makeMod(root.path(), QStringLiteral("plain"), QStringLiteral("forced.mod"), false); - - FakeCache cache; - cache.serveCached = true; - cache.cachedMod = std::make_shared(); - - LocalModProvider provider(root.path(), nullptr, &cache); - const auto discovered = runToCompletion(provider.discover(QStringLiteral("1.5"), nullptr, - nullptr, /*force_reparse=*/true)); - REQUIRE(discovered.has_value()); - CHECK(cache.getCalls == 0); - CHECK(discovered->size() == 1); - CHECK(discovered->constBegin().value() != cache.cachedMod); + QTemporaryDir root; + REQUIRE(root.isValid()); + makeMod(root.path(), QStringLiteral("plain"), QStringLiteral("forced.mod"), + false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = runToCompletion(provider.discover( + QStringLiteral("1.5"), nullptr, nullptr, /*force_reparse=*/true)); + REQUIRE(discovered.has_value()); + CHECK(cache.getCalls == 0); + CHECK(discovered->size() == 1); + CHECK(discovered->constBegin().value() != cache.cachedMod); } diff --git a/tests/core/services/services_support.hpp b/tests/core/services/services_support.hpp index 1043690..c950655 100644 --- a/tests/core/services/services_support.hpp +++ b/tests/core/services/services_support.hpp @@ -1,68 +1,72 @@ #pragma once +#include +#include +#include +#include #include #include -#include #include +#include -#include -#include #include -#include -#include -#include + +#include namespace services_test { -// Owns a QCoreApplication for one test case, unless one already exists (test_providers keeps a -// process-wide instance, and Qt asserts when a second one is created). +// Owns a QCoreApplication for one test case, unless one already exists +// (test_providers keeps a process-wide instance, and Qt asserts when a second +// one is created). class EventLoop { -public: - EventLoop() { - if (!QCoreApplication::instance()) { - m_app.emplace(m_argc, nullptr); - } + public: + EventLoop() { + if (!QCoreApplication::instance()) { + m_app.emplace(m_argc, nullptr); } + } -private: - int m_argc = 0; - std::optional m_app; + private: + int m_argc = 0; + std::optional m_app; }; -// Runs a coroutine to completion on the event loop. Coroutines that never suspend finish -// before the loop starts. +// Runs a coroutine to completion on the event loop. Coroutines that never +// suspend finish before the loop starts. template T runAsync(QCoro::Task task) { - // std::optional is ill-formed, so a void result is carried as std::monostate. - using Outcome = std::conditional_t, std::monostate, T>; - std::optional result; - auto driver = [](QCoro::Task inner, std::optional* out) -> QCoro::Task { - if constexpr (std::is_void_v) { - co_await std::move(inner); - out->emplace(); - } else { - *out = co_await std::move(inner); - } - QCoreApplication::quit(); - }; - driver(std::move(task), &result); - if (!result) { - QCoreApplication::exec(); - } - REQUIRE(result.has_value()); + // std::optional is ill-formed, so a void result is carried as + // std::monostate. + using Outcome = std::conditional_t, std::monostate, T>; + std::optional result; + auto driver = [](QCoro::Task inner, + std::optional* out) -> QCoro::Task { if constexpr (std::is_void_v) { - return; + co_await std::move(inner); + out->emplace(); } else { - return std::move(*result); + *out = co_await std::move(inner); } + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + if (!result) { + QCoreApplication::exec(); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } } inline bool writeText(const QString& path, const QByteArray& bytes) { - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - return false; - } - return file.write(bytes) == bytes.size(); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(bytes) == bytes.size(); } } // namespace services_test diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp index 9a892f6..9fa21ed 100644 --- a/tests/core/services/startup_impact_service/test_db.cpp +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -1,15 +1,15 @@ -#include -#include -#include - -#include - -#include #include #include #include #include #include +#include + +#include + +#include +#include +#include #include "../../test_support.hpp" #include "core/services/startup_impact_service/db.hpp" @@ -24,263 +24,283 @@ namespace { // Awaits a DB call on the event loop and fails the test on error. template auto ok(QCoro::Task> task) { - auto result = QCoro::waitFor(std::move(task)); - if (!result.has_value()) { - UNSCOPED_INFO("DB error: " << result.error().message.toStdString()); - } - REQUIRE(result.has_value()); - if constexpr (std::is_void_v) { - return; - } else { - return std::move(*result); - } + auto result = QCoro::waitFor(std::move(task)); + if (!result.has_value()) { + UNSCOPED_INFO("DB error: " << result.error().message.toStdString()); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } } -StartupImpactMod modEntry(const QString& name, const std::optional& pid, - double totalS, double offS = 0.0) { - StartupImpactMod mod; - mod.modName = name; - mod.packageId = pid; - mod.totalImpactS = totalS; - mod.offThreadTotalImpactS = offS; - return mod; +StartupImpactMod modEntry(const QString& name, + const std::optional& pid, double totalS, + double offS = 0.0) { + StartupImpactMod mod; + mod.modName = name; + mod.packageId = pid; + mod.totalImpactS = totalS; + mod.offThreadTotalImpactS = offS; + return mod; } -StartupImpactReport makeReport(const QString& pid, double totalS, double offThreadS = 0.0) { - StartupImpactReport report; - report.loadingTimeS = 1.0; - report.timestamp = QStringLiteral("now"); - report.mods.append(modEntry(QStringLiteral("Mod ") + pid, pid, totalS, offThreadS)); - return report; +StartupImpactReport makeReport(const QString& pid, double totalS, + double offThreadS = 0.0) { + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append( + modEntry(QStringLiteral("Mod ") + pid, pid, totalS, offThreadS)); + return report; } -QString dbFile(const QTemporaryDir& dir) { return QDir(dir.path()).filePath(QStringLiteral("cache.db")); } +QString dbFile(const QTemporaryDir& dir) { + return QDir(dir.path()).filePath(QStringLiteral("cache.db")); +} } // namespace TEST_CASE("store and clear run in the order they were issued") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - // Both calls are issued before either is awaited. The single worker runs them in order. - const auto report = makeReport(QStringLiteral("author.example"), 0.5); - auto stored = db.storeReport(path, report); - auto cleared = db.clear(path); - REQUIRE(QCoro::waitFor(std::move(stored)).has_value()); - REQUIRE(QCoro::waitFor(std::move(cleared)).has_value()); - CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); - - ok(db.storeReport(path, report)); - const auto latest = ok(db.getLatestReport(path)); - REQUIRE(latest.has_value()); - REQUIRE(latest->mods.size() == 1); - CHECK(latest->mods[0].packageId == QStringLiteral("author.example")); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + // Both calls are issued before either is awaited. The single worker runs them + // in order. + const auto report = makeReport(QStringLiteral("author.example"), 0.5); + auto stored = db.storeReport(path, report); + auto cleared = db.clear(path); + REQUIRE(QCoro::waitFor(std::move(stored)).has_value()); + REQUIRE(QCoro::waitFor(std::move(cleared)).has_value()); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK(latest->mods[0].packageId == QStringLiteral("author.example")); } TEST_CASE("base game keeps its sentinel package id after a round trip") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - StartupImpactReport report; - report.loadingTimeS = 1.0; - report.timestamp = QStringLiteral("now"); - report.mods.append(modEntry(QStringLiteral("Example"), QStringLiteral("author.example"), 0.5)); - report.mods.append(modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); - ok(db.storeReport(path, report)); - const auto latest = ok(db.getLatestReport(path)); - REQUIRE(latest.has_value()); - - QHash> byName; - for (const StartupImpactMod& mod : latest->mods) { - byName.insert(mod.modName, mod.packageId); - } - REQUIRE(byName.value(QString(kBaseGamePid)) == QString(kBaseGamePid)); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append(modEntry(QStringLiteral("Example"), + QStringLiteral("author.example"), 0.5)); + report.mods.append( + modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + + QHash> byName; + for (const StartupImpactMod& mod : latest->mods) { + byName.insert(mod.modName, mod.packageId); + } + REQUIRE(byName.value(QString(kBaseGamePid)) == QString(kBaseGamePid)); } TEST_CASE("a mod stored without a package id reads back without one") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - StartupImpactReport report; - report.loadingTimeS = 1.0; - report.mods.append(modEntry(QStringLiteral("No Package"), std::nullopt, 0.2)); - ok(db.storeReport(path, report)); - const auto latest = ok(db.getLatestReport(path)); - REQUIRE(latest.has_value()); - REQUIRE(latest->mods.size() == 1); - CHECK_FALSE(latest->mods[0].packageId.has_value()); - CHECK(latest->mods[0].modName == QStringLiteral("No Package")); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.mods.append(modEntry(QStringLiteral("No Package"), std::nullopt, 0.2)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK_FALSE(latest->mods[0].packageId.has_value()); + CHECK(latest->mods[0].modName == QStringLiteral("No Package")); } TEST_CASE("metrics survive a round trip") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - StartupImpactReport report; - report.loadingTimeS = 3.0; - StartupImpactMod harmony = - modEntry(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); - harmony.metrics.insert(QStringLiteral("ModConstructor"), 1.0); - harmony.offThreadMetrics.insert(QStringLiteral("LoadPatches"), 0.3); - report.mods.append(std::move(harmony)); - ok(db.storeReport(path, report)); - const auto latest = ok(db.getLatestReport(path)); - REQUIRE(latest.has_value()); - REQUIRE(latest->mods.size() == 1); - const StartupImpactMod& mod = latest->mods[0]; - CHECK(mod.totalImpactS == Catch::Approx(1.25)); - CHECK(mod.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); - CHECK(mod.offThreadMetrics.value(QStringLiteral("LoadPatches")) == Catch::Approx(0.3)); - CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.3)); - CHECK(latest->loadingTimeS == Catch::Approx(3.0)); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 3.0; + StartupImpactMod harmony = modEntry( + QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); + harmony.metrics.insert(QStringLiteral("ModConstructor"), 1.0); + harmony.offThreadMetrics.insert(QStringLiteral("LoadPatches"), 0.3); + report.mods.append(std::move(harmony)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + const StartupImpactMod& mod = latest->mods[0]; + CHECK(mod.totalImpactS == Catch::Approx(1.25)); + CHECK(mod.metrics.value(QStringLiteral("ModConstructor")) == + Catch::Approx(1.0)); + CHECK(mod.offThreadMetrics.value(QStringLiteral("LoadPatches")) == + Catch::Approx(0.3)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.3)); + CHECK(latest->loadingTimeS == Catch::Approx(3.0)); } TEST_CASE("averages weight the newest session most") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - const QString pid = QStringLiteral("a.a"); - - ok(db.storeReport(path, makeReport(pid, 1.0, 0.5))); - ok(db.storeReport(path, makeReport(pid, 3.0, 1.5))); - - // Older session weighs 9, newest weighs 10: (1.0*9 + 3.0*10) / 19 and (0.5*9 + 1.5*10) / 19. - CHECK_THAT(ok(db.getAverage(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); - const auto totals = ok(db.getTotalsFor(path, {pid})); - REQUIRE(totals.contains(pid)); - CHECK_THAT(totals.value(pid).onThread, WithinAbs(39.0 / 19.0, 1e-9)); - CHECK_THAT(totals.value(pid).offThread, WithinAbs(19.5 / 19.0, 1e-9)); - CHECK(ok(db.getAllAverages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + const QString pid = QStringLiteral("a.a"); + + ok(db.storeReport(path, makeReport(pid, 1.0, 0.5))); + ok(db.storeReport(path, makeReport(pid, 3.0, 1.5))); + + // Older session weighs 9, newest weighs 10: (1.0*9 + 3.0*10) / 19 and (0.5*9 + // + 1.5*10) / 19. + CHECK_THAT(ok(db.getAverage(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); + const auto totals = ok(db.getTotalsFor(path, {pid})); + REQUIRE(totals.contains(pid)); + CHECK_THAT(totals.value(pid).onThread, WithinAbs(39.0 / 19.0, 1e-9)); + CHECK_THAT(totals.value(pid).offThread, WithinAbs(19.5 / 19.0, 1e-9)); + CHECK(ok(db.getAllAverages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); } TEST_CASE("only the most recent sessions contribute to averages") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - ok(db.storeReport(path, makeReport(QStringLiteral("old.mod"), 100.0))); - for (int i = 0; i < 5; ++i) { - ok(db.storeReport(path, makeReport(QStringLiteral("new.mod"), 1.0))); - } - - // Five sessions are kept, so the old one is gone from history entirely. - CHECK(ok(db.getAverage(path, QStringLiteral("old.mod"))) == 0.0); - CHECK_THAT(ok(db.getAverage(path, QStringLiteral("new.mod"))), WithinAbs(1.0, 1e-9)); - CHECK_FALSE(ok(db.getAllAverages(path)).contains(QStringLiteral("old.mod"))); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + ok(db.storeReport(path, makeReport(QStringLiteral("old.mod"), 100.0))); + for (int i = 0; i < 5; ++i) { + ok(db.storeReport(path, makeReport(QStringLiteral("new.mod"), 1.0))); + } + + // Five sessions are kept, so the old one is gone from history entirely. + CHECK(ok(db.getAverage(path, QStringLiteral("old.mod"))) == 0.0); + CHECK_THAT(ok(db.getAverage(path, QStringLiteral("new.mod"))), + WithinAbs(1.0, 1e-9)); + CHECK_FALSE(ok(db.getAllAverages(path)).contains(QStringLiteral("old.mod"))); } TEST_CASE("snapshot returns the report with averages for the requested mods") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - StartupImpactReport report = makeReport(QStringLiteral("a.a"), 1.0, 0.2); - report.mods.append(modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); - ok(db.storeReport(path, report)); - - const auto snap = ok(db.getLatestWithAverages( - path, {QStringLiteral("a.a"), QStringLiteral("missing.mod")}, QStringLiteral("a.a"))); - REQUIRE(snap.report.has_value()); - CHECK(snap.report->mods.size() == 2); - CHECK_THAT(snap.baseGameAvg, WithinAbs(2.0, 1e-9)); - CHECK_THAT(snap.selectedAvg, WithinAbs(1.0, 1e-9)); - REQUIRE(snap.totals.contains(QStringLiteral("a.a"))); - CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).onThread, WithinAbs(1.0, 1e-9)); - CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).offThread, WithinAbs(0.2, 1e-9)); - REQUIRE(snap.totals.contains(QStringLiteral("missing.mod"))); - CHECK(snap.totals.value(QStringLiteral("missing.mod")).onThread == 0.0); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report = makeReport(QStringLiteral("a.a"), 1.0, 0.2); + report.mods.append( + modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); + + const auto snap = ok(db.getLatestWithAverages( + path, {QStringLiteral("a.a"), QStringLiteral("missing.mod")}, + QStringLiteral("a.a"))); + REQUIRE(snap.report.has_value()); + CHECK(snap.report->mods.size() == 2); + CHECK_THAT(snap.baseGameAvg, WithinAbs(2.0, 1e-9)); + CHECK_THAT(snap.selectedAvg, WithinAbs(1.0, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("a.a"))); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).onThread, + WithinAbs(1.0, 1e-9)); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).offThread, + WithinAbs(0.2, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("missing.mod"))); + CHECK(snap.totals.value(QStringLiteral("missing.mod")).onThread == 0.0); } -TEST_CASE("reads of a missing database return empty results and create no file") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); - CHECK(ok(db.getAverage(path, QStringLiteral("a.a"))) == 0.0); - CHECK(ok(db.getAllAverages(path)).isEmpty()); - - const auto snap = ok(db.getLatestWithAverages(path, {QStringLiteral("a.a")}, {})); - CHECK_FALSE(snap.report.has_value()); - CHECK(snap.totals.value(QStringLiteral("a.a")).onThread == 0.0); - CHECK_FALSE(QFileInfo::exists(path)); +TEST_CASE( + "reads of a missing database return empty results and create no file") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAverage(path, QStringLiteral("a.a"))) == 0.0); + CHECK(ok(db.getAllAverages(path)).isEmpty()); + + const auto snap = + ok(db.getLatestWithAverages(path, {QStringLiteral("a.a")}, {})); + CHECK_FALSE(snap.report.has_value()); + CHECK(snap.totals.value(QStringLiteral("a.a")).onThread == 0.0); + CHECK_FALSE(QFileInfo::exists(path)); } TEST_CASE("store creates a missing parent directory") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = - QDir(dir.path()).filePath(QStringLiteral("nested/config/cache.db")); - - ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); - CHECK(QFileInfo::exists(path)); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = + QDir(dir.path()).filePath(QStringLiteral("nested/config/cache.db")); + + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + CHECK(QFileInfo::exists(path)); } TEST_CASE("clear removes all history") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - - ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); - ok(db.clear(path)); - CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); - CHECK(ok(db.getAllAverages(path)).isEmpty()); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + ok(db.clear(path)); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAllAverages(path)).isEmpty()); } TEST_CASE("the database uses the legacy schema and version") { - TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - StartupImpactDb db; - const QString path = dbFile(dir); - ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); - - // A second connection reads what the first wrote, as the legacy app would. - const QString name = QStringLiteral("startup_impact_schema_check"); - { - QSqlDatabase verify = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); - verify.setDatabaseName(path); - REQUIRE(verify.open()); - - QSqlQuery version(verify); - REQUIRE(version.exec(QStringLiteral("PRAGMA user_version"))); - REQUIRE(version.next()); - CHECK(version.value(0).toInt() == 1); - - QSqlQuery tables(verify); - REQUIRE(tables.exec(QStringLiteral( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'startup_impact_%'" - " ORDER BY name"))); - QStringList names; - while (tables.next()) { - names.append(tables.value(0).toString()); - } - CHECK(names == QStringList{QStringLiteral("startup_impact_entries"), - QStringLiteral("startup_impact_sessions")}); + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + + // A second connection reads what the first wrote, as the legacy app would. + const QString name = QStringLiteral("startup_impact_schema_check"); + { + QSqlDatabase verify = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + verify.setDatabaseName(path); + REQUIRE(verify.open()); + + QSqlQuery version(verify); + REQUIRE(version.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(version.next()); + CHECK(version.value(0).toInt() == 1); + + QSqlQuery tables(verify); + REQUIRE( + tables.exec(QStringLiteral("SELECT name FROM sqlite_master WHERE type " + "= 'table' AND name LIKE 'startup_impact_%'" + " ORDER BY name"))); + QStringList names; + while (tables.next()) { + names.append(tables.value(0).toString()); } - QSqlDatabase::removeDatabase(name); + CHECK(names == QStringList{QStringLiteral("startup_impact_entries"), + QStringLiteral("startup_impact_sessions")}); + } + QSqlDatabase::removeDatabase(name); } diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp index 04ffb5f..5020a5c 100644 --- a/tests/core/services/startup_impact_service/test_phases.cpp +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -1,9 +1,9 @@ -#include -#include - #include #include +#include +#include + #include "core/services/startup_impact_service/labels.hpp" #include "core/services/startup_impact_service/phases.hpp" @@ -17,66 +17,72 @@ const QString kPrefix = QStringLiteral("LoadingProgress.StartupImpact."); } // namespace TEST_CASE("metric_phase assigns each metric key to its phase") { - struct Case { - const char* key; - Phase phase; - }; - const Case cases[] = { - {"ModContentPackReloadContentInt.Textures", Phase::kContent}, - {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::kContent}, - {"ModConstructor", Phase::kModConstructors}, - {"StaticConstructorOnStartupUtilityCallAll", Phase::kStaticConstructors}, - {"ApplyPatches", Phase::kPatches}, - {"ErrorCheckPatches", Phase::kPatches}, - {"LoadDefs", Phase::kDefs}, - {"ResolveAllWantedCrossReferences.NonImplied", Phase::kDefs}, - {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", Phase::kDeferred}, - {"LanguageDatabaseInitAllMetadata", Phase::kOther}, - {"SomethingNew", Phase::kOther}, - }; - for (const Case& c : cases) { - INFO(c.key); - CHECK(metricPhase(kPrefix + QString::fromLatin1(c.key)) == c.phase); - } + struct Case { + const char* key; + Phase phase; + }; + const Case cases[] = { + {"ModContentPackReloadContentInt.Textures", Phase::kContent}, + {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::kContent}, + {"ModConstructor", Phase::kModConstructors}, + {"StaticConstructorOnStartupUtilityCallAll", Phase::kStaticConstructors}, + {"ApplyPatches", Phase::kPatches}, + {"ErrorCheckPatches", Phase::kPatches}, + {"LoadDefs", Phase::kDefs}, + {"ResolveAllWantedCrossReferences.NonImplied", Phase::kDefs}, + {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", + Phase::kDeferred}, + {"LanguageDatabaseInitAllMetadata", Phase::kOther}, + {"SomethingNew", Phase::kOther}, + }; + for (const Case& c : cases) { + INFO(c.key); + CHECK(metricPhase(kPrefix + QString::fromLatin1(c.key)) == c.phase); + } } TEST_CASE("metric_phase also accepts keys without the prefix") { - CHECK(metricPhase(QStringLiteral("LoadDefs")) == Phase::kDefs); + CHECK(metricPhase(QStringLiteral("LoadDefs")) == Phase::kDefs); } TEST_CASE("phase_totals sums per phase and zero-fills the rest") { - const QMap metrics{ - {kPrefix + QStringLiteral("ApplyPatches"), 1.5}, - {kPrefix + QStringLiteral("LoadPatches"), 0.5}, - {kPrefix + QStringLiteral("LoadDefs"), 2.0}, - }; - const PhaseTotals totals = phaseTotals(metrics); - CHECK_THAT(totals[phaseIndex(Phase::kPatches)], WithinAbs(2.0, 1e-9)); - CHECK_THAT(totals[phaseIndex(Phase::kDefs)], WithinAbs(2.0, 1e-9)); - CHECK(totals[phaseIndex(Phase::kContent)] == 0.0); - CHECK(totals.size() == kPhaseCount); + const QMap metrics{ + {kPrefix + QStringLiteral("ApplyPatches"), 1.5}, + {kPrefix + QStringLiteral("LoadPatches"), 0.5}, + {kPrefix + QStringLiteral("LoadDefs"), 2.0}, + }; + const PhaseTotals totals = phaseTotals(metrics); + CHECK_THAT(totals[phaseIndex(Phase::kPatches)], WithinAbs(2.0, 1e-9)); + CHECK_THAT(totals[phaseIndex(Phase::kDefs)], WithinAbs(2.0, 1e-9)); + CHECK(totals[phaseIndex(Phase::kContent)] == 0.0); + CHECK(totals.size() == kPhaseCount); } TEST_CASE("every phase has a label") { - for (std::size_t i = 0; i < kPhaseCount; ++i) { - CHECK_FALSE(phaseLabel(static_cast(i)).isEmpty()); - } - CHECK(phaseLabel(Phase::kDefs) == QStringLiteral("Defs & XML")); + for (std::size_t i = 0; i < kPhaseCount; ++i) { + CHECK_FALSE(phaseLabel(static_cast(i)).isEmpty()); + } + CHECK(phaseLabel(Phase::kDefs) == QStringLiteral("Defs & XML")); } TEST_CASE("metric_label names a deferred task's target") { - const QString key = - kPrefix + - QStringLiteral("ExecuteToExecuteWhenFinished|RimWorld.AbilityDef -> Void b__88_0()"); - CHECK(metricLabel(key) == - QStringLiteral("Running delayed initialization task (RimWorld.AbilityDef)")); + const QString key = kPrefix + QStringLiteral( + "ExecuteToExecuteWhenFinished|RimWorld." + "AbilityDef -> Void b__88_0()"); + CHECK(metricLabel(key) == + QStringLiteral( + "Running delayed initialization task (RimWorld.AbilityDef)")); } -TEST_CASE("metric_label falls back to the last key segment, then the key itself") { - CHECK(metricLabel(kPrefix + QStringLiteral("ModContentPackReloadContentInt.Textures")) == - QStringLiteral("Loading textures")); - CHECK(metricLabel(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == - QStringLiteral("Loading defs")); - CHECK(metricLabel(kPrefix + QStringLiteral("Totally.New")) == QStringLiteral("Totally.New")); - CHECK(metricLabel(QStringLiteral("ModConstructor")) == QStringLiteral("Running mod constructor")); +TEST_CASE( + "metric_label falls back to the last key segment, then the key itself") { + CHECK(metricLabel(kPrefix + QStringLiteral( + "ModContentPackReloadContentInt.Textures")) == + QStringLiteral("Loading textures")); + CHECK(metricLabel(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == + QStringLiteral("Loading defs")); + CHECK(metricLabel(kPrefix + QStringLiteral("Totally.New")) == + QStringLiteral("Totally.New")); + CHECK(metricLabel(QStringLiteral("ModConstructor")) == + QStringLiteral("Running mod constructor")); } diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp index 70e3da6..3fd1d94 100644 --- a/tests/core/services/startup_impact_service/test_startup_impact.cpp +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -1,14 +1,14 @@ -#include -#include -#include - #include #include #include +#include +#include +#include + +#include "../../test_support.hpp" #include "core/services/startup_impact_service/models.hpp" #include "core/services/startup_impact_service/parser.hpp" -#include "../../test_support.hpp" using namespace px::modrim::startup_impact; using Catch::Matchers::WithinAbs; @@ -71,161 +71,178 @@ const char* kSampleJson = R"({ )"; // Writes `json` to a file in `dir` and parses it. -std::optional parseText(const QTemporaryDir& dir, const QString& name, - const char* json) { - const QString path = QDir(dir.path()).filePath(name); - REQUIRE(writeBytes(path, json)); - return parseStartupImpact(path); +std::optional parseText(const QTemporaryDir& dir, + const QString& name, + const char* json) { + const QString path = QDir(dir.path()).filePath(name); + REQUIRE(writeBytes(path, json)); + return parseStartupImpact(path); } } // namespace TEST_CASE("normalize_package_id lowercases and strips the steam suffix") { - CHECK(normalizePackageId(QStringLiteral("Author.Mod")) == QStringLiteral("author.mod")); - CHECK(normalizePackageId(QStringLiteral("author.mod_steam")) == - QStringLiteral("author.mod")); - CHECK(normalizePackageId(QStringLiteral("author.mod")) == QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("Author.Mod")) == + QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod_steam")) == + QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod")) == + QStringLiteral("author.mod")); } TEST_CASE("format_impact rounds seconds to whole milliseconds") { - CHECK(formatImpact(1.234) == QStringLiteral("1234ms")); - CHECK(formatImpact(0.05) == QStringLiteral("50ms")); - CHECK(formatImpact(0.0) == QStringLiteral("0ms")); + CHECK(formatImpact(1.234) == QStringLiteral("1234ms")); + CHECK(formatImpact(0.05) == QStringLiteral("50ms")); + CHECK(formatImpact(0.0) == QStringLiteral("0ms")); } TEST_CASE("format_impact rounds half to even like Python's round") { - CHECK(formatImpact(0.0005) == QStringLiteral("0ms")); - CHECK(formatImpact(0.0015) == QStringLiteral("2ms")); - CHECK(formatImpact(0.0025) == QStringLiteral("2ms")); + CHECK(formatImpact(0.0005) == QStringLiteral("0ms")); + CHECK(formatImpact(0.0015) == QStringLiteral("2ms")); + CHECK(formatImpact(0.0025) == QStringLiteral("2ms")); } TEST_CASE("get_startup_impact_path sits beside the config folder") { - CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config")) == - QStringLiteral("/home/user/StartupImpactData.json")); - CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config/")) == - QStringLiteral("/home/user/StartupImpactData.json")); - CHECK(getStartupImpactPath(QStringLiteral("/Config")) == - QStringLiteral("/StartupImpactData.json")); + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config/")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(getStartupImpactPath(QStringLiteral("/Config")) == + QStringLiteral("/StartupImpactData.json")); } TEST_CASE("parse_startup_impact reads the full report") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); - REQUIRE(report.has_value()); - CHECK(report->loadingTimeS == Catch::Approx(42.5005)); - CHECK(report->timestamp == QStringLiteral("2026-07-16T14:30:45.123Z")); - REQUIRE(report->metrics.size() == 2); - CHECK(report->metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(10.2)); - CHECK(report->metrics.value(QStringLiteral("ResolveReferences")) == Catch::Approx(1.5)); - CHECK(report->totalImpact == 0.0); - CHECK(report->mods.size() == 4); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + CHECK(report->loadingTimeS == Catch::Approx(42.5005)); + CHECK(report->timestamp == QStringLiteral("2026-07-16T14:30:45.123Z")); + REQUIRE(report->metrics.size() == 2); + CHECK(report->metrics.value(QStringLiteral("LoadModXml")) == + Catch::Approx(10.2)); + CHECK(report->metrics.value(QStringLiteral("ResolveReferences")) == + Catch::Approx(1.5)); + CHECK(report->totalImpact == 0.0); + CHECK(report->mods.size() == 4); } TEST_CASE("base game time is the sum of the base-game metrics") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); - REQUIRE(report.has_value()); - // Not loading time minus mod impacts: loading time includes unaccounted time. - CHECK_THAT(report->baseGameTime(), WithinAbs(11.7, kEps)); - CHECK(std::abs(report->loadingTimeS - report->totalTime()) > 1.0); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + // Not loading time minus mod impacts: loading time includes unaccounted time. + CHECK_THAT(report->baseGameTime(), WithinAbs(11.7, kEps)); + CHECK(std::abs(report->loadingTimeS - report->totalTime()) > 1.0); } TEST_CASE("mods_total_time includes off-thread impact") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); - REQUIRE(report.has_value()); - const StartupImpactMod& harmony = report->mods[0]; - CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); - CHECK(harmony.offThreadTotalImpactS == Catch::Approx(0.3)); - // total_time counts on-thread impact only; mods_total_time adds off-thread impact. - CHECK_THAT(report->totalTime(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); - CHECK_THAT(report->offThreadTotalTime(), WithinAbs(0.3, kEps)); - CHECK_THAT(report->modsTotalTime(), WithinAbs(report->totalTime() + 0.3, kEps)); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); + CHECK(harmony.offThreadTotalImpactS == Catch::Approx(0.3)); + // total_time counts on-thread impact only; mods_total_time adds off-thread + // impact. + CHECK_THAT(report->totalTime(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); + CHECK_THAT(report->offThreadTotalTime(), WithinAbs(0.3, kEps)); + CHECK_THAT(report->modsTotalTime(), + WithinAbs(report->totalTime() + 0.3, kEps)); } TEST_CASE("parse_startup_impact reads mod values") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); - REQUIRE(report.has_value()); - - const StartupImpactMod& harmony = report->mods[0]; - CHECK(harmony.modName == QStringLiteral("Harmony")); - CHECK(harmony.packageId == QStringLiteral("brrainz.harmony")); - CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); - CHECK(harmony.metrics.value(QStringLiteral("ModConstructor")) == Catch::Approx(1.0)); - - const StartupImpactMod& someMod = report->mods[1]; - CHECK(someMod.modName == QStringLiteral("Some Mod")); - CHECK(someMod.packageId == QStringLiteral("author.somemod")); - CHECK(someMod.totalImpactS == Catch::Approx(4.5)); - CHECK(someMod.metrics.value(QStringLiteral("LoadModXml")) == Catch::Approx(4.5)); - - const StartupImpactMod& fast = report->mods[2]; - CHECK(fast.modName == QStringLiteral("Fast Mod")); - CHECK(fast.packageId == QStringLiteral("author.fast")); - CHECK(fast.totalImpactS == Catch::Approx(0.05)); - CHECK(fast.metrics.isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.modName == QStringLiteral("Harmony")); + CHECK(harmony.packageId == QStringLiteral("brrainz.harmony")); + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); + CHECK(harmony.metrics.value(QStringLiteral("ModConstructor")) == + Catch::Approx(1.0)); + + const StartupImpactMod& someMod = report->mods[1]; + CHECK(someMod.modName == QStringLiteral("Some Mod")); + CHECK(someMod.packageId == QStringLiteral("author.somemod")); + CHECK(someMod.totalImpactS == Catch::Approx(4.5)); + CHECK(someMod.metrics.value(QStringLiteral("LoadModXml")) == + Catch::Approx(4.5)); + + const StartupImpactMod& fast = report->mods[2]; + CHECK(fast.modName == QStringLiteral("Fast Mod")); + CHECK(fast.packageId == QStringLiteral("author.fast")); + CHECK(fast.totalImpactS == Catch::Approx(0.05)); + CHECK(fast.metrics.isEmpty()); } TEST_CASE("a mod without a package id has none after parsing") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); - REQUIRE(report.has_value()); - - const StartupImpactMod& noPid = report->mods[3]; - CHECK(noPid.modName == QStringLiteral("No Package ID Mod")); - CHECK_FALSE(noPid.packageId.has_value()); - CHECK(noPid.totalImpactS == Catch::Approx(0.2)); - CHECK(noPid.metrics.isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& noPid = report->mods[3]; + CHECK(noPid.modName == QStringLiteral("No Package ID Mod")); + CHECK_FALSE(noPid.packageId.has_value()); + CHECK(noPid.totalImpactS == Catch::Approx(0.2)); + CHECK(noPid.metrics.isEmpty()); } TEST_CASE("parse_startup_impact returns nullopt for a missing file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - CHECK_FALSE(parseStartupImpact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) - .has_value()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseStartupImpact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) + .has_value()); } TEST_CASE("parse_startup_impact reads an empty mod list") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("empty_mods.json"), - R"({"loadingTime": 1000.0, "mods": []})"); - REQUIRE(report.has_value()); - CHECK(report->loadingTimeS == Catch::Approx(1.0)); - CHECK(report->mods.isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("empty_mods.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->loadingTimeS == Catch::Approx(1.0)); + CHECK(report->mods.isEmpty()); } TEST_CASE("parse_startup_impact rejects a top-level array") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - CHECK_FALSE(parseText(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseText(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); } TEST_CASE("parse_startup_impact rejects malformed JSON") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - CHECK_FALSE(parseText(dir, QStringLiteral("bad.json"), "{invalid").has_value()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseText(dir, QStringLiteral("bad.json"), "{invalid").has_value()); } TEST_CASE("parse_startup_impact accepts an object without known keys") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("invalid.json"), - R"({"wrongKey": "value"})"); - REQUIRE(report.has_value()); - CHECK(report->mods.isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("invalid.json"), + R"({"wrongKey": "value"})"); + REQUIRE(report.has_value()); + CHECK(report->mods.isEmpty()); } TEST_CASE("total_time sums on-thread mod impact") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("total.json"), R"({ + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("total.json"), R"({ "loadingTime": 50000.0, "mods": [ {"modName": "A", "modPackageId": "a.a", @@ -236,23 +253,23 @@ TEST_CASE("total_time sums on-thread mod impact") { "offThreadMetrics": {}, "offThreadTotalImpact": 0.0} ] })"); - REQUIRE(report.has_value()); - CHECK(report->totalTime() == Catch::Approx(5.0)); + REQUIRE(report.has_value()); + CHECK(report->totalTime() == Catch::Approx(5.0)); } TEST_CASE("total_time of an empty report is zero") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("total_empty.json"), - R"({"loadingTime": 1000.0, "mods": []})"); - REQUIRE(report.has_value()); - CHECK(report->totalTime() == 0.0); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("total_empty.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->totalTime() == 0.0); } TEST_CASE("parse_startup_impact reads off-thread mod fields") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto report = parseText(dir, QStringLiteral("offthread.json"), R"({ + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("offthread.json"), R"({ "loadingTime": 10000.0, "mods": [ { @@ -265,32 +282,34 @@ TEST_CASE("parse_startup_impact reads off-thread mod fields") { } ] })"); - REQUIRE(report.has_value()); - const StartupImpactMod& mod = report->mods[0]; - REQUIRE(mod.offThreadMetrics.size() == 1); - CHECK(mod.offThreadMetrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); - CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.5)); + REQUIRE(report.has_value()); + const StartupImpactMod& mod = report->mods[0]; + REQUIRE(mod.offThreadMetrics.size() == 1); + CHECK(mod.offThreadMetrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.5)); } TEST_CASE("find matches by package id, then by mod name") { - StartupImpactReport report; - StartupImpactMod m1; - m1.modName = QStringLiteral("Harmony"); - m1.packageId = QStringLiteral("brrainz.harmony"); - report.mods.append(std::move(m1)); - StartupImpactMod m2; - m2.modName = QStringLiteral("Other"); - m2.packageId = QStringLiteral("other.mod"); - report.mods.append(std::move(m2)); - - const StartupImpactMod* byId = report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); - REQUIRE(byId != nullptr); - CHECK(byId->modName == QStringLiteral("Harmony")); - - const StartupImpactMod* byName = report.find({}, QStringLiteral("OTHER")); - REQUIRE(byName != nullptr); - CHECK(byName->packageId == QStringLiteral("other.mod")); - - CHECK(report.find(QStringLiteral("missing.mod"), QStringLiteral("Missing")) == nullptr); - CHECK(report.find(QString(), QString()) == nullptr); + StartupImpactReport report; + StartupImpactMod m1; + m1.modName = QStringLiteral("Harmony"); + m1.packageId = QStringLiteral("brrainz.harmony"); + report.mods.append(std::move(m1)); + StartupImpactMod m2; + m2.modName = QStringLiteral("Other"); + m2.packageId = QStringLiteral("other.mod"); + report.mods.append(std::move(m2)); + + const StartupImpactMod* byId = + report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); + REQUIRE(byId != nullptr); + CHECK(byId->modName == QStringLiteral("Harmony")); + + const StartupImpactMod* byName = report.find({}, QStringLiteral("OTHER")); + REQUIRE(byName != nullptr); + CHECK(byName->packageId == QStringLiteral("other.mod")); + + CHECK(report.find(QStringLiteral("missing.mod"), QStringLiteral("Missing")) == + nullptr); + CHECK(report.find(QString(), QString()) == nullptr); } diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp index cedafe9..4c0df35 100644 --- a/tests/core/services/test_activation_service.cpp +++ b/tests/core/services/test_activation_service.cpp @@ -1,15 +1,14 @@ -#include - -#include -#include -#include -#include - #include #include #include #include #include +#include +#include +#include +#include + +#include #include "core/config.hpp" #include "core/context.hpp" @@ -26,340 +25,363 @@ namespace { using Mods = QHash; QStringList ids(std::initializer_list items) { - QStringList result; - for (const char* item : items) { - result.append(QString::fromLatin1(item)); - } - return result; + QStringList result; + for (const char* item : items) { + result.append(QString::fromLatin1(item)); + } + return result; } // About.xml mod with the given rules. Each dependency is a bare package id. -ListedModPtr makeMod(const char* name, - const char* pid, - std::initializer_list deps = {}, - std::initializer_list loadAfter = {}, - std::initializer_list loadBefore = {}) { - auto mod = std::make_shared(); - mod->name = QString::fromLatin1(name); - mod->packageId = CaseInsensitiveStr(QString::fromLatin1(pid)); - mod->providerId = QStringLiteral("stub"); - mod->valid = true; - mod->aboutRules.loadAfter = CaseInsensitiveSet(ids(loadAfter)); - mod->aboutRules.loadBefore = CaseInsensitiveSet(ids(loadBefore)); - for (const char* dep : deps) { - DependencyMod dependency; - dependency.name = QString::fromLatin1(dep); - dependency.packageId = CaseInsensitiveStr(QString::fromLatin1(dep)); - mod->aboutRules.setDependency(dependency); - } - return mod; +ListedModPtr makeMod(const char* name, const char* pid, + std::initializer_list deps = {}, + std::initializer_list loadAfter = {}, + std::initializer_list loadBefore = {}) { + auto mod = std::make_shared(); + mod->name = QString::fromLatin1(name); + mod->packageId = CaseInsensitiveStr(QString::fromLatin1(pid)); + mod->providerId = QStringLiteral("stub"); + mod->valid = true; + mod->aboutRules.loadAfter = CaseInsensitiveSet(ids(loadAfter)); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ids(loadBefore)); + for (const char* dep : deps) { + DependencyMod dependency; + dependency.name = QString::fromLatin1(dep); + dependency.packageId = CaseInsensitiveStr(QString::fromLatin1(dep)); + mod->aboutRules.setDependency(dependency); + } + return mod; } Mods baseMods() { - Mods mods; - mods.insert("uuid-a", makeMod("Mod A", "mod.a")); - mods.insert("uuid-b", makeMod("Mod B", "mod.b", {"mod.a"})); - mods.insert("uuid-c", makeMod("Mod C", "mod.c", {"mod.b"})); - mods.insert("uuid-d", makeMod("Mod D", "mod.d")); - mods.insert("uuid-e", makeMod("Mod E", "mod.e", {"mod.a"})); - mods.insert("uuid-f", makeMod("Mod F", "mod.f")); - mods.insert("uuid-g", makeMod("Mod G", "mod.g", {"mod.f"})); - mods.insert("uuid-h", makeMod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); - mods.insert("uuid-i", makeMod("Mod I", "mod.i", {}, {"mod.d"})); - mods.insert("uuid-j", makeMod("Mod J", "mod.j", {}, {}, {"mod.a"})); - return mods; + Mods mods; + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-b", makeMod("Mod B", "mod.b", {"mod.a"})); + mods.insert("uuid-c", makeMod("Mod C", "mod.c", {"mod.b"})); + mods.insert("uuid-d", makeMod("Mod D", "mod.d")); + mods.insert("uuid-e", makeMod("Mod E", "mod.e", {"mod.a"})); + mods.insert("uuid-f", makeMod("Mod F", "mod.f")); + mods.insert("uuid-g", makeMod("Mod G", "mod.g", {"mod.f"})); + mods.insert("uuid-h", makeMod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); + mods.insert("uuid-i", makeMod("Mod I", "mod.i", {}, {"mod.d"})); + mods.insert("uuid-j", makeMod("Mod J", "mod.j", {}, {}, {"mod.a"})); + return mods; } -// Owns the config directory and the context for one test. Starts with the base mods loaded as -// c, d, a, b and the diagnostics rebuilt. +// Owns the config directory and the context for one test. Starts with the base +// mods loaded as c, d, a, b and the diagnostics rebuilt. struct Fixture { - services_test::EventLoop loop; - QTemporaryDir dir; - ConfigService configService{dir.path()}; - std::unique_ptr ctx = CoreContext::create(AppConfig{}, &configService); - - Fixture() { - ctx->load(baseMods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); - ctx->diagnosticsService().rebuild(); - } - - void reload(Mods mods, std::initializer_list active) { - ctx->load(std::move(mods), ids(active)); - ctx->diagnosticsService().rebuild(); - } + services_test::EventLoop loop; + QTemporaryDir dir; + ConfigService configService{dir.path()}; + std::unique_ptr ctx = + CoreContext::create(AppConfig{}, &configService); + + Fixture() { + ctx->load(baseMods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + ctx->diagnosticsService().rebuild(); + } + + void reload(Mods mods, std::initializer_list active) { + ctx->load(std::move(mods), ids(active)); + ctx->diagnosticsService().rebuild(); + } }; } // namespace -TEST_CASE("enabling inserts after dependency without reordering existing mods") { - Fixture f; - std::vector emitted; - f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { - emitted.push_back(uuids); - }); +TEST_CASE( + "enabling inserts after dependency without reordering existing mods") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); - REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}))); - const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"}); - CHECK(f.ctx->activeUuids() == expected); - REQUIRE(emitted.size() == 1); - CHECK(emitted.front() == expected); + const QStringList expected = + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); } TEST_CASE("unconstrained enable appends") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); } TEST_CASE("enabling mod with load-after rule inserts after target") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-i"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-i"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-i", "uuid-a", "uuid-b"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-i", "uuid-a", "uuid-b"})); } TEST_CASE("enabling mod with load-before rule inserts before target") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-j"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-j"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-j", "uuid-a", "uuid-b"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-j", "uuid-a", "uuid-b"})); } TEST_CASE("enabling dependency before active dependent") { - Fixture f; - f.reload(baseMods(), {"uuid-b", "uuid-c", "uuid-d"}); + Fixture f; + f.reload(baseMods(), {"uuid-b", "uuid-c", "uuid-d"}); - REQUIRE(f.ctx->activation().apply(ids({"uuid-a"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-a"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c", "uuid-d"})); + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c", "uuid-d"})); } TEST_CASE("conflicting constraints fall back to appending") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-h"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-h"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-h"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-h"})); } TEST_CASE("batch enables respect dependency order") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-g", "uuid-f"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-g", "uuid-f"}))); - CHECK(f.ctx->activeUuids() == - ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f", "uuid-g"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f", "uuid-g"})); } TEST_CASE("enabling already active mod keeps its position") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-c", "uuid-f"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-c", "uuid-f"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); } TEST_CASE("disabling removes and keeps remaining order") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply({}, ids({"uuid-d", "uuid-a"}))); + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-d", "uuid-a"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-b"})); + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-b"})); } TEST_CASE("unknown ids are ignored") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-missing", "uuid-f"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-missing", "uuid-f"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); } TEST_CASE("mixed selection applies in a single emission") { - Fixture f; - std::vector emitted; - f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { - emitted.push_back(uuids); - }); - - REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}), ids({"uuid-a", "uuid-d"}))); - - const QStringList expected = ids({"uuid-c", "uuid-b", "uuid-f", "uuid-e"}); - CHECK(f.ctx->activeUuids() == expected); - REQUIRE(emitted.size() == 1); - CHECK(emitted.front() == expected); + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}), + ids({"uuid-a", "uuid-d"}))); + + const QStringList expected = ids({"uuid-c", "uuid-b", "uuid-f", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); } TEST_CASE("uuid in both enable and disable favors disable") { - Fixture f; - std::vector emitted; - f.ctx->activeStateChanged().connect([&emitted](const QStringList& uuids) { - emitted.push_back(uuids); - }); - - REQUIRE(f.ctx->activation().apply(ids({"uuid-a", "uuid-f", "uuid-e"}), - ids({"uuid-a", "uuid-f"}))); - - const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-b", "uuid-e"}); - CHECK(f.ctx->activeUuids() == expected); - REQUIRE(emitted.size() == 1); - CHECK(emitted.front() == expected); + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-a", "uuid-f", "uuid-e"}), + ids({"uuid-a", "uuid-f"}))); + + const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-b", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); } TEST_CASE("duplicate enable is deduped in input order") { - Fixture f; + Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e", "uuid-f"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e", "uuid-f"}))); - CHECK(f.ctx->activeUuids() == - ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"})); } TEST_CASE("no-op returns false without emitting") { - const std::vector> cases{ - {ids({"uuid-a", "uuid-c"}), {}}, - {{}, ids({"uuid-e", "uuid-f"})}, - {ids({"uuid-missing"}), {}}, - {{}, ids({"uuid-missing"})}, - {{}, {}}, - {ids({"uuid-missing"}), ids({"uuid-missing"})}, - }; - for (const auto& [enable, disable] : cases) { - Fixture f; - int emissions = 0; - f.ctx->activeStateChanged().connect([&emissions](const QStringList&) { ++emissions; }); - - CHECK_FALSE(f.ctx->activation().apply(enable, disable)); - - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); - CHECK(emissions == 0); - } + const std::vector> cases{ + {ids({"uuid-a", "uuid-c"}), {}}, + {{}, ids({"uuid-e", "uuid-f"})}, + {ids({"uuid-missing"}), {}}, + {{}, ids({"uuid-missing"})}, + {{}, {}}, + {ids({"uuid-missing"}), ids({"uuid-missing"})}, + }; + for (const auto& [enable, disable] : cases) { + Fixture f; + int emissions = 0; + f.ctx->activeStateChanged().connect( + [&emissions](const QStringList&) { ++emissions; }); + + CHECK_FALSE(f.ctx->activation().apply(enable, disable)); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + CHECK(emissions == 0); + } } TEST_CASE("edges through an uninstalled mod add no constraint") { - Fixture f; - Mods mods; - mods.insert("uuid-a", makeMod("Mod A", "mod.a")); - mods.insert("uuid-w", makeMod("Mod W", "mod.w", {}, {"ce.absent"})); - mods.insert("uuid-y", makeMod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); - f.reload(mods, {"uuid-a", "uuid-w"}); + Fixture f; + Mods mods; + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-w", makeMod("Mod W", "mod.w", {}, {"ce.absent"})); + mods.insert("uuid-y", makeMod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); + f.reload(mods, {"uuid-a", "uuid-w"}); - REQUIRE(f.ctx->activation().apply(ids({"uuid-y"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-y"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-w", "uuid-y"})); + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-w", "uuid-y"})); } TEST_CASE("bounds ignore an inconsistent active order") { - Fixture f; - Mods mods = baseMods(); - // Active c depends on b but sits before it; X must still land in d..b. - mods.insert("uuid-x", makeMod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); - f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + Fixture f; + Mods mods = baseMods(); + // Active c depends on b but sits before it; X must still land in d..b. + mods.insert("uuid-x", makeMod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); - REQUIRE(f.ctx->activation().apply(ids({"uuid-x"}))); + REQUIRE(f.ctx->activation().apply(ids({"uuid-x"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"})); + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"})); } TEST_CASE("batch cycle does not unplace downstream mods") { - Fixture f; - Mods mods = baseMods(); - mods.insert("uuid-p", makeMod("Mod P", "mod.p", {"mod.q"})); - mods.insert("uuid-q", makeMod("Mod Q", "mod.q", {"mod.p"})); - mods.insert("uuid-z", makeMod("Mod Z", "mod.z", {"mod.p"})); - mods.insert("uuid-k", makeMod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); - f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); - - REQUIRE(f.ctx->activation().apply(ids({"uuid-z", "uuid-k", "uuid-q", "uuid-p"}))); - - CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-k", "uuid-a", "uuid-b", - "uuid-q", "uuid-p", "uuid-z"})); + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-p", makeMod("Mod P", "mod.p", {"mod.q"})); + mods.insert("uuid-q", makeMod("Mod Q", "mod.q", {"mod.p"})); + mods.insert("uuid-z", makeMod("Mod Z", "mod.z", {"mod.p"})); + mods.insert("uuid-k", makeMod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + + REQUIRE( + f.ctx->activation().apply(ids({"uuid-z", "uuid-k", "uuid-q", "uuid-p"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-k", "uuid-a", + "uuid-b", "uuid-q", "uuid-p", "uuid-z"})); } TEST_CASE("dependents_of lists transitive dependents in active order") { - Fixture f; + Fixture f; - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-c", "uuid-b"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-c", "uuid-b"})); } TEST_CASE("dependents_of excludes its inputs") { - Fixture f; + Fixture f; - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a", "uuid-b"})) == ids({"uuid-c"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a", "uuid-b"})) == + ids({"uuid-c"})); } TEST_CASE("dependents_of excludes inactive dependents") { - Fixture f; + Fixture f; - CHECK_FALSE(f.ctx->activeUuids().contains(QStringLiteral("uuid-e"))); - CHECK_FALSE(f.ctx->activation().dependentsOf(ids({"uuid-a"})).contains(QStringLiteral("uuid-e"))); + CHECK_FALSE(f.ctx->activeUuids().contains(QStringLiteral("uuid-e"))); + CHECK_FALSE(f.ctx->activation() + .dependentsOf(ids({"uuid-a"})) + .contains(QStringLiteral("uuid-e"))); } TEST_CASE("dependents_of reflects the graph after a state change") { - Fixture f; - REQUIRE(f.ctx->activation().apply(ids({"uuid-e"}))); + Fixture f; + REQUIRE(f.ctx->activation().apply(ids({"uuid-e"}))); - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-c", "uuid-e", "uuid-b"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-c", "uuid-e", "uuid-b"})); } TEST_CASE("dependents_of is empty for a mod without dependents") { - Fixture f; + Fixture f; - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-d", "uuid-missing"})).isEmpty()); + CHECK(f.ctx->activation() + .dependentsOf(ids({"uuid-d", "uuid-missing"})) + .isEmpty()); } TEST_CASE("removing a duplicate copy keeps dependents") { - Fixture f; - Mods mods = baseMods(); - mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); - f.reload(mods, {"uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"}); - - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a-copy"})).isEmpty()); - REQUIRE(f.ctx->activation().apply({}, ids({"uuid-a-copy"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c"})); - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == ids({"uuid-b", "uuid-c"})); + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a-copy"})).isEmpty()); + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-a-copy"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-b", "uuid-c"})); } TEST_CASE("enabling a replacement copy keeps dependents") { - Fixture f; - Mods mods = baseMods(); - mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); - f.reload(mods, {"uuid-a", "uuid-b", "uuid-c"}); - - CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"}), ids({"uuid-a-copy"})).isEmpty()); - REQUIRE(f.ctx->activation().apply(ids({"uuid-a-copy"}), ids({"uuid-a"}))); - CHECK(f.ctx->activeUuids() == ids({"uuid-a-copy", "uuid-b", "uuid-c"})); + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation() + .dependentsOf(ids({"uuid-a"}), ids({"uuid-a-copy"})) + .isEmpty()); + REQUIRE(f.ctx->activation().apply(ids({"uuid-a-copy"}), ids({"uuid-a"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a-copy", "uuid-b", "uuid-c"})); } TEST_CASE("sidebar entries are published before the summary") { - // The summary handler re-applies the sidebar filter, so entries must be fresh. - Fixture f; - DiagnosticsService& diagnostics = f.ctx->diagnosticsService(); - QSet inactive; - std::vector> seenAtSummary; - - diagnostics.sidebarEntriesChanged.connect([&inactive](const QList& entries) { + // The summary handler re-applies the sidebar filter, so entries must be + // fresh. + Fixture f; + DiagnosticsService& diagnostics = f.ctx->diagnosticsService(); + QSet inactive; + std::vector> seenAtSummary; + + diagnostics.sidebarEntriesChanged.connect( + [&inactive](const QList& entries) { inactive.clear(); for (const view::SidebarEntry& entry : entries) { - if (entry.label == QStringLiteral("Inactive")) { - inactive = entry.visibleUuids; - } + if (entry.label == QStringLiteral("Inactive")) { + inactive = entry.visibleUuids; + } } - }); - diagnostics.diagnosticsSummaryChanged.connect( - [&inactive, &seenAtSummary](const QHash&) { - seenAtSummary.push_back(inactive); - }); - - diagnostics.rebuild(f.ctx->activeUuids()); - seenAtSummary.clear(); - REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); - - REQUIRE_FALSE(seenAtSummary.empty()); - CHECK_FALSE(seenAtSummary.back().contains(QStringLiteral("uuid-f"))); + }); + diagnostics.diagnosticsSummaryChanged.connect( + [&inactive, + &seenAtSummary](const QHash&) { + seenAtSummary.push_back(inactive); + }); + + diagnostics.rebuild(f.ctx->activeUuids()); + seenAtSummary.clear(); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + + REQUIRE_FALSE(seenAtSummary.empty()); + CHECK_FALSE(seenAtSummary.back().contains(QStringLiteral("uuid-f"))); } diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp index 324d75d..3d7890d 100644 --- a/tests/core/services/test_game_launcher.cpp +++ b/tests/core/services/test_game_launcher.cpp @@ -1,18 +1,18 @@ -#include - -#include -#include -#include -#include - -#include #include #include #include #include -#include #include #include +#include +#include +#include +#include +#include + +#include + +#include #include "core/config.hpp" #include "core/constants.hpp" @@ -43,230 +43,260 @@ using services_test::runAsync; namespace { ProcessInfo proc(const QString& name, qint64 pid = 1, bool zombie = false) { - return ProcessInfo{pid, name, zombie}; + return ProcessInfo{pid, name, zombie}; } struct Replay { - std::vector> snapshots; - std::size_t next = 0; + std::vector> snapshots; + std::size_t next = 0; }; -// Lister that returns the snapshots in order, then keeps returning the last one. +// Lister that returns the snapshots in order, then keeps returning the last +// one. ProcessLister replay(std::vector> snapshots) { - auto state = std::make_shared(Replay{std::move(snapshots), 0}); - return [state] { - const std::size_t last = state->snapshots.size() - 1; - const auto snapshot = state->snapshots[std::min(state->next, last)]; - if (state->next < last) { - ++state->next; - } - return snapshot; - }; + auto state = std::make_shared(Replay{std::move(snapshots), 0}); + return [state] { + const std::size_t last = state->snapshots.size() - 1; + const auto snapshot = state->snapshots[std::min(state->next, last)]; + if (state->next < last) { + ++state->next; + } + return snapshot; + }; } -const MonitorTiming kNoSleep{std::chrono::milliseconds(0), std::chrono::milliseconds(0)}; +const MonitorTiming kNoSleep{std::chrono::milliseconds(0), + std::chrono::milliseconds(0)}; QCoro::Task capture(GameProcessMonitor& monitor) { - co_await monitor.captureBaseline(); - co_return true; + co_await monitor.captureBaseline(); + co_return true; } void touch(const QString& path) { - QFile file(path); - REQUIRE(file.open(QIODevice::WriteOnly)); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); } void makeExecutable(const QString& path) { - touch(path); - REQUIRE(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner | - QFileDevice::ExeOwner)); + touch(path); + REQUIRE(QFile::setPermissions(path, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); } std::unique_ptr contextFor(const QString& gamePath) { - static QTemporaryDir configDir; - static px::modrim::ConfigService configService(configDir.path()); - AppConfig cfg; - cfg.paths.game = gamePath.toStdString(); - return CoreContext::create(cfg, &configService); + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = gamePath.toStdString(); + return CoreContext::create(cfg, &configService); } } // namespace -TEST_CASE("command without wrapper is the executable followed by the arguments") { - auto command = buildDirectCommand(QStringLiteral("/g/RimWorldLinux"), {}, - QStringLiteral("-popupwindow -logfile"), Platform::kLinux); - REQUIRE(command.has_value()); - CHECK(command->argv == QStringList{"/g/RimWorldLinux", "-popupwindow", "-logfile"}); - CHECK(command->env.isEmpty()); +TEST_CASE( + "command without wrapper is the executable followed by the arguments") { + auto command = buildDirectCommand(QStringLiteral("/g/RimWorldLinux"), {}, + QStringLiteral("-popupwindow -logfile"), + Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == + QStringList{"/g/RimWorldLinux", "-popupwindow", "-logfile"}); + CHECK(command->env.isEmpty()); } TEST_CASE("wrapper without placeholder is a prefix") { - auto command = buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), - QStringLiteral("-x"), Platform::kLinux); - REQUIRE(command.has_value()); - CHECK(command->argv == QStringList{"gamemoderun", "/g/rw", "-x"}); + auto command = + buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), + QStringLiteral("-x"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"gamemoderun", "/g/rw", "-x"}); } -TEST_CASE("placeholder marks the game position and leading assignments become environment") { - auto command = buildDirectCommand( - QStringLiteral("/g/rw"), QStringLiteral("MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail"), - QStringLiteral("-x"), Platform::kLinux); - REQUIRE(command.has_value()); - CHECK(command->argv == QStringList{"mangohud", "/g/rw", "-x", "--tail"}); - CHECK(command->env.value(QStringLiteral("MANGOHUD")) == QStringLiteral("1")); - CHECK(command->env.value(QStringLiteral("DXVK_HUD")) == QStringLiteral("fps")); +TEST_CASE( + "placeholder marks the game position and leading assignments become " + "environment") { + auto command = buildDirectCommand( + QStringLiteral("/g/rw"), + QStringLiteral("MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail"), + QStringLiteral("-x"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"mangohud", "/g/rw", "-x", "--tail"}); + CHECK(command->env.value(QStringLiteral("MANGOHUD")) == QStringLiteral("1")); + CHECK(command->env.value(QStringLiteral("DXVK_HUD")) == + QStringLiteral("fps")); } TEST_CASE("quoted POSIX arguments stay one token") { - auto command = buildDirectCommand(QStringLiteral("/g/rw"), {}, - QStringLiteral(R"(-savedatafolder="/tmp/my saves")"), - Platform::kLinux); - REQUIRE(command.has_value()); - CHECK(command->argv == QStringList{"/g/rw", "-savedatafolder=/tmp/my saves"}); + auto command = buildDirectCommand( + QStringLiteral("/g/rw"), {}, + QStringLiteral(R"(-savedatafolder="/tmp/my saves")"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"/g/rw", "-savedatafolder=/tmp/my saves"}); } TEST_CASE("an option containing '=' is not an environment assignment") { - auto command = buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("--cfg=/p wrap"), {}, - Platform::kLinux); - REQUIRE(command.has_value()); - CHECK(command->argv == QStringList{"--cfg=/p", "wrap", "/g/rw"}); - CHECK(command->env.isEmpty()); + auto command = + buildDirectCommand(QStringLiteral("/g/rw"), + QStringLiteral("--cfg=/p wrap"), {}, Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"--cfg=/p", "wrap", "/g/rw"}); + CHECK(command->env.isEmpty()); } TEST_CASE("unbalanced quotes are rejected") { - CHECK_FALSE(buildDirectCommand(QStringLiteral("/g/rw"), {}, QStringLiteral("\"oops"), - Platform::kLinux) - .has_value()); + CHECK_FALSE(buildDirectCommand(QStringLiteral("/g/rw"), {}, + QStringLiteral("\"oops"), Platform::kLinux) + .has_value()); } -TEST_CASE("Windows splitting keeps quotes in the token, as shlex with posix=False does") { - auto tokens = splitArguments(QStringLiteral(R"(-a "b c" d)"), Platform::kWindows); - REQUIRE(tokens.has_value()); - CHECK(*tokens == QStringList{"-a", R"("b c")", "d"}); +TEST_CASE( + "Windows splitting keeps quotes in the token, as shlex with posix=False " + "does") { + auto tokens = + splitArguments(QStringLiteral(R"(-a "b c" d)"), Platform::kWindows); + REQUIRE(tokens.has_value()); + CHECK(*tokens == QStringList{"-a", R"("b c")", "d"}); } TEST_CASE("executable lookup prefers the current binary over the legacy one") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString root = dir.path(); - - makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); - makeExecutable(QDir(root).filePath(QStringLiteral("RimWorld"))); - auto found = findExecutable(root, Platform::kLinux); - REQUIRE(found.has_value()); - CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorld"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorld"))); + auto found = findExecutable(root, Platform::kLinux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorld"))); } TEST_CASE("executable lookup skips a non-executable binary") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString root = dir.path(); - - touch(QDir(root).filePath(QStringLiteral("RimWorld"))); - makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); - REQUIRE(QFile::setPermissions(QDir(root).filePath(QStringLiteral("RimWorld")), - QFileDevice::ReadOwner | QFileDevice::WriteOwner)); - - auto found = findExecutable(root, Platform::kLinux); - REQUIRE(found.has_value()); - CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorld"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + REQUIRE( + QFile::setPermissions(QDir(root).filePath(QStringLiteral("RimWorld")), + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + auto found = findExecutable(root, Platform::kLinux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorldLinux"))); } TEST_CASE("executable lookup finds the Windows and macOS layouts") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString root = dir.path(); - - touch(QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); - auto windows = findExecutable(root, Platform::kWindows); - REQUIRE(windows.has_value()); - CHECK(*windows == QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); - - REQUIRE(QDir(root).mkpath(QStringLiteral("RimWorldMac.app"))); - auto mac = findExecutable(root, Platform::kMacOs); - REQUIRE(mac.has_value()); - CHECK(*mac == QDir(root).filePath(QStringLiteral("RimWorldMac.app"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + auto windows = findExecutable(root, Platform::kWindows); + REQUIRE(windows.has_value()); + CHECK(*windows == QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + + REQUIRE(QDir(root).mkpath(QStringLiteral("RimWorldMac.app"))); + auto mac = findExecutable(root, Platform::kMacOs); + REQUIRE(mac.has_value()); + CHECK(*mac == QDir(root).filePath(QStringLiteral("RimWorldMac.app"))); } TEST_CASE("steam URL is the app id alone without launch arguments") { - CHECK(steamRunUrl(QString()) == QStringLiteral("steam://rungameid/294100")); - CHECK(steamRunUrl(QStringLiteral(" ")) == QStringLiteral("steam://rungameid/294100")); + CHECK(steamRunUrl(QString()) == QStringLiteral("steam://rungameid/294100")); + CHECK(steamRunUrl(QStringLiteral(" ")) == + QStringLiteral("steam://rungameid/294100")); } TEST_CASE("steam URL percent-encodes launch arguments") { - CHECK(steamRunUrl(QStringLiteral("-savedatafolder=/a b")) == - QStringLiteral("steam://run/294100//-savedatafolder%3D%2Fa%20b/")); + CHECK(steamRunUrl(QStringLiteral("-savedatafolder=/a b")) == + QStringLiteral("steam://run/294100//-savedatafolder%3D%2Fa%20b/")); } TEST_CASE("legacy and current game process names are recognized") { - for (const char* name : {"RimWorld", "RimWorld.exe", "RimWorldLinux", "RimWorldWin64.exe", - "RimWorldWin.exe", "RimWorld by Ludeon Studios"}) { - const auto lister = replay({{proc(QString::fromLatin1(name)), proc("bash", 2)}}); - CHECK(isGameRunning(lister)); - } + for (const char* name : + {"RimWorld", "RimWorld.exe", "RimWorldLinux", "RimWorldWin64.exe", + "RimWorldWin.exe", "RimWorld by Ludeon Studios"}) { + const auto lister = + replay({{proc(QString::fromLatin1(name)), proc("bash", 2)}}); + CHECK(isGameRunning(lister)); + } } TEST_CASE("zombie game process is not running") { - const auto lister = replay({{proc("RimWorldLinux", 1, true), proc("bash", 2)}}); - CHECK_FALSE(isGameRunning(lister)); + const auto lister = + replay({{proc("RimWorldLinux", 1, true), proc("bash", 2)}}); + CHECK_FALSE(isGameRunning(lister)); } TEST_CASE("wait_for_exit returns once the game processes disappear") { - EventLoop loop; - // The first snapshot is the baseline, which is empty; then the game runs, then exits. - GameProcessMonitor monitor(replay({{}, {proc("RimWorldLinux")}, {proc("RimWorldLinux")}, {}}), - kNoSleep); - REQUIRE(runAsync(capture(monitor))); - CHECK(runAsync(monitor.waitForExit()) == std::nullopt); + EventLoop loop; + // The first snapshot is the baseline, which is empty; then the game runs, + // then exits. + GameProcessMonitor monitor( + replay({{}, {proc("RimWorldLinux")}, {proc("RimWorldLinux")}, {}}), + kNoSleep); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForExit()) == std::nullopt); } TEST_CASE("processes running before the launch are not counted as started") { - EventLoop loop; - // Baseline holds PID 10; the first poll still sees only PID 10, the second sees PID 20 too. - GameProcessMonitor monitor(replay({{proc("RimWorldLinux", 10)}, - {proc("RimWorldLinux", 10)}, - {proc("RimWorldLinux", 10), proc("RimWorldLinux", 20)}}), - kNoSleep); - REQUIRE(runAsync(capture(monitor))); - CHECK(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); + EventLoop loop; + // Baseline holds PID 10; the first poll still sees only PID 10, the second + // sees PID 20 too. + GameProcessMonitor monitor( + replay({{proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10), proc("RimWorldLinux", 20)}}), + kNoSleep); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); } -TEST_CASE("wait_for_start is false when the spawned process died before the game appeared") { - EventLoop loop; - GameProcessMonitor monitor(replay({{}}), kNoSleep); - monitor.attachProcess(std::make_unique()); // never started: state NotRunning - CHECK_FALSE(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); +TEST_CASE( + "wait_for_start is false when the spawned process died before the game " + "appeared") { + EventLoop loop; + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attachProcess( + std::make_unique()); // never started: state NotRunning + CHECK_FALSE(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); } #if defined(Q_OS_UNIX) TEST_CASE("wait_for_exit reports the spawned process's exit code") { - EventLoop loop; - auto spawned = std::make_unique(); - spawned->start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), QStringLiteral("exit 7")}); - REQUIRE(spawned->waitForFinished(10'000)); - - GameProcessMonitor monitor(replay({{}}), kNoSleep); - monitor.attachProcess(std::move(spawned)); - CHECK(runAsync(monitor.waitForExit()) == std::optional(7)); + EventLoop loop; + auto spawned = std::make_unique(); + spawned->start(QStringLiteral("/bin/sh"), + {QStringLiteral("-c"), QStringLiteral("exit 7")}); + REQUIRE(spawned->waitForFinished(10'000)); + + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attachProcess(std::move(spawned)); + CHECK(runAsync(monitor.waitForExit()) == std::optional(7)); } #endif TEST_CASE("launch without a game path fails before anything runs") { - EventLoop loop; - auto ctx = contextFor({}); - GameLauncher launcher(*ctx); - const Result result = runAsync(launcher.launch(LaunchStrategy::kDirect)); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message == QStringLiteral("Game path not configured")); + EventLoop loop; + auto ctx = contextFor({}); + GameLauncher launcher(*ctx); + const Result result = + runAsync(launcher.launch(LaunchStrategy::kDirect)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game path not configured")); } TEST_CASE("direct launch without an executable fails") { - EventLoop loop; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - auto ctx = contextFor(dir.path()); - GameLauncher launcher(*ctx); - const Result result = runAsync(launcher.launch(LaunchStrategy::kDirect)); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message == QStringLiteral("Game executable not found")); + EventLoop loop; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + auto ctx = contextFor(dir.path()); + GameLauncher launcher(*ctx); + const Result result = + runAsync(launcher.launch(LaunchStrategy::kDirect)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game executable not found")); } diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp index 09e01b6..1198c65 100644 --- a/tests/core/services/test_metadata_cache.cpp +++ b/tests/core/services/test_metadata_cache.cpp @@ -1,6 +1,3 @@ -#include -#include - #include #include #include @@ -8,6 +5,9 @@ #include #include #include +#include + +#include #include "core/models/metadata/parsing.hpp" #include "core/providers/local.hpp" @@ -80,253 +80,294 @@ constexpr auto kAboutTwo = R"(
    )"; -// Writes About/About.xml (and optionally About/PublishedFileId.txt) under `modDir`. -QString createMod(const QString& modDir, const char* xml, bool withPublishedFileId = false) { - REQUIRE(QDir().mkpath(QDir(modDir).filePath(QStringLiteral("About")))); - REQUIRE(writeText(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); - if (withPublishedFileId) { - REQUIRE(writeText(QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), - "123456")); - } - return modDir; +// Writes About/About.xml (and optionally About/PublishedFileId.txt) under +// `modDir`. +QString createMod(const QString& modDir, const char* xml, + bool withPublishedFileId = false) { + REQUIRE(QDir().mkpath(QDir(modDir).filePath(QStringLiteral("About")))); + REQUIRE( + writeText(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); + if (withPublishedFileId) { + REQUIRE(writeText( + QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), + "123456")); + } + return modDir; } -// Stat data the way the providers take it: About.xml mtime in seconds, and its size. +// Stat data the way the providers take it: About.xml mtime in seconds, and its +// size. ModCandidate candidateFor(const QString& modDir) { - const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); - const QFileInfo info(about); - return ModCandidate{modDir, about, info.lastModified().toMSecsSinceEpoch() / 1000.0, - info.size()}; + const QString about = + QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const QFileInfo info(about); + return ModCandidate{modDir, about, + info.lastModified().toMSecsSinceEpoch() / 1000.0, + info.size()}; } // Parses the mod exactly as the providers do, so cache entries match real ones. std::shared_ptr parseAbout(const QString& modDir) { - auto listed = createListedModFromPath(modDir, QStringLiteral("1.5"), true); - REQUIRE(listed.has_value()); - auto about = std::dynamic_pointer_cast(*listed); - REQUIRE(about != nullptr); - return about; + auto listed = createListedModFromPath(modDir, QStringLiteral("1.5"), true); + REQUIRE(listed.has_value()); + auto about = std::dynamic_pointer_cast(*listed); + REQUIRE(about != nullptr); + return about; } // Moves About.xml's mtime, keeping the file's contents. void setAboutMtime(const ModCandidate& candidate, double mtimeSeconds) { - QFile file(candidate.aboutXmlPath); - REQUIRE(file.open(QIODevice::ReadWrite)); - REQUIRE(file.setFileTime(QDateTime::fromMSecsSinceEpoch(std::llround(mtimeSeconds * 1000)), - QFileDevice::FileModificationTime)); + QFile file(candidate.aboutXmlPath); + REQUIRE(file.open(QIODevice::ReadWrite)); + REQUIRE(file.setFileTime( + QDateTime::fromMSecsSinceEpoch(std::llround(mtimeSeconds * 1000)), + QFileDevice::FileModificationTime)); } -// Reads mod_path values with an independent connection, to check what the cache stored. +// Reads mod_path values with an independent connection, to check what the cache +// stored. QStringList storedModPaths(const QString& dbPath) { - const QString name = QStringLiteral("metadata-cache-test-reader"); - QStringList paths; - { - QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); - db.setDatabaseName(dbPath); - REQUIRE(db.open()); - QSqlQuery query(db); - REQUIRE(query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata ORDER BY mod_path"))); - while (query.next()) { - paths.append(query.value(0).toString()); - } + const QString name = QStringLiteral("metadata-cache-test-reader"); + QStringList paths; + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(dbPath); + REQUIRE(db.open()); + QSqlQuery query(db); + REQUIRE(query.exec( + QStringLiteral("SELECT mod_path FROM mod_metadata ORDER BY mod_path"))); + while (query.next()) { + paths.append(query.value(0).toString()); } - QSqlDatabase::removeDatabase(name); - return paths; + } + QSqlDatabase::removeDatabase(name); + return paths; } } // namespace TEST_CASE("cache returns the parsed fields of a stored mod") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidateFor(modDir); - const QString dbPath = metadataCachePath(root.path()); - MetadataCache cache(dbPath); - - runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); - const CacheLookup lookup = runAsync(cache.getMods({candidate}, QStringLiteral("1.5"))); - - REQUIRE(lookup.cached.size() == 1); - REQUIRE(lookup.missing.isEmpty()); - const auto mod = std::dynamic_pointer_cast(lookup.cached[0]); - REQUIRE(mod != nullptr); - CHECK(mod->name == QStringLiteral("Sample Mod One")); - CHECK(mod->packageId.str() == QStringLiteral("author.samplemodone")); - CHECK(mod->authors == QStringList{QStringLiteral("TestAuthor")}); - CHECK(mod->supportedVersions == QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); - CHECK(mod->description == QStringLiteral("Initial description")); - CHECK(mod->modVersion == QStringLiteral("1.0.0")); - CHECK(mod->url == QStringLiteral("https://example.com/mod")); - REQUIRE(mod->modIconPath.has_value()); - CHECK(*mod->modIconPath == QStringLiteral("UI/Icon.png")); - CHECK(std::abs(mod->mtime - candidate.mtime) < 1e-3); - CHECK(mod->aboutRules.dependencies.size() == 1); - CHECK(mod->aboutRules.hasDependency(CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); - REQUIRE(mod->modPath().has_value()); - CHECK(*mod->modPath() == modDir); + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); + MetadataCache cache(dbPath); + + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + const CacheLookup lookup = + runAsync(cache.getMods({candidate}, QStringLiteral("1.5"))); + + REQUIRE(lookup.cached.size() == 1); + REQUIRE(lookup.missing.isEmpty()); + const auto mod = std::dynamic_pointer_cast(lookup.cached[0]); + REQUIRE(mod != nullptr); + CHECK(mod->name == QStringLiteral("Sample Mod One")); + CHECK(mod->packageId.str() == QStringLiteral("author.samplemodone")); + CHECK(mod->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(mod->supportedVersions == + QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(mod->description == QStringLiteral("Initial description")); + CHECK(mod->modVersion == QStringLiteral("1.0.0")); + CHECK(mod->url == QStringLiteral("https://example.com/mod")); + REQUIRE(mod->modIconPath.has_value()); + CHECK(*mod->modIconPath == QStringLiteral("UI/Icon.png")); + CHECK(std::abs(mod->mtime - candidate.mtime) < 1e-3); + CHECK(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.hasDependency( + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); + REQUIRE(mod->modPath().has_value()); + CHECK(*mod->modPath() == modDir); } -TEST_CASE("cache entries are misses when the file size, mtime or target version changes") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidateFor(modDir); - MetadataCache cache(metadataCachePath(root.path())); - runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); - - ModCandidate resized = candidate; - resized.size += 1; - ModCandidate touched = candidate; - touched.mtime += 5.0; - - for (const ModCandidate& stale : {resized, touched}) { - const CacheLookup lookup = runAsync(cache.getMods({stale}, QStringLiteral("1.5"))); - CHECK(lookup.cached.isEmpty()); - CHECK(lookup.missing.size() == 1); - } - const CacheLookup otherVersion = runAsync(cache.getMods({candidate}, QStringLiteral("1.4"))); - CHECK(otherVersion.cached.isEmpty()); - CHECK(otherVersion.missing.size() == 1); +TEST_CASE( + "cache entries are misses when the file size, mtime or target version " + "changes") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + + ModCandidate resized = candidate; + resized.size += 1; + ModCandidate touched = candidate; + touched.mtime += 5.0; + + for (const ModCandidate& stale : {resized, touched}) { + const CacheLookup lookup = + runAsync(cache.getMods({stale}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); + CHECK(lookup.missing.size() == 1); + } + const CacheLookup otherVersion = + runAsync(cache.getMods({candidate}, QStringLiteral("1.4"))); + CHECK(otherVersion.cached.isEmpty()); + CHECK(otherVersion.missing.size() == 1); } TEST_CASE("pruning removes entries for mods that disappeared") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modA = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const QString modB = createMod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); - const QString dbPath = metadataCachePath(root.path()); - MetadataCache cache(dbPath); - runAsync(cache.putMods({CachedMod{parseAbout(modA), candidateFor(modA)}, - CachedMod{parseAbout(modB), candidateFor(modB)}}, - QStringLiteral("1.5"))); - QStringList expectedPaths{normalizePath(modA), normalizePath(modB)}; - expectedPaths.sort(); - REQUIRE(storedModPaths(dbPath) == expectedPaths); - - runAsync(cache.pruneDisappeared(QSet{normalizePath(modA)})); - - CHECK(storedModPaths(dbPath) == QStringList{normalizePath(modA)}); - const CacheLookup lookup = runAsync(cache.getMods({candidateFor(modB)}, QStringLiteral("1.5"))); - CHECK(lookup.cached.isEmpty()); + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modA = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const QString modB = + createMod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); + const QString dbPath = metadataCachePath(root.path()); + MetadataCache cache(dbPath); + runAsync(cache.putMods({CachedMod{parseAbout(modA), candidateFor(modA)}, + CachedMod{parseAbout(modB), candidateFor(modB)}}, + QStringLiteral("1.5"))); + QStringList expectedPaths{normalizePath(modA), normalizePath(modB)}; + expectedPaths.sort(); + REQUIRE(storedModPaths(dbPath) == expectedPaths); + + runAsync(cache.pruneDisappeared(QSet{normalizePath(modA)})); + + CHECK(storedModPaths(dbPath) == QStringList{normalizePath(modA)}); + const CacheLookup lookup = + runAsync(cache.getMods({candidateFor(modB)}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); } TEST_CASE("corrupt cache database is recovered and keeps working") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modDir = createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); - const ModCandidate candidate = candidateFor(modDir); - const QString dbPath = metadataCachePath(root.path()); - { - MetadataCache cache(dbPath); - runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); - runAsync(cache.close()); - } - QByteArray garbage; - for (int i = 0; i < 50; ++i) { - garbage += "corrupt non-sqlite data"; - } - REQUIRE(writeText(dbPath, garbage)); - - MetadataCache recovered(dbPath); - const CacheLookup empty = runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); - CHECK(empty.cached.isEmpty()); - REQUIRE(empty.missing.size() == 1); - - runAsync(recovered.putMods({CachedMod{parseAbout(modDir), candidate}}, QStringLiteral("1.5"))); - const CacheLookup warm = runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); - CHECK(warm.cached.size() == 1); + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); + { + MetadataCache cache(dbPath); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + runAsync(cache.close()); + } + QByteArray garbage; + for (int i = 0; i < 50; ++i) { + garbage += "corrupt non-sqlite data"; + } + REQUIRE(writeText(dbPath, garbage)); + + MetadataCache recovered(dbPath); + const CacheLookup empty = + runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); + CHECK(empty.cached.isEmpty()); + REQUIRE(empty.missing.size() == 1); + + runAsync(recovered.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + const CacheLookup warm = + runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); + CHECK(warm.cached.size() == 1); } TEST_CASE("providers reuse cached metadata and reparse when the file changes") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString modsRoot = QDir(root.path()).filePath(QStringLiteral("mods")); - const QString modDir = createMod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); - const QString about = QDir(modDir).filePath(QStringLiteral("About/About.xml")); - const ModCandidate original = candidateFor(modDir); - MetadataCache cache(metadataCachePath(root.path())); - LocalModProvider provider(modsRoot, nullptr, &cache); - - auto first = runAsync(provider.discover(QStringLiteral("1.5"))); - REQUIRE(first.has_value()); - REQUIRE(first->contains(modDir)); - CHECK((*first)[modDir]->name == QStringLiteral("Sample Mod One")); - - // Same size and mtime, different text: the cache still answers, a forced reparse does not. - REQUIRE(writeText(about, kAboutSameSize)); - setAboutMtime(original, original.mtime); - auto cached = runAsync(provider.discover(QStringLiteral("1.5"))); - REQUIRE(cached.has_value()); - CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); - auto forced = runAsync(provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); - REQUIRE(forced.has_value()); - CHECK((*forced)[modDir]->name == QStringLiteral("Sample Mod Two")); - - // A new mtime invalidates the entry even though the cache is consulted. - setAboutMtime(original, original.mtime + 5.0); - auto changed = runAsync(provider.discover(QStringLiteral("1.5"))); - REQUIRE(changed.has_value()); - CHECK((*changed)[modDir]->name == QStringLiteral("Sample Mod Two")); - // mtime lives on AboutXmlMod; ListedMod has no such field. - const auto changedAbout = std::dynamic_pointer_cast((*changed)[modDir]); - REQUIRE(changedAbout != nullptr); - CHECK(std::abs(changedAbout->mtime - (original.mtime + 5.0)) < 1e-3); + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modsRoot = QDir(root.path()).filePath(QStringLiteral("mods")); + const QString modDir = + createMod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); + const QString about = + QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const ModCandidate original = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); + LocalModProvider provider(modsRoot, nullptr, &cache); + + auto first = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(first.has_value()); + REQUIRE(first->contains(modDir)); + CHECK((*first)[modDir]->name == QStringLiteral("Sample Mod One")); + + // Same size and mtime, different text: the cache still answers, a forced + // reparse does not. + REQUIRE(writeText(about, kAboutSameSize)); + setAboutMtime(original, original.mtime); + auto cached = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(cached.has_value()); + CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); + auto forced = runAsync( + provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); + REQUIRE(forced.has_value()); + CHECK((*forced)[modDir]->name == QStringLiteral("Sample Mod Two")); + + // A new mtime invalidates the entry even though the cache is consulted. + setAboutMtime(original, original.mtime + 5.0); + auto changed = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(changed.has_value()); + CHECK((*changed)[modDir]->name == QStringLiteral("Sample Mod Two")); + // mtime lives on AboutXmlMod; ListedMod has no such field. + const auto changedAbout = + std::dynamic_pointer_cast((*changed)[modDir]); + REQUIRE(changedAbout != nullptr); + CHECK(std::abs(changedAbout->mtime - (original.mtime + 5.0)) < 1e-3); } TEST_CASE("providers keep their identity when they share one cache") { - EventLoop loop; - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString localRoot = QDir(root.path()).filePath(QStringLiteral("local")); - const QString downloadedRoot = QDir(root.path()).filePath(QStringLiteral("downloaded")); - const QString localMod = createMod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); - const QString steamMod = createMod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), kAboutTwo, true); - MetadataCache cache(metadataCachePath(root.path())); - LocalModProvider local(localRoot, nullptr, &cache); - DownloadedModProvider downloaded(downloadedRoot, nullptr, &cache); - - for (int pass = 0; pass < 2; ++pass) { - auto localMods = runAsync(local.discover(QStringLiteral("1.5"))); - auto steamMods = runAsync(downloaded.discover(QStringLiteral("1.5"))); - REQUIRE(localMods.has_value()); - REQUIRE(steamMods.has_value()); - REQUIRE(localMods->contains(localMod)); - REQUIRE(steamMods->contains(steamMod)); - CHECK((*localMods)[localMod]->providerId == QStringLiteral("local")); - CHECK((*steamMods)[steamMod]->providerId == QStringLiteral("downloaded")); - } + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString localRoot = QDir(root.path()).filePath(QStringLiteral("local")); + const QString downloadedRoot = + QDir(root.path()).filePath(QStringLiteral("downloaded")); + const QString localMod = + createMod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); + const QString steamMod = + createMod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), + kAboutTwo, true); + MetadataCache cache(metadataCachePath(root.path())); + LocalModProvider local(localRoot, nullptr, &cache); + DownloadedModProvider downloaded(downloadedRoot, nullptr, &cache); + + for (int pass = 0; pass < 2; ++pass) { + auto localMods = runAsync(local.discover(QStringLiteral("1.5"))); + auto steamMods = runAsync(downloaded.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(steamMods.has_value()); + REQUIRE(localMods->contains(localMod)); + REQUIRE(steamMods->contains(steamMod)); + CHECK((*localMods)[localMod]->providerId == QStringLiteral("local")); + CHECK((*steamMods)[steamMod]->providerId == QStringLiteral("downloaded")); + } } TEST_CASE("normalize_paths matches normalize_path, including symlinks") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString real = QDir(root.path()).filePath(QStringLiteral("real")); - const QString other = QDir(root.path()).filePath(QStringLiteral("other")); - REQUIRE(QDir().mkpath(QDir(real).filePath(QStringLiteral("mod")))); - REQUIRE(QDir().mkpath(other)); - REQUIRE(QFile::link(real, QDir(root.path()).filePath(QStringLiteral("linkdir")))); - REQUIRE(QFile::link(other, QDir(real).filePath(QStringLiteral("linkmod")))); - - const QString linkdir = QDir(root.path()).filePath(QStringLiteral("linkdir")); - const QStringList paths{ - QDir(real).filePath(QStringLiteral("mod")), - QDir(linkdir).filePath(QStringLiteral("mod")), - QDir(linkdir).filePath(QStringLiteral("linkmod")), - QDir(root.path()).filePath(QStringLiteral("linkdir/../real/mod")), - QDir(root.path()).filePath(QStringLiteral("missing/x")), - QStringLiteral("relative/mod"), - }; - - QStringList expected; - for (const QString& path : paths) { - expected.append(normalizePath(path)); - } - CHECK(normalizePaths(paths) == expected); - CHECK(normalizePath(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == normalizePath(other)); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString real = QDir(root.path()).filePath(QStringLiteral("real")); + const QString other = QDir(root.path()).filePath(QStringLiteral("other")); + REQUIRE(QDir().mkpath(QDir(real).filePath(QStringLiteral("mod")))); + REQUIRE(QDir().mkpath(other)); + REQUIRE( + QFile::link(real, QDir(root.path()).filePath(QStringLiteral("linkdir")))); + REQUIRE(QFile::link(other, QDir(real).filePath(QStringLiteral("linkmod")))); + + const QString linkdir = QDir(root.path()).filePath(QStringLiteral("linkdir")); + const QStringList paths{ + QDir(real).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("linkmod")), + QDir(root.path()).filePath(QStringLiteral("linkdir/../real/mod")), + QDir(root.path()).filePath(QStringLiteral("missing/x")), + QStringLiteral("relative/mod"), + }; + + QStringList expected; + for (const QString& path : paths) { + expected.append(normalizePath(path)); + } + CHECK(normalizePaths(paths) == expected); + CHECK(normalizePath(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == + normalizePath(other)); } diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp index 69270ea..04d2d59 100644 --- a/tests/core/services/test_sort_service.cpp +++ b/tests/core/services/test_sort_service.cpp @@ -1,11 +1,10 @@ -#include - -#include - #include #include #include #include +#include + +#include #include "core/services/sort_service.hpp" @@ -19,178 +18,193 @@ using px::modrim::sort::PackageId; namespace { -std::shared_ptr about(const QString& name, const QString& packageId) { - auto mod = std::make_shared(); - mod->name = name; - mod->packageId = CaseInsensitiveStr(packageId); - mod->providerId = QStringLiteral("stub"); - mod->valid = true; - return mod; +std::shared_ptr about(const QString& name, + const QString& packageId) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("stub"); + mod->valid = true; + return mod; } // `deps` maps a dependency package id to its alternative package ids. -std::shared_ptr aboutWithDeps(const QString& name, const QString& packageId, - const QHash& deps) { - auto mod = about(name, packageId); - for (auto it = deps.cbegin(); it != deps.cend(); ++it) { - DependencyMod dep; - dep.name = QStringLiteral("Dep of %1").arg(name); - dep.packageId = CaseInsensitiveStr(it.key()); - for (const auto& alt : it.value()) { - dep.alternativePackageIds.add(alt); - } - mod->aboutRules.dependencies.append(std::move(dep)); +std::shared_ptr aboutWithDeps( + const QString& name, const QString& packageId, + const QHash& deps) { + auto mod = about(name, packageId); + for (auto it = deps.cbegin(); it != deps.cend(); ++it) { + DependencyMod dep; + dep.name = QStringLiteral("Dep of %1").arg(name); + dep.packageId = CaseInsensitiveStr(it.key()); + for (const auto& alt : it.value()) { + dep.alternativePackageIds.add(alt); } - return mod; + mod->aboutRules.dependencies.append(std::move(dep)); + } + return mod; } ListedModPtr listed(const QString& name) { - auto mod = std::make_shared(); - mod->name = name; - mod->providerId = QStringLiteral("stub"); - return mod; + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = QStringLiteral("stub"); + return mod; } -QHash mods(std::initializer_list> entries) { - QHash result; - for (const auto& [uuid, mod] : entries) { - result.insert(uuid, mod); - } - return result; +QHash mods( + std::initializer_list> entries) { + QHash result; + for (const auto& [uuid, mod] : entries) { + result.insert(uuid, mod); + } + return result; } } // namespace TEST_CASE("duplicate inactive copy does not replace the active uuid") { - const auto all = mods({ - {QStringLiteral("uuid-active"), about("Active Copy", "shared.package")}, - {QStringLiteral("uuid-inactive"), about("Inactive Copy", "shared.package")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-active"), about("Active Copy", "shared.package")}, + {QStringLiteral("uuid-inactive"), + about("Inactive Copy", "shared.package")}, + }); - const auto ordered = SortService::orderUuids({PackageId(QStringLiteral("shared.package"))}, all, - {QStringLiteral("uuid-active")}); + const auto ordered = + SortService::orderUuids({PackageId(QStringLiteral("shared.package"))}, + all, {QStringLiteral("uuid-active")}); - CHECK(ordered == QStringList{QStringLiteral("uuid-active")}); + CHECK(ordered == QStringList{QStringLiteral("uuid-active")}); } TEST_CASE("order_uuids follows the sorted package ids and drops unknown ones") { - const auto all = mods({ - {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); - const auto ordered = SortService::orderUuids( - {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("ghost")), - PackageId(QStringLiteral("mod.a"))}, - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); + const auto ordered = SortService::orderUuids( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("ghost")), + PackageId(QStringLiteral("mod.a"))}, + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); - CHECK(ordered == QStringList{QStringLiteral("uuid-b"), QStringLiteral("uuid-a")}); + CHECK(ordered == + QStringList{QStringLiteral("uuid-b"), QStringLiteral("uuid-a")}); } TEST_CASE("order_uuids skips active mods that are not about.xml mods") { - const auto all = mods({ - {QStringLiteral("uuid-folder"), listed("Folder Mod")}, - {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); - const auto ordered = SortService::orderUuids( - {PackageId(QStringLiteral("mod.a"))}, all, - {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}); + const auto ordered = SortService::orderUuids( + {PackageId(QStringLiteral("mod.a"))}, all, + {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}); - CHECK(ordered == QStringList{QStringLiteral("uuid-a")}); + CHECK(ordered == QStringList{QStringLiteral("uuid-a")}); } TEST_CASE("missing dependencies: no dependencies yields nothing") { - const auto all = mods({ - {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) - .isEmpty()); + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); } TEST_CASE("missing dependency that exists as an inactive mod is enabled") { - const auto all = mods({ - {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, - {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); - CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) == - QStringList{QStringLiteral("uuid-b")}); + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) == + QStringList{QStringLiteral("uuid-b")}); } TEST_CASE("missing dependency that is not among the known mods is ignored") { - const auto all = mods({ - {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + }); - CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}).isEmpty()); + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) + .isEmpty()); } TEST_CASE("missing dependency that is already active is not enabled again") { - const auto all = mods({ - {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, - {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) - .isEmpty()); + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); } -TEST_CASE("dependency satisfied by an active alternative package id is not missing") { - const auto all = mods({ - {QStringLiteral("uuid-a"), - aboutWithDeps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, - {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, - }); +TEST_CASE( + "dependency satisfied by an active alternative package id is not missing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-c")}) - .isEmpty()); + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-c")}) + .isEmpty()); } TEST_CASE("non about.xml mods in the active set contribute no dependencies") { - const auto all = mods({ - {QStringLiteral("uuid-folder"), listed("Folder Mod")}, - {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}) - .isEmpty()); + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}) + .isEmpty()); } TEST_CASE("a dependency shared by two active mods is listed once") { - const auto all = mods({ - {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.c", {}}})}, - {QStringLiteral("uuid-b"), aboutWithDeps("Mod B", "mod.b", {{"mod.c", {}}})}, - {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.c", {}}})}, + {QStringLiteral("uuid-b"), + aboutWithDeps("Mod B", "mod.b", {{"mod.c", {}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) == - QStringList{QStringLiteral("uuid-c")}); + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) == + QStringList{QStringLiteral("uuid-c")}); } TEST_CASE("an empty active set needs nothing") { - const auto all = mods({ - {QStringLiteral("uuid-a"), aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, - {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, - }); + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); - CHECK(SortService::missingDependencyUuids(all, {}).isEmpty()); + CHECK(SortService::missingDependencyUuids(all, {}).isEmpty()); } TEST_CASE("a mod without dependencies adds nothing to the result") { - const auto all = mods({ - {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, - {QStringLiteral("uuid-b"), aboutWithDeps("Mod B", "mod.b", {{"mod.a", {}}})}, - }); - - CHECK(SortService::missingDependencyUuids( - all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) - .isEmpty()); + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), + aboutWithDeps("Mod B", "mod.b", {{"mod.a", {}}})}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); } diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp index 021af37..75b5390 100644 --- a/tests/core/services/test_update_service.cpp +++ b/tests/core/services/test_update_service.cpp @@ -1,14 +1,13 @@ -#include - -#include -#include -#include - #include #include #include #include #include +#include +#include +#include + +#include #include "core/services/update_service.hpp" #include "services_support.hpp" @@ -22,50 +21,55 @@ namespace { // Minimal local HTTP server serving a single canned response per connection. class CannedHttpServer : public QObject { -public: - CannedHttpServer(int statusCode, QByteArray body, QByteArray contentType = "application/json") - : m_statusCode(statusCode), m_body(std::move(body)), m_contentType(std::move(contentType)) { - connect(&m_server, &QTcpServer::newConnection, this, &CannedHttpServer::onNewConnection); - REQUIRE(m_server.listen(QHostAddress::LocalHost, 0)); - } - - QString url() const { - return QStringLiteral("http://127.0.0.1:%1/latest").arg(m_server.serverPort()); - } - -private: - void onNewConnection() { - while (QTcpSocket* socket = m_server.nextPendingConnection()) { - connect(socket, &QTcpSocket::readyRead, this, [this, socket] { - if (socket->canReadLine()) { - // Drain the headers. - while (!socket->readLine().trimmed().isEmpty()) { - } - const QByteArray header = - QByteArray("HTTP/1.1 ") + QByteArray::number(m_statusCode) + " OK\r\n" + - "Content-Type: " + m_contentType + "\r\n" + - "Content-Length: " + QByteArray::number(m_body.size()) + "\r\n" + - "Connection: close\r\n\r\n"; - socket->write(header + m_body); - socket->disconnectFromHost(); - } - }); + public: + CannedHttpServer(int statusCode, QByteArray body, + QByteArray contentType = "application/json") + : m_statusCode(statusCode), + m_body(std::move(body)), + m_contentType(std::move(contentType)) { + connect(&m_server, &QTcpServer::newConnection, this, + &CannedHttpServer::onNewConnection); + REQUIRE(m_server.listen(QHostAddress::LocalHost, 0)); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/latest") + .arg(m_server.serverPort()); + } + + private: + void onNewConnection() { + while (QTcpSocket* socket = m_server.nextPendingConnection()) { + connect(socket, &QTcpSocket::readyRead, this, [this, socket] { + if (socket->canReadLine()) { + // Drain the headers. + while (!socket->readLine().trimmed().isEmpty()) { + } + const QByteArray header = + QByteArray("HTTP/1.1 ") + QByteArray::number(m_statusCode) + + " OK\r\n" + "Content-Type: " + m_contentType + "\r\n" + + "Content-Length: " + QByteArray::number(m_body.size()) + "\r\n" + + "Connection: close\r\n\r\n"; + socket->write(header + m_body); + socket->disconnectFromHost(); } + }); } + } - QTcpServer m_server; - int m_statusCode; - QByteArray m_body; - QByteArray m_contentType; + QTcpServer m_server; + int m_statusCode; + QByteArray m_body; + QByteArray m_contentType; }; // Returns a port on localhost that is not currently listening. quint16 closedPort() { - QTcpServer server; - REQUIRE(server.listen(QHostAddress::LocalHost, 0)); - const quint16 port = server.serverPort(); - server.close(); - return port; + QTcpServer server; + REQUIRE(server.listen(QHostAddress::LocalHost, 0)); + const quint16 port = server.serverPort(); + server.close(); + return port; } constexpr auto kReleaseJson = R"({ @@ -75,115 +79,118 @@ constexpr auto kReleaseJson = R"({ "body": "notes" })"; -Result> checkPayload(const QByteArray& payload, int status = 200, - const QString& current = QStringLiteral("0.1.0")) { - EventLoop loop; - QNetworkAccessManager network; - CannedHttpServer server(status, payload); - UpdateService service(current, network, server.url()); - return runAsync(service.check()); +Result> checkPayload( + const QByteArray& payload, int status = 200, + const QString& current = QStringLiteral("0.1.0")) { + EventLoop loop; + QNetworkAccessManager network; + CannedHttpServer server(status, payload); + UpdateService service(current, network, server.url()); + return runAsync(service.check()); } } // namespace -TEST_CASE("is_newer compares versions with padding, pre-release suffixes and dev versions") { - struct Case { - const char* tag; - const char* current; - bool expected; - }; - const std::vector cases = { - {"v0.2.0", "0.1.0", true}, - {"v0.1.0", "0.1.0", false}, - {"v0.1.0", "0.2.0", false}, - {"v0.10.0", "0.9.0", true}, - {"v1.0", "1.0.0", false}, - {"v1.0.1", "1.0", true}, - {"v0.2.0-beta.1", "0.1.0", true}, - {"nightly", "0.1.0", false}, - {"v0.2.0", "unknown", false}, - {"v0.2.0", "0.2.0-rc1", true}, - {"v0.2.0-rc1", "0.2.0", false}, - {"v0.2.0-rc2", "0.2.0-rc1", true}, - {"v0.2.0-rc1", "0.2.0-rc1", false}, - {"v0.2.0-rc1", "0.1.9", true}, - {"v0.1.9", "0.2.0-rc1", false}, - {"v0.2.0+build5", "0.2.0", false}, - }; - for (const auto& [tag, current, expected] : cases) { - CAPTURE(tag, current); - CHECK(isNewer(QString::fromLatin1(tag), QString::fromLatin1(current)) == expected); - } +TEST_CASE( + "is_newer compares versions with padding, pre-release suffixes and dev " + "versions") { + struct Case { + const char* tag; + const char* current; + bool expected; + }; + const std::vector cases = { + {"v0.2.0", "0.1.0", true}, {"v0.1.0", "0.1.0", false}, + {"v0.1.0", "0.2.0", false}, {"v0.10.0", "0.9.0", true}, + {"v1.0", "1.0.0", false}, {"v1.0.1", "1.0", true}, + {"v0.2.0-beta.1", "0.1.0", true}, {"nightly", "0.1.0", false}, + {"v0.2.0", "unknown", false}, {"v0.2.0", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0", false}, {"v0.2.0-rc2", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0-rc1", false}, {"v0.2.0-rc1", "0.1.9", true}, + {"v0.1.9", "0.2.0-rc1", false}, {"v0.2.0+build5", "0.2.0", false}, + }; + for (const auto& [tag, current, expected] : cases) { + CAPTURE(tag, current); + CHECK(isNewer(QString::fromLatin1(tag), QString::fromLatin1(current)) == + expected); + } } -TEST_CASE("parse_version extracts leading numeric parts and rejects non-numeric tags") { - CHECK(parseVersion(QStringLiteral("latest")) == std::nullopt); - const auto parsed = parseVersion(QStringLiteral(" v1.2.3 ")); - REQUIRE(parsed.has_value()); - CHECK(*parsed == std::vector{1, 2, 3}); +TEST_CASE( + "parse_version extracts leading numeric parts and rejects non-numeric " + "tags") { + CHECK(parseVersion(QStringLiteral("latest")) == std::nullopt); + const auto parsed = parseVersion(QStringLiteral(" v1.2.3 ")); + REQUIRE(parsed.has_value()); + CHECK(*parsed == std::vector{1, 2, 3}); } TEST_CASE("UpdateService returns a newer release") { - const auto result = checkPayload(kReleaseJson); - REQUIRE(result.has_value()); - REQUIRE(result->has_value()); - const ReleaseInfo& release = **result; - CHECK(release.tag == QStringLiteral("v0.2.0")); - CHECK(release.name == QStringLiteral("Second")); - CHECK(release.notes == QStringLiteral("notes")); - CHECK(release.url == QStringLiteral("https://example.test/r/v0.2.0")); + const auto result = checkPayload(kReleaseJson); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + const ReleaseInfo& release = **result; + CHECK(release.tag == QStringLiteral("v0.2.0")); + CHECK(release.name == QStringLiteral("Second")); + CHECK(release.notes == QStringLiteral("notes")); + CHECK(release.url == QStringLiteral("https://example.test/r/v0.2.0")); } -TEST_CASE("UpdateService returns nullopt when the latest release is not newer") { - const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0")); - REQUIRE(result.has_value()); - CHECK(!result->has_value()); +TEST_CASE( + "UpdateService returns nullopt when the latest release is not newer") { + const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0")); + REQUIRE(result.has_value()); + CHECK(!result->has_value()); } TEST_CASE("UpdateService offers a plain release over its own rc") { - const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); - REQUIRE(result.has_value()); - REQUIRE(result->has_value()); - CHECK((**result).tag == QStringLiteral("v0.2.0")); + const auto result = + checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + CHECK((**result).tag == QStringLiteral("v0.2.0")); } TEST_CASE("UpdateService wraps HTTP status errors") { - const auto result = checkPayload("{}", 500); - CHECK(!result.has_value()); + const auto result = checkPayload("{}", 500); + CHECK(!result.has_value()); } TEST_CASE("UpdateService wraps transport failures") { - EventLoop loop; - QNetworkAccessManager network; - const QString deadUrl = QStringLiteral("http://127.0.0.1:%1/latest").arg(closedPort()); - UpdateService service(QStringLiteral("0.1.0"), network, deadUrl); - const auto result = runAsync(service.check()); - CHECK(!result.has_value()); + EventLoop loop; + QNetworkAccessManager network; + const QString deadUrl = + QStringLiteral("http://127.0.0.1:%1/latest").arg(closedPort()); + UpdateService service(QStringLiteral("0.1.0"), network, deadUrl); + const auto result = runAsync(service.check()); + CHECK(!result.has_value()); } TEST_CASE("UpdateService wraps non-JSON bodies") { - const auto result = checkPayload("", 200); - CHECK(!result.has_value()); + const auto result = checkPayload("", 200); + CHECK(!result.has_value()); } TEST_CASE("UpdateService rejects non-object JSON payloads") { - const std::initializer_list payloads{"[]", "null", "\"tag\"", "3"}; - for (const QByteArray& payload : payloads) { - CAPTURE(payload); - const auto result = checkPayload(payload); - CHECK(!result.has_value()); - } + const std::initializer_list payloads{"[]", "null", "\"tag\"", + "3"}; + for (const QByteArray& payload : payloads) { + CAPTURE(payload); + const auto result = checkPayload(payload); + CHECK(!result.has_value()); + } } TEST_CASE("UpdateService rejects responses lacking tag_name or html_url") { - for (const QByteArray& payload : { - QByteArray("{}"), - QByteArray(R"({"tag_name": "v0.2.0"})"), - QByteArray(R"({"html_url": "https://example.test/r"})"), - QByteArray(R"({"tag_name": "", "html_url": "https://example.test/r"})"), - }) { - CAPTURE(payload); - const auto result = checkPayload(payload); - CHECK(!result.has_value()); - } + for (const QByteArray& payload : { + QByteArray("{}"), + QByteArray(R"({"tag_name": "v0.2.0"})"), + QByteArray(R"({"html_url": "https://example.test/r"})"), + QByteArray( + R"({"tag_name": "", "html_url": "https://example.test/r"})"), + }) { + CAPTURE(payload); + const auto result = checkPayload(payload); + CHECK(!result.has_value()); + } } diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp index 20c7881..5e9e6d6 100644 --- a/tests/core/sort/test_community.cpp +++ b/tests/core/sort/test_community.cpp @@ -1,29 +1,30 @@ -#include - #include #include -#include "core/sort/community.hpp" +#include + #include "../test_support.hpp" +#include "core/sort/community.hpp" using px::modrim::sort::loadCommunityRules; using px::modrim::sort::PackageId; TEST_CASE("a missing community rules file means no rules") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); - const auto rules = loadCommunityRules(dir.filePath(QStringLiteral("communityRules.json"))); + const auto rules = + loadCommunityRules(dir.filePath(QStringLiteral("communityRules.json"))); - REQUIRE(rules.has_value()); - CHECK(rules->isEmpty()); + REQUIRE(rules.has_value()); + CHECK(rules->isEmpty()); } TEST_CASE("community rules are keyed by lowercased package id") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("communityRules.json")); - REQUIRE(test_support::writeBytes(path, R"({ + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::writeBytes(path, R"({ "timestamp": 1, "rules": { "Some.Mod": { @@ -35,26 +36,26 @@ TEST_CASE("community rules are keyed by lowercased package id") { } })")); - const auto loaded = loadCommunityRules(path); - - REQUIRE(loaded.has_value()); - REQUIRE(loaded->contains(PackageId(QStringLiteral("some.mod")))); - const auto& rule = loaded->value(PackageId(QStringLiteral("some.mod"))); - CHECK(rule.packageId == PackageId(QStringLiteral("some.mod"))); - CHECK(rule.loadAfter.contains(QStringLiteral("base.mod"))); - CHECK(rule.loadBefore.contains(QStringLiteral("late.mod"))); - CHECK(rule.loadFirst); - CHECK_FALSE(rule.loadLast); - CHECK(rule.incompatibleWith.empty()); + const auto loaded = loadCommunityRules(path); + + REQUIRE(loaded.has_value()); + REQUIRE(loaded->contains(PackageId(QStringLiteral("some.mod")))); + const auto& rule = loaded->value(PackageId(QStringLiteral("some.mod"))); + CHECK(rule.packageId == PackageId(QStringLiteral("some.mod"))); + CHECK(rule.loadAfter.contains(QStringLiteral("base.mod"))); + CHECK(rule.loadBefore.contains(QStringLiteral("late.mod"))); + CHECK(rule.loadFirst); + CHECK_FALSE(rule.loadLast); + CHECK(rule.incompatibleWith.empty()); } TEST_CASE("a malformed community rules file is an error") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("communityRules.json")); - REQUIRE(test_support::writeBytes(path, R"({"rules": [1, 2)")); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::writeBytes(path, R"({"rules": [1, 2)")); - const auto loaded = loadCommunityRules(path); + const auto loaded = loadCommunityRules(path); - CHECK_FALSE(loaded.has_value()); + CHECK_FALSE(loaded.has_value()); } diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp index cac2e52..af9d00d 100644 --- a/tests/core/sort/test_tier_order.cpp +++ b/tests/core/sort/test_tier_order.cpp @@ -1,12 +1,11 @@ -#include - -#include -#include - #include #include #include #include +#include +#include + +#include #include "core/checker/graph.hpp" #include "core/checker/sort.hpp" @@ -28,163 +27,171 @@ namespace { using ModPtr = std::shared_ptr; using ModMap = QHash; -PackageId pid(const QString& value) { - return PackageId(value); -} +PackageId pid(const QString& value) { return PackageId(value); } ModPtr makeMod(const QString& packageId) { - auto mod = std::make_shared(); - mod->packageId = CaseInsensitiveStr(packageId); - mod->name = packageId; - return mod; + auto mod = std::make_shared(); + mod->packageId = CaseInsensitiveStr(packageId); + mod->name = packageId; + return mod; } ModMap activeDict(std::initializer_list mods) { - ModMap result; - for (const auto& mod : mods) { - result.insert(mod->packageId, mod); - } - return result; + ModMap result; + for (const auto& mod : mods) { + result.insert(mod->packageId, mod); + } + return result; } -// Community rules are off and alternative ids are ignored, so only the tier config and explicit -// load-order rules shape the result. -QList sortMods(const ModMap& mods, const TierConfig& tierConfig = TierConfig::defaults()) { - SortSettings settings; - settings.use_community_rules = false; - settings.use_alternative_package_ids = false; - settings.tier_config = tierConfig; - - ConstraintGraph graph; - graph.build(mods, mods.keys(), settings); - return topologicalSort(mods, graph, settings); +// Community rules are off and alternative ids are ignored, so only the tier +// config and explicit load-order rules shape the result. +QList sortMods( + const ModMap& mods, const TierConfig& tierConfig = TierConfig::defaults()) { + SortSettings settings; + settings.use_community_rules = false; + settings.use_alternative_package_ids = false; + settings.tier_config = tierConfig; + + ConstraintGraph graph; + graph.build(mods, mods.keys(), settings); + return topologicalSort(mods, graph, settings); } -// Checks only the entries of `result` that belong to `expected`, in their order. +// Checks only the entries of `result` that belong to `expected`, in their +// order. void assertOrder(const QList& result, const QStringList& expected) { - QStringList inTier; - for (const auto& entry : result) { - if (expected.contains(entry.str())) { - inTier.append(entry.str()); - } + QStringList inTier; + for (const auto& entry : result) { + if (expected.contains(entry.str())) { + inTier.append(entry.str()); } - CHECK(inTier == expected); + } + CHECK(inTier == expected); } } // namespace TEST_CASE("tier 0 mods follow the config order") { - const auto mods = activeDict({ - makeMod(QStringLiteral("brrainz.harmony")), - makeMod(QStringLiteral("zetrith.prepatcher")), - makeMod(QStringLiteral("ludeon.rimworld")), - makeMod(QStringLiteral("ludeon.rimworld.royalty")), - makeMod(QStringLiteral("ludeon.rimworld.ideology")), - makeMod(QStringLiteral("ludeon.rimworld.biotech")), - makeMod(QStringLiteral("ludeon.rimworld.anomaly")), - makeMod(QStringLiteral("ludeon.rimworld.odyssey")), - }); - - assertOrder(sortMods(mods), - {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony"), - QStringLiteral("ludeon.rimworld"), QStringLiteral("ludeon.rimworld.royalty"), - QStringLiteral("ludeon.rimworld.ideology"), QStringLiteral("ludeon.rimworld.biotech"), - QStringLiteral("ludeon.rimworld.anomaly"), QStringLiteral("ludeon.rimworld.odyssey")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), + makeMod(QStringLiteral("ludeon.rimworld.ideology")), + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("ludeon.rimworld.anomaly")), + makeMod(QStringLiteral("ludeon.rimworld.odyssey")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony"), + QStringLiteral("ludeon.rimworld"), + QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.ideology"), + QStringLiteral("ludeon.rimworld.biotech"), + QStringLiteral("ludeon.rimworld.anomaly"), + QStringLiteral("ludeon.rimworld.odyssey")}); } TEST_CASE("tier 0 partial active set keeps config order") { - const auto mods = activeDict({ - makeMod(QStringLiteral("ludeon.rimworld.biotech")), - makeMod(QStringLiteral("zetrith.prepatcher")), - makeMod(QStringLiteral("ludeon.rimworld.royalty")), - }); - - assertOrder(sortMods(mods), - {QStringLiteral("zetrith.prepatcher"), QStringLiteral("ludeon.rimworld.royalty"), - QStringLiteral("ludeon.rimworld.biotech")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.biotech")}); } TEST_CASE("tier 0 load-after rule orders mods within the tier") { - const auto mods = activeDict({ - makeMod(QStringLiteral("brrainz.harmony")), - makeMod(QStringLiteral("zetrith.prepatcher")), - }); - mods.value(pid(QStringLiteral("brrainz.harmony")))->aboutRules.loadAfter = - CaseInsensitiveSet(QStringList{QStringLiteral("zetrith.prepatcher")}); - - assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), + }); + mods.value(pid(QStringLiteral("brrainz.harmony")))->aboutRules.loadAfter = + CaseInsensitiveSet(QStringList{QStringLiteral("zetrith.prepatcher")}); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony")}); } TEST_CASE("tier 1 mods follow the config order") { - const auto mods = activeDict({ - makeMod(QStringLiteral("unlimitedhugs.hugslib")), - makeMod(QStringLiteral("adaptive.storage.framework")), - makeMod(QStringLiteral("aoba.framework")), - makeMod(QStringLiteral("smashphil.vehicleframework")), - makeMod(QStringLiteral("vanillaexpanded.backgrounds")), - }); - - assertOrder(sortMods(mods), - {QStringLiteral("adaptive.storage.framework"), QStringLiteral("aoba.framework"), - QStringLiteral("smashphil.vehicleframework"), QStringLiteral("unlimitedhugs.hugslib"), - QStringLiteral("vanillaexpanded.backgrounds")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("unlimitedhugs.hugslib")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("aoba.framework")), + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), + QStringLiteral("aoba.framework"), + QStringLiteral("smashphil.vehicleframework"), + QStringLiteral("unlimitedhugs.hugslib"), + QStringLiteral("vanillaexpanded.backgrounds")}); } TEST_CASE("tier 1 partial active set keeps config order") { - const auto mods = activeDict({ - makeMod(QStringLiteral("smashphil.vehicleframework")), - makeMod(QStringLiteral("adaptive.storage.framework")), - makeMod(QStringLiteral("vanillaexpanded.backgrounds")), - }); - - assertOrder(sortMods(mods), - {QStringLiteral("adaptive.storage.framework"), QStringLiteral("smashphil.vehicleframework"), - QStringLiteral("vanillaexpanded.backgrounds")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), + QStringLiteral("smashphil.vehicleframework"), + QStringLiteral("vanillaexpanded.backgrounds")}); } TEST_CASE("tier 1 load-after rule orders mods within the tier") { - const auto mods = activeDict({ - makeMod(QStringLiteral("aoba.exosuit.framework")), - makeMod(QStringLiteral("aoba.framework")), - }); - mods.value(pid(QStringLiteral("aoba.exosuit.framework")))->aboutRules.loadAfter = - CaseInsensitiveSet(QStringList{QStringLiteral("aoba.framework")}); - - assertOrder(sortMods(mods), {QStringLiteral("aoba.framework"), QStringLiteral("aoba.exosuit.framework")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("aoba.exosuit.framework")), + makeMod(QStringLiteral("aoba.framework")), + }); + mods.value(pid(QStringLiteral("aoba.exosuit.framework"))) + ->aboutRules.loadAfter = + CaseInsensitiveSet(QStringList{QStringLiteral("aoba.framework")}); + + assertOrder(sortMods(mods), {QStringLiteral("aoba.framework"), + QStringLiteral("aoba.exosuit.framework")}); } TEST_CASE("tiers are ordered: 0 before 1 before 2 before 3") { - const auto mods = activeDict({ - makeMod(QStringLiteral("some.two.mod")), - makeMod(QStringLiteral("brrainz.harmony")), - makeMod(QStringLiteral("adaptive.storage.framework")), - makeMod(QStringLiteral("some.three.mod")), - }); - TierConfig tierConfig; - tierConfig.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; - tierConfig.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; - tierConfig.tier_3 = {pid(QStringLiteral("some.three.mod"))}; - - const auto result = sortMods(mods, tierConfig); - - CHECK(result.indexOf(pid(QStringLiteral("brrainz.harmony"))) < - result.indexOf(pid(QStringLiteral("adaptive.storage.framework")))); - CHECK(result.indexOf(pid(QStringLiteral("adaptive.storage.framework"))) < - result.indexOf(pid(QStringLiteral("some.two.mod")))); - CHECK(result.indexOf(pid(QStringLiteral("some.two.mod"))) < - result.indexOf(pid(QStringLiteral("some.three.mod")))); + const auto mods = activeDict({ + makeMod(QStringLiteral("some.two.mod")), + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("some.three.mod")), + }); + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; + tierConfig.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; + tierConfig.tier_3 = {pid(QStringLiteral("some.three.mod"))}; + + const auto result = sortMods(mods, tierConfig); + + CHECK(result.indexOf(pid(QStringLiteral("brrainz.harmony"))) < + result.indexOf(pid(QStringLiteral("adaptive.storage.framework")))); + CHECK(result.indexOf(pid(QStringLiteral("adaptive.storage.framework"))) < + result.indexOf(pid(QStringLiteral("some.two.mod")))); + CHECK(result.indexOf(pid(QStringLiteral("some.two.mod"))) < + result.indexOf(pid(QStringLiteral("some.three.mod")))); } TEST_CASE("mods outside every configured tier come after configured mods") { - const auto mods = activeDict({ - makeMod(QStringLiteral("unknown.first")), - makeMod(QStringLiteral("zetrith.prepatcher")), - makeMod(QStringLiteral("unknown.second")), - makeMod(QStringLiteral("brrainz.harmony")), - }); - TierConfig tierConfig; - tierConfig.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), pid(QStringLiteral("brrainz.harmony"))}; - - assertOrder(sortMods(mods, tierConfig), - {QStringLiteral("zetrith.prepatcher"), QStringLiteral("brrainz.harmony")}); + const auto mods = activeDict({ + makeMod(QStringLiteral("unknown.first")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("unknown.second")), + makeMod(QStringLiteral("brrainz.harmony")), + }); + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), + pid(QStringLiteral("brrainz.harmony"))}; + + assertOrder(sortMods(mods, tierConfig), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony")}); } diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp index ea838a8..d9aed23 100644 --- a/tests/core/sort/test_tiers.cpp +++ b/tests/core/sort/test_tiers.cpp @@ -1,9 +1,9 @@ -#include - #include #include #include +#include + #include "core/sort/tiers.hpp" using px::modrim::sort::assignTiers; @@ -17,132 +17,137 @@ using px::modrim::sort::TierConfig; namespace { -PackageId pid(const QString& value) { - return PackageId(value); -} +PackageId pid(const QString& value) { return PackageId(value); } void addEdge(PackageGraph& graph, const QString& from, const QString& to) { - graph[pid(from)].insert(pid(to)); + graph[pid(from)].insert(pid(to)); } } // namespace TEST_CASE("tier graph algorithms handle long chains") { - constexpr int kCount = 5000; - QList nodes; - for (int index = 0; index < kCount; ++index) { - nodes.append(pid(QStringLiteral("mod.%1").arg(index))); - } - const QSet all(nodes.cbegin(), nodes.cend()); - - PackageGraph deps; - PackageGraph reverseDeps; - for (int index = 0; index + 1 < kCount; ++index) { - deps[nodes[index]].insert(nodes[index + 1]); - reverseDeps[nodes[index + 1]].insert(nodes[index]); - } - - const QSet tail(nodes.cbegin() + 1, nodes.cend()); - const QSet head(nodes.cbegin(), nodes.cend() - 1); - CHECK(getDepsRecursive(nodes.front(), deps) == tail); - CHECK(getReverseDepsRecursive(nodes.back(), reverseDeps) == head); - CHECK_FALSE(findCycle(all, deps).has_value()); - - deps[nodes.back()].insert(nodes.front()); - const auto cycle = findCycle(all, deps); - REQUIRE(cycle.has_value()); - CHECK(cycle->size() == kCount + 1); - CHECK(cycle->front() == cycle->back()); - CHECK(QSet(cycle->cbegin(), cycle->cend() - 1) == all); + constexpr int kCount = 5000; + QList nodes; + for (int index = 0; index < kCount; ++index) { + nodes.append(pid(QStringLiteral("mod.%1").arg(index))); + } + const QSet all(nodes.cbegin(), nodes.cend()); + + PackageGraph deps; + PackageGraph reverseDeps; + for (int index = 0; index + 1 < kCount; ++index) { + deps[nodes[index]].insert(nodes[index + 1]); + reverseDeps[nodes[index + 1]].insert(nodes[index]); + } + + const QSet tail(nodes.cbegin() + 1, nodes.cend()); + const QSet head(nodes.cbegin(), nodes.cend() - 1); + CHECK(getDepsRecursive(nodes.front(), deps) == tail); + CHECK(getReverseDepsRecursive(nodes.back(), reverseDeps) == head); + CHECK_FALSE(findCycle(all, deps).has_value()); + + deps[nodes.back()].insert(nodes.front()); + const auto cycle = findCycle(all, deps); + REQUIRE(cycle.has_value()); + CHECK(cycle->size() == kCount + 1); + CHECK(cycle->front() == cycle->back()); + CHECK(QSet(cycle->cbegin(), cycle->cend() - 1) == all); } TEST_CASE("find_cycle returns a closed path whose steps are dependency edges") { - PackageGraph deps; - addEdge(deps, QStringLiteral("a"), QStringLiteral("b")); - addEdge(deps, QStringLiteral("b"), QStringLiteral("c")); - addEdge(deps, QStringLiteral("c"), QStringLiteral("a")); - addEdge(deps, QStringLiteral("c"), QStringLiteral("d")); - const QSet nodes{pid(QStringLiteral("a")), pid(QStringLiteral("b")), - pid(QStringLiteral("c")), pid(QStringLiteral("d"))}; - - const auto cycle = findCycle(nodes, deps); - REQUIRE(cycle.has_value()); - REQUIRE(cycle->size() == 4); - CHECK(cycle->front() == cycle->back()); - for (qsizetype index = 0; index + 1 < cycle->size(); ++index) { - CHECK(deps.value((*cycle)[index]).contains((*cycle)[index + 1])); - } + PackageGraph deps; + addEdge(deps, QStringLiteral("a"), QStringLiteral("b")); + addEdge(deps, QStringLiteral("b"), QStringLiteral("c")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("a")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("d")); + const QSet nodes{ + pid(QStringLiteral("a")), pid(QStringLiteral("b")), + pid(QStringLiteral("c")), pid(QStringLiteral("d"))}; + + const auto cycle = findCycle(nodes, deps); + REQUIRE(cycle.has_value()); + REQUIRE(cycle->size() == 4); + CHECK(cycle->front() == cycle->back()); + for (qsizetype index = 0; index + 1 < cycle->size(); ++index) { + CHECK(deps.value((*cycle)[index]).contains((*cycle)[index + 1])); + } } TEST_CASE("find_cycle ignores edges that leave the node set") { - PackageGraph deps; - addEdge(deps, QStringLiteral("a"), QStringLiteral("outside")); - addEdge(deps, QStringLiteral("outside"), QStringLiteral("a")); - const QSet nodes{pid(QStringLiteral("a"))}; + PackageGraph deps; + addEdge(deps, QStringLiteral("a"), QStringLiteral("outside")); + addEdge(deps, QStringLiteral("outside"), QStringLiteral("a")); + const QSet nodes{pid(QStringLiteral("a"))}; - CHECK_FALSE(findCycle(nodes, deps).has_value()); + CHECK_FALSE(findCycle(nodes, deps).has_value()); } TEST_CASE("assign_tiers places tier 0 mods and their dependencies in tier 0") { - PackageGraph deps; - addEdge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); - const QSet canonical{pid(QStringLiteral("zetrith")), pid(QStringLiteral("bar")), - pid(QStringLiteral("other"))}; - TierConfig config; - config.tier_0 = {pid(QStringLiteral("zetrith"))}; - - const auto tiers = assignTiers(canonical, deps, {}, {}, {}, config); - - CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::kTier0); - CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::kTier0); - CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::kTier2); + PackageGraph deps; + addEdge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); + const QSet canonical{pid(QStringLiteral("zetrith")), + pid(QStringLiteral("bar")), + pid(QStringLiteral("other"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("zetrith"))}; + + const auto tiers = assignTiers(canonical, deps, {}, {}, {}, config); + + CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::kTier2); } TEST_CASE("assign_tiers sinks load-last mods and their dependents to tier 3") { - PackageGraph revDeps; - addEdge(revDeps, QStringLiteral("late"), QStringLiteral("dependent")); - const QSet canonical{pid(QStringLiteral("late")), pid(QStringLiteral("dependent")), - pid(QStringLiteral("mid"))}; - - const auto tiers = assignTiers(canonical, {}, revDeps, {}, - QSet{pid(QStringLiteral("late"))}, TierConfig{}); - - CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::kTier3); - CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::kTier3); - CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::kTier2); + PackageGraph revDeps; + addEdge(revDeps, QStringLiteral("late"), QStringLiteral("dependent")); + const QSet canonical{pid(QStringLiteral("late")), + pid(QStringLiteral("dependent")), + pid(QStringLiteral("mid"))}; + + const auto tiers = + assignTiers(canonical, {}, revDeps, {}, + QSet{pid(QStringLiteral("late"))}, TierConfig{}); + + CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::kTier2); } TEST_CASE("tier 0 overrides tier 3 on overlap") { - const QSet canonical{pid(QStringLiteral("x"))}; - TierConfig config; - config.tier_0 = {pid(QStringLiteral("x"))}; + const QSet canonical{pid(QStringLiteral("x"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("x"))}; - const auto tiers = assignTiers(canonical, {}, {}, {}, - QSet{pid(QStringLiteral("x"))}, config); + const auto tiers = assignTiers( + canonical, {}, {}, {}, QSet{pid(QStringLiteral("x"))}, config); - CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::kTier0); } TEST_CASE("tier 1 only promotes tier 2 mods") { - const QSet canonical{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; - const QSet loadFirst{pid(QStringLiteral("first")), pid(QStringLiteral("last"))}; - const QSet loadLast{pid(QStringLiteral("last"))}; + const QSet canonical{pid(QStringLiteral("first")), + pid(QStringLiteral("last"))}; + const QSet loadFirst{pid(QStringLiteral("first")), + pid(QStringLiteral("last"))}; + const QSet loadLast{pid(QStringLiteral("last"))}; - const auto tiers = assignTiers(canonical, {}, {}, loadFirst, loadLast, TierConfig{}); + const auto tiers = + assignTiers(canonical, {}, {}, loadFirst, loadLast, TierConfig{}); - CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::kTier1); - CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::kTier1); + CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::kTier3); } TEST_CASE("assign_tiers never returns ids outside the canonical set") { - const QSet canonical{pid(QStringLiteral("a"))}; - TierConfig config; - config.tier_0 = {pid(QStringLiteral("ghost"))}; - config.tier_3 = {pid(QStringLiteral("ghost"))}; + const QSet canonical{pid(QStringLiteral("a"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("ghost"))}; + config.tier_3 = {pid(QStringLiteral("ghost"))}; - const auto tiers = assignTiers(canonical, {}, {}, {}, {}, config); + const auto tiers = assignTiers(canonical, {}, {}, {}, {}, config); - CHECK(tiers.size() == 1); - CHECK_FALSE(tiers.contains(pid(QStringLiteral("ghost")))); - CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::kTier2); + CHECK(tiers.size() == 1); + CHECK_FALSE(tiers.contains(pid(QStringLiteral("ghost")))); + CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::kTier2); } diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp index fb74ffa..c769dc6 100644 --- a/tests/core/support/test_report.cpp +++ b/tests/core/support/test_report.cpp @@ -1,10 +1,4 @@ -#include - -#include -#include - #include -#include #include #include #include @@ -12,6 +6,12 @@ #include #include #include +#include +#include + +#include + +#include #include "../test_support.hpp" #include "core/models/metadata/structures.hpp" @@ -29,323 +29,361 @@ namespace { // Awaits a coroutine on the event loop. template T wait(QCoro::Task task) { - return QCoro::waitFor(std::move(task)); + return QCoro::waitFor(std::move(task)); } void requireError(const Result& result, const QString& fragment) { - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message.contains(fragment)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(fragment)); } -// Answers each request with `response`. An empty response never answers, which lets a test -// hit the client's timeout. The request is recorded for inspection. +// Answers each request with `response`. An empty response never answers, which +// lets a test hit the client's timeout. The request is recorded for inspection. class CannedServer { -public: - explicit CannedServer(QByteArray response) : m_response(std::move(response)) { - REQUIRE(m_server.listen(QHostAddress::LocalHost)); - QObject::connect(&m_server, &QTcpServer::newConnection, [this] { accept(); }); + public: + explicit CannedServer(QByteArray response) : m_response(std::move(response)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, + [this] { accept(); }); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/").arg(m_server.serverPort()); + } + + const QByteArray& request() const { return m_request; } + + private: + void accept() { + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket] { + m_request += socket->readAll(); + if (m_response.isEmpty() || !complete(m_request)) { + return; + } + socket->write(m_response); + socket->disconnectFromHost(); + }); + } + + // True once the headers have arrived and the Content-Length body is complete. + static bool complete(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { + return false; } - - QString url() const { - return QStringLiteral("http://127.0.0.1:%1/").arg(m_server.serverPort()); + const QByteArray headers = raw.left(headersEnd).toLower(); + const qsizetype key = headers.indexOf("content-length:"); + if (key < 0) { + return true; } - - const QByteArray& request() const { return m_request; } - -private: - void accept() { - QTcpSocket* socket = m_server.nextPendingConnection(); - socket->setParent(&m_server); - QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket] { - m_request += socket->readAll(); - if (m_response.isEmpty() || !complete(m_request)) { - return; - } - socket->write(m_response); - socket->disconnectFromHost(); - }); + qsizetype eol = headers.indexOf("\r\n", key); + if (eol < 0) { + eol = headers.size(); } - - // True once the headers have arrived and the Content-Length body is complete. - static bool complete(const QByteArray& raw) { - const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); - if (headersEnd < 0) { - return false; - } - const QByteArray headers = raw.left(headersEnd).toLower(); - const qsizetype key = headers.indexOf("content-length:"); - if (key < 0) { - return true; - } - qsizetype eol = headers.indexOf("\r\n", key); - if (eol < 0) { - eol = headers.size(); - } - const qint64 length = headers.mid(key + 15, eol - key - 15).trimmed().toLongLong(); - return raw.size() - (headersEnd + 4) >= length; - } - - QTcpServer m_server; - QByteArray m_response; - QByteArray m_request; + const qint64 length = + headers.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + return raw.size() - (headersEnd + 4) >= length; + } + + QTcpServer m_server; + QByteArray m_response; + QByteArray m_request; }; QByteArray httpResponse(const QByteArray& statusLine, const QByteArray& body) { - return "HTTP/1.1 " + statusLine + "\r\nContent-Type: text/plain\r\nContent-Length: " + - QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body; + return "HTTP/1.1 " + statusLine + + "\r\nContent-Type: text/plain\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + + body; } ListedModPtr aboutMod(const QString& name, const QString& packageId) { - auto mod = std::make_shared(); - mod->name = name; - mod->packageId = CaseInsensitiveStr(packageId); - return mod; + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(packageId); + return mod; } } // namespace TEST_CASE("redact replaces the home directory and username in user paths") { - CHECK(redact(QStringLiteral("User alice at /home/alice/.config/log.txt and /mnt/home/alice/x"), - QStringLiteral("/home/alice"), QStringLiteral("alice")) == - QStringLiteral("User alice at ~/.config/log.txt and /mnt/home//x")); + CHECK( + redact(QStringLiteral("User alice at /home/alice/.config/log.txt and " + "/mnt/home/alice/x"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral("User alice at ~/.config/log.txt and /mnt/home//x")); } TEST_CASE("redact handles Windows-style paths") { - CHECK(redact(QString::fromLatin1(R"(Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log)"), - QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == - QString::fromLatin1(R"(Loaded ~\AppData\Local\pxmodrim\test.log)")); - CHECK(redact(QStringLiteral("Loaded C:/Users/Bob/AppData/test.log"), - QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == - QStringLiteral("Loaded ~/AppData/test.log")); + CHECK(redact(QString::fromLatin1( + R"(Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log)"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QString::fromLatin1(R"(Loaded ~\AppData\Local\pxmodrim\test.log)")); + CHECK(redact(QStringLiteral("Loaded C:/Users/Bob/AppData/test.log"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QStringLiteral("Loaded ~/AppData/test.log")); } TEST_CASE("redact respects path boundaries around the home directory") { - CHECK(redact(QStringLiteral("/home/bob/a and /home/bobby/b and /home/bob"), - QStringLiteral("/home/bob"), QStringLiteral("bob")) == - QStringLiteral("~/a and /home/bobby/b and ~")); + CHECK(redact(QStringLiteral("/home/bob/a and /home/bobby/b and /home/bob"), + QStringLiteral("/home/bob"), QStringLiteral("bob")) == + QStringLiteral("~/a and /home/bobby/b and ~")); } TEST_CASE("redact redacts usernames on removable media mounts") { - CHECK(redact(QStringLiteral("/run/media/alice/SD/mods and /media/alice/x and /media/alicia/y"), - QStringLiteral("/home/alice"), QStringLiteral("alice")) == - QStringLiteral("/run/media//SD/mods and /media//x and /media/alicia/y")); + CHECK( + redact(QStringLiteral("/run/media/alice/SD/mods and /media/alice/x and " + "/media/alicia/y"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral( + "/run/media//SD/mods and /media//x and /media/alicia/y")); } TEST_CASE("redact handles doubled backslashes from repr") { - const QString raw = QString::fromLatin1(R"('C:\\Users\\Bob\\AppData' and 'D:\\Users\\Bob\\mods')"); - const QString result = - redact(raw, QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")); - CHECK_FALSE(result.contains(QStringLiteral("Bob"))); - CHECK(result.startsWith(QStringLiteral("'~"))); + const QString raw = QString::fromLatin1( + R"('C:\\Users\\Bob\\AppData' and 'D:\\Users\\Bob\\mods')"); + const QString result = redact(raw, QString::fromLatin1(R"(C:\Users\Bob)"), + QStringLiteral("Bob")); + CHECK_FALSE(result.contains(QStringLiteral("Bob"))); + CHECK(result.startsWith(QStringLiteral("'~"))); } TEST_CASE("redact matches short usernames only in user directories") { - CHECK(redact(QString::fromLatin1(R"(ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar)"), - QStringLiteral("/home/ed"), QStringLiteral("ed")) == - QString::fromLatin1(R"(ed saw D:\Users\\mods and /Users//x but not /Users/edgar)")); + CHECK( + redact( + QString::fromLatin1( + R"(ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar)"), + QStringLiteral("/home/ed"), QStringLiteral("ed")) == + QString::fromLatin1( + R"(ed saw D:\Users\\mods and /Users//x but not /Users/edgar)")); } TEST_CASE("redact leaves package ids that contain the username alone") { - const QString raw = QStringLiteral("Core (ludeon.rimworld) from /opt/rimworld; rimworld started"); - CHECK(redact(raw, QStringLiteral("/home/rimworld"), QStringLiteral("rimworld")) == raw); + const QString raw = QStringLiteral( + "Core (ludeon.rimworld) from /opt/rimworld; rimworld started"); + CHECK(redact(raw, QStringLiteral("/home/rimworld"), + QStringLiteral("rimworld")) == raw); } TEST_CASE("read_log_tail reports a missing file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString content = - readLogTail(QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); - CHECK(content.contains(QStringLiteral(""))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString content = readLogTail( + QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); + CHECK(content.contains(QStringLiteral(""))); } TEST_CASE("read_log_tail reports an empty file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = QDir(dir.path()).filePath(QStringLiteral("empty.log")); - REQUIRE(writeBytes(path, "")); - CHECK(readLogTail(path).contains(QStringLiteral(""))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("empty.log")); + REQUIRE(writeBytes(path, "")); + CHECK(readLogTail(path).contains(QStringLiteral(""))); } TEST_CASE("read_log_tail returns a small file unchanged") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = QDir(dir.path()).filePath(QStringLiteral("app.log")); - const char* text = "Line 1\nLine 2\nLine 3\n"; - REQUIRE(writeBytes(path, text)); - CHECK(readLogTail(path) == QString::fromLatin1(text)); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("app.log")); + const char* text = "Line 1\nLine 2\nLine 3\n"; + REQUIRE(writeBytes(path, text)); + CHECK(readLogTail(path) == QString::fromLatin1(text)); } TEST_CASE("read_log_tail unpacks loguru JSON records") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = QDir(dir.path()).filePath(QStringLiteral("json.log")); - REQUIRE(writeBytes(path, - R"({"text": "2026-09-28 | INFO | Loaded mods\n", "record": {"id": 1}})" - "\n" - "Plain log line\n" - R"({"text": "2026-09-28 | INFO | Ready\n", "record": {"id": 2}})" - "\n")); - const QString content = readLogTail(path); - CHECK(content == QStringLiteral("2026-09-28 | INFO | Loaded mods\n" - "Plain log line\n" - "2026-09-28 | INFO | Ready\n")); - CHECK_FALSE(content.contains(QString::fromLatin1(R"({"text")"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("json.log")); + REQUIRE(writeBytes( + path, + R"({"text": "2026-09-28 | INFO | Loaded mods\n", "record": {"id": 1}})" + "\n" + "Plain log line\n" + R"({"text": "2026-09-28 | INFO | Ready\n", "record": {"id": 2}})" + "\n")); + const QString content = readLogTail(path); + CHECK(content == QStringLiteral("2026-09-28 | INFO | Loaded mods\n" + "Plain log line\n" + "2026-09-28 | INFO | Ready\n")); + CHECK_FALSE(content.contains(QString::fromLatin1(R"({"text")"))); } TEST_CASE("read_log_tail keeps the end of a large file and adds a banner") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = QDir(dir.path()).filePath(QStringLiteral("large.log")); - QStringList lines; - for (int i = 0; i < 200; ++i) { - lines.append(QStringLiteral("Log record number %1 with some extra padding").arg(i, 4, 10, QLatin1Char('0'))); - } - REQUIRE(writeBytes(path, lines.join(QLatin1Char('\n')).toStdString())); - - const QString content = readLogTail(path, 200); - CHECK(content.startsWith(QStringLiteral("[... truncated, showing last "))); - CHECK(content.contains(QStringLiteral(" of "))); - CHECK(content.contains(QStringLiteral("Log record number 0199"))); - CHECK_FALSE(content.contains(QStringLiteral("Log record number 0001"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("large.log")); + QStringList lines; + for (int i = 0; i < 200; ++i) { + lines.append(QStringLiteral("Log record number %1 with some extra padding") + .arg(i, 4, 10, QLatin1Char('0'))); + } + REQUIRE(writeBytes(path, lines.join(QLatin1Char('\n')).toStdString())); + + const QString content = readLogTail(path, 200); + CHECK(content.startsWith(QStringLiteral("[... truncated, showing last "))); + CHECK(content.contains(QStringLiteral(" of "))); + CHECK(content.contains(QStringLiteral("Log record number 0199"))); + CHECK_FALSE(content.contains(QStringLiteral("Log record number 0001"))); } -TEST_CASE("build_report contains every section with active mods in load order") { - TestApplication app; - QTemporaryDir homeDir; - REQUIRE(homeDir.isValid()); - QTemporaryDir logDir; - REQUIRE(logDir.isValid()); - - const QString home = QDir(homeDir.path()).filePath(QStringLiteral("home/testuser")); - REQUIRE(QDir().mkpath(home)); - - ReportSource source; - source.gameVersion = QStringLiteral("1.5.4243"); - source.paths.game = QDir(home).filePath(QStringLiteral("Games/RimWorld")).toStdString(); - source.paths.config_folder = QDir(home).filePath(QStringLiteral(".config/unity3d")).toStdString(); - source.paths.local = QDir(home).filePath(QStringLiteral("Games/RimWorld/Mods")).toStdString(); - source.allMods = { - {QStringLiteral("u1"), aboutMod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, - {QStringLiteral("u2"), aboutMod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, - {QStringLiteral("u3"), aboutMod(QStringLiteral("Royalty"), QStringLiteral("ludeon.rimworld.royalty"))}, - }; - source.activeUuids = {QStringLiteral("u2"), QStringLiteral("u1"), QStringLiteral("u3")}; - - const QString logPath = QDir(logDir.path()).filePath(QStringLiteral("app.log")); - REQUIRE(writeBytes(logPath, "INFO | Loaded /home/testuser/mods for testuser\n")); - - const QString report = - buildReport(source, logPath, home, QStringLiteral("testuser")); - - CHECK(report.contains(QStringLiteral("=== PxModRim System Report ==="))); - CHECK(report.contains(QStringLiteral("=== RimWorld Configuration ==="))); - CHECK(report.contains(QStringLiteral("=== Mod Collection ==="))); - CHECK(report.contains(QStringLiteral("=== Active Mods (Load Order) ==="))); - CHECK(report.contains(QStringLiteral("=== Application Log ==="))); - - CHECK(report.contains(QStringLiteral("App Version: ") + appVersion())); - CHECK(report.contains(QStringLiteral("Qt Version: "))); - CHECK(report.contains(QStringLiteral("OS: "))); - - CHECK(report.contains(QStringLiteral("Game Version: 1.5.4243"))); - const QString slashedReport = QString(report).replace(u'\\', u'/'); - CHECK(slashedReport.contains(QStringLiteral("~/Games/RimWorld"))); - CHECK(slashedReport.contains(QStringLiteral("~/.config/unity3d"))); - CHECK_FALSE(report.contains(home)); - - CHECK(report.contains(QStringLiteral("Total Mods: 3"))); - CHECK(report.contains(QStringLiteral("Active Mods: 3"))); - - CHECK(report.contains(QStringLiteral(" 1. Core (ludeon.rimworld)\n" - " 2. Harmony (brrainz.harmony)\n" - " 3. Royalty (ludeon.rimworld.royalty)"))); - - CHECK(report.contains(QStringLiteral("Loaded /home//mods for testuser"))); +TEST_CASE( + "build_report contains every section with active mods in load order") { + TestApplication app; + QTemporaryDir homeDir; + REQUIRE(homeDir.isValid()); + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); + + const QString home = + QDir(homeDir.path()).filePath(QStringLiteral("home/testuser")); + REQUIRE(QDir().mkpath(home)); + + ReportSource source; + source.gameVersion = QStringLiteral("1.5.4243"); + source.paths.game = + QDir(home).filePath(QStringLiteral("Games/RimWorld")).toStdString(); + source.paths.config_folder = + QDir(home).filePath(QStringLiteral(".config/unity3d")).toStdString(); + source.paths.local = + QDir(home).filePath(QStringLiteral("Games/RimWorld/Mods")).toStdString(); + source.allMods = { + {QStringLiteral("u1"), + aboutMod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, + {QStringLiteral("u2"), + aboutMod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, + {QStringLiteral("u3"), + aboutMod(QStringLiteral("Royalty"), + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + source.activeUuids = {QStringLiteral("u2"), QStringLiteral("u1"), + QStringLiteral("u3")}; + + const QString logPath = + QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE( + writeBytes(logPath, "INFO | Loaded /home/testuser/mods for testuser\n")); + + const QString report = + buildReport(source, logPath, home, QStringLiteral("testuser")); + + CHECK(report.contains(QStringLiteral("=== PxModRim System Report ==="))); + CHECK(report.contains(QStringLiteral("=== RimWorld Configuration ==="))); + CHECK(report.contains(QStringLiteral("=== Mod Collection ==="))); + CHECK(report.contains(QStringLiteral("=== Active Mods (Load Order) ==="))); + CHECK(report.contains(QStringLiteral("=== Application Log ==="))); + + CHECK(report.contains(QStringLiteral("App Version: ") + appVersion())); + CHECK(report.contains(QStringLiteral("Qt Version: "))); + CHECK(report.contains(QStringLiteral("OS: "))); + + CHECK(report.contains(QStringLiteral("Game Version: 1.5.4243"))); + const QString slashedReport = QString(report).replace(u'\\', u'/'); + CHECK(slashedReport.contains(QStringLiteral("~/Games/RimWorld"))); + CHECK(slashedReport.contains(QStringLiteral("~/.config/unity3d"))); + CHECK_FALSE(report.contains(home)); + + CHECK(report.contains(QStringLiteral("Total Mods: 3"))); + CHECK(report.contains(QStringLiteral("Active Mods: 3"))); + + CHECK(report.contains( + QStringLiteral(" 1. Core (ludeon.rimworld)\n" + " 2. Harmony (brrainz.harmony)\n" + " 3. Royalty (ludeon.rimworld.royalty)"))); + + CHECK( + report.contains(QStringLiteral("Loaded /home//mods for testuser"))); } TEST_CASE("build_report names active uuids it cannot resolve") { - TestApplication app; - QTemporaryDir logDir; - REQUIRE(logDir.isValid()); - ReportSource source; - source.activeUuids = {QStringLiteral("/mods/gone")}; - - const QString logPath = QDir(logDir.path()).filePath(QStringLiteral("app.log")); - REQUIRE(writeBytes(logPath, "x\n")); - const QString report = buildReport(source, logPath); - CHECK(report.contains(QStringLiteral(" 1. Unknown Mod (/mods/gone)"))); - CHECK(report.contains(QStringLiteral("Total Mods: 0"))); + TestApplication app; + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); + ReportSource source; + source.activeUuids = {QStringLiteral("/mods/gone")}; + + const QString logPath = + QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(writeBytes(logPath, "x\n")); + const QString report = buildReport(source, logPath); + CHECK(report.contains(QStringLiteral(" 1. Unknown Mod (/mods/gone)"))); + CHECK(report.contains(QStringLiteral("Total Mods: 0"))); } TEST_CASE("PasteUploader posts the report and returns the URL") { - TestApplication app; - CannedServer server(httpResponse("201 Created", "https://paste.rs/abc123\n")); - QNetworkAccessManager network; - PasteUploader uploader(network, server.url()); - - const Result result = wait(uploader.upload(QStringLiteral("Report text payload"))); - REQUIRE(result.has_value()); - CHECK(*result == QStringLiteral("https://paste.rs/abc123")); - - const QByteArray request = server.request(); - CHECK(request.contains("User-Agent: PxModRim/")); - CHECK(request.contains("Content-Type: text/plain; charset=utf-8")); - CHECK(request.endsWith("Report text payload")); + TestApplication app; + CannedServer server(httpResponse("201 Created", "https://paste.rs/abc123\n")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + + const Result result = + wait(uploader.upload(QStringLiteral("Report text payload"))); + REQUIRE(result.has_value()); + CHECK(*result == QStringLiteral("https://paste.rs/abc123")); + + const QByteArray request = server.request(); + CHECK(request.contains("User-Agent: PxModRim/")); + CHECK(request.contains("Content-Type: text/plain; charset=utf-8")); + CHECK(request.endsWith("Report text payload")); } TEST_CASE("PasteUploader rejects empty text without sending it") { - TestApplication app; - QNetworkAccessManager network; - PasteUploader uploader(network, QStringLiteral("http://127.0.0.1:9/")); - requireError(wait(uploader.upload(QStringLiteral(" "))), QStringLiteral("Report text is empty")); + TestApplication app; + QNetworkAccessManager network; + PasteUploader uploader(network, QStringLiteral("http://127.0.0.1:9/")); + requireError(wait(uploader.upload(QStringLiteral(" "))), + QStringLiteral("Report text is empty")); } TEST_CASE("PasteUploader reports an HTTP error status") { - TestApplication app; - CannedServer server(httpResponse("403 Forbidden", "nope")); - QNetworkAccessManager network; - PasteUploader uploader(network, server.url()); - requireError(wait(uploader.upload(QStringLiteral("Report content"))), - QStringLiteral("HTTP 403 Forbidden")); + TestApplication app; + CannedServer server(httpResponse("403 Forbidden", "nope")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 403 Forbidden")); } TEST_CASE("PasteUploader reports a server error status") { - TestApplication app; - CannedServer server(httpResponse("500 Internal Server Error", "boom")); - QNetworkAccessManager network; - PasteUploader uploader(network, server.url()); - requireError(wait(uploader.upload(QStringLiteral("Report content"))), - QStringLiteral("HTTP 500 Internal Server Error")); + TestApplication app; + CannedServer server(httpResponse("500 Internal Server Error", "boom")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 500 Internal Server Error")); } TEST_CASE("PasteUploader rejects a success body that is not a URL") { - TestApplication app; - CannedServer server(httpResponse("200 OK", "Something unexpected went wrong")); - QNetworkAccessManager network; - PasteUploader uploader(network, server.url()); - requireError(wait(uploader.upload(QStringLiteral("Report content"))), - QStringLiteral("valid URL")); + TestApplication app; + CannedServer server( + httpResponse("200 OK", "Something unexpected went wrong")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("valid URL")); } TEST_CASE("PasteUploader reports a refused connection as a network error") { - TestApplication app; - // Listen and close to obtain a port that nothing is listening on. - QTcpServer closed; - REQUIRE(closed.listen(QHostAddress::LocalHost)); - const QString url = QStringLiteral("http://127.0.0.1:%1/").arg(closed.serverPort()); - closed.close(); - - QNetworkAccessManager network; - PasteUploader uploader(network, url); - requireError(wait(uploader.upload(QStringLiteral("Report content"))), - QStringLiteral("network error")); + TestApplication app; + // Listen and close to obtain a port that nothing is listening on. + QTcpServer closed; + REQUIRE(closed.listen(QHostAddress::LocalHost)); + const QString url = + QStringLiteral("http://127.0.0.1:%1/").arg(closed.serverPort()); + closed.close(); + + QNetworkAccessManager network; + PasteUploader uploader(network, url); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("network error")); } TEST_CASE("PasteUploader reports a request that gets no answer as a timeout") { - TestApplication app; - CannedServer server{QByteArray{}}; - QNetworkAccessManager network; - PasteUploader uploader(network, server.url(), std::chrono::milliseconds(200)); - requireError(wait(uploader.upload(QStringLiteral("Report content"))), - QStringLiteral("timed out")); + TestApplication app; + CannedServer server{QByteArray{}}; + QNetworkAccessManager network; + PasteUploader uploader(network, server.url(), std::chrono::milliseconds(200)); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("timed out")); } diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index 5574b1a..b4b135d 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -1,14 +1,13 @@ -#include - -#include - -#include #include #include #include #include #include #include +#include + +#include +#include #include "core/config.hpp" #include "core/constants.hpp" @@ -24,227 +23,252 @@ using test_support::writeBytes; // Stands in for ui_prefs.json's struct: these tests exercise ConfigService's // versioning, not the UI settings themselves. -// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or on the wire +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire struct TestPrefs { - bool desc_expanded = false; - LaunchStrategy launch_strategy = LaunchStrategy::kDirect; - std::string skipped_update_tag; + bool desc_expanded = false; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; + std::string skipped_update_tag; - bool operator==(const TestPrefs&) const = default; + bool operator==(const TestPrefs&) const = default; }; // NOLINTEND(readability-identifier-naming) -QString pathIn(const QTemporaryDir& dir, const char* name) { return dir.filePath(QString::fromLatin1(name)); } +QString pathIn(const QTemporaryDir& dir, const char* name) { + return dir.filePath(QString::fromLatin1(name)); +} QStringList backupsOf(const QTemporaryDir& dir, const char* name) { - return QDir(dir.path()).entryList({QString::fromLatin1(name) + QStringLiteral(".bak.*")}, QDir::Files); + return QDir(dir.path()) + .entryList({QString::fromLatin1(name) + QStringLiteral(".bak.*")}, + QDir::Files); } int schemaVersionOf(const QString& path) { - glz::json_t document; - REQUIRE_FALSE(glz::read_json(document, readBytes(path))); - const auto& object = document.get_object(); - const auto marker = object.find(std::string(kSchemaVersionKey)); - REQUIRE(marker != object.end()); - return static_cast(marker->second.get()); + glz::json_t document; + REQUIRE_FALSE(glz::read_json(document, readBytes(path))); + const auto& object = document.get_object(); + const auto marker = object.find(std::string(kSchemaVersionKey)); + REQUIRE(marker != object.end()); + return static_cast(marker->second.get()); } AppConfig legacyExpectedConfig() { - AppConfig cfg; - cfg.paths = PathConfig{ - .game = "/legacy/game", - .local = "/legacy/mods", - .workshop = "/legacy/workshop", - .config_folder = "/legacy/config", - .community_rules_file = "/legacy/rules", - .no_version_warning_file = "/legacy/no-warning", - .use_this_instead_file = "/legacy/use-instead", - }; - return cfg; + AppConfig cfg; + cfg.paths = PathConfig{ + .game = "/legacy/game", + .local = "/legacy/mods", + .workshop = "/legacy/workshop", + .config_folder = "/legacy/config", + .community_rules_file = "/legacy/rules", + .no_version_warning_file = "/legacy/no-warning", + .use_this_instead_file = "/legacy/use-instead", + }; + return cfg; } } // namespace TEST_CASE("read_game_version reads and trims Version.txt") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); - REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); - REQUIRE(writeBytes(pathIn(dir, "Version.txt"), " 1.6.4871 rev598 \n")); - REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), " 1.6.4871 rev598 \n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); } -TEST_CASE("read_game_version returns nullopt when Version.txt is missing or unreadable") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); - - REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); - REQUIRE(QFile(pathIn(dir, "Version.txt")).setPermissions(QFileDevice::Permissions{})); - REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); +TEST_CASE( + "read_game_version returns nullopt when Version.txt is missing or " + "unreadable") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); + + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(QFile(pathIn(dir, "Version.txt")) + .setPermissions(QFileDevice::Permissions{})); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); } TEST_CASE("legacy app config migrates with a backup") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "config.json"); - const std::string legacy = - R"({"paths":{"game":"/legacy/game","local":"/legacy/mods","workshop":"/legacy/workshop",)" - R"("config_folder":"/legacy/config","community_rules_file":"/legacy/rules",)" - R"("no_version_warning_file":"/legacy/no-warning","use_this_instead_file":"/legacy/use-instead",)" - R"("steamcmd_prefix":"/legacy/steamcmd"}})"; - REQUIRE(writeBytes(path, legacy)); - - ConfigService service(dir.path()); - AppConfigService app(service); - REQUIRE(runSync(app.setup()).has_value()); - - REQUIRE(app.cfg() == legacyExpectedConfig()); - REQUIRE(schemaVersionOf(path) == 1); - const auto backups = backupsOf(dir, "config.json"); - REQUIRE(backups.size() == 1); - REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + const std::string legacy = + R"({"paths":{"game":"/legacy/game","local":"/legacy/mods","workshop":"/legacy/workshop",)" + R"("config_folder":"/legacy/config","community_rules_file":"/legacy/rules",)" + R"("no_version_warning_file":"/legacy/no-warning","use_this_instead_file":"/legacy/use-instead",)" + R"("steamcmd_prefix":"/legacy/steamcmd"}})"; + REQUIRE(writeBytes(path, legacy)); + + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(runSync(app.setup()).has_value()); + + REQUIRE(app.cfg() == legacyExpectedConfig()); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "config.json"); + REQUIRE(backups.size() == 1); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); } TEST_CASE("legacy ui prefs migrate with a backup") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "ui_prefs.json"); - const std::string legacy = R"({"validate_downloads":true,"desc_expanded":true,"launch_strategy":1})"; - REQUIRE(writeBytes(path, legacy)); - - ConfigService service(dir.path()); - const auto prefs = service.load("ui_prefs.json"); - REQUIRE(prefs.has_value()); - REQUIRE(*prefs == TestPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::kSteam, .skipped_update_tag = ""}); - REQUIRE(schemaVersionOf(path) == 1); - const auto backups = backupsOf(dir, "ui_prefs.json"); - REQUIRE(backups.size() == 1); - REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "ui_prefs.json"); + const std::string legacy = + R"({"validate_downloads":true,"desc_expanded":true,"launch_strategy":1})"; + REQUIRE(writeBytes(path, legacy)); + + ConfigService service(dir.path()); + const auto prefs = service.load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(*prefs == TestPrefs{.desc_expanded = true, + .launch_strategy = LaunchStrategy::kSteam, + .skipped_update_tag = ""}); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "ui_prefs.json"); + REQUIRE(backups.size() == 1); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); } TEST_CASE("fresh services return defaults without creating files") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - ConfigService service(dir.path()); - AppConfigService app(service); - REQUIRE(runSync(app.setup()).has_value()); - - REQUIRE(app.cfg() == AppConfig{}); - REQUIRE(service.load("ui_prefs.json") == TestPrefs{}); - REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "config.json"))); - REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "ui_prefs.json"))); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(runSync(app.setup()).has_value()); + + REQUIRE(app.cfg() == AppConfig{}); + REQUIRE(service.load("ui_prefs.json") == TestPrefs{}); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "config.json"))); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "ui_prefs.json"))); } TEST_CASE("managed saves stamp schema_version and round-trip values") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - ConfigService service(dir.path()); - AppConfig expectedConfig; - expectedConfig.paths.game = "/game"; - expectedConfig.paths.local = "/mods"; - const TestPrefs expectedPrefs{.desc_expanded = true, .launch_strategy = LaunchStrategy::kSteam, .skipped_update_tag = ""}; - - REQUIRE(service.save("config.json", expectedConfig).has_value()); - REQUIRE(readBytes(pathIn(dir, "config.json")).find("\"schema_version\": 1\n}") != std::string::npos); - REQUIRE(service.save("ui_prefs.json", expectedPrefs).has_value()); - - REQUIRE(schemaVersionOf(pathIn(dir, "config.json")) == 1); - REQUIRE(schemaVersionOf(pathIn(dir, "ui_prefs.json")) == 1); - REQUIRE(service.load("config.json") == expectedConfig); - REQUIRE(service.load("ui_prefs.json") == expectedPrefs); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfig expectedConfig; + expectedConfig.paths.game = "/game"; + expectedConfig.paths.local = "/mods"; + const TestPrefs expectedPrefs{.desc_expanded = true, + .launch_strategy = LaunchStrategy::kSteam, + .skipped_update_tag = ""}; + + REQUIRE(service.save("config.json", expectedConfig).has_value()); + REQUIRE( + readBytes(pathIn(dir, "config.json")).find("\"schema_version\": 1\n}") != + std::string::npos); + REQUIRE(service.save("ui_prefs.json", expectedPrefs).has_value()); + + REQUIRE(schemaVersionOf(pathIn(dir, "config.json")) == 1); + REQUIRE(schemaVersionOf(pathIn(dir, "ui_prefs.json")) == 1); + REQUIRE(service.load("config.json") == expectedConfig); + REQUIRE(service.load("ui_prefs.json") == expectedPrefs); } TEST_CASE("current config load does not rewrite the file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "config.json"); - REQUIRE(writeBytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); - const std::string before = readBytes(path); - - const auto cfg = ConfigService(dir.path()).load("config.json"); - REQUIRE(cfg.has_value()); - REQUIRE(cfg->paths.game == "/current"); - REQUIRE(readBytes(path) == before); - REQUIRE(backupsOf(dir, "config.json").isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE( + writeBytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); + const std::string before = readBytes(path); + + const auto cfg = ConfigService(dir.path()).load("config.json"); + REQUIRE(cfg.has_value()); + REQUIRE(cfg->paths.game == "/current"); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); } TEST_CASE("current ui prefs load does not rewrite the file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "ui_prefs.json"); - REQUIRE(writeBytes(path, R"({"schema_version":1,"desc_expanded":true})")); - const std::string before = readBytes(path); - - const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); - REQUIRE(prefs.has_value()); - REQUIRE(prefs->desc_expanded); - REQUIRE(readBytes(path) == before); - REQUIRE(backupsOf(dir, "ui_prefs.json").isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "ui_prefs.json"); + REQUIRE(writeBytes(path, R"({"schema_version":1,"desc_expanded":true})")); + const std::string before = readBytes(path); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "ui_prefs.json").isEmpty()); } TEST_CASE("corrupt config returns safe defaults") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE(writeBytes(pathIn(dir, "config.json"), "{invalid")); - REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(pathIn(dir, "config.json"), "{invalid")); + REQUIRE(ConfigService(dir.path()).load("config.json") == + AppConfig{}); } TEST_CASE("unreadable config returns safe defaults") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "config.json"); - REQUIRE(writeBytes(path, "{}")); - REQUIRE(QFile(path).setPermissions(QFileDevice::Permissions{})); - REQUIRE(ConfigService(dir.path()).load("config.json") == AppConfig{}); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, "{}")); + REQUIRE(QFile(path).setPermissions(QFileDevice::Permissions{})); + REQUIRE(ConfigService(dir.path()).load("config.json") == + AppConfig{}); } TEST_CASE("missing JSON migration step fails without rewrite") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "config.json"); - REQUIRE(writeBytes(path, R"({"paths":{"game":"/legacy"}})")); - const std::string before = readBytes(path); - - const ConfigService service(dir.path(), JsonMigrations{}); - const auto loaded = service.load("config.json"); - REQUIRE_FALSE(loaded.has_value()); - REQUIRE(loaded.error().message.contains("Missing JSON migration step")); - REQUIRE(readBytes(path) == before); - REQUIRE(backupsOf(dir, "config.json").isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"paths":{"game":"/legacy"}})")); + const std::string before = readBytes(path); + + const ConfigService service(dir.path(), JsonMigrations{}); + const auto loaded = service.load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Missing JSON migration step")); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); } TEST_CASE("unsupported config schema fails without mutation") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = pathIn(dir, "config.json"); - REQUIRE(writeBytes(path, R"({"schema_version":2})")); - const std::string before = readBytes(path); - - const auto loaded = ConfigService(dir.path()).load("config.json"); - REQUIRE_FALSE(loaded.has_value()); - REQUIRE(loaded.error().message.contains("Unsupported JSON schema version")); - REQUIRE(readBytes(path) == before); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"schema_version":2})")); + const std::string before = readBytes(path); + + const auto loaded = ConfigService(dir.path()).load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Unsupported JSON schema version")); + REQUIRE(readBytes(path) == before); } TEST_CASE("ui prefs skipped update tag round-trips") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - ConfigService service(dir.path()); - REQUIRE(service.save("ui_prefs.json", TestPrefs{.desc_expanded = false, .launch_strategy = LaunchStrategy::kDirect, .skipped_update_tag = "v0.2.0"}).has_value()); - REQUIRE(service.load("ui_prefs.json").value().skipped_update_tag == "v0.2.0"); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + REQUIRE(service + .save("ui_prefs.json", + TestPrefs{.desc_expanded = false, + .launch_strategy = LaunchStrategy::kDirect, + .skipped_update_tag = "v0.2.0"}) + .has_value()); + REQUIRE(service.load("ui_prefs.json").value().skipped_update_tag == + "v0.2.0"); } TEST_CASE("ui prefs with removed keys still load") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE(writeBytes(pathIn(dir, "ui_prefs.json"), - R"({"schema_version":1,"deps_expanded":true,"desc_expanded":true,)" - R"("skipped_update_tag":"v0.3.0"})")); - - const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); - REQUIRE(prefs.has_value()); - REQUIRE(prefs->desc_expanded); - REQUIRE(prefs->skipped_update_tag == "v0.3.0"); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes( + pathIn(dir, "ui_prefs.json"), + R"({"schema_version":1,"deps_expanded":true,"desc_expanded":true,)" + R"("skipped_update_tag":"v0.3.0"})")); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(prefs->skipped_update_tag == "v0.3.0"); } diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp index dfc6c50..b713164 100644 --- a/tests/core/test_context.cpp +++ b/tests/core/test_context.cpp @@ -1,15 +1,14 @@ -#include - -#include -#include -#include - #include #include #include #include #include #include +#include +#include +#include + +#include #include "core/config.hpp" #include "core/context.hpp" @@ -27,121 +26,122 @@ using services_test::runAsync; namespace { -std::shared_ptr about(const QString& packageId, bool valid = true) { - auto mod = std::make_shared(); - mod->name = packageId; - mod->packageId = CaseInsensitiveStr(packageId); - mod->providerId = QStringLiteral("local"); - mod->valid = valid; - return mod; +std::shared_ptr about(const QString& packageId, + bool valid = true) { + auto mod = std::make_shared(); + mod->name = packageId; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("local"); + mod->valid = valid; + return mod; } std::unique_ptr makeContext(const QString& gamePath = {}) { - static QTemporaryDir configDir; - static px::modrim::ConfigService configService(configDir.path()); - AppConfig cfg; - cfg.paths.game = gamePath.toStdString(); - return CoreContext::create(cfg, &configService); + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = gamePath.toStdString(); + return CoreContext::create(cfg, &configService); } void writeVersion(const QString& gameDir, const QString& version) { - QFile file(QDir(gameDir).filePath(QStringLiteral("Version.txt"))); - REQUIRE(file.open(QIODevice::WriteOnly)); - file.write(version.toUtf8()); + QFile file(QDir(gameDir).filePath(QStringLiteral("Version.txt"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(version.toUtf8()); } } // namespace TEST_CASE("target version is the major.minor of the game version") { - QTemporaryDir game; - REQUIRE(game.isValid()); - - auto ctx = makeContext(); - CHECK(ctx->gameVersion() == QStringLiteral("Unknown")); - CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); - - writeVersion(game.path(), QStringLiteral("1.5.4297 rev123")); - ctx->updateConfig([&] { - AppConfig cfg = ctx->config(); - cfg.paths.game = game.path().toStdString(); - return cfg; - }()); - CHECK(ctx->gameVersion() == QStringLiteral("1.5.4297 rev123")); - CHECK(ctx->targetVersion() == QStringLiteral("1.5")); + QTemporaryDir game; + REQUIRE(game.isValid()); + + auto ctx = makeContext(); + CHECK(ctx->gameVersion() == QStringLiteral("Unknown")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); + + writeVersion(game.path(), QStringLiteral("1.5.4297 rev123")); + ctx->updateConfig([&] { + AppConfig cfg = ctx->config(); + cfg.paths.game = game.path().toStdString(); + return cfg; + }()); + CHECK(ctx->gameVersion() == QStringLiteral("1.5.4297 rev123")); + CHECK(ctx->targetVersion() == QStringLiteral("1.5")); } TEST_CASE("a game version without a minor part has no target version") { - QTemporaryDir game; - REQUIRE(game.isValid()); - writeVersion(game.path(), QStringLiteral("1")); + QTemporaryDir game; + REQUIRE(game.isValid()); + writeVersion(game.path(), QStringLiteral("1")); - auto ctx = makeContext(game.path()); - CHECK(ctx->gameVersion() == QStringLiteral("1")); - CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); + auto ctx = makeContext(game.path()); + CHECK(ctx->gameVersion() == QStringLiteral("1")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); } TEST_CASE("setActive emits on every change and keeps the loaded list") { - EventLoop loop; - auto ctx = makeContext(); - ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, - {QStringLiteral("b"), about(QStringLiteral("b.mod"))}}, - {QStringLiteral("a")}); + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}}, + {QStringLiteral("a")}); - std::vector events; - const auto id = ctx->activeStateChanged().connect( - [&events](const QStringList& active) { events.push_back(active); }); + std::vector events; + const auto id = ctx->activeStateChanged().connect( + [&events](const QStringList& active) { events.push_back(active); }); - ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); - REQUIRE(events.size() == 1); - CHECK(events.back() == QStringList{"a", "b"}); + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + REQUIRE(events.size() == 1); + CHECK(events.back() == QStringList{"a", "b"}); - ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); - CHECK(events.size() == 1); + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK(events.size() == 1); - ctx->setActive({QStringLiteral("b"), QStringLiteral("a")}); - REQUIRE(events.size() == 2); - CHECK(ctx->activeUuids() == QStringList{"b", "a"}); - CHECK(ctx->loadedActiveUuids() == QStringList{"a"}); + ctx->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(events.size() == 2); + CHECK(ctx->activeUuids() == QStringList{"b", "a"}); + CHECK(ctx->loadedActiveUuids() == QStringList{"a"}); - CHECK(ctx->activeStateChanged().disconnect(id)); + CHECK(ctx->activeStateChanged().disconnect(id)); } TEST_CASE("computeStats counts total, active, inactive and invalid mods") { - EventLoop loop; - auto ctx = makeContext(); - ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, - {QStringLiteral("b"), about(QStringLiteral("b.mod"))}, - {QStringLiteral("c"), about(QStringLiteral("c.mod"), false)}}, - {QStringLiteral("a")}); - - const auto stats = ctx->computeStats(); - CHECK(stats.total == 3); - CHECK(stats.active == 1); - CHECK(stats.inactive == 2); - CHECK(stats.errors == 1); - - const auto overrideStats = ctx->computeStats(QStringList{"a", "b"}); - CHECK(overrideStats.active == 2); - CHECK(overrideStats.inactive == 1); + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}, + {QStringLiteral("c"), about(QStringLiteral("c.mod"), false)}}, + {QStringLiteral("a")}); + + const auto stats = ctx->computeStats(); + CHECK(stats.total == 3); + CHECK(stats.active == 1); + CHECK(stats.inactive == 2); + CHECK(stats.errors == 1); + + const auto overrideStats = ctx->computeStats(QStringList{"a", "b"}); + CHECK(overrideStats.active == 2); + CHECK(overrideStats.inactive == 1); } TEST_CASE("autoSort enables a missing dependency and orders it first") { - EventLoop loop; - auto dependent = about(QStringLiteral("mod.a")); - DependencyMod dependency; - dependency.name = QStringLiteral("Mod B"); - dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); - dependent->aboutRules.dependencies.append(std::move(dependency)); - - auto ctx = makeContext(); - ctx->load({{QStringLiteral("uuid-a"), dependent}, - {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, - {QStringLiteral("uuid-a")}); - - const auto [count, elapsed_ms] = runAsync(ctx->autoSort()); - CHECK(count == 2); - CHECK(elapsed_ms >= 0.0); - REQUIRE(ctx->activeUuids().size() == 2); - CHECK(ctx->activeUuids().indexOf(QStringLiteral("uuid-b")) < - ctx->activeUuids().indexOf(QStringLiteral("uuid-a"))); + EventLoop loop; + auto dependent = about(QStringLiteral("mod.a")); + DependencyMod dependency; + dependency.name = QStringLiteral("Mod B"); + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + dependent->aboutRules.dependencies.append(std::move(dependency)); + + auto ctx = makeContext(); + ctx->load({{QStringLiteral("uuid-a"), dependent}, + {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, + {QStringLiteral("uuid-a")}); + + const auto [count, elapsedMs] = runAsync(ctx->autoSort()); + CHECK(count == 2); + CHECK(elapsedMs >= 0.0); + REQUIRE(ctx->activeUuids().size() == 2); + CHECK(ctx->activeUuids().indexOf(QStringLiteral("uuid-b")) < + ctx->activeUuids().indexOf(QStringLiteral("uuid-a"))); } diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp index 54f622d..fb3d7fa 100644 --- a/tests/core/test_loading.cpp +++ b/tests/core/test_loading.cpp @@ -1,85 +1,89 @@ -#include - #include #include #include #include +#include + #include "core/loading.hpp" namespace { struct Emission { - QString status; - int pct; - int current; - int total; + QString status; + int pct; + int current; + int total; - bool operator==(const Emission&) const = default; + bool operator==(const Emission&) const = default; }; } // namespace -TEST_CASE("loading state reports the innermost frame and pops back to its parent") { - px::modrim::LoadingState state; - QList emissions; - int finished = 0; - QObject::connect(&state, &px::modrim::LoadingState::changed, - [&](const QString& status, int pct, int current, int total) { - emissions.append(Emission{status, pct, current, total}); - }); - QObject::connect(&state, &px::modrim::LoadingState::finished, [&] { ++finished; }); +TEST_CASE( + "loading state reports the innermost frame and pops back to its parent") { + px::modrim::LoadingState state; + QList emissions; + int finished = 0; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + QObject::connect(&state, &px::modrim::LoadingState::finished, + [&] { ++finished; }); + { + auto outer = state.task(QStringLiteral("outer"), 4); + REQUIRE(state.statuses() == QStringList{"outer"}); + state.setProgress(1); { - auto outer = state.task(QStringLiteral("outer"), 4); - REQUIRE(state.statuses() == QStringList{"outer"}); - state.setProgress(1); - { - auto inner = state.task(QStringLiteral("inner")); - REQUIRE(state.statuses() == QStringList{"outer", "inner"}); - state.step(29); - } - REQUIRE(state.statuses() == QStringList{"outer"}); + auto inner = state.task(QStringLiteral("inner")); + REQUIRE(state.statuses() == QStringList{"outer", "inner"}); + state.step(29); } + REQUIRE(state.statuses() == QStringList{"outer"}); + } - REQUIRE(state.statuses().isEmpty()); - REQUIRE(finished == 1); - REQUIRE(emissions.last() == Emission{QStringLiteral("outer"), 25, 1, 4}); - REQUIRE(emissions.contains(Emission{QStringLiteral("inner"), 28, 29, 100})); + REQUIRE(state.statuses().isEmpty()); + REQUIRE(finished == 1); + REQUIRE(emissions.last() == Emission{QStringLiteral("outer"), 25, 1, 4}); + REQUIRE(emissions.contains(Emission{QStringLiteral("inner"), 28, 29, 100})); } TEST_CASE("percent truncates the float quotient, as the legacy progress did") { - px::modrim::LoadingState state; - QList emissions; - QObject::connect(&state, &px::modrim::LoadingState::changed, - [&](const QString& status, int pct, int current, int total) { - emissions.append(Emission{status, pct, current, total}); - }); - state.push(QStringLiteral("p"), 100); - state.setProgress(29); - REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); + px::modrim::LoadingState state; + QList emissions; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("p"), 100); + state.setProgress(29); + REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); } -TEST_CASE("set_total clamps to at least one step and progress stays within bounds") { - px::modrim::LoadingState state; - QList emissions; - QObject::connect(&state, &px::modrim::LoadingState::changed, - [&](const QString& status, int pct, int current, int total) { - emissions.append(Emission{status, pct, current, total}); - }); - state.push(QStringLiteral("t"), 10); - state.setTotal(0); - REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); - state.setProgress(50); - REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 100, 1, 1}); - state.setProgress(-5); - REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); +TEST_CASE( + "set_total clamps to at least one step and progress stays within bounds") { + px::modrim::LoadingState state; + QList emissions; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("t"), 10); + state.setTotal(0); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); + state.setProgress(50); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 100, 1, 1}); + state.setProgress(-5); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); } TEST_CASE("popping an empty stack still reports completion") { - px::modrim::LoadingState state; - int finished = 0; - QObject::connect(&state, &px::modrim::LoadingState::finished, [&] { ++finished; }); - state.pop(); - REQUIRE(finished == 1); + px::modrim::LoadingState state; + int finished = 0; + QObject::connect(&state, &px::modrim::LoadingState::finished, + [&] { ++finished; }); + state.pop(); + REQUIRE(finished == 1); } diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp index 2c688c7..d9415bd 100644 --- a/tests/core/test_migrator.cpp +++ b/tests/core/test_migrator.cpp @@ -1,7 +1,3 @@ -#include - -#include - #include #include #include @@ -10,6 +6,9 @@ #include #include #include +#include + +#include #include "core/migrator.hpp" #include "test_support.hpp" @@ -23,53 +22,53 @@ using test_support::readBytes; // Owns one named SQLite connection and closes it when it goes out of scope. class Database { -public: - Database(const QString& path, QString name) : m_name(std::move(name)) { - m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_name); - m_db.setDatabaseName(path); - REQUIRE(m_db.open()); - } - - ~Database() { - m_db.close(); - m_db = QSqlDatabase(); - QSqlDatabase::removeDatabase(m_name); - } - - Database(const Database&) = delete; - Database& operator=(const Database&) = delete; - - QSqlDatabase& get() { return m_db; } - - void exec(const QString& sql) { - QSqlQuery query(m_db); - REQUIRE(query.exec(sql)); - } - - QStringList column(const QString& sql) { - QSqlQuery query(m_db); - REQUIRE(query.exec(sql)); - QStringList result; - while (query.next()) { - result.append(query.value(0).toString()); - } - return result; - } - - int userVersion() { - QSqlQuery query(m_db); - REQUIRE(query.exec(QStringLiteral("PRAGMA user_version"))); - REQUIRE(query.next()); - return query.value(0).toInt(); + public: + Database(const QString& path, QString name) : m_name(std::move(name)) { + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_name); + m_db.setDatabaseName(path); + REQUIRE(m_db.open()); + } + + ~Database() { + m_db.close(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(m_name); + } + + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + QSqlDatabase& get() { return m_db; } + + void exec(const QString& sql) { + QSqlQuery query(m_db); + REQUIRE(query.exec(sql)); + } + + QStringList column(const QString& sql) { + QSqlQuery query(m_db); + REQUIRE(query.exec(sql)); + QStringList result; + while (query.next()) { + result.append(query.value(0).toString()); } - -private: - QSqlDatabase m_db; - QString m_name; + return result; + } + + int userVersion() { + QSqlQuery query(m_db); + REQUIRE(query.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(query.next()); + return query.value(0).toInt(); + } + + private: + QSqlDatabase m_db; + QString m_name; }; QStringList backupsOf(const QString& dir, const QString& dbName) { - return QDir(dir).entryList({dbName + QStringLiteral(".bak.*")}, QDir::Files); + return QDir(dir).entryList({dbName + QStringLiteral(".bak.*")}, QDir::Files); } Result noop() { return {}; } @@ -77,159 +76,178 @@ Result noop() { return {}; } } // namespace TEST_CASE("fresh database is initialised and stamped without running steps") { - test_support::TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("cache.db")); - bool stepped = false; - const MigrationSteps steps{ - {1, noop}, - {2, [&]() -> Result { - stepped = true; - return {}; - }}, - }; - { - Database db(path, QStringLiteral("fresh")); - REQUIRE(ensureSchema(db.get(), - QStringLiteral("CREATE TABLE state (value TEXT DEFAULT 'a;b'); -- note; ignored\n" - "CREATE TABLE other (x INTEGER);"), - 2, steps, path) - .has_value()); - REQUIRE(db.userVersion() == 2); - REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'")) == - QStringList{"state"}); - REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'other'")) == - QStringList{"other"}); - } - REQUIRE_FALSE(stepped); - REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + bool stepped = false; + const MigrationSteps steps{ + {1, noop}, + {2, + [&]() -> Result { + stepped = true; + return {}; + }}, + }; + { + Database db(path, QStringLiteral("fresh")); + REQUIRE(ensureSchema(db.get(), + QStringLiteral("CREATE TABLE state (value TEXT " + "DEFAULT 'a;b'); -- note; ignored\n" + "CREATE TABLE other (x INTEGER);"), + 2, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 2); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE " + "type = 'table' AND name = 'state'")) == + QStringList{"state"}); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE " + "type = 'table' AND name = 'other'")) == + QStringList{"other"}); + } + REQUIRE_FALSE(stepped); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } TEST_CASE("migrations checkpoint the WAL before backing up") { - test_support::TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("cache.db")); - QStringList events; - { - Database db(path, QStringLiteral("wal")); - REQUIRE(db.column(QStringLiteral("PRAGMA journal_mode = WAL")) == QStringList{"wal"}); - db.exec(QStringLiteral("PRAGMA wal_autocheckpoint = 0")); - db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); - db.exec(QStringLiteral("INSERT INTO state VALUES ('before')")); - db.exec(QStringLiteral("PRAGMA user_version = 1")); - REQUIRE(QFileInfo(path + QStringLiteral("-wal")).size() > 0); - - const MigrationSteps steps{ - {2, [&]() -> Result { - events.append(QStringLiteral("two")); - db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN second TEXT")); - return {}; - }}, - {3, [&]() -> Result { - events.append(QStringLiteral("three")); - db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN third TEXT")); - return {}; - }}, - }; - REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, steps, - path) - .has_value()); - REQUIRE(db.userVersion() == 3); - REQUIRE(db.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == - QStringList{"value", "second", "third"}); - REQUIRE(events == QStringList{"two", "three"}); - } + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + QStringList events; + { + Database db(path, QStringLiteral("wal")); + REQUIRE(db.column(QStringLiteral("PRAGMA journal_mode = WAL")) == + QStringList{"wal"}); + db.exec(QStringLiteral("PRAGMA wal_autocheckpoint = 0")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("INSERT INTO state VALUES ('before')")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + REQUIRE(QFileInfo(path + QStringLiteral("-wal")).size() > 0); - const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); - REQUIRE(found.size() == 1); - const QString backup = dir.filePath(found.first()); - { - Database backupDb(backup, QStringLiteral("wal-backup")); - REQUIRE(backupDb.userVersion() == 1); - REQUIRE(backupDb.column(QStringLiteral("SELECT name FROM pragma_table_info('state')")) == - QStringList{"value"}); - REQUIRE(backupDb.column(QStringLiteral("SELECT value FROM state")) == QStringList{"before"}); - } - for (const char* suffix : {"-wal", "-shm"}) { - QFile::remove(backup + QString::fromLatin1(suffix)); - } + const MigrationSteps steps{ + {2, + [&]() -> Result { + events.append(QStringLiteral("two")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN second TEXT")); + return {}; + }}, + {3, + [&]() -> Result { + events.append(QStringLiteral("three")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN third TEXT")); + return {}; + }}, + }; + REQUIRE(ensureSchema(db.get(), + QStringLiteral( + "CREATE TABLE IF NOT EXISTS state (value TEXT);"), + 3, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 3); + REQUIRE(db.column(QStringLiteral( + "SELECT name FROM pragma_table_info('state')")) == + QStringList{"value", "second", "third"}); + REQUIRE(events == QStringList{"two", "three"}); + } + + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + const QString backup = dir.filePath(found.first()); + { + Database backupDb(backup, QStringLiteral("wal-backup")); + REQUIRE(backupDb.userVersion() == 1); + REQUIRE(backupDb.column(QStringLiteral( + "SELECT name FROM pragma_table_info('state')")) == + QStringList{"value"}); + REQUIRE(backupDb.column(QStringLiteral("SELECT value FROM state")) == + QStringList{"before"}); + } + for (const char* suffix : {"-wal", "-shm"}) { + QFile::remove(backup + QString::fromLatin1(suffix)); + } } TEST_CASE("missing migration step fails without advancing the version") { - test_support::TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("cache.db")); - std::string before; - { - Database db(path, QStringLiteral("missing")); - db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); - db.exec(QStringLiteral("PRAGMA user_version = 1")); - before = readBytes(path); - const auto result = ensureSchema(db.get(), - QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, - MigrationSteps{{2, noop}}, path); - REQUIRE_FALSE(result.has_value()); - REQUIRE(result.error().message.contains("Missing migration step")); - REQUIRE(db.userVersion() == 1); - } - - REQUIRE(readBytes(path) == before); - const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); - REQUIRE(found.size() == 1); - REQUIRE(readBytes(dir.filePath(found.first())) == before); + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + { + Database db(path, QStringLiteral("missing")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = readBytes(path); + const auto result = ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, + MigrationSteps{{2, noop}}, path); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Missing migration step")); + REQUIRE(db.userVersion() == 1); + } + + REQUIRE(readBytes(path) == before); + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + REQUIRE(readBytes(dir.filePath(found.first())) == before); } TEST_CASE("current schema is a no-op") { - test_support::TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("cache.db")); - std::string before; - bool called = false; - { - Database db(path, QStringLiteral("current")); - db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); - db.exec(QStringLiteral("PRAGMA user_version = 1")); - before = readBytes(path); - const MigrationSteps steps{{1, [&]() -> Result { - called = true; - return {}; - }}}; - REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, - steps, path) - .has_value()); - REQUIRE(db.userVersion() == 1); - } - REQUIRE_FALSE(called); - REQUIRE(readBytes(path) == before); - REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("current")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = readBytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE( + ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), + 1, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 1); + } + REQUIRE_FALSE(called); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } TEST_CASE("newer schema is a no-op") { - test_support::TestApplication app; - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("cache.db")); - std::string before; - bool called = false; - { - Database db(path, QStringLiteral("newer")); - db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); - db.exec(QStringLiteral("PRAGMA user_version = 2")); - before = readBytes(path); - const MigrationSteps steps{{1, [&]() -> Result { - called = true; - return {}; - }}}; - REQUIRE(ensureSchema(db.get(), QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), 1, - steps, path) - .has_value()); - REQUIRE(db.userVersion() == 2); - } - REQUIRE_FALSE(called); - REQUIRE(readBytes(path) == before); - REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("newer")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 2")); + before = readBytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE( + ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), + 1, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 2); + } + REQUIRE_FALSE(called); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); } diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp index 62768f4..fa596b4 100644 --- a/tests/core/test_mods_config.cpp +++ b/tests/core/test_mods_config.cpp @@ -1,16 +1,15 @@ -#include - -#include - -#include #include #include #include #include +#include + +#include +#include #include "core/file_io.hpp" -#include "core/mods_config.hpp" #include "core/models/metadata/structures.hpp" +#include "core/mods_config.hpp" #include "test_support.hpp" namespace { @@ -40,62 +39,65 @@ constexpr auto kSampleXml = R"( )"; -CaseInsensitiveStr pid(const char* text) { return CaseInsensitiveStr(QString::fromLatin1(text)); } +CaseInsensitiveStr pid(const char* text) { + return CaseInsensitiveStr(QString::fromLatin1(text)); +} -std::optional parseText(const std::string& content, const QTemporaryDir& dir) { - const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeBytes(path, content)); - return parseModsConfig(path); +std::optional parseText(const std::string& content, + const QTemporaryDir& dir) { + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeBytes(path, content)); + return parseModsConfig(path); } ModsConfig modsConfig(const char* packageId) { - return ModsConfig{ - .version = QStringLiteral("1.5"), - .activeMods = {pid(packageId)}, - .knownExpansions = {}, - }; + return ModsConfig{ + .version = QStringLiteral("1.5"), + .activeMods = {pid(packageId)}, + .knownExpansions = {}, + }; } // Package ids of the snapshots in `dir`, newest first. QList retainedIds(const QString& dir) { - QList ids; - for (const auto& snapshot : getSnapshots(dir)) { - const auto parsed = parseModsConfig(snapshot); - REQUIRE(parsed.has_value()); - ids.append(parsed->activeMods.first()); - } - return ids; + QList ids; + for (const auto& snapshot : getSnapshots(dir)) { + const auto parsed = parseModsConfig(snapshot); + REQUIRE(parsed.has_value()); + ids.append(parsed->activeMods.first()); + } + return ids; } } // namespace TEST_CASE("parse_mods_config reads version, active mods and expansions") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto config = parseText(kSampleXml, dir); - REQUIRE(config.has_value()); - REQUIRE(config->version == QStringLiteral("1.6.4871 rev600")); - REQUIRE(config->activeMods == QList{ - pid("ludeon.rimworld"), - pid("ludeon.rimworld.royalty"), - pid("ludeon.rimworld.ideology"), - pid("ludeon.rimworld.biotech"), - pid("ludeon.rimworld.anomaly"), - pid("ludeon.rimworld.odyssey"), - }); - REQUIRE(config->knownExpansions == QList{ - pid("ludeon.rimworld.royalty"), - pid("ludeon.rimworld.ideology"), - pid("ludeon.rimworld.biotech"), - pid("ludeon.rimworld.anomaly"), - pid("ludeon.rimworld.odyssey"), - }); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(kSampleXml, dir); + REQUIRE(config.has_value()); + REQUIRE(config->version == QStringLiteral("1.6.4871 rev600")); + REQUIRE(config->activeMods == QList{ + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); + REQUIRE(config->knownExpansions == QList{ + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); } TEST_CASE("parse_mods_config accepts a single active mod") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto config = parseText(R"( + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(R"( 1.5 @@ -105,241 +107,286 @@ TEST_CASE("parse_mods_config accepts a single active mod") {
  • ludeon.rimworld.royalty
  • )", - dir); - REQUIRE(config.has_value()); - REQUIRE(config->activeMods == QList{pid("ludeon.rimworld")}); - REQUIRE(config->knownExpansions == QList{pid("ludeon.rimworld.royalty")}); + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods == + QList{pid("ludeon.rimworld")}); + REQUIRE(config->knownExpansions == + QList{pid("ludeon.rimworld.royalty")}); } -TEST_CASE("parse_mods_config falls back to the DLC list when no expansions are declared") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const auto config = parseText(R"( +TEST_CASE( + "parse_mods_config falls back to the DLC list when no expansions are " + "declared") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(R"( 1.5 )", - dir); - REQUIRE(config.has_value()); - REQUIRE(config->activeMods.isEmpty()); - REQUIRE_FALSE(config->knownExpansions.isEmpty()); + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods.isEmpty()); + REQUIRE_FALSE(config->knownExpansions.isEmpty()); } TEST_CASE("parse_mods_config returns nullopt for a missing file") { - REQUIRE_FALSE(parseModsConfig(QStringLiteral("/nonexistent/ModsConfig.xml")).has_value()); + REQUIRE_FALSE(parseModsConfig(QStringLiteral("/nonexistent/ModsConfig.xml")) + .has_value()); } TEST_CASE("write then parse round-trips the mod lists") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const ModsConfig original{ - .version = QStringLiteral("1.6.4871 rev600"), - .activeMods = { - pid("ludeon.rimworld"), - pid("ludeon.rimworld.royalty"), - pid("ludeon.rimworld.ideology"), - pid("ludeon.rimworld.biotech"), - pid("ludeon.rimworld.anomaly"), - }, - .knownExpansions = { - pid("ludeon.rimworld.royalty"), - pid("ludeon.rimworld.ideology"), - pid("ludeon.rimworld.biotech"), - pid("ludeon.rimworld.anomaly"), - }, - }; - const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(path, original, dir.filePath(QStringLiteral("snapshots"))).has_value()); - - const auto loaded = parseModsConfig(path); - REQUIRE(loaded.has_value()); - REQUIRE(loaded->version == original.version); - REQUIRE(loaded->activeMods == original.activeMods); - REQUIRE(loaded->knownExpansions == original.knownExpansions); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const ModsConfig original{ + .version = QStringLiteral("1.6.4871 rev600"), + .activeMods = + { + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + .knownExpansions = + { + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + }; + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE( + writeModsConfig(path, original, dir.filePath(QStringLiteral("snapshots"))) + .has_value()); + + const auto loaded = parseModsConfig(path); + REQUIRE(loaded.has_value()); + REQUIRE(loaded->version == original.version); + REQUIRE(loaded->activeMods == original.activeMods); + REQUIRE(loaded->knownExpansions == original.knownExpansions); } TEST_CASE("write keeps unknown XML in an existing ModsConfigData document") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); - std::string text = kSampleXml; - text.replace(text.find(""), std::string("").size(), - R"(42)"); - REQUIRE(writeBytes(path, text)); - - auto loaded = parseModsConfig(path); - REQUIRE(loaded.has_value()); - loaded->activeMods = {pid("author.updated")}; - REQUIRE(writeModsConfig(path, *loaded, dir.filePath(QStringLiteral("snapshots"))).has_value()); - - pugi::xml_document doc; - REQUIRE(doc.load_file(path.toStdString().c_str())); - const pugi::xml_node root = doc.document_element(); - REQUIRE(std::string(root.attribute("custom").value()) == "preserve"); - REQUIRE(std::string(root.child("otherSetting").child_value("value")) == "42"); - REQUIRE(std::string(root.child("otherSetting").attribute("enabled").value()) == "true"); - REQUIRE(std::string(root.child("activeMods").child_value("li")) == "author.updated"); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + std::string text = kSampleXml; + text.replace( + text.find(""), std::string("").size(), + R"(42)"); + REQUIRE(writeBytes(path, text)); + + auto loaded = parseModsConfig(path); + REQUIRE(loaded.has_value()); + loaded->activeMods = {pid("author.updated")}; + REQUIRE( + writeModsConfig(path, *loaded, dir.filePath(QStringLiteral("snapshots"))) + .has_value()); + + pugi::xml_document doc; + REQUIRE(doc.load_file(path.toStdString().c_str())); + const pugi::xml_node root = doc.document_element(); + REQUIRE(std::string(root.attribute("custom").value()) == "preserve"); + REQUIRE(std::string(root.child("otherSetting").child_value("value")) == "42"); + REQUIRE( + std::string(root.child("otherSetting").attribute("enabled").value()) == + "true"); + REQUIRE(std::string(root.child("activeMods").child_value("li")) == + "author.updated"); } TEST_CASE("replacing a config keeps the previous contents as a snapshot") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - - REQUIRE(writeModsConfig(configPath, modsConfig("mod.alpha"), snapshotsDir).has_value()); - REQUIRE(getSnapshots(snapshotsDir).isEmpty()); - - REQUIRE(writeModsConfig(configPath, modsConfig("mod.beta"), snapshotsDir).has_value()); - - const auto snapshots = getSnapshots(snapshotsDir); - REQUIRE(snapshots.size() == 1); - const auto previous = parseModsConfig(snapshots.first()); - const auto current = parseModsConfig(configPath); - REQUIRE(previous.has_value()); - REQUIRE(previous->activeMods == QList{pid("mod.alpha")}); - REQUIRE(current.has_value()); - REQUIRE(current->activeMods == QList{pid("mod.beta")}); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + + REQUIRE(writeModsConfig(configPath, modsConfig("mod.alpha"), snapshotsDir) + .has_value()); + REQUIRE(getSnapshots(snapshotsDir).isEmpty()); + + REQUIRE(writeModsConfig(configPath, modsConfig("mod.beta"), snapshotsDir) + .has_value()); + + const auto snapshots = getSnapshots(snapshotsDir); + REQUIRE(snapshots.size() == 1); + const auto previous = parseModsConfig(snapshots.first()); + const auto current = parseModsConfig(configPath); + REQUIRE(previous.has_value()); + REQUIRE(previous->activeMods == QList{pid("mod.alpha")}); + REQUIRE(current.has_value()); + REQUIRE(current->activeMods == QList{pid("mod.beta")}); } TEST_CASE("replacement retains only the newest ten snapshots") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir).has_value()); - - for (int index = 1; index < 12; ++index) { - const std::string id = "mod." + std::to_string(index); - REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir).has_value()); - } - - QList expected; - for (int index = 10; index >= 1; --index) { - expected.append(pid(("mod." + std::to_string(index)).c_str())); - } - REQUIRE(getSnapshots(snapshotsDir).size() == 10); - REQUIRE(retainedIds(snapshotsDir) == expected); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir) + .has_value()); + + for (int index = 1; index < 12; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir) + .has_value()); + } + + QList expected; + for (int index = 10; index >= 1; --index) { + expected.append(pid(("mod." + std::to_string(index)).c_str())); + } + REQUIRE(getSnapshots(snapshotsDir).size() == 10); + REQUIRE(retainedIds(snapshotsDir) == expected); } TEST_CASE("configurable retention limit is honored") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, 3).has_value()); - - for (int index = 1; index < 6; ++index) { - const std::string id = "mod." + std::to_string(index); - REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, 3).has_value()); - } - - const auto snapshots = getSnapshots(snapshotsDir); - REQUIRE(snapshots.size() == 3); - REQUIRE(retainedIds(snapshotsDir) == - QList{pid("mod.4"), pid("mod.3"), pid("mod.2")}); - - // Lowering the limit on duplicate content prunes the excess before returning. - REQUIRE(writeBytes(configPath, readBytes(snapshots.first()))); - const auto duplicate = createSnapshot(configPath, snapshotsDir, 2); - REQUIRE(duplicate.has_value()); - REQUIRE(duplicate->has_value()); - REQUIRE(**duplicate == snapshots.first()); - REQUIRE(getSnapshots(snapshotsDir).size() == 2); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, 3) + .has_value()); + + for (int index = 1; index < 6; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, 3) + .has_value()); + } + + const auto snapshots = getSnapshots(snapshotsDir); + REQUIRE(snapshots.size() == 3); + REQUIRE(retainedIds(snapshotsDir) == + QList{pid("mod.4"), pid("mod.3"), pid("mod.2")}); + + // Lowering the limit on duplicate content prunes the excess before returning. + REQUIRE(writeBytes(configPath, readBytes(snapshots.first()))); + const auto duplicate = createSnapshot(configPath, snapshotsDir, 2); + REQUIRE(duplicate.has_value()); + REQUIRE(duplicate->has_value()); + REQUIRE(**duplicate == snapshots.first()); + REQUIRE(getSnapshots(snapshotsDir).size() == 2); } -TEST_CASE("restoring the oldest snapshot at full retention keeps it and backs up the current file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - constexpr int kMaxSnapshots = 3; - REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, kMaxSnapshots).has_value()); - for (int index = 1; index < 4; ++index) { - const std::string id = "mod." + std::to_string(index); - REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, kMaxSnapshots).has_value()); - } - - const QString selected = getSnapshots(snapshotsDir).last(); - const std::string selectedData = readBytes(selected); - - REQUIRE(restoreSnapshot(selected, configPath, snapshotsDir, kMaxSnapshots).has_value()); - - const auto restored = parseModsConfig(configPath); - REQUIRE(restored.has_value()); - REQUIRE(restored->activeMods == QList{pid("mod.0")}); - REQUIRE(QFileInfo::exists(selected)); - REQUIRE(readBytes(selected) == selectedData); - REQUIRE(getSnapshots(snapshotsDir).size() == kMaxSnapshots); - REQUIRE(retainedIds(snapshotsDir) == - QList{pid("mod.3"), pid("mod.2"), pid("mod.0")}); +TEST_CASE( + "restoring the oldest snapshot at full retention keeps it and backs up the " + "current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + constexpr int kMaxSnapshots = 3; + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, + kMaxSnapshots) + .has_value()); + for (int index = 1; index < 4; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, + kMaxSnapshots) + .has_value()); + } + + const QString selected = getSnapshots(snapshotsDir).last(); + const std::string selectedData = readBytes(selected); + + REQUIRE(restoreSnapshot(selected, configPath, snapshotsDir, kMaxSnapshots) + .has_value()); + + const auto restored = parseModsConfig(configPath); + REQUIRE(restored.has_value()); + REQUIRE(restored->activeMods == QList{pid("mod.0")}); + REQUIRE(QFileInfo::exists(selected)); + REQUIRE(readBytes(selected) == selectedData); + REQUIRE(getSnapshots(snapshotsDir).size() == kMaxSnapshots); + REQUIRE(retainedIds(snapshotsDir) == + QList{pid("mod.3"), pid("mod.2"), pid("mod.0")}); } -TEST_CASE("restoring a snapshot writes its exact bytes and backs up the current file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(configPath, modsConfig("mod.current"), snapshotsDir).has_value()); - - const QString snapshotPath = dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); - const std::string snapshotData = - "\n" - "1.5" - "
  • mod.saved
  • \n"; - REQUIRE(QDir().mkpath(dir.filePath(QStringLiteral("snapshots")))); - REQUIRE(writeBytes(snapshotPath, snapshotData)); - - REQUIRE(restoreSnapshot(snapshotPath, configPath, snapshotsDir).has_value()); - REQUIRE(readBytes(configPath) == snapshotData); - - QStringList backups; - for (const auto& snapshot : getSnapshots(snapshotsDir)) { - if (snapshot != snapshotPath) { - backups.append(snapshot); - } +TEST_CASE( + "restoring a snapshot writes its exact bytes and backs up the current " + "file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.current"), snapshotsDir) + .has_value()); + + const QString snapshotPath = + dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); + const std::string snapshotData = + "\n" + "1.5" + "
  • mod.saved
  • \n"; + REQUIRE(QDir().mkpath(dir.filePath(QStringLiteral("snapshots")))); + REQUIRE(writeBytes(snapshotPath, snapshotData)); + + REQUIRE(restoreSnapshot(snapshotPath, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == snapshotData); + + QStringList backups; + for (const auto& snapshot : getSnapshots(snapshotsDir)) { + if (snapshot != snapshotPath) { + backups.append(snapshot); } - REQUIRE(backups.size() == 1); - const auto backup = parseModsConfig(backups.first()); - REQUIRE(backup.has_value()); - REQUIRE(backup->activeMods == QList{pid("mod.current")}); + } + REQUIRE(backups.size() == 1); + const auto backup = parseModsConfig(backups.first()); + REQUIRE(backup.has_value()); + REQUIRE(backup->activeMods == QList{pid("mod.current")}); } TEST_CASE("invalid snapshots never change the current file") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir).has_value()); - const std::string original = readBytes(configPath); - - const QString missing = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing.xml")); - const QString corrupt = dir.filePath(QStringLiteral("snapshots/ModsConfig_corrupt.xml")); - const QString wrongRoot = dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); - const QString missingActive = dir.filePath(QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); - REQUIRE(QDir().mkpath(snapshotsDir)); - REQUIRE(writeBytes(corrupt, "not XML")); - REQUIRE(writeBytes(wrongRoot, "")); - REQUIRE(writeBytes(missingActive, "1.5")); - - for (const QString& snapshot : {missing, corrupt, wrongRoot, missingActive}) { - const QStringList before = getSnapshots(snapshotsDir); - REQUIRE_FALSE(restoreSnapshot(snapshot, configPath, snapshotsDir).has_value()); - REQUIRE(readBytes(configPath) == original); - REQUIRE(getSnapshots(snapshotsDir) == before); - } + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir) + .has_value()); + const std::string original = readBytes(configPath); + + const QString missing = + dir.filePath(QStringLiteral("snapshots/ModsConfig_missing.xml")); + const QString corrupt = + dir.filePath(QStringLiteral("snapshots/ModsConfig_corrupt.xml")); + const QString wrongRoot = + dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); + const QString missingActive = dir.filePath( + QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); + REQUIRE(QDir().mkpath(snapshotsDir)); + REQUIRE(writeBytes(corrupt, "not XML")); + REQUIRE(writeBytes(wrongRoot, "")); + REQUIRE( + writeBytes(missingActive, + "1.5")); + + for (const QString& snapshot : {missing, corrupt, wrongRoot, missingActive}) { + const QStringList before = getSnapshots(snapshotsDir); + REQUIRE_FALSE( + restoreSnapshot(snapshot, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == original); + REQUIRE(getSnapshots(snapshotsDir) == before); + } } -TEST_CASE("a failed replacement reports an error and leaves no temporary files") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); - const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); - REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir).has_value()); - - // A directory at the target makes the final rename fail after the temporary file is written. - const QString blocked = dir.filePath(QStringLiteral("Blocked.xml")); - REQUIRE(QDir().mkpath(blocked)); - REQUIRE_FALSE(writeModsConfig(blocked, modsConfig("mod.new"), snapshotsDir).has_value()); - REQUIRE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden) == QStringList{QStringLiteral("ModsConfig.xml")}); +TEST_CASE( + "a failed replacement reports an error and leaves no temporary files") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir) + .has_value()); + + // A directory at the target makes the final rename fail after the temporary + // file is written. + const QString blocked = dir.filePath(QStringLiteral("Blocked.xml")); + REQUIRE(QDir().mkpath(blocked)); + REQUIRE_FALSE(writeModsConfig(blocked, modsConfig("mod.new"), snapshotsDir) + .has_value()); + REQUIRE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden) == + QStringList{QStringLiteral("ModsConfig.xml")}); } diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp index e557fbe..d980585 100644 --- a/tests/core/test_plugin.cpp +++ b/tests/core/test_plugin.cpp @@ -1,12 +1,12 @@ -#include - +#include +#include +#include #include #include #include -#include -#include -#include + +#include #include "core/plugin.hpp" #include "test_support.hpp" @@ -15,7 +15,7 @@ namespace { struct TestContext { - QStringList log; + QStringList log; }; using Plugin = px::modrim::BasicPlugin; @@ -23,115 +23,122 @@ using PluginRegistry = px::modrim::BasicPluginRegistry; using test_support::runSync; class RecorderPlugin : public Plugin { -public: - RecorderPlugin(QString name, QStringList deps = {}, QStringList* shutdownLog = nullptr) - : Plugin(std::move(name), std::move(deps)), m_shutdownLog(shutdownLog) {} + public: + RecorderPlugin(QString name, QStringList deps = {}, + QStringList* shutdownLog = nullptr) + : Plugin(std::move(name), std::move(deps)), m_shutdownLog(shutdownLog) {} - void setup(TestContext& ctx) override { ctx.log.append(name()); } + void setup(TestContext& ctx) override { ctx.log.append(name()); } - QCoro::Task init(TestContext& ctx) override { - ctx.log.append(name()); - co_return; - } + QCoro::Task init(TestContext& ctx) override { + ctx.log.append(name()); + co_return; + } - QCoro::Task shutdown() override { - if (m_shutdownLog) { - m_shutdownLog->append(name()); - } - co_return; + QCoro::Task shutdown() override { + if (m_shutdownLog) { + m_shutdownLog->append(name()); } + co_return; + } -private: - QStringList* m_shutdownLog; + private: + QStringList* m_shutdownLog; }; } // namespace TEST_CASE("empty registry sets up nothing") { - PluginRegistry reg; - TestContext ctx; - REQUIRE(reg.setupAll(ctx).has_value()); - REQUIRE(ctx.log.isEmpty()); + PluginRegistry reg; + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log.isEmpty()); } TEST_CASE("plugins without dependencies set up in name order") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a")); - reg.registerPlugin(std::make_unique("b")); - reg.registerPlugin(std::make_unique("c")); - TestContext ctx; - REQUIRE(reg.setupAll(ctx).has_value()); - REQUIRE(ctx.log == QStringList{"a", "b", "c"}); + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b")); + reg.registerPlugin(std::make_unique("c")); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("linear dependency chain sets up in order") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a")); - reg.registerPlugin(std::make_unique("b", QStringList{"a"})); - reg.registerPlugin(std::make_unique("c", QStringList{"b"})); - TestContext ctx; - REQUIRE(reg.setupAll(ctx).has_value()); - REQUIRE(ctx.log == QStringList{"a", "b", "c"}); + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("complex DAG sets up level by level, names sorted within a level") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a")); - reg.registerPlugin(std::make_unique("b", QStringList{"a"})); - reg.registerPlugin(std::make_unique("c", QStringList{"a"})); - reg.registerPlugin(std::make_unique("d", QStringList{"b", "c"})); - reg.registerPlugin(std::make_unique("e", QStringList{"d"})); - reg.registerPlugin(std::make_unique("f")); - reg.registerPlugin(std::make_unique("g", QStringList{"e", "f"})); - TestContext ctx; - REQUIRE(reg.setupAll(ctx).has_value()); - REQUIRE(ctx.log == QStringList{"a", "f", "b", "c", "d", "e", "g"}); + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"a"})); + reg.registerPlugin( + std::make_unique("d", QStringList{"b", "c"})); + reg.registerPlugin(std::make_unique("e", QStringList{"d"})); + reg.registerPlugin(std::make_unique("f")); + reg.registerPlugin( + std::make_unique("g", QStringList{"e", "f"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "f", "b", "c", "d", "e", "g"}); } TEST_CASE("missing dependency is reported before anything is set up") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a", QStringList{"nonexistent"})); - TestContext ctx; - const auto result = reg.setupAll(ctx); - REQUIRE_FALSE(result.has_value()); - REQUIRE(result.error().message.contains("depends on 'nonexistent'")); - REQUIRE(ctx.log.isEmpty()); + PluginRegistry reg; + reg.registerPlugin( + std::make_unique("a", QStringList{"nonexistent"})); + TestContext ctx; + const auto result = reg.setupAll(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("depends on 'nonexistent'")); + REQUIRE(ctx.log.isEmpty()); } TEST_CASE("extra dependencies satisfy missing plugins") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a", QStringList{"external_dep"})); - TestContext ctx; - REQUIRE(reg.setupAll(ctx, QSet{"external_dep"}).has_value()); - REQUIRE(ctx.log == QStringList{"a"}); + PluginRegistry reg; + reg.registerPlugin( + std::make_unique("a", QStringList{"external_dep"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx, QSet{"external_dep"}).has_value()); + REQUIRE(ctx.log == QStringList{"a"}); } TEST_CASE("cyclic dependencies are reported as an error") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a", QStringList{"b"})); - reg.registerPlugin(std::make_unique("b", QStringList{"a"})); - TestContext ctx; - const auto result = reg.setupAll(ctx); - REQUIRE_FALSE(result.has_value()); - REQUIRE(result.error().message.contains("Circular dependencies")); - REQUIRE(ctx.log.isEmpty()); + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a", QStringList{"b"})); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + TestContext ctx; + const auto result = reg.setupAll(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Circular dependencies")); + REQUIRE(ctx.log.isEmpty()); } TEST_CASE("init runs in dependency order") { - PluginRegistry reg; - reg.registerPlugin(std::make_unique("a")); - reg.registerPlugin(std::make_unique("b", QStringList{"a"})); - reg.registerPlugin(std::make_unique("c", QStringList{"b"})); - TestContext ctx; - REQUIRE(runSync(reg.initAll(ctx)).has_value()); - REQUIRE(ctx.log == QStringList{"a", "b", "c"}); + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(runSync(reg.initAll(ctx)).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); } TEST_CASE("shutdown runs in reverse dependency order") { - PluginRegistry reg; - QStringList shutdownOrder; - reg.registerPlugin(std::make_unique("dependent", QStringList{"dependency"}, &shutdownOrder)); - reg.registerPlugin(std::make_unique("dependency", QStringList{}, &shutdownOrder)); - REQUIRE(runSync(reg.shutdownAll()).has_value()); - REQUIRE(shutdownOrder == QStringList{"dependent", "dependency"}); + PluginRegistry reg; + QStringList shutdownOrder; + reg.registerPlugin(std::make_unique( + "dependent", QStringList{"dependency"}, &shutdownOrder)); + reg.registerPlugin(std::make_unique( + "dependency", QStringList{}, &shutdownOrder)); + REQUIRE(runSync(reg.shutdownAll()).has_value()); + REQUIRE(shutdownOrder == QStringList{"dependent", "dependency"}); } diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp index 0a375ad..999982b 100644 --- a/tests/core/test_plugin_config.cpp +++ b/tests/core/test_plugin_config.cpp @@ -1,11 +1,10 @@ -#include - -#include - #include #include #include #include +#include + +#include #include "core/config.hpp" #include "core/plugin_config.hpp" @@ -19,48 +18,52 @@ using test_support::writeBytes; // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct DemoSettings { - bool show_badges = false; - int level = 0; + bool show_badges = false; + int level = 0; - bool operator==(const DemoSettings&) const = default; + bool operator==(const DemoSettings&) const = default; }; // NOLINTEND(readability-identifier-naming) } // namespace -TEST_CASE("plugin config seeds from legacy config.json keys and persists changes") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), R"({"old_badges":true,"paths":{}})")); - const QString settingsPath = dir.filePath(QStringLiteral("plugins/demo.json")); +TEST_CASE( + "plugin config seeds from legacy config.json keys and persists changes") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), + R"({"old_badges":true,"paths":{}})")); + const QString settingsPath = + dir.filePath(QStringLiteral("plugins/demo.json")); - ConfigService service(dir.path()); - PluginConfig settings(&service, QStringLiteral("demo"), - QHash{{QStringLiteral("old_badges"), - QStringLiteral("show_badges")}}); - REQUIRE(settings.value().show_badges); - REQUIRE(QFileInfo::exists(settingsPath)); + ConfigService service(dir.path()); + PluginConfig settings( + &service, QStringLiteral("demo"), + QHash{ + {QStringLiteral("old_badges"), QStringLiteral("show_badges")}}); + REQUIRE(settings.value().show_badges); + REQUIRE(QFileInfo::exists(settingsPath)); - int emitted = 0; - DemoSettings last; - settings.changed.connect([&](const DemoSettings& value) { - ++emitted; - last = value; - }); - settings.update(DemoSettings{.show_badges = false, .level = 2}); - REQUIRE(emitted == 1); - REQUIRE(last == DemoSettings{.show_badges = false, .level = 2}); + int emitted = 0; + DemoSettings last; + settings.changed.connect([&](const DemoSettings& value) { + ++emitted; + last = value; + }); + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); + REQUIRE(last == DemoSettings{.show_badges = false, .level = 2}); - settings.update(DemoSettings{.show_badges = false, .level = 2}); - REQUIRE(emitted == 1); + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); - PluginConfig reloaded(&service, QStringLiteral("demo")); - REQUIRE(reloaded.value() == DemoSettings{.show_badges = false, .level = 2}); + PluginConfig reloaded(&service, QStringLiteral("demo")); + REQUIRE(reloaded.value() == DemoSettings{.show_badges = false, .level = 2}); } TEST_CASE("plugin config without a service keeps settings in memory") { - PluginConfig settings(nullptr, QStringLiteral("memory")); - REQUIRE(settings.value() == DemoSettings{}); - settings.update(DemoSettings{.show_badges = true, .level = 0}); - REQUIRE(settings.value().show_badges); + PluginConfig settings(nullptr, QStringLiteral("memory")); + REQUIRE(settings.value() == DemoSettings{}); + settings.update(DemoSettings{.show_badges = true, .level = 0}); + REQUIRE(settings.value().show_badges); } diff --git a/tests/core/test_support.hpp b/tests/core/test_support.hpp index 5052466..5d24739 100644 --- a/tests/core/test_support.hpp +++ b/tests/core/test_support.hpp @@ -1,59 +1,62 @@ #pragma once +#include +#include +#include +#include #include #include #include #include -#include -#include #include -#include -#include -#include + +#include namespace test_support { -// Qt resolves database driver plugins through the application instance, so tests -// that open databases create one for their scope (one at a time, as test_version does). +// Qt resolves database driver plugins through the application instance, so +// tests that open databases create one for their scope (one at a time, as +// test_version does). class TestApplication { -public: - TestApplication() : m_app(m_argc, nullptr) {} + public: + TestApplication() : m_app(m_argc, nullptr) {} -private: - int m_argc = 0; - QCoreApplication m_app; + private: + int m_argc = 0; + QCoreApplication m_app; }; -// Drives a coroutine whose awaited work finishes without suspending, which holds for -// every coroutine these tests start. The REQUIRE fails if it suspended instead. +// Drives a coroutine whose awaited work finishes without suspending, which +// holds for every coroutine these tests start. The REQUIRE fails if it +// suspended instead. template QCoro::Task awaitInto(QCoro::Task task, std::optional& out) { - out.emplace(co_await std::move(task)); + out.emplace(co_await std::move(task)); } template T runSync(QCoro::Task task) { - std::optional out; - [[maybe_unused]] auto pending = awaitInto(std::move(task), out); - REQUIRE(out.has_value()); - return std::move(*out); + std::optional out; + [[maybe_unused]] auto pending = awaitInto(std::move(task), out); + REQUIRE(out.has_value()); + return std::move(*out); } inline bool writeBytes(const QString& path, std::string_view bytes) { - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - return false; - } - const auto size = static_cast(bytes.size()); - return file.write(bytes.data(), size) == size; + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + const auto size = static_cast(bytes.size()); + return file.write(bytes.data(), size) == size; } inline std::string readBytes(const QString& path) { - QFile file(path); - REQUIRE(file.open(QIODevice::ReadOnly)); - const QByteArray data = file.readAll(); - return std::string(data.constData(), static_cast(data.size())); + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly)); + const QByteArray data = file.readAll(); + return std::string(data.constData(), static_cast(data.size())); } } // namespace test_support diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp index b65040c..89786d7 100644 --- a/tests/core/test_utils.cpp +++ b/tests/core/test_utils.cpp @@ -1,63 +1,66 @@ -#include - #include #include #include +#include + #include "core/utils.hpp" namespace { void writeFile(const QString& path, const QByteArray& content) { - QFile file(path); - REQUIRE(file.open(QIODevice::WriteOnly)); - file.write(content); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); } } // namespace TEST_CASE("findAboutXml locates About/About.xml") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QDir mod(root.path()); - REQUIRE(mod.mkpath(QStringLiteral("About"))); - writeFile(mod.filePath(QStringLiteral("About/About.xml")), ""); - - const auto found = px::modrim::findAboutXml(root.path()); - REQUIRE(found.has_value()); - CHECK(found->endsWith(QStringLiteral("About/About.xml"))); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("About"))); + writeFile(mod.filePath(QStringLiteral("About/About.xml")), ""); + + const auto found = px::modrim::findAboutXml(root.path()); + REQUIRE(found.has_value()); + CHECK(found->endsWith(QStringLiteral("About/About.xml"))); } -TEST_CASE("findAboutXml matches the About folder and file name case-insensitively") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QDir mod(root.path()); - REQUIRE(mod.mkpath(QStringLiteral("ABOUT"))); - writeFile(mod.filePath(QStringLiteral("ABOUT/about.XML")), ""); +TEST_CASE( + "findAboutXml matches the About folder and file name case-insensitively") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("ABOUT"))); + writeFile(mod.filePath(QStringLiteral("ABOUT/about.XML")), ""); - const auto found = px::modrim::findAboutXml(root.path()); - REQUIRE(found.has_value()); - CHECK(found->endsWith(QStringLiteral("ABOUT/about.XML"))); + const auto found = px::modrim::findAboutXml(root.path()); + REQUIRE(found.has_value()); + CHECK(found->endsWith(QStringLiteral("ABOUT/about.XML"))); } TEST_CASE("findAboutXml returns nullopt when About has no About.xml") { - QTemporaryDir root; - REQUIRE(root.isValid()); - REQUIRE(QDir(root.path()).mkpath(QStringLiteral("About"))); + QTemporaryDir root; + REQUIRE(root.isValid()); + REQUIRE(QDir(root.path()).mkpath(QStringLiteral("About"))); - CHECK_FALSE(px::modrim::findAboutXml(root.path()).has_value()); + CHECK_FALSE(px::modrim::findAboutXml(root.path()).has_value()); } TEST_CASE("findAboutXml returns nullopt for a missing directory") { - QTemporaryDir root; - REQUIRE(root.isValid()); + QTemporaryDir root; + REQUIRE(root.isValid()); - CHECK_FALSE(px::modrim::findAboutXml(root.filePath(QStringLiteral("nonexistent"))).has_value()); + CHECK_FALSE( + px::modrim::findAboutXml(root.filePath(QStringLiteral("nonexistent"))) + .has_value()); } TEST_CASE("isAsciiDigits accepts only non-empty ASCII digit strings") { - CHECK(px::modrim::isAsciiDigits(QStringLiteral("123"))); - CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(""))); - CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral("12a"))); - CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(" 12"))); + CHECK(px::modrim::isAsciiDigits(QStringLiteral("123"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(""))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral("12a"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(" 12"))); } diff --git a/tests/core/test_version.cpp b/tests/core/test_version.cpp index 1e31b39..bebdebd 100644 --- a/tests/core/test_version.cpp +++ b/tests/core/test_version.cpp @@ -1,21 +1,22 @@ -#include - #include + #include +#include + #include "core/version.hpp" TEST_CASE("coroutine resumes on the Qt event loop") { - int argc = 0; - QCoreApplication app(argc, nullptr); + int argc = 0; + QCoreApplication app(argc, nullptr); - QString result; - auto run = [&]() -> QCoro::Task<> { - result = co_await px::modrim::delayedVersion(5); - app.quit(); - }; - run(); - app.exec(); + QString result; + auto run = [&]() -> QCoro::Task<> { + result = co_await px::modrim::delayedVersion(5); + app.quit(); + }; + run(); + app.exec(); - REQUIRE(result == px::modrim::appVersion()); + REQUIRE(result == px::modrim::appVersion()); } diff --git a/tests/core/test_xml.cpp b/tests/core/test_xml.cpp index d14db1a..df2aa4d 100644 --- a/tests/core/test_xml.cpp +++ b/tests/core/test_xml.cpp @@ -1,9 +1,9 @@ -#include - #include #include #include +#include + #include "core/xml.hpp" using namespace px::modrim; @@ -11,86 +11,91 @@ using namespace px::modrim; namespace { std::string dump(const glz::json_t& value) { - const auto text = value.dump(); - REQUIRE(text.has_value()); - return *text; + const auto text = value.dump(); + REQUIRE(text.has_value()); + return *text; } } // namespace -TEST_CASE("elementToJson maps attributes, repeated children, and trimmed text") { - pugi::xml_document doc; - REQUIRE(doc.load_string( - R"(xy hi )")); +TEST_CASE( + "elementToJson maps attributes, repeated children, and trimmed text") { + pugi::xml_document doc; + REQUIRE(doc.load_string( + R"(xy hi )")); - CHECK(dump(elementToJson(doc.document_element())) == - R"({"root":{"@a":"1","item":["x","y"],"text":"hi"}})"); + CHECK(dump(elementToJson(doc.document_element())) == + R"({"root":{"@a":"1","item":["x","y"],"text":"hi"}})"); } -TEST_CASE("childText returns stripped text and treats blank elements as missing") { - pugi::xml_document doc; - REQUIRE(doc.load_string(" value ")); - const pugi::xml_node root = doc.document_element(); +TEST_CASE( + "childText returns stripped text and treats blank elements as missing") { + pugi::xml_document doc; + REQUIRE(doc.load_string(" value ")); + const pugi::xml_node root = doc.document_element(); - REQUIRE(childText(root, "a").has_value()); - CHECK(*childText(root, "a") == QStringLiteral("value")); - CHECK_FALSE(childText(root, "b").has_value()); - CHECK_FALSE(childText(root, "missing").has_value()); + REQUIRE(childText(root, "a").has_value()); + CHECK(*childText(root, "a") == QStringLiteral("value")); + CHECK_FALSE(childText(root, "b").has_value()); + CHECK_FALSE(childText(root, "missing").has_value()); } TEST_CASE("appendJsonAsXml inverts elementToJson") { - glz::json_t::object_t inner; - glz::json_t::array_t items; - glz::json_t first; - first.data = std::string("a"); - glz::json_t second; - second.data = std::string("b"); - items.push_back(first); - items.push_back(second); - glz::json_t activeMods; - activeMods.data = glz::json_t::object_t{{"li", glz::json_t{}}}; - std::get(activeMods.data)["li"].data = items; - glz::json_t version; - version.data = std::string("1.5"); - inner["version"] = version; - inner["activeMods"] = activeMods; - glz::json_t data; - data.data = glz::json_t::object_t{{"ModsConfigData", glz::json_t{}}}; - std::get(data.data)["ModsConfigData"].data = inner; - - pugi::xml_document doc; - REQUIRE(appendJsonAsXml(doc, data)); - CHECK(dump(elementToJson(doc.document_element())) == dump(data)); + glz::json_t::object_t inner; + glz::json_t::array_t items; + glz::json_t first; + first.data = std::string("a"); + glz::json_t second; + second.data = std::string("b"); + items.push_back(first); + items.push_back(second); + glz::json_t activeMods; + activeMods.data = glz::json_t::object_t{{"li", glz::json_t{}}}; + std::get(activeMods.data)["li"].data = items; + glz::json_t version; + version.data = std::string("1.5"); + inner["version"] = version; + inner["activeMods"] = activeMods; + glz::json_t data; + data.data = glz::json_t::object_t{{"ModsConfigData", glz::json_t{}}}; + std::get(data.data)["ModsConfigData"].data = inner; + + pugi::xml_document doc; + REQUIRE(appendJsonAsXml(doc, data)); + CHECK(dump(elementToJson(doc.document_element())) == dump(data)); } TEST_CASE("appendJsonAsXml rejects dictionaries without exactly one root key") { - glz::json_t data; - data.data = glz::json_t::object_t{}; - pugi::xml_document doc; - const auto result = appendJsonAsXml(doc, data); - REQUIRE_FALSE(result); - CHECK(result.error().message == QStringLiteral("Dictionary must have exactly one root key")); + glz::json_t data; + data.data = glz::json_t::object_t{}; + pugi::xml_document doc; + const auto result = appendJsonAsXml(doc, data); + REQUIRE_FALSE(result); + CHECK(result.error().message == + QStringLiteral("Dictionary must have exactly one root key")); } TEST_CASE("loadXmlFile reports missing files") { - QTemporaryDir root; - REQUIRE(root.isValid()); - pugi::xml_document doc; - const auto result = loadXmlFile(doc, QDir(root.path()).filePath(QStringLiteral("nope.xml"))); - REQUIRE_FALSE(result); - CHECK(result.error().message.startsWith(QStringLiteral("XML file does not exist at"))); + QTemporaryDir root; + REQUIRE(root.isValid()); + pugi::xml_document doc; + const auto result = + loadXmlFile(doc, QDir(root.path()).filePath(QStringLiteral("nope.xml"))); + REQUIRE_FALSE(result); + CHECK(result.error().message.startsWith( + QStringLiteral("XML file does not exist at"))); } TEST_CASE("xmlFileToJson reads a file into the legacy dict shape") { - QTemporaryDir root; - REQUIRE(root.isValid()); - const QString path = QDir(root.path()).filePath(QStringLiteral("ModIds.xml")); - QFile file(path); - REQUIRE(file.open(QIODevice::WriteOnly)); - file.write("
  • one
  • two
  • "); - file.close(); - - const auto data = xmlFileToJson(path); - REQUIRE(data.has_value()); - CHECK(dump(*data) == R"({"ModIdsToFix":{"li":["one","two"]}})"); + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString path = QDir(root.path()).filePath(QStringLiteral("ModIds.xml")); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write("
  • one
  • two
  • "); + file.close(); + + const auto data = xmlFileToJson(path); + REQUIRE(data.has_value()); + CHECK(dump(*data) == R"({"ModIdsToFix":{"li":["one","two"]}})"); } diff --git a/tests/core/workshop/support.hpp b/tests/core/workshop/support.hpp index ebe1ef8..972c847 100644 --- a/tests/core/workshop/support.hpp +++ b/tests/core/workshop/support.hpp @@ -1,5 +1,12 @@ #pragma once +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -8,25 +15,20 @@ #include #include +#include + #include #include -#include -#include -#include -#include -#include -#include -#include -#include #include "core/workshop/types.hpp" namespace workshop_test { -// Awaits a coroutine on a local event loop, so network replies and disk work can complete. +// Awaits a coroutine on a local event loop, so network replies and disk work +// can complete. template T run(QCoro::Task task) { - return QCoro::waitFor(std::move(task)); + return QCoro::waitFor(std::move(task)); } // Payloads exactly as the catalog service sends them. @@ -84,139 +86,164 @@ inline constexpr std::string_view kCollectionJson = R"JSON({ })JSON"; inline px::modrim::workshop::CatalogMod decodeMod(std::string_view json) { - px::modrim::workshop::CatalogMod mod; - REQUIRE_FALSE(glz::read(mod, std::string(json))); - return mod; + px::modrim::workshop::CatalogMod mod; + REQUIRE_FALSE( + glz::read(mod, std::string(json))); + return mod; } -inline px::modrim::workshop::CatalogCollection decodeCollection(std::string_view json) { - px::modrim::workshop::CatalogCollection collection; - REQUIRE_FALSE(glz::read(collection, std::string(json))); - return collection; +inline px::modrim::workshop::CatalogCollection decodeCollection( + std::string_view json) { + px::modrim::workshop::CatalogCollection collection; + REQUIRE_FALSE(glz::read(collection, + std::string(json))); + return collection; } // The wire form of any catalog type, for building responses. template std::string wire(const T& value) { - auto text = glz::write(value); - REQUIRE(text.has_value()); - return *text; + auto text = glz::write(value); + REQUIRE(text.has_value()); + return *text; } struct HttpRequest { - std::string method; - std::string target; - std::string body; - - QUrlQuery query() const { - return QUrlQuery(QUrl(QStringLiteral("http://localhost") + QString::fromStdString(target))); - } - QString path() const { return QUrl(QStringLiteral("http://localhost") + QString::fromStdString(target)).path(); } + std::string method; + std::string target; + std::string body; + + QUrlQuery query() const { + return QUrlQuery(QUrl(QStringLiteral("http://localhost") + + QString::fromStdString(target))); + } + QString path() const { + return QUrl(QStringLiteral("http://localhost") + + QString::fromStdString(target)) + .path(); + } }; struct HttpResponse { - int status = 200; - std::string body; + int status = 200; + std::string body; }; using Handler = std::function; -// A loopback catalog: answers each request with `handler`, one request per connection. +// A loopback catalog: answers each request with `handler`, one request per +// connection. class FakeCatalog { -public: - explicit FakeCatalog(Handler handler) : m_handler(std::move(handler)) { - REQUIRE(m_server.listen(QHostAddress::LocalHost)); - QObject::connect(&m_server, &QTcpServer::newConnection, &m_server, [this] { accept(); }); - } - - QString url() const { return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); } - - std::vector requests; - -private: - void accept() { - QTcpSocket* socket = m_server.nextPendingConnection(); - socket->setParent(&m_server); - QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); - auto raw = std::make_shared(); - auto answered = std::make_shared(false); - QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket, raw, answered] { - *raw += socket->readAll(); - if (*answered || !complete(*raw)) { - return; - } - *answered = true; - HttpRequest request = parse(*raw); - requests.push_back(request); - socket->write(serialize(m_handler(request))); - socket->disconnectFromHost(); - }); + public: + explicit FakeCatalog(Handler handler) : m_handler(std::move(handler)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, &m_server, + [this] { accept(); }); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + + std::vector requests; + + private: + void accept() { + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); + QObject::connect(socket, &QTcpSocket::disconnected, socket, + &QObject::deleteLater); + auto raw = std::make_shared(); + auto answered = std::make_shared(false); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket, raw, answered] { + *raw += socket->readAll(); + if (*answered || !complete(*raw)) { + return; + } + *answered = true; + HttpRequest request = parse(*raw); + requests.push_back(request); + socket->write(serialize(m_handler(request))); + socket->disconnectFromHost(); + }); + } + + // True once the headers and the Content-Length body have arrived. + static bool complete(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { + return false; } - - // True once the headers and the Content-Length body have arrived. - static bool complete(const QByteArray& raw) { - const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); - if (headersEnd < 0) { - return false; - } - return raw.size() - (headersEnd + 4) >= contentLength(raw.left(headersEnd)); - } - - static qint64 contentLength(const QByteArray& headers) { - const QByteArray lower = headers.toLower(); - const qsizetype key = lower.indexOf("content-length:"); - if (key < 0) { - return 0; - } - qsizetype eol = lower.indexOf("\r\n", key); - if (eol < 0) { - eol = lower.size(); - } - return lower.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + return raw.size() - (headersEnd + 4) >= contentLength(raw.left(headersEnd)); + } + + static qint64 contentLength(const QByteArray& headers) { + const QByteArray lower = headers.toLower(); + const qsizetype key = lower.indexOf("content-length:"); + if (key < 0) { + return 0; } - - static HttpRequest parse(const QByteArray& raw) { - const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); - const QByteArray requestLine = raw.left(raw.indexOf("\r\n")); - const QList parts = requestLine.split(' '); - HttpRequest request; - request.method = parts.value(0).toStdString(); - request.target = parts.value(1).toStdString(); - request.body = raw.mid(headersEnd + 4, contentLength(raw.left(headersEnd))).toStdString(); - return request; + qsizetype eol = lower.indexOf("\r\n", key); + if (eol < 0) { + eol = lower.size(); } - - static QByteArray serialize(const HttpResponse& response) { - return "HTTP/1.1 " + QByteArray::number(response.status) + " " + reason(response.status) + - "\r\nContent-Type: application/json\r\nContent-Length: " + - QByteArray::number(static_cast(response.body.size())) + - "\r\nConnection: close\r\n\r\n" + QByteArray::fromStdString(response.body); - } - - static QByteArray reason(int status) { - switch (status) { - case 200: return "OK"; - case 400: return "Bad Request"; - case 401: return "Unauthorized"; - case 404: return "Not Found"; - case 500: return "Internal Server Error"; - case 502: return "Bad Gateway"; - case 503: return "Service Unavailable"; - default: return "Status"; - } + return lower.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + } + + static HttpRequest parse(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + const QByteArray requestLine = raw.left(raw.indexOf("\r\n")); + const QList parts = requestLine.split(' '); + HttpRequest request; + request.method = parts.value(0).toStdString(); + request.target = parts.value(1).toStdString(); + request.body = raw.mid(headersEnd + 4, contentLength(raw.left(headersEnd))) + .toStdString(); + return request; + } + + static QByteArray serialize(const HttpResponse& response) { + return "HTTP/1.1 " + QByteArray::number(response.status) + " " + + reason(response.status) + + "\r\nContent-Type: application/json\r\nContent-Length: " + + QByteArray::number(static_cast(response.body.size())) + + "\r\nConnection: close\r\n\r\n" + + QByteArray::fromStdString(response.body); + } + + static QByteArray reason(int status) { + switch (status) { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 404: + return "Not Found"; + case 500: + return "Internal Server Error"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + default: + return "Status"; } + } - QTcpServer m_server; - Handler m_handler; + QTcpServer m_server; + Handler m_handler; }; // A URL that refuses connections: the port was bound, then released. inline QString unreachableUrl() { - QTcpServer probe; - REQUIRE(probe.listen(QHostAddress::LocalHost)); - const quint16 port = probe.serverPort(); - probe.close(); - return QStringLiteral("http://127.0.0.1:%1").arg(port); + QTcpServer probe; + REQUIRE(probe.listen(QHostAddress::LocalHost)); + const quint16 port = probe.serverPort(); + probe.close(); + return QStringLiteral("http://127.0.0.1:%1").arg(port); } } // namespace workshop_test diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index dcf7796..ded271b 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -1,5 +1,11 @@ -#include - +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -8,15 +14,8 @@ #include #include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include #include "../test_support.hpp" #include "core/config.hpp" @@ -34,439 +33,506 @@ using namespace px::modrim; using namespace px::modrim::workshop; using test_support::TestApplication; using test_support::writeBytes; +using workshop_test::decodeMod; using workshop_test::FakeCatalog; using workshop_test::HttpRequest; using workshop_test::HttpResponse; -using workshop_test::decodeMod; using workshop_test::run; using workshop_test::wire; namespace { // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct BatchWire { - std::vector items; - std::vector unavailable_ids; + std::vector items; + std::vector unavailable_ids; }; // NOLINTEND(readability-identifier-naming) struct IdsBatchPayload { - std::vector ids; + std::vector ids; }; struct FakeDownloader : public Downloader { - FakeDownloader(DownloadManager& manager, std::map synced) - : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), manager), synced(std::move(synced)) {} - - bool isDownloading() const override { return isBusy; } - QSet activeIds() const override { return active; } - bool accepts(const QString&) const override { return true; } - std::optional updatableId(const ListedMod& mod) const override { return mod.publishedFileId(); } - std::optional lastSynced(const ListedMod& mod) const override { - const auto id = mod.publishedFileId(); - if (!id) { - return std::nullopt; - } - const auto found = synced.find(*id); - return found == synced.end() ? std::nullopt : std::optional(found->second); - } - - QCoro::Task> downloadMods(QStringList ids) override { - recordedCalls.push_back(ids); - co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + FakeDownloader(DownloadManager& manager, std::map synced) + : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), + manager), + synced(std::move(synced)) {} + + bool isDownloading() const override { return isBusy; } + QSet activeIds() const override { return active; } + bool accepts(const QString&) const override { return true; } + std::optional updatableId(const ListedMod& mod) const override { + return mod.publishedFileId(); + } + std::optional lastSynced(const ListedMod& mod) const override { + const auto id = mod.publishedFileId(); + if (!id) { + return std::nullopt; } - - void cancel() override {} - - std::map synced; - bool isBusy = false; - QSet active; - std::vector recordedCalls; + const auto found = synced.find(*id); + return found == synced.end() ? std::nullopt + : std::optional(found->second); + } + + QCoro::Task> downloadMods(QStringList ids) override { + recordedCalls.push_back(ids); + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + + void cancel() override {} + + std::map synced; + bool isBusy = false; + QSet active; + std::vector recordedCalls; }; -ListedModPtr makeMod(const QString& path, const QString& provider = QStringLiteral("local")) { - auto mod = std::make_shared(); - REQUIRE(mod->setModPath(path).has_value()); - mod->providerId = provider; - return mod; +ListedModPtr makeMod(const QString& path, + const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(path).has_value()); + mod->providerId = provider; + return mod; } CatalogMod modWith(std::string id, std::optional updated) { - CatalogMod mod = decodeMod(workshop_test::kModJson); - mod.id = std::move(id); - mod.updated_at = updated; - return mod; + CatalogMod mod = decodeMod(workshop_test::kModJson); + mod.id = std::move(id); + mod.updated_at = updated; + return mod; } struct ContextFixture { - QTemporaryDir dir; - std::unique_ptr config; - DownloadManager downloads; - std::unique_ptr downloader; - QList mods; - - ContextFixture() { - REQUIRE(dir.isValid()); - config = std::make_unique(dir.path()); - downloader = std::make_unique(downloads, std::map{ - {QStringLiteral("1"), 200.0}, - {QStringLiteral("2"), 50.0}, - }); - downloads.registerDownloader(*downloader); - - // Mods 1, 2, 4 are local; 3 is workshop; NotWorkshop has no numeric folder name. - mods.append(makeMod(dir.filePath(QStringLiteral("Mods/1")))); - mods.append(makeMod(dir.filePath(QStringLiteral("Mods/2")))); - mods.append(makeMod(dir.filePath(QStringLiteral("Workshop/3")), QStringLiteral("workshop"))); - mods.append(makeMod(dir.filePath(QStringLiteral("Mods/4")))); - mods.append(makeMod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); - } + QTemporaryDir dir; + std::unique_ptr config; + DownloadManager downloads; + std::unique_ptr downloader; + QList mods; + + ContextFixture() { + REQUIRE(dir.isValid()); + config = std::make_unique(dir.path()); + downloader = std::make_unique( + downloads, std::map{ + {QStringLiteral("1"), 200.0}, + {QStringLiteral("2"), 50.0}, + }); + downloads.registerDownloader(*downloader); + + // Mods 1, 2, 4 are local; 3 is workshop; NotWorkshop has no numeric folder + // name. + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/1")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/2")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Workshop/3")), + QStringLiteral("workshop"))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/4")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); + } }; // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolvePayload { - std::vector ids; - std::vector collection_ids; + std::vector ids; + std::vector collection_ids; }; // NOLINTEND(readability-identifier-naming) } // namespace TEST_CASE("workshop install state uses sync timestamp, not directory mtime") { - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - CHECK(catalog.installState(modWith("1", 100)) == InstallState::kInstalled); - CHECK(catalog.installState(modWith("2", 100)) == InstallState::kOutdated); - // 3 has no sync record, so it cannot be outdated. - CHECK(catalog.installState(modWith("3", 100)) == InstallState::kInstalled); - CHECK(catalog.installState(modWith("4", 100)) == InstallState::kInstalled); - // 5 is not installed at all. - CHECK(catalog.installState(modWith("5", 100)) == InstallState::kMissing); - // When updated_at is null or equal to sync, the mod is installed. - CHECK(catalog.installState(modWith("2", std::nullopt)) == InstallState::kInstalled); - CHECK(catalog.installState(modWith("2", 50)) == InstallState::kInstalled); + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + CHECK(catalog.installState(modWith("1", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 100)) == InstallState::kOutdated); + // 3 has no sync record, so it cannot be outdated. + CHECK(catalog.installState(modWith("3", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("4", 100)) == InstallState::kInstalled); + // 5 is not installed at all. + CHECK(catalog.installState(modWith("5", 100)) == InstallState::kMissing); + // When updated_at is null or equal to sync, the mod is installed. + CHECK(catalog.installState(modWith("2", std::nullopt)) == + InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 50)) == InstallState::kInstalled); } TEST_CASE("workshop plan keeps dependency order and incomplete flags") { - TestApplication app; - ContextFixture fixture; - - FakeCatalog server([](const HttpRequest&) -> HttpResponse { - ResolvedDownload answer; - answer.roots = {"5", "picked:starter"}; - for (const char* id : {"1", "2", "3", "4", "5"}) { - CatalogMod m = modWith(id, 100); - m.title = std::string("Title ") + id; - answer.items.emplace(id, std::move(m)); - } - answer.items.emplace("picked:starter", workshop_test::decodeCollection(workshop_test::kCollectionJson)); - answer.mod_ids = {"1", "2", "3", "4", "5"}; - answer.unavailable_ids = {"99"}; - answer.incomplete_collection_ids = {"picked:starter"}; - answer.is_complete = false; - return {200, wire(answer)}; - }); - - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.setBaseUrl(server.url()); - - const auto plan = run(catalog.plan({QStringLiteral("5")}, {QStringLiteral("picked:starter")})); - REQUIRE(plan.has_value()); - CHECK(plan->to_download == QStringList{QStringLiteral("2"), QStringLiteral("5")}); - CHECK(plan->already_current == QStringList{QStringLiteral("1"), QStringLiteral("3"), QStringLiteral("4")}); - CHECK(plan->unavailable_ids == QStringList{QStringLiteral("99")}); - CHECK(plan->incomplete_collection_ids == QStringList{QStringLiteral("picked:starter")}); - CHECK_FALSE(plan->is_complete); - CHECK(plan->titles.value(QStringLiteral("2")) == QStringLiteral("Title 2")); - - const auto payload = glz::read_json(server.requests.back().body); - REQUIRE(payload.has_value()); - CHECK(payload->ids == std::vector{"5"}); - CHECK(payload->collection_ids == std::vector{"picked:starter"}); + TestApplication app; + ContextFixture fixture; + + FakeCatalog server([](const HttpRequest&) -> HttpResponse { + ResolvedDownload answer; + answer.roots = {"5", "picked:starter"}; + for (const char* id : {"1", "2", "3", "4", "5"}) { + CatalogMod m = modWith(id, 100); + m.title = std::string("Title ") + id; + answer.items.emplace(id, std::move(m)); + } + answer.items.emplace("picked:starter", workshop_test::decodeCollection( + workshop_test::kCollectionJson)); + answer.mod_ids = {"1", "2", "3", "4", "5"}; + answer.unavailable_ids = {"99"}; + answer.incomplete_collection_ids = {"picked:starter"}; + answer.is_complete = false; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(server.url()); + + const auto plan = run( + catalog.plan({QStringLiteral("5")}, {QStringLiteral("picked:starter")})); + REQUIRE(plan.has_value()); + CHECK(plan->to_download == + QStringList{QStringLiteral("2"), QStringLiteral("5")}); + CHECK(plan->already_current == QStringList{QStringLiteral("1"), + QStringLiteral("3"), + QStringLiteral("4")}); + CHECK(plan->unavailable_ids == QStringList{QStringLiteral("99")}); + CHECK(plan->incomplete_collection_ids == + QStringList{QStringLiteral("picked:starter")}); + CHECK_FALSE(plan->is_complete); + CHECK(plan->titles.value(QStringLiteral("2")) == QStringLiteral("Title 2")); + + const auto payload = + glz::read_json(server.requests.back().body); + REQUIRE(payload.has_value()); + CHECK(payload->ids == std::vector{"5"}); + CHECK(payload->collection_ids == std::vector{"picked:starter"}); } TEST_CASE("workshop installed metadata uses batch") { - TestApplication app; - ContextFixture fixture; - - std::vector requestedIds; - FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { - const auto body = glz::read_json(request.body); - REQUIRE(body.has_value()); - requestedIds = body->ids; - BatchWire answer{ - .items = {modWith("2", 100)}, - .unavailable_ids = {"1", "3", "4"}, - }; - return {200, wire(answer)}; - }); - - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.setBaseUrl(server.url()); - - const auto installed = run(catalog.installedWithUpdates()); - REQUIRE(installed.has_value()); - CHECK(requestedIds == std::vector{"1", "2", "3", "4"}); - REQUIRE(installed->size() == 1); - CHECK(catalog.installState(installed->front()) == InstallState::kOutdated); + TestApplication app; + ContextFixture fixture; + + std::vector requestedIds; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + requestedIds = body->ids; + BatchWire answer{ + .items = {modWith("2", 100)}, + .unavailable_ids = {"1", "3", "4"}, + }; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(server.url()); + + const auto installed = run(catalog.installedWithUpdates()); + REQUIRE(installed.has_value()); + CHECK(requestedIds == std::vector{"1", "2", "3", "4"}); + REQUIRE(installed->size() == 1); + CHECK(catalog.installState(installed->front()) == InstallState::kOutdated); } TEST_CASE("workshop download delegates without side effects") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - DownloadPlan plan{ - .to_download = {QStringLiteral("2")}, - .already_current = {QStringLiteral("1")}, - .titles = {{QStringLiteral("2"), QStringLiteral("Mod")}}, - .is_complete = true, - }; - const auto result = run(catalog.download(plan)); - REQUIRE(result.has_value()); - CHECK(result->succeeded == QStringList{QStringLiteral("2")}); - REQUIRE(fixture.downloader->recordedCalls.size() == 1); - CHECK(fixture.downloader->recordedCalls[0] == QStringList{QStringLiteral("2")}); + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + DownloadPlan plan{ + .to_download = {QStringLiteral("2")}, + .already_current = {QStringLiteral("1")}, + .titles = {{QStringLiteral("2"), QStringLiteral("Mod")}}, + .is_complete = true, + }; + const auto result = run(catalog.download(plan)); + REQUIRE(result.has_value()); + CHECK(result->succeeded == QStringList{QStringLiteral("2")}); + REQUIRE(fixture.downloader->recordedCalls.size() == 1); + CHECK(fixture.downloader->recordedCalls[0] == + QStringList{QStringLiteral("2")}); } TEST_CASE("workshop empty URL is disabled without creating a client") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - catalog.setBaseUrl(QString()); - CHECK_FALSE(catalog.configured()); - - const auto discover = run(catalog.discover()); - REQUIRE_FALSE(discover.has_value()); - CHECK(discover.error().message.contains(QStringLiteral("disabled"))); - - const auto installed = run(catalog.installedWithUpdates()); - REQUIRE_FALSE(installed.has_value()); - CHECK(installed.error().message.contains(QStringLiteral("disabled"))); - - const auto plan = run(catalog.plan({QStringLiteral("1")}, {})); - REQUIRE_FALSE(plan.has_value()); - CHECK(plan.error().message.contains(QStringLiteral("disabled"))); + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(QString()); + CHECK_FALSE(catalog.configured()); + + const auto discover = run(catalog.discover()); + REQUIRE_FALSE(discover.has_value()); + CHECK(discover.error().message.contains(QStringLiteral("disabled"))); + + const auto installed = run(catalog.installedWithUpdates()); + REQUIRE_FALSE(installed.has_value()); + CHECK(installed.error().message.contains(QStringLiteral("disabled"))); + + const auto plan = run(catalog.plan({QStringLiteral("1")}, {})); + REQUIRE_FALSE(plan.has_value()); + CHECK(plan.error().message.contains(QStringLiteral("disabled"))); } TEST_CASE("workshop configuration and installed events") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - std::vector urlChanges; - int installedInvalidated = 0; - catalog.catalogUrlChanged.connect([&](const QString& url) { urlChanges.push_back(url); }); - catalog.installedChanged.connect([&](std::monostate) { ++installedInvalidated; }); - - catalog.setBaseUrl(QStringLiteral(" https://other.example/ ")); - CHECK(catalog.settings().url == "https://other.example"); - const auto stored = fixture.config->loadExisting(QStringLiteral("plugins/workshop_catalog.json")); - REQUIRE(stored.has_value()); - REQUIRE(stored->has_value()); - CHECK((*stored)->url == "https://other.example"); - - catalog.setBaseUrl(QString()); - CHECK_FALSE(catalog.configured()); - - // Firing download_finished and manual invalidation both emit installed_changed. - catalog.invalidateInstalled(); - fixture.downloads.downloadFinished.notify(DownloadResult{}); - CHECK(installedInvalidated == 2); - CHECK(urlChanges == std::vector{QStringLiteral("https://other.example"), QString()}); + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + std::vector urlChanges; + int installedInvalidated = 0; + catalog.catalogUrlChanged.connect( + [&](const QString& url) { urlChanges.push_back(url); }); + catalog.installedChanged.connect( + [&](std::monostate) { ++installedInvalidated; }); + + catalog.setBaseUrl(QStringLiteral(" https://other.example/ ")); + CHECK(catalog.settings().url == "https://other.example"); + const auto stored = fixture.config->loadExisting( + QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "https://other.example"); + + catalog.setBaseUrl(QString()); + CHECK_FALSE(catalog.configured()); + + // Firing download_finished and manual invalidation both emit + // installed_changed. + catalog.invalidateInstalled(); + fixture.downloads.downloadFinished.notify(DownloadResult{}); + CHECK(installedInvalidated == 2); + CHECK(urlChanges == std::vector{ + QStringLiteral("https://other.example"), QString()}); } TEST_CASE("workshop cached serves remembered value before refreshing") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - std::vector sequence{"first", "second", "second"}; - std::size_t nextIdx = 0; - int calls = 0; - - auto produce = [&]() -> QCoro::Task> { - ++calls; - co_return sequence.at(nextIdx++); - }; - - auto seen = [&](double freshFor) { - std::vector out; - const auto done = run(catalog.cached( - QStringLiteral("k"), produce, [&](const std::string& v) { out.push_back(v); }, freshFor)); - REQUIRE(done.has_value()); - return out; - }; - - CHECK(seen(300.0) == std::vector{"first"}); - CHECK(seen(300.0) == std::vector{"first"}); - CHECK(calls == 1); - CHECK(seen(0.0) == std::vector{"first", "second"}); - CHECK(calls == 2); - CHECK(seen(0.0) == std::vector{"second"}); - CHECK(calls == 3); + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + std::vector sequence{"first", "second", "second"}; + std::size_t nextIdx = 0; + int calls = 0; + + auto produce = [&]() -> QCoro::Task> { + ++calls; + co_return sequence.at(nextIdx++); + }; + + auto seen = [&](double freshFor) { + std::vector out; + const auto done = run(catalog.cached( + QStringLiteral("k"), produce, + [&](const std::string& v) { out.push_back(v); }, freshFor)); + REQUIRE(done.has_value()); + return out; + }; + + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(calls == 1); + CHECK(seen(0.0) == std::vector{"first", "second"}); + CHECK(calls == 2); + CHECK(seen(0.0) == std::vector{"second"}); + CHECK(calls == 3); } TEST_CASE("workshop enqueue runs batches in order after the running download") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - fixture.downloader->isBusy = true; - DownloadPlan first{.to_download = {QStringLiteral("5"), QStringLiteral("6")}}; - DownloadPlan second{.to_download = {QStringLiteral("6"), QStringLiteral("7")}}; - catalog.enqueue(first); - catalog.enqueue(second); - - CHECK(catalog.queuedIds() == QSet{QStringLiteral("5"), QStringLiteral("6"), QStringLiteral("7")}); - CHECK(fixture.downloader->recordedCalls.empty()); - - // Once the downloads report idle, the queue drains both batches in order. - fixture.downloader->isBusy = false; - fixture.downloads.busyChanged.notify(false); - - CHECK(fixture.downloader->recordedCalls.size() == 2); - CHECK(fixture.downloader->recordedCalls[0] == QStringList{QStringLiteral("5"), QStringLiteral("6")}); - CHECK(fixture.downloader->recordedCalls[1] == QStringList{QStringLiteral("7")}); - CHECK(catalog.queuedIds().isEmpty()); + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + fixture.downloader->isBusy = true; + DownloadPlan first{.to_download = {QStringLiteral("5"), QStringLiteral("6")}}; + DownloadPlan second{ + .to_download = {QStringLiteral("6"), QStringLiteral("7")}}; + catalog.enqueue(first); + catalog.enqueue(second); + + CHECK(catalog.queuedIds() == QSet{QStringLiteral("5"), + QStringLiteral("6"), + QStringLiteral("7")}); + CHECK(fixture.downloader->recordedCalls.empty()); + + // Once the downloads report idle, the queue drains both batches in order. + fixture.downloader->isBusy = false; + fixture.downloads.busyChanged.notify(false); + + CHECK(fixture.downloader->recordedCalls.size() == 2); + CHECK(fixture.downloader->recordedCalls[0] == + QStringList{QStringLiteral("5"), QStringLiteral("6")}); + CHECK(fixture.downloader->recordedCalls[1] == + QStringList{QStringLiteral("7")}); + CHECK(catalog.queuedIds().isEmpty()); } -TEST_CASE("workshop cached values survive a restart and corrupt files are ignored") { - TestApplication app; - ContextFixture fixture; - - auto produce = []() -> QCoro::Task>> { - co_return std::vector{1, 2}; - }; - auto mustNotRun = []() -> QCoro::Task>> { - FAIL("A fresh value on disk must not hit the catalog"); - co_return std::vector{}; - }; - - // First run persists to disk. - { - WorkshopCatalog first(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - std::vector out; - const auto done = run(first.cached>( - QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); - REQUIRE(done.has_value()); - CHECK(out == std::vector{1, 2}); - } - - // Second run loads from disk without invoking produce. - { - WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - std::vector out; - const auto done = run(second.cached>( - QStringLiteral("k"), mustNotRun, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); - REQUIRE(done.has_value()); - CHECK(out == std::vector{1, 2}); - } - - // Corrupting the file causes a miss: produce is invoked again. - const QString cacheDir = fixture.dir.filePath(QStringLiteral("workshop-cache")); - for (const QFileInfo& file : QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { - REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); - } - { - WorkshopCatalog third(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - std::vector out; - const auto done = run(third.cached>( - QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, /*persist=*/true)); - REQUIRE(done.has_value()); - CHECK(out == std::vector{1, 2}); - } +TEST_CASE( + "workshop cached values survive a restart and corrupt files are ignored") { + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{1, 2}; + }; + auto mustNotRun = []() -> QCoro::Task>> { + FAIL("A fresh value on disk must not hit the catalog"); + co_return std::vector{}; + }; + + // First run persists to disk. + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(first.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Second run loads from disk without invoking produce. + { + WorkshopCatalog second(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(second.cached>( + QStringLiteral("k"), mustNotRun, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Corrupting the file causes a miss: produce is invoked again. + const QString cacheDir = + fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : + QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); + } + { + WorkshopCatalog third(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(third.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } } -TEST_CASE("workshop equal cache refresh keeps the displayed mods and refreshes age") { - TestApplication app; - ContextFixture fixture; - WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - - int calls = 0; - auto produce = [&]() -> QCoro::Task>> { - ++calls; - co_return std::vector{modWith("1", 100)}; - }; - - std::vector> initial; - REQUIRE(run(catalog.cached>( - QStringLiteral("installed"), produce, [&](const auto& v) { initial.push_back(v); }, 300.0)) - .has_value()); - CHECK(initial.size() == 1); - CHECK(calls == 1); - - // Refresh with fresh_for = 0: produce runs, but because the value is equal, show is called only once. - std::vector> refreshed; - REQUIRE(run(catalog.cached>( - QStringLiteral("installed"), produce, [&](const auto& v) { refreshed.push_back(v); }, 0.0)) - .has_value()); - CHECK(refreshed.size() == 1); - CHECK(refreshed[0] == initial[0]); - CHECK(calls == 2); - - // Immediate repeat: the refreshed timestamp is fresh, so produce does not run. - std::vector> current; - REQUIRE(run(catalog.cached>( - QStringLiteral("installed"), produce, [&](const auto& v) { current.push_back(v); }, 300.0)) - .has_value()); - CHECK(current.size() == 1); - CHECK(current[0] == initial[0]); - CHECK(calls == 2); +TEST_CASE( + "workshop equal cache refresh keeps the displayed mods and refreshes age") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + int calls = 0; + auto produce = [&]() -> QCoro::Task>> { + ++calls; + co_return std::vector{modWith("1", 100)}; + }; + + std::vector> initial; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { initial.push_back(v); }, 300.0)) + .has_value()); + CHECK(initial.size() == 1); + CHECK(calls == 1); + + // Refresh with fresh_for = 0: produce runs, but because the value is equal, + // show is called only once. + std::vector> refreshed; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { refreshed.push_back(v); }, 0.0)) + .has_value()); + CHECK(refreshed.size() == 1); + CHECK(refreshed[0] == initial[0]); + CHECK(calls == 2); + + // Immediate repeat: the refreshed timestamp is fresh, so produce does not + // run. + std::vector> current; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { current.push_back(v); }, 300.0)) + .has_value()); + CHECK(current.size() == 1); + CHECK(current[0] == initial[0]); + CHECK(calls == 2); } TEST_CASE("workshop disk cache is reused in memory after first load") { - TestApplication app; - ContextFixture fixture; - - auto produce = []() -> QCoro::Task>> { - co_return std::vector{modWith("1", 100)}; - }; - auto mustNotRun = []() -> QCoro::Task>> { - FAIL("A fresh cached response must not hit the catalog"); - co_return std::vector{}; - }; - - { - WorkshopCatalog first(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - REQUIRE(run(first.cached>(QStringLiteral("installed"), produce, [](const auto&) {}, 300.0, - /*persist=*/true)) - .has_value()); - } - - WorkshopCatalog second(fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); - std::vector loaded; - REQUIRE(run(second.cached>(QStringLiteral("installed"), mustNotRun, - [&](const auto& v) { loaded = v; }, 300.0, /*persist=*/true)) - .has_value()); - REQUIRE(loaded.size() == 1); - - // Corrupt the disk file now: second catalog must still serve its in-memory copy. - const QString cacheDir = fixture.dir.filePath(QStringLiteral("workshop-cache")); - for (const QFileInfo& file : QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { - REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); - } - - std::vector fromMemory; - REQUIRE(run(second.cached>(QStringLiteral("installed"), mustNotRun, - [&](const auto& v) { fromMemory = v; }, 300.0, /*persist=*/true)) - .has_value()); - CHECK(fromMemory == loaded); + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{modWith("1", 100)}; + }; + auto mustNotRun = + []() -> QCoro::Task>> { + FAIL("A fresh cached response must not hit the catalog"); + co_return std::vector{}; + }; + + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + REQUIRE( + run(first.cached>( + QStringLiteral("installed"), produce, [](const auto&) {}, 300.0, + /*persist=*/true)) + .has_value()); + } + + WorkshopCatalog second(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector loaded; + REQUIRE(run(second.cached>( + QStringLiteral("installed"), mustNotRun, + [&](const auto& v) { loaded = v; }, 300.0, /*persist=*/true)) + .has_value()); + REQUIRE(loaded.size() == 1); + + // Corrupt the disk file now: second catalog must still serve its in-memory + // copy. + const QString cacheDir = + fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : + QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); + } + + std::vector fromMemory; + REQUIRE( + run(second.cached>( + QStringLiteral("installed"), mustNotRun, + [&](const auto& v) { fromMemory = v; }, 300.0, /*persist=*/true)) + .has_value()); + CHECK(fromMemory == loaded); } TEST_CASE("workshop catalog URL moves out of the old shared config") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), - R"({"schema_version":1,"workshop_catalog_url":"http://localhost:8787"})")); - - ConfigService service(dir.path()); - DownloadManager downloads; - WorkshopCatalog catalog(&service, downloads, [] { return QList{}; }); - CHECK(catalog.settings().url == "http://localhost:8787"); - - const auto stored = service.loadExisting(QStringLiteral("plugins/workshop_catalog.json")); - REQUIRE(stored.has_value()); - REQUIRE(stored->has_value()); - CHECK((*stored)->url == "http://localhost:8787"); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes( + dir.filePath(QStringLiteral("config.json")), + R"({"schema_version":1,"workshop_catalog_url":"http://localhost:8787"})")); + + ConfigService service(dir.path()); + DownloadManager downloads; + WorkshopCatalog catalog(&service, downloads, + [] { return QList{}; }); + CHECK(catalog.settings().url == "http://localhost:8787"); + + const auto stored = service.loadExisting( + QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "http://localhost:8787"); } diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp index b421431..66b9a1e 100644 --- a/tests/core/workshop/test_client.cpp +++ b/tests/core/workshop/test_client.cpp @@ -1,12 +1,11 @@ -#include - +#include +#include #include #include #include +#include #include -#include -#include #include "../test_support.hpp" #include "core/workshop/client.hpp" @@ -15,11 +14,11 @@ using namespace px::modrim::workshop; using test_support::TestApplication; +using workshop_test::decodeCollection; +using workshop_test::decodeMod; using workshop_test::FakeCatalog; using workshop_test::HttpRequest; using workshop_test::HttpResponse; -using workshop_test::decodeCollection; -using workshop_test::decodeMod; using workshop_test::run; using workshop_test::unreachableUrl; using workshop_test::wire; @@ -28,403 +27,439 @@ namespace { // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolveRequest { - std::vector ids; - std::vector collection_ids; + std::vector ids; + std::vector collection_ids; }; // NOLINTEND(readability-identifier-naming) struct IdsRequest { - std::vector ids; + std::vector ids; }; // The `{"items":[...],"total":N,"next_cursor":...}` page shape. -std::string page(const std::string& items, int total, const std::string& cursorJson) { - return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + R"(,"next_cursor":)" + cursorJson + "}"; +std::string page(const std::string& items, int total, + const std::string& cursorJson) { + return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + + R"(,"next_cursor":)" + cursorJson + "}"; } std::string errorBody(const std::string& message) { - return R"({"error":{"message":")" + message + R"("}})"; + return R"({"error":{"message":")" + message + R"("}})"; } QString queryValue(const HttpRequest& request, const char* key) { - return request.query().queryItemValue(QString::fromLatin1(key)); + return request.query().queryItemValue(QString::fromLatin1(key)); } CatalogMod withId(std::string id) { - CatalogMod mod = decodeMod(workshop_test::kModJson); - mod.id = std::move(id); - return mod; + CatalogMod mod = decodeMod(workshop_test::kModJson); + mod.id = std::move(id); + return mod; } -CatalogCollection steamCollection(std::string id, std::string steamId, std::vector members) { - CatalogCollection collection = decodeCollection(workshop_test::kCollectionJson); - collection.id = std::move(id); - collection.source = CollectionSource::kSteam; - collection.steam_id = std::move(steamId); - collection.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + *collection.steam_id; - collection.member_count = static_cast(members.size()); - collection.member_ids = std::move(members); - return collection; +CatalogCollection steamCollection(std::string id, std::string steamId, + std::vector members) { + CatalogCollection collection = + decodeCollection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.source = CollectionSource::kSteam; + collection.steam_id = std::move(steamId); + collection.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=" + + *collection.steam_id; + collection.member_count = static_cast(members.size()); + collection.member_ids = std::move(members); + return collection; } } // namespace TEST_CASE("workshop client decodes every endpoint and sends the query") { - TestApplication app; - const std::string mod = std::string(workshop_test::kModJson); - const std::string collection = std::string(workshop_test::kCollectionJson); - const std::string nested = wire(steamCollection("steam:456", "456", {"2009463077"})); - const std::string steam = wire(steamCollection("steam:123", "123", {"2009463077", "steam:456", "99"})); - - const std::string detailPicked = R"({"collection":)" + collection + R"(,"members":[)" + mod + - R"(],"unavailable_ids":["99"],"is_complete":false})"; - const std::string detailSteam = R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + - R"(],"unavailable_ids":["99"],"is_complete":false})"; - const std::string resolution = R"({"roots":["picked:starter"],"items":{"2009463077":)" + mod + - R"(,"picked:starter":)" + collection + - R"(},"mod_ids":["2009463077"],"unavailable_ids":["99"],)" - R"("incomplete_collection_ids":["picked:starter"],"is_complete":false})"; - - FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { - const std::string path = request.path().toStdString(); - if (path == "/catalog/discover") { - return {200, R"({"mods":)" + page(mod, 1, R"("next-page")") + R"(,"collections":)" + - page(collection, 1, "null") + "}"}; - } - if (path == "/catalog/mods") { - return {200, page(mod, 1, R"("next-page")")}; - } - if (path == "/catalog/collections") { - return {200, page(collection, 1, "null")}; - } - if (path == "/catalog/mods/2009463077") { - return {200, mod}; - } - if (path == "/catalog/collections/picked/starter") { - return {200, detailPicked}; - } - if (path == "/catalog/collections/steam/123") { - return {200, detailSteam}; - } - if (path == "/catalog/resolve") { - return {200, resolution}; - } - return {404, errorBody("unknown")}; - }); - - CatalogClient client(server.url()); - const auto discover = run(client.discover()); - REQUIRE(discover.has_value()); - CHECK(discover->mods.items[0].file_size == std::string("18446744073709551615")); - CHECK(discover->mods.items[0].votes->positive_percent == 90.0); - CHECK(discover->mods.items[0].previews[1].type == PreviewType::kVideo); - CHECK(discover->collections.items[0].description_format == CollectionDescriptionFormat::kText); - - CatalogQuery query; - query.query = QStringLiteral("quality"); - query.tag = QStringLiteral("Libraries"); - query.version = QStringLiteral("1.6"); - query.sort = QStringLiteral("updated"); - query.limit = 50; - query.cursor = QStringLiteral("next"); - query.source = QStringLiteral("picked"); - const auto mods = run(client.mods(query)); - REQUIRE(mods.has_value()); - CHECK(mods->next_cursor == std::optional("next-page")); - const HttpRequest& sent = server.requests.back(); - CHECK(queryValue(sent, "q") == QStringLiteral("quality")); - CHECK(queryValue(sent, "tag") == QStringLiteral("Libraries")); - CHECK(queryValue(sent, "version") == QStringLiteral("1.6")); - CHECK(queryValue(sent, "sort") == QStringLiteral("updated")); - CHECK(queryValue(sent, "limit") == QStringLiteral("50")); - CHECK(queryValue(sent, "cursor") == QStringLiteral("next")); - CHECK(queryValue(sent, "source") == QStringLiteral("picked")); - CHECK(sent.query().queryItems().size() == 7); - - const auto collections = run(client.collections(query)); - REQUIRE(collections.has_value()); - CHECK(collections->items[0].id == "picked:starter"); - - const auto single = run(client.mod("2009463077")); - REQUIRE(single.has_value()); - REQUIRE(single->has_value()); - CHECK((*single)->description == decodeMod(mod).description); - CHECK((*single)->kind == ModKind::kMod); - - for (const char* id : {"picked:starter", "steam:123"}) { - const auto detail = run(client.collection(id)); - REQUIRE(detail.has_value()); - REQUIRE(detail->has_value()); - const CollectionDetail& value = **detail; - const bool isSteam = std::string(id) == "steam:123"; - REQUIRE(value.members.size() == (isSteam ? 2 : 1)); - CHECK(std::holds_alternative(value.members[0])); - CHECK(value.unavailable_ids == std::vector{"99"}); - CHECK_FALSE(value.is_complete); - if (isSteam) { - CHECK(std::holds_alternative(value.members[1])); - } + TestApplication app; + const std::string mod = std::string(workshop_test::kModJson); + const std::string collection = std::string(workshop_test::kCollectionJson); + const std::string nested = + wire(steamCollection("steam:456", "456", {"2009463077"})); + const std::string steam = wire( + steamCollection("steam:123", "123", {"2009463077", "steam:456", "99"})); + + const std::string detailPicked = + R"({"collection":)" + collection + R"(,"members":[)" + mod + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string detailSteam = + R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string resolution = + R"({"roots":["picked:starter"],"items":{"2009463077":)" + mod + + R"(,"picked:starter":)" + collection + + R"(},"mod_ids":["2009463077"],"unavailable_ids":["99"],)" + R"("incomplete_collection_ids":["picked:starter"],"is_complete":false})"; + + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const std::string path = request.path().toStdString(); + if (path == "/catalog/discover") { + return {200, R"({"mods":)" + page(mod, 1, R"("next-page")") + + R"(,"collections":)" + page(collection, 1, "null") + + "}"}; } - - const auto resolved = run(client.resolve({}, {"picked:starter"})); - REQUIRE(resolved.has_value()); - CHECK(resolved->roots == std::vector{"picked:starter"}); - CHECK(resolved->mod_ids == std::vector{"2009463077"}); - CHECK(std::holds_alternative(resolved->items.at("picked:starter"))); - CHECK(resolved->incomplete_collection_ids == std::vector{"picked:starter"}); - CHECK_FALSE(resolved->is_complete); - const auto body = glz::read_json(server.requests.back().body); - REQUIRE(body.has_value()); - CHECK(body->ids.empty()); - CHECK(body->collection_ids == std::vector{"picked:starter"}); + if (path == "/catalog/mods") { + return {200, page(mod, 1, R"("next-page")")}; + } + if (path == "/catalog/collections") { + return {200, page(collection, 1, "null")}; + } + if (path == "/catalog/mods/2009463077") { + return {200, mod}; + } + if (path == "/catalog/collections/picked/starter") { + return {200, detailPicked}; + } + if (path == "/catalog/collections/steam/123") { + return {200, detailSteam}; + } + if (path == "/catalog/resolve") { + return {200, resolution}; + } + return {404, errorBody("unknown")}; + }); + + CatalogClient client(server.url()); + const auto discover = run(client.discover()); + REQUIRE(discover.has_value()); + CHECK(discover->mods.items[0].file_size == + std::string("18446744073709551615")); + CHECK(discover->mods.items[0].votes->positive_percent == 90.0); + CHECK(discover->mods.items[0].previews[1].type == PreviewType::kVideo); + CHECK(discover->collections.items[0].description_format == + CollectionDescriptionFormat::kText); + + CatalogQuery query; + query.query = QStringLiteral("quality"); + query.tag = QStringLiteral("Libraries"); + query.version = QStringLiteral("1.6"); + query.sort = QStringLiteral("updated"); + query.limit = 50; + query.cursor = QStringLiteral("next"); + query.source = QStringLiteral("picked"); + const auto mods = run(client.mods(query)); + REQUIRE(mods.has_value()); + CHECK(mods->next_cursor == std::optional("next-page")); + const HttpRequest& sent = server.requests.back(); + CHECK(queryValue(sent, "q") == QStringLiteral("quality")); + CHECK(queryValue(sent, "tag") == QStringLiteral("Libraries")); + CHECK(queryValue(sent, "version") == QStringLiteral("1.6")); + CHECK(queryValue(sent, "sort") == QStringLiteral("updated")); + CHECK(queryValue(sent, "limit") == QStringLiteral("50")); + CHECK(queryValue(sent, "cursor") == QStringLiteral("next")); + CHECK(queryValue(sent, "source") == QStringLiteral("picked")); + CHECK(sent.query().queryItems().size() == 7); + + const auto collections = run(client.collections(query)); + REQUIRE(collections.has_value()); + CHECK(collections->items[0].id == "picked:starter"); + + const auto single = run(client.mod("2009463077")); + REQUIRE(single.has_value()); + REQUIRE(single->has_value()); + CHECK((*single)->description == decodeMod(mod).description); + CHECK((*single)->kind == ModKind::kMod); + + for (const char* id : {"picked:starter", "steam:123"}) { + const auto detail = run(client.collection(id)); + REQUIRE(detail.has_value()); + REQUIRE(detail->has_value()); + const CollectionDetail& value = **detail; + const bool isSteam = std::string(id) == "steam:123"; + REQUIRE(value.members.size() == (isSteam ? 2 : 1)); + CHECK(std::holds_alternative(value.members[0])); + CHECK(value.unavailable_ids == std::vector{"99"}); + CHECK_FALSE(value.is_complete); + if (isSteam) { + CHECK(std::holds_alternative(value.members[1])); + } + } + + const auto resolved = run(client.resolve({}, {"picked:starter"})); + REQUIRE(resolved.has_value()); + CHECK(resolved->roots == std::vector{"picked:starter"}); + CHECK(resolved->mod_ids == std::vector{"2009463077"}); + CHECK(std::holds_alternative( + resolved->items.at("picked:starter"))); + CHECK(resolved->incomplete_collection_ids == + std::vector{"picked:starter"}); + CHECK_FALSE(resolved->is_complete); + const auto body = glz::read_json(server.requests.back().body); + REQUIRE(body.has_value()); + CHECK(body->ids.empty()); + CHECK(body->collection_ids == std::vector{"picked:starter"}); } -TEST_CASE("workshop client reports error statuses as errors, never as empty results") { - TestApplication app; - for (int status : {400, 401, 500, 502, 503}) { - for (bool jsonBody : {true, false}) { - FakeCatalog server([&](const HttpRequest&) -> HttpResponse { - return jsonBody ? HttpResponse{status, errorBody("Catalog unavailable")} - : HttpResponse{status, "Upstream failure"}; - }); - CatalogClient client(server.url()); - const auto result = run(client.mod("123")); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().status == status); - if (jsonBody) { - CHECK(result.error().message == QStringLiteral("Catalog unavailable")); - } else { - CHECK(result.error().message.contains(QStringLiteral("HTTP %1").arg(status))); - CHECK_FALSE(result.error().message.contains(QStringLiteral(""))); - } - } +TEST_CASE( + "workshop client reports error statuses as errors, never as empty " + "results") { + TestApplication app; + for (int status : {400, 401, 500, 502, 503}) { + for (bool jsonBody : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return jsonBody ? HttpResponse{status, errorBody("Catalog unavailable")} + : HttpResponse{status, "Upstream failure"}; + }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().status == status); + if (jsonBody) { + CHECK(result.error().message == QStringLiteral("Catalog unavailable")); + } else { + CHECK(result.error().message.contains( + QStringLiteral("HTTP %1").arg(status))); + CHECK_FALSE(result.error().message.contains(QStringLiteral(""))); + } } + } } TEST_CASE("workshop optional details return nothing only for a 404") { - TestApplication app; - for (bool jsonBody : {true, false}) { - FakeCatalog server([&](const HttpRequest&) -> HttpResponse { - return jsonBody ? HttpResponse{404, errorBody("Item unavailable")} : HttpResponse{404, "Not found"}; - }); - CatalogClient client(server.url()); - const auto mod = run(client.mod("123")); - REQUIRE(mod.has_value()); - CHECK_FALSE(mod->has_value()); - const auto collection = run(client.collection("steam:123")); - REQUIRE(collection.has_value()); - CHECK_FALSE(collection->has_value()); - } + TestApplication app; + for (bool jsonBody : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return jsonBody ? HttpResponse{404, errorBody("Item unavailable")} + : HttpResponse{404, "Not found"}; + }); + CatalogClient client(server.url()); + const auto mod = run(client.mod("123")); + REQUIRE(mod.has_value()); + CHECK_FALSE(mod->has_value()); + const auto collection = run(client.collection("steam:123")); + REQUIRE(collection.has_value()); + CHECK_FALSE(collection->has_value()); + } } -TEST_CASE("workshop optional details keep server, network and decoding failures") { - TestApplication app; - FakeCatalog failing([](const HttpRequest&) -> HttpResponse { return {500, "Server error"}; }); - CatalogClient failingClient(failing.url()); - const auto serverError = run(failingClient.mod("123")); - REQUIRE_FALSE(serverError.has_value()); - CHECK(serverError.error().status == 500); - const auto collectionError = run(failingClient.collection("steam:123")); - REQUIRE_FALSE(collectionError.has_value()); - CHECK(collectionError.error().status == 500); - - CatalogClient offline(unreachableUrl()); - const auto network = run(offline.mod("123")); - REQUIRE_FALSE(network.has_value()); - CHECK_FALSE(network.error().status.has_value()); - - FakeCatalog garbage([](const HttpRequest&) -> HttpResponse { return {200, "not-json"}; }); - CatalogClient garbageClient(garbage.url()); - const auto decoding = run(garbageClient.collection("steam:123")); - REQUIRE_FALSE(decoding.has_value()); - CHECK(decoding.error().status == 200); +TEST_CASE( + "workshop optional details keep server, network and decoding failures") { + TestApplication app; + FakeCatalog failing( + [](const HttpRequest&) -> HttpResponse { return {500, "Server error"}; }); + CatalogClient failingClient(failing.url()); + const auto serverError = run(failingClient.mod("123")); + REQUIRE_FALSE(serverError.has_value()); + CHECK(serverError.error().status == 500); + const auto collectionError = run(failingClient.collection("steam:123")); + REQUIRE_FALSE(collectionError.has_value()); + CHECK(collectionError.error().status == 500); + + CatalogClient offline(unreachableUrl()); + const auto network = run(offline.mod("123")); + REQUIRE_FALSE(network.has_value()); + CHECK_FALSE(network.error().status.has_value()); + + FakeCatalog garbage( + [](const HttpRequest&) -> HttpResponse { return {200, "not-json"}; }); + CatalogClient garbageClient(garbage.url()); + const auto decoding = run(garbageClient.collection("steam:123")); + REQUIRE_FALSE(decoding.has_value()); + CHECK(decoding.error().status == 200); } TEST_CASE("workshop non-optional endpoints keep 404 errors") { - TestApplication app; - FakeCatalog server([](const HttpRequest&) -> HttpResponse { return {404, ""}; }); - CatalogClient client(server.url()); - CatalogQuery query; - - const auto mods = run(client.mods(query)); - REQUIRE_FALSE(mods.has_value()); - CHECK(mods.error().status == 404); - const auto collections = run(client.collections(query)); - REQUIRE_FALSE(collections.has_value()); - CHECK(collections.error().status == 404); - const auto batch = run(client.modsBatch({"123"})); - REQUIRE_FALSE(batch.has_value()); - CHECK(batch.error().status == 404); - const auto resolved = run(client.resolve({"123"}, {})); - REQUIRE_FALSE(resolved.has_value()); - CHECK(resolved.error().status == 404); + TestApplication app; + FakeCatalog server( + [](const HttpRequest&) -> HttpResponse { return {404, ""}; }); + CatalogClient client(server.url()); + CatalogQuery query; + + const auto mods = run(client.mods(query)); + REQUIRE_FALSE(mods.has_value()); + CHECK(mods.error().status == 404); + const auto collections = run(client.collections(query)); + REQUIRE_FALSE(collections.has_value()); + CHECK(collections.error().status == 404); + const auto batch = run(client.modsBatch({"123"})); + REQUIRE_FALSE(batch.has_value()); + CHECK(batch.error().status == 404); + const auto resolved = run(client.resolve({"123"}, {})); + REQUIRE_FALSE(resolved.has_value()); + CHECK(resolved.error().status == 404); } TEST_CASE("workshop client rejects invalid collection ids before sending") { - TestApplication app; - FakeCatalog server([](const HttpRequest&) -> HttpResponse { return {200, "{}"}; }); - CatalogClient client(server.url()); - for (const char* id : {"steam", "steam:", ":123", "other:123"}) { - const auto result = run(client.collection(id)); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message == QStringLiteral("Invalid Workshop collection ID.")); - } - CHECK(server.requests.empty()); + TestApplication app; + FakeCatalog server( + [](const HttpRequest&) -> HttpResponse { return {200, "{}"}; }); + CatalogClient client(server.url()); + for (const char* id : {"steam", "steam:", ":123", "other:123"}) { + const auto result = run(client.collection(id)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == + QStringLiteral("Invalid Workshop collection ID.")); + } + CHECK(server.requests.empty()); } -TEST_CASE("workshop client treats responses that violate the wire contract as invalid") { - TestApplication app; - const std::string validMod(workshop_test::kModJson); - std::string numericId = validMod; - numericId.replace(numericId.find("\"2009463077\""), 12, "123"); - std::string badKind = validMod; - badKind.replace(badKind.find("\"mod\""), 5, "\"collection\""); - std::string missingTitle = validMod; - missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); - - for (const std::string& body : {numericId, badKind, missingTitle, std::string("not-json")}) { - FakeCatalog server([&](const HttpRequest&) -> HttpResponse { return {200, body}; }); - CatalogClient client(server.url()); - const auto result = run(client.mod("123")); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message == QStringLiteral("The Workshop catalog returned an invalid response.")); - CHECK(result.error().status == 200); - } +TEST_CASE( + "workshop client treats responses that violate the wire contract as " + "invalid") { + TestApplication app; + const std::string validMod(workshop_test::kModJson); + std::string numericId = validMod; + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + std::string badKind = validMod; + badKind.replace(badKind.find("\"mod\""), 5, "\"collection\""); + std::string missingTitle = validMod; + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + + for (const std::string& body : + {numericId, badKind, missingTitle, std::string("not-json")}) { + FakeCatalog server( + [&](const HttpRequest&) -> HttpResponse { return {200, body}; }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == + QStringLiteral("The Workshop catalog returned an invalid response.")); + CHECK(result.error().status == 200); + } } TEST_CASE("workshop batch requests split ids into chunks of 100") { - TestApplication app; - FakeCatalog server([](const HttpRequest& request) -> HttpResponse { - const auto ids = glz::read_json(request.body); - REQUIRE(ids.has_value()); - std::string items; - std::vector unavailable; - for (const std::string& id : ids->ids) { - if (id == "105") { - unavailable.push_back(id); - continue; - } - items += (items.empty() ? "" : ",") + wire(withId(id)); - } - std::string unavailableJson = "["; - for (const std::string& id : unavailable) { - unavailableJson += "\"" + id + "\""; - } - return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + unavailableJson + "]}"}; - }); - - CatalogClient client(server.url()); - std::vector ids; - for (int id = 1; id <= 205; ++id) { - ids.push_back(std::to_string(id)); + TestApplication app; + FakeCatalog server([](const HttpRequest& request) -> HttpResponse { + const auto ids = glz::read_json(request.body); + REQUIRE(ids.has_value()); + std::string items; + std::vector unavailable; + for (const std::string& id : ids->ids) { + if (id == "105") { + unavailable.push_back(id); + continue; + } + items += (items.empty() ? "" : ",") + wire(withId(id)); } - QStringList qids; - for (const std::string& id : ids) { - qids.append(QString::fromStdString(id)); + std::string unavailableJson = "["; + for (const std::string& id : unavailable) { + unavailableJson += "\"" + id + "\""; } - const auto result = run(client.modsBatch(qids)); - REQUIRE(result.has_value()); - - REQUIRE(server.requests.size() == 3); - CHECK(glz::read_json(server.requests[0].body)->ids.size() == 100); - CHECK(glz::read_json(server.requests[1].body)->ids.size() == 100); - CHECK(glz::read_json(server.requests[2].body)->ids.size() == 5); - CHECK(result->items.size() == 204); - CHECK(result->items.front().id == "1"); - CHECK(result->unavailableIds == std::vector{"105"}); - - const auto empty = run(client.modsBatch({})); - REQUIRE(empty.has_value()); - CHECK(empty->items.empty()); - CHECK(empty->unavailableIds.empty()); - CHECK(server.requests.size() == 3); + return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + + unavailableJson + "]}"}; + }); + + CatalogClient client(server.url()); + std::vector ids; + for (int id = 1; id <= 205; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + const auto result = run(client.modsBatch(qids)); + REQUIRE(result.has_value()); + + REQUIRE(server.requests.size() == 3); + CHECK(glz::read_json(server.requests[0].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[1].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[2].body)->ids.size() == 5); + CHECK(result->items.size() == 204); + CHECK(result->items.front().id == "1"); + CHECK(result->unavailableIds == std::vector{"105"}); + + const auto empty = run(client.modsBatch({})); + REQUIRE(empty.has_value()); + CHECK(empty->items.empty()); + CHECK(empty->unavailableIds.empty()); + CHECK(server.requests.size() == 3); } TEST_CASE("workshop transport failures are catalog errors without a status") { - TestApplication app; - CatalogClient client(unreachableUrl()); - const auto result = run(client.discover()); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message.startsWith(QStringLiteral("Cannot reach the Workshop catalog"))); - CHECK_FALSE(result.error().status.has_value()); + TestApplication app; + CatalogClient client(unreachableUrl()); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith( + QStringLiteral("Cannot reach the Workshop catalog"))); + CHECK_FALSE(result.error().status.has_value()); } TEST_CASE("workshop disabled client sends nothing") { - TestApplication app; - CatalogClient client(QStringLiteral("")); - const auto result = run(client.discover()); - REQUIRE_FALSE(result.has_value()); - CHECK(result.error().message.contains(QStringLiteral("disabled"))); - CHECK_FALSE(result.error().status.has_value()); + TestApplication app; + CatalogClient client(QStringLiteral("")); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(QStringLiteral("disabled"))); + CHECK_FALSE(result.error().status.has_value()); } TEST_CASE("workshop resolve splits requests and merges their answers") { - TestApplication app; - std::vector sent; - FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { - const auto body = glz::read_json(request.body); - REQUIRE(body.has_value()); - sent.push_back(*body); - const bool second = sent.size() == 2; - - // Every answer repeats the dependency 999, which must be reported once. - std::vector modIds{"999"}; - modIds.insert(modIds.end(), body->ids.begin(), body->ids.end()); - std::string items = "{"; - for (const std::string& id : modIds) { - items += "\"" + id + "\":" + wire(withId(id)); - items += id == modIds.back() ? "" : ","; - } - items += "}"; - std::string modsJson = "["; - for (const std::string& id : modIds) { - modsJson += "\"" + id + "\","; - } - modsJson.back() = ']'; - std::string rootsJson = "["; - for (const std::string& id : body->ids) { - rootsJson += "\"" + id + "\","; - } - for (const std::string& id : body->collection_ids) { - rootsJson += "\"" + id + "\","; - } - rootsJson.back() = ']'; - - return {200, R"({"roots":)" + rootsJson + R"(,"items":)" + items + R"(,"mod_ids":)" + modsJson + - R"(,"unavailable_ids":)" + (second ? R"(["404"])" : "[]") + - R"(,"incomplete_collection_ids":)" + (second ? R"(["picked:pack-10"])" : "[]") + - R"(,"is_complete":)" + (second ? "false" : "true") + "}"}; - }); - - std::vector ids; - for (int id = 1; id <= 103; ++id) { - ids.push_back(std::to_string(id)); + TestApplication app; + std::vector sent; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + sent.push_back(*body); + const bool second = sent.size() == 2; + + // Every answer repeats the dependency 999, which must be reported once. + std::vector modIds{"999"}; + modIds.insert(modIds.end(), body->ids.begin(), body->ids.end()); + std::string items = "{"; + for (const std::string& id : modIds) { + items += "\"" + id + "\":" + wire(withId(id)); + items += id == modIds.back() ? "" : ","; } - QStringList qids; - for (const std::string& id : ids) { - qids.append(QString::fromStdString(id)); + items += "}"; + std::string modsJson = "["; + for (const std::string& id : modIds) { + modsJson += "\"" + id + "\","; } - QStringList collections; - for (int index = 0; index < 11; ++index) { - collections.append(QStringLiteral("picked:pack-%1").arg(index)); + modsJson.back() = ']'; + std::string rootsJson = "["; + for (const std::string& id : body->ids) { + rootsJson += "\"" + id + "\","; } - - CatalogClient client(server.url()); - const auto resolved = run(client.resolve(qids, collections)); - REQUIRE(resolved.has_value()); - - REQUIRE(sent.size() == 3); - CHECK(sent[0].ids.size() == 50); - CHECK(sent[1].ids.size() == 50); - CHECK(sent[2].ids.size() == 3); - CHECK(sent[0].collection_ids.size() == 10); - CHECK(sent[1].collection_ids.size() == 1); - CHECK(sent[2].collection_ids.empty()); - - CHECK(resolved->roots.size() == 114); - CHECK(resolved->mod_ids.size() == 104); - CHECK(resolved->mod_ids.front() == "999"); - CHECK(resolved->unavailable_ids == std::vector{"404"}); - CHECK(resolved->incomplete_collection_ids == std::vector{"picked:pack-10"}); - CHECK_FALSE(resolved->is_complete); + for (const std::string& id : body->collection_ids) { + rootsJson += "\"" + id + "\","; + } + rootsJson.back() = ']'; + + return {200, R"({"roots":)" + rootsJson + R"(,"items":)" + items + + R"(,"mod_ids":)" + modsJson + R"(,"unavailable_ids":)" + + (second ? R"(["404"])" : "[]") + + R"(,"incomplete_collection_ids":)" + + (second ? R"(["picked:pack-10"])" : "[]") + + R"(,"is_complete":)" + (second ? "false" : "true") + "}"}; + }); + + std::vector ids; + for (int id = 1; id <= 103; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + QStringList collections; + for (int index = 0; index < 11; ++index) { + collections.append(QStringLiteral("picked:pack-%1").arg(index)); + } + + CatalogClient client(server.url()); + const auto resolved = run(client.resolve(qids, collections)); + REQUIRE(resolved.has_value()); + + REQUIRE(sent.size() == 3); + CHECK(sent[0].ids.size() == 50); + CHECK(sent[1].ids.size() == 50); + CHECK(sent[2].ids.size() == 3); + CHECK(sent[0].collection_ids.size() == 10); + CHECK(sent[1].collection_ids.size() == 1); + CHECK(sent[2].collection_ids.empty()); + + CHECK(resolved->roots.size() == 114); + CHECK(resolved->mod_ids.size() == 104); + CHECK(resolved->mod_ids.front() == "999"); + CHECK(resolved->unavailable_ids == std::vector{"404"}); + CHECK(resolved->incomplete_collection_ids == + std::vector{"picked:pack-10"}); + CHECK_FALSE(resolved->is_complete); } diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp index 60acd38..204fd87 100644 --- a/tests/core/workshop/test_favourites.cpp +++ b/tests/core/workshop/test_favourites.cpp @@ -1,9 +1,8 @@ -#include - +#include #include #include -#include +#include #include "core/config.hpp" #include "core/workshop/favourites.hpp" @@ -17,62 +16,64 @@ using workshop_test::decodeCollection; namespace { CatalogCollection makeCollection(std::string id, std::string title = "Pack") { - CatalogCollection collection = decodeCollection(workshop_test::kCollectionJson); - collection.id = std::move(id); - collection.title = std::move(title); - return collection; + CatalogCollection collection = + decodeCollection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.title = std::move(title); + return collection; } } // namespace TEST_CASE("workshop favourites persist newest first and survive a restart") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - ConfigService service(dir.path()); - - FavouriteCollections favourites(&service); - const CatalogCollection first = makeCollection("steam:1"); - const CatalogCollection second = makeCollection("steam:2"); - favourites.set(first, true); - favourites.set(second, true); - // Setting an already-starred collection again is a no-op: it does not jump to the front. - favourites.set(second, true); - - FavouriteCollections reloaded(&service); - REQUIRE(reloaded.items().size() == 2); - CHECK(reloaded.items()[0].id == "steam:2"); - CHECK(reloaded.items()[1].id == "steam:1"); - CHECK(reloaded.contains("steam:1")); - - reloaded.set(first, false); - FavouriteCollections afterRemoval(&service); - REQUIRE(afterRemoval.items().size() == 1); - CHECK(afterRemoval.items()[0].id == "steam:2"); - CHECK_FALSE(afterRemoval.contains("steam:1")); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + + FavouriteCollections favourites(&service); + const CatalogCollection first = makeCollection("steam:1"); + const CatalogCollection second = makeCollection("steam:2"); + favourites.set(first, true); + favourites.set(second, true); + // Setting an already-starred collection again is a no-op: it does not jump to + // the front. + favourites.set(second, true); + + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 2); + CHECK(reloaded.items()[0].id == "steam:2"); + CHECK(reloaded.items()[1].id == "steam:1"); + CHECK(reloaded.contains("steam:1")); + + reloaded.set(first, false); + FavouriteCollections afterRemoval(&service); + REQUIRE(afterRemoval.items().size() == 1); + CHECK(afterRemoval.items()[0].id == "steam:2"); + CHECK_FALSE(afterRemoval.contains("steam:1")); } TEST_CASE("workshop favourites remember refreshes only starred snapshots") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - ConfigService service(dir.path()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); - FavouriteCollections favourites(&service); - favourites.set(makeCollection("steam:1", "Old title"), true); + FavouriteCollections favourites(&service); + favourites.set(makeCollection("steam:1", "Old title"), true); - int saves = 0; - favourites.changed().connect([&](const FavouritesState&) { ++saves; }); + int saves = 0; + favourites.changed().connect([&](const FavouritesState&) { ++saves; }); - // An equal collection does not save. - favourites.remember(makeCollection("steam:1", "Old title")); - // A collection that was never starred does not save. - favourites.remember(makeCollection("steam:2", "Not starred")); - CHECK(saves == 0); + // An equal collection does not save. + favourites.remember(makeCollection("steam:1", "Old title")); + // A collection that was never starred does not save. + favourites.remember(makeCollection("steam:2", "Not starred")); + CHECK(saves == 0); - favourites.remember(makeCollection("steam:1", "New title")); - CHECK(saves == 1); + favourites.remember(makeCollection("steam:1", "New title")); + CHECK(saves == 1); - FavouriteCollections reloaded(&service); - REQUIRE(reloaded.items().size() == 1); - CHECK(reloaded.items()[0].title == "New title"); - CHECK(reloaded.items()[0].id == "steam:1"); + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 1); + CHECK(reloaded.items()[0].title == "New title"); + CHECK(reloaded.items()[0].id == "steam:1"); } diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp index a940228..158acb7 100644 --- a/tests/core/workshop/test_types.cpp +++ b/tests/core/workshop/test_types.cpp @@ -1,7 +1,6 @@ -#include - #include +#include #include #include "core/workshop/types.hpp" @@ -17,23 +16,24 @@ namespace { // Decodes `json` as a `T`, returning whether glaze accepted it. template bool accepts(const std::string& json) { - T value{}; - return !glz::read(value, json); + T value{}; + return !glz::read(value, json); } } // namespace -TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form") { - CatalogMod mod = decodeMod(workshop_test::kModJson); - REQUIRE(mod.id == "2009463077"); - REQUIRE(mod.title == "Harmony"); - REQUIRE(mod.votes.has_value()); - REQUIRE(mod.votes->positive_percent == 90.0); - REQUIRE(mod.previews[1].type == PreviewType::kVideo); - REQUIRE(mod.file_size == std::string("18446744073709551615")); - - // Absent values stay absent instead of being invented. - const std::string nulled = R"({ +TEST_CASE( + "workshop mod decodes with nulls and round-trips through the wire form") { + CatalogMod mod = decodeMod(workshop_test::kModJson); + REQUIRE(mod.id == "2009463077"); + REQUIRE(mod.title == "Harmony"); + REQUIRE(mod.votes.has_value()); + REQUIRE(mod.votes->positive_percent == 90.0); + REQUIRE(mod.previews[1].type == PreviewType::kVideo); + REQUIRE(mod.file_size == std::string("18446744073709551615")); + + // Absent values stay absent instead of being invented. + const std::string nulled = R"({ "id": "7", "kind": "mod", "source": "steam", "title": "T", "author": {"id": "1", "name": null, "profile_url": null}, "description": "", "description_format": "bbcode", "preview_url": null, @@ -41,20 +41,20 @@ TEST_CASE("workshop mod decodes with nulls and round-trips through the wire form "created_at": null, "updated_at": null, "file_size": null, "subscriptions": null, "votes": null, "dependencies": [], "package_id": null, "incompatible": false })"; - CatalogMod sparse = decodeMod(nulled); - CHECK_FALSE(sparse.author.name.has_value()); - CHECK_FALSE(sparse.votes.has_value()); - CHECK_FALSE(sparse.updated_at.has_value()); - CHECK_FALSE(sparse.file_size.has_value()); - - const std::string encoded = wire(sparse); - CHECK(encoded.find(R"("kind":"mod")") != std::string::npos); - CHECK(decodeMod(encoded) == sparse); - CHECK(decodeMod(wire(mod)) == mod); + CatalogMod sparse = decodeMod(nulled); + CHECK_FALSE(sparse.author.name.has_value()); + CHECK_FALSE(sparse.votes.has_value()); + CHECK_FALSE(sparse.updated_at.has_value()); + CHECK_FALSE(sparse.file_size.has_value()); + + const std::string encoded = wire(sparse); + CHECK(encoded.find(R"("kind":"mod")") != std::string::npos); + CHECK(decodeMod(encoded) == sparse); + CHECK(decodeMod(wire(mod)) == mod); } TEST_CASE("workshop steam collection and empty page decode") { - CatalogCollection collection = decodeCollection(R"({ + CatalogCollection collection = decodeCollection(R"({ "id": "steam:123", "kind": "collection", "source": "steam", "steam_id": "123", "title": "Pack", "author": {"id": null, "name": "Curator", "profile_url": null}, "description": "[b]x[/b]", "description_format": "bbcode", @@ -65,57 +65,61 @@ TEST_CASE("workshop steam collection and empty page decode") { "member_previews": ["https://images.example.test/a.png"], "total_size": "10", "no_common_version": true })"); - CHECK(collection.member_ids == std::vector{"2009463077", "steam:456"}); - CHECK(collection.source == CollectionSource::kSteam); - CHECK(collection.no_common_version); - CHECK(decodeCollection(wire(collection)) == collection); - - CatalogPage page{}; - REQUIRE(accepts>(R"({"items":[],"total":0,"next_cursor":null})")); - REQUIRE_FALSE(glz::read(page, std::string(R"({"items":[],"total":0,"next_cursor":null})"))); - CHECK(page.items.empty()); - CHECK(page.total == 0); - CHECK_FALSE(page.next_cursor.has_value()); + CHECK(collection.member_ids == + std::vector{"2009463077", "steam:456"}); + CHECK(collection.source == CollectionSource::kSteam); + CHECK(collection.no_common_version); + CHECK(decodeCollection(wire(collection)) == collection); + + CatalogPage page{}; + REQUIRE(accepts>( + R"({"items":[],"total":0,"next_cursor":null})")); + REQUIRE_FALSE(glz::read( + page, std::string(R"({"items":[],"total":0,"next_cursor":null})"))); + CHECK(page.items.empty()); + CHECK(page.total == 0); + CHECK_FALSE(page.next_cursor.has_value()); } -TEST_CASE("workshop wire decoding rejects payloads outside the catalog contract") { - // A numeric id is not a string. - std::string numericId(workshop_test::kModJson); - numericId.replace(numericId.find("\"2009463077\""), 12, "123"); - CHECK_FALSE(accepts(numericId)); +TEST_CASE( + "workshop wire decoding rejects payloads outside the catalog contract") { + // A numeric id is not a string. + std::string numericId(workshop_test::kModJson); + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + CHECK_FALSE(accepts(numericId)); - // A collection payload is not a mod. - CHECK_FALSE(accepts(std::string(workshop_test::kCollectionJson))); + // A collection payload is not a mod. + CHECK_FALSE(accepts(std::string(workshop_test::kCollectionJson))); - // A mod payload is not a collection. - CHECK_FALSE(accepts(std::string(workshop_test::kModJson))); + // A mod payload is not a collection. + CHECK_FALSE(accepts(std::string(workshop_test::kModJson))); - // A missing required key fails. - std::string missingTitle(workshop_test::kModJson); - missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); - CHECK_FALSE(accepts(missingTitle)); + // A missing required key fails. + std::string missingTitle(workshop_test::kModJson); + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + CHECK_FALSE(accepts(missingTitle)); - // A preview type outside image|video fails. - std::string badPreview(workshop_test::kModJson); - badPreview.replace(badPreview.find("\"image\""), 7, "\"audio\""); - CHECK_FALSE(accepts(badPreview)); + // A preview type outside image|video fails. + std::string badPreview(workshop_test::kModJson); + badPreview.replace(badPreview.find("\"image\""), 7, "\"audio\""); + CHECK_FALSE(accepts(badPreview)); - CHECK_FALSE(accepts("not-json")); + CHECK_FALSE(accepts("not-json")); } TEST_CASE("workshop wire decoding ignores keys the contract does not name") { - std::string extra(workshop_test::kModJson); - extra.insert(1, R"("unexpected": {"nested": true},)"); - CHECK(accepts(extra)); + std::string extra(workshop_test::kModJson); + extra.insert(1, R"("unexpected": {"nested": true},)"); + CHECK(accepts(extra)); } TEST_CASE("workshop catalog query has the documented defaults") { - CatalogQuery query; - CHECK(query.query.isEmpty()); - CHECK(query.sort == QStringLiteral("popular")); - CHECK(query.limit == 24); - CHECK(query.source == QStringLiteral("all")); - CHECK_FALSE(query.tag.has_value()); - CHECK_FALSE(query.version.has_value()); - CHECK_FALSE(query.cursor.has_value()); + CatalogQuery query; + CHECK(query.query.isEmpty()); + CHECK(query.sort == QStringLiteral("popular")); + CHECK(query.limit == 24); + CHECK(query.source == QStringLiteral("all")); + CHECK_FALSE(query.tag.has_value()); + CHECK_FALSE(query.version.has_value()); + CHECK_FALSE(query.cursor.has_value()); } diff --git a/tests/ui/main.cpp b/tests/ui/main.cpp index 27863a2..651dabf 100644 --- a/tests/ui/main.cpp +++ b/tests/ui/main.cpp @@ -1,13 +1,15 @@ -#include - #include + #include +#include + Q_IMPORT_QML_PLUGIN(PxModRimPlugin) -// One QApplication for the whole UI test binary: Qt allows a single instance per process. +// One QApplication for the whole UI test binary: Qt allows a single instance +// per process. int main(int argc, char* argv[]) { - qputenv("QT_QPA_PLATFORM", "offscreen"); - QApplication app(argc, argv); - return Catch::Session().run(argc, argv); + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + return Catch::Session().run(argc, argv); } diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp index 1a39c0b..b4101b4 100644 --- a/tests/ui/test_dialog_chrome.cpp +++ b/tests/ui/test_dialog_chrome.cpp @@ -1,40 +1,44 @@ -#include - #include #include #include #include +#include + #include "ui/components/dialog_chrome.hpp" namespace { // The shared QApplication is created by tests/ui/main.cpp. -QApplication& application() { return *static_cast(QApplication::instance()); } +QApplication& application() { + return *static_cast(QApplication::instance()); +} void clickClose(QDialog& dialog) { - px::modrim::installDialogChrome(&dialog); - auto* button = dialog.findChild(QStringLiteral("dialogCloseBtn")); - REQUIRE(button != nullptr); - button->click(); + px::modrim::installDialogChrome(&dialog); + auto* button = + dialog.findChild(QStringLiteral("dialogCloseBtn")); + REQUIRE(button != nullptr); + button->click(); } } // namespace TEST_CASE("message box close yields cancel") { - application(); - for (const auto buttons : {QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, - QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel}) { - QMessageBox box; - box.setStandardButtons(buttons); - clickClose(box); - CHECK(box.result() == static_cast(QMessageBox::Cancel)); - } + application(); + for (const auto buttons : + {QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel}) { + QMessageBox box; + box.setStandardButtons(buttons); + clickClose(box); + CHECK(box.result() == static_cast(QMessageBox::Cancel)); + } } TEST_CASE("plain dialog close rejects") { - application(); - QDialog dialog; - clickClose(dialog); - CHECK(dialog.result() == QDialog::Rejected); + application(); + QDialog dialog; + clickClose(dialog); + CHECK(dialog.result() == QDialog::Rejected); } diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp index e4ef9d2..092656c 100644 --- a/tests/ui/test_downloads_model.cpp +++ b/tests/ui/test_downloads_model.cpp @@ -1,49 +1,55 @@ -#include - #include +#include + #include "ui/plugins/downloads/model.hpp" namespace { QHash states(const px::modrim::DownloadsModel& model) { - QHash result; - for (int row = 0; row < model.rowCount(); ++row) { - const auto index = model.index(row); - result.insert(model.data(index, px::modrim::DownloadsModel::kIdRole).toString(), - model.data(index, px::modrim::DownloadsModel::kStateRole).toString()); - } - return result; -} + QHash result; + for (int row = 0; row < model.rowCount(); ++row) { + const auto index = model.index(row); + result.insert( + model.data(index, px::modrim::DownloadsModel::kIdRole).toString(), + model.data(index, px::modrim::DownloadsModel::kStateRole).toString()); + } + return result; } +} // namespace TEST_CASE("download item events resolve states and cancel leftover rows") { - px::modrim::DownloadsModel model; - model.begin({"1", "2", "3", "4", "5"}, {{"1", "One"}}); - model.apply({"1", "downloading", 0, 0}); - model.apply({"2", "downloading", 5, 10}); - model.apply({"3", "downloading", 10, 10}); - model.apply({"4", "error", 0, 0, "boom"}); - model.flush(); - const QHash expected{{"1", "unchanged"}, {"2", "downloading"}, {"3", "updated"}, {"4", "failed"}, {"5", "queued"}}; - CHECK(states(model) == expected); - model.finish({{"1", "3"}, {"4"}, {"3"}}); - CHECK(states(model).value("2") == "cancelled"); - CHECK(states(model).value("5") == "cancelled"); - CHECK(model.updated() == 1); - CHECK(model.unchanged() == 1); - CHECK(model.failed() == 3); - CHECK(model.active() == 0); - CHECK(model.failedIds() == QStringList{"2", "4", "5"}); + px::modrim::DownloadsModel model; + model.begin({"1", "2", "3", "4", "5"}, {{"1", "One"}}); + model.apply({"1", "downloading", 0, 0}); + model.apply({"2", "downloading", 5, 10}); + model.apply({"3", "downloading", 10, 10}); + model.apply({"4", "error", 0, 0, "boom"}); + model.flush(); + const QHash expected{{"1", "unchanged"}, + {"2", "downloading"}, + {"3", "updated"}, + {"4", "failed"}, + {"5", "queued"}}; + CHECK(states(model) == expected); + model.finish({{"1", "3"}, {"4"}, {"3"}}); + CHECK(states(model).value("2") == "cancelled"); + CHECK(states(model).value("5") == "cancelled"); + CHECK(model.updated() == 1); + CHECK(model.unchanged() == 1); + CHECK(model.failed() == 3); + CHECK(model.active() == 0); + CHECK(model.failedIds() == QStringList{"2", "4", "5"}); } TEST_CASE("download filters limit visible rows and keep fallback titles") { - px::modrim::DownloadsModel model; - model.begin({"1", "2"}, {{"1", "One"}}); - model.apply({"2", "error", 0, 0, "x"}); - model.flush(); - model.setFilter(QStringLiteral("failed")); - REQUIRE(model.rowCount() == 1); - CHECK(model.data(model.index(0), px::modrim::DownloadsModel::kTitleRole).toString() == "2"); - model.setFilter(QStringLiteral("all")); - CHECK(model.rowCount() == 2); + px::modrim::DownloadsModel model; + model.begin({"1", "2"}, {{"1", "One"}}); + model.apply({"2", "error", 0, 0, "x"}); + model.flush(); + model.setFilter(QStringLiteral("failed")); + REQUIRE(model.rowCount() == 1); + CHECK(model.data(model.index(0), px::modrim::DownloadsModel::kTitleRole) + .toString() == "2"); + model.setFilter(QStringLiteral("all")); + CHECK(model.rowCount() == 2); } diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index ede5f66..b2cd768 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -1,9 +1,3 @@ -#include -#include - -#include - -#include #include #include #include @@ -12,6 +6,12 @@ #include #include #include +#include + +#include + +#include +#include #include "core/config.hpp" #include "core/context.hpp" @@ -23,167 +23,192 @@ namespace { void ensureApplication() { - static int argc = 1; - static char name[] = "pxmodrim-window-tests"; - static char* argv[] = {name, nullptr}; - static std::unique_ptr app; - if (!QApplication::instance()) app = std::make_unique(argc, argv); + static int argc = 1; + static char name[] = "pxmodrim-window-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) + app = std::make_unique(argc, argv); } class FirstView : public px::modrim::RailView { -public: - using RailView::RailView; - static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("first"), QLatin1StringView("mods"), QLatin1StringView("First")}; + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("first"), QLatin1StringView("mods"), + QLatin1StringView("First")}; }; class SecondView : public px::modrim::RailView { -public: - using RailView::RailView; - static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("second"), QLatin1StringView("mods"), QLatin1StringView("Second")}; - void openRoute(const QStringList& path) override { opened = path; } - QStringList opened; + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("second"), QLatin1StringView("mods"), + QLatin1StringView("Second")}; + void openRoute(const QStringList& path) override { opened = path; } + QStringList opened; }; class ThirdView : public px::modrim::RailView { -public: - using RailView::RailView; - static constexpr px::modrim::RailViewMeta kRailMeta{QLatin1StringView("third"), QLatin1StringView("mods"), QLatin1StringView("Third")}; + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("third"), QLatin1StringView("mods"), + QLatin1StringView("Third")}; }; struct Fixture { - QTemporaryDir directory; - px::modrim::ConfigService configService{directory.path()}; - std::unique_ptr core; - std::unique_ptr app; - explicit Fixture(bool canSave = true) { - ensureApplication(); - px::modrim::AppConfig config; - if (canSave) { - config.paths.config_folder = QDir(directory.path()).filePath(QStringLiteral("game-config")).toStdString(); - QDir().mkpath(QString::fromStdString(config.paths.config_folder)); - } - core = px::modrim::CoreContext::create(config, &configService); - core->registerPlugin(std::make_unique()); - auto a = std::make_shared(); - a->packageId = px::modrim::CaseInsensitiveStr(u"test.a"); - auto b = std::make_shared(); - b->packageId = px::modrim::CaseInsensitiveStr(u"test.b"); - core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, {QStringLiteral("a"), QStringLiteral("b")}); - app = std::make_unique(*core); - app->addRailView(); - app->addRailView(); - app->addRailView(); + QTemporaryDir directory; + px::modrim::ConfigService configService{directory.path()}; + std::unique_ptr core; + std::unique_ptr app; + explicit Fixture(bool canSave = true) { + ensureApplication(); + px::modrim::AppConfig config; + if (canSave) { + config.paths.config_folder = QDir(directory.path()) + .filePath(QStringLiteral("game-config")) + .toStdString(); + QDir().mkpath(QString::fromStdString(config.paths.config_folder)); } + core = px::modrim::CoreContext::create(config, &configService); + core->registerPlugin(std::make_unique()); + auto a = std::make_shared(); + a->packageId = px::modrim::CaseInsensitiveStr(u"test.a"); + auto b = std::make_shared(); + b->packageId = px::modrim::CaseInsensitiveStr(u"test.b"); + core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, + {QStringLiteral("a"), QStringLiteral("b")}); + app = std::make_unique(*core); + app->addRailView(); + app->addRailView(); + app->addRailView(); + } }; QStackedWidget* stackOf(px::modrim::MainWindow& window) { - return window.findChild(QStringLiteral("viewStack")); + return window.findChild(QStringLiteral("viewStack")); } QMessageBox* closePrompt(px::modrim::MainWindow& window) { - for (auto* dialog : window.findChildren()) - if (dialog->windowTitle() == QStringLiteral("Unsaved Changes") && dialog->isVisible()) return dialog; - return nullptr; -} + for (auto* dialog : window.findChildren()) + if (dialog->windowTitle() == QStringLiteral("Unsaved Changes") && + dialog->isVisible()) + return dialog; + return nullptr; } +} // namespace TEST_CASE("View shortcuts wrap and ignore invalid indexes", "[ui][window]") { - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - auto* stack = stackOf(window); - REQUIRE(stack != nullptr); - window.cycleView(-1); - CHECK(stack->currentIndex() == 2); - window.cycleView(1); - CHECK(stack->currentIndex() == 0); - window.selectView(3); - window.selectView(-1); - CHECK(stack->currentIndex() == 0); + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.cycleView(-1); + CHECK(stack->currentIndex() == 2); + window.cycleView(1); + CHECK(stack->currentIndex() == 0); + window.selectView(3); + window.selectView(-1); + CHECK(stack->currentIndex() == 0); } -TEST_CASE("Routes select their view before handing it the path", "[ui][window]") { - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - auto* stack = stackOf(window); - REQUIRE(stack != nullptr); - window.openRoute(QStringLiteral("modrim://second/mod/42")); - CHECK(stack->currentIndex() == 1); - CHECK(static_cast(stack->widget(1))->opened == QStringList{"mod", "42"}); - window.openRoute(QStringLiteral("modrim://missing/x")); - window.openRoute(QStringLiteral("second")); - CHECK(stack->currentIndex() == 1); +TEST_CASE("Routes select their view before handing it the path", + "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.openRoute(QStringLiteral("modrim://second/mod/42")); + CHECK(stack->currentIndex() == 1); + CHECK(static_cast(stack->widget(1))->opened == + QStringList{"mod", "42"}); + window.openRoute(QStringLiteral("modrim://missing/x")); + window.openRoute(QStringLiteral("second")); + CHECK(stack->currentIndex() == 1); } TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - window.selectView(1); - window.openRoute(QStringLiteral("modrim://settings")); - CHECK(stackOf(window)->currentIndex() == 1); - bool found = false; - for (auto* dialog : window.findChildren()) { - if (dialog->isVisible()) { found = true; dialog->reject(); } + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + window.selectView(1); + window.openRoute(QStringLiteral("modrim://settings")); + CHECK(stackOf(window)->currentIndex() == 1); + bool found = false; + for (auto* dialog : window.findChildren()) { + if (dialog->isVisible()) { + found = true; + dialog->reject(); } - CHECK(found); + } + CHECK(found); } -TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui][window]") { - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - CHECK_FALSE(window.hasUnsavedChanges()); - fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); - CHECK(window.hasUnsavedChanges()); - CHECK(window.isWindowModified()); - fixture.core->setActive({QStringLiteral("a"), QStringLiteral("b")}); - CHECK_FALSE(window.hasUnsavedChanges()); - fixture.core->setActive({QStringLiteral("a")}); - fixture.core->modService().modsChanged.notify(std::monostate{}); - CHECK(window.hasUnsavedChanges()); +TEST_CASE("Dirty state tracks active list order and disk reload baseline", + "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + CHECK(window.hasUnsavedChanges()); + CHECK(window.isWindowModified()); + fixture.core->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("a")}); + fixture.core->modService().modsChanged.notify(std::monostate{}); + CHECK(window.hasUnsavedChanges()); } TEST_CASE("Successful saves clear the unsaved state", "[ui][window]") { - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); - REQUIRE(window.hasUnsavedChanges()); - CHECK(QCoro::waitFor(window.saveActiveMods())); - CHECK_FALSE(window.hasUnsavedChanges()); - CHECK_FALSE(window.isWindowModified()); + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(window.hasUnsavedChanges()); + CHECK(QCoro::waitFor(window.saveActiveMods())); + CHECK_FALSE(window.hasUnsavedChanges()); + CHECK_FALSE(window.isWindowModified()); } -TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", "[ui][window]") { - const auto choice = GENERATE(QMessageBox::Save, QMessageBox::Discard, QMessageBox::Cancel); - Fixture fixture; - px::modrim::MainWindow window(*fixture.app); - fixture.core->setActive({QStringLiteral("a")}); - int closed = 0; - QEventLoop loop; - window.setAppQuitCallback([&] { ++closed; loop.quit(); }); - QCloseEvent first, second; - QCoreApplication::sendEvent(&window, &first); - QCoreApplication::sendEvent(&window, &second); - CHECK_FALSE(first.isAccepted()); - CHECK_FALSE(second.isAccepted()); - auto* prompt = closePrompt(window); - REQUIRE(prompt != nullptr); - int visiblePrompts = 0; - for (auto* item : window.findChildren()) if (item->isVisible()) ++visiblePrompts; - CHECK(visiblePrompts == 1); - QTimer::singleShot(10, prompt, [prompt, choice] { prompt->done(choice); }); - QTimer::singleShot(choice == QMessageBox::Cancel ? 40 : 3000, &loop, &QEventLoop::quit); - loop.exec(); - CHECK(closed == (choice == QMessageBox::Cancel ? 0 : 1)); +TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", + "[ui][window]") { + const auto choice = + GENERATE(QMessageBox::Save, QMessageBox::Discard, QMessageBox::Cancel); + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + int closed = 0; + QEventLoop loop; + window.setAppQuitCallback([&] { + ++closed; + loop.quit(); + }); + QCloseEvent first, second; + QCoreApplication::sendEvent(&window, &first); + QCoreApplication::sendEvent(&window, &second); + CHECK_FALSE(first.isAccepted()); + CHECK_FALSE(second.isAccepted()); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + int visiblePrompts = 0; + for (auto* item : window.findChildren()) + if (item->isVisible()) ++visiblePrompts; + CHECK(visiblePrompts == 1); + QTimer::singleShot(10, prompt, [prompt, choice] { prompt->done(choice); }); + QTimer::singleShot(choice == QMessageBox::Cancel ? 40 : 3000, &loop, + &QEventLoop::quit); + loop.exec(); + CHECK(closed == (choice == QMessageBox::Cancel ? 0 : 1)); } TEST_CASE("A failed close-time save leaves the window open", "[ui][window]") { - Fixture fixture(false); - px::modrim::MainWindow window(*fixture.app); - fixture.core->setActive({QStringLiteral("a")}); - bool closed = false; - window.setAppQuitCallback([&] { closed = true; }); - QCloseEvent event; - QCoreApplication::sendEvent(&window, &event); - auto* prompt = closePrompt(window); - REQUIRE(prompt != nullptr); - prompt->done(QMessageBox::Save); - CHECK_FALSE(closed); - CHECK(window.hasUnsavedChanges()); + Fixture fixture(false); + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + bool closed = false; + window.setAppQuitCallback([&] { closed = true; }); + QCloseEvent event; + QCoreApplication::sendEvent(&window, &event); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + prompt->done(QMessageBox::Save); + CHECK_FALSE(closed); + CHECK(window.hasUnsavedChanges()); } diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp index d283f2d..6d1611b 100644 --- a/tests/ui/test_menu_bar.cpp +++ b/tests/ui/test_menu_bar.cpp @@ -1,69 +1,74 @@ -#include - -#include -#include - #include #include #include #include #include +#include +#include + +#include #include "ui/window/actions.hpp" #include "ui/window/menu_bar.hpp" namespace { void ensureApplication() { - static int argc = 1; - static char name[] = "pxmodrim-menu-tests"; - static char* argv[] = {name, nullptr}; - static std::unique_ptr app; - if (!QApplication::instance()) app = std::make_unique(argc, argv); -} + static int argc = 1; + static char name[] = "pxmodrim-menu-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) + app = std::make_unique(argc, argv); } +} // namespace TEST_CASE("Every window action belongs to exactly one menu", "[ui][window]") { - std::multiset placed; - for (const auto& menu : px::modrim::menuLayout()) - for (const auto id : menu.actions) if (id) placed.insert(*id); - REQUIRE(placed.size() == px::modrim::actionSpecs().size()); - for (const auto& spec : px::modrim::actionSpecs()) CHECK(placed.count(spec.id) == 1); + std::multiset placed; + for (const auto& menu : px::modrim::menuLayout()) + for (const auto id : menu.actions) + if (id) placed.insert(*id); + REQUIRE(placed.size() == px::modrim::actionSpecs().size()); + for (const auto& spec : px::modrim::actionSpecs()) + CHECK(placed.count(spec.id) == 1); } -TEST_CASE("Window shortcuts are unique and view switching is collapsed", "[ui][window]") { - ensureApplication(); - std::set shortcuts; - for (const auto& spec : px::modrim::actionSpecs()) - if (!spec.shortcut.isEmpty()) CHECK(shortcuts.insert(spec.shortcut).second); - std::set> unique; - int viewRows = 0; - bool save = false, refresh = false; - for (const auto& row : px::modrim::shortcutRows()) { - CHECK(unique.emplace(row.first, row.second).second); - if (row.second == QStringLiteral("Switch to view")) ++viewRows; - save |= row.second == QStringLiteral("Save mod list"); - refresh |= row.second == QStringLiteral("Refresh mods"); - } - CHECK(viewRows == 1); - CHECK(save); - CHECK(refresh); +TEST_CASE("Window shortcuts are unique and view switching is collapsed", + "[ui][window]") { + ensureApplication(); + std::set shortcuts; + for (const auto& spec : px::modrim::actionSpecs()) + if (!spec.shortcut.isEmpty()) CHECK(shortcuts.insert(spec.shortcut).second); + std::set> unique; + int viewRows = 0; + bool save = false, refresh = false; + for (const auto& row : px::modrim::shortcutRows()) { + CHECK(unique.emplace(row.first, row.second).second); + if (row.second == QStringLiteral("Switch to view")) ++viewRows; + save |= row.second == QStringLiteral("Save mod list"); + refresh |= row.second == QStringLiteral("Refresh mods"); + } + CHECK(viewRows == 1); + CHECK(save); + CHECK(refresh); } TEST_CASE("Hidden menu actions retain their window shortcuts", "[ui][window]") { - ensureApplication(); - QMainWindow window; - const auto actions = px::modrim::createActions(window); - px::modrim::MenuBar menu(actions, &window); - menu.hide(); - bool triggered = false; - QObject::connect(actions.at(px::modrim::ActionId::kSave), &QAction::triggered, &window, [&] { triggered = true; }); - QMenu* file = nullptr; - for (auto* item : menu.findChildren()) if (item->title() == QStringLiteral("&File")) file = item; - REQUIRE(file != nullptr); - auto* save = actions.at(px::modrim::ActionId::kSave); - CHECK(file->actions().contains(save)); - CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); - CHECK(window.actions().contains(save)); - save->trigger(); - CHECK(triggered); + ensureApplication(); + QMainWindow window; + const auto actions = px::modrim::createActions(window); + px::modrim::MenuBar menu(actions, &window); + menu.hide(); + bool triggered = false; + QObject::connect(actions.at(px::modrim::ActionId::kSave), &QAction::triggered, + &window, [&] { triggered = true; }); + QMenu* file = nullptr; + for (auto* item : menu.findChildren()) + if (item->title() == QStringLiteral("&File")) file = item; + REQUIRE(file != nullptr); + auto* save = actions.at(px::modrim::ActionId::kSave); + CHECK(file->actions().contains(save)); + CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); + CHECK(window.actions().contains(save)); + save->trigger(); + CHECK(triggered); } diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp index a927bb4..ea8e40a 100644 --- a/tests/ui/test_mod_info_data.cpp +++ b/tests/ui/test_mod_info_data.cpp @@ -1,271 +1,322 @@ -#include - -#include -#include - #include #include #include #include +#include +#include + +#include -#include "test_support.hpp" #include "core/downloads/manager.hpp" +#include "test_support.hpp" #include "ui/panels/mod_info_data.hpp" namespace px::modrim { namespace { std::shared_ptr makeMod(const QString& name, const QString& pid) { - auto mod = std::make_shared(); - mod->name = name; - mod->packageId = CaseInsensitiveStr(pid); - mod->description.clear(); - return mod; + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(pid); + mod->description.clear(); + return mod; } void addDependency(AboutXmlMod& mod, const QString& pid, - const QStringList& alternatives = {}) { - DependencyMod dependency; - dependency.name = pid; - dependency.packageId = CaseInsensitiveStr(pid); - dependency.alternativePackageIds = CaseInsensitiveSet(alternatives); - mod.aboutRules.dependencies.append(std::move(dependency)); + const QStringList& alternatives = {}) { + DependencyMod dependency; + dependency.name = pid; + dependency.packageId = CaseInsensitiveStr(pid); + dependency.alternativePackageIds = CaseInsensitiveSet(alternatives); + mod.aboutRules.dependencies.append(std::move(dependency)); } struct ModInfoContext { - QTemporaryDir game; - std::unique_ptr ctx; + QTemporaryDir game; + std::unique_ptr ctx; - ModInfoContext(const QList& mods, QStringList active) { - if (!QCoreApplication::instance()) { - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); - static int argc = 1; - static char name[] = "pxmodrim-mod-info-tests"; - static char* argv[] = {name, nullptr}; - static QApplication application(argc, argv); - } - REQUIRE(game.isValid()); - QFile version(game.filePath("Version.txt")); - REQUIRE(version.open(QIODevice::WriteOnly)); - REQUIRE(version.write("1.5.4297") == 8); - version.close(); - AppConfig config; - config.paths.game = game.path().toStdString(); - ctx = px::ui_test::makeCore(config); - // CoreContext registers the real, empty DownloadManager during construction. - REQUIRE_FALSE(ctx->downloads().available()); - QHash indexed; - for (const auto& mod : mods) indexed.insert(mod->uuid(), mod); - ctx->load(std::move(indexed), std::move(active)); + ModInfoContext(const QList& mods, QStringList active) { + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim-mod-info-tests"; + static char* argv[] = {name, nullptr}; + static QApplication application(argc, argv); } + REQUIRE(game.isValid()); + QFile version(game.filePath("Version.txt")); + REQUIRE(version.open(QIODevice::WriteOnly)); + REQUIRE(version.write("1.5.4297") == 8); + version.close(); + AppConfig config; + config.paths.game = game.path().toStdString(); + ctx = px::ui_test::makeCore(config); + // CoreContext registers the real, empty DownloadManager during + // construction. + REQUIRE_FALSE(ctx->downloads().available()); + QHash indexed; + for (const auto& mod : mods) indexed.insert(mod->uuid(), mod); + ctx->load(std::move(indexed), std::move(active)); + } }; -} // namespace +} // namespace -TEST_CASE("Mod info autolinks bare URLs without changing markup", "[ui][mod-info]") { - CHECK(autolink("see https://a.com/x?y=1 now") == - "see https://a.com/x?y=1 now"); - CHECK(autolink("go to https://a.com.") == - "go to https://a.com."); - const QString anchor = "https://b.com"; - CHECK(autolink(anchor) == anchor); - CHECK(autolink("https://a.com/?a=1&b=2") == - "https://a.com/?a=1&b=2"); +TEST_CASE("Mod info autolinks bare URLs without changing markup", + "[ui][mod-info]") { + CHECK(autolink("see https://a.com/x?y=1 now") == + "see https://a.com/x?y=1 now"); + CHECK(autolink("go to https://a.com.") == + "go to https://a.com."); + const QString anchor = "https://b.com"; + CHECK(autolink(anchor) == anchor); + CHECK( + autolink("https://a.com/?a=1&b=2") == + "https://a.com/?a=1&b=2"); } -TEST_CASE("Mod info converts BBCode images and linked images", "[ui][mod-info]") { - CHECK(bbcodeImages("[img]https://i.imgur.com/a.png[/img]\nhi") == - "\nhi"); - CHECK_FALSE(autolink(bbcodeImages("[img]https://i.imgur.com/a.png[/img]")).contains("")); +TEST_CASE("Mod info converts BBCode images and linked images", + "[ui][mod-info]") { + CHECK(bbcodeImages("[img]https://i.imgur.com/a.png[/img]\nhi") == + "\nhi"); + CHECK_FALSE(autolink(bbcodeImages("[img]https://i.imgur.com/a.png[/img]")) + .contains("")); } TEST_CASE("Mod info converts BBCode links and formatting", "[ui][mod-info]") { - CHECK(bbcodeMarkup("[url=https://a.com/w]the wiki[/url]") == - "the wiki"); - const QString unsafe = "[url=javascript:alert(1)]x[/url]"; - CHECK(bbcodeMarkup(unsafe) == unsafe); - CHECK(bbcodeMarkup("[b]a[/b] [i]b[/i]") == "a b"); + CHECK(bbcodeMarkup("[url=https://a.com/w]the wiki[/url]") == + "the wiki"); + const QString unsafe = "[url=javascript:alert(1)]x[/url]"; + CHECK(bbcodeMarkup(unsafe) == unsafe); + CHECK(bbcodeMarkup("[b]a[/b] [i]b[/i]") == "a b"); } -TEST_CASE("Mod info converts Steam structural tags without stray breaks", "[ui][mod-info]") { - CHECK(bbcodeMarkup("[list]
    [*]one
    [*]two
    [/list]
    after") == - "
    • one
    • two
    after"); - CHECK(bbcodeMarkup("[h2]Title[/h2]
    body") == "

    Title

    body"); - CHECK(bbcodeMarkup("[quote=Bob]hi[/quote]") == - "
    Bob wrote:
    hi
    "); - CHECK(bbcodeMarkup("[table noborder=1][tr][td]x[/td][/tr][/table]") == - "
    x
    "); +TEST_CASE("Mod info converts Steam structural tags without stray breaks", + "[ui][mod-info]") { + CHECK(bbcodeMarkup("[list]
    [*]one
    [*]two
    [/list]
    after") == + "
    • one
    • two
    after"); + CHECK(bbcodeMarkup("[h2]Title[/h2]
    body") == "

    Title

    body"); + CHECK(bbcodeMarkup("[quote=Bob]hi[/quote]") == + "
    Bob wrote:
    hi
    "); + CHECK(bbcodeMarkup("[table noborder=1][tr][td]x[/td][/tr][/table]") == + "
    x
    "); } -TEST_CASE("Mod info restores entities once and excludes URL delimiters", "[ui][mod-info]") { - CHECK(restoreEntities("&#8226; item") == "• item"); - CHECK(restoreEntities("a & b") == "a & b"); - CHECK(descriptionToHtml("see \"https://a.com/x\" ok") == - "see "https://a.com/x" ok"); - CHECK(descriptionToHtml("") == - "<https://a.com/x>"); +TEST_CASE("Mod info restores entities once and excludes URL delimiters", + "[ui][mod-info]") { + CHECK(restoreEntities("&#8226; item") == "• item"); + CHECK(restoreEntities("a & b") == "a & b"); + CHECK(descriptionToHtml("see \"https://a.com/x\" ok") == + "see "https://a.com/x" ok"); + CHECK(descriptionToHtml("") == + "<https://a.com/x>"); } -TEST_CASE("Mod info rich descriptions preserve formatting entities and images", "[ui][mod-info]") { - CHECK(descriptionToHtml( - "[h2]Title[/h2]\nBold •\n" - "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki") == - "

    Title

    Bold •
    " - "
    " - "https://a.com/wiki"); +TEST_CASE("Mod info rich descriptions preserve formatting entities and images", + "[ui][mod-info]") { + CHECK(descriptionToHtml( + "[h2]Title[/h2]\nBold •\n" + "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki") == + "

    Title

    Bold •
    " + "
    " + "https://a.com/wiki"); } -TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", "[ui][mod-info]") { - CHECK(descriptionToHtml( - "Literal & text\n[img]https://a.com/image.png[/img]\n" - "[url=https://a.com]wiki[/url]", "plaintext") == - "<b>Literal & text</b>
    " - "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]"); +TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", + "[ui][mod-info]") { + CHECK(descriptionToHtml( + "Literal & text\n[img]https://a.com/image.png[/img]\n" + "[url=https://a.com]wiki[/url]", + "plaintext") == + "<b>Literal & text</b>
    " + "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]"); } TEST_CASE("Mod info only embeds HTTPS images", "[ui][mod-info][security]") { - CHECK_FALSE(descriptionToHtml("[img]file:///etc/passwd[/img]").contains("")); - CHECK_FALSE(descriptionToHtml("[url=javascript:alert(1)]x[/url]").contains("")); + CHECK_FALSE( + descriptionToHtml("[url=javascript:alert(1)]x[/url]").contains("x y"); - CHECK_FALSE(html.contains("") == - "<img src="file:///etc/passwd">"); +TEST_CASE("Mod info escapes raw HTML and URL attribute injection", + "[ui][mod-info][security]") { + const auto html = + descriptionToHtml("x y"); + CHECK_FALSE(html.contains("") == + "<img src="file:///etc/passwd">"); } -TEST_CASE("Mod info rejects unsafe schemes in every rich link form", "[ui][mod-info][security]") { - const QStringList urls{"javascript:alert(1)", "data:image/png;base64,AAAA", - "file:///etc/passwd", "qrc:/private.png", "steam://run/294100"}; - for (const auto& url : urls) { - CAPTURE(url); - const auto html = descriptionToHtml( - "[url=" + url + "]link[/url] [url]" + url + "[/url] [img]" + url + - "[/img] Unity link"); - CHECK_FALSE(html.contains("Unity link"); + CHECK_FALSE(html.contains("uuid()}); - const auto needs = buildModInfo(*context.ctx, *main, {}).value("needs").toList(); - const QVariantList expected{ - QVariantMap{{"name", "a.active"}, {"state", "active"}, {"uuid", active->uuid()}}, - QVariantMap{{"name", "A.Inactive"}, {"state", "inactive"}, {"uuid", inactive->uuid()}}, - QVariantMap{{"name", "a.missing"}, {"state", "inactive"}, {"uuid", alternative->uuid()}}, - QVariantMap{{"name", "a.gone"}, {"state", "missing"}, {"uuid", ""}}}; - CHECK(needs == expected); +TEST_CASE( + "Mod info reports dependency states and case insensitive alternatives", + "[ui][mod-info]") { + auto active = makeMod("Active", "a.active"); + auto inactive = makeMod("Inactive", "a.inactive"); + auto alternative = makeMod("Alt", "a.alt"); + auto main = makeMod("Main", "a.main"); + addDependency(*main, "a.active"); + addDependency(*main, "A.Inactive"); + addDependency(*main, "a.missing", {"a.alt"}); + addDependency(*main, "a.gone"); + ModInfoContext context({main, active, inactive, alternative}, + {active->uuid()}); + const auto needs = + buildModInfo(*context.ctx, *main, {}).value("needs").toList(); + const QVariantList expected{ + QVariantMap{ + {"name", "a.active"}, {"state", "active"}, {"uuid", active->uuid()}}, + QVariantMap{{"name", "A.Inactive"}, + {"state", "inactive"}, + {"uuid", inactive->uuid()}}, + QVariantMap{{"name", "a.missing"}, + {"state", "inactive"}, + {"uuid", alternative->uuid()}}, + QVariantMap{{"name", "a.gone"}, {"state", "missing"}, {"uuid", ""}}}; + CHECK(needs == expected); } -TEST_CASE("Mod info conflicts include only active mods sorted by name", "[ui][mod-info]") { - auto beta = makeMod("beta", "x.b"); - auto alpha = makeMod("Alpha", "x.a"); - auto off = makeMod("Off", "x.off"); - auto main = makeMod("Main", "x.main"); - main->aboutRules.incompatibleWith = CaseInsensitiveSet( - QStringList{"X.B", "x.a", "x.off", "x.no"}); - ModInfoContext context({main, alpha, beta, off}, {alpha->uuid(), beta->uuid()}); - CHECK(buildModInfo(*context.ctx, *main, {}).value("conflicts").toStringList() == - QStringList{"Alpha", "beta"}); +TEST_CASE("Mod info conflicts include only active mods sorted by name", + "[ui][mod-info]") { + auto beta = makeMod("beta", "x.b"); + auto alpha = makeMod("Alpha", "x.a"); + auto off = makeMod("Off", "x.off"); + auto main = makeMod("Main", "x.main"); + main->aboutRules.incompatibleWith = + CaseInsensitiveSet(QStringList{"X.B", "x.a", "x.off", "x.no"}); + ModInfoContext context({main, alpha, beta, off}, + {alpha->uuid(), beta->uuid()}); + CHECK( + buildModInfo(*context.ctx, *main, {}).value("conflicts").toStringList() == + QStringList{"Alpha", "beta"}); } -TEST_CASE("Mod info needed by matches alternatives and sorts dependents", "[ui][mod-info]") { - auto target = makeMod("Target", "t.target"); - auto zed = makeMod("zed", "t.z"); - auto apple = makeMod("Apple", "t.a"); - auto unrelated = makeMod("Unrelated", "t.u"); - addDependency(*zed, "t.target"); - addDependency(*apple, "t.other", {"T.Target"}); - addDependency(*unrelated, "t.other"); - ModInfoContext context({target, zed, apple, unrelated}, {}); - const QVariantList expected{ - QVariantMap{{"name", "Apple"}, {"uuid", apple->uuid()}}, - QVariantMap{{"name", "zed"}, {"uuid", zed->uuid()}}}; - CHECK(buildModInfo(*context.ctx, *target, {}).value("neededBy").toList() == expected); +TEST_CASE("Mod info needed by matches alternatives and sorts dependents", + "[ui][mod-info]") { + auto target = makeMod("Target", "t.target"); + auto zed = makeMod("zed", "t.z"); + auto apple = makeMod("Apple", "t.a"); + auto unrelated = makeMod("Unrelated", "t.u"); + addDependency(*zed, "t.target"); + addDependency(*apple, "t.other", {"T.Target"}); + addDependency(*unrelated, "t.other"); + ModInfoContext context({target, zed, apple, unrelated}, {}); + const QVariantList expected{ + QVariantMap{{"name", "Apple"}, {"uuid", apple->uuid()}}, + QVariantMap{{"name", "zed"}, {"uuid", zed->uuid()}}}; + CHECK(buildModInfo(*context.ctx, *target, {}).value("neededBy").toList() == + expected); } -TEST_CASE("Mod info builds active mod status issues dependencies and description", "[ui][mod-info]") { - auto dep = makeMod("Dep", "d.dep"); - auto main = makeMod("Main", "m.main"); - addDependency(*main, "d.dep"); - main->description = "hello x https://a.com/x"; - main->authors = {"Ann"}; - auto other = makeMod("Other", "o.other"); - ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); - const view::ModIssueView issue{"c", "Cat", std::nullopt, false}; - const auto info = buildModInfo(*context.ctx, *main, {issue}); - CHECK(info.value("name").toString() == "Main"); - CHECK(info.value("author").toString() == "Ann"); - const auto status = info.value("status").toMap(); - CHECK(status.value("level").toString() == "warning"); - CHECK(status.value("title").toString() == "1 warning"); - CHECK(status.value("active").toBool()); - CHECK(status.value("position").toInt() == 2); - CHECK(status.value("total").toInt() == 2); - const QVariantList expectedIssues{ - QVariantMap{{"title", "Cat"}, {"detail", ""}, {"isError", false}}}; - CHECK(status.value("issues").toList() == expectedIssues); - const QVariantList expectedNeeds{ - QVariantMap{{"name", "d.dep"}, {"state", "inactive"}, {"uuid", dep->uuid()}}}; - CHECK(info.value("needs").toList() == expectedNeeds); - CHECK(info.value("description").toString().contains("")); - const QVariantMap expectedVersion{{"known", false}, {"ok", false}, {"target", "1.5"}}; - CHECK(info.value("version").toMap() == expectedVersion); +TEST_CASE( + "Mod info builds active mod status issues dependencies and description", + "[ui][mod-info]") { + auto dep = makeMod("Dep", "d.dep"); + auto main = makeMod("Main", "m.main"); + addDependency(*main, "d.dep"); + main->description = "hello x https://a.com/x"; + main->authors = {"Ann"}; + auto other = makeMod("Other", "o.other"); + ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); + const view::ModIssueView issue{"c", "Cat", std::nullopt, false}; + const auto info = buildModInfo(*context.ctx, *main, {issue}); + CHECK(info.value("name").toString() == "Main"); + CHECK(info.value("author").toString() == "Ann"); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "warning"); + CHECK(status.value("title").toString() == "1 warning"); + CHECK(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 2); + CHECK(status.value("total").toInt() == 2); + const QVariantList expectedIssues{ + QVariantMap{{"title", "Cat"}, {"detail", ""}, {"isError", false}}}; + CHECK(status.value("issues").toList() == expectedIssues); + const QVariantList expectedNeeds{QVariantMap{ + {"name", "d.dep"}, {"state", "inactive"}, {"uuid", dep->uuid()}}}; + CHECK(info.value("needs").toList() == expectedNeeds); + CHECK(info.value("description") + .toString() + .contains("")); + const QVariantMap expectedVersion{ + {"known", false}, {"ok", false}, {"target", "1.5"}}; + CHECK(info.value("version").toMap() == expectedVersion); } -TEST_CASE("Mod info builds inactive mod without description or dependents", "[ui][mod-info]") { - auto mod = makeMod("Solo", "s.solo"); - ModInfoContext context({mod}, {}); - const auto info = buildModInfo(*context.ctx, *mod, {}); - const auto status = info.value("status").toMap(); - CHECK(status.value("level").toString() == "ok"); - CHECK_FALSE(status.value("active").toBool()); - CHECK(status.value("position").toInt() == 0); - CHECK(info.value("description").toString().isEmpty()); - CHECK(info.value("neededBy").toList().isEmpty()); +TEST_CASE("Mod info builds inactive mod without description or dependents", + "[ui][mod-info]") { + auto mod = makeMod("Solo", "s.solo"); + ModInfoContext context({mod}, {}); + const auto info = buildModInfo(*context.ctx, *mod, {}); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "ok"); + CHECK_FALSE(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 0); + CHECK(info.value("description").toString().isEmpty()); + CHECK(info.value("neededBy").toList().isEmpty()); } -TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", "[ui][mod-info]") { - constexpr double kNow = 2'000'000'000; - CHECK(formatAge(0, kNow).isEmpty()); - CHECK(formatAge(-1, kNow).isEmpty()); - CHECK(formatAge(kNow + 1, kNow) == "just now"); - CHECK(formatAge(kNow, kNow) == "just now"); - CHECK(formatAge(kNow - 3599, kNow) == "just now"); - struct AgeCase { double elapsed; const char* expected; }; - const AgeCase cases[]{ - {3600, "1 hour ago"}, {7200, "2 hours ago"}, {86399, "23 hours ago"}, - {86400, "1 day ago"}, {2 * 86400, "2 days ago"}, {7 * 86400 - 1, "6 days ago"}, - {7 * 86400, "1 week ago"}, {14 * 86400, "2 weeks ago"}, - {30 * 86400 - 1, "4 weeks ago"}, {30 * 86400, "1 month ago"}, - {60 * 86400, "2 months ago"}, {365 * 86400 - 1, "12 months ago"}, - {365 * 86400, "1 year ago"}, {730 * 86400, "2 years ago"}}; - for (const auto& age : cases) { - CAPTURE(age.elapsed); - CHECK(formatAge(kNow - age.elapsed, kNow) == age.expected); - } +TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", + "[ui][mod-info]") { + constexpr double kNow = 2'000'000'000; + CHECK(formatAge(0, kNow).isEmpty()); + CHECK(formatAge(-1, kNow).isEmpty()); + CHECK(formatAge(kNow + 1, kNow) == "just now"); + CHECK(formatAge(kNow, kNow) == "just now"); + CHECK(formatAge(kNow - 3599, kNow) == "just now"); + struct AgeCase { + double elapsed; + const char* expected; + }; + const AgeCase cases[]{{3600, "1 hour ago"}, + {7200, "2 hours ago"}, + {86399, "23 hours ago"}, + {86400, "1 day ago"}, + {2 * 86400, "2 days ago"}, + {7 * 86400 - 1, "6 days ago"}, + {7 * 86400, "1 week ago"}, + {14 * 86400, "2 weeks ago"}, + {30 * 86400 - 1, "4 weeks ago"}, + {30 * 86400, "1 month ago"}, + {60 * 86400, "2 months ago"}, + {365 * 86400 - 1, "12 months ago"}, + {365 * 86400, "1 year ago"}, + {730 * 86400, "2 years ago"}}; + for (const auto& age : cases) { + CAPTURE(age.elapsed); + CHECK(formatAge(kNow - age.elapsed, kNow) == age.expected); + } } -} // namespace px::modrim +} // namespace px::modrim diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp index 3c26232..32206f1 100644 --- a/tests/ui/test_mod_list_model.cpp +++ b/tests/ui/test_mod_list_model.cpp @@ -1,123 +1,145 @@ -#include #include #include -#include "ui/models/mod_list_model.hpp" + +#include + #include "ui/models/impact.hpp" +#include "ui/models/mod_list_model.hpp" #include "ui/theme/palette.hpp" using namespace px::modrim; namespace { struct ListFixture { - QHash mods; - ModListModel model{{{QStringLiteral("stub"),QStringLiteral("#abcdef")}}}; - ListFixture() { - for (int i = 0; i < 5; ++i) { - auto mod = std::make_shared(); - mod->name = QStringLiteral("Mod %1").arg(i); - mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); - mod->providerId = QStringLiteral("stub"); - mods.insert(QStringLiteral("uuid-%1").arg(i),mod); - } - model.loadMods(mods,{"uuid-0","uuid-2"}); - } - QStringList order() const { - QStringList result; - for (int row = 0; row < model.rowCount(); ++row) result.append(model.getItem(row)->uuid); - return result; - } - QList indices() const { - QList result; - for (int row = 0; row < model.rowCount(); ++row) result.append(model.data(model.index(row),ModListModel::kLoadIndexRole).toInt()); - return result; + QHash mods; + ModListModel model{{{QStringLiteral("stub"), QStringLiteral("#abcdef")}}}; + ListFixture() { + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->providerId = QStringLiteral("stub"); + mods.insert(QStringLiteral("uuid-%1").arg(i), mod); } + model.loadMods(mods, {"uuid-0", "uuid-2"}); + } + QStringList order() const { + QStringList result; + for (int row = 0; row < model.rowCount(); ++row) + result.append(model.getItem(row)->uuid); + return result; + } + QList indices() const { + QList result; + for (int row = 0; row < model.rowCount(); ++row) + result.append( + model.data(model.index(row), ModListModel::kLoadIndexRole).toInt()); + return result; + } }; -} +} // namespace TEST_CASE("mod list load skips missing active UUIDs and sorts inactive mods") { - ListFixture f; - CHECK(f.order() == QStringList{"uuid-0","uuid-2","uuid-1","uuid-3","uuid-4"}); - f.model.loadMods(f.mods,{"ghost","uuid-2"}); - CHECK(f.model.activeUuids() == QStringList{"uuid-2"}); - CHECK(f.model.rowCount() == 5); - CHECK(f.model.rowCount(f.model.index(0)) == 0); - CHECK_FALSE(f.model.data({}).isValid()); - CHECK_FALSE(f.model.setData({},Qt::Checked,ModListModel::kCheckStateRole)); + ListFixture f; + CHECK(f.order() == + QStringList{"uuid-0", "uuid-2", "uuid-1", "uuid-3", "uuid-4"}); + f.model.loadMods(f.mods, {"ghost", "uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-2"}); + CHECK(f.model.rowCount() == 5); + CHECK(f.model.rowCount(f.model.index(0)) == 0); + CHECK_FALSE(f.model.data({}).isValid()); + CHECK_FALSE(f.model.setData({}, Qt::Checked, ModListModel::kCheckStateRole)); } TEST_CASE("mod list check state emits role updates without resetting") { - ListFixture f; - QSignalSpy reset(&f.model,&QAbstractItemModel::modelReset); - QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); - QSignalSpy active(&f.model,&ModListModel::activeModsChanged); - REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::kCheckStateRole)); - CHECK(f.model.getItem(2)->checked); - CHECK(f.indices() == QList{1,2,3,-1,-1}); - CHECK(reset.isEmpty()); - REQUIRE(changed.size() == 1); - CHECK(changed.first()[2].value>().contains(ModListModel::kLoadIndexRole)); - CHECK(active.size() == 1); - changed.clear(); - REQUIRE(f.model.setData(f.model.index(2),Qt::Checked,ModListModel::kCheckStateRole)); - CHECK(changed.isEmpty()); - f.model.setCheckStates({0},false); - CHECK(f.indices() == QList{-1,1,2,-1,-1}); + ListFixture f; + QSignalSpy reset(&f.model, &QAbstractItemModel::modelReset); + QSignalSpy changed(&f.model, &QAbstractItemModel::dataChanged); + QSignalSpy active(&f.model, &ModListModel::activeModsChanged); + REQUIRE(f.model.setData(f.model.index(2), Qt::Checked, + ModListModel::kCheckStateRole)); + CHECK(f.model.getItem(2)->checked); + CHECK(f.indices() == QList{1, 2, 3, -1, -1}); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains( + ModListModel::kLoadIndexRole)); + CHECK(active.size() == 1); + changed.clear(); + REQUIRE(f.model.setData(f.model.index(2), Qt::Checked, + ModListModel::kCheckStateRole)); + CHECK(changed.isEmpty()); + f.model.setCheckStates({0}, false); + CHECK(f.indices() == QList{-1, 1, 2, -1, -1}); } -TEST_CASE("mod list commits subset order preserving persistent UUID identities") { - ListFixture f; - QPersistentModelIndex persistent(f.model.index(0)); - QSignalSpy about(&f.model,&QAbstractItemModel::layoutAboutToBeChanged); - QSignalSpy changed(&f.model,&QAbstractItemModel::layoutChanged); - f.model.commitOrder({"uuid-2","uuid-0"}); - CHECK(f.order() == QStringList{"uuid-2","uuid-0","uuid-1","uuid-3","uuid-4"}); - CHECK(persistent.row() == 1); - CHECK(persistent.data(ModListModel::kUuidRole).toString() == "uuid-0"); - CHECK(about.size() == 1); - CHECK(changed.size() == 1); - f.model.commitOrder(f.order()); - CHECK(changed.size() == 1); - f.model.commitOrder({"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); - CHECK(f.order() == QStringList{"uuid-4","uuid-3","uuid-1","uuid-0","uuid-2"}); - CHECK(f.model.activeUuids() == QStringList{"uuid-0","uuid-2"}); +TEST_CASE( + "mod list commits subset order preserving persistent UUID identities") { + ListFixture f; + QPersistentModelIndex persistent(f.model.index(0)); + QSignalSpy about(&f.model, &QAbstractItemModel::layoutAboutToBeChanged); + QSignalSpy changed(&f.model, &QAbstractItemModel::layoutChanged); + f.model.commitOrder({"uuid-2", "uuid-0"}); + CHECK(f.order() == + QStringList{"uuid-2", "uuid-0", "uuid-1", "uuid-3", "uuid-4"}); + CHECK(persistent.row() == 1); + CHECK(persistent.data(ModListModel::kUuidRole).toString() == "uuid-0"); + CHECK(about.size() == 1); + CHECK(changed.size() == 1); + f.model.commitOrder(f.order()); + CHECK(changed.size() == 1); + f.model.commitOrder({"uuid-4", "uuid-3", "uuid-1", "uuid-0", "uuid-2"}); + CHECK(f.order() == + QStringList{"uuid-4", "uuid-3", "uuid-1", "uuid-0", "uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-0", "uuid-2"}); } TEST_CASE("mod list move updates load indices and rejects invalid moves") { - ListFixture f; - QSignalSpy changed(&f.model,&QAbstractItemModel::dataChanged); - CHECK(f.indices() == QList{1,2,-1,-1,-1}); - REQUIRE(f.model.moveRow(0,1)); - CHECK(f.order().first() == "uuid-2"); - CHECK(f.indices() == QList{1,2,-1,-1,-1}); - REQUIRE_FALSE(changed.isEmpty()); - CHECK(changed.first()[2].value>().contains(ModListModel::kLoadIndexRole)); - CHECK_FALSE(f.model.moveRow(0,0)); - CHECK_FALSE(f.model.moveRow(-1,1)); - CHECK_FALSE(f.model.moveRow(0,5)); + ListFixture f; + QSignalSpy changed(&f.model, &QAbstractItemModel::dataChanged); + CHECK(f.indices() == QList{1, 2, -1, -1, -1}); + REQUIRE(f.model.moveRow(0, 1)); + CHECK(f.order().first() == "uuid-2"); + CHECK(f.indices() == QList{1, 2, -1, -1, -1}); + REQUIRE_FALSE(changed.isEmpty()); + CHECK(changed.first()[2].value>().contains( + ModListModel::kLoadIndexRole)); + CHECK_FALSE(f.model.moveRow(0, 0)); + CHECK_FALSE(f.model.moveRow(-1, 1)); + CHECK_FALSE(f.model.moveRow(0, 5)); } TEST_CASE("mod list provider labels and QML roles match the reference") { - CHECK(providerLabel("steam") == "Workshop"); - CHECK(providerLabel("downloaded") == "Workshop"); - CHECK(providerLabel("local") == "Local"); - CHECK(providerLabel("core","ludeon.rimworld") == "Core"); - CHECK(providerLabel("core","ludeon.rimworld.royalty") == "DLC"); - CHECK(providerLabel("unknown","ludeon.rimworld.ideology") == "DLC"); - ListFixture f; - const auto roles = f.model.roleNames(); - CHECK(roles.value(ModListModel::kCheckStateRole) == "checkState"); - CHECK(roles.value(ModListModel::kSectionNameRole) == "sectionName"); - CHECK(roles.value(ModListModel::kStartupImpactTextRole) == "startupImpactText"); - CHECK(f.model.data(f.model.index(0),ModListModel::kProviderColorRole).toString() == "#abcdef"); - CHECK(f.model.data(f.model.index(2),ModListModel::kSectionNameRole).toString() == "Inactive (3)"); + CHECK(providerLabel("steam") == "Workshop"); + CHECK(providerLabel("downloaded") == "Workshop"); + CHECK(providerLabel("local") == "Local"); + CHECK(providerLabel("core", "ludeon.rimworld") == "Core"); + CHECK(providerLabel("core", "ludeon.rimworld.royalty") == "DLC"); + CHECK(providerLabel("unknown", "ludeon.rimworld.ideology") == "DLC"); + ListFixture f; + const auto roles = f.model.roleNames(); + CHECK(roles.value(ModListModel::kCheckStateRole) == "checkState"); + CHECK(roles.value(ModListModel::kSectionNameRole) == "sectionName"); + CHECK(roles.value(ModListModel::kStartupImpactTextRole) == + "startupImpactText"); + CHECK(f.model.data(f.model.index(0), ModListModel::kProviderColorRole) + .toString() == "#abcdef"); + CHECK(f.model.data(f.model.index(2), ModListModel::kSectionNameRole) + .toString() == "Inactive (3)"); } -TEST_CASE("mod list startup impacts include presentation roles and empty input is silent") { - ModListModel empty; - QSignalSpy noChanges(&empty,&QAbstractItemModel::dataChanged); - empty.setStartupImpact({{"mod.0",1.5}}); - CHECK(noChanges.isEmpty()); - ListFixture f; - f.model.setStartupImpact({{"mod.0",1.5}}); - CHECK(f.model.data(f.model.index(0),ModListModel::kStartupImpactRole).toDouble() == 1.5); - CHECK(f.model.data(f.model.index(0),ModListModel::kStartupImpactTextRole).toString() == "1.50 s"); - CHECK(f.model.data(f.model.index(1),ModListModel::kStartupImpactRole).toDouble() == 0); - CHECK(formatDuration(0.0001) == "0 ms"); - CHECK(formatDuration(0.5) == "500 ms"); - CHECK(formatDuration(60) == "1m 00s"); - CHECK(impactColor(0.5) == paletteValue(u"SUCCESS")); - CHECK(impactColor(1) == paletteValue(u"WARNING")); - CHECK(impactColor(5) == paletteValue(u"DANGER")); +TEST_CASE( + "mod list startup impacts include presentation roles and empty input is " + "silent") { + ModListModel empty; + QSignalSpy noChanges(&empty, &QAbstractItemModel::dataChanged); + empty.setStartupImpact({{"mod.0", 1.5}}); + CHECK(noChanges.isEmpty()); + ListFixture f; + f.model.setStartupImpact({{"mod.0", 1.5}}); + CHECK(f.model.data(f.model.index(0), ModListModel::kStartupImpactRole) + .toDouble() == 1.5); + CHECK(f.model.data(f.model.index(0), ModListModel::kStartupImpactTextRole) + .toString() == "1.50 s"); + CHECK(f.model.data(f.model.index(1), ModListModel::kStartupImpactRole) + .toDouble() == 0); + CHECK(formatDuration(0.0001) == "0 ms"); + CHECK(formatDuration(0.5) == "500 ms"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(impactColor(0.5) == paletteValue(u"SUCCESS")); + CHECK(impactColor(1) == paletteValue(u"WARNING")); + CHECK(impactColor(5) == paletteValue(u"DANGER")); } diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp index 799181e..ffd6120 100644 --- a/tests/ui/test_mod_list_panel.cpp +++ b/tests/ui/test_mod_list_panel.cpp @@ -1,176 +1,241 @@ -#include #include #include #include #include -#include #include +#include #include #include #include #include #include #include -#include "test_support.hpp" + +#include + +#include "../core/services/services_support.hpp" #include "core/context.hpp" #include "core/services/diagnostics_service.hpp" +#include "test_support.hpp" #include "ui/components/mod_activation.hpp" #include "ui/components/svg_provider.hpp" #include "ui/panels/mod_list_panel.hpp" #include "ui/theme/qml_theme.hpp" -#include "../core/services/services_support.hpp" using px::modrim::ModListPanel; namespace { struct PanelFixture { - std::unique_ptr ctx; - QWidget parent; - QQmlEngine* engine; - std::unique_ptr panel; - PanelFixture(bool active = true, bool dependencies = false) { - ctx = px::ui_test::makeCore(); - QHash mods; - for (int i = 0; i < 3; ++i) { - const auto letter = QChar('a'+i); - auto mod = std::make_shared(); - mod->name = QStringLiteral("Mod %1").arg(QChar('A'+i)); - mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); - mod->providerId = QStringLiteral("stub"); - if (dependencies && i > 0) { - px::modrim::DependencyMod dependency; - dependency.packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(QChar('a'+i-1))); - mod->aboutRules.dependencies.append(dependency); - } - mods.insert(QStringLiteral("uuid-%1").arg(letter),mod); - } - ctx->load(mods,active ? QStringList{"uuid-a","uuid-b","uuid-c"} : QStringList{}); - ctx->diagnosticsService().rebuild(); - engine = px::modrim::createQmlEngine(&parent); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"),new px::modrim::Theme(engine)); - panel = std::make_unique(*ctx,engine,&parent); - panel->loadMods(mods,ctx->activeUuids()); - } - QObject* root() const { return panel->qmlWidget()->rootObject(); } - QObject* view() const { return root() ? root()->findChild(QStringLiteral("listView")) : nullptr; } - void select(int row, const QString& uuid) const { - REQUIRE(root()); - REQUIRE(QMetaObject::invokeMethod(root(),"selectRow",Qt::DirectConnection, - Q_ARG(QVariant,QVariant(row)),Q_ARG(QVariant,QVariant(uuid)),Q_ARG(QVariant,QVariant(0)))); - } - void show() const { - panel->resize(420,320); panel->show(); QTest::qWait(20); - REQUIRE(panel->qmlWidget()->status() == QQuickWidget::Ready); - REQUIRE(root()); REQUIRE(view()); + std::unique_ptr ctx; + QWidget parent; + QQmlEngine* engine; + std::unique_ptr panel; + PanelFixture(bool active = true, bool dependencies = false) { + ctx = px::ui_test::makeCore(); + QHash mods; + for (int i = 0; i < 3; ++i) { + const auto letter = QChar('a' + i); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(QChar('A' + i)); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); + mod->providerId = QStringLiteral("stub"); + if (dependencies && i > 0) { + px::modrim::DependencyMod dependency; + dependency.packageId = px::modrim::CaseInsensitiveStr( + QStringLiteral("mod.%1").arg(QChar('a' + i - 1))); + mod->aboutRules.dependencies.append(dependency); + } + mods.insert(QStringLiteral("uuid-%1").arg(letter), mod); } + ctx->load(mods, active ? QStringList{"uuid-a", "uuid-b", "uuid-c"} + : QStringList{}); + ctx->diagnosticsService().rebuild(); + engine = px::modrim::createQmlEngine(&parent); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + panel = std::make_unique(*ctx, engine, &parent); + panel->loadMods(mods, ctx->activeUuids()); + } + QObject* root() const { return panel->qmlWidget()->rootObject(); } + QObject* view() const { + return root() ? root()->findChild(QStringLiteral("listView")) + : nullptr; + } + void select(int row, const QString& uuid) const { + REQUIRE(root()); + REQUIRE(QMetaObject::invokeMethod(root(), "selectRow", Qt::DirectConnection, + Q_ARG(QVariant, QVariant(row)), + Q_ARG(QVariant, QVariant(uuid)), + Q_ARG(QVariant, QVariant(0)))); + } + void show() const { + panel->resize(420, 320); + panel->show(); + QTest::qWait(20); + REQUIRE(panel->qmlWidget()->status() == QQuickWidget::Ready); + REQUIRE(root()); + REQUIRE(view()); + } }; -QVariant qmlValue(QObject* object,const char* name) { - const auto value = object->property(name); - return value.metaType() == QMetaType::fromType() ? value.value().toVariant() : value; -} -void toggleWithAnswer(PanelFixture& f, const QList& rows, QMessageBox::StandardButton answer, QString* prompt = nullptr) { - QTimer click(&f.parent); click.setInterval(1); - QObject::connect(&click,&QTimer::timeout,&f.parent,[&] { - for (auto* box : f.panel->findChildren()) if (box->isVisible()) { - if (prompt) *prompt = box->text(); - click.stop(); box->button(answer)->click(); return; - } - }); - click.start(); services_test::runAsync(f.panel->toggleRows(rows)); +QVariant qmlValue(QObject* object, const char* name) { + const auto value = object->property(name); + return value.metaType() == QMetaType::fromType() + ? value.value().toVariant() + : value; } +void toggleWithAnswer(PanelFixture& f, const QList& rows, + QMessageBox::StandardButton answer, + QString* prompt = nullptr) { + QTimer click(&f.parent); + click.setInterval(1); + QObject::connect(&click, &QTimer::timeout, &f.parent, [&] { + for (auto* box : f.panel->findChildren()) + if (box->isVisible()) { + if (prompt) *prompt = box->text(); + click.stop(); + box->button(answer)->click(); + return; + } + }); + click.start(); + services_test::runAsync(f.panel->toggleRows(rows)); } -TEST_CASE("disabling dependencies supports selected only all dependents and cancellation") { - SECTION("selected only") { - PanelFixture f(true,true); QString prompt; - toggleWithAnswer(f,{0},QMessageBox::No,&prompt); - CHECK(f.panel->activeUuids() == QStringList{"uuid-b","uuid-c"}); - CHECK(prompt.contains("Mod B (mod.b)")); CHECK(prompt.contains("Mod C (mod.c)")); - } - SECTION("selected plus transitive dependents") { - PanelFixture f(true,true); - toggleWithAnswer(f,{0,1},QMessageBox::Yes); - CHECK(f.panel->activeUuids().isEmpty()); - } - SECTION("cancel") { - PanelFixture f(true,true); - toggleWithAnswer(f,{0},QMessageBox::Cancel); - CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); - } +} // namespace +TEST_CASE( + "disabling dependencies supports selected only all dependents and " + "cancellation") { + SECTION("selected only") { + PanelFixture f(true, true); + QString prompt; + toggleWithAnswer(f, {0}, QMessageBox::No, &prompt); + CHECK(f.panel->activeUuids() == QStringList{"uuid-b", "uuid-c"}); + CHECK(prompt.contains("Mod B (mod.b)")); + CHECK(prompt.contains("Mod C (mod.c)")); + } + SECTION("selected plus transitive dependents") { + PanelFixture f(true, true); + toggleWithAnswer(f, {0, 1}, QMessageBox::Yes); + CHECK(f.panel->activeUuids().isEmpty()); + } + SECTION("cancel") { + PanelFixture f(true, true); + toggleWithAnswer(f, {0}, QMessageBox::Cancel); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-b", "uuid-c"}); + } } -TEST_CASE("disabled mods leave the active block and reenabling restores dependency position") { - PanelFixture f(true,true); - toggleWithAnswer(f,{1},QMessageBox::No); - CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-c"}); - const auto* inactive = f.panel->model()->getItem(2); - REQUIRE(inactive); CHECK(inactive->uuid == "uuid-b"); CHECK_FALSE(inactive->checked); CHECK(inactive->loadIndex == -1); - services_test::runAsync(f.panel->toggleRows({f.panel->rowForUuid("uuid-b")})); - CHECK(f.panel->activeUuids() == QStringList{"uuid-a","uuid-b","uuid-c"}); - CHECK(f.ctx->activeUuids() == f.panel->activeUuids()); +TEST_CASE( + "disabled mods leave the active block and reenabling restores dependency " + "position") { + PanelFixture f(true, true); + toggleWithAnswer(f, {1}, QMessageBox::No); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-c"}); + const auto* inactive = f.panel->model()->getItem(2); + REQUIRE(inactive); + CHECK(inactive->uuid == "uuid-b"); + CHECK_FALSE(inactive->checked); + CHECK(inactive->loadIndex == -1); + services_test::runAsync(f.panel->toggleRows({f.panel->rowForUuid("uuid-b")})); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-b", "uuid-c"}); + CHECK(f.ctx->activeUuids() == f.panel->activeUuids()); } TEST_CASE("hover and tiny drag handle jitter never display the drag proxy") { - PanelFixture f; f.show(); - auto* dragProxy = f.root()->findChild(QStringLiteral("dragProxy")); REQUIRE(dragProxy); - auto* widget = f.panel->qmlWidget(); - QTest::mouseMove(widget,QPoint(4,26)); QTest::mouseMove(widget,QPoint(14,26)); - CHECK_FALSE(dragProxy->property("visible").toBool()); - QTest::mousePress(widget,Qt::LeftButton,Qt::NoModifier,QPoint(20,26)); - QTest::mouseMove(widget,QPoint(21,26)); - CHECK_FALSE(dragProxy->property("visible").toBool()); - QTest::mouseRelease(widget,Qt::LeftButton,Qt::NoModifier,QPoint(21,26)); + PanelFixture f; + f.show(); + auto* dragProxy = f.root()->findChild(QStringLiteral("dragProxy")); + REQUIRE(dragProxy); + auto* widget = f.panel->qmlWidget(); + QTest::mouseMove(widget, QPoint(4, 26)); + QTest::mouseMove(widget, QPoint(14, 26)); + CHECK_FALSE(dragProxy->property("visible").toBool()); + QTest::mousePress(widget, Qt::LeftButton, Qt::NoModifier, QPoint(20, 26)); + QTest::mouseMove(widget, QPoint(21, 26)); + CHECK_FALSE(dragProxy->property("visible").toBool()); + QTest::mouseRelease(widget, Qt::LeftButton, Qt::NoModifier, QPoint(21, 26)); } TEST_CASE("highlights preserve user selection across filter changes") { - PanelFixture f; f.select(0,"uuid-a"); - f.panel->highlightRows({"uuid-c"}); - f.panel->setSearchFilter("Mod"); f.panel->setSearchFilter(""); - CHECK(f.panel->isHighlighted("uuid-c")); CHECK_FALSE(f.panel->isHighlighted("uuid-a")); - REQUIRE(f.view()); - CHECK(qmlValue(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-a")}); - CHECK(f.view()->property("currentUuid").toString() == "uuid-a"); + PanelFixture f; + f.select(0, "uuid-a"); + f.panel->highlightRows({"uuid-c"}); + f.panel->setSearchFilter("Mod"); + f.panel->setSearchFilter(""); + CHECK(f.panel->isHighlighted("uuid-c")); + CHECK_FALSE(f.panel->isHighlighted("uuid-a")); + REQUIRE(f.view()); + CHECK(qmlValue(f.view(), "selectedUuids").toList() == + QVariantList{QStringLiteral("uuid-a")}); + CHECK(f.view()->property("currentUuid").toString() == "uuid-a"); } TEST_CASE("highlight also changes the selected delegate tint") { - PanelFixture f; f.show(); f.select(0,"uuid-a"); QTest::qWait(1); - auto colors = [&] { - QSet result; - auto* content = f.view()->property("contentItem").value(); - REQUIRE(content); - for (auto* item : content->childItems()) if (item->property("color").isValid()) result.insert(item->property("color").value().name(QColor::HexArgb)); - return result; - }; - const auto before = colors(); - f.panel->highlightRows({"uuid-a"}); QTest::qWait(1); - CHECK(colors() != before); + PanelFixture f; + f.show(); + f.select(0, "uuid-a"); + QTest::qWait(1); + auto colors = [&] { + QSet result; + auto* content = f.view()->property("contentItem").value(); + REQUIRE(content); + for (auto* item : content->childItems()) + if (item->property("color").isValid()) + result.insert( + item->property("color").value().name(QColor::HexArgb)); + return result; + }; + const auto before = colors(); + f.panel->highlightRows({"uuid-a"}); + QTest::qWait(1); + CHECK(colors() != before); } -TEST_CASE("QML selection clears when filters hide current UUID and empty state appears") { - PanelFixture f(false); REQUIRE(f.view()); - CHECK(f.view()->property("currentIndex").toInt() == -1); - f.select(2,"uuid-c"); f.panel->setSearchFilter("Mod A"); - CHECK(f.view()->property("count").toInt() == 1); - CHECK(f.view()->property("currentIndex").toInt() == -1); - CHECK(f.view()->property("anchorIndex").toInt() == -1); - CHECK(qmlValue(f.view(),"selectedIndices").toList().isEmpty()); - f.panel->setSearchFilter("not present"); - CHECK(f.view()->property("count").toInt() == 0); - auto* empty = f.root()->findChild(QStringLiteral("emptyStateText")); REQUIRE(empty); - CHECK(empty->property("visible").toBool()); +TEST_CASE( + "QML selection clears when filters hide current UUID and empty state " + "appears") { + PanelFixture f(false); + REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == -1); + f.select(2, "uuid-c"); + f.panel->setSearchFilter("Mod A"); + CHECK(f.view()->property("count").toInt() == 1); + CHECK(f.view()->property("currentIndex").toInt() == -1); + CHECK(f.view()->property("anchorIndex").toInt() == -1); + CHECK(qmlValue(f.view(), "selectedIndices").toList().isEmpty()); + f.panel->setSearchFilter("not present"); + CHECK(f.view()->property("count").toInt() == 0); + auto* empty = f.root()->findChild(QStringLiteral("emptyStateText")); + REQUIRE(empty); + CHECK(empty->property("visible").toBool()); } -TEST_CASE("QML selection follows UUID during a row move and reveal respects filters") { - PanelFixture f; - f.select(2,"uuid-c"); f.panel->moveRow(2,0); - CHECK(f.panel->uuidAt(0) == "uuid-c"); REQUIRE(f.view()); - CHECK(f.view()->property("currentIndex").toInt() == 0); - CHECK(qmlValue(f.view(),"selectedIndices").toList() == QVariantList{0}); - CHECK(qmlValue(f.view(),"selectedUuids").toList() == QVariantList{QStringLiteral("uuid-c")}); - CHECK(f.panel->selectUuid("uuid-b")); - f.panel->setSearchFilter("Mod A"); CHECK_FALSE(f.panel->selectUuid("uuid-b")); +TEST_CASE( + "QML selection follows UUID during a row move and reveal respects " + "filters") { + PanelFixture f; + f.select(2, "uuid-c"); + f.panel->moveRow(2, 0); + CHECK(f.panel->uuidAt(0) == "uuid-c"); + REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == 0); + CHECK(qmlValue(f.view(), "selectedIndices").toList() == QVariantList{0}); + CHECK(qmlValue(f.view(), "selectedUuids").toList() == + QVariantList{QStringLiteral("uuid-c")}); + CHECK(f.panel->selectUuid("uuid-b")); + f.panel->setSearchFilter("Mod A"); + CHECK_FALSE(f.panel->selectUuid("uuid-b")); } -TEST_CASE("search focus disables list keyboard actions and Space remains input text") { - PanelFixture f(false); f.show(); - f.parent.show(); REQUIRE(QTest::qWaitForWindowActive(&f.parent)); - QTest::mouseClick(f.panel->qmlWidget(),Qt::LeftButton,Qt::NoModifier,QPoint(100,26)); - QTest::qWait(1); CHECK(f.root()->property("keyboardActive").toBool()); - f.select(0,"uuid-a"); f.panel->searchInput->setFocus(); QTest::qWait(1); - CHECK_FALSE(f.root()->property("keyboardActive").toBool()); - QTest::keyClick(f.panel->searchInput,Qt::Key_Return); - CHECK(f.panel->activeUuids().isEmpty()); - QTest::keyClick(f.panel->searchInput,Qt::Key_Space); - CHECK(f.panel->searchInput->text() == " "); - CHECK(f.panel->activeUuids().isEmpty()); +TEST_CASE( + "search focus disables list keyboard actions and Space remains input " + "text") { + PanelFixture f(false); + f.show(); + f.parent.show(); + REQUIRE(QTest::qWaitForWindowActive(&f.parent)); + QTest::mouseClick(f.panel->qmlWidget(), Qt::LeftButton, Qt::NoModifier, + QPoint(100, 26)); + QTest::qWait(1); + CHECK(f.root()->property("keyboardActive").toBool()); + f.select(0, "uuid-a"); + f.panel->searchInput->setFocus(); + QTest::qWait(1); + CHECK_FALSE(f.root()->property("keyboardActive").toBool()); + QTest::keyClick(f.panel->searchInput, Qt::Key_Return); + CHECK(f.panel->activeUuids().isEmpty()); + QTest::keyClick(f.panel->searchInput, Qt::Key_Space); + CHECK(f.panel->searchInput->text() == " "); + CHECK(f.panel->activeUuids().isEmpty()); } diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp index cf715c6..37129bf 100644 --- a/tests/ui/test_mod_list_proxy_model.cpp +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -1,129 +1,174 @@ -#include #include #include + +#include + #include "ui/models/mod_list_proxy_model.hpp" using namespace px::modrim; namespace { struct ProxyFixture { - ModListModel source; - ModListProxyModel proxy{&source}; - ProxyFixture() { - QHash mods; - for (int i = 0; i < 5; ++i) { - auto mod = std::make_shared(); - mod->name = QStringLiteral("Mod %1").arg(i); - mod->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); - mods.insert(QStringLiteral("uuid-%1").arg(i),mod); - } - source.loadMods(mods,{"uuid-0","uuid-2"}); + ModListModel source; + ModListProxyModel proxy{&source}; + ProxyFixture() { + QHash mods; + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mods.insert(QStringLiteral("uuid-%1").arg(i), mod); } - QList sourceRows() const { - QList rows; - for (int row = 0; row < proxy.rowCount(); ++row) rows.append(proxy.mapToSource(proxy.index(row)).row()); - return rows; - } - void checkRoundTrip() const { - for (int row = 0; row < proxy.rowCount(); ++row) { - const auto sourceIndex = proxy.mapToSource(proxy.index(row)); - REQUIRE(sourceIndex.isValid()); - CHECK(proxy.mapFromSource(sourceIndex).row() == row); - } + source.loadMods(mods, {"uuid-0", "uuid-2"}); + } + QList sourceRows() const { + QList rows; + for (int row = 0; row < proxy.rowCount(); ++row) + rows.append(proxy.mapToSource(proxy.index(row)).row()); + return rows; + } + void checkRoundTrip() const { + for (int row = 0; row < proxy.rowCount(); ++row) { + const auto sourceIndex = proxy.mapToSource(proxy.index(row)); + REQUIRE(sourceIndex.isValid()); + CHECK(proxy.mapFromSource(sourceIndex).row() == row); } + } }; -} +} // namespace TEST_CASE("mod proxy sidebar filtering maps subset empty and cleared states") { - ProxyFixture f; - CHECK(f.proxy.rowCount() == 5); - f.proxy.setSidebarFilter(QSet{"uuid-0","uuid-2","uuid-4"}); - CHECK(f.sourceRows() == QList{0,1,4}); - f.checkRoundTrip(); - f.proxy.setSidebarFilter(QSet{}); - CHECK(f.proxy.rowCount() == 0); - f.proxy.setSidebarFilter(std::nullopt); - CHECK(f.proxy.rowCount() == 5); + ProxyFixture f; + CHECK(f.proxy.rowCount() == 5); + f.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-2", "uuid-4"}); + CHECK(f.sourceRows() == QList{0, 1, 4}); + f.checkRoundTrip(); + f.proxy.setSidebarFilter(QSet{}); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSidebarFilter(std::nullopt); + CHECK(f.proxy.rowCount() == 5); } -TEST_CASE("mod proxy search normalizes case whitespace package IDs and Unicode") { - ProxyFixture f; - f.proxy.setSearchFilter("Mod 1"); CHECK(f.sourceRows() == QList{2}); - f.proxy.setSearchFilter("mod.3"); CHECK(f.sourceRows() == QList{3}); - f.proxy.setSearchFilter("MOD 2"); CHECK(f.sourceRows() == QList{1}); - f.proxy.setSearchFilter("zzzz"); CHECK(f.proxy.rowCount() == 0); - f.proxy.setSearchFilter(" \t \n "); CHECK(f.proxy.rowCount() == 5); - auto unicode = std::make_shared(); - unicode->name = QStringLiteral("Große Mod"); - unicode->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); - auto padded = std::make_shared(); - padded->name = QStringLiteral(" Padded Mod "); - padded->packageId = px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); - f.source.loadMods({{"u",unicode},{"p",padded}},{"u","p"}); - f.proxy.setSearchFilter(QStringLiteral(" GROßE ")); CHECK(f.sourceRows() == QList{0}); - f.proxy.setSearchFilter(" padded.pkg "); CHECK(f.sourceRows() == QList{1}); +TEST_CASE( + "mod proxy search normalizes case whitespace package IDs and Unicode") { + ProxyFixture f; + f.proxy.setSearchFilter("Mod 1"); + CHECK(f.sourceRows() == QList{2}); + f.proxy.setSearchFilter("mod.3"); + CHECK(f.sourceRows() == QList{3}); + f.proxy.setSearchFilter("MOD 2"); + CHECK(f.sourceRows() == QList{1}); + f.proxy.setSearchFilter("zzzz"); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(" \t \n "); + CHECK(f.proxy.rowCount() == 5); + auto unicode = std::make_shared(); + unicode->name = QStringLiteral("Große Mod"); + unicode->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); + auto padded = std::make_shared(); + padded->name = QStringLiteral(" Padded Mod "); + padded->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); + f.source.loadMods({{"u", unicode}, {"p", padded}}, {"u", "p"}); + f.proxy.setSearchFilter(QStringLiteral(" GROßE ")); + CHECK(f.sourceRows() == QList{0}); + f.proxy.setSearchFilter(" padded.pkg "); + CHECK(f.sourceRows() == QList{1}); } -TEST_CASE("mod proxy combines search and sidebar filters and exposes transitions") { - ProxyFixture f; - QSignalSpy filtered(&f.proxy,&ModListProxyModel::isFilteredChanged); - f.proxy.setSidebarFilter(QSet{"uuid-1","uuid-2","uuid-3"}); - f.proxy.setSearchFilter("Mod 2"); CHECK(f.sourceRows() == QList{1}); - CHECK(f.proxy.isFiltered()); CHECK(filtered.size() == 1); - f.proxy.setSearchFilter("Mod 0"); CHECK(f.proxy.rowCount() == 0); - f.proxy.setSearchFilter(""); CHECK(f.proxy.rowCount() == 3); - CHECK(filtered.size() == 1); - f.proxy.setSidebarFilter(std::nullopt); CHECK_FALSE(f.proxy.isFiltered()); CHECK(filtered.size() == 2); - f.proxy.setSearchFilter("mod"); CHECK(filtered.size() == 3); - f.proxy.setSearchFilter(""); CHECK(filtered.size() == 4); +TEST_CASE( + "mod proxy combines search and sidebar filters and exposes transitions") { + ProxyFixture f; + QSignalSpy filtered(&f.proxy, &ModListProxyModel::isFilteredChanged); + f.proxy.setSidebarFilter(QSet{"uuid-1", "uuid-2", "uuid-3"}); + f.proxy.setSearchFilter("Mod 2"); + CHECK(f.sourceRows() == QList{1}); + CHECK(f.proxy.isFiltered()); + CHECK(filtered.size() == 1); + f.proxy.setSearchFilter("Mod 0"); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(""); + CHECK(f.proxy.rowCount() == 3); + CHECK(filtered.size() == 1); + f.proxy.setSidebarFilter(std::nullopt); + CHECK_FALSE(f.proxy.isFiltered()); + CHECK(filtered.size() == 2); + f.proxy.setSearchFilter("mod"); + CHECK(filtered.size() == 3); + f.proxy.setSearchFilter(""); + CHECK(filtered.size() == 4); } TEST_CASE("mod proxy preserves persistent UUIDs that survive filtering") { - ProxyFixture f; - QPersistentModelIndex kept(f.proxy.index(2)); - QPersistentModelIndex hidden(f.proxy.index(1)); - f.proxy.setSidebarFilter(QSet{"uuid-1","uuid-3"}); - CHECK(kept.isValid()); CHECK(kept.row() == 0); - CHECK(kept.data(ModListModel::kUuidRole).toString() == "uuid-1"); - CHECK_FALSE(hidden.isValid()); - CHECK_FALSE(f.proxy.mapFromSource(f.source.index(0)).isValid()); - f.proxy.setSidebarFilter(std::nullopt); - f.proxy.setSearchFilter("Mod 4"); CHECK(f.sourceRows() == QList{4}); - f.proxy.setSearchFilter(""); f.checkRoundTrip(); + ProxyFixture f; + QPersistentModelIndex kept(f.proxy.index(2)); + QPersistentModelIndex hidden(f.proxy.index(1)); + f.proxy.setSidebarFilter(QSet{"uuid-1", "uuid-3"}); + CHECK(kept.isValid()); + CHECK(kept.row() == 0); + CHECK(kept.data(ModListModel::kUuidRole).toString() == "uuid-1"); + CHECK_FALSE(hidden.isValid()); + CHECK_FALSE(f.proxy.mapFromSource(f.source.index(0)).isValid()); + f.proxy.setSidebarFilter(std::nullopt); + f.proxy.setSearchFilter("Mod 4"); + CHECK(f.sourceRows() == QList{4}); + f.proxy.setSearchFilter(""); + f.checkRoundTrip(); } TEST_CASE("mod proxy moves retain source mappings with and without filters") { - ProxyFixture f; - REQUIRE(f.proxy.moveRow(0,2)); f.checkRoundTrip(); - ProxyFixture filtered; - filtered.proxy.setSidebarFilter(QSet{"uuid-0","uuid-1","uuid-4"}); - REQUIRE(filtered.proxy.moveRow(0,1)); filtered.checkRoundTrip(); - CHECK_FALSE(filtered.proxy.mapFromSource(filtered.source.index(0)).isValid()); + ProxyFixture f; + REQUIRE(f.proxy.moveRow(0, 2)); + f.checkRoundTrip(); + ProxyFixture filtered; + filtered.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-1", "uuid-4"}); + REQUIRE(filtered.proxy.moveRow(0, 1)); + filtered.checkRoundTrip(); + CHECK_FALSE(filtered.proxy.mapFromSource(filtered.source.index(0)).isValid()); } TEST_CASE("mod proxy clamped move stays inside the unfiltered active block") { - ProxyFixture f; - REQUIRE(f.proxy.activeCount() == 2); - REQUIRE(f.proxy.moveRow(0,4,true)); - CHECK(f.proxy.data(f.proxy.index(0),ModListModel::kUuidRole).toString() == "uuid-2"); - CHECK(f.proxy.data(f.proxy.index(1),ModListModel::kUuidRole).toString() == "uuid-0"); - CHECK(f.proxy.data(f.proxy.index(2),ModListModel::kUuidRole).toString() == "uuid-1"); - CHECK_FALSE(f.proxy.moveRow(2,0,true)); - f.proxy.setSearchFilter("mod"); CHECK_FALSE(f.proxy.moveRow(0,1,true)); - f.checkRoundTrip(); + ProxyFixture f; + REQUIRE(f.proxy.activeCount() == 2); + REQUIRE(f.proxy.moveRow(0, 4, true)); + CHECK(f.proxy.data(f.proxy.index(0), ModListModel::kUuidRole).toString() == + "uuid-2"); + CHECK(f.proxy.data(f.proxy.index(1), ModListModel::kUuidRole).toString() == + "uuid-0"); + CHECK(f.proxy.data(f.proxy.index(2), ModListModel::kUuidRole).toString() == + "uuid-1"); + CHECK_FALSE(f.proxy.moveRow(2, 0, true)); + f.proxy.setSearchFilter("mod"); + CHECK_FALSE(f.proxy.moveRow(0, 1, true)); + f.checkRoundTrip(); } TEST_CASE("mod proxy emits data changes only after move completion") { - ProxyFixture f; - f.source.loadMods({{"u0",std::make_shared()}, - {"u1",std::make_shared()}, - {"u2",std::make_shared()}},{"u0","u1","u2"}); - QStringList events; - QObject::connect(&f.proxy,&QAbstractItemModel::rowsAboutToBeMoved,&f.proxy,[&] { events.append("begin"); }); - QObject::connect(&f.proxy,&QAbstractItemModel::rowsMoved,&f.proxy,[&] { events.append("end"); }); - QObject::connect(&f.proxy,&QAbstractItemModel::dataChanged,&f.proxy,[&](const QModelIndex& top,const QModelIndex& bottom) { - events.append(QStringLiteral("data:%1:%2").arg(top.row()).arg(bottom.row())); - }); - REQUIRE(f.proxy.moveRow(0,2)); - CHECK(events == QStringList{"begin","end","data:0:2"}); - for (int row = 0; row < 3; ++row) CHECK(f.proxy.data(f.proxy.index(row),ModListModel::kLoadIndexRole).toInt() == row+1); + ProxyFixture f; + f.source.loadMods({{"u0", std::make_shared()}, + {"u1", std::make_shared()}, + {"u2", std::make_shared()}}, + {"u0", "u1", "u2"}); + QStringList events; + QObject::connect(&f.proxy, &QAbstractItemModel::rowsAboutToBeMoved, &f.proxy, + [&] { events.append("begin"); }); + QObject::connect(&f.proxy, &QAbstractItemModel::rowsMoved, &f.proxy, + [&] { events.append("end"); }); + QObject::connect( + &f.proxy, &QAbstractItemModel::dataChanged, &f.proxy, + [&](const QModelIndex& top, const QModelIndex& bottom) { + events.append( + QStringLiteral("data:%1:%2").arg(top.row()).arg(bottom.row())); + }); + REQUIRE(f.proxy.moveRow(0, 2)); + CHECK(events == QStringList{"begin", "end", "data:0:2"}); + for (int row = 0; row < 3; ++row) + CHECK(f.proxy.data(f.proxy.index(row), ModListModel::kLoadIndexRole) + .toInt() == row + 1); } -TEST_CASE("mod proxy section counts reflect visible rows rather than the whole collection") { - ProxyFixture f; - f.proxy.setSidebarFilter(QSet{"uuid-0","uuid-1"}); - CHECK(f.proxy.activeCount() == 1); - CHECK(f.proxy.data(f.proxy.index(1),ModListModel::kSectionNameRole).toString() == "Inactive (1)"); - f.proxy.setSidebarFilter(QSet{"uuid-1"}); - CHECK(f.proxy.data(f.proxy.index(0),ModListModel::kSectionNameRole).toString().isEmpty()); +TEST_CASE( + "mod proxy section counts reflect visible rows rather than the whole " + "collection") { + ProxyFixture f; + f.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-1"}); + CHECK(f.proxy.activeCount() == 1); + CHECK(f.proxy.data(f.proxy.index(1), ModListModel::kSectionNameRole) + .toString() == "Inactive (1)"); + f.proxy.setSidebarFilter(QSet{"uuid-1"}); + CHECK(f.proxy.data(f.proxy.index(0), ModListModel::kSectionNameRole) + .toString() + .isEmpty()); } diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp index 2feaaa3..981c822 100644 --- a/tests/ui/test_mod_tree_model.cpp +++ b/tests/ui/test_mod_tree_model.cpp @@ -1,8 +1,8 @@ -#include - #include #include +#include + #include "ui/plugins/organizer/filter_model.hpp" #include "ui/plugins/organizer/tree_model.hpp" @@ -13,267 +13,324 @@ using px::modrim::OrganizerFilterModel; namespace { struct OrganizerFixture { - OrganizerState state; - ModsByUuid mods; - QSet active{QStringLiteral("u1")}; + OrganizerState state; + ModsByUuid mods; + QSet active{QStringLiteral("u1")}; - OrganizerFixture() { - state.folders.insert(kRootId, {kRootId, std::nullopt, QStringLiteral("Root")}); - auto add = [&](const QString& uuid, const QString& name, const QString& pid, const QString& provider) { - auto mod = std::make_shared(); - mod->name = name; - mod->packageId = CaseInsensitiveStr(pid); - mod->providerId = provider; - mods.insert(uuid, mod); - }; - add("u1", "One", "a.one", "local"); - add("u2", "Two", "a.two", "steam"); - add("u3", "Loose", "loose.mod", "local"); - } + OrganizerFixture() { + state.folders.insert(kRootId, + {kRootId, std::nullopt, QStringLiteral("Root")}); + auto add = [&](const QString& uuid, const QString& name, const QString& pid, + const QString& provider) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(pid); + mod->providerId = provider; + mods.insert(uuid, mod); + }; + add("u1", "One", "a.one", "local"); + add("u2", "Two", "a.two", "steam"); + add("u3", "Loose", "loose.mod", "local"); + } - void nested() { - state.folders.insert(2, {2, kRootId, QStringLiteral("Top")}); - state.folders.insert(3, {3, 2, QStringLiteral("Sub")}); - state.placements.insert("a.one", 2); - state.placements.insert("a.two", 3); - } + void nested() { + state.folders.insert(2, {2, kRootId, QStringLiteral("Top")}); + state.folders.insert(3, {3, 2, QStringLiteral("Sub")}); + state.placements.insert("a.one", 2); + state.placements.insert("a.two", 3); + } - FolderNode tree(const std::optional& query = std::nullopt) const { - return buildTree(state, mods, active, query); - } + FolderNode tree(const std::optional& query = std::nullopt) const { + return buildTree(state, mods, active, query); + } }; -ModTreeModel::SummaryFor noDiagnostics = [](const QString&) -> std::optional { - return std::nullopt; +ModTreeModel::SummaryFor noDiagnostics = + [](const QString&) -> std::optional { + return std::nullopt; }; -} +} // namespace TEST_CASE("organizer tree has nested folder rows and trailing ungrouped") { - OrganizerFixture f; - f.nested(); - ModTreeModel model(noDiagnostics); - model.setTree(f.tree(), f.mods, {}, false); - REQUIRE(model.rowCount() == 2); - const auto top = model.index(0, 0); - const auto ungrouped = model.index(1, 0); - CHECK(model.data(top).toString() == "Top"); - CHECK(model.data(ungrouped).toString() == "Ungrouped"); - CHECK(model.data(model.index(0, 0, top)).toString() == "Sub"); - CHECK(model.data(model.index(1, 0, top)).toString() == "One"); - CHECK(model.data(model.index(0, 0, ungrouped)).toString() == "Loose"); - CHECK(model.parent(model.index(0, 0, top)) == top); - CHECK(model.parent(model.index(0, 0, ungrouped)) == ungrouped); - CHECK_FALSE(model.parent(top).isValid()); - CHECK(model.data(ungrouped, ModTreeModel::kTotalCountRole).toInt() == 1); - CHECK(model.data(top, ModTreeModel::kTotalCountRole).toInt() == 2); - CHECK(model.nodeAt(top)->folderId() == 2); - CHECK(model.nodeAt(model.indexForUuid("u2"))->folderId() == 3); - CHECK(model.nodeAt(ungrouped)->folderId() == kRootId); - CHECK(model.nodeAt({}) == nullptr); - CHECK_FALSE(model.index(-1, 0).isValid()); - CHECK_FALSE(model.index(0, 1).isValid()); - CHECK_FALSE(model.indexForKey("missing").isValid()); - CHECK_FALSE(model.indexForUuid("missing").isValid()); - CHECK_FALSE(model.data({}).isValid()); - CHECK(model.columnCount() == 1); + OrganizerFixture f; + f.nested(); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + REQUIRE(model.rowCount() == 2); + const auto top = model.index(0, 0); + const auto ungrouped = model.index(1, 0); + CHECK(model.data(top).toString() == "Top"); + CHECK(model.data(ungrouped).toString() == "Ungrouped"); + CHECK(model.data(model.index(0, 0, top)).toString() == "Sub"); + CHECK(model.data(model.index(1, 0, top)).toString() == "One"); + CHECK(model.data(model.index(0, 0, ungrouped)).toString() == "Loose"); + CHECK(model.parent(model.index(0, 0, top)) == top); + CHECK(model.parent(model.index(0, 0, ungrouped)) == ungrouped); + CHECK_FALSE(model.parent(top).isValid()); + CHECK(model.data(ungrouped, ModTreeModel::kTotalCountRole).toInt() == 1); + CHECK(model.data(top, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.nodeAt(top)->folderId() == 2); + CHECK(model.nodeAt(model.indexForUuid("u2"))->folderId() == 3); + CHECK(model.nodeAt(ungrouped)->folderId() == kRootId); + CHECK(model.nodeAt({}) == nullptr); + CHECK_FALSE(model.index(-1, 0).isValid()); + CHECK_FALSE(model.index(0, 1).isValid()); + CHECK_FALSE(model.indexForKey("missing").isValid()); + CHECK_FALSE(model.indexForUuid("missing").isValid()); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.columnCount() == 1); } TEST_CASE("organizer check states retain total counts while filtering") { - OrganizerFixture f; - f.state.folders.insert(2, {2, kRootId, QStringLiteral("Group")}); - f.state.placements.insert("a.one", 2); - f.state.placements.insert("a.two", 2); - ModTreeModel model(noDiagnostics); - model.setTree(f.tree(), f.mods, {}, false); - auto group = model.index(0, 0); - CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == Qt::PartiallyChecked); - CHECK(model.data(model.index(0, 0, group), ModTreeModel::kCheckStateRole).toInt() == Qt::Checked); - CHECK(model.data(model.index(1, 0, group), ModTreeModel::kCheckStateRole).toInt() == Qt::Unchecked); - CHECK(model.data(model.index(1, 0), ModTreeModel::kCheckStateRole).toInt() == Qt::Unchecked); - TreeQuery query; - query.text = QStringLiteral("One"); - model.setTree(f.tree(query), f.mods, {}, true); - group = model.index(0, 0); - CHECK(model.rowCount() == 1); - CHECK(model.filtering()); - CHECK(model.data(group, ModTreeModel::kFilteringRole).toBool()); - CHECK(model.data(group, ModTreeModel::kVisibleCountRole).toInt() == 1); - CHECK(model.data(group, ModTreeModel::kTotalCountRole).toInt() == 2); - CHECK(model.data(group, ModTreeModel::kEnabledCountRole).toInt() == 1); - CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == Qt::PartiallyChecked); - CHECK(model.indexForKey("f:2").isValid()); + OrganizerFixture f; + f.state.folders.insert(2, {2, kRootId, QStringLiteral("Group")}); + f.state.placements.insert("a.one", 2); + f.state.placements.insert("a.two", 2); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + auto group = model.index(0, 0); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == + Qt::PartiallyChecked); + CHECK(model.data(model.index(0, 0, group), ModTreeModel::kCheckStateRole) + .toInt() == Qt::Checked); + CHECK(model.data(model.index(1, 0, group), ModTreeModel::kCheckStateRole) + .toInt() == Qt::Unchecked); + CHECK(model.data(model.index(1, 0), ModTreeModel::kCheckStateRole).toInt() == + Qt::Unchecked); + TreeQuery query; + query.text = QStringLiteral("One"); + model.setTree(f.tree(query), f.mods, {}, true); + group = model.index(0, 0); + CHECK(model.rowCount() == 1); + CHECK(model.filtering()); + CHECK(model.data(group, ModTreeModel::kFilteringRole).toBool()); + CHECK(model.data(group, ModTreeModel::kVisibleCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.data(group, ModTreeModel::kEnabledCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == + Qt::PartiallyChecked); + CHECK(model.indexForKey("f:2").isValid()); } TEST_CASE("organizer issue markers only show on enabled mods") { - OrganizerFixture f; - view::ModDiagnosticsView summary{true, true, QStringLiteral("Error"), QStringLiteral("Incompatible")}; - int calls = 0; - ModTreeModel model([&](const QString&) -> std::optional { ++calls; return summary; }); - model.setTree(f.tree(), f.mods, {}, false); - const auto enabled = model.indexForUuid("u1"); - const auto inactive = model.indexForUuid("u2"); - CHECK(model.parent(enabled) == model.index(0, 0)); - CHECK(model.data(enabled, ModTreeModel::kHasErrorRole).toBool()); - CHECK(model.data(enabled, ModTreeModel::kHasWarningRole).toBool()); - CHECK(model.data(enabled, ModTreeModel::kErrorTooltipRole).toString() == "Error"); - CHECK(model.data(enabled, ModTreeModel::kWarningTooltipRole).toString() == "Incompatible"); - calls = 0; - CHECK_FALSE(model.data(inactive, ModTreeModel::kHasErrorRole).toBool()); - CHECK_FALSE(model.data(inactive, ModTreeModel::kHasWarningRole).toBool()); - CHECK(model.data(inactive, ModTreeModel::kErrorTooltipRole).toString().isEmpty()); - CHECK(model.data(inactive, ModTreeModel::kWarningTooltipRole).toString().isEmpty()); - CHECK(calls == 0); - f.active.insert("u2"); - model.setTree(f.tree(), f.mods, {}, false); - CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kHasWarningRole).toBool()); - QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); - model.refreshDiagnostics(); - REQUIRE(changed.count() == 2); - CHECK(changed.first()[2].value>() == QList{ModTreeModel::kHasErrorRole, - ModTreeModel::kErrorTooltipRole, ModTreeModel::kHasWarningRole, ModTreeModel::kWarningTooltipRole}); + OrganizerFixture f; + view::ModDiagnosticsView summary{true, true, QStringLiteral("Error"), + QStringLiteral("Incompatible")}; + int calls = 0; + ModTreeModel model( + [&](const QString&) -> std::optional { + ++calls; + return summary; + }); + model.setTree(f.tree(), f.mods, {}, false); + const auto enabled = model.indexForUuid("u1"); + const auto inactive = model.indexForUuid("u2"); + CHECK(model.parent(enabled) == model.index(0, 0)); + CHECK(model.data(enabled, ModTreeModel::kHasErrorRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kErrorTooltipRole).toString() == + "Error"); + CHECK(model.data(enabled, ModTreeModel::kWarningTooltipRole).toString() == + "Incompatible"); + calls = 0; + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasErrorRole).toBool()); + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(inactive, ModTreeModel::kErrorTooltipRole) + .toString() + .isEmpty()); + CHECK(model.data(inactive, ModTreeModel::kWarningTooltipRole) + .toString() + .isEmpty()); + CHECK(calls == 0); + f.active.insert("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kHasWarningRole) + .toBool()); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.refreshDiagnostics(); + REQUIRE(changed.count() == 2); + CHECK(changed.first()[2].value>() == + QList{ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, + ModTreeModel::kHasWarningRole, + ModTreeModel::kWarningTooltipRole}); } -TEST_CASE("organizer same-shape updates preserve persistent indexes and selection") { - OrganizerFixture f; - f.nested(); - ModTreeModel model(noDiagnostics); - model.setTree(f.tree(), f.mods, {}, false); - QPersistentModelIndex one(model.indexForUuid("u1")); - model.select({one}); - model.select({model.indexForUuid("u2")}, "extend"); - REQUIRE(model.selectedNodes().size() == 2); - model.select({model.indexForUuid("u2")}, "toggle"); - CHECK_FALSE(model.isSelected(model.indexForUuid("u2"))); - CHECK(model.isSelected(one)); - QSignalSpy reset(&model, &QAbstractItemModel::modelReset); - f.active.insert("u2"); - model.setTree(f.tree(), f.mods, {}, false); - CHECK(reset.isEmpty()); - CHECK(one.isValid()); - CHECK(model.data(one).toString() == "One"); - CHECK(model.data(one, ModTreeModel::kSelectedRole).toBool()); - model.clearSelection(); - CHECK(model.selectedNodes().isEmpty()); - model.select({model.indexForUuid("u3")}); - TreeQuery query; - query.text = QStringLiteral("One"); - model.setTree(f.tree(query), f.mods, {}, true); - CHECK(reset.count() == 1); - CHECK_FALSE(one.isValid()); - CHECK(model.selectedNodes().isEmpty()); +TEST_CASE( + "organizer same-shape updates preserve persistent indexes and selection") { + OrganizerFixture f; + f.nested(); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + QPersistentModelIndex one(model.indexForUuid("u1")); + model.select({one}); + model.select({model.indexForUuid("u2")}, "extend"); + REQUIRE(model.selectedNodes().size() == 2); + model.select({model.indexForUuid("u2")}, "toggle"); + CHECK_FALSE(model.isSelected(model.indexForUuid("u2"))); + CHECK(model.isSelected(one)); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + f.active.insert("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(one.isValid()); + CHECK(model.data(one).toString() == "One"); + CHECK(model.data(one, ModTreeModel::kSelectedRole).toBool()); + model.clearSelection(); + CHECK(model.selectedNodes().isEmpty()); + model.select({model.indexForUuid("u3")}); + TreeQuery query; + query.text = QStringLiteral("One"); + model.setTree(f.tree(query), f.mods, {}, true); + CHECK(reset.count() == 1); + CHECK_FALSE(one.isValid()); + CHECK(model.selectedNodes().isEmpty()); } -TEST_CASE("organizer expansion respects collapsed ancestors except when filtering") { - OrganizerFixture f; - f.nested(); - f.state.folders[2].collapsed = true; - ModTreeModel model(noDiagnostics); - model.setTree(f.tree(), f.mods, {}, false); - auto expanded = model.expandedIndexes(); - REQUIRE(expanded.size() == 1); - CHECK(model.data(expanded.first(), ModTreeModel::kKeyRole).toString() == "ungrouped"); - model.setTree(f.tree(), f.mods, {}, true); - expanded = model.expandedIndexes(); - REQUIRE(expanded.size() == 3); - CHECK(model.data(expanded[0], ModTreeModel::kKeyRole).toString() == "f:2"); - CHECK(model.data(expanded[1], ModTreeModel::kKeyRole).toString() == "f:3"); - CHECK(model.expandedIndexes(model.indexForKey("f:2")).size() == 1); +TEST_CASE( + "organizer expansion respects collapsed ancestors except when filtering") { + OrganizerFixture f; + f.nested(); + f.state.folders[2].collapsed = true; + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + auto expanded = model.expandedIndexes(); + REQUIRE(expanded.size() == 1); + CHECK(model.data(expanded.first(), ModTreeModel::kKeyRole).toString() == + "ungrouped"); + model.setTree(f.tree(), f.mods, {}, true); + expanded = model.expandedIndexes(); + REQUIRE(expanded.size() == 3); + CHECK(model.data(expanded[0], ModTreeModel::kKeyRole).toString() == "f:2"); + CHECK(model.data(expanded[1], ModTreeModel::kKeyRole).toString() == "f:3"); + CHECK(model.expandedIndexes(model.indexForKey("f:2")).size() == 1); } TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { - OrganizerFixture f; - f.nested(); - f.state.tags.insert(7, {7, QStringLiteral("Later"), QStringLiteral("blue")}); - f.state.tags.insert(2, {2, QStringLiteral("First"), QStringLiteral("red")}); - f.state.modTags.insert("a.one", {7, 2, 99}); - auto about = std::dynamic_pointer_cast(f.mods["u1"]); - about->modVersion = QStringLiteral("1.2.3"); - auto tree = f.tree(); - tree.children[0].hasRules = true; - tree.hasRules = true; - ModTreeModel model(noDiagnostics); - model.setTree(tree, f.mods, {{QStringLiteral("local"), QStringLiteral("green")}}, false, f.state.tags); - const auto one = model.indexForUuid("u1"); - CHECK(model.data(one, ModTreeModel::kUuidRole).toString() == "u1"); - CHECK(model.data(one, ModTreeModel::kPackageIdRole).toString() == "a.one"); - CHECK(model.data(one, ModTreeModel::kCanPlaceRole).toBool()); - CHECK(model.data(one, ModTreeModel::kProviderLabelRole).toString() == "Local"); - CHECK(model.data(one, ModTreeModel::kProviderIconRole).toString() == "folder"); - CHECK(model.data(one, ModTreeModel::kProviderColorRole).toString() == "green"); - CHECK(model.data(one, ModTreeModel::kModVersionRole).toString() == "1.2.3"); - CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kProviderIconRole).toString() == "steam"); - const auto tags = model.data(one, ModTreeModel::kTagsRole).toList(); - REQUIRE(tags.size() == 2); - CHECK(tags[0].toMap()["name"].toString() == "First"); - CHECK(tags[1].toMap()["color"].toString() == "blue"); - CHECK(model.data(model.indexForKey("f:2"), ModTreeModel::kHasRuleRole).toBool()); - CHECK_FALSE(model.data(model.indexForKey("ungrouped"), ModTreeModel::kHasRuleRole).toBool()); - CHECK(model.roleNames()[ModTreeModel::kProviderLabelRole] == "providerLabel"); - CHECK(model.roleNames()[ModTreeModel::kTagsRole] == "tags"); + OrganizerFixture f; + f.nested(); + f.state.tags.insert(7, {7, QStringLiteral("Later"), QStringLiteral("blue")}); + f.state.tags.insert(2, {2, QStringLiteral("First"), QStringLiteral("red")}); + f.state.modTags.insert("a.one", {7, 2, 99}); + auto about = std::dynamic_pointer_cast(f.mods["u1"]); + about->modVersion = QStringLiteral("1.2.3"); + auto tree = f.tree(); + tree.children[0].hasRules = true; + tree.hasRules = true; + ModTreeModel model(noDiagnostics); + model.setTree(tree, f.mods, + {{QStringLiteral("local"), QStringLiteral("green")}}, false, + f.state.tags); + const auto one = model.indexForUuid("u1"); + CHECK(model.data(one, ModTreeModel::kUuidRole).toString() == "u1"); + CHECK(model.data(one, ModTreeModel::kPackageIdRole).toString() == "a.one"); + CHECK(model.data(one, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(one, ModTreeModel::kProviderLabelRole).toString() == + "Local"); + CHECK(model.data(one, ModTreeModel::kProviderIconRole).toString() == + "folder"); + CHECK(model.data(one, ModTreeModel::kProviderColorRole).toString() == + "green"); + CHECK(model.data(one, ModTreeModel::kModVersionRole).toString() == "1.2.3"); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kProviderIconRole) + .toString() == "steam"); + const auto tags = model.data(one, ModTreeModel::kTagsRole).toList(); + REQUIRE(tags.size() == 2); + CHECK(tags[0].toMap()["name"].toString() == "First"); + CHECK(tags[1].toMap()["color"].toString() == "blue"); + CHECK(model.data(model.indexForKey("f:2"), ModTreeModel::kHasRuleRole) + .toBool()); + CHECK_FALSE( + model.data(model.indexForKey("ungrouped"), ModTreeModel::kHasRuleRole) + .toBool()); + CHECK(model.roleNames()[ModTreeModel::kProviderLabelRole] == "providerLabel"); + CHECK(model.roleNames()[ModTreeModel::kTagsRole] == "tags"); - auto invalid = std::make_shared(); - invalid->providerId = QStringLiteral("git"); - f.mods.insert("invalid", invalid); - model.setTree(f.tree(), f.mods, {}, false); - const auto idx = model.indexForUuid("invalid"); - REQUIRE(idx.isValid()); - CHECK_FALSE(model.data(idx, ModTreeModel::kCanPlaceRole).toBool()); - CHECK(model.data(idx, ModTreeModel::kPackageIdRole).toString().isEmpty()); - CHECK(model.data(idx, ModTreeModel::kProviderIconRole).toString() == "git"); - CHECK(model.data(idx, ModTreeModel::kModVersionRole).toString().isEmpty()); + auto invalid = std::make_shared(); + invalid->providerId = QStringLiteral("git"); + f.mods.insert("invalid", invalid); + model.setTree(f.tree(), f.mods, {}, false); + const auto idx = model.indexForUuid("invalid"); + REQUIRE(idx.isValid()); + CHECK_FALSE(model.data(idx, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(idx, ModTreeModel::kPackageIdRole).toString().isEmpty()); + CHECK(model.data(idx, ModTreeModel::kProviderIconRole).toString() == "git"); + CHECK(model.data(idx, ModTreeModel::kModVersionRole).toString().isEmpty()); } -TEST_CASE("organizer filter sections icons and fallbacks match sidebar entries") { - OrganizerFilterModel model; - CHECK(model.rowCount() == 0); - CHECK(model.forKey("missing").key == "all"); - CHECK_FALSE(model.keyAt(0)); - QList entries{ - {QStringLiteral("all"), QStringLiteral("All"), 10}, - {QStringLiteral("active"), QStringLiteral("Active"), 6, StatusFilter::kActive}, - {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, StatusFilter::kInactive}, - {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, StatusFilter::kAll, QStringLiteral("steam")}, - {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, StatusFilter::kAll, std::nullopt, 1}, - {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::kAll, std::nullopt, 2}}; - model.update(entries); - REQUIRE(model.rowCount() == 6); - const QStringList sections{"Status", "Status", "Status", "Providers", "Tags", "Tags"}; - const QStringList icons{"grid", "check-circle", "ban", "steam", "tag", "tag"}; - for (int row = 0; row < 6; ++row) { - CHECK(model.data(model.index(row), OrganizerFilterModel::kSectionRole).toString() == sections[row]); - CHECK(model.data(model.index(row), OrganizerFilterModel::kIconRole).toString() == icons[row]); - } - CHECK(model.data(model.index(4), OrganizerFilterModel::kKeyRole).toString() == "tag:1"); - CHECK(model.data(model.index(4)).toString() == "Medieval"); - CHECK(model.data(model.index(4), OrganizerFilterModel::kLabelRole).toString() == "Medieval"); - CHECK(model.data(model.index(4), OrganizerFilterModel::kCountRole).toInt() == 5); - CHECK(model.data(model.index(5), OrganizerFilterModel::kCountRole).toInt() == 0); - CHECK(model.forKey("tag:1").label == "Medieval"); - CHECK(model.forKey("tag:999").key == "all"); - CHECK(model.keyAt(4) == QStringLiteral("tag:1")); - CHECK_FALSE(model.keyAt(-1)); - CHECK_FALSE(model.keyAt(6)); - CHECK(model.rowCount(model.index(0)) == 0); - CHECK_FALSE(model.data({}).isValid()); - CHECK(model.roleNames()[Qt::DisplayRole] == "filterLabel"); - QSignalSpy reset(&model, &QAbstractItemModel::modelReset); - QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); - entries[4].label = QStringLiteral("Medieval Overhaul"); - model.update(entries); - CHECK(reset.isEmpty()); - CHECK(changed.count() == 1); - CHECK(model.data(model.index(4)).toString() == "Medieval Overhaul"); - entries.removeLast(); - model.update(entries); - CHECK(reset.count() == 1); +TEST_CASE( + "organizer filter sections icons and fallbacks match sidebar entries") { + OrganizerFilterModel model; + CHECK(model.rowCount() == 0); + CHECK(model.forKey("missing").key == "all"); + CHECK_FALSE(model.keyAt(0)); + QList entries{ + {QStringLiteral("all"), QStringLiteral("All"), 10}, + {QStringLiteral("active"), QStringLiteral("Active"), 6, + StatusFilter::kActive}, + {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, + StatusFilter::kInactive}, + {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, + StatusFilter::kAll, QStringLiteral("steam")}, + {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, + StatusFilter::kAll, std::nullopt, 1}, + {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::kAll, + std::nullopt, 2}}; + model.update(entries); + REQUIRE(model.rowCount() == 6); + const QStringList sections{"Status", "Status", "Status", + "Providers", "Tags", "Tags"}; + const QStringList icons{"grid", "check-circle", "ban", "steam", "tag", "tag"}; + for (int row = 0; row < 6; ++row) { + CHECK(model.data(model.index(row), OrganizerFilterModel::kSectionRole) + .toString() == sections[row]); + CHECK(model.data(model.index(row), OrganizerFilterModel::kIconRole) + .toString() == icons[row]); + } + CHECK(model.data(model.index(4), OrganizerFilterModel::kKeyRole).toString() == + "tag:1"); + CHECK(model.data(model.index(4)).toString() == "Medieval"); + CHECK( + model.data(model.index(4), OrganizerFilterModel::kLabelRole).toString() == + "Medieval"); + CHECK(model.data(model.index(4), OrganizerFilterModel::kCountRole).toInt() == + 5); + CHECK(model.data(model.index(5), OrganizerFilterModel::kCountRole).toInt() == + 0); + CHECK(model.forKey("tag:1").label == "Medieval"); + CHECK(model.forKey("tag:999").key == "all"); + CHECK(model.keyAt(4) == QStringLiteral("tag:1")); + CHECK_FALSE(model.keyAt(-1)); + CHECK_FALSE(model.keyAt(6)); + CHECK(model.rowCount(model.index(0)) == 0); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.roleNames()[Qt::DisplayRole] == "filterLabel"); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + entries[4].label = QStringLiteral("Medieval Overhaul"); + model.update(entries); + CHECK(reset.isEmpty()); + CHECK(changed.count() == 1); + CHECK(model.data(model.index(4)).toString() == "Medieval Overhaul"); + entries.removeLast(); + model.update(entries); + CHECK(reset.count() == 1); } -TEST_CASE("organizer filter infers sections from keys and unknown providers use folders") { - OrganizerFilterModel model; - model.update({{QStringLiteral("provider:custom"), QStringLiteral("Custom"), 1}, - {QStringLiteral("tag:9"), QStringLiteral("Tag"), 1}}); - CHECK(model.data(model.index(0), OrganizerFilterModel::kSectionRole).toString() == "Providers"); - CHECK(model.data(model.index(0), OrganizerFilterModel::kIconRole).toString() == "folder"); - CHECK(model.data(model.index(1), OrganizerFilterModel::kSectionRole).toString() == "Tags"); - CHECK(model.data(model.index(1), OrganizerFilterModel::kIconRole).toString() == "tag"); +TEST_CASE( + "organizer filter infers sections from keys and unknown providers use " + "folders") { + OrganizerFilterModel model; + model.update( + {{QStringLiteral("provider:custom"), QStringLiteral("Custom"), 1}, + {QStringLiteral("tag:9"), QStringLiteral("Tag"), 1}}); + CHECK(model.data(model.index(0), OrganizerFilterModel::kSectionRole) + .toString() == "Providers"); + CHECK( + model.data(model.index(0), OrganizerFilterModel::kIconRole).toString() == + "folder"); + CHECK(model.data(model.index(1), OrganizerFilterModel::kSectionRole) + .toString() == "Tags"); + CHECK( + model.data(model.index(1), OrganizerFilterModel::kIconRole).toString() == + "tag"); } diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp index b676c33..c840902 100644 --- a/tests/ui/test_mod_updates.cpp +++ b/tests/ui/test_mod_updates.cpp @@ -1,27 +1,35 @@ -#include #include #include #include #include -#include "ui/components/mod_updates.hpp" + +#include + #include "../core/services/services_support.hpp" +#include "ui/components/mod_updates.hpp" TEST_CASE("small updates skip confirmation") { - QWidget parent; - CHECK(services_test::runAsync(px::modrim::confirmUpdate(px::modrim::kConfirmThreshold-1,&parent))); - CHECK(parent.findChildren().isEmpty()); + QWidget parent; + CHECK(services_test::runAsync( + px::modrim::confirmUpdate(px::modrim::kConfirmThreshold - 1, &parent))); + CHECK(parent.findChildren().isEmpty()); } TEST_CASE("large updates follow the actual confirmation dialog answer") { - QWidget parent; - for (const auto answer : {QMessageBox::Yes,QMessageBox::Cancel}) { - QTimer click(&parent); - click.setInterval(1); - QObject::connect(&click,&QTimer::timeout,&parent,[&] { - for (auto* box : parent.findChildren()) if (box->isVisible()) { - click.stop(); box->button(answer)->click(); return; - } - }); - click.start(); - CHECK(services_test::runAsync(px::modrim::confirmUpdate(px::modrim::kConfirmThreshold,&parent)) == (answer == QMessageBox::Yes)); - } + QWidget parent; + for (const auto answer : {QMessageBox::Yes, QMessageBox::Cancel}) { + QTimer click(&parent); + click.setInterval(1); + QObject::connect(&click, &QTimer::timeout, &parent, [&] { + for (auto* box : parent.findChildren()) + if (box->isVisible()) { + click.stop(); + box->button(answer)->click(); + return; + } + }); + click.start(); + CHECK(services_test::runAsync(px::modrim::confirmUpdate( + px::modrim::kConfirmThreshold, &parent)) == + (answer == QMessageBox::Yes)); + } } diff --git a/tests/ui/test_navigation.cpp b/tests/ui/test_navigation.cpp index 7611392..0ca2902 100644 --- a/tests/ui/test_navigation.cpp +++ b/tests/ui/test_navigation.cpp @@ -1,30 +1,34 @@ -#include - #include #include +#include + #include "ui/navigation.hpp" -using px::modrim::Route; using px::modrim::buildRoute; using px::modrim::parseRoute; +using px::modrim::Route; TEST_CASE("route round-trips ids with reserved characters") { - const QString url = buildRoute(QStringLiteral("workshop"), - {QStringLiteral("collection"), QStringLiteral("picked:a/b")}); - CHECK(url == QStringLiteral("modrim://workshop/collection/picked%3Aa%2Fb")); - CHECK(parseRoute(url) == Route{QStringLiteral("workshop"), - {QStringLiteral("collection"), QStringLiteral("picked:a/b")}}); + const QString url = + buildRoute(QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}); + CHECK(url == QStringLiteral("modrim://workshop/collection/picked%3Aa%2Fb")); + CHECK(parseRoute(url) == + Route{QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}}); } TEST_CASE("view-only route has an empty path") { - CHECK(parseRoute(QStringLiteral("modrim://downloads")) == Route{QStringLiteral("downloads"), {}}); - CHECK(parseRoute(QStringLiteral("modrim://downloads/")) == Route{QStringLiteral("downloads"), {}}); + CHECK(parseRoute(QStringLiteral("modrim://downloads")) == + Route{QStringLiteral("downloads"), {}}); + CHECK(parseRoute(QStringLiteral("modrim://downloads/")) == + Route{QStringLiteral("downloads"), {}}); } TEST_CASE("non-routes are rejected") { - for (const char* url : {"downloads", "https://downloads", "modrim://", ""}) { - CAPTURE(url); - CHECK_FALSE(parseRoute(QString::fromUtf8(url)).has_value()); - } + for (const char* url : {"downloads", "https://downloads", "modrim://", ""}) { + CAPTURE(url); + CHECK_FALSE(parseRoute(QString::fromUtf8(url)).has_value()); + } } diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp index c855df5..6688b84 100644 --- a/tests/ui/test_organizer_dialogs.cpp +++ b/tests/ui/test_organizer_dialogs.cpp @@ -1,11 +1,11 @@ -#include - #include #include #include #include #include +#include + #include "ui/plugins/organizer/dialogs.hpp" using px::modrim::DeleteFolderDialog; @@ -13,17 +13,19 @@ using px::modrim::DeleteFolderDialog; namespace { // The shared QApplication is created by tests/ui/main.cpp. -QApplication& widgetApp() { return *static_cast(QApplication::instance()); } +QApplication& widgetApp() { + return *static_cast(QApplication::instance()); +} } // namespace TEST_CASE("delete folder dialog confirms with yes button") { - widgetApp(); - QWidget parent; - DeleteFolderDialog dialog(QStringLiteral("Fixes"), &parent); - auto* yes = qobject_cast(dialog.button(QMessageBox::Yes)); - REQUIRE(yes != nullptr); - CHECK(yes->text() == QStringLiteral("Delete")); - yes->click(); - CHECK(dialog.result() == static_cast(QMessageBox::Yes)); + widgetApp(); + QWidget parent; + DeleteFolderDialog dialog(QStringLiteral("Fixes"), &parent); + auto* yes = qobject_cast(dialog.button(QMessageBox::Yes)); + REQUIRE(yes != nullptr); + CHECK(yes->text() == QStringLiteral("Delete")); + yes->click(); + CHECK(dialog.result() == static_cast(QMessageBox::Yes)); } diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp index c603b2a..b712c4c 100644 --- a/tests/ui/test_organizer_rule_editor.cpp +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -1,7 +1,3 @@ -#include - -#include -#include #include #include #include @@ -11,16 +7,20 @@ #include #include #include +#include +#include -#include "test_support.hpp" +#include + +#include "../core/organizer/test_util.hpp" #include "core/context.hpp" #include "core/organizer/defaults.hpp" +#include "test_support.hpp" #include "ui/components/svg_provider.hpp" #include "ui/context.hpp" #include "ui/plugins/organizer/tree_model.hpp" #include "ui/plugins/organizer/view.hpp" #include "ui/theme/qml_theme.hpp" -#include "../core/organizer/test_util.hpp" namespace { using namespace px::modrim; @@ -31,325 +31,399 @@ using organizer_test::wait; // UI tests may share a QApplication supplied by their test executable. class Application { -public: - Application() { - if (!QCoreApplication::instance()) m_app = std::make_unique(m_argc, m_argv); - REQUIRE(qobject_cast(QCoreApplication::instance())); - } -private: - int m_argc = 1; - char m_name[16] = "organizer-test"; - char* m_argv[2] = {m_name, nullptr}; - std::unique_ptr m_app; + public: + Application() { + if (!QCoreApplication::instance()) + m_app = std::make_unique(m_argc, m_argv); + REQUIRE(qobject_cast(QCoreApplication::instance())); + } + + private: + int m_argc = 1; + char m_name[16] = "organizer-test"; + char* m_argv[2] = {m_name, nullptr}; + std::unique_ptr m_app; }; -template +template void until(Predicate predicate) { - QElapsedTimer timer; - timer.start(); - while (!predicate() && timer.elapsed() < 5000) QTest::qWait(10); - REQUIRE(predicate()); + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < 5000) QTest::qWait(10); + REQUIRE(predicate()); } QQuickItem* item(QQuickItem* root, const QString& name) { - if (root->objectName() == name) return root; - for (auto* child : root->childItems()) { - if (auto* found = item(child, name)) return found; - } - return nullptr; + if (root->objectName() == name) return root; + for (auto* child : root->childItems()) { + if (auto* found = item(child, name)) return found; + } + return nullptr; } struct Harness { - Application application; - QTemporaryDir dir; - std::unique_ptr ctx = px::ui_test::makeCore(AppConfig{}); - OrganizerDb db{dir.filePath(QStringLiteral("organizer.db"))}; - OrganizerService* service = nullptr; - std::unique_ptr engine{createQmlEngine(QCoreApplication::instance())}; - AppContext app{*ctx}; - QWidget parent; - std::unique_ptr view; - QQuickItem* editor = nullptr; + Application application; + QTemporaryDir dir; + std::unique_ptr ctx = px::ui_test::makeCore(AppConfig{}); + OrganizerDb db{dir.filePath(QStringLiteral("organizer.db"))}; + OrganizerService* service = nullptr; + std::unique_ptr engine{ + createQmlEngine(QCoreApplication::instance())}; + AppContext app{*ctx}; + QWidget parent; + std::unique_ptr view; + QQuickItem* editor = nullptr; - Harness() { - REQUIRE(dir.isValid()); - auto mod = std::make_shared(); - mod->name = QStringLiteral("Example"); - mod->providerId = QStringLiteral("local"); - mod->packageId = CaseInsensitiveStr(QStringView(u"example.mod")); - ctx->load({{QStringLiteral("example"), mod}}, {}); - auto plugin = std::make_unique(*ctx, db); - service = plugin.get(); - ctx->registerPlugin(std::move(plugin)); - wait(service->init()); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new Theme(engine.get())); - view = std::make_unique(*ctx, engine.get(), &parent, app); - parent.resize(920, 700); - view->resize(parent.size()); - parent.show(); - view->show(); - view->rebuild(); - } + Harness() { + REQUIRE(dir.isValid()); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Example"); + mod->providerId = QStringLiteral("local"); + mod->packageId = CaseInsensitiveStr(QStringView(u"example.mod")); + ctx->load({{QStringLiteral("example"), mod}}, {}); + auto plugin = std::make_unique(*ctx, db); + service = plugin.get(); + ctx->registerPlugin(std::move(plugin)); + wait(service->init()); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new Theme(engine.get())); + view = + std::make_unique(*ctx, engine.get(), &parent, app); + parent.resize(920, 700); + view->resize(parent.size()); + parent.show(); + view->show(); + view->rebuild(); + } - ~Harness() { - view.reset(); - wait(service->shutdown()); - } + ~Harness() { + view.reset(); + wait(service->shutdown()); + } - QJSValue call(const QString& expression) { - engine->globalObject().setProperty(QStringLiteral("editor"), engine->newQObject(editor)); - auto result = engine->evaluate(QStringLiteral("editor.") + expression); - INFO(result.toString().toStdString()); - REQUIRE_FALSE(result.isError()); - return result; - } + QJSValue call(const QString& expression) { + engine->globalObject().setProperty(QStringLiteral("editor"), + engine->newQObject(editor)); + auto result = engine->evaluate(QStringLiteral("editor.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + return result; + } - void open(int count) { - view->openRules(); - auto* quick = view->findChild(QStringLiteral("organizerRuleEditorQuickWidget")); - REQUIRE(quick); - until([&] { return quick->status() != QQuickWidget::Loading; }); - INFO((quick->errors().isEmpty() ? std::string{} : quick->errors().first().toString().toStdString())); - REQUIRE(quick->status() == QQuickWidget::Ready); - editor = quick->rootObject(); - REQUIRE(editor); - until([&] { return editor->property("opened").toBool() && draft().size() == count; }); - } + void open(int count) { + view->openRules(); + auto* quick = view->findChild( + QStringLiteral("organizerRuleEditorQuickWidget")); + REQUIRE(quick); + until([&] { return quick->status() != QQuickWidget::Loading; }); + INFO((quick->errors().isEmpty() + ? std::string{} + : quick->errors().first().toString().toStdString())); + REQUIRE(quick->status() == QQuickWidget::Ready); + editor = quick->rootObject(); + REQUIRE(editor); + until([&] { + return editor->property("opened").toBool() && draft().size() == count; + }); + } - QVariantList draft() { return call(QStringLiteral("draftRules()")).toVariant().toList(); } + QVariantList draft() { + return call(QStringLiteral("draftRules()")).toVariant().toList(); + } - void pattern(int index, const QString& value) { - REQUIRE(QMetaObject::invokeMethod(editor, "setRulePattern", Q_ARG(QVariant, index), Q_ARG(QVariant, value))); - } + void pattern(int index, const QString& value) { + REQUIRE(QMetaObject::invokeMethod(editor, "setRulePattern", + Q_ARG(QVariant, index), + Q_ARG(QVariant, value))); + } - QQuickItem* row(int index) { - QQuickItem* result = nullptr; - until([&] { - auto visit = [&](auto&& self, QQuickItem* node) -> void { - if (node->objectName() == QStringLiteral("organizerRuleRow") && node->property("index").toInt() == index) result = node; - for (auto* child : node->childItems()) self(self, child); - }; - visit(visit, editor); - return result != nullptr; - }); - return result; - } + QQuickItem* row(int index) { + QQuickItem* result = nullptr; + until([&] { + auto visit = [&](auto&& self, QQuickItem* node) -> void { + if (node->objectName() == QStringLiteral("organizerRuleRow") && + node->property("index").toInt() == index) + result = node; + for (auto* child : node->childItems()) self(self, child); + }; + visit(visit, editor); + return result != nullptr; + }); + return result; + } - void choose(int index, const QString& name, int choice) { - auto* control = item(row(index), name); - REQUIRE(control); - REQUIRE(control->setProperty("currentIndex", choice)); - REQUIRE(QMetaObject::invokeMethod(control, "activated", Q_ARG(int, choice))); - } + void choose(int index, const QString& name, int choice) { + auto* control = item(row(index), name); + REQUIRE(control); + REQUIRE(control->setProperty("currentIndex", choice)); + REQUIRE( + QMetaObject::invokeMethod(control, "activated", Q_ARG(int, choice))); + } - void save() { - call(QStringLiteral("submitRules()")); - until([&] { return !editor->property("opened").toBool(); }); - } + void save() { + call(QStringLiteral("submitRules()")); + until([&] { return !editor->property("opened").toBool(); }); + } }; -void checkRule(const Rule& rule, RuleOp op, const QString& pattern, qint64 folder) { - CHECK(rule.field == RuleField::kPackageId); - CHECK(rule.op == op); - CHECK(rule.pattern == pattern); - CHECK(rule.folderId == folder); +void checkRule(const Rule& rule, RuleOp op, const QString& pattern, + qint64 folder) { + CHECK(rule.field == RuleField::kPackageId); + CHECK(rule.op == op); + CHECK(rule.pattern == pattern); + CHECK(rule.folderId == folder); } -} // namespace +} // namespace -TEST_CASE("organizer rule editor preserves order and retains an invalid draft", "[ui][organizer]") { - Harness h; - const auto first = expect(wait(h.service->createFolder(QStringLiteral("First")))); - const auto second = expect(wait(h.service->createFolder(QStringLiteral("Second")))); - h.view->rebuild(); - h.open(0); - h.call(QStringLiteral("addRule()")); - h.pattern(0, QStringLiteral("example")); - h.save(); - auto state = expect(wait(h.db.load())); - REQUIRE(state.rules.size() == 1); - checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folderModUuids(first.id)) == QStringList{QStringLiteral("example")}); +TEST_CASE("organizer rule editor preserves order and retains an invalid draft", + "[ui][organizer]") { + Harness h; + const auto first = + expect(wait(h.service->createFolder(QStringLiteral("First")))); + const auto second = + expect(wait(h.service->createFolder(QStringLiteral("Second")))); + h.view->rebuild(); + h.open(0); + h.call(QStringLiteral("addRule()")); + h.pattern(0, QStringLiteral("example")); + h.save(); + auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == + QStringList{QStringLiteral("example")}); - h.open(1); - h.call(QStringLiteral("addRule()")); - h.pattern(1, QStringLiteral("example.mod")); - h.choose(1, QStringLiteral("organizerRuleOperator"), 2); - const auto folders = h.view->ruleFolders(); - const auto target = std::find_if(folders.begin(), folders.end(), [&](const QVariant& value) { - return value.toMap().value(QStringLiteral("id")).toLongLong() == second.id; - }); - REQUIRE(target != folders.end()); - h.choose(1, QStringLiteral("organizerRuleFolder"), static_cast(target - folders.begin())); - h.call(QStringLiteral("moveUp(1)")); - REQUIRE(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral("example.mod")); - h.save(); - state = expect(wait(h.db.load())); - REQUIRE(state.rules.size() == 2); - checkRule(state.rules[0], RuleOp::kEquals, QStringLiteral("example.mod"), second.id); - checkRule(state.rules[1], RuleOp::kPrefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folderModUuids(second.id)) == QStringList{QStringLiteral("example")}); + h.open(1); + h.call(QStringLiteral("addRule()")); + h.pattern(1, QStringLiteral("example.mod")); + h.choose(1, QStringLiteral("organizerRuleOperator"), 2); + const auto folders = h.view->ruleFolders(); + const auto target = + std::find_if(folders.begin(), folders.end(), [&](const QVariant& value) { + return value.toMap().value(QStringLiteral("id")).toLongLong() == + second.id; + }); + REQUIRE(target != folders.end()); + h.choose(1, QStringLiteral("organizerRuleFolder"), + static_cast(target - folders.begin())); + h.call(QStringLiteral("moveUp(1)")); + REQUIRE(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral("example.mod")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 2); + checkRule(state.rules[0], RuleOp::kEquals, QStringLiteral("example.mod"), + second.id); + checkRule(state.rules[1], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(second.id)) == + QStringList{QStringLiteral("example")}); - const auto saved = state.rules; - h.open(2); - h.pattern(0, QStringLiteral(" ")); - h.call(QStringLiteral("submitRules()")); - until([&] { return h.editor->property("errorMessage").toString().contains(QStringLiteral("pattern cannot be empty")); }); - CHECK(h.editor->property("opened").toBool()); - CHECK(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral(" ")); - CHECK(h.draft()[1].toMap().value(QStringLiteral("pattern")).toString() == QStringLiteral("example")); - CHECK(expect(wait(h.db.load())).rules == saved); - h.call(QStringLiteral("removeRule(0)")); - h.save(); - state = expect(wait(h.db.load())); - REQUIRE(state.rules.size() == 1); - checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), first.id); - CHECK(expect(h.service->folderModUuids(first.id)) == QStringList{QStringLiteral("example")}); + const auto saved = state.rules; + h.open(2); + h.pattern(0, QStringLiteral(" ")); + h.call(QStringLiteral("submitRules()")); + until([&] { + return h.editor->property("errorMessage") + .toString() + .contains(QStringLiteral("pattern cannot be empty")); + }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral(" ")); + CHECK(h.draft()[1].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral("example")); + CHECK(expect(wait(h.db.load())).rules == saved); + h.call(QStringLiteral("removeRule(0)")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == + QStringList{QStringLiteral("example")}); } -TEST_CASE("organizer standard rules refresh only a clean editor draft", "[ui][organizer]") { - Harness h; - expect(wait(h.service->createFolder(QStringLiteral("Mine")))); - h.view->rebuild(); - h.open(0); - auto* standard = item(h.editor, QStringLiteral("organizerAddStandardRules")); - REQUIRE(standard); - REQUIRE(standard->property("enabled").toBool()); - REQUIRE(QMetaObject::invokeMethod(standard, "clicked")); - until([&] { return h.draft().size() == standardRules().size(); }); - CHECK(h.editor->property("opened").toBool()); - CHECK(h.editor->property("errorMessage").toString().isEmpty()); - CHECK_FALSE(h.editor->property("draftDirty").toBool()); - const auto state = expect(wait(h.db.load())); - REQUIRE(state.rules.size() == standardRules().size()); - for (qsizetype i = 0; i < state.rules.size(); ++i) { - const auto& actual = state.rules[i]; - const auto& expected = standardRules()[i]; - CHECK(state.folders.value(actual.folderId).name == expected.folder); - CHECK(actual.field == expected.field); - CHECK(actual.op == expected.op); - CHECK(actual.pattern == expected.pattern); - CHECK(h.draft()[i].toMap().value(QStringLiteral("pattern")).toString() == expected.pattern); - } - REQUIRE(standard->property("enabled").toBool()); - h.pattern(0, QStringLiteral("edited")); - until([&] { return !standard->property("enabled").toBool(); }); - CHECK(h.editor->property("draftDirty").toBool()); - CHECK(expect(wait(h.db.load())).rules == state.rules); +TEST_CASE("organizer standard rules refresh only a clean editor draft", + "[ui][organizer]") { + Harness h; + expect(wait(h.service->createFolder(QStringLiteral("Mine")))); + h.view->rebuild(); + h.open(0); + auto* standard = item(h.editor, QStringLiteral("organizerAddStandardRules")); + REQUIRE(standard); + REQUIRE(standard->property("enabled").toBool()); + REQUIRE(QMetaObject::invokeMethod(standard, "clicked")); + until([&] { return h.draft().size() == standardRules().size(); }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.editor->property("errorMessage").toString().isEmpty()); + CHECK_FALSE(h.editor->property("draftDirty").toBool()); + const auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == standardRules().size()); + for (qsizetype i = 0; i < state.rules.size(); ++i) { + const auto& actual = state.rules[i]; + const auto& expected = standardRules()[i]; + CHECK(state.folders.value(actual.folderId).name == expected.folder); + CHECK(actual.field == expected.field); + CHECK(actual.op == expected.op); + CHECK(actual.pattern == expected.pattern); + CHECK(h.draft()[i].toMap().value(QStringLiteral("pattern")).toString() == + expected.pattern); + } + REQUIRE(standard->property("enabled").toBool()); + h.pattern(0, QStringLiteral("edited")); + until([&] { return !standard->property("enabled").toBool(); }); + CHECK(h.editor->property("draftDirty").toBool()); + CHECK(expect(wait(h.db.load())).rules == state.rules); } -TEST_CASE("organizer tag actions persist assignments and validated edits", "[ui][organizer]") { - Harness h; - auto second = std::make_shared(); - second->name = QStringLiteral("Second"); - second->providerId = QStringLiteral("local"); - second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); - auto mods = h.ctx->allMods(); - mods.insert(QStringLiteral("second"), second); - h.ctx->load(std::move(mods), {}); - h.view->rebuild(); - auto* model = h.view->findChild(); - REQUIRE(model); - const auto select = [&](bool both) { - const auto first = model->indexForUuid(QStringLiteral("example")); - REQUIRE(first.isValid()); - h.view->selectSingle(first); - if (both) { - const auto other = model->indexForUuid(QStringLiteral("second")); - REQUIRE(other.isValid()); - h.view->selectRow(other, static_cast(Qt::ControlModifier), {}); - } - }; - // Invoke through QML so QmlTask-returning actions follow the real call path. - h.engine->globalObject().setProperty(QStringLiteral("panel"), h.engine->newQObject(h.view.get())); - const auto action = [&](const QString& expression) { - const auto result = h.engine->evaluate(QStringLiteral("panel.") + expression); - INFO(result.toString().toStdString()); - REQUIRE_FALSE(result.isError()); - }; - select(false); - action(QStringLiteral("createTag('Favorites', 'not-a-color')")); - until([&] { return h.view->editorError().contains(QStringLiteral("valid color")); }); - CHECK(h.service->state().tags.isEmpty()); - action(QStringLiteral("createTag('Favorites', '#3399aa')")); - until([&] { return h.service->state().tags.size() == 1; }); - const auto id = h.service->state().tags.constBegin().key(); - CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); - CHECK(h.service->state().tags.value(id).color == QStringLiteral("#3399aa")); - CHECK(h.view->editorError().isEmpty()); - const auto assign = [&](bool add) { - action(QStringLiteral("assignTag(%1, %2)").arg(id).arg(add ? QStringLiteral("true") : QStringLiteral("false"))); - }; - const auto checkState = [&] { - const auto rows = h.view->tagRows(); - REQUIRE(rows.size() == 1); - return rows[0].toMap().value(QStringLiteral("checkState")).toInt(); - }; - CHECK(checkState() == 0); - assign(true); - until([&] { return h.service->state().modTags.value(QStringLiteral("example.mod")).contains(id); }); - CHECK(checkState() == 1); - select(true); - CHECK(checkState() == 2); - assign(true); - until([&] { return h.service->state().modTags.value(QStringLiteral("second.mod")).contains(id); }); - CHECK(checkState() == 1); - auto persisted = expect(wait(h.db.load())); - CHECK(persisted.modTags.value(QStringLiteral("example.mod")) == QSet{id}); - CHECK(persisted.modTags.value(QStringLiteral("second.mod")) == QSet{id}); - assign(false); - until([&] { - return h.service->state().modTags.value(QStringLiteral("example.mod")).isEmpty() - && h.service->state().modTags.value(QStringLiteral("second.mod")).isEmpty(); - }); - CHECK(checkState() == 0); - action(QStringLiteral("updateTag(%1, 'Best', 'bad')").arg(id)); - until([&] { return h.view->editorError().contains(QStringLiteral("valid color")); }); - CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); - CHECK(expect(wait(h.db.load())).tags.value(id).color == QStringLiteral("#3399aa")); - action(QStringLiteral("updateTag(%1, 'Best', '#123456')").arg(id)); - until([&] { return h.service->state().tags.value(id).name == QStringLiteral("Best"); }); - CHECK(h.view->editorError().isEmpty()); - persisted = expect(wait(h.db.load())); - CHECK(persisted.tags.value(id).name == QStringLiteral("Best")); - CHECK(persisted.tags.value(id).color == QStringLiteral("#123456")); - CHECK(persisted.modTags.value(QStringLiteral("example.mod")).isEmpty()); - CHECK(persisted.modTags.value(QStringLiteral("second.mod")).isEmpty()); - action(QStringLiteral("deleteTag(%1)").arg(id)); - until([&] { return h.service->state().tags.isEmpty(); }); - CHECK(expect(wait(h.db.load())).tags.isEmpty()); +TEST_CASE("organizer tag actions persist assignments and validated edits", + "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + const auto select = [&](bool both) { + const auto first = model->indexForUuid(QStringLiteral("example")); + REQUIRE(first.isValid()); + h.view->selectSingle(first); + if (both) { + const auto other = model->indexForUuid(QStringLiteral("second")); + REQUIRE(other.isValid()); + h.view->selectRow(other, static_cast(Qt::ControlModifier), {}); + } + }; + // Invoke through QML so QmlTask-returning actions follow the real call path. + h.engine->globalObject().setProperty(QStringLiteral("panel"), + h.engine->newQObject(h.view.get())); + const auto action = [&](const QString& expression) { + const auto result = + h.engine->evaluate(QStringLiteral("panel.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + }; + select(false); + action(QStringLiteral("createTag('Favorites', 'not-a-color')")); + until([&] { + return h.view->editorError().contains(QStringLiteral("valid color")); + }); + CHECK(h.service->state().tags.isEmpty()); + action(QStringLiteral("createTag('Favorites', '#3399aa')")); + until([&] { return h.service->state().tags.size() == 1; }); + const auto id = h.service->state().tags.constBegin().key(); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(h.service->state().tags.value(id).color == QStringLiteral("#3399aa")); + CHECK(h.view->editorError().isEmpty()); + const auto assign = [&](bool add) { + action(QStringLiteral("assignTag(%1, %2)") + .arg(id) + .arg(add ? QStringLiteral("true") : QStringLiteral("false"))); + }; + const auto checkState = [&] { + const auto rows = h.view->tagRows(); + REQUIRE(rows.size() == 1); + return rows[0].toMap().value(QStringLiteral("checkState")).toInt(); + }; + CHECK(checkState() == 0); + assign(true); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("example.mod")) + .contains(id); + }); + CHECK(checkState() == 1); + select(true); + CHECK(checkState() == 2); + assign(true); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("second.mod")) + .contains(id); + }); + CHECK(checkState() == 1); + auto persisted = expect(wait(h.db.load())); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")) == + QSet{id}); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")) == + QSet{id}); + assign(false); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("example.mod")) + .isEmpty() && + h.service->state() + .modTags.value(QStringLiteral("second.mod")) + .isEmpty(); + }); + CHECK(checkState() == 0); + action(QStringLiteral("updateTag(%1, 'Best', 'bad')").arg(id)); + until([&] { + return h.view->editorError().contains(QStringLiteral("valid color")); + }); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(expect(wait(h.db.load())).tags.value(id).color == + QStringLiteral("#3399aa")); + action(QStringLiteral("updateTag(%1, 'Best', '#123456')").arg(id)); + until([&] { + return h.service->state().tags.value(id).name == QStringLiteral("Best"); + }); + CHECK(h.view->editorError().isEmpty()); + persisted = expect(wait(h.db.load())); + CHECK(persisted.tags.value(id).name == QStringLiteral("Best")); + CHECK(persisted.tags.value(id).color == QStringLiteral("#123456")); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")).isEmpty()); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")).isEmpty()); + action(QStringLiteral("deleteTag(%1)").arg(id)); + until([&] { return h.service->state().tags.isEmpty(); }); + CHECK(expect(wait(h.db.load())).tags.isEmpty()); } -TEST_CASE("organizer folder checkbox toggles descendants with one active-state change", "[ui][organizer]") { - Harness h; - auto second = std::make_shared(); - second->name = QStringLiteral("Second"); - second->providerId = QStringLiteral("local"); - second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); - auto mods = h.ctx->allMods(); - mods.insert(QStringLiteral("second"), second); - h.ctx->load(std::move(mods), {}); - const auto parent = expect(wait(h.service->createFolder(QStringLiteral("Parent")))); - const auto child = expect(wait(h.service->createFolder(QStringLiteral("Child"), parent.id))); - expect(wait(h.service->place({QStringLiteral("example.mod")}, child.id))); - expect(wait(h.service->place({QStringLiteral("second.mod")}, parent.id))); - h.view->rebuild(); - auto* model = h.view->findChild(); - REQUIRE(model); - int changes = 0; - const auto connection = h.ctx->activeStateChanged().connect([&](const QStringList&) { ++changes; }); - const auto toggle = [&] { - const auto index = model->indexForKey(QStringLiteral("f:%1").arg(parent.id)); - REQUIRE(index.isValid()); - h.view->toggleCheck(index); - }; - toggle(); - until([&] { return h.ctx->activeUuids().size() == 2; }); - CHECK(h.ctx->activeUuids().contains(QStringLiteral("example"))); - CHECK(h.ctx->activeUuids().contains(QStringLiteral("second"))); - CHECK(changes == 1); - h.view->rebuild(); - toggle(); - until([&] { return h.ctx->activeUuids().isEmpty(); }); - CHECK(changes == 2); - h.ctx->activeStateChanged().disconnect(connection); +TEST_CASE( + "organizer folder checkbox toggles descendants with one active-state " + "change", + "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + const auto parent = + expect(wait(h.service->createFolder(QStringLiteral("Parent")))); + const auto child = + expect(wait(h.service->createFolder(QStringLiteral("Child"), parent.id))); + expect(wait(h.service->place({QStringLiteral("example.mod")}, child.id))); + expect(wait(h.service->place({QStringLiteral("second.mod")}, parent.id))); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + int changes = 0; + const auto connection = h.ctx->activeStateChanged().connect( + [&](const QStringList&) { ++changes; }); + const auto toggle = [&] { + const auto index = + model->indexForKey(QStringLiteral("f:%1").arg(parent.id)); + REQUIRE(index.isValid()); + h.view->toggleCheck(index); + }; + toggle(); + until([&] { return h.ctx->activeUuids().size() == 2; }); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("example"))); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("second"))); + CHECK(changes == 1); + h.view->rebuild(); + toggle(); + until([&] { return h.ctx->activeUuids().isEmpty(); }); + CHECK(changes == 2); + h.ctx->activeStateChanged().disconnect(connection); } diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp index fc59dab..66c36b8 100644 --- a/tests/ui/test_procedural_preview.cpp +++ b/tests/ui/test_procedural_preview.cpp @@ -1,68 +1,74 @@ -#include - #include #include #include #include +#include + #include "ui/components/procedural_preview.hpp" namespace { -// generate_preview paints with QPainter and QFont, so it needs a GUI application. Reuse one -// if the test binary already has it; otherwise create one, offscreen unless the environment picks a platform. +// generate_preview paints with QPainter and QFont, so it needs a GUI +// application. Reuse one if the test binary already has it; otherwise create +// one, offscreen unless the environment picks a platform. QApplication& application() { - if (auto* app = qobject_cast(QCoreApplication::instance())) { - return *app; - } - static int argc = 1; - static char name[] = "pxmodrim_ui_tests"; - static char* argv[] = {name, nullptr}; - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { - qputenv("QT_QPA_PLATFORM", "offscreen"); - } - static QApplication app(argc, argv); - return app; + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "pxmodrim_ui_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + static QApplication app(argc, argv); + return app; } } // namespace TEST_CASE("initials abbreviates titles") { - struct Case { - const char* title; - const char* expected; - }; - const Case cases[] = { - {"[AV] Framework - Russian Translation", "FR"}, - {"SF Grim Reality", "SG"}, - {"Vanilla Expanded Framework", "VE"}, - {"RimworldTweaks", "RT"}, - {"Combat", "CO"}, - {"[AV]", "AV"}, - {"[AV] Framework", "AF"}, - {"The Lord of Rings", "LR"}, - {"Русский перевод", "РП"}, - {"---", "--"}, - }; - for (const Case& c : cases) { - INFO("title: " << c.title); - CHECK(px::modrim::initials(QString::fromUtf8(c.title)) == QString::fromUtf8(c.expected)); - } + struct Case { + const char* title; + const char* expected; + }; + const Case cases[] = { + {"[AV] Framework - Russian Translation", "FR"}, + {"SF Grim Reality", "SG"}, + {"Vanilla Expanded Framework", "VE"}, + {"RimworldTweaks", "RT"}, + {"Combat", "CO"}, + {"[AV]", "AV"}, + {"[AV] Framework", "AF"}, + {"The Lord of Rings", "LR"}, + {"Русский перевод", "РП"}, + {"---", "--"}, + }; + for (const Case& c : cases) { + INFO("title: " << c.title); + CHECK(px::modrim::initials(QString::fromUtf8(c.title)) == + QString::fromUtf8(c.expected)); + } } TEST_CASE("generate preview returns a non-null image of the requested size") { - application(); - const QImage image = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); - REQUIRE(!image.isNull()); - CHECK(image.width() == 120); - CHECK(image.height() == 60); + application(); + const QImage image = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + CHECK(image.width() == 120); + CHECK(image.height() == 60); } TEST_CASE("generate preview returns the cached image for the same request") { - application(); - const QImage image = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); - REQUIRE(!image.isNull()); - // QImage shares its data implicitly, so a cache hit is identified by the shared data's cacheKey. - const QImage again = px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); - CHECK(again.cacheKey() == image.cacheKey()); + application(); + const QImage image = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + // QImage shares its data implicitly, so a cache hit is identified by the + // shared data's cacheKey. + const QImage again = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + CHECK(again.cacheKey() == image.cacheKey()); } diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp index f307593..2db6d6d 100644 --- a/tests/ui/test_restore_snapshot_dialog.cpp +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -1,5 +1,3 @@ -#include - #include #include #include @@ -15,253 +13,301 @@ #include #include -#include "core/mods_config.hpp" +#include + #include "core/models/metadata/structures.hpp" +#include "core/mods_config.hpp" #include "ui/panels/restore_snapshot_dialog.hpp" namespace { using px::modrim::CaseInsensitiveStr; -using px::modrim::ModsConfig; using px::modrim::ConfirmRestoreDialog; +using px::modrim::ModsConfig; using px::modrim::RestoreSnapshotDialog; -// Widgets need a QApplication for the whole run; the first test that asks for it creates it. +// Widgets need a QApplication for the whole run; the first test that asks for +// it creates it. QApplication& ensureApp() { - if (auto* existing = qobject_cast(QCoreApplication::instance())) return *existing; - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); - static int argc = 1; - static char name[] = "pxmodrim_tests"; - static char* argv[] = {name, nullptr}; - static QApplication app(argc, argv); - return app; + if (auto* existing = + qobject_cast(QCoreApplication::instance())) + return *existing; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim_tests"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; } void writeText(const QString& path, const QByteArray& bytes) { - QFile file(path); - REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); - REQUIRE(file.write(bytes) == bytes.size()); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + REQUIRE(file.write(bytes) == bytes.size()); } void writeSnapshot(const QString& path, const QStringList& packageIds) { - ModsConfig config; - config.version = QStringLiteral("1.5"); - for (const QString& id : packageIds) { - config.activeMods.append(CaseInsensitiveStr(id)); - } - REQUIRE(px::modrim::writeModsConfig(path, config, QFileInfo(path).absolutePath()).has_value()); + ModsConfig config; + config.version = QStringLiteral("1.5"); + for (const QString& id : packageIds) { + config.activeMods.append(CaseInsensitiveStr(id)); + } + REQUIRE( + px::modrim::writeModsConfig(path, config, QFileInfo(path).absolutePath()) + .has_value()); } QListWidget* listOf(RestoreSnapshotDialog& dialog) { - return dialog.findChild(); + return dialog.findChild(); } -QPushButton* buttonLabelled(RestoreSnapshotDialog& dialog, const QString& text) { - for (QPushButton* button : dialog.findChildren()) { - if (button->text() == text) { - return button; - } +QPushButton* buttonLabelled(RestoreSnapshotDialog& dialog, + const QString& text) { + for (QPushButton* button : dialog.findChildren()) { + if (button->text() == text) { + return button; } - return nullptr; + } + return nullptr; } -// The details label is the only label carrying markup; the instruction line has none. +// The details label is the only label carrying markup; the instruction line has +// none. QString detailsText(RestoreSnapshotDialog& dialog) { - for (QLabel* label : dialog.findChildren()) { - if (label->text().contains(QStringLiteral(""))) { - return label->text(); - } + for (QLabel* label : dialog.findChildren()) { + if (label->text().contains(QStringLiteral(""))) { + return label->text(); } - return {}; + } + return {}; } } // namespace -TEST_CASE("Restore dialog selects readable snapshots and disables Restore for unreadable ones", - "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - writeSnapshot(validSnap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - writeText(corruptSnap, "<<>>"); - - RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - REQUIRE(list->count() == 2); - - // The first readable snapshot is selected initially. - REQUIRE(dialog.selectedSnapshot() == validSnap); - QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); - QPushButton* cancelBtn = buttonLabelled(dialog, QStringLiteral("Cancel")); - REQUIRE(restoreBtn != nullptr); - REQUIRE(cancelBtn != nullptr); - REQUIRE(restoreBtn->isEnabled()); - REQUIRE(restoreBtn->parentWidget() == &dialog); - - list->setCurrentRow(1); - REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - REQUIRE_FALSE(restoreBtn->isEnabled()); - - list->setCurrentRow(0); - REQUIRE(dialog.selectedSnapshot() == validSnap); - REQUIRE(restoreBtn->isEnabled()); - - cancelBtn->click(); - REQUIRE(dialog.result() == QDialog::Rejected); - - RestoreSnapshotDialog acceptDialog(QStringList{validSnap}); - QListWidget* acceptList = listOf(acceptDialog); - REQUIRE(acceptList != nullptr); - emit acceptList->itemDoubleClicked(acceptList->item(0)); - REQUIRE(acceptDialog.result() == QDialog::Accepted); - REQUIRE(acceptDialog.selectedSnapshot() == validSnap); +TEST_CASE( + "Restore dialog selects readable snapshots and disables Restore for " + "unreadable ones", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + // The first readable snapshot is selected initially. + REQUIRE(dialog.selectedSnapshot() == validSnap); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + QPushButton* cancelBtn = buttonLabelled(dialog, QStringLiteral("Cancel")); + REQUIRE(restoreBtn != nullptr); + REQUIRE(cancelBtn != nullptr); + REQUIRE(restoreBtn->isEnabled()); + REQUIRE(restoreBtn->parentWidget() == &dialog); + + list->setCurrentRow(1); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE_FALSE(restoreBtn->isEnabled()); + + list->setCurrentRow(0); + REQUIRE(dialog.selectedSnapshot() == validSnap); + REQUIRE(restoreBtn->isEnabled()); + + cancelBtn->click(); + REQUIRE(dialog.result() == QDialog::Rejected); + + RestoreSnapshotDialog acceptDialog(QStringList{validSnap}); + QListWidget* acceptList = listOf(acceptDialog); + REQUIRE(acceptList != nullptr); + emit acceptList->itemDoubleClicked(acceptList->item(0)); + REQUIRE(acceptDialog.result() == QDialog::Accepted); + REQUIRE(acceptDialog.selectedSnapshot() == validSnap); } -TEST_CASE("Restore dialog Restore button accepts the selected snapshot", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - writeSnapshot(validSnap, {QStringLiteral("mod.foo")}); - - RestoreSnapshotDialog dialog(QStringList{validSnap}); - QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); - REQUIRE(restoreBtn != nullptr); - restoreBtn->click(); - REQUIRE(dialog.result() == QDialog::Accepted); - REQUIRE(dialog.selectedSnapshot() == validSnap); +TEST_CASE("Restore dialog Restore button accepts the selected snapshot", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{validSnap}); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + restoreBtn->click(); + REQUIRE(dialog.result() == QDialog::Accepted); + REQUIRE(dialog.selectedSnapshot() == validSnap); } -TEST_CASE("Restore dialog double-click on an unreadable snapshot does not accept", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - writeText(corruptSnap, "<<>>"); - - RestoreSnapshotDialog dialog(QStringList{corruptSnap}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - emit list->itemDoubleClicked(list->item(0)); - REQUIRE(dialog.result() == QDialog::Rejected); - REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); +TEST_CASE( + "Restore dialog double-click on an unreadable snapshot does not accept", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + emit list->itemDoubleClicked(list->item(0)); + REQUIRE(dialog.result() == QDialog::Rejected); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); } -TEST_CASE("Restore dialog lists snapshot timestamps and mod counts in given order", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString twoMods = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - writeSnapshot(twoMods, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString oneMod = dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); - writeSnapshot(oneMod, {QStringLiteral("mod.foo")}); - const QString noMods = dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); - writeSnapshot(noMods, {}); - const QString corrupt = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - writeText(corrupt, "<<>>"); - - RestoreSnapshotDialog dialog(QStringList{twoMods, oneMod, noMods, corrupt}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - REQUIRE(list->count() == 4); - - const QString dash = QString::fromUtf8("\u2014"); - REQUIRE(list->item(0)->text() == QStringLiteral("2026-09-22 12:00:00 UTC ") + dash + - QStringLiteral(" 2 active mods, RimWorld 1.5")); - REQUIRE(list->item(1)->text() == QStringLiteral("2026-09-22 14:00:00 UTC ") + dash + - QStringLiteral(" 1 active mod, RimWorld 1.5")); - REQUIRE(list->item(2)->text() == QStringLiteral("2026-09-22 15:00:00 UTC ") + dash + - QStringLiteral(" 0 active mods, RimWorld 1.5")); - REQUIRE(list->item(3)->text() == QStringLiteral("2026-09-22 13:00:00 UTC ") + dash + - QStringLiteral(" Cannot be read")); - REQUIRE(list->item(0)->toolTip() == QStringLiteral("ModsConfig_20260922_120000.xml")); +TEST_CASE( + "Restore dialog lists snapshot timestamps and mod counts in given order", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString twoMods = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(twoMods, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString oneMod = + dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); + writeSnapshot(oneMod, {QStringLiteral("mod.foo")}); + const QString noMods = + dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); + writeSnapshot(noMods, {}); + const QString corrupt = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corrupt, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{twoMods, oneMod, noMods, corrupt}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 4); + + const QString dash = QString::fromUtf8("\u2014"); + REQUIRE(list->item(0)->text() == + QStringLiteral("2026-09-22 12:00:00 UTC ") + dash + + QStringLiteral(" 2 active mods, RimWorld 1.5")); + REQUIRE(list->item(1)->text() == + QStringLiteral("2026-09-22 14:00:00 UTC ") + dash + + QStringLiteral(" 1 active mod, RimWorld 1.5")); + REQUIRE(list->item(2)->text() == + QStringLiteral("2026-09-22 15:00:00 UTC ") + dash + + QStringLiteral(" 0 active mods, RimWorld 1.5")); + REQUIRE(list->item(3)->text() == QStringLiteral("2026-09-22 13:00:00 UTC ") + + dash + + QStringLiteral(" Cannot be read")); + REQUIRE(list->item(0)->toolTip() == + QStringLiteral("ModsConfig_20260922_120000.xml")); } -TEST_CASE("Restore dialog falls back to file modification time for unnamed or invalid snapshot names", - "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString unnamed = dir.filePath(QStringLiteral("custom.xml")); - writeSnapshot(unnamed, {QStringLiteral("mod.foo")}); - const QString badDate = dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); - writeSnapshot(badDate, {QStringLiteral("mod.foo")}); - - RestoreSnapshotDialog dialog(QStringList{unnamed, badDate}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - REQUIRE(list->count() == 2); - - const QRegularExpression pattern(QStringLiteral( - R"(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC \x{2014} 1 active mod, RimWorld 1\.5$)")); - REQUIRE(pattern.match(list->item(0)->text()).hasMatch()); - REQUIRE(pattern.match(list->item(1)->text()).hasMatch()); +TEST_CASE( + "Restore dialog falls back to file modification time for unnamed or " + "invalid snapshot names", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString unnamed = dir.filePath(QStringLiteral("custom.xml")); + writeSnapshot(unnamed, {QStringLiteral("mod.foo")}); + const QString badDate = + dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); + writeSnapshot(badDate, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{unnamed, badDate}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + const QRegularExpression pattern(QStringLiteral( + R"(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC \x{2014} 1 active mod, RimWorld 1\.5$)")); + REQUIRE(pattern.match(list->item(0)->text()).hasMatch()); + REQUIRE(pattern.match(list->item(1)->text()).hasMatch()); } -TEST_CASE("Restore dialog details describe the selected snapshot", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - const QString validSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); - writeSnapshot(validSnap, {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); - const QString corruptSnap = dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); - writeText(corruptSnap, "<<>>"); - - RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - - REQUIRE(detailsText(dialog) == - QStringLiteral("File: ModsConfig_20260922_120000.xml
    Active mods: 2 | " - "Game version: 1.5")); - - list->setCurrentRow(1); - REQUIRE(detailsText(dialog) == - QStringLiteral("This saved mod list cannot be read. ModsConfig_20260922_130000.xml " - "will not be restored.")); - - list->setCurrentItem(nullptr); - REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - REQUIRE(detailsText(dialog).isEmpty()); +TEST_CASE("Restore dialog details describe the selected snapshot", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + + REQUIRE( + detailsText(dialog) == + QStringLiteral("File: ModsConfig_20260922_120000.xml
    Active " + "mods: 2 | " + "Game version: 1.5")); + + list->setCurrentRow(1); + REQUIRE(detailsText(dialog) == + QStringLiteral("This saved mod list cannot be read. " + "ModsConfig_20260922_130000.xml " + "will not be restored.")); + + list->setCurrentItem(nullptr); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE(detailsText(dialog).isEmpty()); } -TEST_CASE("Restore dialog with no snapshots has nothing to select", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - - RestoreSnapshotDialog dialog(QStringList{}); - QListWidget* list = listOf(dialog); - REQUIRE(list != nullptr); - REQUIRE(list->count() == 0); - REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); - QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); - REQUIRE(restoreBtn != nullptr); - REQUIRE_FALSE(restoreBtn->isEnabled()); +TEST_CASE("Restore dialog with no snapshots has nothing to select", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + + RestoreSnapshotDialog dialog(QStringList{}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 0); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + REQUIRE_FALSE(restoreBtn->isEnabled()); } -TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", "[ui][restore_snapshot]") { - [[maybe_unused]] auto& app = ensureApp(); - - ConfirmRestoreDialog confirm(QStringLiteral("ModsConfig_20260922_120000.xml")); - REQUIRE(confirm.windowTitle() == QStringLiteral("Restore Mod List")); - REQUIRE(confirm.text().contains(QStringLiteral("ModsConfig_20260922_120000.xml"))); - REQUIRE(confirm.informativeText().contains(QStringLiteral("backup"))); - REQUIRE(confirm.icon() == QMessageBox::Question); - REQUIRE(confirm.defaultButton() == confirm.button(QMessageBox::Cancel)); - - auto* restoreBtn = qobject_cast(confirm.button(QMessageBox::Yes)); - REQUIRE(restoreBtn != nullptr); - REQUIRE(restoreBtn->text() == QStringLiteral("Restore")); - REQUIRE(restoreBtn->objectName() == QStringLiteral("primaryAction")); - auto* cancelBtn = qobject_cast(confirm.button(QMessageBox::Cancel)); - REQUIRE(cancelBtn != nullptr); - REQUIRE(cancelBtn->text() == QStringLiteral("Cancel")); +TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + + ConfirmRestoreDialog confirm( + QStringLiteral("ModsConfig_20260922_120000.xml")); + REQUIRE(confirm.windowTitle() == QStringLiteral("Restore Mod List")); + REQUIRE(confirm.text().contains( + QStringLiteral("ModsConfig_20260922_120000.xml"))); + REQUIRE(confirm.informativeText().contains(QStringLiteral("backup"))); + REQUIRE(confirm.icon() == QMessageBox::Question); + REQUIRE(confirm.defaultButton() == confirm.button(QMessageBox::Cancel)); + + auto* restoreBtn = + qobject_cast(confirm.button(QMessageBox::Yes)); + REQUIRE(restoreBtn != nullptr); + REQUIRE(restoreBtn->text() == QStringLiteral("Restore")); + REQUIRE(restoreBtn->objectName() == QStringLiteral("primaryAction")); + auto* cancelBtn = + qobject_cast(confirm.button(QMessageBox::Cancel)); + REQUIRE(cancelBtn != nullptr); + REQUIRE(cancelBtn->text() == QStringLiteral("Cancel")); } diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp index da9b62d..c70f8d4 100644 --- a/tests/ui/test_settings_panel.cpp +++ b/tests/ui/test_settings_panel.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -7,55 +6,64 @@ #include #include #include -#include "test_support.hpp" -#include "ui/theme/qml_theme.hpp" + +#include + #include "core/context.hpp" +#include "test_support.hpp" #include "ui/components/svg_provider.hpp" #include "ui/panels/settings_panel.hpp" +#include "ui/theme/qml_theme.hpp" namespace { void ensureApplication() { - if (QCoreApplication::instance()) return; - static int argc = 1; - static char name[] = "settings_panel_tests"; - static char* argv[] = {name, nullptr}; - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); - static QApplication app(argc, argv); + if (QCoreApplication::instance()) return; + static int argc = 1; + static char name[] = "settings_panel_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static QApplication app(argc, argv); } void settle(int milliseconds) { - QEventLoop loop; - QTimer timer; - timer.setSingleShot(true); - QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); - timer.start(milliseconds); - loop.exec(); -} + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(milliseconds); + loop.exec(); } +} // namespace TEST_CASE("Cancel settings unloads QML without binding errors") { - ensureApplication(); - QWidget host; - auto* engine = px::modrim::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); - QList warnings; - QObject::connect(engine, &QQmlEngine::warnings, &host, [&](const QList& values) { warnings.append(values); }); - auto context = px::ui_test::makeCore(); - px::modrim::SettingsPanel panel(*context, engine, &host); - panel.resize(760, 640); - panel.show(); - settle(300); - auto* quick = panel.findChild(); - REQUIRE(quick != nullptr); - auto* root = quick->rootObject(); - REQUIRE(root != nullptr); - QQuickItem* cancel = nullptr; - for (auto* item : root->findChildren()) - if (QString::fromLatin1(item->metaObject()->className()).startsWith("PxButton") && item->property("text").toString() == "Cancel") { - cancel = item; - break; - } - REQUIRE(cancel != nullptr); - REQUIRE(QMetaObject::invokeMethod(cancel, "clicked", Qt::DirectConnection)); - settle(20); - CHECK(quick->rootObject() == nullptr); - CHECK_FALSE(panel.isVisible()); - CHECK(warnings.isEmpty()); + ensureApplication(); + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + QList warnings; + QObject::connect( + engine, &QQmlEngine::warnings, &host, + [&](const QList& values) { warnings.append(values); }); + auto context = px::ui_test::makeCore(); + px::modrim::SettingsPanel panel(*context, engine, &host); + panel.resize(760, 640); + panel.show(); + settle(300); + auto* quick = panel.findChild(); + REQUIRE(quick != nullptr); + auto* root = quick->rootObject(); + REQUIRE(root != nullptr); + QQuickItem* cancel = nullptr; + for (auto* item : root->findChildren()) + if (QString::fromLatin1(item->metaObject()->className()) + .startsWith("PxButton") && + item->property("text").toString() == "Cancel") { + cancel = item; + break; + } + REQUIRE(cancel != nullptr); + REQUIRE(QMetaObject::invokeMethod(cancel, "clicked", Qt::DirectConnection)); + settle(20); + CHECK(quick->rootObject() == nullptr); + CHECK_FALSE(panel.isVisible()); + CHECK(warnings.isEmpty()); } diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp index a976e8c..30b2361 100644 --- a/tests/ui/test_sidebar_model.cpp +++ b/tests/ui/test_sidebar_model.cpp @@ -1,33 +1,44 @@ -#include #include + +#include + #include "ui/models/sidebar_model.hpp" #include "ui/theme/palette.hpp" TEST_CASE("sidebar entry updates refresh provider icons without resetting") { - using px::modrim::view::SidebarEntry; - px::modrim::SidebarModel model; - model.setEntries({{SidebarEntry::Kind::kProvider,"Steam",{"a","b"},"steam"}}); - QSignalSpy changed(&model,&QAbstractItemModel::dataChanged); - QSignalSpy reset(&model,&QAbstractItemModel::modelReset); - model.updateEntries({{SidebarEntry::Kind::kProvider,"Local",{"a","b"},"local"}}); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::kIconRole).toString() == "folder"); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::kIconColorRole).toString() == px::modrim::paletteValue(u"TEXT_DIM")); - CHECK(model.data(model.index(0),px::modrim::SidebarModel::kCountRole).toInt() == 2); - CHECK(reset.isEmpty()); - REQUIRE(changed.size() == 1); - CHECK(changed.first()[2].value>().contains(px::modrim::SidebarModel::kIconRole)); - CHECK(model.getEntry(0)->providerId == "local"); + using px::modrim::view::SidebarEntry; + px::modrim::SidebarModel model; + model.setEntries( + {{SidebarEntry::Kind::kProvider, "Steam", {"a", "b"}, "steam"}}); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + model.updateEntries( + {{SidebarEntry::Kind::kProvider, "Local", {"a", "b"}, "local"}}); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kIconRole) + .toString() == "folder"); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kIconColorRole) + .toString() == px::modrim::paletteValue(u"TEXT_DIM")); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kCountRole) + .toInt() == 2); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains( + px::modrim::SidebarModel::kIconRole)); + CHECK(model.getEntry(0)->providerId == "local"); } -TEST_CASE("sidebar models status sections and resets when its entry shape changes") { - using px::modrim::view::SidebarEntry; - using px::modrim::SidebarModel; - SidebarModel model; - model.setEntries({{SidebarEntry::Kind::kAll,"All",{"a"},{}}, - {SidebarEntry::Kind::kWithErrors,"Errors",{"a"},{}}}); - CHECK(model.data(model.index(0),SidebarModel::kSectionRole).toString() == "Status"); - CHECK(model.data(model.index(1),SidebarModel::kBadgeFgRole).toString() == px::modrim::paletteValue(u"DANGER")); - CHECK_FALSE(model.getEntry(-1).has_value()); - QSignalSpy reset(&model,&QAbstractItemModel::modelReset); - model.updateEntries({}); - CHECK(reset.size() == 1); - CHECK(model.rowCount() == 0); +TEST_CASE( + "sidebar models status sections and resets when its entry shape changes") { + using px::modrim::SidebarModel; + using px::modrim::view::SidebarEntry; + SidebarModel model; + model.setEntries({{SidebarEntry::Kind::kAll, "All", {"a"}, {}}, + {SidebarEntry::Kind::kWithErrors, "Errors", {"a"}, {}}}); + CHECK(model.data(model.index(0), SidebarModel::kSectionRole).toString() == + "Status"); + CHECK(model.data(model.index(1), SidebarModel::kBadgeFgRole).toString() == + px::modrim::paletteValue(u"DANGER")); + CHECK_FALSE(model.getEntry(-1).has_value()); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + model.updateEntries({}); + CHECK(reset.size() == 1); + CHECK(model.rowCount() == 0); } diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp index 0d94791..21b6b69 100644 --- a/tests/ui/test_startup_impact_data.cpp +++ b/tests/ui/test_startup_impact_data.cpp @@ -1,98 +1,123 @@ -#include #include +#include + #include "ui/panels/startup_impact_data.hpp" using namespace px::modrim; using namespace px::modrim::startup_impact; namespace { StartupImpactReport report() { - StartupImpactReport result; - result.mods = { - {"Small", QStringLiteral("a.small"), 1, {{"LoadingProgress.StartupImpact.ApplyPatches", 1}}}, - {"Big", QStringLiteral("b.big"), 4, {{"LoadingProgress.StartupImpact.ModContentPackReloadContentInt.Textures", 3}, - {"LoadingProgress.StartupImpact.ApplyPatches", 1}}, {}, 0.5}, - {QString(kBaseGamePid), QString(kBaseGamePid), 10, - {{"LoadingProgress.StartupImpact.GlobalTextureAtlasManagerBakeStaticAtlases", 10}}}}; - return result; -} -QVariantMap build(QStringList active = {}, std::optional selected = {}, double estimate = 0) { - return buildStartupImpact(report(), active, selected, estimate); + StartupImpactReport result; + result.mods = {{"Small", + QStringLiteral("a.small"), + 1, + {{"LoadingProgress.StartupImpact.ApplyPatches", 1}}}, + {"Big", + QStringLiteral("b.big"), + 4, + {{"LoadingProgress.StartupImpact." + "ModContentPackReloadContentInt.Textures", + 3}, + {"LoadingProgress.StartupImpact.ApplyPatches", 1}}, + {}, + 0.5}, + {QString(kBaseGamePid), + QString(kBaseGamePid), + 10, + {{"LoadingProgress.StartupImpact." + "GlobalTextureAtlasManagerBakeStaticAtlases", + 10}}}}; + return result; } +QVariantMap build(QStringList active = {}, std::optional selected = {}, + double estimate = 0) { + return buildStartupImpact(report(), active, selected, estimate); } +} // namespace TEST_CASE("Startup impact excludes base and ranks mods") { - const auto rows = build().value("mods").toList(); - REQUIRE(rows.size() == 2); - CHECK(rows[0].toMap().value("name").toString() == "Big"); - CHECK(rows[1].toMap().value("name").toString() == "Small"); - CHECK(rows[0].toMap().value("rank").toInt() == 1); - CHECK(rows[1].toMap().value("rank").toInt() == 2); - const auto sum = [](const QVariantMap& row) { - double total = 0; - for (const auto& segment : row.value("segments").toList()) total += segment.toMap().value("fraction").toDouble(); - return total; - }; - CHECK(sum(rows[0].toMap()) == Catch::Approx(1)); - CHECK(sum(rows[1].toMap()) == Catch::Approx(0.25)); - CHECK(rows[0].toMap().value("dominant").toString() == "Textures & audio"); - CHECK(rows[0].toMap().value("off_thread").toString() == "500 ms"); + const auto rows = build().value("mods").toList(); + REQUIRE(rows.size() == 2); + CHECK(rows[0].toMap().value("name").toString() == "Big"); + CHECK(rows[1].toMap().value("name").toString() == "Small"); + CHECK(rows[0].toMap().value("rank").toInt() == 1); + CHECK(rows[1].toMap().value("rank").toInt() == 2); + const auto sum = [](const QVariantMap& row) { + double total = 0; + for (const auto& segment : row.value("segments").toList()) + total += segment.toMap().value("fraction").toDouble(); + return total; + }; + CHECK(sum(rows[0].toMap()) == Catch::Approx(1)); + CHECK(sum(rows[1].toMap()) == Catch::Approx(0.25)); + CHECK(rows[0].toMap().value("dominant").toString() == "Textures & audio"); + CHECK(rows[0].toMap().value("off_thread").toString() == "500 ms"); } TEST_CASE("Startup impact normalizes selected and active package IDs") { - const auto data = build({"B.Big_steam"}, QStringLiteral("A.SMALL")); - const auto rows = data.value("mods").toList(); - CHECK(rows[0].toMap().value("active").toBool()); - CHECK_FALSE(rows[0].toMap().value("selected").toBool()); - CHECK_FALSE(rows[1].toMap().value("active").toBool()); - CHECK(rows[1].toMap().value("selected").toBool()); - CHECK(data.value("active_count").toInt() == 1); - CHECK(data.value("selected_key").toString() == "a.small"); + const auto data = build({"B.Big_steam"}, QStringLiteral("A.SMALL")); + const auto rows = data.value("mods").toList(); + CHECK(rows[0].toMap().value("active").toBool()); + CHECK_FALSE(rows[0].toMap().value("selected").toBool()); + CHECK_FALSE(rows[1].toMap().value("active").toBool()); + CHECK(rows[1].toMap().value("selected").toBool()); + CHECK(data.value("active_count").toInt() == 1); + CHECK(data.value("selected_key").toString() == "a.small"); } TEST_CASE("Startup impact summary and estimates") { - const auto data = build({}, {}, 12); - CHECK(data.value("estimated").toString().isEmpty()); - CHECK(build({"a.small"}, {}, 12).value("estimated").toString() == "12.00 s"); - CHECK(data.value("last_launch").toString() == "15.00 s"); - const auto summary = data.value("summary").toList(); - REQUIRE(summary.size() == 3); - double total = 0; - for (qsizetype i = 0; i < summary.size(); ++i) { - CHECK(summary[i].toMap().value("parallel").toBool() == (i == 2)); - total += summary[i].toMap().value("fraction").toDouble(); - } - CHECK(total == Catch::Approx(1)); - CHECK(summary[0].toMap().value("fraction").toDouble() == Catch::Approx(10.0 / 15.5)); + const auto data = build({}, {}, 12); + CHECK(data.value("estimated").toString().isEmpty()); + CHECK(build({"a.small"}, {}, 12).value("estimated").toString() == "12.00 s"); + CHECK(data.value("last_launch").toString() == "15.00 s"); + const auto summary = data.value("summary").toList(); + REQUIRE(summary.size() == 3); + double total = 0; + for (qsizetype i = 0; i < summary.size(); ++i) { + CHECK(summary[i].toMap().value("parallel").toBool() == (i == 2)); + total += summary[i].toMap().value("fraction").toDouble(); + } + CHECK(total == Catch::Approx(1)); + CHECK(summary[0].toMap().value("fraction").toDouble() == + Catch::Approx(10.0 / 15.5)); } TEST_CASE("Startup impact phases toggle base contribution") { - const auto data = build({}, QStringLiteral("b.big")); - const auto phases = data.value("phases_with_base").toList(); - REQUIRE(phases.size() == 2); - const auto content = phases[0].toMap(); - CHECK(content.value("label").toString() == "Textures & audio"); - CHECK(phases[1].toMap().value("label").toString() == "Patches"); - CHECK(content.value("value").toString() == "13.00 s"); - CHECK(content.value("share").toString() == "87%"); - const auto contributors = content.value("mods").toList(); - REQUIRE(contributors.size() == 2); - CHECK(contributors[0].toMap().value("name").toString() == "Base game"); - CHECK(contributors[0].toMap().value("base").toBool()); - CHECK(contributors[1].toMap().value("selected").toBool()); - const auto modsOnly = data.value("phases_mods_only").toList(); - REQUIRE(modsOnly.size() == 2); - CHECK(modsOnly[0].toMap().value("value").toString() == "3.00 s"); - CHECK(modsOnly[1].toMap().value("value").toString() == "2.00 s"); - for (const auto& phase : modsOnly) - for (const auto& mod : phase.toMap().value("mods").toList()) CHECK_FALSE(mod.toMap().value("base").toBool()); + const auto data = build({}, QStringLiteral("b.big")); + const auto phases = data.value("phases_with_base").toList(); + REQUIRE(phases.size() == 2); + const auto content = phases[0].toMap(); + CHECK(content.value("label").toString() == "Textures & audio"); + CHECK(phases[1].toMap().value("label").toString() == "Patches"); + CHECK(content.value("value").toString() == "13.00 s"); + CHECK(content.value("share").toString() == "87%"); + const auto contributors = content.value("mods").toList(); + REQUIRE(contributors.size() == 2); + CHECK(contributors[0].toMap().value("name").toString() == "Base game"); + CHECK(contributors[0].toMap().value("base").toBool()); + CHECK(contributors[1].toMap().value("selected").toBool()); + const auto modsOnly = data.value("phases_mods_only").toList(); + REQUIRE(modsOnly.size() == 2); + CHECK(modsOnly[0].toMap().value("value").toString() == "3.00 s"); + CHECK(modsOnly[1].toMap().value("value").toString() == "2.00 s"); + for (const auto& phase : modsOnly) + for (const auto& mod : phase.toMap().value("mods").toList()) + CHECK_FALSE(mod.toMap().value("base").toBool()); } TEST_CASE("Startup impact duration formatting") { - CHECK(formatDuration(0.0004) == "0 ms"); - CHECK(formatDuration(0.25) == "250 ms"); - CHECK(formatDuration(59.5) == "59.50 s"); - CHECK(formatDuration(60) == "1m 00s"); - CHECK(formatDuration(268.7) == "4m 29s"); + CHECK(formatDuration(0.0004) == "0 ms"); + CHECK(formatDuration(0.25) == "250 ms"); + CHECK(formatDuration(59.5) == "59.50 s"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(formatDuration(268.7) == "4m 29s"); } TEST_CASE("Startup impact tooltip combines metrics sharing labels") { - auto data = report(); - data.mods[0].metrics = {{"ApplyPatches|Foo -> a", 1}, {"ApplyPatches|Foo -> b", 2}}; - const auto rows = buildStartupImpact(data, {}, {}, 0).value("mods").toList(); - const auto tooltip = rows[1].toMap().value("phases").toList()[0].toMap().value("tooltip").toString(); - CHECK(tooltip.count('\n') == 0); - CHECK(tooltip.endsWith("3.00 s")); + auto data = report(); + data.mods[0].metrics = {{"ApplyPatches|Foo -> a", 1}, + {"ApplyPatches|Foo -> b", 2}}; + const auto rows = buildStartupImpact(data, {}, {}, 0).value("mods").toList(); + const auto tooltip = rows[1] + .toMap() + .value("phases") + .toList()[0] + .toMap() + .value("tooltip") + .toString(); + CHECK(tooltip.count('\n') == 0); + CHECK(tooltip.endsWith("3.00 s")); } diff --git a/tests/ui/test_support.hpp b/tests/ui/test_support.hpp index 40324a0..ca27cdc 100644 --- a/tests/ui/test_support.hpp +++ b/tests/ui/test_support.hpp @@ -1,20 +1,21 @@ #pragma once -#include - #include +#include #include "core/config.hpp" #include "core/context.hpp" namespace px::ui_test { -// CoreContext services read their cache/config files through a ConfigService, so tests -// give every context one rooted in a directory that lives for the whole test run. -inline std::unique_ptr makeCore(px::modrim::AppConfig config = {}) { - static QTemporaryDir configDir; - static px::modrim::ConfigService configService(configDir.path()); - return px::modrim::CoreContext::create(std::move(config), &configService); +// CoreContext services read their cache/config files through a ConfigService, +// so tests give every context one rooted in a directory that lives for the +// whole test run. +inline std::unique_ptr makeCore( + px::modrim::AppConfig config = {}) { + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + return px::modrim::CoreContext::create(std::move(config), &configService); } } // namespace px::ui_test diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp index e44e3de..71b293a 100644 --- a/tests/ui/test_toast_and_progress.cpp +++ b/tests/ui/test_toast_and_progress.cpp @@ -1,5 +1,3 @@ -#include - #include #include #include @@ -9,6 +7,8 @@ #include #include +#include + #include "core/loading.hpp" #include "ui/components/progress_dialog.hpp" #include "ui/components/toast.hpp" @@ -21,68 +21,71 @@ using px::modrim::ToastLevel; using px::modrim::ToastManager; QApplication& application() { - if (auto* app = qobject_cast(QCoreApplication::instance())) { - return *app; - } - static int argc = 1; - static char name[] = "test_toast_and_progress"; - static char* argv[] = {name, nullptr}; - static QApplication app(argc, argv); - return app; + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "test_toast_and_progress"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; } // Runs the event loop for `ms` so animations and timers can fire. void waitMs(int ms) { - QEventLoop loop; - QTimer::singleShot(ms, &loop, &QEventLoop::quit); - loop.exec(); + QEventLoop loop; + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); } } // namespace TEST_CASE("close button removes a persistent toast") { - application(); - QWidget parent; - ToastManager manager(&parent); - manager.showToast(QStringLiteral("stays"), ToastLevel::kInfo, 0); + application(); + QWidget parent; + ToastManager manager(&parent); + manager.showToast(QStringLiteral("stays"), ToastLevel::kInfo, 0); - auto* toast = manager.findChild(); - REQUIRE(toast != nullptr); - auto* close = toast->findChild(QStringLiteral("toastClose")); - REQUIRE(close != nullptr); + auto* toast = manager.findChild(); + REQUIRE(toast != nullptr); + auto* close = toast->findChild(QStringLiteral("toastClose")); + REQUIRE(close != nullptr); - close->click(); - waitMs(300); + close->click(); + waitMs(300); - REQUIRE(manager.layout()->count() == 0); + REQUIRE(manager.layout()->count() == 0); } TEST_CASE("timed toast dismisses itself after its duration") { - application(); - QWidget parent; - ToastManager manager(&parent); - manager.info(QStringLiteral("gone soon"), 50); - REQUIRE(manager.layout()->count() == 1); + application(); + QWidget parent; + ToastManager manager(&parent); + manager.info(QStringLiteral("gone soon"), 50); + REQUIRE(manager.layout()->count() == 1); - waitMs(600); + waitMs(600); - REQUIRE(manager.layout()->count() == 0); + REQUIRE(manager.layout()->count() == 0); } -TEST_CASE("progress dialog stays visible when a new task starts before it hides") { - application(); - QWidget parent; - px::modrim::LoadingState loading(&parent); - ProgressDialog dialog(loading, &parent); - - { auto first = loading.task(QStringLiteral("first")); } - { - auto second = loading.task(QStringLiteral("second")); - waitMs(500); - REQUIRE(dialog.isVisible()); - REQUIRE(dialog.windowTitle() == QStringLiteral("second")); - } - - waitMs(800); - REQUIRE_FALSE(dialog.isVisible()); +TEST_CASE( + "progress dialog stays visible when a new task starts before it hides") { + application(); + QWidget parent; + px::modrim::LoadingState loading(&parent); + ProgressDialog dialog(loading, &parent); + + { + auto first = loading.task(QStringLiteral("first")); + } + { + auto second = loading.task(QStringLiteral("second")); + waitMs(500); + REQUIRE(dialog.isVisible()); + REQUIRE(dialog.windowTitle() == QStringLiteral("second")); + } + + waitMs(800); + REQUIRE_FALSE(dialog.isVisible()); } diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp index 85b68a9..2418853 100644 --- a/tests/ui/test_unity_rich_text.cpp +++ b/tests/ui/test_unity_rich_text.cpp @@ -1,128 +1,144 @@ -#include - -#include - #include #include +#include + +#include #include "ui/components/unity_rich_text.hpp" namespace { struct Case { - const char* input; - const char* expected; + const char* input; + const char* expected; }; -// Checks every case and reports each failing input, rather than stopping at the first. +// Checks every case and reports each failing input, rather than stopping at the +// first. void checkCases(std::initializer_list cases) { - for (const Case& c : cases) { - INFO("input: " << c.input); - CHECK(px::modrim::unityRichTextToHtml(QString::fromUtf8(c.input)) == QString::fromUtf8(c.expected)); - } + for (const Case& c : cases) { + INFO("input: " << c.input); + CHECK(px::modrim::unityRichTextToHtml(QString::fromUtf8(c.input)) == + QString::fromUtf8(c.expected)); + } } } // namespace TEST_CASE("unity rich text passes plain text through") { - checkCases({{"Hello world", "Hello world"}}); + checkCases({{"Hello world", "Hello world"}}); } TEST_CASE("unity rich text maps bold tags") { - checkCases({ - {"bold", "bold"}, - {"bold", "bold"}, - }); + checkCases({ + {"bold", "bold"}, + {"bold", "bold"}, + }); } TEST_CASE("unity rich text maps italic tags") { - checkCases({ - {"italic", "italic"}, - {"italic", "italic"}, - }); + checkCases({ + {"italic", "italic"}, + {"italic", "italic"}, + }); } TEST_CASE("unity rich text maps underline to a span") { - checkCases({{"underline", "underline"}}); + checkCases( + {{"underline", + "underline"}}); } TEST_CASE("unity rich text maps strikethrough") { - checkCases({{"strike", "strike"}}); + checkCases({{"strike", "strike"}}); } TEST_CASE("unity rich text maps color names and hex values to font color") { - checkCases({ - {"red", "red"}, - {"red", "red"}, - }); + checkCases({ + {"red", "red"}, + {"red", "red"}, + }); } TEST_CASE("unity rich text maps size units to font-size points") { - checkCases({ - {"big", "big"}, - {"big", "big"}, - {"big", "big"}, - {"relative", "relative"}, - {"small", "small"}, - }); + checkCases({ + {"big", "big"}, + {"big", "big"}, + {"big", "big"}, + {"relative", + "relative"}, + {"small", + "small"}, + }); } TEST_CASE("unity rich text maps align to a text-align div") { - checkCases({{"center", "
    center
    "}}); + checkCases({{"center", + "
    center
    "}}); } TEST_CASE("unity rich text maps indent to a margin-left div") { - checkCases({{"indented", "
    indented
    "}}); + checkCases({{"indented", + "
    indented
    "}}); } TEST_CASE("unity rich text maps mark to a background-colored mark") { - checkCases({{"marked", "marked"}}); + checkCases({{"marked", + "marked"}}); } TEST_CASE("unity rich text keeps sub and sup") { - checkCases({ - {"sub", "sub"}, - {"sup", "sup"}, - }); + checkCases({ + {"sub", "sub"}, + {"sup", "sup"}, + }); } TEST_CASE("unity rich text maps http and https links to anchors") { - checkCases({{"link", "
    link"}}); + checkCases({{"link", + "link"}}); } TEST_CASE("unity rich text drops links with unsafe urls and keeps their text") { - for (const char* url : { - "javascript:alert(1)", - "steam://rungameid/294100", - "https:example.com", - }) { - INFO("url: " << url); - const QString input = QStringLiteral("link").arg(QString::fromUtf8(url)); - CHECK(px::modrim::unityRichTextToHtml(input) == QStringLiteral("link")); - } + for (const char* url : { + "javascript:alert(1)", + "steam://rungameid/294100", + "https:example.com", + }) { + INFO("url: " << url); + const QString input = + QStringLiteral("link").arg(QString::fromUtf8(url)); + CHECK(px::modrim::unityRichTextToHtml(input) == QStringLiteral("link")); + } } TEST_CASE("unity rich text uses the default size for a non-finite size") { - // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. - const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::modrim::unityRichTextToHtml(QStringLiteral("large").arg(huge)); - CHECK(html == QStringLiteral("large")); + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to + // infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::modrim::unityRichTextToHtml( + QStringLiteral("large").arg(huge)); + CHECK(html == + QStringLiteral("large")); } TEST_CASE("unity rich text uses the default indent for a non-finite indent") { - // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to infinity. - const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); - const QString html = px::modrim::unityRichTextToHtml(QStringLiteral("wide").arg(huge)); - CHECK(html == QStringLiteral("
    wide
    ")); + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to + // infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::modrim::unityRichTextToHtml( + QStringLiteral("wide").arg(huge)); + CHECK(html == QStringLiteral("
    wide
    ")); } TEST_CASE("unity rich text nests formatting tags") { - checkCases({ - {"bold italic", "bold italic"}, - {"green", "green"}, - }); + checkCases({ + {"bold italic", "bold italic"}, + {"green", + "green"}, + }); } TEST_CASE("unity rich text strips unknown tags") { - checkCases({{"bar", "bar"}}); + checkCases({{"bar", "bar"}}); } diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp index 2fd735b..f4883d9 100644 --- a/tests/ui/test_views.cpp +++ b/tests/ui/test_views.cpp @@ -1,5 +1,3 @@ -#include - #include #include #include @@ -8,8 +6,10 @@ #include #include -#include "test_support.hpp" +#include + #include "core/context.hpp" +#include "test_support.hpp" #include "ui/components/svg_provider.hpp" #include "ui/context.hpp" #include "ui/theme/qml_theme.hpp" @@ -18,34 +18,38 @@ #include "ui/plugins/steam_workshop/view.hpp" namespace { -// The embedded browser exists only in WebEngine builds; without it there is no "workshopWeb" object. +// The embedded browser exists only in WebEngine builds; without it there is no +// "workshopWeb" object. QObject* webOf(const px::modrim::SteamWorkshopViewPanel& view) { - for (auto* quick : view.findChildren()) { - if (auto* root = quick->rootObject()) { - if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) return web; - } + for (auto* quick : view.findChildren()) { + if (auto* root = quick->rootObject()) { + if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) + return web; } - return nullptr; + } + return nullptr; } } // namespace TEST_CASE("steam workshop view preloads without error") { - QWidget host; - auto* engine = px::modrim::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); - auto core = px::ui_test::makeCore(); - px::modrim::AppContext app(*core); - px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); - view.preload(); + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); + view.preload(); } TEST_CASE("steam workshop view has no web view before initialization") { - QWidget host; - auto* engine = px::modrim::createQmlEngine(&host); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); - auto core = px::ui_test::makeCore(); - px::modrim::AppContext app(*core); - px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); - CHECK(webOf(view) == nullptr); + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); + CHECK(webOf(view) == nullptr); } #endif diff --git a/tests/ui/test_workshop_images.cpp b/tests/ui/test_workshop_images.cpp index 05db17a..6616636 100644 --- a/tests/ui/test_workshop_images.cpp +++ b/tests/ui/test_workshop_images.cpp @@ -1,8 +1,3 @@ -#include - -#include -#include - #include #include #include @@ -19,6 +14,10 @@ #include #include #include +#include +#include + +#include namespace { @@ -26,140 +25,150 @@ const QString kWorkshopDir = QStringLiteral("qrc:/qt/qml/PxModRim"); constexpr qint64 kWaitMs = 5000; - -// Loads thumbs.js the way `import "thumbs.js" as Thumbs` does, minus the `.pragma library` -// header, which only QML's loader understands. +// Loads thumbs.js the way `import "thumbs.js" as Thumbs` does, minus the +// `.pragma library` header, which only QML's loader understands. class Thumbs { -public: - Thumbs() { - const QString path = QStringLiteral(":/qt/qml/PxModRim/thumbs.js"); - QFile file(path); - REQUIRE(file.open(QIODevice::ReadOnly | QIODevice::Text)); - QString script = QString::fromUtf8(file.readAll()); - const QString pragma = QStringLiteral(".pragma library"); - if (script.startsWith(pragma)) - script.remove(0, pragma.size()); - const QJSValue result = m_engine.evaluate(script, path); - REQUIRE(!result.isError()); - m_sized = m_engine.globalObject().property(QStringLiteral("sized")); - REQUIRE(m_sized.isCallable()); - } - - QString sized(const QString& url, int width, int height) { - return m_sized.call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}).toString(); - } - -private: - QJSEngine m_engine; - QJSValue m_sized; + public: + Thumbs() { + const QString path = QStringLiteral(":/qt/qml/PxModRim/thumbs.js"); + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QString script = QString::fromUtf8(file.readAll()); + const QString pragma = QStringLiteral(".pragma library"); + if (script.startsWith(pragma)) script.remove(0, pragma.size()); + const QJSValue result = m_engine.evaluate(script, path); + REQUIRE(!result.isError()); + m_sized = m_engine.globalObject().property(QStringLiteral("sized")); + REQUIRE(m_sized.isCallable()); + } + + QString sized(const QString& url, int width, int height) { + return m_sized + .call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}) + .toString(); + } + + private: + QJSEngine m_engine; + QJSValue m_sized; }; // Spins the event loop until `predicate` holds or kWaitMs elapses. bool waitUntil(const std::function& predicate) { - QElapsedTimer clock; - clock.start(); - while (!predicate()) { - if (clock.hasExpired(kWaitMs)) - return false; - QEventLoop loop; - QTimer::singleShot(10, &loop, [&loop] { loop.quit(); }); - loop.exec(); - } - return true; + QElapsedTimer clock; + clock.start(); + while (!predicate()) { + if (clock.hasExpired(kWaitMs)) return false; + QEventLoop loop; + QTimer::singleShot(10, &loop, [&loop] { loop.quit(); }); + loop.exec(); + } + return true; } // Runs the teardown when a REQUIRE/CHECK failure unwinds the test body. struct Cleanup { - std::function run; - ~Cleanup() { run(); } + std::function run; + ~Cleanup() { run(); } }; } // namespace TEST_CASE("thumbnail leaves other URLs unchanged") { - Thumbs thumbs; - const QStringList urls{ - QStringLiteral("https://example.com/image.png"), - QStringLiteral("https://notsteamstatic.com/image.png"), - QStringLiteral("https://steamusercontent.com.example.com/image.png"), - QStringLiteral("https://user@steamstatic.com/image.png"), - QStringLiteral(""), - }; - for (const QString& url : urls) { - INFO(url.toStdString()); - CHECK(thumbs.sized(url, 256, 128) == url); - } + Thumbs thumbs; + const QStringList urls{ + QStringLiteral("https://example.com/image.png"), + QStringLiteral("https://notsteamstatic.com/image.png"), + QStringLiteral("https://steamusercontent.com.example.com/image.png"), + QStringLiteral("https://user@steamstatic.com/image.png"), + QStringLiteral(""), + }; + for (const QString& url : urls) { + INFO(url.toStdString()); + CHECK(thumbs.sized(url, 256, 128) == url); + } } TEST_CASE("thumbnail replaces resize query before fragment") { - Thumbs thumbs; - for (const char* host : {"images.steamusercontent.com", "steamstatic.com", "cdn.steamstatic.com"}) { - const QString hostName = QString::fromLatin1(host); - INFO(host); - const QString url = QStringLiteral("https://%1/image.png?token=abc&imw=720&imh=360&ima=fit&impolicy=Letterbox#preview") - .arg(hostName); - const QString expected = QStringLiteral("https://%1/image.png?token=abc&imw=256&imh=128&ima=fit&impolicy=Letterbox#preview") - .arg(hostName); - const QString resized = thumbs.sized(url, 256, 128); - CHECK(resized == expected); - CHECK(thumbs.sized(resized, 256, 128) == expected); - } + Thumbs thumbs; + for (const char* host : {"images.steamusercontent.com", "steamstatic.com", + "cdn.steamstatic.com"}) { + const QString hostName = QString::fromLatin1(host); + INFO(host); + const QString url = QStringLiteral( + "https://%1/" + "image.png?token=abc&imw=720&imh=360&ima=fit&" + "impolicy=Letterbox#preview") + .arg(hostName); + const QString expected = QStringLiteral( + "https://%1/" + "image.png?token=abc&imw=256&imh=128&ima=fit&" + "impolicy=Letterbox#preview") + .arg(hostName); + const QString resized = thumbs.sized(url, 256, 128); + CHECK(resized == expected); + CHECK(thumbs.sized(resized, 256, 128) == expected); + } } TEST_CASE("hidden thumbnail unloads and can load again") { - QTemporaryDir dir; - REQUIRE(dir.isValid()); - - QImage image(64, 32, QImage::Format_RGB32); - image.fill(0xFF00FF00); - const QString imagePath = dir.filePath(QStringLiteral("preview.png")); - REQUIRE(image.save(imagePath)); - - const QString sourcePath = dir.filePath(QStringLiteral("Image.qml")); - { - QFile source(sourcePath); - REQUIRE(source.open(QIODevice::WriteOnly | QIODevice::Text)); - const QString text = QStringLiteral( - "import QtQuick\n" - "import \"%1\" as Workshop\n" - "Workshop.CatalogImage { previewUrl: \"%2\" }\n") - .arg(kWorkshopDir, - QUrl::fromLocalFile(imagePath).toString()); - REQUIRE(source.write(text.toUtf8()) >= 0); - } - - QStringList warnings; - auto owner = std::make_unique(); - auto* engine = new QQmlEngine(owner.get()); - QObject::connect(engine, &QQmlEngine::warnings, owner.get(), [&warnings](const QList& errors) { - for (const QQmlError& error : errors) - warnings.append(error.toString()); - }); - - auto* widget = new QQuickWidget(engine, owner.get()); - widget->setResizeMode(QQuickWidget::SizeRootObjectToView); - widget->resize(200, 110); - widget->setSource(QUrl::fromLocalFile(sourcePath)); - owner->show(); - widget->show(); - const Cleanup cleanup{[&] { - delete widget; - delete engine; - }}; - - QQuickItem* root = widget->rootObject(); - REQUIRE(root != nullptr); - - // QQuickImageBase::Status: Null = 0, Ready = 1. - constexpr int kNull = 0; - constexpr int kReady = 1; - REQUIRE(waitUntil([&] { return root->property("status").toInt() == kReady; })); - root->setVisible(false); - REQUIRE(waitUntil([&] { return root->property("status").toInt() == kNull; })); - root->setVisible(true); - widget->resize(300, 150); - REQUIRE(waitUntil([&] { return root->property("status").toInt() == kReady; })); - CHECK(widget->grabFramebuffer().pixelColor(150, 75).name() == QStringLiteral("#00ff00")); - INFO(warnings.join(QLatin1Char('\n')).toStdString()); - CHECK(warnings.isEmpty()); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + QImage image(64, 32, QImage::Format_RGB32); + image.fill(0xFF00FF00); + const QString imagePath = dir.filePath(QStringLiteral("preview.png")); + REQUIRE(image.save(imagePath)); + + const QString sourcePath = dir.filePath(QStringLiteral("Image.qml")); + { + QFile source(sourcePath); + REQUIRE(source.open(QIODevice::WriteOnly | QIODevice::Text)); + const QString text = + QStringLiteral( + "import QtQuick\n" + "import \"%1\" as Workshop\n" + "Workshop.CatalogImage { previewUrl: \"%2\" }\n") + .arg(kWorkshopDir, QUrl::fromLocalFile(imagePath).toString()); + REQUIRE(source.write(text.toUtf8()) >= 0); + } + + QStringList warnings; + auto owner = std::make_unique(); + auto* engine = new QQmlEngine(owner.get()); + QObject::connect(engine, &QQmlEngine::warnings, owner.get(), + [&warnings](const QList& errors) { + for (const QQmlError& error : errors) + warnings.append(error.toString()); + }); + + auto* widget = new QQuickWidget(engine, owner.get()); + widget->setResizeMode(QQuickWidget::SizeRootObjectToView); + widget->resize(200, 110); + widget->setSource(QUrl::fromLocalFile(sourcePath)); + owner->show(); + widget->show(); + const Cleanup cleanup{[&] { + delete widget; + delete engine; + }}; + + QQuickItem* root = widget->rootObject(); + REQUIRE(root != nullptr); + + // QQuickImageBase::Status: Null = 0, Ready = 1. + constexpr int kNull = 0; + constexpr int kReady = 1; + REQUIRE( + waitUntil([&] { return root->property("status").toInt() == kReady; })); + root->setVisible(false); + REQUIRE(waitUntil([&] { return root->property("status").toInt() == kNull; })); + root->setVisible(true); + widget->resize(300, 150); + REQUIRE( + waitUntil([&] { return root->property("status").toInt() == kReady; })); + CHECK(widget->grabFramebuffer().pixelColor(150, 75).name() == + QStringLiteral("#00ff00")); + INFO(warnings.join(QLatin1Char('\n')).toStdString()); + CHECK(warnings.isEmpty()); } diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp index 61a4244..f5010b4 100644 --- a/tests/ui/test_workshop_installed.cpp +++ b/tests/ui/test_workshop_installed.cpp @@ -1,8 +1,7 @@ -#include - +#include #include -#include +#include #include "ui/plugins/workshop/installed.hpp" @@ -11,81 +10,90 @@ using px::modrim::workshop::InstalledList; using px::modrim::workshop::kPage; namespace { -CatalogMod installedMod(std::string id, std::string title, std::string author = "Someone", - std::vector tags = {}) { - CatalogMod mod; - mod.id = std::move(id); - mod.title = std::move(title); - mod.author.name = std::move(author); - mod.tags = std::move(tags); - return mod; +CatalogMod installedMod(std::string id, std::string title, + std::string author = "Someone", + std::vector tags = {}) { + CatalogMod mod; + mod.id = std::move(id); + mod.title = std::move(title); + mod.author.name = std::move(author); + mod.tags = std::move(tags); + return mod; } QStringList ids(const std::vector& mods) { - QStringList result; - for (const auto& mod : mods) result.append(QString::fromStdString(mod.id)); - return result; + QStringList result; + for (const auto& mod : mods) result.append(QString::fromStdString(mod.id)); + return result; } } // namespace -TEST_CASE("workshop installed search matches title author id and tags case insensitively") { - InstalledList installed; - REQUIRE_FALSE(installed.loaded()); - REQUIRE(installed.total() == 0); - REQUIRE(installed.visible().empty()); - REQUIRE(installed.nextPage().empty()); - installed.search(""); - installed.replace({installedMod("11", "Harmony", "Brrainz"), installedMod("12", "HugsLib", "Someone", {"Library"}), - installedMod("999", "Other")}); - REQUIRE(installed.loaded()); - REQUIRE(installed.mods().size() == 3); - const std::vector> cases{{"harm", {"11"}}, {"BRRAINZ", {"11"}}, - {"library", {"12"}}, {"999", {"999"}}, {"", {"11", "12", "999"}}, {"nothing", {}}}; - for (const auto& [query, expected] : cases) { - CAPTURE(query.toStdString()); - installed.search(query); - REQUIRE(ids(installed.visible()) == expected); - REQUIRE(installed.total() == expected.size()); - } +TEST_CASE( + "workshop installed search matches title author id and tags case " + "insensitively") { + InstalledList installed; + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.total() == 0); + REQUIRE(installed.visible().empty()); + REQUIRE(installed.nextPage().empty()); + installed.search(""); + installed.replace({installedMod("11", "Harmony", "Brrainz"), + installedMod("12", "HugsLib", "Someone", {"Library"}), + installedMod("999", "Other")}); + REQUIRE(installed.loaded()); + REQUIRE(installed.mods().size() == 3); + const std::vector> cases{ + {"harm", {"11"}}, {"BRRAINZ", {"11"}}, {"library", {"12"}}, + {"999", {"999"}}, {"", {"11", "12", "999"}}, {"nothing", {}}}; + for (const auto& [query, expected] : cases) { + CAPTURE(query.toStdString()); + installed.search(query); + REQUIRE(ids(installed.visible()) == expected); + REQUIRE(installed.total() == expected.size()); + } } TEST_CASE("workshop installed paging reveals one page and search restarts it") { - InstalledList installed; - std::vector mods; - for (int i = 0; i < kPage * 2 + 5; ++i) mods.push_back(installedMod(std::to_string(i), "Mod")); - installed.replace(mods); - REQUIRE(installed.visible().size() == kPage); - REQUIRE(installed.hasMore()); - const auto second = installed.nextPage(); - REQUIRE(second.size() == kPage); - REQUIRE(second.front().id == std::to_string(kPage)); - REQUIRE(installed.nextPage().size() == 5); - REQUIRE_FALSE(installed.hasMore()); - REQUIRE(installed.nextPage().empty()); - installed.search(""); - REQUIRE(installed.visible().size() == kPage); - REQUIRE(installed.total() == kPage * 2 + 5); - installed.nextPage(); - installed.resetPaging(); - REQUIRE(installed.visible().size() == kPage); - REQUIRE(installed.hasMore()); + InstalledList installed; + std::vector mods; + for (int i = 0; i < kPage * 2 + 5; ++i) + mods.push_back(installedMod(std::to_string(i), "Mod")); + installed.replace(mods); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); + const auto second = installed.nextPage(); + REQUIRE(second.size() == kPage); + REQUIRE(second.front().id == std::to_string(kPage)); + REQUIRE(installed.nextPage().size() == 5); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); + installed.search(""); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.total() == kPage * 2 + 5); + installed.nextPage(); + installed.resetPaging(); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); } -TEST_CASE("workshop replacing installed mods preserves query and current paging") { - InstalledList installed; - installed.search("mod"); - std::vector mods; - for (int i = 0; i < kPage * 3; ++i) mods.push_back(installedMod(std::to_string(i), "Mod")); - installed.replace(mods); - installed.nextPage(); - installed.replace(mods); - REQUIRE(installed.visible().size() == kPage * 2); - installed.replace({installedMod("match", "Mod"), installedMod("other", "Other")}); - REQUIRE(ids(installed.visible()) == QStringList{"match"}); - REQUIRE(installed.total() == 1); - REQUIRE_FALSE(installed.hasMore()); - REQUIRE(installed.nextPage().empty()); - installed.replace({}); - REQUIRE_FALSE(installed.loaded()); - REQUIRE(installed.visible().empty()); +TEST_CASE( + "workshop replacing installed mods preserves query and current paging") { + InstalledList installed; + installed.search("mod"); + std::vector mods; + for (int i = 0; i < kPage * 3; ++i) + mods.push_back(installedMod(std::to_string(i), "Mod")); + installed.replace(mods); + installed.nextPage(); + installed.replace(mods); + REQUIRE(installed.visible().size() == kPage * 2); + installed.replace( + {installedMod("match", "Mod"), installedMod("other", "Other")}); + REQUIRE(ids(installed.visible()) == QStringList{"match"}); + REQUIRE(installed.total() == 1); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); + installed.replace({}); + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.visible().empty()); } diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp index 66219c6..7dc54ef 100644 --- a/tests/ui/test_workshop_models.cpp +++ b/tests/ui/test_workshop_models.cpp @@ -1,9 +1,8 @@ -#include - +#include #include #include -#include +#include #include "ui/panels/mod_info_data.hpp" #include "ui/plugins/workshop/models.hpp" @@ -13,216 +12,272 @@ using namespace px::modrim::workshop; namespace { CatalogMod mod(std::string id = "1") { - CatalogMod value; - value.id = std::move(id); - value.title = "Mod " + value.id; - value.author = Author{"1", "Author", std::nullopt}; - value.description = "[b]Description[/b]"; - value.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=" + value.id; - value.tags = {"Utility"}; - value.supported_versions = {"1.5"}; - value.updated_at = 100; - value.file_size = "1048576"; - return value; + CatalogMod value; + value.id = std::move(id); + value.title = "Mod " + value.id; + value.author = Author{"1", "Author", std::nullopt}; + value.description = "[b]Description[/b]"; + value.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=" + value.id; + value.tags = {"Utility"}; + value.supported_versions = {"1.5"}; + value.updated_at = 100; + value.file_size = "1048576"; + return value; } InstallState missing(const CatalogMod&) { return InstallState::kMissing; } InstallState outdated(const CatalogMod&) { return InstallState::kOutdated; } int role(const CatalogListModel& model, const QByteArray& name) { - const auto names = model.roleNames(); - for (auto it = names.cbegin(); it != names.cend(); ++it) if (it.value() == name) return it.key(); - FAIL("Missing role " << name.constData()); - return -1; + const auto names = model.roleNames(); + for (auto it = names.cbegin(); it != names.cend(); ++it) + if (it.value() == name) return it.key(); + FAIL("Missing role " << name.constData()); + return -1; } } // namespace TEST_CASE("workshop model rejects unsafe URLs and preserves web URLs") { - for (const auto* url : {"javascript:alert(1)", "file:///tmp/test", "data:text/plain,hi", "//example.com", - "https:///path", "https://example.com/\nsecret", "https://[bad", "https://example.com/a b"}) { - CAPTURE(url); - REQUIRE(safeUrl(QString::fromUtf8(url)).isEmpty()); - } - for (const auto* url : {"https://example.com/path", "http://example.com", "HTTPS://example.com"}) { - REQUIRE(safeUrl(QString::fromUtf8(url)) == QString::fromUtf8(url)); - } + for (const auto* url : + {"javascript:alert(1)", "file:///tmp/test", "data:text/plain,hi", + "//example.com", "https:///path", "https://example.com/\nsecret", + "https://[bad", "https://example.com/a b"}) { + CAPTURE(url); + REQUIRE(safeUrl(QString::fromUtf8(url)).isEmpty()); + } + for (const auto* url : {"https://example.com/path", "http://example.com", + "HTTPS://example.com"}) { + REQUIRE(safeUrl(QString::fromUtf8(url)) == QString::fromUtf8(url)); + } } TEST_CASE("workshop catalog roles paging append deduplication and reset") { - QObject owner; - CatalogListModel model(&owner); - const QList rows{itemRow(mod("1"), outdated, "1.6"), itemRow(mod("2"), outdated, "1.6"), - itemRow(mod("3"), outdated, "1.6")}; - int notifications = 0; - QObject::connect(&model, &CatalogListModel::changed, [&] { ++notifications; }); - model.setPage({rows[0], rows[1]}, 10, QStringLiteral("page2")); - REQUIRE(model.hasMore()); - REQUIRE(model.cursor() == std::optional(QStringLiteral("page2"))); - REQUIRE(model.count() == 2); - REQUIRE(model.total() == 10); - QSet names; - for (const auto& name : model.roleNames()) names.insert(QString::fromUtf8(name)); - REQUIRE(names == QSet(rows[0].keyBegin(), rows[0].keyEnd())); - REQUIRE(role(model, "itemId") == Qt::UserRole + 1); - REQUIRE(role(model, "compatLabel") == Qt::UserRole + 25); - const int idRole = role(model, "itemId"); - REQUIRE(model.data(model.index(0), idRole).toString() == "1"); - model.setPage({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); - REQUIRE(model.count() == 3); - REQUIRE_FALSE(model.hasMore()); - REQUIRE(model.data(model.index(2), idRole).toString() == "3"); - REQUIRE_FALSE(model.data({}, idRole).isValid()); - REQUIRE_FALSE(model.data(model.index(0), Qt::DisplayRole).isValid()); - REQUIRE_FALSE(model.data(model.index(0), Qt::UserRole + 26).isValid()); - REQUIRE(model.rowCount(model.index(0)) == 0); - REQUIRE(model.rowCount() == 3); - model.setPage({}, 0, std::nullopt); - REQUIRE(model.count() == 0); - REQUIRE(model.total() == 0); - REQUIRE(notifications == 3); + QObject owner; + CatalogListModel model(&owner); + const QList rows{itemRow(mod("1"), outdated, "1.6"), + itemRow(mod("2"), outdated, "1.6"), + itemRow(mod("3"), outdated, "1.6")}; + int notifications = 0; + QObject::connect(&model, &CatalogListModel::changed, + [&] { ++notifications; }); + model.setPage({rows[0], rows[1]}, 10, QStringLiteral("page2")); + REQUIRE(model.hasMore()); + REQUIRE(model.cursor() == std::optional(QStringLiteral("page2"))); + REQUIRE(model.count() == 2); + REQUIRE(model.total() == 10); + QSet names; + for (const auto& name : model.roleNames()) + names.insert(QString::fromUtf8(name)); + REQUIRE(names == QSet(rows[0].keyBegin(), rows[0].keyEnd())); + REQUIRE(role(model, "itemId") == Qt::UserRole + 1); + REQUIRE(role(model, "compatLabel") == Qt::UserRole + 25); + const int idRole = role(model, "itemId"); + REQUIRE(model.data(model.index(0), idRole).toString() == "1"); + model.setPage({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); + REQUIRE(model.count() == 3); + REQUIRE_FALSE(model.hasMore()); + REQUIRE(model.data(model.index(2), idRole).toString() == "3"); + REQUIRE_FALSE(model.data({}, idRole).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::DisplayRole).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::UserRole + 26).isValid()); + REQUIRE(model.rowCount(model.index(0)) == 0); + REQUIRE(model.rowCount() == 3); + model.setPage({}, 0, std::nullopt); + REQUIRE(model.count() == 0); + REQUIRE(model.total() == 0); + REQUIRE(notifications == 3); } -TEST_CASE("workshop refreshing a page changes only changed rows without resetting") { - CatalogListModel model; - auto first = mod("1"), second = mod("2"), third = mod("3"); - auto show = [&] { model.setPage({itemRow(first, missing, "1.5"), itemRow(second, missing, "1.5"), - itemRow(third, missing, "1.5")}, 3, std::nullopt); }; - show(); - int resets = 0; - QList changed; - QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); - QObject::connect(&model, &QAbstractItemModel::dataChanged, - [&](const QModelIndex& firstIndex, const QModelIndex&) { changed.append(firstIndex.row()); }); - first.subscriptions = 500; - second.title = "Renamed"; - show(); - REQUIRE(resets == 0); - REQUIRE(changed == QList{1}); - REQUIRE(model.data(model.index(1), role(model, "title")).toString() == "Renamed"); - auto reversed = model.rows(); - std::reverse(reversed.begin(), reversed.end()); - model.setPage(reversed, 3, std::nullopt); - REQUIRE(resets == 1); - model.refreshRows([](const QVariantMap& row) { auto copy = row; copy["queued"] = true; return copy; }); - REQUIRE(model.rows().front().value("queued").toBool()); - REQUIRE(changed.back() == 0); - model.setPage({}, 0, std::nullopt); - changed.clear(); - model.refreshRows([](const QVariantMap& row) { return row; }); - REQUIRE(changed.isEmpty()); +TEST_CASE( + "workshop refreshing a page changes only changed rows without resetting") { + CatalogListModel model; + auto first = mod("1"), second = mod("2"), third = mod("3"); + auto show = [&] { + model.setPage( + {itemRow(first, missing, "1.5"), itemRow(second, missing, "1.5"), + itemRow(third, missing, "1.5")}, + 3, std::nullopt); + }; + show(); + int resets = 0; + QList changed; + QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); + QObject::connect(&model, &QAbstractItemModel::dataChanged, + [&](const QModelIndex& firstIndex, const QModelIndex&) { + changed.append(firstIndex.row()); + }); + first.subscriptions = 500; + second.title = "Renamed"; + show(); + REQUIRE(resets == 0); + REQUIRE(changed == QList{1}); + REQUIRE(model.data(model.index(1), role(model, "title")).toString() == + "Renamed"); + auto reversed = model.rows(); + std::reverse(reversed.begin(), reversed.end()); + model.setPage(reversed, 3, std::nullopt); + REQUIRE(resets == 1); + model.refreshRows([](const QVariantMap& row) { + auto copy = row; + copy["queued"] = true; + return copy; + }); + REQUIRE(model.rows().front().value("queued").toBool()); + REQUIRE(changed.back() == 0); + model.setPage({}, 0, std::nullopt); + changed.clear(); + model.refreshRows([](const QVariantMap& row) { return row; }); + REQUIRE(changed.isEmpty()); } -TEST_CASE("workshop mod rows preserve state sizes versions descriptions and compatibility") { - auto value = mod(); - auto row = itemRow(value, outdated, "1.6"); - REQUIRE(row.value("actionLabel").toString() == "Update"); - REQUIRE(row.value("stateLabel").toString() == "Update available"); - REQUIRE(row.value("incompatible").toBool()); - REQUIRE(row.value("fileSize").toString() == "1.0 MB"); - REQUIRE(row.value("description").toString().isEmpty()); - REQUIRE(itemRow(value, missing, "1.5", true).value("description").toString() - == px::modrim::descriptionToHtml(QString::fromStdString(value.description), "bbcode")); - value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; - value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; - row = itemRow(value, missing, "1.5"); - REQUIRE(row.value("versions").toString() == "1.2, 1.2.1, 1.5, 1.10"); - REQUIRE(row.value("tags").toString() == QStringLiteral("Mod · Utility")); - struct Compatibility { std::vector versions; QString game; bool incompatible; QString label; }; - const std::vector compatibilityCases{ - {{"1.5"}, "1.5", false, "Works with 1.5"}, - {{"1.4"}, "1.5", true, "Not for 1.5"}, - {{}, "1.5", false, "Version not stated"}, {{"1.5"}, "", false, ""}}; - for (const auto& scenario : compatibilityCases) { - value.supported_versions = scenario.versions; - value.incompatible = scenario.incompatible; - REQUIRE(itemRow(value, missing, scenario.game).value("compatLabel").toString() == scenario.label); - } - value.author = {}; - value.file_size.reset(); - row = itemRow(value, missing, ""); - REQUIRE(row.value("author").toString() == "Unknown author"); - REQUIRE(row.value("fileSize").toString() == "Unknown"); +TEST_CASE( + "workshop mod rows preserve state sizes versions descriptions and " + "compatibility") { + auto value = mod(); + auto row = itemRow(value, outdated, "1.6"); + REQUIRE(row.value("actionLabel").toString() == "Update"); + REQUIRE(row.value("stateLabel").toString() == "Update available"); + REQUIRE(row.value("incompatible").toBool()); + REQUIRE(row.value("fileSize").toString() == "1.0 MB"); + REQUIRE(row.value("description").toString().isEmpty()); + REQUIRE( + itemRow(value, missing, "1.5", true).value("description").toString() == + px::modrim::descriptionToHtml(QString::fromStdString(value.description), + "bbcode")); + value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; + value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; + row = itemRow(value, missing, "1.5"); + REQUIRE(row.value("versions").toString() == "1.2, 1.2.1, 1.5, 1.10"); + REQUIRE(row.value("tags").toString() == QStringLiteral("Mod · Utility")); + struct Compatibility { + std::vector versions; + QString game; + bool incompatible; + QString label; + }; + const std::vector compatibilityCases{ + {{"1.5"}, "1.5", false, "Works with 1.5"}, + {{"1.4"}, "1.5", true, "Not for 1.5"}, + {{}, "1.5", false, "Version not stated"}, + {{"1.5"}, "", false, ""}}; + for (const auto& scenario : compatibilityCases) { + value.supported_versions = scenario.versions; + value.incompatible = scenario.incompatible; + REQUIRE(itemRow(value, missing, scenario.game) + .value("compatLabel") + .toString() == scenario.label); + } + value.author = {}; + value.file_size.reset(); + row = itemRow(value, missing, ""); + REQUIRE(row.value("author").toString() == "Unknown author"); + REQUIRE(row.value("fileSize").toString() == "Unknown"); } TEST_CASE("workshop vote approval uses counts and formats thousands") { - struct Case { std::optional votes; QString approval, up, down; }; - const std::vector cases{{std::nullopt, "Unrated", "0", "0"}, {Votes{0, 0, 100}, "Unrated", "0", "0"}, - {Votes{0, 1, {}}, "0%", "0", "1"}, {Votes{1, 0, {}}, "100%", "1", "0"}, - {Votes{1, 2, {}}, "33%", "1", "2"}, {Votes{2, 1, {}}, "67%", "2", "1"}, - {Votes{1000, 3000, {}}, "25%", "1,000", "3,000"}}; - for (const auto& scenario : cases) { - auto value = mod(); - value.votes = scenario.votes; - const auto row = itemRow(value, [](const CatalogMod&) { return InstallState::kInstalled; }, "1.5"); - REQUIRE(row.value("votes").toString() == scenario.approval); - REQUIRE(row.value("votesUp").toString() == scenario.up); - REQUIRE(row.value("votesDown").toString() == scenario.down); - REQUIRE(row.value("actionLabel").toString() == "Installed"); - } + struct Case { + std::optional votes; + QString approval, up, down; + }; + const std::vector cases{ + {std::nullopt, "Unrated", "0", "0"}, + {Votes{0, 0, 100}, "Unrated", "0", "0"}, + {Votes{0, 1, {}}, "0%", "0", "1"}, + {Votes{1, 0, {}}, "100%", "1", "0"}, + {Votes{1, 2, {}}, "33%", "1", "2"}, + {Votes{2, 1, {}}, "67%", "2", "1"}, + {Votes{1000, 3000, {}}, "25%", "1,000", "3,000"}}; + for (const auto& scenario : cases) { + auto value = mod(); + value.votes = scenario.votes; + const auto row = itemRow( + value, [](const CatalogMod&) { return InstallState::kInstalled; }, + "1.5"); + REQUIRE(row.value("votes").toString() == scenario.approval); + REQUIRE(row.value("votesUp").toString() == scenario.up); + REQUIRE(row.value("votesDown").toString() == scenario.down); + REQUIRE(row.value("actionLabel").toString() == "Installed"); + } } -TEST_CASE("workshop summaries strip bbcode media and truncate at a word boundary") { - REQUIRE(summarize("[h1]Title[/h1] [img]https://x.test/a.png[/img] Adds [url=https://x.test]a thing[/url]") - == "Title Adds a thing"); - REQUIRE(summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept"); - const QString summary = summarize(QStringLiteral("word ").repeated(100)); - REQUIRE(summary.endsWith(QStringLiteral("word…"))); - REQUIRE(summary.size() <= 141); +TEST_CASE( + "workshop summaries strip bbcode media and truncate at a word boundary") { + REQUIRE(summarize("[h1]Title[/h1] [img]https://x.test/a.png[/img] Adds " + "[url=https://x.test]a thing[/url]") == + "Title Adds a thing"); + REQUIRE(summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept"); + const QString summary = summarize(QStringLiteral("word ").repeated(100)); + REQUIRE(summary.endsWith(QStringLiteral("word…"))); + REQUIRE(summary.size() <= 141); } -TEST_CASE("workshop collection rows select previews filter collage and display server size") { - CatalogCollection value; - value.id = "picked:x"; - value.source = CollectionSource::kPicked; - value.title = "C"; - value.author.name = "Curator"; - value.description = "[b]Description[/b]"; - value.previews = {{PreviewType::kVideo, "https://youtube.test/v"}, {PreviewType::kImage, "https://images.test/own.png"}}; - value.member_ids = {"10"}; - value.member_count = 1; - value.member_previews = {"https://images.test/m.png", "javascript:alert(1)"}; - const auto neverInstall = [](const CatalogMod&) { FAIL("Collections must not request install state"); return InstallState::kMissing; }; - auto row = itemRow(value, neverInstall, "1.6", true); - REQUIRE(row.value("previewUrl").toString() == "https://images.test/own.png"); - REQUIRE(row.value("collage").toStringList() == QStringList{"https://images.test/m.png"}); - REQUIRE(row.value("votes").toString() == "Unrated"); - REQUIRE(row.value("votesUp").toString() == "0"); - REQUIRE(row.value("votesDown").toString() == "0"); - REQUIRE_FALSE(row.value("active").toBool()); - REQUIRE(row.value("fileSize").toString() == "Unknown"); - REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); - REQUIRE(row.value("actionLabel").toString() == "Download all"); - REQUIRE(row.value("memberCount").toInt() == 1); - REQUIRE(row.value("description").toString() == px::modrim::descriptionToHtml("[b]Description[/b]", "text")); - value.total_size = "3145728"; - value.preview_url = "https://images.test/explicit.png"; - value.source = CollectionSource::kSteam; - row = itemRow(value, neverInstall, "1.6"); - REQUIRE(row.value("fileSize").toString() == "3.0 MB"); - REQUIRE(row.value("previewUrl").toString() == "https://images.test/explicit.png"); - REQUIRE(row.value("sourceLabel").toString() == "Steam collection"); +TEST_CASE( + "workshop collection rows select previews filter collage and display " + "server size") { + CatalogCollection value; + value.id = "picked:x"; + value.source = CollectionSource::kPicked; + value.title = "C"; + value.author.name = "Curator"; + value.description = "[b]Description[/b]"; + value.previews = {{PreviewType::kVideo, "https://youtube.test/v"}, + {PreviewType::kImage, "https://images.test/own.png"}}; + value.member_ids = {"10"}; + value.member_count = 1; + value.member_previews = {"https://images.test/m.png", "javascript:alert(1)"}; + const auto neverInstall = [](const CatalogMod&) { + FAIL("Collections must not request install state"); + return InstallState::kMissing; + }; + auto row = itemRow(value, neverInstall, "1.6", true); + REQUIRE(row.value("previewUrl").toString() == "https://images.test/own.png"); + REQUIRE(row.value("collage").toStringList() == + QStringList{"https://images.test/m.png"}); + REQUIRE(row.value("votes").toString() == "Unrated"); + REQUIRE(row.value("votesUp").toString() == "0"); + REQUIRE(row.value("votesDown").toString() == "0"); + REQUIRE_FALSE(row.value("active").toBool()); + REQUIRE(row.value("fileSize").toString() == "Unknown"); + REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); + REQUIRE(row.value("actionLabel").toString() == "Download all"); + REQUIRE(row.value("memberCount").toInt() == 1); + REQUIRE(row.value("description").toString() == + px::modrim::descriptionToHtml("[b]Description[/b]", "text")); + value.total_size = "3145728"; + value.preview_url = "https://images.test/explicit.png"; + value.source = CollectionSource::kSteam; + row = itemRow(value, neverInstall, "1.6"); + REQUIRE(row.value("fileSize").toString() == "3.0 MB"); + REQUIRE(row.value("previewUrl").toString() == + "https://images.test/explicit.png"); + REQUIRE(row.value("sourceLabel").toString() == "Steam collection"); } -TEST_CASE("workshop collection version declarations override conflicting member summary") { - CatalogCollection value; - value.no_common_version = true; - auto row = itemRow(value, missing, "1.6"); - REQUIRE(row.value("compatLabel").toString() == "No common version"); - REQUIRE(row.value("incompatible").toBool()); - value.no_common_version = false; +TEST_CASE( + "workshop collection version declarations override conflicting member " + "summary") { + CatalogCollection value; + value.no_common_version = true; + auto row = itemRow(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "No common version"); + REQUIRE(row.value("incompatible").toBool()); + value.no_common_version = false; + row = itemRow(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "Version not stated"); + REQUIRE_FALSE(row.value("incompatible").toBool()); + value.no_common_version = true; + const std::vector< + std::pair, std::vector>> + cases{ + {{}, {"Mod", "1.6"}}, {{"1.5"}, {"Mod", "1.6"}}, {{"1.6"}, {"Mod"}}}; + for (const auto& [versions, tags] : cases) { + value.supported_versions = versions; + value.tags = tags; row = itemRow(value, missing, "1.6"); - REQUIRE(row.value("compatLabel").toString() == "Version not stated"); REQUIRE_FALSE(row.value("incompatible").toBool()); - value.no_common_version = true; - const std::vector, std::vector>> cases{ - {{}, {"Mod", "1.6"}}, {{"1.5"}, {"Mod", "1.6"}}, {{"1.6"}, {"Mod"}}}; - for (const auto& [versions, tags] : cases) { - value.supported_versions = versions; - value.tags = tags; - row = itemRow(value, missing, "1.6"); - REQUIRE_FALSE(row.value("incompatible").toBool()); - REQUIRE(row.value("compatLabel").toString() == "Works with 1.6"); - REQUIRE(row.value("versions").toString().contains("1.6")); - REQUIRE(row.value("tags").toString() == "Mod"); - } + REQUIRE(row.value("compatLabel").toString() == "Works with 1.6"); + REQUIRE(row.value("versions").toString().contains("1.6")); + REQUIRE(row.value("tags").toString() == "Mod"); + } } diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index cc0277f..fe8d159 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -1,24 +1,24 @@ -#include - -#include -#include #include #include #include #include #include +#include +#include + +#include -#include "test_support.hpp" -#include "ui/theme/qml_theme.hpp" #include "../core/workshop/support.hpp" #include "core/context.hpp" #include "core/downloads/downloader.hpp" #include "core/mod_service.hpp" +#include "test_support.hpp" #include "ui/components/svg_provider.hpp" #include "ui/context.hpp" #include "ui/navigation.hpp" #include "ui/plugins/workshop/plugin.hpp" #include "ui/plugins/workshop/view.hpp" +#include "ui/theme/qml_theme.hpp" namespace { using namespace px::modrim; @@ -32,392 +32,514 @@ using workshop_test::wire; // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct Batch { - std::vector items; - std::vector unavailable_ids; + std::vector items; + std::vector unavailable_ids; }; // NOLINTEND(readability-identifier-naming) class DownloaderFixture : public Downloader { -public: - explicit DownloaderFixture(DownloadManager& manager) - : Downloader(QStringLiteral("test_steam"), QStringLiteral("Steam"), manager) {} - bool isDownloading() const override { return busy; } - QSet activeIds() const override { return active; } - bool accepts(const QString&) const override { return true; } - std::optional updatableId(const ListedMod& mod) const override { return mod.publishedFileId(); } - std::optional lastSynced(const ListedMod&) const override { return 50.; } - QCoro::Task> downloadMods(QStringList ids) override { - calls.append(ids); - co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; - } - void cancel() override {} - bool busy = false; - QSet active; - QList calls; + public: + explicit DownloaderFixture(DownloadManager& manager) + : Downloader(QStringLiteral("test_steam"), QStringLiteral("Steam"), + manager) {} + bool isDownloading() const override { return busy; } + QSet activeIds() const override { return active; } + bool accepts(const QString&) const override { return true; } + std::optional updatableId(const ListedMod& mod) const override { + return mod.publishedFileId(); + } + std::optional lastSynced(const ListedMod&) const override { + return 50.; + } + QCoro::Task> downloadMods(QStringList ids) override { + calls.append(ids); + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + void cancel() override {} + bool busy = false; + QSet active; + QList calls; }; struct PanelFixture { - QWidget owner; - QTemporaryDir dir; - std::unique_ptr core = px::ui_test::makeCore(AppConfig{}); - AppContext app{*core}; - DownloaderFixture downloader{core->downloads()}; - WorkshopCatalog catalog{nullptr, core->downloads(), [this] { return core->allMods().values(); }}; - CatalogMod mod; - CatalogMod dependency; - CatalogCollection collection; - bool fail = false; - bool complete = false; - std::function onRequest; - workshop_test::FakeCatalog server{[this](const HttpRequest& request) { return answer(request); }}; - QQmlEngine* engine = nullptr; - std::unique_ptr view; + QWidget owner; + QTemporaryDir dir; + std::unique_ptr core = px::ui_test::makeCore(AppConfig{}); + AppContext app{*core}; + DownloaderFixture downloader{core->downloads()}; + WorkshopCatalog catalog{nullptr, core->downloads(), + [this] { return core->allMods().values(); }}; + CatalogMod mod; + CatalogMod dependency; + CatalogCollection collection; + bool fail = false; + bool complete = false; + std::function onRequest; + workshop_test::FakeCatalog server{ + [this](const HttpRequest& request) { return answer(request); }}; + QQmlEngine* engine = nullptr; + std::unique_ptr view; - PanelFixture() { - REQUIRE(QApplication::instance()); - REQUIRE(dir.isValid()); - core->downloads().registerDownloader(downloader); - mod.id = "1"; mod.title = "Untrusted title"; - mod.author.name = "Author"; mod.description = "[b]Safe description[/b]"; - mod.tags = {"Utility"}; mod.supported_versions = {"1.5"}; - mod.updated_at = 100; mod.file_size = "1024"; mod.dependencies = {"2"}; - mod.workshop_url = "https://steamcommunity.com/sharedfiles/filedetails/?id=1"; - dependency = mod; dependency.id = "2"; dependency.title = "Dependency"; dependency.dependencies.clear(); - collection.id = "picked:test"; collection.source = CollectionSource::kPicked; - collection.title = "Test collection"; collection.author.name = "Curator"; - collection.description = "Plain text"; - collection.supported_versions = {"1.6"}; collection.member_ids = {"1", "missing"}; collection.member_count = 2; - catalog.setBaseUrl(server.url()); - engine = createQmlEngine(&owner); - engine->rootContext()->setContextProperty(QStringLiteral("Theme"), new px::modrim::Theme(engine)); - view = std::make_unique(*core, engine, &owner, app, catalog); - view->resize(1100, 800); - run(view->load()); - } - ~PanelFixture() { - view.reset(); - run(catalog.shutdown()); - } - HttpResponse answer(const HttpRequest& request) { - if (onRequest) onRequest(request); - if (fail) return {503, R"({"error":{"message":"Catalog unavailable"}})"}; - const auto path = request.path(); - if (path == QStringLiteral("/catalog/discover")) return {200, wire(Discover{{{mod}, 1, {}}, {{collection}, 1, {}}})}; - if (path == QStringLiteral("/catalog/mods")) { - const bool second = request.query().hasQueryItem(QStringLiteral("cursor")); - return {200, wire(CatalogPage{{second ? dependency : mod}, 2, second ? std::nullopt : std::optional("second")})}; - } - if (path == QStringLiteral("/catalog/collections")) return {200, wire(CatalogPage{{collection}, 1, {}})}; - if (path == QStringLiteral("/catalog/mods/1")) return {200, wire(mod)}; - if (path == QStringLiteral("/catalog/mods/2")) return {200, wire(dependency)}; - if (path == QStringLiteral("/catalog/collections/picked/test")) { - auto item = collection; - if (complete) { item.member_ids = {"1"}; item.member_count = 1; } - return {200, wire(CollectionDetail{item, {mod}, complete ? std::vector{} : std::vector{"missing"}, complete})}; - } - if (path == QStringLiteral("/catalog/mods/batch")) return {200, wire(Batch{{mod}, {}})}; - if (path == QStringLiteral("/catalog/resolve")) { - const bool collectionRequest = request.body.find("picked:test") != std::string::npos; - const bool incomplete = collectionRequest && !complete; - return {200, wire(ResolvedDownload{{"1"}, {{"1", mod}, {"2", dependency}}, {"2", "1"}, - incomplete ? std::vector{"missing"} : std::vector{}, - incomplete ? std::vector{"picked:test"} : std::vector{}, !incomplete})}; - } - return {404, R"({"error":{"message":"Not found"}})"}; + PanelFixture() { + REQUIRE(QApplication::instance()); + REQUIRE(dir.isValid()); + core->downloads().registerDownloader(downloader); + mod.id = "1"; + mod.title = "Untrusted title"; + mod.author.name = "Author"; + mod.description = "[b]Safe description[/b]"; + mod.tags = {"Utility"}; + mod.supported_versions = {"1.5"}; + mod.updated_at = 100; + mod.file_size = "1024"; + mod.dependencies = {"2"}; + mod.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=1"; + dependency = mod; + dependency.id = "2"; + dependency.title = "Dependency"; + dependency.dependencies.clear(); + collection.id = "picked:test"; + collection.source = CollectionSource::kPicked; + collection.title = "Test collection"; + collection.author.name = "Curator"; + collection.description = "Plain text"; + collection.supported_versions = {"1.6"}; + collection.member_ids = {"1", "missing"}; + collection.member_count = 2; + catalog.setBaseUrl(server.url()); + engine = createQmlEngine(&owner); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + view = std::make_unique(*core, engine, &owner, app, + catalog); + view->resize(1100, 800); + run(view->load()); + } + ~PanelFixture() { + view.reset(); + run(catalog.shutdown()); + } + HttpResponse answer(const HttpRequest& request) { + if (onRequest) onRequest(request); + if (fail) return {503, R"({"error":{"message":"Catalog unavailable"}})"}; + const auto path = request.path(); + if (path == QStringLiteral("/catalog/discover")) + return {200, wire(Discover{{{mod}, 1, {}}, {{collection}, 1, {}}})}; + if (path == QStringLiteral("/catalog/mods")) { + const bool second = + request.query().hasQueryItem(QStringLiteral("cursor")); + return {200, wire(CatalogPage{ + {second ? dependency : mod}, + 2, + second ? std::nullopt + : std::optional("second")})}; } - QString install(const QString& id, const QString& provider = QStringLiteral("steam")) { - auto installed = std::make_shared(); - REQUIRE(installed->setModPath(dir.filePath(provider + QLatin1Char('/') + id)).has_value()); - installed->providerId = provider; - installed->name = id; - auto mods = core->allMods(); - const auto uuid = installed->uuid(); - mods.insert(uuid, installed); - core->load(std::move(mods), core->activeUuids()); - catalog.invalidateInstalled(); - return uuid; + if (path == QStringLiteral("/catalog/collections")) + return {200, wire(CatalogPage{{collection}, 1, {}})}; + if (path == QStringLiteral("/catalog/mods/1")) return {200, wire(mod)}; + if (path == QStringLiteral("/catalog/mods/2")) + return {200, wire(dependency)}; + if (path == QStringLiteral("/catalog/collections/picked/test")) { + auto item = collection; + if (complete) { + item.member_ids = {"1"}; + item.member_count = 1; + } + return {200, wire(CollectionDetail{ + item, + {mod}, + complete ? std::vector{} + : std::vector{"missing"}, + complete})}; } - qsizetype requests(const QString& path) const { - return std::ranges::count_if(server.requests, [&path](const auto& request) { return request.path() == path; }); + if (path == QStringLiteral("/catalog/mods/batch")) + return {200, wire(Batch{{mod}, {}})}; + if (path == QStringLiteral("/catalog/resolve")) { + const bool collectionRequest = + request.body.find("picked:test") != std::string::npos; + const bool incomplete = collectionRequest && !complete; + return {200, wire(ResolvedDownload{ + {"1"}, + {{"1", mod}, {"2", dependency}}, + {"2", "1"}, + incomplete ? std::vector{"missing"} + : std::vector{}, + incomplete ? std::vector{"picked:test"} + : std::vector{}, + !incomplete})}; } + return {404, R"({"error":{"message":"Not found"}})"}; + } + QString install(const QString& id, + const QString& provider = QStringLiteral("steam")) { + auto installed = std::make_shared(); + REQUIRE( + installed->setModPath(dir.filePath(provider + QLatin1Char('/') + id)) + .has_value()); + installed->providerId = provider; + installed->name = id; + auto mods = core->allMods(); + const auto uuid = installed->uuid(); + mods.insert(uuid, installed); + core->load(std::move(mods), core->activeUuids()); + catalog.invalidateInstalled(); + return uuid; + } + qsizetype requests(const QString& path) const { + return std::ranges::count_if(server.requests, [&path](const auto& request) { + return request.path() == path; + }); + } }; -} +} // namespace -TEST_CASE("workshop view loads discovery and paginates filtered catalog results") { - PanelFixture f; - CHECK(f.view->findChild()->status() == QQuickWidget::Ready); - CHECK(f.view->modsModel->count() == 1); - CHECK(f.view->collectionsModel->count() == 1); - run(f.view->selectTabAsync(QStringLiteral("Mods"))); - run(f.view->filterItems({}, QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); - run(f.view->load(true)); - CHECK(f.view->modsModel->count() == 2); - CHECK_FALSE(f.view->modsModel->hasMore()); - CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("cursor")) == QStringLiteral("second")); - CHECK(f.server.requests.back().query().queryItemValue(QStringLiteral("version")) == QStringLiteral("1.6")); - f.fail = true; - run(f.view->filterItems(QStringLiteral("uncached"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->error() == QStringLiteral("Catalog unavailable")); - f.fail = false; - run(f.view->retryLoad()); - CHECK(f.view->error().isEmpty()); - CHECK(f.view->modsModel->count() == 1); +TEST_CASE( + "workshop view loads discovery and paginates filtered catalog results") { + PanelFixture f; + CHECK(f.view->findChild()->status() == QQuickWidget::Ready); + CHECK(f.view->modsModel->count() == 1); + CHECK(f.view->collectionsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems({}, QStringLiteral("1.6"), QStringLiteral("all"), + QStringLiteral("popular"), {})); + run(f.view->load(true)); + CHECK(f.view->modsModel->count() == 2); + CHECK_FALSE(f.view->modsModel->hasMore()); + CHECK(f.server.requests.back().query().queryItemValue( + QStringLiteral("cursor")) == QStringLiteral("second")); + CHECK(f.server.requests.back().query().queryItemValue( + QStringLiteral("version")) == QStringLiteral("1.6")); + f.fail = true; + run(f.view->filterItems(QStringLiteral("uncached"), QStringLiteral("1.6"), + QStringLiteral("all"), QStringLiteral("popular"), + {})); + CHECK(f.view->error() == QStringLiteral("Catalog unavailable")); + f.fail = false; + run(f.view->retryLoad()); + CHECK(f.view->error().isEmpty()); + CHECK(f.view->modsModel->count() == 1); } TEST_CASE("workshop detail escapes descriptions and lists dependencies") { - PanelFixture f; - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - CHECK(f.view->hasDetail()); - CHECK(f.view->membersModel->count() == 1); - CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("Safe description"))); - run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - CHECK(f.view->detail().value(QStringLiteral("description")).toString().contains(QStringLiteral("<b>Plain text</b>"))); - CHECK(f.view->detail().value(QStringLiteral("warning")).toString().contains(QStringLiteral("missing"))); + PanelFixture f; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->hasDetail()); + CHECK(f.view->membersModel->count() == 1); + CHECK(f.view->detail() + .value(QStringLiteral("description")) + .toString() + .contains(QStringLiteral("Safe description"))); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail() + .value(QStringLiteral("description")) + .toString() + .contains(QStringLiteral("<b>Plain text</b>"))); + CHECK(f.view->detail() + .value(QStringLiteral("warning")) + .toString() + .contains(QStringLiteral("missing"))); } -TEST_CASE("incomplete workshop downloads require explicit partial approval and never activate") { - PanelFixture f; - const auto uuid = f.install(QStringLiteral("1")); - run(f.view->downloadItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - CHECK(f.view->pendingPlan()); - CHECK(f.view->canDownloadAvailable()); - CHECK(f.downloader.calls.isEmpty()); - f.view->downloadAvailable(); - CHECK_FALSE(f.view->pendingPlan()); - REQUIRE(f.downloader.calls.size() == 1); - CHECK(f.downloader.calls.front() == QStringList{QStringLiteral("2"), QStringLiteral("1")}); - CHECK_FALSE(f.core->activeUuids().contains(uuid)); - CHECK(f.view->notice().contains(QStringLiteral("Mods are not activated"))); +TEST_CASE( + "incomplete workshop downloads require explicit partial approval and never " + "activate") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->pendingPlan()); + CHECK(f.view->canDownloadAvailable()); + CHECK(f.downloader.calls.isEmpty()); + f.view->downloadAvailable(); + CHECK_FALSE(f.view->pendingPlan()); + REQUIRE(f.downloader.calls.size() == 1); + CHECK(f.downloader.calls.front() == + QStringList{QStringLiteral("2"), QStringLiteral("1")}); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + CHECK(f.view->notice().contains(QStringLiteral("Mods are not activated"))); } TEST_CASE("workshop partial plan cancellation clears notice without queuing") { - PanelFixture f; - run(f.view->downloadItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - f.view->dismissPlan(); - CHECK_FALSE(f.view->pendingPlan()); - CHECK(f.view->notice().isEmpty()); - CHECK(f.downloader.calls.isEmpty()); + PanelFixture f; + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + f.view->dismissPlan(); + CHECK_FALSE(f.view->pendingPlan()); + CHECK(f.view->notice().isEmpty()); + CHECK(f.downloader.calls.isEmpty()); } TEST_CASE("workshop collection detail falls back to member preview collage") { - PanelFixture f; - f.mod.preview_url = "https://example.com/preview.png"; - run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - CHECK(f.view->detail().value(QStringLiteral("collage")).toStringList() == QStringList{QStringLiteral("https://example.com/preview.png")}); + PanelFixture f; + f.mod.preview_url = "https://example.com/preview.png"; + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("collage")).toStringList() == + QStringList{QStringLiteral("https://example.com/preview.png")}); } -TEST_CASE("workshop collection listing detects partial and fully installed members") { - PanelFixture f; - f.collection.member_ids = {"1", "2"}; f.collection.member_count = 2; - f.install(QStringLiteral("1")); - run(f.view->selectTabAsync(QStringLiteral("Collections"))); - auto row = f.view->collectionsModel->rows().front(); - CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Complete download")); - CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); - f.install(QStringLiteral("2")); - row = f.view->collectionsModel->rows().front(); - CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Installed")); - CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); - f.downloader.active = {QStringLiteral("1")}; - f.core->downloads().busyChanged.notify(true); - row = f.view->collectionsModel->rows().front(); - CHECK(row.value(QStringLiteral("queued")).toBool()); - CHECK(row.value(QStringLiteral("actionLabel")) == QStringLiteral("Downloading")); +TEST_CASE( + "workshop collection listing detects partial and fully installed members") { + PanelFixture f; + f.collection.member_ids = {"1", "2"}; + f.collection.member_count = 2; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Collections"))); + auto row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Complete download")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); + f.install(QStringLiteral("2")); + row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Installed")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); + f.downloader.active = {QStringLiteral("1")}; + f.core->downloads().busyChanged.notify(true); + row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("queued")).toBool()); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Downloading")); } TEST_CASE("disabled workshop catalog does not issue requests") { - PanelFixture f; - f.catalog.setBaseUrl({}); - const auto before = f.server.requests.size(); - run(f.view->load()); - CHECK_FALSE(f.view->configured()); - CHECK_FALSE(f.view->busy()); - CHECK(f.server.requests.size() == before); + PanelFixture f; + f.catalog.setBaseUrl({}); + const auto before = f.server.requests.size(); + run(f.view->load()); + CHECK_FALSE(f.view->configured()); + CHECK_FALSE(f.view->busy()); + CHECK(f.server.requests.size() == before); } TEST_CASE("workshop settings apply catalog URL only on save") { - PanelFixture f; - CatalogSettingsSection section(f.catalog, &f.owner); - REQUIRE(section.set(QStringLiteral("url"), QStringLiteral(" https://example.com/catalog/// "))); - CHECK(f.catalog.settings().url == f.server.url().toStdString()); - f.view->teardown(); - REQUIRE(section.apply().has_value()); - CHECK(f.catalog.settings().url == "https://example.com/catalog"); + PanelFixture f; + CatalogSettingsSection section(f.catalog, &f.owner); + REQUIRE(section.set(QStringLiteral("url"), + QStringLiteral(" https://example.com/catalog/// "))); + CHECK(f.catalog.settings().url == f.server.url().toStdString()); + f.view->teardown(); + REQUIRE(section.apply().has_value()); + CHECK(f.catalog.settings().url == "https://example.com/catalog"); } TEST_CASE("installed workshop search is local and cached across revisits") { - PanelFixture f; - f.install(QStringLiteral("1")); - run(f.view->selectTabAsync(QStringLiteral("Installed"))); - REQUIRE(f.view->modsModel->count() == 1); - const auto fetched = f.requests(QStringLiteral("/catalog/mods/batch")); - run(f.view->filterItems(QStringLiteral("absent"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->modsModel->count() == 0); - run(f.view->filterItems(QStringLiteral("Author"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->modsModel->count() == 1); - run(f.view->selectTabAsync(QStringLiteral("Mods"))); - run(f.view->selectTabAsync(QStringLiteral("Installed"))); - CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == fetched); + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + REQUIRE(f.view->modsModel->count() == 1); + const auto fetched = f.requests(QStringLiteral("/catalog/mods/batch")); + run(f.view->filterItems(QStringLiteral("absent"), {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 0); + run(f.view->filterItems(QStringLiteral("Author"), {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == fetched); } -TEST_CASE("workshop activation toggles one copy and removes every active duplicate") { - PanelFixture f; - const auto first = f.install(QStringLiteral("1")); - const auto second = f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); - run(f.view->toggleActivationAsync(QStringLiteral("1"))); - REQUIRE(f.core->activeUuids().size() == 1); - CHECK((f.core->activeUuids().contains(first) || f.core->activeUuids().contains(second))); - f.core->setActive({first, second}); - run(f.view->toggleActivationAsync(QStringLiteral("1"))); - CHECK(f.core->activeUuids().isEmpty()); - run(f.view->toggleActivationAsync(QStringLiteral("absent"))); - CHECK(f.core->activeUuids().isEmpty()); +TEST_CASE( + "workshop activation toggles one copy and removes every active duplicate") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = + f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + REQUIRE(f.core->activeUuids().size() == 1); + CHECK((f.core->activeUuids().contains(first) || + f.core->activeUuids().contains(second))); + f.core->setActive({first, second}); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + CHECK(f.core->activeUuids().isEmpty()); + run(f.view->toggleActivationAsync(QStringLiteral("absent"))); + CHECK(f.core->activeUuids().isEmpty()); } -TEST_CASE("external workshop activation refreshes listing detail and dependencies") { - PanelFixture f; - const auto first = f.install(QStringLiteral("1")); - const auto second = f.install(QStringLiteral("2")); - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - f.core->setActive({first, second}); - CHECK(f.view->detail().value(QStringLiteral("active")).toBool()); - CHECK(f.view->modsModel->rows().front().value(QStringLiteral("active")).toBool()); - CHECK(f.view->membersModel->rows().front().value(QStringLiteral("active")).toBool()); +TEST_CASE( + "external workshop activation refreshes listing detail and dependencies") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = f.install(QStringLiteral("2")); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + f.core->setActive({first, second}); + CHECK(f.view->detail().value(QStringLiteral("active")).toBool()); + CHECK(f.view->modsModel->rows() + .front() + .value(QStringLiteral("active")) + .toBool()); + CHECK(f.view->membersModel->rows() + .front() + .value(QStringLiteral("active")) + .toBool()); } TEST_CASE("workshop tags accumulate excluding supported version tags") { - PanelFixture f; - f.mod.tags = {"1.5", "Utility", "Animals"}; - run(f.view->selectTabAsync(QStringLiteral("Mods"))); - f.dependency.tags = {"Library"}; - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - CHECK(f.view->tagOptions() == QStringList{QStringLiteral("All tags"), QStringLiteral("Animals"), QStringLiteral("Library"), QStringLiteral("Utility")}); + PanelFixture f; + f.mod.tags = {"1.5", "Utility", "Animals"}; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + f.dependency.tags = {"Library"}; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->tagOptions() == + QStringList{QStringLiteral("All tags"), QStringLiteral("Animals"), + QStringLiteral("Library"), QStringLiteral("Utility")}); } -TEST_CASE("workshop detail back walks history before restoring listing filters") { - PanelFixture f; - run(f.view->selectTabAsync(QStringLiteral("Mods"))); - run(f.view->filterItems(QStringLiteral("query"), QStringLiteral("1.6"), QStringLiteral("all"), QStringLiteral("popular"), {})); - run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - CHECK(f.view->backLabel() == QStringLiteral("Back to Test collection")); - run(f.view->goBack()); - CHECK(f.view->detail().value(QStringLiteral("itemId")) == QStringLiteral("picked:test")); - run(f.view->goBack()); - CHECK_FALSE(f.view->hasDetail()); - CHECK(f.view->tab() == QStringLiteral("Mods")); - CHECK(f.view->searchQuery() == QStringLiteral("query")); +TEST_CASE( + "workshop detail back walks history before restoring listing filters") { + PanelFixture f; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems(QStringLiteral("query"), QStringLiteral("1.6"), + QStringLiteral("all"), QStringLiteral("popular"), + {})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->backLabel() == QStringLiteral("Back to Test collection")); + run(f.view->goBack()); + CHECK(f.view->detail().value(QStringLiteral("itemId")) == + QStringLiteral("picked:test")); + run(f.view->goBack()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK(f.view->searchQuery() == QStringLiteral("query")); } TEST_CASE("workshop routes tabs items and settings through app navigator") { - PanelFixture f; - QString route; - f.app.setNavigator([&route](const QString& value) { route = value; }); - f.view->openItem(QStringLiteral("picked:test"), QStringLiteral("collection")); - REQUIRE(parseRoute(route)); - CHECK(parseRoute(route)->path == QStringList{QStringLiteral("collection"), QStringLiteral("picked:test")}); - run(f.view->followRoute(parseRoute(route)->path)); - CHECK(f.view->hasDetail()); - run(f.view->followRoute({QStringLiteral("mods")})); - CHECK(f.view->tab() == QStringLiteral("Mods")); - CHECK_FALSE(f.view->hasDetail()); - f.view->openSettings(); - REQUIRE(parseRoute(route)); - CHECK(parseRoute(route)->viewId == QStringLiteral("settings")); + PanelFixture f; + QString route; + f.app.setNavigator([&route](const QString& value) { route = value; }); + f.view->openItem(QStringLiteral("picked:test"), QStringLiteral("collection")); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->path == QStringList{QStringLiteral("collection"), + QStringLiteral("picked:test")}); + run(f.view->followRoute(parseRoute(route)->path)); + CHECK(f.view->hasDetail()); + run(f.view->followRoute({QStringLiteral("mods")})); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK_FALSE(f.view->hasDetail()); + f.view->openSettings(); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->viewId == QStringLiteral("settings")); } TEST_CASE("workshop starred collections stay in favourites until unstarred") { - PanelFixture f; - f.view->toggleFavourite(QStringLiteral("picked:test")); - run(f.view->selectTabAsync(QStringLiteral("Favourites"))); - REQUIRE(f.view->collectionsModel->count() == 1); - CHECK(f.view->collectionsModel->rows().front().value(QStringLiteral("favourite")).toBool()); - run(f.view->filterItems(QStringLiteral("not present"), {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - CHECK(f.view->collectionsModel->count() == 0); - run(f.view->filterItems({}, {}, QStringLiteral("all"), QStringLiteral("popular"), {})); - f.view->toggleFavourite(QStringLiteral("picked:test")); - CHECK(f.view->collectionsModel->count() == 0); + PanelFixture f; + f.view->toggleFavourite(QStringLiteral("picked:test")); + run(f.view->selectTabAsync(QStringLiteral("Favourites"))); + REQUIRE(f.view->collectionsModel->count() == 1); + CHECK(f.view->collectionsModel->rows() + .front() + .value(QStringLiteral("favourite")) + .toBool()); + run(f.view->filterItems(QStringLiteral("not present"), {}, + QStringLiteral("all"), QStringLiteral("popular"), + {})); + CHECK(f.view->collectionsModel->count() == 0); + run(f.view->filterItems({}, {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + f.view->toggleFavourite(QStringLiteral("picked:test")); + CHECK(f.view->collectionsModel->count() == 0); } -TEST_CASE("fully installed workshop pack activates all or only its members and dependencies") { - PanelFixture f; - f.complete = true; - const auto first = f.install(QStringLiteral("1")); - const auto duplicate = f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); - const auto dependency = f.install(QStringLiteral("2")); - const auto other = f.install(QStringLiteral("3")); - f.core->setActive({duplicate, other}); - run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - CHECK(f.view->detail().value(QStringLiteral("state")) == QStringLiteral("installed")); - run(f.view->toggleCollectionAsync()); - CHECK(f.core->activeUuids().contains(duplicate)); - CHECK(f.core->activeUuids().contains(dependency)); - CHECK(f.core->activeUuids().contains(other)); - CHECK_FALSE(f.core->activeUuids().contains(first)); - run(f.view->toggleCollectionAsync(true)); - CHECK_FALSE(f.core->activeUuids().contains(other)); - f.core->setActive({first, duplicate, dependency}); - run(f.view->toggleCollectionAsync()); - CHECK(f.core->activeUuids().isEmpty()); +TEST_CASE( + "fully installed workshop pack activates all or only its members and " + "dependencies") { + PanelFixture f; + f.complete = true; + const auto first = f.install(QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + const auto dependency = f.install(QStringLiteral("2")); + const auto other = f.install(QStringLiteral("3")); + f.core->setActive({duplicate, other}); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("state")) == + QStringLiteral("installed")); + run(f.view->toggleCollectionAsync()); + CHECK(f.core->activeUuids().contains(duplicate)); + CHECK(f.core->activeUuids().contains(dependency)); + CHECK(f.core->activeUuids().contains(other)); + CHECK_FALSE(f.core->activeUuids().contains(first)); + run(f.view->toggleCollectionAsync(true)); + CHECK_FALSE(f.core->activeUuids().contains(other)); + f.core->setActive({first, duplicate, dependency}); + run(f.view->toggleCollectionAsync()); + CHECK(f.core->activeUuids().isEmpty()); } -TEST_CASE("partly installed and incomplete workshop collections cannot activate") { - PanelFixture f; - f.install(QStringLiteral("1")); - for (bool complete : {false, true}) { - f.complete = complete; - f.catalog.setBaseUrl(f.server.url() + (complete ? QStringLiteral("/") : QString{})); - run(f.view->openItemAsync(QStringLiteral("picked:test"), QStringLiteral("collection"))); - run(f.view->toggleCollectionAsync(true)); - CHECK(f.core->activeUuids().isEmpty()); - } +TEST_CASE( + "partly installed and incomplete workshop collections cannot activate") { + PanelFixture f; + f.install(QStringLiteral("1")); + for (bool complete : {false, true}) { + f.complete = complete; + f.catalog.setBaseUrl(f.server.url() + + (complete ? QStringLiteral("/") : QString{})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + run(f.view->toggleCollectionAsync(true)); + CHECK(f.core->activeUuids().isEmpty()); + } } -TEST_CASE("workshop installed events preserve open details until back reloads the list") { - PanelFixture f; - f.install(QStringLiteral("1")); - run(f.view->selectTabAsync(QStringLiteral("Installed"))); - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - const auto before = f.requests(QStringLiteral("/catalog/mods/batch")); - f.install(QStringLiteral("2")); - CHECK(f.view->hasDetail()); - CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before); - run(f.view->goBack()); - CHECK_FALSE(f.view->hasDetail()); - CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before + 1); +TEST_CASE( + "workshop installed events preserve open details until back reloads the " + "list") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + const auto before = f.requests(QStringLiteral("/catalog/mods/batch")); + f.install(QStringLiteral("2")); + CHECK(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before); + run(f.view->goBack()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before + 1); } -TEST_CASE("workshop teardown disconnects events and blocks late fetch and plan completion") { - PanelFixture f; - const auto uuid = f.install(QStringLiteral("1")); - f.onRequest = [&f](const HttpRequest& request) { - if (request.path() == QStringLiteral("/catalog/mods")) f.view->teardown(); - }; - run(f.view->selectTabAsync(QStringLiteral("Mods"))); - f.view->teardown(); - run(f.view->toggleActivationAsync(QStringLiteral("1"))); - CHECK_FALSE(f.core->activeUuids().contains(uuid)); - f.catalog.queueChanged.notify({}); - f.catalog.installedChanged.notify({}); - f.core->downloads().busyChanged.notify(false); - CHECK(f.view->modsModel->count() == 1); +TEST_CASE( + "workshop teardown disconnects events and blocks late fetch and plan " + "completion") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + f.onRequest = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/mods")) f.view->teardown(); + }; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + f.view->teardown(); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + f.catalog.queueChanged.notify({}); + f.catalog.installedChanged.notify({}); + f.core->downloads().busyChanged.notify(false); + CHECK(f.view->modsModel->count() == 1); } TEST_CASE("workshop discards download plan completing after view destruction") { - PanelFixture f; - f.onRequest = [&f](const HttpRequest& request) { - if (request.path() == QStringLiteral("/catalog/resolve")) f.view.reset(); - }; - run(f.view->downloadItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - CHECK_FALSE(f.view); - CHECK(f.downloader.calls.isEmpty()); + PanelFixture f; + f.onRequest = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/resolve")) f.view.reset(); + }; + run(f.view->downloadItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK_FALSE(f.view); + CHECK(f.downloader.calls.isEmpty()); } -TEST_CASE("workshop detail metadata retains all tags and numerically sorted versions") { - PanelFixture f; - f.mod.tags = {"1.6", "Utility", "Animals"}; - f.mod.supported_versions = {"1.10", "1.6", "1.5"}; - run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); - CHECK(f.view->detail().value(QStringLiteral("versions")) == QStringLiteral("1.5, 1.6, 1.10")); - CHECK(f.view->detail().value(QStringLiteral("tags")) == QStringLiteral("Utility · Animals")); +TEST_CASE( + "workshop detail metadata retains all tags and numerically sorted " + "versions") { + PanelFixture f; + f.mod.tags = {"1.6", "Utility", "Animals"}; + f.mod.supported_versions = {"1.10", "1.6", "1.5"}; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->detail().value(QStringLiteral("versions")) == + QStringLiteral("1.5, 1.6, 1.10")); + CHECK(f.view->detail().value(QStringLiteral("tags")) == + QStringLiteral("Utility · Animals")); } From dca30b1df427a44e9bf0138d1488d2e572052d65 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 04:28:02 +0300 Subject: [PATCH 11/50] build: just run-native and run-none for the Workshop variants --- AGENTS.md | 2 +- justfile | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af9a6a9..a184cd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ CI runs configure, build and test on every OS (see CI matrix). Only the Linux St - Without PxSteamDL the app runs as a plain mod sorter. ## Entrypoint -- `just run` (sets `PX_LOG_LEVEL=debug`), or `build/default/src/app/pxmodrim`. +- `just run` (Steam browser variant, `build/default`), `just run-native` (native Workshop browser, `build/native`) or `just run-none` (sorter only, `build/none`). Each configures its own build directory with the matching `PX_WORKSHOP_VARIANT`, builds, and starts the app with `PX_LOG_LEVEL=debug`; extra arguments are passed to the app. - Chain: `src/app/main.cpp` `main()` → `runApp()`, the composition root: config services → `CoreContext::create` → `px::modrim::registerCorePlugins` → `px::modrim::AppContext` → `px::modrim::registerUiPlugins` → `setupAll()` → `MainWindow` → `refreshMods()` → `initAll()`. - `src/app/plugins.cpp` is the only list of concrete plugins. diff --git a/justfile b/justfile index 916afba..d666873 100644 --- a/justfile +++ b/justfile @@ -12,8 +12,20 @@ build *args: build-js [ -f build/default/CMakeCache.txt ] || cmake --preset default cmake --build --preset default {{args}} -run *args: build - PX_LOG_LEVEL=debug build/default/src/app/pxmodrim {{args}} +# Build and start the app (Steam browser variant); each variant has its own build directory +run *args: (_run "SteamWorkshop" "default" args) + +# Build and start the app with the native Workshop browser +run-native *args: (_run "NativeWorkshop" "native" args) + +# Build and start the app as a plain mod sorter (no Workshop) +run-none *args: (_run "NoWorkshop" "none" args) + +[private] +_run variant dir *args: build-js + cmake -S . -B build/{{dir}} -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPX_WORKSHOP_VARIANT={{variant}} + cmake --build build/{{dir}} + PX_LOG_LEVEL=debug build/{{dir}}/src/app/pxmodrim {{args}} test: build QT_QPA_PLATFORM=offscreen ctest --preset default From cb214016cee9f880b2e4b777990a45cd091ec5f1 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 04:30:41 +0300 Subject: [PATCH 12/50] build: just run builds only the app target --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index d666873..c41df58 100644 --- a/justfile +++ b/justfile @@ -24,7 +24,7 @@ run-none *args: (_run "NoWorkshop" "none" args) [private] _run variant dir *args: build-js cmake -S . -B build/{{dir}} -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPX_WORKSHOP_VARIANT={{variant}} - cmake --build build/{{dir}} + cmake --build build/{{dir}} --target pxmodrim PX_LOG_LEVEL=debug build/{{dir}}/src/app/pxmodrim {{args}} test: build From 9a3f55d776909ff645f06b12ed1dc9a9adba951a Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 04:40:02 +0300 Subject: [PATCH 13/50] fix: stale names in code compiled only for other variants and platforms --- src/core/config.cpp | 81 +++++++++++++------------- src/ui/plugins/steam_workshop/view.cpp | 2 +- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/core/config.cpp b/src/core/config.cpp index 90e8f1f..a823307 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -178,7 +178,7 @@ GamePaths findGamePaths(QString& foundRoot) { } #elif defined(Q_OS_MACOS) QString find_mac_app(const QString& rimworld_dir) { - if (!is_dir(rimworld_dir)) { + if (!isDir(rimworld_dir)) { return {}; } const QStringList apps = @@ -186,33 +186,31 @@ QString find_mac_app(const QString& rimworld_dir) { .entryList({QStringLiteral("*.app")}, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); - return apps.isEmpty() ? QString() : join_path(rimworld_dir, {apps.first()}); + return apps.isEmpty() ? QString() : joinPath(rimworld_dir, {apps.first()}); } -GamePaths find_game_paths(QString& found_root) { - const QString steam_id = steam_app_id(); - const QString steam_root = - join_path(QDir::homePath(), {QStringLiteral("Library"), - QStringLiteral("Application Support"), - QStringLiteral("Steam")}); - found_root = is_dir(steam_root) ? steam_root : QString(); - if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); + const QString steamRoot = + joinPath(QDir::homePath(), {QStringLiteral("Library"), + QStringLiteral("Application Support"), + QStringLiteral("Steam")}); + foundRoot = isDir(steamRoot) ? steamRoot : QString(); + if (const QString game = findRimworldInSteamRoot(steamRoot, steamId); !game.isEmpty()) { - return game_paths_for(game, - workshop_path_from_game(game, steam_root, steam_id)); + return gamePathsFor(game, workshopPathFromGame(game, steamRoot, steamId)); } - const QString fallback = join_path( - steam_root, {QStringLiteral("steamapps"), QStringLiteral("common"), - QStringLiteral("RimWorld")}); + const QString fallback = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); if (const QString app_bundle = find_mac_app(fallback); !app_bundle.isEmpty()) { return GamePaths{ .game = app_bundle, - .local = join_path(fallback, {QStringLiteral("Mods")}), - .workshop = - join_path(steam_root, - {QStringLiteral("steamapps"), QStringLiteral("workshop"), - QStringLiteral("content"), steam_id}), + .local = joinPath(fallback, {QStringLiteral("Mods")}), + .workshop = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}), }; } return {}; @@ -220,42 +218,41 @@ GamePaths find_game_paths(QString& found_root) { #elif defined(Q_OS_WIN) // Steam's install location is recorded in the registry; the 32-bit view is // checked first, as the legacy detection did. -GamePaths find_game_paths(QString& found_root) { - const QString steam_id = steam_app_id(); +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); const QStringList registry_keys = { QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam)"), QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam)"), }; for (const auto& key : registry_keys) { - const QString steam_root = QSettings(key, QSettings::NativeFormat) - .value(QStringLiteral("InstallPath")) - .toString(); - if (steam_root.isEmpty()) { + const QString steamRoot = QSettings(key, QSettings::NativeFormat) + .value(QStringLiteral("InstallPath")) + .toString(); + if (steamRoot.isEmpty()) { spdlog::debug("Unable to inspect Windows Steam registry key {}", key.toStdString()); continue; } - if (const QString game = find_rimworld_in_steam_root(steam_root, steam_id); + if (const QString game = findRimworldInSteamRoot(steamRoot, steamId); !game.isEmpty()) { - return game_paths_for( - game, workshop_path_from_game(game, steam_root, steam_id)); + return gamePathsFor(game, workshopPathFromGame(game, steamRoot, steamId)); } } const QString fallback = QStringLiteral("C:/Program Files (x86)/Steam"); - const QString game = join_path( - fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), - QStringLiteral("RimWorld")}); - if (is_dir(game)) { - return game_paths_for( - game, join_path(fallback, {QStringLiteral("steamapps"), - QStringLiteral("workshop"), - QStringLiteral("content"), steam_id})); + const QString game = + joinPath(fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return gamePathsFor( + game, joinPath(fallback, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId})); } return {}; } #else -GamePaths find_game_paths(QString&) { return {}; } +GamePaths findGamePaths(QString&) { return {}; } #endif // Locates the RimWorld Config folder, which the game writes beside its save @@ -287,21 +284,21 @@ QString detectConfigFolder([[maybe_unused]] const QString& steamRoot) { return native; } #elif defined(Q_OS_MACOS) - const QString config = join_path( + const QString config = joinPath( QDir::homePath(), {QStringLiteral("Library"), QStringLiteral("Application Support"), QStringLiteral("Rimworld"), QStringLiteral("Config")}); - if (is_dir(config)) { + if (isDir(config)) { spdlog::debug("Detected macOS config folder: {}", config.toStdString()); return config; } #elif defined(Q_OS_WIN) - const QString config = join_path( + const QString config = joinPath( QDir::homePath(), {QStringLiteral("AppData"), QStringLiteral("LocalLow"), QStringLiteral("Ludeon Studios"), QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); - if (is_dir(config)) { + if (isDir(config)) { spdlog::debug("Detected Windows config folder: {}", config.toStdString()); return config; } diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index c0234c7..55b84a3 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -64,7 +64,7 @@ SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, #ifdef PX_HAS_WEBENGINE layout->addWidget(m_qml, 1); #else - qml_->hide(); + m_qml->hide(); auto* unavailable = new QLabel( QStringLiteral( "This build does not include the Steam Workshop web browser."), From a74ef1f6b6bf8214854f10994150994c9c45b384 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 05:00:36 +0300 Subject: [PATCH 14/50] refactor: clean-room rewrite of the RimSort-derived modules (xml, constants, metadata, startup impact parser) --- CHANGELOG.md | 1 + src/core/constants.hpp | 38 +- src/core/models/metadata/parsing.cpp | 541 +++++++----------- src/core/models/metadata/parsing.hpp | 13 +- src/core/models/metadata/structures.cpp | 268 +++++---- src/core/models/metadata/structures.hpp | 141 ++--- .../startup_impact_service/models.cpp | 85 ++- .../startup_impact_service/models.hpp | 12 - .../startup_impact_service/parser.cpp | 188 +++--- .../startup_impact_service/parser.hpp | 5 - src/core/xml.cpp | 232 +++----- src/core/xml.hpp | 19 - 12 files changed, 629 insertions(+), 914 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2f6b4..d0aa0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - Rewrote PxModRim in C++23 on Qt 6 (Qt Quick UI, QCoro coroutines) with CMake, replacing the Python/PySide6 implementation. User-visible behaviour is unchanged. Building from source now needs CMake 3.24+, Qt 6.8+ (CI uses 6.11), QCoro, pugixml, libzip and spdlog, see `AGENTS.md`; release packages are built from CMake install rules and Qt deployment tools. - Project license changed from LGPL-3.0 to GPL-3.0-or-later. Parts of earlier releases were derived from RimSort (GPL-3.0); `NOTICE` previously described RimSort as MIT-licensed, which was incorrect and is fixed. +- Re-implemented the RimSort-derived modules (XML conversion, game constants, About.xml metadata structures and parsing, startup-impact report parsing) independently of the original code, working only from the clean-room rewrite of their behaviour and the existing tests; their public interfaces are unchanged. - Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. ## [0.3.1] - 2026-10-13 diff --git a/src/core/constants.hpp b/src/core/constants.hpp index 046bc47..2cecf19 100644 --- a/src/core/constants.hpp +++ b/src/core/constants.hpp @@ -9,21 +9,12 @@ namespace px::modrim { inline constexpr QLatin1StringView kRimworldSteamAppId{"294100"}; - -enum class LaunchStrategy : int { - kDirect = 0, - kSteam = 1, -}; - -enum class AfterLaunch : int { - kStay = 0, - kMinimize = 1, - kClose = 2, -}; - inline constexpr QLatin1StringView kDefaultMissingPackageid{ "missing.packageid"}; +enum class LaunchStrategy : int { kDirect = 0, kSteam = 1 }; +enum class AfterLaunch : int { kStay = 0, kMinimize = 1, kClose = 2 }; + struct RimWorldDlc { QLatin1StringView appId; QLatin1StringView packageId; @@ -32,7 +23,6 @@ struct RimWorldDlc { QLatin1StringView description; }; -// Ordered by Steam app id, matching the legacy dict's iteration order. inline constexpr std::array kRimworldDlcMetadata{{ {QLatin1StringView{"294100"}, QLatin1StringView{"ludeon.rimworld"}, QLatin1StringView{"RimWorld"}, @@ -57,31 +47,27 @@ inline constexpr std::array kRimworldDlcMetadata{{ {QLatin1StringView{"2380740"}, QLatin1StringView{"ludeon.rimworld.anomaly"}, QLatin1StringView{"RimWorld - Anomaly"}, QLatin1StringView{ - "https://store.steampowered.com/app/2380740/RimWorld__Anomaly/"}, + "https://store.steampowered.com/app/2380740/RimWorld__Anomaly"}, QLatin1StringView{"DLC #4"}}, {QLatin1StringView{"3022790"}, QLatin1StringView{"ludeon.rimworld.odyssey"}, QLatin1StringView{"RimWorld - Odyssey"}, QLatin1StringView{ - "https://store.steampowered.com/app/3022790/RimWorld__Odyssey/"}, + "https://store.steampowered.com/app/3022790/RimWorld__Odyssey"}, QLatin1StringView{"DLC #5"}}, }}; -// Ordered package ids of RIMWORLD_DLC_METADATA. inline const QStringList kRimworldPackageIds = [] { - QStringList ids; - for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { - ids.append(QString(dlc.packageId)); + QStringList result; + result.reserve(kRimworldDlcMetadata.size()); + for (const auto& release : kRimworldDlcMetadata) { + result.push_back(QString(release.packageId)); } - return ids; + return result; }(); -// Exact (case-sensitive) lookup; callers pass lowercase ids as produced by -// CaseInsensitiveStr. inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { - for (const RimWorldDlc& dlc : kRimworldDlcMetadata) { - if (packageId == QLatin1StringView(dlc.packageId)) { - return &dlc; - } + for (const auto& release : kRimworldDlcMetadata) { + if (packageId == release.packageId) return &release; } return nullptr; } diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 41cca09..7b78f9e 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -1,8 +1,10 @@ #include "core/models/metadata/parsing.hpp" +#include #include #include -#include +#include +#include #include @@ -11,165 +13,109 @@ #include "core/xml.hpp" namespace px::modrim { - namespace { -// Either the stripped text of an element or the stripped texts of its
  • -// children. -using ElementValue = std::variant; - -std::string utf8(const QString& text) { return text.toStdString(); } - -void setInvalid(ListedMod& mod, const QString& message) { - mod.valid = false; - spdlog::warn("{}", utf8(message)); -} - -CaseInsensitiveStr missingPackageId() { - return CaseInsensitiveStr(QString(kDefaultMissingPackageid)); -} - -// Text or
  • list of an element; nullopt when the element is absent or empty. -std::optional elementValue(pugi::xml_node element) { - QStringList items; - bool hasItems = false; - for (pugi::xml_node li : element.children("li")) { - hasItems = true; - const QString text = rawText(li).trimmed(); +std::optional listText(pugi::xml_node node) { + if (!node.child("li")) { + return std::nullopt; + } + QStringList result; + for (const auto item : node.children("li")) { + auto text = rawText(item).trimmed(); if (!text.isEmpty()) { - items.append(text); + result.append(std::move(text)); } } - if (hasItems) { - return ElementValue{items}; - } - const QString text = rawText(element).trimmed(); - if (!text.isEmpty()) { - return ElementValue{text}; - } - return std::nullopt; -} - -std::optional asText(const std::optional& value) { - if (value && std::holds_alternative(*value)) { - return std::get(*value); - } - return std::nullopt; -} - -std::optional asList(const std::optional& value) { - if (value && std::holds_alternative(*value)) { - return std::get(*value); - } - return std::nullopt; + return result; } -// The `` child matching the target game version, or a null -// node when none matches or the version has no major.minor part. -pugi::xml_node matchVersionedChild(pugi::xml_node parent, - const QString& targetVersion) { - const QStringList parts = targetVersion.split(QLatin1Char('.')); - if (parts.size() < 2) { +QString scalarText(pugi::xml_node node) { + if (node.child("li")) { return {}; } - const QString majorMinor = parts[0] + QLatin1Char('.') + parts[1]; - const std::string prefixed = utf8(QLatin1Char('v') + majorMinor); - return parent.child(prefixed.c_str()); + return rawText(node).trimmed(); } -QString officialModName(const QString& packageId, const QString& name) { - if (!name.isEmpty() && name != QStringLiteral("Unknown Mod Name") && - name != packageId) { - return name; - } - if (packageId == QLatin1StringView("ludeon.rimworld")) { - return QStringLiteral("Core"); +pugi::xml_node versionSection(pugi::xml_node parent, + const QString& shortVersion) { + if (shortVersion.isEmpty()) { + return {}; } - if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId)) { - const QString dlcName(dlc->name); - const QString prefix = QStringLiteral("RimWorld - "); - return dlcName.startsWith(prefix) ? dlcName.mid(prefix.size()) : dlcName; + const auto prefixed = (QStringLiteral("v") + shortVersion).toUtf8(); + if (auto node = parent.child(prefixed.constData())) { + return node; } - return packageId; + const auto bare = shortVersion.toUtf8(); + return parent.child(bare.constData()); } -DependencyMod dependencyFromLi(pugi::xml_node li) { - DependencyMod dep; - if (const auto packageId = childText(li, "packageId")) { - dep.packageId = CaseInsensitiveStr(*packageId); - } - if (const auto name = childText(li, "displayName")) { - dep.name = *name; - } - if (const auto url = childText(li, "workshopUrl")) { - dep.workshopUrl = *url; - } - if (pugi::xml_node alternatives = li.child("alternativePackageIds")) { - for (pugi::xml_node alternative : alternatives.children("li")) { - const QString text = rawText(alternative).trimmed(); +void readDependencies(pugi::xml_node container, BaseRules& rules) { + for (const auto item : container.children("li")) { + if (QString::fromUtf8(item.attribute("isNull").value()) == + QStringLiteral("True")) { + continue; + } + bool hasElement = false; + for (const auto child : item.children()) { + if (child.type() == pugi::node_element) { + hasElement = true; + break; + } + } + if (!item.first_attribute() && !hasElement) { + continue; + } + DependencyMod dependency; + const auto id = rawText(item.child("packageId")).trimmed(); + if (!id.isEmpty()) { + dependency.packageId = CaseInsensitiveStr(id); + } + dependency.name = rawText(item.child("displayName")).trimmed(); + dependency.workshopUrl = rawText(item.child("workshopUrl")).trimmed(); + for (const auto alternative : + item.child("alternativePackageIds").children("li")) { + const auto text = rawText(alternative).trimmed(); if (!text.isEmpty()) { - dep.alternativePackageIds.add(text); + dependency.alternativePackageIds.add(text); } } - } - return dep; -} - -// lxml's `len(li)` counts comments as well as elements, but not text. -bool hasChildNodes(pugi::xml_node node) { - for (pugi::xml_node child = node.first_child(); child; - child = child.next_sibling()) { - if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { - return true; + if (rules.hasDependency(dependency.packageId)) { + spdlog::warn("Duplicate dependency {}; keeping its first declaration", + dependency.packageId.str().toStdString()); + } else { + rules.dependencies.append(std::move(dependency)); } } - return false; -} - -std::shared_ptr aboutModWithoutXml() { - auto mod = std::make_shared(); - mod->valid = false; - return mod; -} - -template -Result> withModPath(std::shared_ptr mod, - const QString& path) { - if (auto set = mod->setModPath(path); !set) { - return std::unexpected(set.error()); - } - return mod; } -Result invalidListedMod(const QString& path) { - auto mod = std::make_shared(); - mod->valid = false; - return withModPath(std::move(mod), path); -} - -// Mirrors the legacy `_create_about_mod_from_xml`: unreadable or malformed -// files still yield an invalid AboutXmlMod that carries the mod path, so the -// caller can cache and report it. -Result> createAboutModFromXml( - const QString& basePath, const QString& aboutXmlPath, - const QString& targetVersion, bool preferVersioned) { - pugi::xml_document doc; - if (auto loaded = loadXmlFile(doc, aboutXmlPath); !loaded) { - spdlog::error("Unable to parse {}: {}", utf8(aboutXmlPath), - utf8(loaded.error().message)); - return withModPath(aboutModWithoutXml(), basePath); +void applyOfficialMetadata(AboutXmlMod& mod, const QString& declaredName) { + const auto* official = findRimWorldDlcByPackageId(mod.packageId.str()); + if (!declaredName.isEmpty() && + declaredName != QStringLiteral("Unknown Mod Name") && + declaredName != mod.packageId.str()) { + mod.name = declaredName; + } else if (official) { + if (official->appId == kRimworldSteamAppId) { + mod.name = QStringLiteral("Core"); + } else { + mod.name = + QString(official->name).mid(QStringLiteral("RimWorld - ").size()); + } + } else { + mod.name = mod.packageId.str(); } - auto mod = createAboutModFromElement(doc.document_element(), targetVersion, - preferVersioned); - auto withPath = withModPath(std::move(mod), basePath); - if (!withPath) { - return withPath; + if (official && official->appId != kRimworldSteamAppId) { + const CaseInsensitiveStr coreId(QStringLiteral("ludeon.rimworld")); + if (!mod.aboutRules.hasDependency(coreId)) { + const auto* core = findRimWorldDlcByPackageId(coreId.str()); + DependencyMod dependency; + dependency.packageId = coreId; + dependency.name = QString(core->name); + dependency.workshopUrl = QString(core->steamUrl); + mod.aboutRules.dependencies.append(std::move(dependency)); + } } - const QFileInfo info(aboutXmlPath); - (*withPath)->mtime = - info.exists() ? info.lastModified().toMSecsSinceEpoch() / 1000.0 : 0.0; - return withPath; } } // namespace @@ -177,218 +123,125 @@ Result> createAboutModFromXml( std::shared_ptr createAboutModFromElement( pugi::xml_node root, const QString& targetVersion, bool preferVersioned) { auto mod = std::make_shared(); - bool hasPackageId = false; - - pugi::xml_node depsEl; - bool depsByVersion = false; - QStringList loadBeforeLi; - QStringList loadAfterLi; - QStringList incompatLi; - QStringList forceBeforeLi; - QStringList forceAfterLi; - pugi::xml_node loadBeforeByVersionEl; - pugi::xml_node loadAfterByVersionEl; - pugi::xml_node incompatByVersionEl; - - for (pugi::xml_node child = root.first_child(); child; - child = child.next_sibling()) { + const auto versionParts = targetVersion.split(QLatin1Char('.')); + const QString shortVersion = + versionParts.size() >= 2 + ? versionParts[0] + QLatin1Char('.') + versionParts[1] + : QString{}; + QString declaredName; + QString description; + QString versionDescription; + bool sawPackageId = false; + pugi::xml_node dependencies; + pugi::xml_node versionDependencies; + constexpr std::array kRuleTags{ + "loadBefore", "loadAfter", "incompatibleWith", "forceLoadBefore", + "forceLoadAfter"}; + constexpr std::array kVersionRuleTags{ + "loadBeforeByVersion", "loadAfterByVersion", "incompatibleWithByVersion"}; + std::array ruleLists; + std::array versionRules; + + for (const auto child : root.children()) { if (child.type() != pugi::node_element) { continue; } - const std::string_view tag = child.name(); - - if (tag == "packageId") { - hasPackageId = true; - const auto text = asText(elementValue(child)); - if (text && !text->isEmpty()) { - mod->packageId = CaseInsensitiveStr(*text); - } else { - setInvalid(*mod, QStringLiteral( - "packageId missing or invalid. Assigned sentinel " - "'%1'.") - .arg(QString(kDefaultMissingPackageid))); - mod->packageId = missingPackageId(); + const QLatin1StringView tag(child.name()); + if (tag == QLatin1StringView("packageId")) { + sawPackageId = true; + auto id = scalarText(child); + if (id.isEmpty()) { + mod->valid = false; + id = QString(kDefaultMissingPackageid); + spdlog::warn("About.xml contains an unusable packageId"); } - } else if (tag == "steamAppId") { - const auto text = asText(elementValue(child)); - if (text && isAsciiDigits(*text)) { + mod->packageId = CaseInsensitiveStr(id); + } else if (tag == QLatin1StringView("steamAppId")) { + const auto text = scalarText(child); + if (isAsciiDigits(text)) { bool ok = false; - const qint64 appId = text->toLongLong(&ok); + const auto appId = text.toLongLong(&ok); if (ok) { mod->steamAppId = appId; } } - } else if (tag == "name") { - if (const auto text = asText(elementValue(child))) { - mod->name = *text; - } - } else if (tag == "description") { - if (const auto text = asText(elementValue(child))) { - mod->description = *text; - } - } else if (tag == "author") { - if (const auto text = asText(elementValue(child))) { - mod->authors.append(*text); - } - } else if (tag == "authors") { - if (const auto list = asList(elementValue(child))) { - mod->authors.append(*list); - } - } else if (tag == "supportedVersions") { - if (const auto list = asList(elementValue(child))) { - mod->supportedVersions = QSet(list->cbegin(), list->cend()); - } - } else if (tag == "modVersion") { - if (const auto text = asText(elementValue(child))) { - mod->modVersion = *text; - } - } else if (tag == "modIconPath") { - if (const auto text = asText(elementValue(child))) { - mod->modIconPath = *text; - } - } else if (tag == "url") { - if (const auto text = asText(elementValue(child))) { - mod->url = *text; - } - } else if (tag == "modDependencies") { - if (!depsByVersion) { - depsEl = child; - } - } else if (tag == "modDependenciesByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = - matchVersionedChild(child, targetVersion)) { - depsEl = matched; - depsByVersion = true; - } - } - } else if (tag == "loadBefore") { - if (const auto list = asList(elementValue(child))) { - loadBeforeLi = *list; + } else if (tag == QLatin1StringView("name")) { + const auto text = scalarText(child); + if (!text.isEmpty()) declaredName = text; + } else if (tag == QLatin1StringView("description")) { + const auto text = scalarText(child); + if (!text.isEmpty()) description = text; + } else if (tag == QLatin1StringView("modVersion")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->modVersion = text; + } else if (tag == QLatin1StringView("url")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->url = text; + } else if (tag == QLatin1StringView("modIconPath")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->modIconPath = text; + } else if (tag == QLatin1StringView("author")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->authors.append(text); + } else if (tag == QLatin1StringView("authors")) { + if (auto list = listText(child)) mod->authors.append(*list); + } else if (tag == QLatin1StringView("supportedVersions")) { + if (auto list = listText(child)) { + mod->supportedVersions = QSet(list->begin(), list->end()); } - } else if (tag == "loadBeforeByVersion") { + } else if (tag == QLatin1StringView("modDependencies")) { + dependencies = child; + } else if (tag == QLatin1StringView("modDependenciesByVersion")) { if (preferVersioned) { - if (const pugi::xml_node matched = - matchVersionedChild(child, targetVersion)) { - loadBeforeByVersionEl = matched; + if (auto section = versionSection(child, shortVersion)) { + versionDependencies = section; } } - } else if (tag == "loadAfter") { - if (const auto list = asList(elementValue(child))) { - loadAfterLi = *list; - } - } else if (tag == "loadAfterByVersion") { - if (preferVersioned) { - if (const pugi::xml_node matched = - matchVersionedChild(child, targetVersion)) { - loadAfterByVersionEl = matched; + } else if (tag == QLatin1StringView("descriptionsByVersion")) { + // Descriptions select their version independently of rule preferences. + const auto text = rawText(versionSection(child, shortVersion)).trimmed(); + if (!text.isEmpty()) versionDescription = text; + } else { + for (size_t index = 0; index < kRuleTags.size(); ++index) { + if (tag == QLatin1StringView(kRuleTags[index])) { + if (auto list = listText(child)) ruleLists[index] = std::move(*list); } } - } else if (tag == "forceLoadBefore") { - if (const auto list = asList(elementValue(child))) { - forceBeforeLi = *list; - } - } else if (tag == "forceLoadAfter") { - if (const auto list = asList(elementValue(child))) { - forceAfterLi = *list; - } - } else if (tag == "incompatibleWith") { - if (const auto list = asList(elementValue(child))) { - incompatLi = *list; - } - } else if (tag == "incompatibleWithByVersion") { if (preferVersioned) { - if (const pugi::xml_node matched = - matchVersionedChild(child, targetVersion)) { - incompatByVersionEl = matched; - } - } - } else if (tag == "descriptionsByVersion") { - if (const pugi::xml_node matched = - matchVersionedChild(child, targetVersion)) { - const QString text = rawText(matched).trimmed(); - if (!text.isEmpty()) { - mod->description = text; + for (size_t index = 0; index < kVersionRuleTags.size(); ++index) { + if (tag == QLatin1StringView(kVersionRuleTags[index])) { + if (auto section = versionSection(child, shortVersion)) { + versionRules[index] = section; + } + } } } } } - if (!hasPackageId) { - setInvalid( - *mod, - QStringLiteral("packageId missing or invalid. Assigned sentinel '%1'.") - .arg(QString(kDefaultMissingPackageid))); - mod->packageId = missingPackageId(); + if (!sawPackageId) { + mod->valid = false; + mod->packageId = CaseInsensitiveStr(QString(kDefaultMissingPackageid)); + spdlog::warn("About.xml has no packageId"); } - - // Versioned overrides replace the unversioned lists they shadow. - if (loadBeforeByVersionEl) { - if (const auto list = asList(elementValue(loadBeforeByVersionEl))) { - loadBeforeLi = *list; - } - } - if (loadAfterByVersionEl) { - if (const auto list = asList(elementValue(loadAfterByVersionEl))) { - loadAfterLi = *list; - } + if (!versionDescription.isEmpty()) { + mod->description = std::move(versionDescription); + } else if (!description.isEmpty()) { + mod->description = std::move(description); } - if (incompatByVersionEl) { - if (const auto list = asList(elementValue(incompatByVersionEl))) { - incompatLi = *list; + for (size_t index = 0; index < versionRules.size(); ++index) { + if (auto list = listText(versionRules[index])) { + ruleLists[index] = std::move(*list); } } - - loadBeforeLi.append(forceBeforeLi); - loadAfterLi.append(forceAfterLi); - - BaseRules rules; - rules.loadBefore = CaseInsensitiveSet(loadBeforeLi); - rules.loadAfter = CaseInsensitiveSet(loadAfterLi); - rules.incompatibleWith = CaseInsensitiveSet(incompatLi); - - if (depsEl) { - for (pugi::xml_node li = depsEl.first_child(); li; li = li.next_sibling()) { - if (li.type() != pugi::node_element || - std::string_view(li.name()) != "li") { - continue; - } - if (std::string_view(li.attribute("isNull").value()) == "True") { - continue; - } - if (!li.first_attribute() && !hasChildNodes(li)) { - continue; - } - DependencyMod dep = dependencyFromLi(li); - if (rules.findDependency(dep.packageId)) { - spdlog::warn("Duplicate dependency found: {}. Skipping.", - utf8(dep.packageId.str())); - } else { - rules.dependencies.append(std::move(dep)); - } - } - } - - const QString packageId = mod->packageId.str(); - mod->name = officialModName(packageId, mod->name); - const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId); - if (dlc && mod->description.isEmpty()) { - mod->description = QString(dlc->description); - } - // DLC cannot load without the base game, so it always depends on RimWorld. - if (dlc && QLatin1StringView(dlc->appId) != kRimworldSteamAppId) { - const CaseInsensitiveStr rimworldPid(QStringLiteral("ludeon.rimworld")); - if (!rules.hasDependency(rimworldPid)) { - DependencyMod rimworld; - rimworld.name = QStringLiteral("RimWorld"); - rimworld.packageId = rimworldPid; - rimworld.workshopUrl = - QStringLiteral("https://store.steampowered.com/app/294100/RimWorld"); - rules.dependencies.append(std::move(rimworld)); - } - } - - mod->aboutRules = std::move(rules); + ruleLists[0].append(ruleLists[3]); + ruleLists[1].append(ruleLists[4]); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ruleLists[0]); + mod->aboutRules.loadAfter = CaseInsensitiveSet(ruleLists[1]); + mod->aboutRules.incompatibleWith = CaseInsensitiveSet(ruleLists[2]); + readDependencies(versionDependencies ? versionDependencies : dependencies, + mod->aboutRules); + applyOfficialMetadata(*mod, declaredName); return mod; } @@ -402,29 +255,35 @@ Result createListedModFromPath( if (!aboutXmlPath) { aboutXmlPath = findAboutXml(path); } + ListedModPtr mod; if (aboutXmlPath) { - auto mod = createAboutModFromXml(path, *aboutXmlPath, targetVersion, - preferVersioned); - if (!mod) { - return std::unexpected(mod.error()); + pugi::xml_document document; + if (auto loaded = loadXmlFile(document, *aboutXmlPath); loaded) { + auto about = createAboutModFromElement(document.document_element(), + targetVersion, preferVersioned); + const QFileInfo metadata(*aboutXmlPath); + if (metadata.exists()) { + about->mtime = + static_cast(metadata.lastModified().toMSecsSinceEpoch()) / + 1000.0; + } + mod = std::move(about); + } else { + spdlog::error("Unable to read mod metadata {}: {}", + aboutXmlPath->toStdString(), + loaded.error().message.toStdString()); + mod = std::make_shared(); + mod->valid = false; } - return ListedModPtr(std::move(*mod)); + } else { + mod = std::make_shared(); + mod->valid = false; + spdlog::warn("No About.xml found for {}", path.toStdString()); } - - const QStringList rscFiles = QDir(path).entryList( - {QStringLiteral("*.rsc")}, - QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::CaseSensitive); - if (rscFiles.size() > 1) { - spdlog::warn("Multiple .rsc files found in {}. Aborting.", utf8(path)); - return invalidListedMod(path); + if (auto assigned = mod->setModPath(path); !assigned) { + return std::unexpected(assigned.error()); } - if (rscFiles.size() == 1) { - // Scenario .rsc files are not parsed yet. - spdlog::warn("Scenario .rsc files not yet supported: {}", utf8(path)); - return invalidListedMod(path); - } - spdlog::warn("No About.xml found in directory: {}", utf8(path)); - return invalidListedMod(path); + return mod; } } // namespace px::modrim diff --git a/src/core/models/metadata/parsing.hpp b/src/core/models/metadata/parsing.hpp index a24a475..c6faf66 100644 --- a/src/core/models/metadata/parsing.hpp +++ b/src/core/models/metadata/parsing.hpp @@ -11,21 +11,14 @@ namespace px::modrim { -// Builds a mod for the directory `path`, parsing its About.xml (found -// automatically, or the explicit `aboutXmlPath`). Returns Err only when `path` -// is not a directory. A mod whose metadata is unusable is still returned, with -// `valid == false`. -// -// `targetVersion` selects `*ByVersion` entries: "1.5" matches ``. When -// `preferVersioned` is false those entries are ignored. +// A missing or unreadable About.xml yields an invalid mod, not an error. +// Only a path that is not a directory is rejected. Result createListedModFromPath( const QString& path, const QString& targetVersion, bool preferVersioned = true, std::optional aboutXmlPath = std::nullopt); -// Builds an AboutXmlMod from the `` root element of an About.xml. -// The result's `valid` flag reports whether the metadata is usable; no mod path -// is set. +// Parses metadata without assigning a directory to the resulting mod. std::shared_ptr createAboutModFromElement( pugi::xml_node root, const QString& targetVersion, bool preferVersioned = true); diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp index fa21ee1..745bc92 100644 --- a/src/core/models/metadata/structures.cpp +++ b/src/core/models/metadata/structures.cpp @@ -1,96 +1,114 @@ #include "core/models/metadata/structures.hpp" +#include #include +#include #include #include +#include #include +#include +#include #include "core/constants.hpp" -#include "core/utils.hpp" namespace px::modrim { - namespace { -// Python's Path.stem / Path.suffix rule: a leading dot does not start an -// extension. -qsizetype lastExtensionDot(const QString& name) { - const qsizetype dot = name.lastIndexOf(QLatin1Char('.')); - return (dot > 0 && dot < name.size() - 1) ? dot : -1; +QString pathLeaf(QStringView path) { + while (!path.isEmpty()) { + if (path.endsWith(QLatin1Char('/'))) { + path.chop(1); + } else if (path.endsWith(QLatin1StringView("/."))) { + path.chop(2); + } else { + break; + } + } + if (path == QLatin1StringView(".")) { + return {}; + } + return path.mid(path.lastIndexOf(QLatin1Char('/')) + 1).toString(); } -QString pathStem(const QString& name) { - const qsizetype dot = lastExtensionDot(name); - return dot < 0 ? name : name.left(dot); +bool isWorkshopNumber(QStringView text) { + return !text.isEmpty() && std::ranges::all_of(text, [](QChar character) { + return character >= QLatin1Char('0') && character <= QLatin1Char('9'); + }); } -QString pathSuffix(const QString& name) { - const qsizetype dot = lastExtensionDot(name); - return dot < 0 ? QString() : name.mid(dot); -} +std::optional readWorkshopNumber(const QString& directory) { + QFile declaration( + QDir(directory).filePath(QStringLiteral("About/PublishedFileId.txt"))); + if (declaration.open(QIODevice::ReadOnly)) { + const QByteArray bytes = declaration.readAll(); + if (declaration.error() != QFileDevice::NoError) { + return std::nullopt; + } + QStringDecoder decoder(QStringDecoder::Utf8, + QStringConverter::Flag::Stateless); + const QString text = decoder(bytes); + if (decoder.hasError()) { + return std::nullopt; + } + const QString candidate = text.trimmed(); + if (isWorkshopNumber(candidate)) { + return candidate; + } + } else if (declaration.error() == QFileDevice::PermissionsError || + QFileInfo::exists(declaration.fileName())) { + return std::nullopt; + } -bool hasEntries(const QString& dirPath) { - return !QDir(dirPath) - .entryList(QDir::AllEntries | QDir::Hidden | QDir::System | - QDir::NoDotAndDotDot) - .isEmpty(); + const QString folder = pathLeaf(directory); + return isWorkshopNumber(folder) ? std::optional(folder) + : std::nullopt; } } // namespace +CaseInsensitiveStr::CaseInsensitiveStr(QStringView pid) + : m_text(pid.toString().toLower()) {} + CaseInsensitiveSet::CaseInsensitiveSet(const QStringList& values) { + m_members.reserve(values.size()); for (const QString& value : values) { - m_data.insert(CaseInsensitiveStr(value)); + add(value); } } -CaseInsensitiveSet::CaseInsensitiveSet( - const QList& values) { - for (const CaseInsensitiveStr& value : values) { - m_data.insert(value); - } -} +CaseInsensitiveSet::CaseInsensitiveSet(const QList& values) + : m_members(values.cbegin(), values.cend()) {} bool CaseInsensitiveSet::contains(QStringView value) const { - return m_data.contains(CaseInsensitiveStr(value)); + return contains(CaseInsensitiveStr(value)); } bool CaseInsensitiveSet::contains(const CaseInsensitiveStr& value) const { - return m_data.contains(value); + return m_members.contains(value); } void CaseInsensitiveSet::add(QStringView value) { - m_data.insert(CaseInsensitiveStr(value)); + add(CaseInsensitiveStr(value)); } void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { - m_data.insert(value); + m_members.insert(value); } void CaseInsensitiveSet::discard(QStringView value) { - m_data.remove(CaseInsensitiveStr(value)); + discard(CaseInsensitiveStr(value)); } void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { - m_data.remove(value); + m_members.remove(value); } CaseInsensitiveSet CaseInsensitiveSet::operator|( const CaseInsensitiveSet& other) const { - CaseInsensitiveSet result = *this; - result.m_data.unite(other.m_data); - return result; -} - -bool ModsConfig::checkActiveDuplicates() const { - const QSet unique(activeMods.cbegin(), activeMods.cend()); - return activeMods.size() != unique.size(); -} - -bool ModsConfig::checkExpansionsDuplicates() const { - const QSet unique(knownExpansions.cbegin(), - knownExpansions.cend()); - return knownExpansions.size() != unique.size(); + CaseInsensitiveSet combined = *this; + combined.m_members.unite(other.m_members); + return combined; } const DependencyMod* BaseRules::findDependency( @@ -113,122 +131,96 @@ void BaseRules::setDependency(DependencyMod dependency) { dependencies.append(std::move(dependency)); } -QString ListedMod::newUuid() { +QString ListedMod::makeIdentity() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } -QString ListedMod::uuid() const { - if (m_modPath) { - return *m_modPath; - } - return m_uuid; -} +QString ListedMod::uuid() const { return m_directory.value_or(m_identity); } Result ListedMod::setModPath(const QString& path) { - if (m_modPath) { + if (m_directory) { return std::unexpected( Error{QStringLiteral("Mod path already set. Cannot override.")}); } - m_modPath = path; - m_publishedFileIdCache.reset(); + m_directory = path; + m_workshopIdReady = false; + m_workshopId.reset(); return {}; } std::optional ListedMod::modFolder() const { - if (!m_modPath) { + if (!m_directory) { return std::nullopt; } - return pathStem(QFileInfo(QDir::cleanPath(*m_modPath)).fileName()); + QString folder = pathLeaf(*m_directory); + const qsizetype extension = folder.lastIndexOf(QLatin1Char('.')); + if (extension > 0 && folder != QLatin1StringView("..")) { + folder.truncate(extension); + } + return folder; } qint64 ListedMod::internalTimeTouched() const { - if (!m_modPath || !QFileInfo::exists(*m_modPath)) { + if (!m_directory) { return -1; } - return QFileInfo(*m_modPath).lastModified().toSecsSinceEpoch(); + const QDateTime modified = QFileInfo(*m_directory).lastModified(); + return modified.isValid() ? modified.toSecsSinceEpoch() : -1; } std::optional ListedMod::publishedFileId() const { - if (!m_publishedFileIdCache) { - m_publishedFileIdCache = [this]() -> std::optional { - if (!m_modPath) { - return std::nullopt; - } - const QString pfidPath = - QDir(*m_modPath) - .filePath(QStringLiteral("About/PublishedFileId.txt")); - if (QFileInfo::exists(pfidPath)) { - QFile file(pfidPath); - if (!file.open(QIODevice::ReadOnly)) { - return std::nullopt; - } - // utf-8-sig: drop a leading byte-order mark before trimming. - QString content = QString::fromUtf8(file.readAll()); - if (content.startsWith(QChar(0xFEFF))) { - content.remove(0, 1); - } - content = content.trimmed(); - if (isAsciiDigits(content)) { - return content; - } - } - const std::optional folder = modFolder(); - if (folder && isAsciiDigits(*folder)) { - return folder; - } - return std::nullopt; - }(); + if (!m_workshopIdReady) { + m_workshopId = + m_directory ? readWorkshopNumber(*m_directory) : std::nullopt; + m_workshopIdReady = true; } - return *m_publishedFileIdCache; + return m_workshopId; } bool ListedMod::cSharpMod() const { - if (!m_cSharpModCache) { - m_cSharpModCache = [this] { - if (!m_modPath) { - return false; - } - const QString assemblies = - QDir(*m_modPath).filePath(QStringLiteral("Assemblies")); - return QFileInfo(assemblies).isDir() && hasEntries(assemblies); - }(); + if (!m_hasAssemblies) { + bool found = false; + if (m_directory) { + QDirIterator entries( + QDir(*m_directory).filePath(QStringLiteral("Assemblies")), + QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden | + QDir::System); + found = entries.hasNext(); + } + m_hasAssemblies = found; } - return *m_cSharpModCache; + return *m_hasAssemblies; } bool ListedMod::xmlPatchMod() const { - if (!m_xmlPatchModCache) { - m_xmlPatchModCache = [this] { - if (!m_modPath) { - return false; - } - const QString patches = - QDir(*m_modPath).filePath(QStringLiteral("Patches")); - if (!QFileInfo(patches).isDir()) { - return false; - } - const QFileInfoList entries = - QDir(patches).entryInfoList(QDir::AllEntries | QDir::Hidden | - QDir::System | QDir::NoDotAndDotDot); - for (const QFileInfo& entry : entries) { - if (pathSuffix(entry.fileName()) == QStringLiteral(".xml")) { - return true; + if (!m_hasPatches) { + bool found = false; + if (m_directory) { + QDirIterator entries( + QDir(*m_directory).filePath(QStringLiteral("Patches")), + QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden | + QDir::System); + while (entries.hasNext()) { + entries.next(); + const QString name = entries.fileName(); + if (name.size() > 4 && name.endsWith(QLatin1StringView(".xml"))) { + found = true; + break; } } - return false; - }(); + } + m_hasPatches = found; } - return *m_xmlPatchModCache; + return *m_hasPatches; } std::optional ListedMod::previewImgPath() const { - if (!m_modPath) { - return std::nullopt; - } - const QString candidate = - QDir(*m_modPath).filePath(QStringLiteral("About/Preview.png")); - if (QFileInfo::exists(candidate)) { - return candidate; + if (m_directory) { + const QString preview = + QDir(*m_directory).filePath(QStringLiteral("About/Preview.png")); + if (QFileInfo::exists(preview)) { + return preview; + } } return std::nullopt; } @@ -240,4 +232,28 @@ std::optional AboutXmlMod::getDlcName() const { return std::nullopt; } +bool ModsConfig::checkActiveDuplicates() const { + QSet visited; + visited.reserve(activeMods.size()); + for (const CaseInsensitiveStr& packageId : activeMods) { + if (visited.contains(packageId)) { + return true; + } + visited.insert(packageId); + } + return false; +} + +bool ModsConfig::checkExpansionsDuplicates() const { + QSet visited; + visited.reserve(knownExpansions.size()); + for (const CaseInsensitiveStr& packageId : knownExpansions) { + if (visited.contains(packageId)) { + return true; + } + visited.insert(packageId); + } + return false; +} + } // namespace px::modrim diff --git a/src/core/models/metadata/structures.hpp b/src/core/models/metadata/structures.hpp index 1d99b65..9dca84f 100644 --- a/src/core/models/metadata/structures.hpp +++ b/src/core/models/metadata/structures.hpp @@ -20,27 +20,23 @@ namespace px::modrim { -// Package id that compares and hashes without regard to case. Stored -// lowercased. class CaseInsensitiveStr { public: CaseInsensitiveStr() = default; - explicit CaseInsensitiveStr(QStringView pid) - : m_value(pid.toString().toLower()) {} + explicit CaseInsensitiveStr(QStringView pid); - const QString& str() const noexcept { return m_value; } + const QString& str() const noexcept { return m_text; } friend bool operator==(const CaseInsensitiveStr&, const CaseInsensitiveStr&) = default; friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { - return qHash(key.m_value, seed); + return qHash(key.m_text, seed); } private: - QString m_value; + QString m_text; }; -// Set of CaseInsensitiveStr with string-friendly membership tests. class CaseInsensitiveSet { public: CaseInsensitiveSet() = default; @@ -53,57 +49,27 @@ class CaseInsensitiveSet { void add(const CaseInsensitiveStr& value); void discard(QStringView value); void discard(const CaseInsensitiveStr& value); - - qsizetype size() const noexcept { return m_data.size(); } - bool empty() const noexcept { return m_data.isEmpty(); } - - // Set union; the result keeps the elements of both operands. CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; - auto begin() const { return m_data.cbegin(); } - auto end() const { return m_data.cend(); } + qsizetype size() const noexcept { return m_members.size(); } + bool empty() const noexcept { return m_members.isEmpty(); } + auto begin() const { return m_members.cbegin(); } + auto end() const { return m_members.cend(); } friend bool operator==(const CaseInsensitiveSet&, const CaseInsensitiveSet&) = default; private: - QSet m_data; + QSet m_members; }; -// Parsed ModsConfig.xml data with active mods and known expansions. -struct ModsConfig { - QString version; - QList activeMods; - QList knownExpansions; - - bool checkActiveDuplicates() const; - bool checkExpansionsDuplicates() const; - void clearActiveMods() { activeMods.clear(); } - void clearAll() { - activeMods.clear(); - knownExpansions.clear(); - } -}; - -struct ReplacementInfo { - QString name; - QString author; - QString packageid; - QString pfid; - QStringList supportedversions; - QString source = QStringLiteral("database"); -}; - -// Dependency declared by a mod's About.xml (or synthesized, e.g. the RimWorld -// base game for DLC). struct DependencyMod { - QString name; + QString name = QStringLiteral("Unknown Mod Name"); CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; QString workshopUrl; CaseInsensitiveSet alternativePackageIds; }; -// Load-order rules and dependencies. Dependencies keep declaration order. struct BaseRules { CaseInsensitiveSet loadAfter; CaseInsensitiveSet loadBefore; @@ -115,12 +81,9 @@ struct BaseRules { bool hasDependency(const CaseInsensitiveStr& packageId) const { return findDependency(packageId) != nullptr; } - // Inserts, or replaces the dependency with the same package id in place. void setDependency(DependencyMod dependency); }; -// Mod discovered by any provider. Polymorphic so providers can hand out -// AboutXmlMod (and ScenarioMod) through a shared base pointer. class ListedMod { public: ListedMod() = default; @@ -132,54 +95,35 @@ class ListedMod { bool valid = true; QSet supportedVersions; QString description = QStringLiteral( - "This mod is considered invalid by PxModRim (and the RimWorld game).\n\n" - "This mod does NOT contain an ./About/About.xml and is likely leftover " - "from previous " - "usage."); + "This mod is considered invalid by PxModRim and by the game itself. " + "It has no About/About.xml file, so it is most likely a leftover " + "from earlier usage (for example a removed mod whose folder stayed " + "behind)."); QString providerId; bool obsolete = false; bool dbBuilderNoName = false; - // Identity: the mod path once it is known, otherwise a random id assigned at - // construction. virtual QString uuid() const; - - const std::optional& modPath() const noexcept { return m_modPath; } - // Fails if a path was already set; a mod's directory is fixed after - // discovery. + const std::optional& modPath() const noexcept { return m_directory; } Result setModPath(const QString& path); - - // Folder name without its last extension (Python's Path.stem); nullopt - // without a path. std::optional modFolder() const; - // Modification time of the mod folder in seconds since the epoch, or -1 if - // unavailable. qint64 internalTimeTouched() const; - - // Published file id from About/PublishedFileId.txt or a numeric folder name. - // Cached on first use; this touches the disk, so callers may warm it off the - // UI thread. std::optional publishedFileId() const; bool cSharpMod() const; bool xmlPatchMod() const; std::optional previewImgPath() const; private: - static QString newUuid(); - - QString m_uuid = newUuid(); - std::optional m_modPath; - mutable std::optional> m_publishedFileIdCache; - mutable std::optional m_cSharpModCache; - mutable std::optional m_xmlPatchModCache; + QString m_identity = makeIdentity(); + std::optional m_directory; + mutable bool m_workshopIdReady = false; + mutable std::optional m_workshopId; + mutable std::optional m_hasAssemblies; + mutable std::optional m_hasPatches; + + static QString makeIdentity(); }; -class ScenarioMod : public ListedMod { - public: - QString summary; -}; - -// A mod with an About.xml: authors, version, rules, and DLC metadata. class AboutXmlMod : public ListedMod { public: CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; @@ -191,13 +135,39 @@ class AboutXmlMod : public ListedMod { double mtime = 0.0; BaseRules aboutRules; - // Display name of the RimWorld DLC this package id belongs to, if any. std::optional getDlcName() const; }; +class ScenarioMod : public ListedMod { + public: + QString summary; +}; + using ListedModPtr = std::shared_ptr; -// Pre-computed dependency graphs and tier classification for sorting. +struct ModsConfig { + QString version; + QList activeMods; + QList knownExpansions; + + bool checkActiveDuplicates() const; + bool checkExpansionsDuplicates() const; + void clearActiveMods() { activeMods.clear(); } + void clearAll() { + activeMods.clear(); + knownExpansions.clear(); + } +}; + +struct ReplacementInfo { + QString name; + QString author; + QString packageid; + QString pfid; + QStringList supportedversions; + QString source = QStringLiteral("database"); +}; + struct CompiledDependencyData { QHash> depsGraph; QHash> revDepsGraph; @@ -208,10 +178,6 @@ struct CompiledDependencyData { QHash> declaredIncompatibilities; }; -// Glaze schemas for community rules and the Steam Workshop database. Fields the -// upstream JSON may write either as a single value or as a list use -// std::variant. - // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or // on the wire struct SubExternalRule { @@ -308,14 +274,13 @@ struct WorkshopUpdateResult { } // namespace px::modrim -// Package ids are JSON strings on the wire. template <> struct glz::to { template static void op(const px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& b, auto&& ix) { - glz::to::template op( - value.str().toStdString(), ctx, b, ix); + const std::string text = value.str().toStdString(); + glz::to::template op(text, ctx, b, ix); } }; diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp index 4f8565d..c5d1375 100644 --- a/src/core/services/startup_impact_service/models.cpp +++ b/src/core/services/startup_impact_service/models.cpp @@ -4,60 +4,73 @@ namespace px::modrim::startup_impact { -namespace { - -template -double sumValues(const Map& map) { - double total = 0.0; - for (const double value : map) { - total += value; +QString normalizePackageId(QStringView packageId) { + QString result = packageId.toString().toLower(); + constexpr QLatin1StringView kSuffix{"_steam"}; + if (result.endsWith(kSuffix)) { + result.chop(kSuffix.size()); } - return total; + return result; } -bool isSet(const std::optional& value) { - return value && !value->isEmpty(); +QString formatImpact(double seconds) { + const double milliseconds = seconds * 1000.0; + const double magnitude = std::abs(milliseconds); + double rounded = std::floor(magnitude); + const double fraction = magnitude - rounded; + // Choose the even neighbour at an exact tie, independent of the FP mode. + if (fraction > 0.5 || (fraction == 0.5 && std::fmod(rounded, 2.0) != 0.0)) { + rounded += 1.0; + } + if (milliseconds < 0.0 && rounded != 0.0) { + rounded = -rounded; + } + return QString::number(rounded, 'f', 0) + QLatin1StringView{"ms"}; } -} // namespace - double StartupImpactReport::totalTime() const { - double total = 0.0; - for (const StartupImpactMod& mod : mods) { - total += mod.totalImpactS; + double result = 0.0; + for (const auto& mod : mods) { + result += mod.totalImpactS; } - return total; + return result; } double StartupImpactReport::offThreadTotalTime() const { - double total = 0.0; - for (const StartupImpactMod& mod : mods) { - total += mod.offThreadTotalImpactS; + double result = 0.0; + for (const auto& mod : mods) { + result += mod.offThreadTotalImpactS; } - return total; + return result; } double StartupImpactReport::modsTotalTime() const { return totalTime() + offThreadTotalTime(); } -double StartupImpactReport::baseGameTime() const { return sumValues(metrics); } +double StartupImpactReport::baseGameTime() const { + double result = 0.0; + for (double duration : metrics) { + result += duration; + } + return result; +} const StartupImpactMod* StartupImpactReport::find( const std::optional& packageId, const std::optional& modName) const { - if (isSet(packageId)) { - const QString normalized = normalizePackageId(*packageId); - for (const StartupImpactMod& mod : mods) { - if (mod.packageId == normalized) { + if (packageId && !packageId->isEmpty()) { + const QString wanted = normalizePackageId(*packageId); + for (const auto& mod : mods) { + if (mod.packageId && *mod.packageId == wanted) { return &mod; } } } - if (isSet(modName)) { - const QString nameLower = modName->toLower(); - for (const StartupImpactMod& mod : mods) { - if (mod.modName.toLower() == nameLower) { + if (modName && !modName->isEmpty()) { + const QString wanted = modName->toLower(); + for (const auto& mod : mods) { + if (mod.modName.toLower() == wanted) { return &mod; } } @@ -65,18 +78,4 @@ const StartupImpactMod* StartupImpactReport::find( return nullptr; } -QString normalizePackageId(QStringView packageId) { - QString normalized = packageId.toString().toLower(); - constexpr QLatin1StringView kSteamSuffix{"_steam"}; - if (normalized.endsWith(kSteamSuffix)) { - normalized.chop(kSteamSuffix.size()); - } - return normalized; -} - -QString formatImpact(double seconds) { - const auto ms = static_cast(std::nearbyint(seconds * 1000.0)); - return QStringLiteral("%1ms").arg(ms); -} - } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.hpp b/src/core/services/startup_impact_service/models.hpp index f3d76ff..9190a75 100644 --- a/src/core/services/startup_impact_service/models.hpp +++ b/src/core/services/startup_impact_service/models.hpp @@ -11,13 +11,8 @@ namespace px::modrim::startup_impact { inline constexpr double kImpactWarnThresholdS = 1.0; inline constexpr double kImpactHighThresholdS = 5.0; - -// Stored as the mod's package id for the RimWorld base game's own profiler -// metrics. inline constexpr QLatin1StringView kBaseGamePid{"__base_game__"}; -// Impact durations are in seconds. Metric maps are keyed by profiler metric -// name and ordered by name, so iteration is deterministic. struct StartupImpactMod { QString modName; std::optional packageId; @@ -41,18 +36,11 @@ struct StartupImpactReport { double offThreadTotalTime() const; double modsTotalTime() const; double baseGameTime() const; - - // Matches by normalized package id first, then by case-insensitive mod name. const StartupImpactMod* find(const std::optional& packageId, const std::optional& modName) const; }; -// Lowercases and drops the "_steam" suffix that Steam Workshop folders add to -// package ids. QString normalizePackageId(QStringView packageId); - -// Rounds half to even, as Python's round() does, so displayed values match the -// legacy app. QString formatImpact(double seconds); } // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp index 00b48f1..03fe6d4 100644 --- a/src/core/services/startup_impact_service/parser.cpp +++ b/src/core/services/startup_impact_service/parser.cpp @@ -1,150 +1,130 @@ #include "core/services/startup_impact_service/parser.hpp" +#include #include #include #include -#include -#include -#include -#include -#include +#include +#include +#include #include namespace px::modrim::startup_impact { - namespace { -constexpr QLatin1StringView kReportFilename{"StartupImpactData.json"}; - -// The game writes milliseconds; the rest of the app works in seconds. -double msToS(double ms) { return ms / 1000.0; } - -// Python's bool is an int subclass, so the legacy parser accepts true/false as -// 1/0 ms. -bool isNumeric(const QJsonValue& value) { - return value.isDouble() || value.isBool(); +const glz::json_t& field(const glz::json_t& object, std::string_view name) { + static const glz::json_t kMissing; + const auto& members = object.get_object(); + const auto it = members.find(name); + return it == members.end() ? kMissing : it->second; } -double numericMs(const QJsonValue& value) { - return value.isBool() ? static_cast(value.toBool()) - : value.toDouble(); -} - -double asSeconds(const QJsonValue& value) { - if (isNumeric(value)) { - return msToS(numericMs(value)); +double durationSeconds(const glz::json_t& value) { + if (value.is_number()) { + return value.get_number() / 1000.0; } - if (value.isString()) { + if (value.is_string()) { bool ok = false; - const double ms = value.toString().toDouble(&ok); - return ok ? msToS(ms) : 0.0; + const double number = + QString::fromStdString(value.get_string()).toDouble(&ok); + return ok ? number / 1000.0 : 0.0; } return 0.0; } -// Non-numeric values are skipped, as the legacy parser skips them. -QMap asSecondsMap(const QJsonValue& value) { +QMap durationMap(const glz::json_t& value) { QMap result; - if (!value.isObject()) { - return result; - } - const QJsonObject object = value.toObject(); - for (auto it = object.constBegin(); it != object.constEnd(); ++it) { - if (isNumeric(it.value())) { - result.insert(it.key(), msToS(numericMs(it.value()))); + if (value.is_object()) { + for (const auto& [name, duration] : value.get_object()) { + if (duration.is_number()) { + result.insert(QString::fromStdString(name), + duration.get_number() / 1000.0); + } } } return result; } -std::optional parseMod(const QJsonValue& value) { - if (!value.isObject()) { - return std::nullopt; - } - const QJsonObject entry = value.toObject(); - const QJsonValue modName = entry.value(QStringLiteral("modName")); - if (!modName.isString() || modName.toString().isEmpty()) { - return std::nullopt; - } - - const QJsonValue rawPackageId = entry.value(QStringLiteral("modPackageId")); - std::optional packageId; - if (rawPackageId.isString() && !rawPackageId.toString().isEmpty()) { - packageId = normalizePackageId(rawPackageId.toString()); - } - - return StartupImpactMod{ - .modName = modName.toString(), - .packageId = packageId, - .totalImpactS = asSeconds(entry.value(QStringLiteral("totalImpact"))), - .metrics = asSecondsMap(entry.value(QStringLiteral("metrics"))), - .offThreadMetrics = - asSecondsMap(entry.value(QStringLiteral("offThreadMetrics"))), - .offThreadTotalImpactS = - asSeconds(entry.value(QStringLiteral("offThreadTotalImpact"))), - }; -} - } // namespace QString getStartupImpactPath(const QString& configFolder) { - // Mirrors Path(config_folder).parent: a trailing separator is ignored and a - // bare name has the current directory as its parent. - const QString clean = QDir::cleanPath(configFolder); - const qsizetype slash = clean.lastIndexOf(u'/'); - const QString parent = slash < 0 ? QStringLiteral(".") - : slash == 0 ? QStringLiteral("/") - : clean.left(slash); - return QDir::cleanPath(QDir(parent).filePath(kReportFilename.toString())); + const QString folder = QDir::cleanPath(configFolder); + return QDir(QFileInfo(folder).path()) + .filePath(QStringLiteral("StartupImpactData.json")); } std::optional parseStartupImpact(const QString& path) { - if (!QFileInfo::exists(path)) { - spdlog::debug("Startup impact report not found at {}", path.toStdString()); + QFile input(path); + if (!input.open(QIODevice::ReadOnly)) { + if (!input.exists()) { + spdlog::debug("Startup impact report missing: {}", path.toStdString()); + } else { + spdlog::warn("Cannot open startup impact report {}: {}", + path.toStdString(), input.errorString().toStdString()); + } return std::nullopt; } - - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - spdlog::warn("Failed to parse startup impact report at {}: {}", - path.toStdString(), file.errorString().toStdString()); + const QByteArray bytes = input.readAll(); + if (input.error() != QFileDevice::NoError) { + spdlog::warn("Cannot read startup impact report {}: {}", path.toStdString(), + input.errorString().toStdString()); return std::nullopt; } - - QJsonParseError error; - const QJsonDocument document = - QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError) { - spdlog::warn("Failed to parse startup impact report at {}: {}", - path.toStdString(), error.errorString().toStdString()); + if (!QByteArrayView(bytes).isValidUtf8()) { + spdlog::warn("Startup impact report is not UTF-8: {}", path.toStdString()); return std::nullopt; } - if (!document.isObject()) { - spdlog::warn("Startup impact report is not a JSON object: {}", + const std::string_view text(bytes.constData(), + static_cast(bytes.size())); + glz::json_t root; + if (auto error = glz::read_json(root, text)) { + spdlog::warn("Cannot parse startup impact report {}: {}", + path.toStdString(), glz::format_error(error, text)); + return std::nullopt; + } + if (!root.is_object()) { + spdlog::warn("Startup impact report must be an object: {}", path.toStdString()); return std::nullopt; } - const QJsonObject data = document.object(); StartupImpactReport report; - report.path = QDir::cleanPath(path); - report.loadingTimeS = asSeconds(data.value(QStringLiteral("loadingTime"))); - if (const QJsonValue timestamp = data.value(QStringLiteral("timestamp")); - timestamp.isString()) { - report.timestamp = timestamp.toString(); - } - report.metrics = asSecondsMap(data.value(QStringLiteral("metrics"))); - report.totalImpact = asSeconds(data.value(QStringLiteral("totalImpact"))); - report.offThreadMetrics = - asSecondsMap(data.value(QStringLiteral("offThreadMetrics"))); + report.path = path; + report.loadingTimeS = durationSeconds(field(root, "loadingTime")); + report.metrics = durationMap(field(root, "metrics")); + report.totalImpact = durationSeconds(field(root, "totalImpact")); + report.offThreadMetrics = durationMap(field(root, "offThreadMetrics")); report.offThreadTotalImpact = - asSeconds(data.value(QStringLiteral("offThreadTotalImpact"))); - - // A non-array "mods" yields no entries, as iterating the legacy value did. - for (const QJsonValue& entry : data.value(QStringLiteral("mods")).toArray()) { - if (auto mod = parseMod(entry)) { - report.mods.append(std::move(*mod)); + durationSeconds(field(root, "offThreadTotalImpact")); + const auto& timestamp = field(root, "timestamp"); + if (timestamp.is_string()) { + report.timestamp = QString::fromStdString(timestamp.get_string()); + } + const auto& entries = field(root, "mods"); + if (entries.is_array()) { + report.mods.reserve(static_cast(entries.get_array().size())); + for (const auto& entry : entries.get_array()) { + if (!entry.is_object()) { + continue; + } + const auto& name = field(entry, "modName"); + if (!name.is_string() || name.get_string().empty()) { + continue; + } + StartupImpactMod mod; + mod.modName = QString::fromStdString(name.get_string()); + const auto& packageId = field(entry, "modPackageId"); + if (packageId.is_string() && !packageId.get_string().empty()) { + mod.packageId = + normalizePackageId(QString::fromStdString(packageId.get_string())); + } + mod.totalImpactS = durationSeconds(field(entry, "totalImpact")); + mod.metrics = durationMap(field(entry, "metrics")); + mod.offThreadMetrics = durationMap(field(entry, "offThreadMetrics")); + mod.offThreadTotalImpactS = + durationSeconds(field(entry, "offThreadTotalImpact")); + report.mods.append(std::move(mod)); } } return report; diff --git a/src/core/services/startup_impact_service/parser.hpp b/src/core/services/startup_impact_service/parser.hpp index ee72f77..6e29592 100644 --- a/src/core/services/startup_impact_service/parser.hpp +++ b/src/core/services/startup_impact_service/parser.hpp @@ -7,12 +7,7 @@ namespace px::modrim::startup_impact { -// The report lives next to the game's config folder, not inside it. QString getStartupImpactPath(const QString& configFolder); - -// Reads a StartupImpactData.json report. Returns nullopt when the file is -// missing, unreadable, or not a JSON object. Malformed entries are skipped -// rather than failing the whole report. std::optional parseStartupImpact(const QString& path); } // namespace px::modrim::startup_impact diff --git a/src/core/xml.cpp b/src/core/xml.cpp index f828197..1679182 100644 --- a/src/core/xml.cpp +++ b/src/core/xml.cpp @@ -3,134 +3,93 @@ #include #include #include -#include #include +#include namespace px::modrim { - namespace { -glz::json_t jsonString(std::string value) { - glz::json_t out; - out.data = std::move(value); - return out; -} - -QString scalarToString(const glz::json_t& value) { - return std::visit( - [](const auto& v) -> QString { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return QString::fromStdString(v); - } else if constexpr (std::is_same_v) { - // Python's str(True) spelling, matching the legacy writer. - return v ? QStringLiteral("True") : QStringLiteral("False"); - } else if constexpr (std::is_same_v) { - return QString::number(v, 'g', 17); - } else { - return {}; - } - }, - value.data); -} - -// The value part of `{tag: value}` for one element. -glz::json_t elementValue(pugi::xml_node element) { - glz::json_t::object_t container; - bool hasChildren = false; - for (pugi::xml_node child = element.first_child(); child; - child = child.next_sibling()) { - if (child.type() != pugi::node_element) { - continue; - } - hasChildren = true; - const std::string key = child.name(); - glz::json_t value = elementValue(child); - auto existing = container.find(key); - if (existing == container.end()) { - container.emplace(key, std::move(value)); - continue; - } - if (auto* items = - std::get_if(&existing->second.data)) { - items->push_back(std::move(value)); +glz::json_t nodeValue(pugi::xml_node node) { + glz::json_t::object_t fields; + bool hasElements = false; + for (const auto child : node.children()) { + if (child.type() != pugi::node_element) continue; + hasElements = true; + auto value = nodeValue(child); + auto [position, inserted] = + fields.try_emplace(child.name(), std::move(value)); + if (inserted) continue; + if (auto* repeated = + std::get_if(&position->second.data)) { + repeated->push_back(std::move(value)); } else { - glz::json_t::array_t merged; - merged.push_back(std::move(existing->second)); - merged.push_back(std::move(value)); - existing->second.data = std::move(merged); + glz::json_t::array_t entries; + entries.push_back(std::move(position->second)); + entries.push_back(std::move(value)); + position->second.data = std::move(entries); } } - - bool hasAttributes = false; - for (pugi::xml_attribute attribute = element.first_attribute(); attribute; - attribute = attribute.next_attribute()) { - hasAttributes = true; - container["@" + std::string(attribute.name())] = - jsonString(attribute.value()); - } - - // lxml's `if t.text:` is a truthiness check on the raw text; the stored value - // is stripped. - const QString text = rawText(element); - const QString trimmed = text.trimmed(); - + const QString text = rawText(node).trimmed(); glz::json_t result; - if (hasChildren || hasAttributes) { - if (!trimmed.isEmpty()) { - container["#text"] = jsonString(trimmed.toStdString()); + if (!hasElements && !node.first_attribute()) { + const auto first = node.first_child(); + if (first.type() == pugi::node_pcdata || first.type() == pugi::node_cdata) { + result.data = text.toStdString(); + } else { + result.data = glz::json_t::object_t{}; } - result.data = std::move(container); - } else if (!text.isEmpty()) { - result = jsonString(trimmed.toStdString()); - } else { - result.data = std::move(container); + return result; } + for (const auto attribute : node.attributes()) { + fields[std::string("@") + attribute.name()].data = + std::string(attribute.value()); + } + if (!text.isEmpty()) fields["#text"].data = text.toStdString(); + result.data = std::move(fields); return result; } -// Fills `element` from a JSON value: objects describe children/attributes/text, -// arrays become anonymous `li` children, and scalars become the element's text. -Result fillElement(pugi::xml_node element, const glz::json_t& value) { - if (const auto* object = std::get_if(&value.data)) { - for (const auto& [key, child] : *object) { +std::string scalarText(const glz::json_t& value) { + if (const auto* text = std::get_if(&value.data)) return *text; + if (const auto* boolean = std::get_if(&value.data)) { + return *boolean ? "True" : "False"; + } + if (std::holds_alternative(value.data)) return "None"; + return value.dump().value_or(std::string{}); +} + +void populateNode(pugi::xml_node node, const glz::json_t& value) { + if (const auto* fields = std::get_if(&value.data)) { + for (const auto& [key, item] : *fields) { if (key.starts_with('@')) { - const std::string name = key.substr(1); - pugi::xml_attribute attribute = element.attribute(name.c_str()); - if (!attribute) { - attribute = element.append_attribute(name.c_str()); - } - attribute.set_value(scalarToString(child).toUtf8().constData()); + node.append_attribute(key.c_str() + 1) + .set_value(scalarText(item).c_str()); } else if (key == "#text") { - element.text().set(scalarToString(child).toUtf8().constData()); - } else if (const auto* items = - std::get_if(&child.data)) { - for (const glz::json_t& item : *items) { - pugi::xml_node repeated = element.append_child(key.c_str()); - if (auto filled = fillElement(repeated, item); !filled) { - return filled; - } + // A leading text node must remain before children regardless of map + // order. + auto first = node.first_child(); + if (first.type() != pugi::node_pcdata && + first.type() != pugi::node_cdata) { + first = node.prepend_child(pugi::node_pcdata); } - } else { - pugi::xml_node single = element.append_child(key.c_str()); - if (auto filled = fillElement(single, child); !filled) { - return filled; + first.set_value(scalarText(item).c_str()); + } else if (const auto* entries = + std::get_if(&item.data)) { + for (const auto& entry : *entries) { + populateNode(node.append_child(key.c_str()), entry); } + } else { + populateNode(node.append_child(key.c_str()), item); } } - return {}; - } - if (const auto* items = std::get_if(&value.data)) { - for (const glz::json_t& item : *items) { - pugi::xml_node li = element.append_child("li"); - if (auto filled = fillElement(li, item); !filled) { - return filled; - } + } else if (const auto* entries = + std::get_if(&value.data)) { + for (const auto& entry : *entries) { + populateNode(node.append_child("li"), entry); } - return {}; + } else { + node.append_child(pugi::node_pcdata).set_value(scalarText(value).c_str()); } - element.text().set(scalarToString(value).toUtf8().constData()); - return {}; } } // namespace @@ -142,56 +101,49 @@ Result loadXmlFile(pugi::xml_document& doc, const QString& path) { } QFile file(path); if (!file.open(QIODevice::ReadOnly)) { - return std::unexpected(Error{QStringLiteral("Error parsing XML file %1: %2") - .arg(path, file.errorString())}); + return std::unexpected( + Error{QStringLiteral("Failed to read XML file %1: %2") + .arg(path, file.errorString())}); } - const QByteArray bytes = file.readAll(); - - const pugi::xml_parse_result parsed = - doc.load_buffer(bytes.constData(), bytes.size()); - if (!parsed) { + const QByteArray contents = file.readAll(); + if (file.error() != QFileDevice::NoError) { return std::unexpected( - Error{QStringLiteral("Error parsing XML file %1: %2") - .arg(path, QString::fromUtf8(parsed.description()))}); + Error{QStringLiteral("Failed to read XML file %1: %2") + .arg(path, file.errorString())}); } - if (!doc.document_element()) { + // Keep whitespace text, comments, and PIs so .text never crosses a node. + const auto parsed = doc.load_buffer( + contents.constData(), static_cast(contents.size()), + pugi::parse_default | pugi::parse_ws_pcdata | pugi::parse_comments | + pugi::parse_pi); + if (!parsed || !doc.document_element()) { return std::unexpected( - Error{QStringLiteral("Error parsing XML file %1: no root element") - .arg(path)}); + Error{QStringLiteral("Failed to parse XML file %1: %2") + .arg(path, QString::fromUtf8(parsed.description()))}); } return {}; } QString rawText(pugi::xml_node node) { QString text; - for (pugi::xml_node child = node.first_child(); child; - child = child.next_sibling()) { - if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) { + for (const auto child : node.children()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) break; - } text += QString::fromUtf8(child.value()); } return text; } std::optional childText(pugi::xml_node parent, const char* tag) { - const pugi::xml_node found = parent.child(tag); - if (!found) { - return std::nullopt; - } - const QString text = rawText(found).trimmed(); - if (text.isEmpty()) { - return std::nullopt; - } + const QString text = rawText(parent.child(tag)).trimmed(); + if (text.isEmpty()) return std::nullopt; return text; } glz::json_t elementToJson(pugi::xml_node element) { - glz::json_t::object_t wrapped; - wrapped.emplace(std::string(element.name()), elementValue(element)); - glz::json_t out; - out.data = std::move(wrapped); - return out; + glz::json_t result; + result.data = glz::json_t::object_t{{element.name(), nodeValue(element)}}; + return result; } Result xmlFileToJson(const QString& path) { @@ -203,14 +155,14 @@ Result xmlFileToJson(const QString& path) { } Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data) { - const auto* object = std::get_if(&data.data); - if (object == nullptr || object->size() != 1) { + const auto* roots = std::get_if(&data.data); + if (!roots || roots->size() != 1) { return std::unexpected( Error{QStringLiteral("Dictionary must have exactly one root key")}); } - const auto& [rootKey, rootValue] = *object->begin(); - pugi::xml_node root = parent.append_child(rootKey.c_str()); - return fillElement(root, rootValue); + const auto& [name, value] = *roots->begin(); + populateNode(parent.append_child(name.c_str()), value); + return {}; } } // namespace px::modrim diff --git a/src/core/xml.hpp b/src/core/xml.hpp index 9e72391..6365f98 100644 --- a/src/core/xml.hpp +++ b/src/core/xml.hpp @@ -10,30 +10,11 @@ namespace px::modrim { -// Loads and parses an XML file into `doc`. Fails if the file is missing, -// unreadable, malformed, or has no root element. Result loadXmlFile(pugi::xml_document& doc, const QString& path); - -// Text before the first child node of `node` (lxml's `.text`), unstripped; -// empty when absent. QString rawText(pugi::xml_node node); - -// Stripped text of the first child element named `tag`, or nullopt when missing -// or blank. std::optional childText(pugi::xml_node parent, const char* tag); - -// Converts an element into the legacy dict shape: `{tag: value}`. Repeated -// child tags become arrays, attributes become "@name" keys, and mixed text -// becomes "#text". glz::json_t elementToJson(pugi::xml_node element); - -// Parses an XML file and returns `{rootTag: value}` as produced by -// elementToJson. Result xmlFileToJson(const QString& path); - -// Appends the one-key dict `{tag: value}` as an element under `parent` (the -// inverse of elementToJson). Fails unless `data` is an object with exactly one -// key. Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data); } // namespace px::modrim From 85fceedaa489ba169842aae2c65615a94a3db931 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:23 +0300 Subject: [PATCH 15/50] fix(core): use the declared Platform enumerators and drop the invalid-mod text from parsed mods --- src/core/models/metadata/parsing.cpp | 2 +- src/core/services/game_process.cpp | 4 ++-- tests/core/models/metadata/test_parsing.cpp | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp index 7b78f9e..3d9d585 100644 --- a/src/core/models/metadata/parsing.cpp +++ b/src/core/models/metadata/parsing.cpp @@ -226,7 +226,7 @@ std::shared_ptr createAboutModFromElement( } if (!versionDescription.isEmpty()) { mod->description = std::move(versionDescription); - } else if (!description.isEmpty()) { + } else { mod->description = std::move(description); } for (size_t index = 0; index < versionRules.size(); ++index) { diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 514f9b0..4f1913c 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -183,9 +183,9 @@ bool isEnvAssignment(const QString& token) { Platform currentPlatform() { #if defined(Q_OS_MACOS) - return Platform::MacOS; + return Platform::kMacOs; #elif defined(Q_OS_WIN) - return Platform::Windows; + return Platform::kWindows; #else return Platform::kLinux; #endif diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp index ef98c00..205c5cf 100644 --- a/tests/core/models/metadata/test_parsing.cpp +++ b/tests/core/models/metadata/test_parsing.cpp @@ -160,6 +160,14 @@ TEST_CASE( CHECK(mod->description == QStringLiteral("Text for 1.5")); } +TEST_CASE("a parsed mod without a description does not claim to be invalid") { + const auto mod = aboutFromXml( + "x.yValid"); + CHECK(mod->valid); + CHECK(mod->description.isEmpty()); +} + TEST_CASE("explicit steamAppId is read") { const auto mod = aboutFromXml(R"(x.y 1234)"); From a161fdb5bea46642d09c5cc5528884117c43023f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:23 +0300 Subject: [PATCH 16/50] build: UI-off tests, CMake 3.24 presets, macOS bundle run path, QCoro without WebSockets --- .github/workflows/build.yml | 3 +++ .github/workflows/ci.yml | 1 + CMakePresets.json | 2 +- justfile | 3 ++- tests/CMakeLists.txt | 10 ++++++---- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b5f50e..9d49685 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,7 @@ jobs: -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ -DUSE_QT_VERSION=6 \ -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ -DQCORO_BUILD_EXAMPLES=OFF \ -DBUILD_TESTING=OFF cmake --build "$RUNNER_TEMP/qcoro-build" @@ -266,6 +267,7 @@ jobs: -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ -DUSE_QT_VERSION=6 \ -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ -DQCORO_BUILD_EXAMPLES=OFF \ -DBUILD_TESTING=OFF cmake --build "$RUNNER_TEMP/qcoro-build" @@ -377,6 +379,7 @@ jobs: -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ -DUSE_QT_VERSION=6 \ -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ -DQCORO_BUILD_EXAMPLES=OFF \ -DBUILD_TESTING=OFF cmake --build "$RUNNER_TEMP/qcoro-build" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb824cb..3d05b24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ -DUSE_QT_VERSION=6 \ -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ -DQCORO_BUILD_EXAMPLES=OFF \ -DBUILD_TESTING=OFF cmake --build "$RUNNER_TEMP/qcoro-build" diff --git a/CMakePresets.json b/CMakePresets.json index 28adfe6..ff3253f 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -1,5 +1,5 @@ { - "version": 6, + "version": 5, "configurePresets": [ { "name": "default", diff --git a/justfile b/justfile index c41df58..6ba280d 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,7 @@ set windows-shell := ["bash", "-cu"] python := if os() == "windows" { "python" } else { "python3" } +app_exe := if os() == "macos" { "pxmodrim.app/Contents/MacOS/pxmodrim" } else { "pxmodrim" } @default: just --list @@ -25,7 +26,7 @@ run-none *args: (_run "NoWorkshop" "none" args) _run variant dir *args: build-js cmake -S . -B build/{{dir}} -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPX_WORKSHOP_VARIANT={{variant}} cmake --build build/{{dir}} --target pxmodrim - PX_LOG_LEVEL=debug build/{{dir}}/src/app/pxmodrim {{args}} + PX_LOG_LEVEL=debug build/{{dir}}/src/app/{{app_exe}} {{args}} test: build QT_QPA_PLATFORM=offscreen ctest --preset default diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 70e9105..2d2cf0d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,7 +6,9 @@ target_link_libraries(pxcore_tests PRIVATE pxcore Catch2::Catch2WithMain) catch_discover_tests(pxcore_tests) target_compile_definitions(pxcore_tests PRIVATE PX_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") -file(GLOB_RECURSE PX_UI_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/ui/*.cpp) -add_executable(pxui_tests ${PX_UI_TESTS}) -target_link_libraries(pxui_tests PRIVATE pxui pxuiplugin Qt6::Test Catch2::Catch2) -catch_discover_tests(pxui_tests) +if(PX_BUILD_UI) + file(GLOB_RECURSE PX_UI_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/ui/*.cpp) + add_executable(pxui_tests ${PX_UI_TESTS}) + target_link_libraries(pxui_tests PRIVATE pxui pxuiplugin Qt6::Test Catch2::Catch2) + catch_discover_tests(pxui_tests) +endif() From c6b04c89bcf224dbb1325ca903249f0df6545722 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:23 +0300 Subject: [PATCH 17/50] fix(workshop): await catalog and Steam downloader coroutines on shutdown --- src/core/downloads/steam.cpp | 39 ++++++--- src/core/downloads/steam.hpp | 4 +- src/core/workshop/catalog.cpp | 40 +++++++-- src/core/workshop/catalog.hpp | 6 +- tests/core/downloads/test_steam.cpp | 119 +++++++++++++++++++++++++++ tests/core/workshop/test_catalog.cpp | 114 ++++++++++++++++++++++++- 6 files changed, 300 insertions(+), 22 deletions(-) diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index 022a2b2..cba52bd 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -173,8 +175,8 @@ QCoro::Task SteamDownloader::shutdown() { m_stopping = true; m_autoTimer.stop(); cancel(); - if (m_running.isValid()) { - QFuture pending = m_running; + if (m_batchFinished.isValid()) { + QFuture pending = m_batchFinished; co_await pending; } } @@ -233,6 +235,10 @@ QStringList SteamDownloader::staleIds(double maxAgeS, double now) const { QCoro::Task> SteamDownloader::downloadMods( QStringList modIds) { + if (m_stopping) { + co_return std::unexpected( + Error{QStringLiteral("Steam downloader is shutting down.")}); + } if (modIds.isEmpty()) { co_return std::unexpected( Error{QStringLiteral("No mods selected for download.")}); @@ -264,6 +270,10 @@ QCoro::Task> SteamDownloader::downloadMods( auto batch = std::make_shared(static_cast(ids.size())); m_stop.emplace(); + QPromise completion; + completion.start(); + m_batchFinished = completion.future(); + const auto finish = qScopeGuard([&completion] { completion.finish(); }); m_activeIds = QSet(ids.begin(), ids.end()); busyChanged.notify(true); batchStarted.notify(ids); @@ -330,12 +340,19 @@ SteamDownloader::ensureClient() { QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, std::shared_ptr batch) { + const std::stop_token stop = m_stop->get_token(); const std::filesystem::path rootPath(root.toStdWString()); const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { std::error_code ec; std::filesystem::create_directories(rootPath, ec); return ec; }); + if (stop.stop_requested()) { + for (const auto& id : ids) { + finishItem(*batch, id, QStringLiteral("cancelled")); + } + co_return; + } if (mkdirError) { const QString message = QStringLiteral("Cannot create %1: %2") @@ -353,6 +370,12 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, } const auto loginStarted = std::chrono::steady_clock::now(); auto login = co_await ensureClient(); + if (stop.stop_requested()) { + for (const auto& id : ids) { + finishItem(*batch, id, QStringLiteral("cancelled")); + } + co_return; + } if (!login) { const QString message = QStringLiteral("Steam login failed: %1").arg(login.error().message); @@ -390,7 +413,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, pxsteamdl::Options options; options.parallel_items = static_cast(parallelItems); options.threads_per_item = static_cast(threadsPerItem); - options.stop = m_stop->get_token(); + options.stop = stop; options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { QMetaObject::invokeMethod( target, @@ -423,15 +446,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, : what); } }); - m_running = call; - Outcome outcome; - try { - outcome = co_await QFuture(call); - } catch (...) { - m_running = {}; - throw; - } - m_running = {}; + Outcome outcome = co_await QFuture(call); if (!outcome) { // A failed call leaves the Steam session suspect; log in again next time. diff --git a/src/core/downloads/steam.hpp b/src/core/downloads/steam.hpp index f12a974..0d83a3a 100644 --- a/src/core/downloads/steam.hpp +++ b/src/core/downloads/steam.hpp @@ -207,8 +207,8 @@ class SteamDownloader : public Downloader { // Set while a batch is downloading. std::optional m_stop; QSet m_activeIds; - // The PxSteamDL call of the running batch, so shutdown() can wait for it. - QFuture m_running; + // Includes preparation, login, and all completion notifications. + QFuture m_batchFinished; // Worker-thread callbacks are posted to this object, which lives on the main // thread. diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index 65ccfa9..ddc9534 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -132,23 +132,35 @@ void WorkshopCatalog::disconnectEvents() { } WorkshopCatalog::~WorkshopCatalog() { + *m_alive = false; disconnectEvents(); - // Destroying the queue's coroutine must not leave the downloads holding its - // handle. + auto waiters = std::move(m_idleWaiters); m_idleWaiters.clear(); - m_runner.reset(); + for (std::coroutine_handle<> waiter : waiters) { + waiter.resume(); + } } QCoro::Task WorkshopCatalog::shutdown() { + m_stopping = true; disconnectEvents(); cancelQueue(); - m_idleWaiters.clear(); + auto runner = std::move(m_runner); m_runner.reset(); - m_draining = false; + if (m_downloading) { + m_downloads->cancel(); + } + auto waiters = std::move(m_idleWaiters); + m_idleWaiters.clear(); + for (std::coroutine_handle<> waiter : waiters) { + waiter.resume(); + } for (const auto& client : m_clients) { client->shutdown(); } - co_return; + if (runner) { + co_await *runner; + } } void WorkshopCatalog::setBaseUrl(const QString& url) { @@ -319,6 +331,9 @@ QCoro::Task> WorkshopCatalog::download( } void WorkshopCatalog::enqueue(const DownloadPlan& downloadPlan) { + if (m_stopping) { + return; + } const QSet active = m_downloads->activeIds(); QStringList ids; for (const QString& id : downloadPlan.to_download) { @@ -345,6 +360,7 @@ void WorkshopCatalog::cancelQueue() { } QCoro::Task WorkshopCatalog::drain() { + const auto alive = m_alive; while (!m_queue.isEmpty()) { const QStringList ids = m_queue.takeFirst(); // A batch that waited for the running download stops the queue if that @@ -352,13 +368,25 @@ QCoro::Task WorkshopCatalog::drain() { const bool waited = m_downloads->isDownloading(); while (m_downloads->isDownloading()) { co_await IdleWait{*this}; + if (!*alive) { + co_return; + } + if (m_stopping) { + m_draining = false; + co_return; + } } if (waited && m_downloads->cancelled()) { cancelQueue(); break; } + m_downloading = true; auto result = co_await m_downloads->downloadMods(ids); + if (!*alive) { + co_return; + } + m_downloading = false; if (!result) { spdlog::warn("[workshop] queued download failed: {}", result.error().message.toStdString()); diff --git a/src/core/workshop/catalog.hpp b/src/core/workshop/catalog.hpp index 725d1c7..9c909f7 100644 --- a/src/core/workshop/catalog.hpp +++ b/src/core/workshop/catalog.hpp @@ -106,7 +106,7 @@ class WorkshopCatalog : public Plugin { WorkshopCatalog(const WorkshopCatalog&) = delete; WorkshopCatalog& operator=(const WorkshopCatalog&) = delete; - // Stops the queue and aborts requests in flight. + // Stops the queue, aborts requests, and waits for the queue runner to finish. QCoro::Task shutdown() override; Event catalogUrlChanged; @@ -215,6 +215,10 @@ class WorkshopCatalog : public Plugin { QList m_queue; QSet m_queued; + // Resetting a QCoro task does not cancel its suspended coroutine. + std::shared_ptr m_alive = std::make_shared(true); + bool m_stopping = false; + bool m_downloading = false; bool m_draining = false; std::optional> m_runner; std::vector> m_idleWaiters; diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp index 3aeefd3..ae5a064 100644 --- a/tests/core/downloads/test_steam.cpp +++ b/tests/core/downloads/test_steam.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -22,6 +24,7 @@ #include #include +#include #include #include @@ -244,6 +247,9 @@ struct Service { QCoro::Task>> login( LoginOptions opts) { logins.push_back(opts); + if (loginGate.isValid()) { + co_await QFuture(loginGate); + } if (clients.empty()) { co_return std::unexpected( px::modrim::Error{QStringLiteral("logon denied")}); @@ -261,6 +267,7 @@ struct Service { std::vector logins; std::deque> clients; + QFuture loginGate; std::unique_ptr steam; }; @@ -480,6 +487,118 @@ TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { REQUIRE_FALSE(svc.steam->isDownloading()); } +TEST_CASE("shutdown waits for a batch suspended in login", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + SECTION("login succeeds") {} + SECTION("login fails") { svc.clients.clear(); } + + QPromise loginGate; + loginGate.start(); + svc.loginGate = loginGate.future(); + + bool shutdownDone = false; + int eventsAfterShutdown = 0; + const auto observe = [&](const auto&) { + if (shutdownDone) { + ++eventsAfterShutdown; + } + }; + svc.steam->busyChanged.connect(observe); + svc.steam->batchStarted.connect(observe); + svc.steam->statusMessageChanged.connect(observe); + svc.steam->downloadPhaseChanged.connect(observe); + svc.steam->downloadProgress.connect(observe); + svc.steam->downloadItemStatusChanged.connect(observe); + svc.steam->downloadItemTitled.connect(observe); + svc.steam->downloadFinished.connect(observe); + + std::optional> downloadOut; + auto download = test_support::awaitInto( + svc.steam->downloadMods({"111", "222"}), downloadOut); + + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &loop, [&] { + if (!svc.logins.empty()) { + loop.quit(); + } + }); + timeout.start(10s); + poll.start(1ms); + if (svc.logins.empty()) { + loop.exec(); + } + poll.stop(); + CHECK(svc.logins.size() == 1); + CHECK_FALSE(downloadOut.has_value()); + + bool downloadCompletedBeforeShutdown = false; + const auto finishShutdown = [&]() -> QCoro::Task { + co_await svc.steam->shutdown(); + downloadCompletedBeforeShutdown = downloadOut.has_value(); + shutdownDone = true; + loop.quit(); + }; + auto shutdown = finishShutdown(); + CHECK_FALSE(shutdownDone); + + loginGate.finish(); + timeout.start(10s); + if (!shutdownDone) { + loop.exec(); + } + REQUIRE(shutdownDone); + CHECK(downloadCompletedBeforeShutdown); + REQUIRE(downloadOut.has_value()); + REQUIRE(downloadOut->has_value()); + CHECK((*downloadOut)->succeeded.isEmpty()); + CHECK((*downloadOut)->failed == QStringList{"111", "222"}); + CHECK(client->calls.empty()); + CHECK_FALSE(svc.steam->isDownloading()); + CHECK_FALSE(runLoop(svc.steam->downloadMods({"333"})).has_value()); + + QMetaObject::invokeMethod(&loop, [&] { loop.quit(); }, Qt::QueuedConnection); + loop.exec(); + CHECK(eventsAfterShutdown == 0); +} + +TEST_CASE("cancel during login does not start a workshop download", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QPromise loginGate; + loginGate.start(); + svc.loginGate = loginGate.future(); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (!svc.logins.empty()) { + poll.stop(); + svc.steam->cancel(); + loginGate.finish(); + } + }); + poll.start(1ms); + + auto result = runLoop(svc.steam->downloadMods({"111", "222"})); + + REQUIRE(result.has_value()); + CHECK(result->succeeded.isEmpty()); + CHECK(result->failed == QStringList{"111", "222"}); + CHECK(client->calls.empty()); + CHECK_FALSE(svc.steam->isDownloading()); + REQUIRE(runLoop(svc.steam->downloadMods({"333"})).has_value()); + REQUIRE(client->calls.size() == 1); + CHECK(client->calls.front().ids == std::vector{333}); +} + TEST_CASE("second download while running is rejected", "[downloads][steam]") { TestApplication app; auto client = std::make_shared(); diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index ded271b..6c63da3 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -1,12 +1,15 @@ #include +#include #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -53,6 +56,20 @@ struct IdsBatchPayload { }; struct FakeDownloader : public Downloader { + struct DownloadGate { + std::coroutine_handle<> waiter; + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) noexcept { + waiter = handle; + } + void await_resume() const noexcept {} + // NOLINTEND(readability-identifier-naming) + + void release() { std::exchange(waiter, {}).resume(); } + }; + FakeDownloader(DownloadManager& manager, std::map synced) : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), manager), @@ -76,15 +93,26 @@ struct FakeDownloader : public Downloader { QCoro::Task> downloadMods(QStringList ids) override { recordedCalls.push_back(ids); + if (gate) { + isBusy = true; + active = QSet(ids.begin(), ids.end()); + busyChanged.notify(true); + co_await *gate; + isBusy = false; + active.clear(); + busyChanged.notify(false); + } co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; } - void cancel() override {} + void cancel() override { ++cancelCalls; } std::map synced; bool isBusy = false; QSet active; std::vector recordedCalls; + int cancelCalls = 0; + std::optional gate; }; ListedModPtr makeMod(const QString& path, @@ -367,6 +395,90 @@ TEST_CASE("workshop enqueue runs batches in order after the running download") { CHECK(catalog.queuedIds().isEmpty()); } +TEST_CASE("workshop shutdown waits for its cancelled download to finish") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->gate.emplace(); + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("6")}}); + REQUIRE(fixture.downloader->isBusy); + REQUIRE(fixture.downloader->gate->waiter); + + bool finished = false; + auto finishShutdown = [&]() -> QCoro::Task { + co_await catalog->shutdown(); + finished = true; + }; + auto shutdown = finishShutdown(); + CHECK_FALSE(finished); + CHECK(fixture.downloader->cancelCalls == 1); + CHECK(catalog->queuedIds().isEmpty()); + + SECTION("the catalog survives until shutdown completes") {} + SECTION("the catalog is destroyed before the download finishes") { + catalog.reset(); + } + + fixture.downloader->gate->release(); + run(std::move(shutdown)); + CHECK(finished); + CHECK_FALSE(fixture.downloader->isBusy); + CHECK(fixture.downloader->recordedCalls == + std::vector{{QStringLiteral("5")}}); + + QEventLoop loop; + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + loop.exec(); +} + +TEST_CASE("workshop destruction leaves a suspended download safe to finish") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->gate.emplace(); + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + int queueChanges = 0; + catalog->queueChanged.connect([&](std::monostate) { ++queueChanges; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + REQUIRE(fixture.downloader->gate->waiter); + const int changesBeforeDestruction = queueChanges; + + catalog.reset(); + fixture.downloader->gate->release(); + + QEventLoop loop; + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + loop.exec(); + CHECK_FALSE(fixture.downloader->isBusy); + CHECK(queueChanges == changesBeforeDestruction); +} + +TEST_CASE("workshop shutdown releases idle waiters without cancelling others") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->isBusy = true; + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + REQUIRE(fixture.downloader->recordedCalls.empty()); + + SECTION("shutdown wakes the idle runner") { + run(catalog->shutdown()); + CHECK(catalog->queuedIds().isEmpty()); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("6")}}); + CHECK(catalog->queuedIds().isEmpty()); + } + SECTION("destruction wakes the idle runner") {} + + catalog.reset(); + fixture.downloader->isBusy = false; + fixture.downloads.busyChanged.notify(false); + CHECK(fixture.downloader->cancelCalls == 0); + CHECK(fixture.downloader->recordedCalls.empty()); +} + TEST_CASE( "workshop cached values survive a restart and corrupt files are ignored") { TestApplication app; From 4c12c9e6a673533622040f78f2d42c7d1d5dcba7 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:23 +0300 Subject: [PATCH 18/50] fix(organizer): validate folder moves against the committed tree --- src/core/organizer/db.cpp | 91 ++++++++++++++++++- src/core/organizer/db.hpp | 2 + src/core/organizer/service.cpp | 9 +- src/core/organizer/service.hpp | 2 +- tests/core/organizer/test_service.cpp | 123 ++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 8 deletions(-) diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp index 4145a6a..b81ab81 100644 --- a/src/core/organizer/db.cpp +++ b/src/core/organizer/db.cpp @@ -182,6 +182,74 @@ QString tagMissing(qint64 tagId) { return QStringLiteral("Tag with id %1 does not exist.").arg(tagId); } +Error depthLimitError() { + return Error{QStringLiteral("Folders can be nested at most %1 levels deep.") + .arg(kMaxDepth)}; +} + +Result folderDepth(QSqlDatabase& db, qint64 folderId, + std::optional movingFolder = std::nullopt) { + QSqlQuery query(db); + if (!query.prepare( + QStringLiteral("SELECT parent_id FROM folders WHERE id = ?"))) { + return std::unexpected(errorOf(query)); + } + for (int depth = 0; depth <= kMaxDepth; ++depth) { + if (folderId == movingFolder) { + return std::unexpected( + Error{QStringLiteral("A folder cannot be moved into itself.")}); + } + query.bindValue(0, folderId); + if (!query.exec()) { + return std::unexpected(errorOf(query)); + } + if (!query.next()) { + if (query.lastError().isValid()) { + return std::unexpected(errorOf(query)); + } + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + if (query.value(0).isNull()) { + return depth; + } + folderId = query.value(0).toLongLong(); + query.finish(); + } + return std::unexpected(depthLimitError()); +} + +Result checkFolderMove(QSqlDatabase& db, qint64 folderId, + qint64 parentId) { + auto parentDepth = folderDepth(db, parentId, folderId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + auto height = runStatement( + db, + QStringLiteral("WITH RECURSIVE subtree(id, height) AS (" + " SELECT id, 1 FROM folders WHERE id = ?" + " UNION ALL" + " SELECT f.id, s.height + 1 FROM folders f" + " JOIN subtree s ON f.parent_id = s.id WHERE s.height <= ?" + ") SELECT MAX(height) FROM subtree"), + {folderId, kMaxDepth}); + if (!height) { + return std::unexpected(height.error()); + } + if (!height->next()) { + return std::unexpected(errorOf(*height)); + } + if (height->value(0).isNull()) { + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + if (*parentDepth + height->value(0).toInt() > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + return {}; +} + Result loadState(QSqlDatabase& db) { OrganizerState state; @@ -264,6 +332,13 @@ Result loadState(QSqlDatabase& db) { Result insertFolder(QSqlDatabase& db, qint64 parentId, const QString& name) { + auto parentDepth = folderDepth(db, parentId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + if (*parentDepth + 1 > kMaxDepth) { + return std::unexpected(depthLimitError()); + } auto inserted = runStatement( db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), {parentId, name}); @@ -388,6 +463,15 @@ Result> replaceRuleRows(QSqlDatabase& db, Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, const QStringList& newFolders, const QList& rules) { + if (!newFolders.isEmpty()) { + auto parentDepth = folderDepth(db, parentId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + if (*parentDepth + 1 > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + } QList folderRows; for (const QString& name : newFolders) { folderRows.append({parentId, name}); @@ -581,13 +665,12 @@ QCoro::Task> OrganizerDb::setParent(qint64 folderId, co_return std::unexpected( Error{QStringLiteral("The root folder cannot be moved.")}); } - if (folderId == parentId) { - co_return std::unexpected( - Error{QStringLiteral("A folder cannot be its own parent.")}); - } co_return co_await submit([folderId, parentId](QSqlDatabase& db) -> Result { return inTransaction(db, [&]() -> Result { + if (auto valid = checkFolderMove(db, folderId, parentId); !valid) { + return valid; + } return runExpectingRow( db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), {parentId, folderId}, folderMissing(folderId)); diff --git a/src/core/organizer/db.hpp b/src/core/organizer/db.hpp index a2d93a4..426aa90 100644 --- a/src/core/organizer/db.hpp +++ b/src/core/organizer/db.hpp @@ -20,6 +20,8 @@ QString dbPath(const QString& configDir); // that owns the connection, so operations are serialized in submission order // and each is atomic. The connection opens on first use and closes on close() // or destruction. +// Folder creation and reparenting enforce the depth limit and reject cycles +// against committed rows inside the mutation transaction. class OrganizerDb { public: explicit OrganizerDb(QString path); diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index 518a5f2..21595a3 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -125,7 +125,7 @@ int OrganizerService::depth(qint64 folderId) const { const auto& folders = state().folders; int count = 0; Folder current = folders.value(folderId); - while (current.parentId) { + while (current.parentId && count <= kMaxDepth) { ++count; current = folders.value(*current.parentId); } @@ -157,10 +157,13 @@ QSet OrganizerService::subtreeIds(qint64 folderId) const { return ids; } -int OrganizerService::height(qint64 folderId) const { +int OrganizerService::height(qint64 folderId, int limit) const { + if (limit == 1) { + return 1; + } int tallest = 0; for (const Folder& child : children(folderId)) { - tallest = std::max(tallest, height(child.id)); + tallest = std::max(tallest, height(child.id, limit - 1)); } return 1 + tallest; } diff --git a/src/core/organizer/service.hpp b/src/core/organizer/service.hpp index 751e950..1cea94a 100644 --- a/src/core/organizer/service.hpp +++ b/src/core/organizer/service.hpp @@ -109,7 +109,7 @@ class OrganizerService : public Plugin { std::optional exclude = std::nullopt) const; Result checkDepth(qint64 parentId, int height) const; int depth(qint64 folderId) const; - int height(qint64 folderId) const; + int height(qint64 folderId, int limit = kMaxDepth + 1) const; QList children(qint64 folderId) const; QSet subtreeIds(qint64 folderId) const; diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp index 50ff519..0d5f1f7 100644 --- a/tests/core/organizer/test_service.cpp +++ b/tests/core/organizer/test_service.cpp @@ -1,5 +1,8 @@ #include +#include +#include #include +#include #include #include @@ -64,6 +67,22 @@ struct TestHarness { ~TestHarness() { wait(svc.shutdown()); } }; +void checkRootedTree(const OrganizerState& state) { + for (const Folder& folder : state.folders) { + qint64 current = folder.id; + int depth = 0; + while (current != kRootId && depth <= kMaxDepth) { + const auto it = state.folders.constFind(current); + REQUIRE(it != state.folders.cend()); + REQUIRE(it->parentId.has_value()); + current = *it->parentId; + ++depth; + } + CHECK(current == kRootId); + CHECK(depth <= kMaxDepth); + } +} + } // namespace TEST_CASE("bulk folder expansion and collapse persists") { @@ -128,11 +147,115 @@ TEST_CASE("moving a folder into its own subtree or moving root is rejected") { expectError(wait(h.svc.moveFolder(a.id, b.id)), QStringLiteral("into itself")); expectError(wait(h.svc.moveFolder(kRootId, a.id))); + expectError(wait(h.db.setParent(a.id, a.id)), QStringLiteral("into itself")); + expectError(wait(h.db.setParent(a.id, b.id)), QStringLiteral("into itself")); CHECK(h.changes.count == 2); CHECK(h.svc.state().folders.value(a.id).parentId == kRootId); } +TEST_CASE("concurrent opposite folder moves cannot create a cycle") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait( + h.svc.createFolderFrom(QStringLiteral("B"), {QStringLiteral("a.two")}))); + + auto movingA = h.svc.moveFolder(a.id, b.id); + auto movingB = h.svc.moveFolder(b.id, a.id); + const auto movedA = wait(std::move(movingA)); + const auto movedB = wait(std::move(movingB)); + + REQUIRE(movedA.has_value() != movedB.has_value()); + expectError(movedA ? movedB : movedA, QStringLiteral("into itself")); + checkRootedTree(expect(wait(h.db.load()))); + wait(h.svc.init()); + CHECK(h.svc.tree().total == 2); +} + +TEST_CASE("concurrent folder moves enforce committed depth and height") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y")))); + + bool moveAncestorFirst = false; + SECTION("subtree height changes first") {} + SECTION("destination depth changes first") { moveAncestorFirst = true; } + auto first = moveAncestorFirst ? h.svc.moveFolder(a.id, y.id) + : h.svc.moveFolder(x.id, b.id); + auto second = moveAncestorFirst ? h.svc.moveFolder(x.id, b.id) + : h.svc.moveFolder(a.id, y.id); + const auto movedFirst = wait(std::move(first)); + const auto movedSecond = wait(std::move(second)); + + REQUIRE(movedFirst.has_value() != movedSecond.has_value()); + expectError(movedFirst ? movedSecond : movedFirst, + QStringLiteral("at most %1 levels").arg(kMaxDepth)); + checkRootedTree(expect(wait(h.db.load()))); +} + +TEST_CASE("folder creation rechecks depth after a concurrent parent move") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder child = + expect(wait(h.svc.createFolder(QStringLiteral("Child"), a.id))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B")))); + QStringList packageIds; + SECTION("empty folder") {} + SECTION("folder with mods") { packageIds.append(QStringLiteral("a.one")); } + + auto moving = h.svc.moveFolder(a.id, b.id); + auto creating = packageIds.isEmpty() + ? h.svc.createFolder(QStringLiteral("Too deep"), child.id) + : h.svc.createFolderFrom(QStringLiteral("Too deep"), + packageIds, child.id); + expect(wait(std::move(moving))); + expectError(wait(std::move(creating)), + QStringLiteral("at most %1 levels").arg(kMaxDepth)); + const OrganizerState persisted = expect(wait(h.db.load())); + checkRootedTree(persisted); + CHECK(persisted.folders.size() == 4); + CHECK(persisted.placements.isEmpty()); +} + +TEST_CASE("persisted folder cycles do not hang organizer validation") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + wait(h.db.close()); + const QString connection = QUuid::createUuid().toString(); + { + QSqlDatabase raw = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connection); + raw.setDatabaseName(h.dir.filePath(QStringLiteral("organizer.db"))); + REQUIRE(raw.open()); + QSqlQuery query(raw); + REQUIRE(query.prepare( + QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"))); + query.addBindValue(b.id); + query.addBindValue(a.id); + REQUIRE(query.exec()); + } + QSqlDatabase::removeDatabase(connection); + wait(h.svc.init()); + + const QString depthError = QStringLiteral("at most %1 levels").arg(kMaxDepth); + expectError(wait(h.svc.createFolder(QStringLiteral("Child"), a.id)), + depthError); + CHECK(expect(h.svc.folderMoveTargets(a.id)).isEmpty()); + expectError(wait(h.db.createFolder(a.id, QStringLiteral("Child"))), + depthError); + expectError(wait(h.db.setParent(other.id, a.id)), depthError); +} + TEST_CASE( "folder creation and rename reject duplicate names case-insensitively") { TestHarness h; From 491968f1c14001314c20f884696c71e3472acf70 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 19/50] fix(checker): resolve alternative package ids per dependency --- src/core/checker/graph.cpp | 22 +++++--- src/core/checker/graph.hpp | 4 +- tests/core/checker/test_graph.cpp | 90 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index 12eae3e..69b966e 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -119,7 +119,7 @@ void ConstraintGraph::build(const QHash& activeMods, for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { addRules(it.key(), it.value()->aboutRules, EdgeOrigin::kAboutXml, - alternatives); + alternatives, activeMods, settings.use_alternative_package_ids); } if (settings.use_community_rules && communityRules != nullptr && @@ -297,12 +297,22 @@ void ConstraintGraph::addEdges(const PackageId& source, const Targets& targets, } } -void ConstraintGraph::addRules(const PackageId& pid, const BaseRules& rules, - EdgeOrigin origin, - const Alternatives& alternatives) { +void ConstraintGraph::addRules( + const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, + const Alternatives& alternatives, + const QHash& activeMods, + bool useAlternativePackageIds) { for (const DependencyMod& dep : rules.dependencies) { - addEdges(pid, std::array{dep.packageId}, EdgeType::kDependency, origin, - alternatives); + const PackageId* target = &dep.packageId; + if (useAlternativePackageIds && !activeMods.contains(dep.packageId)) { + for (const PackageId& alternativePid : dep.alternativePackageIds) { + if (activeMods.contains(alternativePid)) { + target = &alternativePid; + break; + } + } + } + addEdges(pid, std::array{*target}, EdgeType::kDependency, origin, {}); if (!dep.alternativePackageIds.empty()) { addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, {}); diff --git a/src/core/checker/graph.hpp b/src/core/checker/graph.hpp index d1bab9a..c80ef36 100644 --- a/src/core/checker/graph.hpp +++ b/src/core/checker/graph.hpp @@ -91,7 +91,9 @@ class ConstraintGraph { void addEdges(const PackageId& source, const Targets& targets, EdgeType type, EdgeOrigin origin, const Alternatives& alternatives); void addRules(const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, - const Alternatives& alternatives); + const Alternatives& alternatives, + const QHash& activeMods, + bool useAlternativePackageIds); void addLoadRules(const PackageId& pid, const CaseInsensitiveSet& loadBefore, const CaseInsensitiveSet& loadAfter, const CaseInsensitiveSet& incompatibleWith, diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp index 33c0b7e..195c1d9 100644 --- a/tests/core/checker/test_graph.cpp +++ b/tests/core/checker/test_graph.cpp @@ -119,6 +119,96 @@ TEST_CASE( QSet{PackageId(QStringLiteral("mod.dependency"))}); } +TEST_CASE("ConstraintGraph resolves alternatives per dependency declaration") { + ConstraintGraph g; + const PackageId aPid(QStringLiteral("a.consumer")); + const PackageId bPid(QStringLiteral("b.consumer")); + const PackageId primaryPid(QStringLiteral("x")); + const PackageId yPid(QStringLiteral("y.alt")); + const PackageId zPid(QStringLiteral("z.alt")); + auto a = makeMod(aPid.str()); + auto b = makeMod(bPid.str()); + DependencyMod aDependency; + aDependency.packageId = primaryPid; + aDependency.alternativePackageIds.add(yPid.str()); + a->aboutRules.dependencies.append(aDependency); + DependencyMod bDependency; + bDependency.packageId = primaryPid; + bDependency.alternativePackageIds.add(zPid.str()); + b->aboutRules.dependencies.append(bDependency); + QHash> mods{ + {aPid, a}, + {bPid, b}, + {yPid, makeMod(yPid.str())}, + {zPid, makeMod(zPid.str())}, + }; + PackageId aTarget = yPid; + PackageId bTarget = zPid; + auto graphSettings = settings(true); + + SECTION("each consumer uses its own accepted alternative") {} + + SECTION("an active primary takes precedence") { + mods.insert(primaryPid, makeMod(primaryPid.str())); + aTarget = primaryPid; + bTarget = primaryPid; + } + + SECTION("another consumer's alternative cannot satisfy a declaration") { + mods.remove(zPid); + bTarget = primaryPid; + } + + SECTION("disabled alternative resolution keeps both primary edges") { + graphSettings.use_alternative_package_ids = false; + aTarget = primaryPid; + bTarget = primaryPid; + } + + g.build(mods, mods.keys(), graphSettings); + const auto aEdges = g.edgesOfType(aPid, EdgeType::kDependency); + REQUIRE(aEdges.size() == 1); + CHECK(aEdges.cbegin()->target == aTarget); + CHECK(aEdges.cbegin()->origin == EdgeOrigin::kAboutXml); + const auto bEdges = g.edgesOfType(bPid, EdgeType::kDependency); + REQUIRE(bEdges.size() == 1); + CHECK(bEdges.cbegin()->target == bTarget); + CHECK(bEdges.cbegin()->origin == EdgeOrigin::kAboutXml); +} + +TEST_CASE("ConstraintGraph retains alternative resolution for load rules") { + ConstraintGraph g; + const PackageId consumerPid(QStringLiteral("mod.consumer")); + const PackageId primaryPid(QStringLiteral("mod.primary")); + const PackageId alternativePid(QStringLiteral("mod.alternative")); + auto consumer = makeMod(consumerPid.str()); + DependencyMod dependency; + dependency.packageId = primaryPid; + dependency.alternativePackageIds.add(alternativePid.str()); + consumer->aboutRules.dependencies.append(dependency); + consumer->aboutRules.loadAfter.add(primaryPid.str()); + sort::CommunityRule rule; + rule.packageId = consumerPid; + rule.loadBefore.add(primaryPid.str()); + const sort::CommunityRules communityRules{{consumerPid, rule}}; + const QHash> mods{ + {consumerPid, consumer}, + {alternativePid, makeMod(alternativePid.str())}, + }; + auto graphSettings = settings(true); + graphSettings.use_community_rules = true; + + g.build(mods, mods.keys(), graphSettings, &communityRules); + const auto aboutEdges = g.edgesOfType(consumerPid, EdgeType::kLoadAfter); + REQUIRE(aboutEdges.size() == 1); + CHECK(aboutEdges.cbegin()->target == alternativePid); + CHECK(aboutEdges.cbegin()->origin == EdgeOrigin::kAboutXml); + const auto communityEdges = g.edgesOfType(consumerPid, EdgeType::kLoadBefore); + REQUIRE(communityEdges.size() == 1); + CHECK(communityEdges.cbegin()->target == alternativePid); + CHECK(communityEdges.cbegin()->origin == EdgeOrigin::kCommunityRules); +} + TEST_CASE("ConstraintGraph incompatibility edge") { ConstraintGraph g; auto a = makeMod(QStringLiteral("mod.a")); From e39dac9380993e1de25f35894da1de8e3b13c461 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 20/50] fix(ui): initialize plugins when the game path is configured after first run --- src/ui/context.cpp | 9 +++++- src/ui/context.hpp | 2 ++ src/ui/window/main_window.cpp | 12 ++++++- src/ui/window/main_window.hpp | 1 + tests/ui/test_main_window.cpp | 59 +++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/ui/context.cpp b/src/ui/context.cpp index 178edbe..2ecac5b 100644 --- a/src/ui/context.cpp +++ b/src/ui/context.cpp @@ -48,10 +48,17 @@ Result AppContext::setupAll() { } QCoro::Task> AppContext::initAll() { + if (m_pluginsInitialized) { + co_return {}; + } if (auto core = co_await m_core.plugins().initAll(m_core); !core) { co_return std::unexpected(core.error()); } - co_return co_await m_plugins.initAll(*this); + if (auto ui = co_await m_plugins.initAll(*this); !ui) { + co_return std::unexpected(ui.error()); + } + m_pluginsInitialized = true; + co_return {}; } QCoro::Task> AppContext::refreshMods(bool full) { diff --git a/src/ui/context.hpp b/src/ui/context.hpp index 32750cf..13cccdb 100644 --- a/src/ui/context.hpp +++ b/src/ui/context.hpp @@ -141,6 +141,7 @@ class AppContext { // names. Result setupAll(); QCoro::Task> initAll(); + bool pluginsInitialized() const noexcept { return m_pluginsInitialized; } // Reloads mods and resolves to how many are now loaded. QCoro::Task> refreshMods(bool full = false); // UI plugins shut down before core plugins. @@ -154,6 +155,7 @@ class AppContext { Notifier* m_toasts = nullptr; std::function m_navigate; UiPrefs m_uiPrefs; + bool m_pluginsInitialized = false; }; template diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index c32e42b..daec289 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +91,7 @@ MainWindow::MainWindow(AppContext& app, QWidget* parent) : QMainWindow(parent), m_app(app), m_ctx(app.coreContext()), + m_startedWithoutGamePath(m_ctx.config().paths.game.empty()), m_engine(createQmlEngine(this)), m_savedActive(m_ctx.loadedActiveUuids()), m_updates(appVersion(), m_ctx.network()) { @@ -554,9 +556,9 @@ void MainWindow::onDownloadsFinished(const DownloadResult& result) { QCoro::Task MainWindow::openSettings() { if (m_settingsOpen) co_return; m_settingsOpen = true; + const auto finish = qScopeGuard([this] { m_settingsOpen = false; }); const auto [result, dialog] = co_await awaitDialog(m_app, m_engine, this); - m_settingsOpen = false; if (result != QDialog::Accepted) co_return; auto config = dialog->getConfig(); const auto saved = @@ -572,6 +574,14 @@ QCoro::Task MainWindow::openSettings() { m_toasts->error(reloaded.error().message); co_return; } + if (m_startedWithoutGamePath && !m_app.pluginsInitialized() && + !m_ctx.config().paths.game.empty()) { + if (auto initialized = co_await m_app.initAll(); !initialized) { + m_toasts->error(initialized.error().message); + co_return; + } + startStartupUpdateCheck(); + } m_toasts->success(QStringLiteral("Settings saved")); } diff --git a/src/ui/window/main_window.hpp b/src/ui/window/main_window.hpp index 0cd5df5..952dd30 100644 --- a/src/ui/window/main_window.hpp +++ b/src/ui/window/main_window.hpp @@ -95,6 +95,7 @@ class MainWindow : public QMainWindow { AppContext& m_app; CoreContext& m_ctx; + const bool m_startedWithoutGamePath; QQmlEngine* m_engine; HeaderController* m_headerController = nullptr; HeaderPanel* m_header = nullptr; diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index b2cd768..cfdfa93 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -55,6 +55,22 @@ class ThirdView : public px::modrim::RailView { QLatin1StringView("Third")}; }; +template +class InitCounter final : public px::modrim::BasicPlugin { + public: + explicit InitCounter(int& calls) + : px::modrim::BasicPlugin(QStringLiteral("init-counter")), + m_calls(calls) {} + + QCoro::Task init(Ctx&) override { + ++m_calls; + co_return; + } + + private: + int& m_calls; +}; + struct Fixture { QTemporaryDir directory; px::modrim::ConfigService configService{directory.path()}; @@ -142,6 +158,49 @@ TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { CHECK(found); } +TEST_CASE("Plugin initialization runs once across configuration saves", + "[ui][window]") { + int coreCalls = 0; + int uiCalls = 0; + Fixture fixture; + fixture.core->registerPlugin( + std::make_unique>(coreCalls)); + fixture.app->registerPlugin( + std::make_unique>(uiCalls)); + REQUIRE(fixture.app->setupAll()); + REQUIRE(fixture.core->config().paths.game.empty()); + CHECK_FALSE(fixture.app->pluginsInitialized()); + + auto config = fixture.core->config(); + config.paths.game = fixture.directory.path().toStdString(); + REQUIRE(fixture.configService.save(QStringLiteral("config.json"), config)); + fixture.core->updateConfig(config); + REQUIRE(QCoro::waitFor(fixture.app->initAll())); + CHECK(fixture.app->pluginsInitialized()); + CHECK(coreCalls == 1); + CHECK(uiCalls == 1); + + config.launch_args = "-logfile second-save.log"; + REQUIRE(fixture.configService.save(QStringLiteral("config.json"), config)); + fixture.core->updateConfig(config); + REQUIRE(QCoro::waitFor(fixture.app->initAll())); + CHECK(fixture.app->pluginsInitialized()); + CHECK(coreCalls == 1); + CHECK(uiCalls == 1); +} + +TEST_CASE("Failed plugin initialization is not marked complete", + "[ui][window]") { + Fixture fixture; + fixture.app->registerPlugin(std::make_unique( + QStringLiteral("cycle_a"), QStringList{QStringLiteral("cycle_b")})); + fixture.app->registerPlugin(std::make_unique( + QStringLiteral("cycle_b"), QStringList{QStringLiteral("cycle_a")})); + + CHECK_FALSE(QCoro::waitFor(fixture.app->initAll())); + CHECK_FALSE(fixture.app->pluginsInitialized()); +} + TEST_CASE("Dirty state tracks active list order and disk reload baseline", "[ui][window]") { Fixture fixture; From aee02f3dfb693e751aad75a3a2861bbcf1593dfb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 21/50] fix(ui): restrict the Steam browser bridge to Steam origins and deactivate every duplicate copy --- src/ui/SteamWorkshop.qml | 10 +- src/ui/plugins/steam_workshop/bridge_obj.cpp | 10 + src/ui/plugins/steam_workshop/bridge_obj.hpp | 3 + src/ui/plugins/steam_workshop/plugin.cpp | 40 ++-- src/ui/plugins/steam_workshop/view.cpp | 15 +- src/ui/plugins/steam_workshop/view.hpp | 3 + tests/ui/test_steam_workshop.cpp | 185 +++++++++++++++++++ 7 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 tests/ui/test_steam_workshop.cpp diff --git a/src/ui/SteamWorkshop.qml b/src/ui/SteamWorkshop.qml index f9e353b..93801db 100644 --- a/src/ui/SteamWorkshop.qml +++ b/src/ui/SteamWorkshop.qml @@ -135,9 +135,17 @@ Rectangle { WebEngineView { id: webView objectName: "workshopWeb" - webChannel: channel + webChannel: steamWorkshopPanel.isSteamOrigin(url) ? channel : null anchors.fill: parent + onNavigationRequested: function(request) { + if (!request.isMainFrame || steamWorkshopPanel.isSteamOrigin(request.url)) + return + request.action = WebEngineView.IgnoreRequest + if (request.url.toString() !== "about:blank") + Qt.openUrlExternally(request.url) + } + profile: WebEngineProfile { id: steamProfile storageName: "pxmodrim-steam" diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp index b8c3b13..c516205 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.cpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -7,9 +7,11 @@ #include #include #include +#include #include #include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/steam_workshop/view.hpp" namespace px::modrim { namespace { @@ -25,6 +27,8 @@ bool isPublishedFileId(const QJsonValue& value) { PxModRimBridge::PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent) : QObject(parent), m_plugin(plugin) {} +void PxModRimBridge::setPage(QObject* page) { m_page = page; } + void PxModRimBridge::call(const QString& requestId, const QString& method, const QString& payload) { handle(requestId, method, payload); @@ -43,6 +47,12 @@ void PxModRimBridge::respond(const QString& requestId, bool ok, QCoro::Task PxModRimBridge::handle(QString requestId, QString method, QString payload) { + if (!m_page || + !SteamWorkshopViewPanel::isSteamOrigin(m_page->property("url").toUrl())) { + respond(requestId, false, {}, + QStringLiteral("The bridge is only available on Steam pages")); + co_return; + } QJsonParseError parseError; const auto document = QJsonDocument::fromJson( payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parseError); diff --git a/src/ui/plugins/steam_workshop/bridge_obj.hpp b/src/ui/plugins/steam_workshop/bridge_obj.hpp index 4d70f01..5949e8a 100644 --- a/src/ui/plugins/steam_workshop/bridge_obj.hpp +++ b/src/ui/plugins/steam_workshop/bridge_obj.hpp @@ -4,6 +4,7 @@ #include #include +#include #include @@ -15,6 +16,7 @@ class PxModRimBridge final : public QObject { Q_OBJECT public: PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); + void setPage(QObject* page); Q_INVOKABLE void call(const QString& requestId, const QString& method, const QString& payload); signals: @@ -26,6 +28,7 @@ class PxModRimBridge final : public QObject { const QJsonValue& result = QJsonValue(QJsonValue::Null), const QString& error = {}); SteamWorkshopUiPlugin& m_plugin; + QPointer m_page; }; } // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp index ca147e3..5cc30ba 100644 --- a/src/ui/plugins/steam_workshop/plugin.cpp +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -104,38 +104,30 @@ void SteamWorkshopUiPlugin::batchToggleDownloadChecked( } void SteamWorkshopUiPlugin::toggleActive(const QString& id, bool active) { - if (!m_core) return; - for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); - ++it) { - if (it.value()->publishedFileId() != id) continue; - auto current = m_core->activeUuids(); - if (active) { - if (!current.contains(it.key())) current.append(it.key()); - } else { - current.removeAll(it.key()); - } - m_core->setActive(std::move(current)); - return; - } + batchToggleActive({id}, active); } void SteamWorkshopUiPlugin::batchToggleActive(const QStringList& ids, bool active) { if (!m_core) return; - QHash byPublishedId; + const QSet requested(ids.cbegin(), ids.cend()); + auto current = m_core->activeUuids(); + QHash chosenCopies; for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); ++it) { - if (auto id = it.value()->publishedFileId()) - byPublishedId.insert(*id, it.key()); + const auto id = it.value()->publishedFileId(); + if (!id || !requested.contains(*id)) continue; + if (!active) { + current.removeAll(it.key()); + } else if (!chosenCopies.contains(*id) || current.contains(it.key())) { + chosenCopies.insert(*id, it.key()); + } } - auto current = m_core->activeUuids(); - for (const auto& id : ids) { - const auto it = byPublishedId.constFind(id); - if (it == byPublishedId.cend()) continue; - if (active) { - if (!current.contains(*it)) current.append(*it); - } else { - current.removeAll(*it); + if (active) { + for (const auto& id : ids) { + const auto copy = chosenCopies.constFind(id); + if (copy != chosenCopies.cend() && !current.contains(*copy)) + current.append(*copy); } } m_core->setActive(std::move(current)); diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp index 55b84a3..9f76dfc 100644 --- a/src/ui/plugins/steam_workshop/view.cpp +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -2,6 +2,7 @@ #ifdef PX_HAS_STEAM +#include #include #include #include @@ -139,6 +140,7 @@ void SteamWorkshopViewPanel::ensureInitialized() { context->setContextProperty(QStringLiteral("_injectCode"), injection()); m_qml->setSource( QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); + if (bridge) bridge->setPage(web()); #endif } @@ -164,12 +166,21 @@ void SteamWorkshopViewPanel::navigateHome() { if (auto* browser = web()) browser->setProperty("url", kWorkshopUrl); } +bool SteamWorkshopViewPanel::isSteamOrigin(const QUrl& url) { + if (!url.isValid() || url.scheme() != QLatin1String("https")) return false; + const auto host = url.host(); + return host == QLatin1String("steamcommunity.com") || + host.endsWith(QLatin1String(".steamcommunity.com")); +} + void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { if (text.isEmpty()) return; auto url = QUrl::fromUserInput(text); - if (url.scheme() == QLatin1String("http")) - url.setScheme(QStringLiteral("https")); if (!url.isValid()) return; + if (!isSteamOrigin(url)) { + QDesktopServices::openUrl(url); + return; + } if (auto* browser = web()) browser->setProperty("url", url); } diff --git a/src/ui/plugins/steam_workshop/view.hpp b/src/ui/plugins/steam_workshop/view.hpp index 0f2f49b..96ba1eb 100644 --- a/src/ui/plugins/steam_workshop/view.hpp +++ b/src/ui/plugins/steam_workshop/view.hpp @@ -2,6 +2,8 @@ #ifdef PX_HAS_STEAM +#include + #include "ui/plugins/steam_workshop/plugin.hpp" #include "ui/views/base.hpp" @@ -25,6 +27,7 @@ class SteamWorkshopViewPanel final : public BaseViewPanel { void teardown(); Q_INVOKABLE void navigateHome(); Q_INVOKABLE void navigateToUrl(const QString& text); + Q_INVOKABLE static bool isSteamOrigin(const QUrl& url); protected: void showEvent(QShowEvent* event) override; diff --git a/tests/ui/test_steam_workshop.cpp b/tests/ui/test_steam_workshop.cpp new file mode 100644 index 0000000..29609a4 --- /dev/null +++ b/tests/ui/test_steam_workshop.cpp @@ -0,0 +1,185 @@ +#if defined(PX_HAS_STEAM) && defined(PX_HAS_WEBENGINE) + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/downloads/steam.hpp" +#include "test_support.hpp" +#include "ui/context.hpp" +#include "ui/plugins/steam_workshop/bridge_obj.hpp" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/steam_workshop/view.hpp" + +namespace { +using namespace px::modrim; + +struct PluginFixture { + QTemporaryDir dir; + std::unique_ptr core = px::ui_test::makeCore(); + AppContext app{*core}; + SteamWorkshopUiPlugin plugin; + + PluginFixture() { + REQUIRE(dir.isValid()); + core->registerPlugin(std::make_unique(SteamDownloaderDeps{ + .manager = core->downloads(), + .config = core->configService(), + .localModsPath = [] { return QString{}; }, + .installedMods = [this] { return core->allMods().values(); }})); + plugin.setup(app); + } + + QString install(const QString& copy, const QString& publishedId) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(dir.filePath(copy + QLatin1Char('/') + publishedId)) + .has_value()); + REQUIRE(mod->publishedFileId() == publishedId); + auto mods = core->allMods(); + const auto uuid = mod->uuid(); + mods.insert(uuid, std::move(mod)); + core->load(std::move(mods), core->activeUuids()); + return uuid; + } +}; +} // namespace + +TEST_CASE("Steam browser accepts only HTTPS Steam Community origins") { + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://steamcommunity.com/workshop/")))); + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://www.steamcommunity.com/")))); + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://HELP.STEAMCOMMUNITY.COM/")))); + for (const auto* text : + {"https://evil.com/", "https://steamcommunity.com.evil.com/", + "https://notsteamcommunity.com/", "http://steamcommunity.com/", + "https://steamcommunity.com@evil.com/", + "https://evil.com/?host=steamcommunity.com", + "https://steampowered.com/", "about:blank"}) { + CAPTURE(text); + CHECK_FALSE( + SteamWorkshopViewPanel::isSteamOrigin(QUrl(QString::fromLatin1(text)))); + } + CHECK_FALSE(SteamWorkshopViewPanel::isSteamOrigin(QUrl{})); +} + +TEST_CASE("Steam bridge rejects calls without a trusted live page") { + SteamWorkshopUiPlugin plugin; + PxModRimBridge bridge(plugin, nullptr); + QSignalSpy replies(&bridge, &PxModRimBridge::resultReady); + REQUIRE(replies.isValid()); + int sidebarChanges = 0; + plugin.sidebarSyncRequested.connect( + [&sidebarChanges](const SidebarSync&) { ++sidebarChanges; }); + + const auto call = [&bridge] { + bridge.call(QStringLiteral("request"), + QStringLiteral("toggle_download_checked"), + QStringLiteral(R"({"mod_id":"1","checked":true})")); + }; + const auto checkDenied = [&] { + const auto previousReplies = replies.size(); + const auto previousChanges = sidebarChanges; + call(); + REQUIRE(replies.size() == previousReplies + 1); + const auto response = + QJsonDocument::fromJson(replies.back()[1].toString().toUtf8()).object(); + CHECK_FALSE(response.value(QStringLiteral("ok")).toBool()); + CHECK_FALSE(response.value(QStringLiteral("error")).toString().isEmpty()); + CHECK(sidebarChanges == previousChanges); + }; + + checkDenied(); + auto page = std::make_unique(); + bridge.setPage(page.get()); + page->setProperty("url", QUrl(QStringLiteral("https://steamcommunity.com/"))); + call(); + REQUIRE(replies.size() == 2); + CHECK(QJsonDocument::fromJson(replies.back()[1].toString().toUtf8()) + .object() + .value(QStringLiteral("ok")) + .toBool()); + CHECK(sidebarChanges == 1); + + for (const auto* url : + {"https://evil.com/", "https://steamcommunity.com.evil.com/", + "http://steamcommunity.com/"}) { + page->setProperty("url", QUrl(QString::fromLatin1(url))); + checkDenied(); + } + page.reset(); + checkDenied(); +} + +TEST_CASE("Steam browser deactivation removes every installed copy at once") { + PluginFixture f; + const auto first = f.install(QStringLiteral("copy-a"), QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("copy-b"), QStringLiteral("1")); + const auto second = f.install(QStringLiteral("copy-a"), QStringLiteral("2")); + const auto other = f.install(QStringLiteral("copy-a"), QStringLiteral("3")); + f.core->setActive({first, duplicate, first, second, other}); + int changes = 0; + f.core->activeStateChanged().connect( + [&changes](const QStringList&) { ++changes; }); + + SECTION("single published id") { + f.plugin.toggleActive(QStringLiteral("1"), false); + CHECK(f.core->activeUuids() == QStringList{second, other}); + } + SECTION("batch published ids") { + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2")}, + false); + CHECK(f.core->activeUuids() == QStringList{other}); + } + CHECK(changes == 1); +} + +TEST_CASE("Steam browser activation keeps one chosen copy per published id") { + PluginFixture f; + const auto first = f.install(QStringLiteral("copy-a"), QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("copy-b"), QStringLiteral("1")); + const auto second = f.install(QStringLiteral("copy-a"), QStringLiteral("2")); + + SECTION("single activation chooses only one installed copy") { + f.plugin.toggleActive(QStringLiteral("1"), true); + REQUIRE(f.core->activeUuids().size() == 1); + CHECK((f.core->activeUuids().contains(first) || + f.core->activeUuids().contains(duplicate))); + } + SECTION("single activation retains an already active copy") { + f.core->setActive({duplicate}); + f.plugin.toggleActive(QStringLiteral("1"), true); + CHECK(f.core->activeUuids() == QStringList{duplicate}); + } + SECTION("batch activation retains an active copy and selects missing ids") { + f.core->setActive({duplicate}); + int changes = 0; + f.core->activeStateChanged().connect( + [&changes](const QStringList&) { ++changes; }); + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2"), + QStringLiteral("1"), QStringLiteral("missing")}, + true); + CHECK(f.core->activeUuids() == QStringList{duplicate, second}); + CHECK(changes == 1); + } + SECTION("batch activation chooses one copy for each inactive published id") { + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2")}, + true); + REQUIRE(f.core->activeUuids().size() == 2); + CHECK(f.core->activeUuids().contains(second)); + CHECK((f.core->activeUuids().contains(first) != + f.core->activeUuids().contains(duplicate))); + } +} + +#endif From 3213e37daa8d00d7f953c7c4bd5c6d1ebdcf368d Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 22/50] fix(ui): keep Organizer focus and range anchor across folder expansion --- src/ui/ModTree.qml | 34 +++++----- src/ui/plugins/organizer/tree_model.cpp | 12 +++- src/ui/plugins/organizer/tree_model.hpp | 2 +- tests/ui/test_mod_tree_model.cpp | 84 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 20 deletions(-) diff --git a/src/ui/ModTree.qml b/src/ui/ModTree.qml index 0989f9e..b4c5c9b 100644 --- a/src/ui/ModTree.qml +++ b/src/ui/ModTree.qml @@ -8,8 +8,8 @@ Rectangle { color: Theme.elevate1 clip: true - property int anchorRow: -1 - property int focusedRow: -1 + property string anchorKey: "" + property string focusedKey: "" property int dropRow: -1 property var dropTarget: null @@ -22,8 +22,8 @@ Rectangle { function collapseAll() { tree.collapseRecursively() - root.anchorRow = -1 - root.focusedRow = -1 + root.anchorKey = "" + root.focusedKey = "" organizerPanel.persistAllCollapsed(true) } @@ -38,15 +38,16 @@ Rectangle { function clickRow(index, row, modifiers) { var range = [] - if ((modifiers & Qt.ShiftModifier) && root.anchorRow >= 0) { - var from = Math.min(root.anchorRow, row) - var to = Math.max(root.anchorRow, row) + var anchorRow = tree.rowAtIndex(modTreeModel.indexForKey(root.anchorKey)) + if ((modifiers & Qt.ShiftModifier) && anchorRow >= 0) { + var from = Math.min(anchorRow, row) + var to = Math.max(anchorRow, row) for (var r = from; r <= to; ++r) range.push(tree.index(r, 0)) } else { - root.anchorRow = row + root.anchorKey = modTreeModel.data(index, Qt.UserRole + 2) } - root.focusedRow = row + root.focusedKey = modTreeModel.data(index, Qt.UserRole + 2) organizerPanel.selectRow(index, modifiers, range) tree.forceActiveFocus() } @@ -70,9 +71,10 @@ Rectangle { } function activeRow() { - if (root.focusedRow >= 0 && root.focusedRow < tree.rows) - return root.focusedRow - return -1 + var row = tree.rowAtIndex(modTreeModel.indexForKey(root.focusedKey)) + if (row < 0) + root.focusedKey = "" + return row } property int modelRevision: 0 @@ -93,8 +95,8 @@ Rectangle { all.push(tree.index(r, 0)) organizerPanel.selectRow(all[0], Qt.NoModifier, []) organizerPanel.selectRow(all[all.length - 1], Qt.ShiftModifier, all) - root.anchorRow = 0 - root.focusedRow = tree.rows - 1 + root.anchorKey = modTreeModel.data(all[0], Qt.UserRole + 2) + root.focusedKey = modTreeModel.data(all[all.length - 1], Qt.UserRole + 2) } Connections { @@ -104,9 +106,9 @@ Rectangle { function onRowsRemoved() { root.modelRevision++ } function onModelReset() { root.modelRevision++ - root.anchorRow = -1 + root.anchorKey = "" Qt.callLater(root.restoreExpansion) - root.focusedRow = -1 + root.focusedKey = "" } } diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index b313330..83845bd 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -323,9 +323,15 @@ TreeNode* ModTreeModel::nodeAt(const QModelIndex& index) const { return index.isValid() ? node(index) : nullptr; } QModelIndex ModTreeModel::indexForKey(const QString& key) const { - for (const auto* item : m_root->walk()) - if (item->key == key) return indexOf(item); - return {}; + auto find = [&](auto&& self, const TreeNode* parent) -> QModelIndex { + for (const auto& child : parent->children) { + if (child->key == key) return indexOf(child.get()); + const auto found = self(self, child.get()); + if (found.isValid()) return found; + } + return {}; + }; + return find(find, m_root.get()); } QModelIndex ModTreeModel::indexForUuid(const QString& uuid) const { for (const auto* item : m_root->walk()) diff --git a/src/ui/plugins/organizer/tree_model.hpp b/src/ui/plugins/organizer/tree_model.hpp index ecf94a6..2a9cf8e 100644 --- a/src/ui/plugins/organizer/tree_model.hpp +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -81,7 +81,7 @@ class ModTreeModel : public QAbstractItemModel { const QHash& tags = {}); void refreshDiagnostics(); TreeNode* nodeAt(const QModelIndex& index) const; - QModelIndex indexForKey(const QString& key) const; + Q_INVOKABLE QModelIndex indexForKey(const QString& key) const; QModelIndex indexForUuid(const QString& uuid) const; QList expandedIndexes(const QModelIndex& parent = {}) const; QList selectedNodes() const; diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp index 981c822..300cd7e 100644 --- a/tests/ui/test_mod_tree_model.cpp +++ b/tests/ui/test_mod_tree_model.cpp @@ -1,5 +1,9 @@ +#include #include +#include +#include #include +#include #include @@ -205,6 +209,86 @@ TEST_CASE( CHECK(model.expandedIndexes(model.indexForKey("f:2")).size() == 1); } +TEST_CASE("organizer item keys resolve visible rows after collapse updates") { + OrganizerFixture f; + f.state.folders.insert(2, {2, kRootId, QStringLiteral("Earlier")}); + f.state.folders.insert(3, {3, kRootId, QStringLiteral("Later")}); + f.state.folders[2].collapsed = true; + f.state.placements.insert("a.one", 2); + f.state.placements.insert("a.two", 3); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + const auto anchorKey = + model.data(model.indexForUuid("u2"), ModTreeModel::kKeyRole).toString(); + const auto folderKey = + model.data(model.index(1, 0), ModTreeModel::kKeyRole).toString(); + + QQmlEngine engine; + QQmlComponent component(&engine); + component.setData(R"( + import QtQuick + TreeView { + required property var treeModel + model: treeModel + function rowForKey(key) { + return rowAtIndex(treeModel.indexForKey(key)) + } + function keyAtRow(row) { + return treeModel.data(index(row, 0), Qt.UserRole + 2) + } + } + )", + QUrl{}); + INFO(component.errorString().toStdString()); + REQUIRE(component.isReady()); + std::unique_ptr tree(component.createWithInitialProperties( + {{QStringLiteral("treeModel"), QVariant::fromValue(&model)}})); + REQUIRE(tree); + tree->setParent(&engine); + engine.globalObject().setProperty(QStringLiteral("tree"), + engine.newQObject(tree.get())); + auto evaluate = [&](const QString& expression) { + auto result = engine.evaluate(expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + return result; + }; + auto rowForKey = [&](const QString& key) { + QVariant row; + REQUIRE(QMetaObject::invokeMethod(tree.get(), "rowForKey", + Q_RETURN_ARG(QVariant, row), + Q_ARG(QVariant, key))); + return row.toInt(); + }; + + evaluate(QStringLiteral("tree.expand(1)")); + REQUIRE(rowForKey(anchorKey) == 2); + REQUIRE(rowForKey(folderKey) == 1); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + evaluate(QStringLiteral("tree.expand(0)")); + f.state.folders[2].collapsed = false; + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(rowForKey(anchorKey) == 3); + CHECK(rowForKey(folderKey) == 2); + CHECK(evaluate(QStringLiteral("tree.keyAtRow(3)")).toString() == anchorKey); + CHECK(evaluate(QStringLiteral("tree.keyAtRow(2)")).toString() == folderKey); + + evaluate(QStringLiteral("tree.collapse(0)")); + f.state.folders[2].collapsed = true; + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(rowForKey(anchorKey) == 2); + CHECK(rowForKey(folderKey) == 1); + evaluate(QStringLiteral("tree.collapse(1)")); + CHECK(rowForKey(anchorKey) == -1); + CHECK(rowForKey(QStringLiteral("missing")) == -1); + f.mods.remove("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK_FALSE(model.indexForKey(anchorKey).isValid()); + CHECK(rowForKey(anchorKey) == -1); +} + TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { OrganizerFixture f; f.nested(); From f8a1889b40e59fd14f93db82de6302973a67bcd9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 23/50] fix(backend): ack after requeue, empty collections, picked version tags, retry index failures --- cf-workers/indexer.ts | 4 +- cf-workers/picks.ts | 4 +- cf-workers/steam.ts | 15 ++- cf-workers/tests/catalog.test.ts | 203 ++++++++++++++++++++++++++++++- cf-workers/worker.ts | 3 +- 5 files changed, 218 insertions(+), 11 deletions(-) diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts index cdabe75..b3f5bae 100644 --- a/cf-workers/indexer.ts +++ b/cf-workers/indexer.ts @@ -43,7 +43,7 @@ async function refreshIsolating(env: Env, ids: string[], budget: { left: number if (budget.left <= 0) return false; budget.left--; try { - await refreshSteamItems(env, ids); + await refreshSteamItems(env, ids, true); return true; } catch (error) { if (!isPerFileFailure(error)) throw error; @@ -117,7 +117,7 @@ export async function indexTick(env: Env): Promise { state.indexed += result.items.length; const reachedSeen = state.since > 0 && result.times_updated.every(time => time * 1000 < state.since); const items = state.since === 0 ? result.items : result.items.filter(item => (item.updated_at ?? 0) * 1000 >= state.since); - await saveItems(env.DB, items); + await saveItems(env.DB, items, true); if (result.next_cursor && !reachedSeen) { state.cursor = result.next_cursor; await saveState(env.DB, state); diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index 84b9351..d62ad1e 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -170,8 +170,10 @@ export async function listPicks( bindings.push(options.tag); } if (options.version !== null) { - predicates.push('EXISTS (SELECT 1 FROM json_each(p.supported_versions) WHERE value = ?)'); + const taggedVersion = /^\d+\.\d+(?:\.\d+)?$/.test(options.version); + predicates.push(`(EXISTS (SELECT 1 FROM json_each(p.supported_versions) WHERE value = ?)${taggedVersion ? ' OR EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?)' : ''})`); bindings.push(options.version); + if (taggedVersion) bindings.push(options.version); } const where = predicates.length ? ` WHERE ${predicates.join(' AND ')}` : ''; const orders = { diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index 2cbd217..6545606 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -204,7 +204,14 @@ function normalized(file: SteamFile, authors: Map): CatalogItem }; } -export async function saveItems(db: D1Database, items: CatalogItem[]): Promise { +export async function saveItems(db: D1Database, items: CatalogItem[], requireIndex = false): Promise { + // Index first so failed crawl writes remain eligible for verification retries. + try { + await indexMods(db, items.filter((item): item is CatalogMod => item.kind === 'mod')); + } catch (error) { + if (requireIndex) throw error; + console.error('Search index update failed', error instanceof Error ? error.message : error); + } const now = Date.now(); let rowsWritten = 0; for (let offset = 0; offset < items.length; offset += API_BATCH_SIZE) { @@ -214,11 +221,9 @@ export async function saveItems(db: D1Database, items: CatalogItem[]): Promise total + result.meta.rows_written, 0); } if (items.length) console.log('[D1_WRITES]', JSON.stringify({ category: 'catalog_cache', rows_written: rowsWritten })); - await indexMods(db, items.filter((item): item is CatalogMod => item.kind === 'mod')).catch(error => - console.error('Search index update failed', error instanceof Error ? error.message : error)); } -export async function refreshSteamItems(env: Env, ids: string[]): Promise> { +export async function refreshSteamItems(env: Env, ids: string[], requireIndex = false): Promise> { const fetched = new Map(); const unavailable: string[] = []; for (let offset = 0; offset < ids.length; offset += API_BATCH_SIZE) { @@ -250,7 +255,7 @@ export async function refreshSteamItems(env: Env, ids: string[]): Promise env.DB.prepare( diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index f439c95..d54a278 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -3,8 +3,10 @@ import { readFile } from 'node:fs/promises'; import { resolve as resolvePath } from 'node:path'; import { after, before, test } from 'node:test'; import { Miniflare } from 'miniflare'; -import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, SteamFile } from '../types'; +import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, DependencyMessage, Env, SteamFile } from '../types'; import { indexMods } from '../search'; +import { indexTick } from '../indexer'; +import workerHandler from '../worker'; const creator = '76561198000000001'; function mod(id: string, title: string, children: string[] = [], versions = ['1.6']): SteamFile { @@ -217,6 +219,40 @@ test('picked search treats wildcard text literally, combines tag/version filters assert.equal((await (await request('/catalog/collections?source=picked&q=%25&version=1.5')).json() as CatalogPage).total, 0); }); +test('picked version filters include numeric tag declarations with correct totals and pagination', async () => { + const declarations = [ + { slug: 'version-filter-tagged', tags: ['version-filter', '1.6'], supported_versions: [] }, + { slug: 'version-filter-explicit', tags: ['version-filter', '1.6'], supported_versions: ['1.6'] }, + { slug: 'version-filter-patch', tags: ['version-filter', '1.6.1'], supported_versions: [] }, + { slug: 'version-filter-lookalike', tags: ['version-filter', 'v1.6', '1.6beta', '1.60'], supported_versions: [] }, + ]; + try { + for (const { slug, ...declaration } of declarations) { + await createPick(slug, ['10', '14'], slug, declaration); + } + const detail = await (await request('/catalog/collections/picked/version-filter-tagged')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, ['1.6']); + const path = '/catalog/collections?source=picked&tag=version-filter&version=1.6&limit=1'; + const first = await (await request(path)).json() as CatalogPage; + assert.equal(first.total, 2); + assert.equal(first.items.length, 1); + assert.ok(first.next_cursor); + const second = await (await request(`${path}&cursor=${encodeURIComponent(first.next_cursor!)}`)).json() as CatalogPage; + assert.equal(second.total, 2); + assert.equal(second.next_cursor, null); + assert.deepEqual([...first.items, ...second.items].map(item => item.id).sort(), [ + 'picked:version-filter-explicit', 'picked:version-filter-tagged', + ]); + const patch = await (await request('/catalog/collections?source=picked&tag=version-filter&version=1.6.1')).json() as CatalogPage; + assert.equal(patch.total, 1); + assert.deepEqual(patch.items.map(item => item.id), ['picked:version-filter-patch']); + } finally { + for (const { slug } of declarations) { + await request(`/catalog/collections/picked/${slug}`, 'DELETE', undefined, 'test-admin'); + } + } +}); + test('picked ranks round-trip through PUT, detail and ordered lists when editing metadata', async () => { const slugs = ['rank-first', 'rank-second']; const body = { @@ -361,6 +397,86 @@ test('the idempotent dependency migration and existing /deps response preserve p assert.equal(value.items['777']?.package_id, 'existing.mod'); }); +test('an authoritative empty Steam collection clears stored and cached dependency membership', async () => { + const rootId = '80001'; + const memberId = '80002'; + files[rootId] = { ...mod(rootId, 'Emptied collection', [memberId]), file_type: 2, result: 15 }; + const now = Date.now(); + await db.batch([ + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind(rootId, 'Emptied collection', 1, 'OK', now, '', memberId), + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind(memberId, 'Removed member', 0, 'OK', now, '', ''), + ]); + try { + const retained = await (await request(`/deps?id=${rootId}&read=unavailable`)).json() as { items: Record }; + assert.deepEqual(retained.items[rootId]?.deps, [memberId]); + files[rootId] = { ...mod(rootId, 'Emptied collection'), file_type: 2 }; + for (let read = 0; read < 2; read++) { + const response = await request(`/deps?id=${rootId}&read=${read}`); + assert.equal(response.status, 200); + const value = await response.json() as { isComplete: boolean; items: Record }; + assert.equal(value.isComplete, true); + assert.deepEqual(value.items[rootId]?.deps, []); + assert.deepEqual(Object.keys(value.items), [rootId]); + } + const stored = await db.prepare('SELECT deps FROM items WHERE id = ?').bind(rootId).first<{ deps: string }>(); + assert.equal(stored?.deps, ''); + } finally { + delete files[rootId]; + await db.prepare('DELETE FROM items WHERE id IN (?, ?)').bind(rootId, memberId).run(); + } +}); + +test('queue continuations are acknowledged only after sending succeeds and retried on send failure', async (t) => { + const roots = ['80101', '80111']; + const detailCalls = new Map(); + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const { publishedfileids } = JSON.parse(url.searchParams.get('input_json') ?? '{}') as { publishedfileids: string[] }; + return Response.json({ response: { publishedfiledetails: publishedfileids.map(id => { + if (!roots.includes(id)) return mod(id, 'Resolved member'); + const calls = (detailCalls.get(id) ?? 0) + 1; + detailCalls.set(id, calls); + const children = [String(Number(id) + 1)]; + if (calls > 1) children.push(String(Number(id) + 2)); + return { ...mod(id, 'Growing collection', children), file_type: 2 }; + }) } }); + }); + try { + for (const [index, rootId] of roots.entries()) { + const initialMember = String(Number(rootId) + 1); + await db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)') + .bind(rootId, 'Growing collection', 1, 'OK', Date.now(), '', initialMember).run(); + const events: string[] = []; + const env: Env = { + DB: db, STEAM_API_KEY: 'fixture-only-key', + SCRAPE_QUEUE: { + async send(body: DependencyMessage, options?: QueueSendOptions) { + assert.deepEqual(body, { rootId }); + assert.equal(options?.delaySeconds, 30); + events.push('send'); + if (index === 1) throw new Error('Injected queue send failure'); + return { metadata: { metrics: { backlogCount: 1, backlogBytes: 0 } } }; + }, + } as Queue, + }; + await workerHandler.queue({ + queue: 'dependencies', + metadata: { metrics: { backlogCount: 1, backlogBytes: 0 } }, + messages: [{ + id: rootId, timestamp: new Date(), attempts: 1, body: { rootId }, + ack() { events.push('ack'); }, + retry() { events.push('retry'); }, + }], + ackAll() { assert.fail('Expected per-message acknowledgement'); }, + retryAll() { assert.fail('Expected per-message retry'); }, + }, env); + assert.deepEqual(events, index === 0 ? ['send', 'ack'] : ['send', 'retry']); + } + } finally { + await db.prepare('DELETE FROM items WHERE id BETWEEN ? AND ?').bind('80101', '80113').run(); + } +}); + test('an unconfigured Steam key does not turn a missing catalog into a successful empty page', async () => { const noKey = new Miniflare({ workers: [{ config: { @@ -928,3 +1044,88 @@ test('indexMods updates FTS when description changes only beyond the first 300 c } }); + +test('index crawl retries failed index writes without advancing or marking ready while requests remain best-effort', async (t) => { + const isolated = new Miniflare({ workers: [{ + config: { + name: 'index-write-failure', compatibilityDate: '2026-10-05', + manifest: { mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm', contents: await readFile(resolvePath('dist/worker.js'), 'utf8') } } }, + env: { + DB: { type: 'd1', id: 'index-write-failure' }, + STEAM_API_KEY: { type: 'text', value: 'fixture-only-key' }, + }, + }, + dev: { outboundService: { type: 'fetcher', handler: upstream } }, + }] }); + const id = '80201'; + files[id] = mod(id, 'IndexWriteFailureFixture'); + let nextCursor = 'retry-page'; + const cursors: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + const request = new Request(input); + const url = new URL(request.url); + if (!url.pathname.includes('QueryFiles')) return await upstream(request); + const { cursor } = JSON.parse(url.searchParams.get('input_json') ?? '{}') as { cursor: string }; + cursors.push(cursor); + return Response.json({ response: { + total: 1, publishedfiledetails: [files[id]], next_cursor: nextCursor, + } }); + }); + try { + const database = await isolated.getD1Database('DB'); + await migrate(database); + const failIndexWrites = async () => { + await database.prepare(`CREATE TRIGGER fail_index_write BEFORE INSERT ON catalog_index + WHEN NEW.id = '${id}' BEGIN SELECT RAISE(ABORT, 'Injected search index failure'); END`).run(); + }; + const state = async () => await database.prepare( + 'SELECT cursor, indexed, full_at, cycle_started_at FROM index_state WHERE id = 1', + ).first<{ cursor: string; indexed: number; full_at: number; cycle_started_at: number }>(); + await failIndexWrites(); + const response = await isolated.dispatchFetch(`https://index-write-failure.test/catalog/mods/${id}`); + assert.equal(response.status, 200); + assert.equal((await response.json() as CatalogMod).id, id); + assert.ok(await database.prepare('SELECT id FROM catalog_items WHERE id = ?').bind(id).first()); + assert.equal(await database.prepare('SELECT id FROM catalog_index WHERE id = ?').bind(id).first(), null); + const env: Env = { DB: database, STEAM_API_KEY: 'fixture-only-key' }; + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), { cursor: '*', indexed: 0, full_at: 0, cycle_started_at: 0 }); + + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + const pending = await state(); + assert.equal(pending?.cursor, 'retry-page'); + assert.equal(pending?.indexed, 1); + assert.equal(pending?.full_at, 0); + assert.ok((pending?.cycle_started_at ?? 0) > 0); + assert.ok(await database.prepare('SELECT id FROM catalog_index WHERE id = ?').bind(id).first()); + + nextCursor = ''; + await failIndexWrites(); + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), pending); + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + const complete = await state(); + assert.equal(complete?.cursor, '*'); + assert.equal(complete?.cycle_started_at, 0); + assert.ok((complete?.full_at ?? 0) > 0); + assert.deepEqual(cursors, ['*', '*', 'retry-page', 'retry-page']); + + await database.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind(id).run(); + await database.prepare("UPDATE index_state SET cursor = 'verify:0:', cycle_started_at = ?, full_at = 0 WHERE id = 1").bind(Date.now()).run(); + const verifying = await state(); + await failIndexWrites(); + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), verifying); + const stale = await database.prepare('SELECT updated_at FROM catalog_items WHERE id = ?').bind(id).first<{ updated_at: number }>(); + assert.equal(stale?.updated_at, 0); + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + assert.equal((await state())?.cycle_started_at, 0); + assert.ok(((await state())?.full_at ?? 0) > 0); + } finally { + delete files[id]; + await isolated.dispose(); + } +}); diff --git a/cf-workers/worker.ts b/cf-workers/worker.ts index 3022544..b53fe49 100644 --- a/cf-workers/worker.ts +++ b/cf-workers/worker.ts @@ -530,7 +530,6 @@ async function ensureCollectionRootDeps(env: Env, rootId: string): Promise c.publishedfileid).filter(Boolean); - if (children.length === 0) return false; const currentDeps = cached.data.deps || []; const depsChanged = children.length !== currentDeps.length || @@ -893,11 +892,11 @@ export default { } else { console.log(`[QUEUE CONTINUE] Root ${rootId}: ${check.missingIds.size} still missing, re-scheduling`); if (!env.SCRAPE_QUEUE) throw new Error('SCRAPE_QUEUE not configured'); - message.ack(); await env.SCRAPE_QUEUE.send( { rootId }, { delaySeconds: 30 } ); + message.ack(); } } catch (err) { console.error(`[QUEUE ERROR] Root ${rootId}:`, err instanceof Error ? err.message : err); From 44f56f5b6262e2b105c1719014a1b88859842f20 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 16:53:24 +0300 Subject: [PATCH 24/50] docs: changelog entries for the review fixes --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aa0f1..26d0af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Re-implemented the RimSort-derived modules (XML conversion, game constants, About.xml metadata structures and parsing, startup-impact report parsing) independently of the original code, working only from the clean-room rewrite of their behaviour and the existing tests; their public interfaces are unchanged. - Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. +### Fixed +- Windows and macOS builds no longer fail on the renamed `Platform` enumerators; the UI-off configuration no longer builds UI tests; presets work with CMake 3.24; `just run*` launches the macOS app bundle; CI/release QCoro builds no longer require Qt WebSockets. +- Shutting down while a Workshop download is queued, logging in or running no longer leaves the catalog or Steam downloader coroutines touching freed objects. +- Concurrent Organizer folder moves can no longer create folder cycles; the move is validated against the committed tree. +- Auto-sort now resolves alternative package ids per dependency declaration instead of one shared substitute per missing id. +- Configuring the game path after a first run without one now initializes plugins (Organizer, Steam downloader) without a restart. +- The embedded Steam browser exposes its native bridge only to `steamcommunity.com`; deactivating a Workshop mod from it disables every installed copy; Organizer keyboard focus and range selection survive folder expansion. +- Mods with a parsed About.xml but no description no longer show the "invalid mod" text. +- Workshop backend: queue continuations are acknowledged only after they are re-queued, emptied collections drop their old members, picked collections honour version tags in the version filter, and failed search-index writes are retried by the crawler. + ## [0.3.1] - 2026-10-13 ### Changed From cbfb6f5386ed852deb0ced62cb18eefc7ee2a83f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 25/50] refactor(workshop): simplify downloader and catalog internals --- src/core/downloads/steam.cpp | 48 ++++++++++++++--------------------- src/core/workshop/catalog.cpp | 24 ++++++------------ src/core/workshop/catalog.hpp | 2 +- src/core/workshop/client.cpp | 15 ++++------- 4 files changed, 33 insertions(+), 56 deletions(-) diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index cba52bd..2b3aa67 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -259,30 +259,23 @@ QCoro::Task> SteamDownloader::downloadMods( Error{QStringLiteral("A download is already running.")}); } - QStringList ids; - QSet seen; - for (const auto& id : modIds) { - if (!seen.contains(id)) { - seen.insert(id); - ids.append(id); - } - } + modIds.removeDuplicates(); - auto batch = std::make_shared(static_cast(ids.size())); + auto batch = std::make_shared(static_cast(modIds.size())); m_stop.emplace(); QPromise completion; completion.start(); m_batchFinished = completion.future(); const auto finish = qScopeGuard([&completion] { completion.finish(); }); - m_activeIds = QSet(ids.begin(), ids.end()); + m_activeIds = QSet(modIds.begin(), modIds.end()); busyChanged.notify(true); - batchStarted.notify(ids); + batchStarted.notify(modIds); spdlog::info("[workshop] downloading {} items into {} (first ids: {})", - ids.size(), root.toStdString(), - ids.mid(0, 5).join(QStringLiteral(", ")).toStdString()); + modIds.size(), root.toStdString(), + modIds.mid(0, 5).join(QStringLiteral(", ")).toStdString()); try { - co_await runBatch(ids, root, batch); + co_await runBatch(std::move(modIds), root, batch); } catch (...) { finishBatch(batch); throw; @@ -334,6 +327,8 @@ SteamDownloader::ensureClient() { m_client = std::move(*loggedIn); m_clientOptions = options; } + // Returning m_client directly moved it out of the member (the session was + // then logged in again), so hand back a copy. auto result = m_client; co_return result; } @@ -341,6 +336,11 @@ SteamDownloader::ensureClient() { QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, std::shared_ptr batch) { const std::stop_token stop = m_stop->get_token(); + const auto failItems = [&](const QString& error) { + for (const auto& id : ids) { + finishItem(*batch, id, error); + } + }; const std::filesystem::path rootPath(root.toStdWString()); const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { std::error_code ec; @@ -348,9 +348,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, return ec; }); if (stop.stop_requested()) { - for (const auto& id : ids) { - finishItem(*batch, id, QStringLiteral("cancelled")); - } + failItems(QStringLiteral("cancelled")); co_return; } if (mkdirError) { @@ -359,9 +357,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, .arg(root, QString::fromStdString(mkdirError.message())); spdlog::error("[workshop] {}", message.toStdString()); statusMessageChanged.notify(message); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } + failItems(message); co_return; } @@ -371,9 +367,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, const auto loginStarted = std::chrono::steady_clock::now(); auto login = co_await ensureClient(); if (stop.stop_requested()) { - for (const auto& id : ids) { - finishItem(*batch, id, QStringLiteral("cancelled")); - } + failItems(QStringLiteral("cancelled")); co_return; } if (!login) { @@ -381,9 +375,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, QStringLiteral("Steam login failed: %1").arg(login.error().message); spdlog::error("[workshop] {}", message.toStdString()); statusMessageChanged.notify(message); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } + failItems(message); co_return; } spdlog::debug("[workshop] steam session ready in {:.1f}s", @@ -452,9 +444,7 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, // A failed call leaves the Steam session suspect; log in again next time. m_client.reset(); const QString message = QString::fromStdString(outcome.error()); - for (const auto& id : ids) { - finishItem(*batch, id, message); - } + failItems(message); co_return; } diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp index ddc9534..1ac86c1 100644 --- a/src/core/workshop/catalog.cpp +++ b/src/core/workshop/catalog.cpp @@ -113,8 +113,11 @@ void WorkshopCatalog::bindDownloads(DownloadManager& downloads) { m_downloads = &downloads; m_downloadFinishedId = m_downloads->downloadFinished.connect( [this](const DownloadResult&) { invalidateInstalled(); }); - m_busyChangedId = m_downloads->busyChanged.connect( - [this](bool busy) { onBusyChanged(busy); }); + m_busyChangedId = m_downloads->busyChanged.connect([this](bool busy) { + if (!busy) { + resumeIdleWaiters(); + } + }); } void WorkshopCatalog::disconnectEvents() { @@ -134,11 +137,7 @@ void WorkshopCatalog::disconnectEvents() { WorkshopCatalog::~WorkshopCatalog() { *m_alive = false; disconnectEvents(); - auto waiters = std::move(m_idleWaiters); - m_idleWaiters.clear(); - for (std::coroutine_handle<> waiter : waiters) { - waiter.resume(); - } + resumeIdleWaiters(); } QCoro::Task WorkshopCatalog::shutdown() { @@ -150,11 +149,7 @@ QCoro::Task WorkshopCatalog::shutdown() { if (m_downloading) { m_downloads->cancel(); } - auto waiters = std::move(m_idleWaiters); - m_idleWaiters.clear(); - for (std::coroutine_handle<> waiter : waiters) { - waiter.resume(); - } + resumeIdleWaiters(); for (const auto& client : m_clients) { client->shutdown(); } @@ -401,10 +396,7 @@ QCoro::Task WorkshopCatalog::drain() { m_draining = false; } -void WorkshopCatalog::onBusyChanged(bool busy) { - if (busy) { - return; - } +void WorkshopCatalog::resumeIdleWaiters() { auto waiters = std::move(m_idleWaiters); m_idleWaiters.clear(); for (std::coroutine_handle<> waiter : waiters) { diff --git a/src/core/workshop/catalog.hpp b/src/core/workshop/catalog.hpp index 9c909f7..a5e6ba4 100644 --- a/src/core/workshop/catalog.hpp +++ b/src/core/workshop/catalog.hpp @@ -180,7 +180,7 @@ class WorkshopCatalog : public Plugin { InstallState installStateOf(const CatalogMod& mod, const InstalledIndex& installed) const; void changeUrl(const QString& url); - void onBusyChanged(bool busy); + void resumeIdleWaiters(); QCoro::Task drain(); template diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index ee98245..cc65729 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -161,16 +162,10 @@ QCoro::Task> CatalogClient::send( reply = m_network.get(request); } m_pending.insert(reply); - // Runs however the coroutine leaves, so a finished reply is never left - // registered for abort. - struct Release { - QSet& pending; - QNetworkReply* reply; - ~Release() { - pending.remove(reply); - reply->deleteLater(); - } - } release{m_pending, reply}; + const auto release = qScopeGuard([this, reply] { + m_pending.remove(reply); + reply->deleteLater(); + }); co_await qCoro(reply).waitForFinished(); From 41bccdb3369bdea1f3d24034dc56a2a2b0cc3d9a Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 26/50] refactor(organizer): drop allocating helpers in the service and resolver --- src/core/organizer/resolve.cpp | 12 +++---- src/core/organizer/service.cpp | 59 ++++++++++++++-------------------- src/core/organizer/service.hpp | 1 - 3 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp index b1e770f..72256b6 100644 --- a/src/core/organizer/resolve.cpp +++ b/src/core/organizer/resolve.cpp @@ -87,7 +87,7 @@ class Resolver { }; bool matchesQuery(const ModLeaf& leaf, const QString& providerId, - const TreeQuery& query) { + const TreeQuery& query, const QString& text) { if (query.status == StatusFilter::kActive && !leaf.enabled) { return false; } @@ -102,7 +102,6 @@ bool matchesQuery(const ModLeaf& leaf, const QString& providerId, return false; } } - const QString text = query.text.trimmed().toCaseFolded(); if (text.isEmpty()) { return true; } @@ -136,7 +135,6 @@ bool folderLess(const Folder& a, const Folder& b) { // Builds the folder tree bottom-up. `visited` is shared across the whole walk, // so a folder reachable twice is attached once. struct TreeBuilder { - const OrganizerState& state; const QHash>& childrenOf; const QHash>& allByFolder; const QHash>& visibleByFolder; @@ -370,6 +368,8 @@ FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, const QSet& active, const std::optional& query) { const bool filtering = query && !query->isEmpty(); + const QString text = + filtering ? query->text.trimmed().toCaseFolded() : QString{}; const Resolver resolver(state); QHash> allByFolder; @@ -393,7 +393,7 @@ FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, .placement = where.placement, }; allByFolder[where.folderId].append(leaf); - if (!filtering || matchesQuery(leaf, mod.providerId, *query)) { + if (!filtering || matchesQuery(leaf, mod.providerId, *query, text)) { visibleByFolder[where.folderId].append(leaf); } } @@ -409,8 +409,8 @@ FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, ruled.insert(rule.folderId); } - TreeBuilder builder{state, childrenOf, allByFolder, visibleByFolder, - ruled, filtering, {}}; + TreeBuilder builder{childrenOf, allByFolder, visibleByFolder, + ruled, filtering, {}}; return builder.build(state.folders.value(kRootId), 0); } diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp index 21595a3..9b63898 100644 --- a/src/core/organizer/service.cpp +++ b/src/core/organizer/service.cpp @@ -124,31 +124,21 @@ Result OrganizerService::findTag(qint64 tagId) const { int OrganizerService::depth(qint64 folderId) const { const auto& folders = state().folders; int count = 0; - Folder current = folders.value(folderId); - while (current.parentId && count <= kMaxDepth) { + auto current = folders.constFind(folderId); + while (current != folders.cend() && current->parentId && count <= kMaxDepth) { ++count; - current = folders.value(*current.parentId); + current = folders.constFind(*current->parentId); } return count; } -QList OrganizerService::children(qint64 folderId) const { - QList result; - for (const Folder& folder : state().folders) { - if (folder.parentId == folderId) { - result.append(folder); - } - } - return result; -} - QSet OrganizerService::subtreeIds(qint64 folderId) const { QSet ids{folderId}; QList pending{folderId}; while (!pending.isEmpty()) { const qint64 current = pending.takeLast(); - for (const Folder& child : children(current)) { - if (!ids.contains(child.id)) { + for (const Folder& child : state().folders) { + if (child.parentId == current && !ids.contains(child.id)) { ids.insert(child.id); pending.append(child.id); } @@ -162,8 +152,10 @@ int OrganizerService::height(qint64 folderId, int limit) const { return 1; } int tallest = 0; - for (const Folder& child : children(folderId)) { - tallest = std::max(tallest, height(child.id, limit - 1)); + for (const Folder& child : state().folders) { + if (child.parentId == folderId) { + tallest = std::max(tallest, height(child.id, limit - 1)); + } } return 1 + tallest; } @@ -181,8 +173,8 @@ Result OrganizerService::checkDepth(qint64 parentId, Result OrganizerService::checkUniqueFolderName( qint64 parentId, const QString& name, std::optional exclude) const { const QString key = name.toCaseFolded(); - for (const Folder& sibling : children(parentId)) { - if (exclude && sibling.id == *exclude) { + for (const Folder& sibling : state().folders) { + if (sibling.parentId != parentId || sibling.id == exclude) { continue; } if (sibling.name.toCaseFolded() == key) { @@ -477,9 +469,7 @@ QCoro::Task> OrganizerService::setModTags(QStringList packageIds, // ── Rules ──────────────────────────────────────────────────────────────────── QCoro::Task> OrganizerService::setRules(QList specs) { - QList cleaned; - cleaned.reserve(specs.size()); - for (RuleSpec spec : specs) { + for (RuleSpec& spec : specs) { spec.pattern = spec.pattern.trimmed(); if (spec.pattern.isEmpty()) { co_return std::unexpected( @@ -492,9 +482,8 @@ QCoro::Task> OrganizerService::setRules(QList specs) { if (auto target = findFolder(spec.folderId); !target) { co_return std::unexpected(target.error()); } - cleaned.append(std::move(spec)); } - if (auto replaced = co_await m_db.replaceRules(cleaned); !replaced) { + if (auto replaced = co_await m_db.replaceRules(specs); !replaced) { co_return std::unexpected(replaced.error()); } co_return co_await commit(); @@ -510,14 +499,15 @@ QCoro::Task> OrganizerService::addStandardRules() { rule.pattern.toCaseFolded()); } - QSet existing; - for (const Folder& folder : children(kRootId)) { - existing.insert(folder.name.toCaseFolded()); + QSet knownFolders; + for (const Folder& folder : state().folders) { + if (folder.parentId == kRootId) { + knownFolders.insert(folder.name.toCaseFolded()); + } } QList rules; QStringList newFolders; - QSet newFolderKeys; for (const StandardRule& rule : standardRules()) { const RuleKey key{static_cast(rule.field), static_cast(rule.op), rule.pattern.toCaseFolded()}; @@ -529,8 +519,8 @@ QCoro::Task> OrganizerService::addStandardRules() { // The first spelling of a missing top-level folder names it, as in the // legacy service. const QString folderKey = rule.folder.toCaseFolded(); - if (!existing.contains(folderKey) && !newFolderKeys.contains(folderKey)) { - newFolderKeys.insert(folderKey); + if (!knownFolders.contains(folderKey)) { + knownFolders.insert(folderKey); newFolders.append(rule.folder); } } @@ -616,10 +606,11 @@ Result> OrganizerService::folderMoveTargets( if (depth(candidate.id) + treeHeight > kMaxDepth) { continue; } - const QList siblings = children(candidate.id); - const bool clash = std::any_of(siblings.cbegin(), siblings.cend(), - [&key](const Folder& child) { - return child.name.toCaseFolded() == key; + const auto& folders = state().folders; + const bool clash = std::any_of(folders.cbegin(), folders.cend(), + [&candidate, &key](const Folder& child) { + return child.parentId == candidate.id && + child.name.toCaseFolded() == key; }); if (!clash) { targets.insert(candidate.id); diff --git a/src/core/organizer/service.hpp b/src/core/organizer/service.hpp index 1cea94a..58b1e86 100644 --- a/src/core/organizer/service.hpp +++ b/src/core/organizer/service.hpp @@ -110,7 +110,6 @@ class OrganizerService : public Plugin { Result checkDepth(qint64 parentId, int height) const; int depth(qint64 folderId) const; int height(qint64 folderId, int limit = kMaxDepth + 1) const; - QList children(qint64 folderId) const; QSet subtreeIds(qint64 folderId) const; const QHash& allMods() const; From bca800f3bac1bdd91eaa726e4735e7c71d588b30 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 27/50] refactor(services): trim copies in metadata cache, update and process services --- src/core/services/game_process.cpp | 4 +++- src/core/services/metadata_cache.cpp | 34 +++++++++------------------- src/core/services/sort_service.cpp | 9 ++------ src/core/services/update_service.cpp | 14 +++++------- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index 4f1913c..a4f5977 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -426,7 +427,8 @@ void GameProcessMonitor::attachProcess(std::unique_ptr proc) { bool GameProcessMonitor::launchedAlive() const { const QSet live = liveGamePids(m_lister); - return !(live - m_baseline).isEmpty(); + return std::ranges::any_of( + live, [this](qint64 pid) { return !m_baseline.contains(pid); }); } void GameProcessMonitor::dropProcess() { diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp index 3cf6f85..7c29577 100644 --- a/src/core/services/metadata_cache.cpp +++ b/src/core/services/metadata_cache.cpp @@ -124,21 +124,14 @@ QString resolvePath(const QString& path) { // ---- Payload (de)serialization // ------------------------------------------------------------ -QJsonArray sortedArray(const QStringList& values) { - QStringList sorted = values; - sorted.sort(); - return QJsonArray::fromStringList(sorted); -} - -template -QStringList sortedStrings(const Set& set) { +QJsonArray sortedArray(const CaseInsensitiveSet& set) { QStringList values; values.reserve(set.size()); for (const auto& item : set) { values.append(item.str()); } values.sort(); - return values; + return QJsonArray::fromStringList(values); } QJsonObject serializeMod(const AboutXmlMod& mod) { @@ -171,16 +164,14 @@ QJsonObject serializeMod(const AboutXmlMod& mod) { entry[QStringLiteral("package_id")] = dep.packageId.str(); entry[QStringLiteral("workshop_url")] = dep.workshopUrl; entry[QStringLiteral("alternative_package_ids")] = - sortedArray(sortedStrings(dep.alternativePackageIds)); + sortedArray(dep.alternativePackageIds); dependencies.append(entry); } QJsonObject rules; - rules[QStringLiteral("load_before")] = - sortedArray(sortedStrings(mod.aboutRules.loadBefore)); - rules[QStringLiteral("load_after")] = - sortedArray(sortedStrings(mod.aboutRules.loadAfter)); + rules[QStringLiteral("load_before")] = sortedArray(mod.aboutRules.loadBefore); + rules[QStringLiteral("load_after")] = sortedArray(mod.aboutRules.loadAfter); rules[QStringLiteral("incompatible_with")] = - sortedArray(sortedStrings(mod.aboutRules.incompatibleWith)); + sortedArray(mod.aboutRules.incompatibleWith); rules[QStringLiteral("dependencies")] = dependencies; object[QStringLiteral("about_rules")] = rules; return object; @@ -629,7 +620,7 @@ void MetadataCache::prune(const QSet& liveModPaths) { return; } - QStringList cached; + QStringList disappeared; { QSqlQuery query(*db); if (!query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata"))) { @@ -638,16 +629,13 @@ void MetadataCache::prune(const QSet& liveModPaths) { return; } while (query.next()) { - cached.append(query.value(0).toString()); + const QString path = query.value(0).toString(); + if (!liveModPaths.contains(path)) { + disappeared.append(path); + } } } - QStringList disappeared; - for (const QString& path : cached) { - if (!liveModPaths.contains(path)) { - disappeared.append(path); - } - } if (disappeared.isEmpty()) { return; } diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp index 163ab81..dd8286f 100644 --- a/src/core/services/sort_service.cpp +++ b/src/core/services/sort_service.cpp @@ -112,13 +112,8 @@ QStringList SortService::missingDependencyUuids( } } - QStringList unique; - for (const auto& uuid : missing) { - if (!unique.contains(uuid)) { - unique.append(uuid); - } - } - return unique; + missing.removeDuplicates(); + return missing; } } // namespace px::modrim diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp index 0e53178..15bd72b 100644 --- a/src/core/services/update_service.cpp +++ b/src/core/services/update_service.cpp @@ -57,14 +57,12 @@ std::optional splitVersion(const QString& text) { // over pre-release, then the suffix text. int compareVersions(const ParsedVersion& a, const ParsedVersion& b) { const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); - auto padded = [width](std::vector numbers) { - numbers.resize(width, 0); - return numbers; - }; - const std::vector left = padded(a.numbers); - const std::vector right = padded(b.numbers); - if (left != right) { - return left < right ? -1 : 1; + for (std::size_t i = 0; i < width; ++i) { + const qint64 left = i < a.numbers.size() ? a.numbers[i] : 0; + const qint64 right = i < b.numbers.size() ? b.numbers[i] : 0; + if (left != right) { + return left < right ? -1 : 1; + } } const bool leftRelease = a.suffix.isEmpty(); const bool rightRelease = b.suffix.isEmpty(); From 2895a77e42d86adb47dcb3a10e1e25ced0e68754 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 28/50] refactor(core): simplify checker sort keys, provider scanning and cache bypass --- src/core/checker/graph.cpp | 20 ++++++------------ src/core/checker/sort.cpp | 6 ++---- src/core/providers/base.cpp | 4 +--- src/core/providers/local.cpp | 40 ++++++++++++------------------------ 4 files changed, 22 insertions(+), 48 deletions(-) diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp index 69b966e..5c54e5c 100644 --- a/src/core/checker/graph.cpp +++ b/src/core/checker/graph.cpp @@ -313,10 +313,8 @@ void ConstraintGraph::addRules( } } addEdges(pid, std::array{*target}, EdgeType::kDependency, origin, {}); - if (!dep.alternativePackageIds.empty()) { - addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, - {}); - } + addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, + {}); } addLoadRules(pid, rules.loadBefore, rules.loadAfter, rules.incompatibleWith, @@ -329,16 +327,10 @@ void ConstraintGraph::addLoadRules(const PackageId& pid, const CaseInsensitiveSet& incompatibleWith, EdgeOrigin origin, const Alternatives& alternatives) { - if (!loadAfter.empty()) { - addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); - } - if (!loadBefore.empty()) { - addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); - } - if (!incompatibleWith.empty()) { - addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, - alternatives); - } + addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); + addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); + addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, + alternatives); } } // namespace px::modrim::checker diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp index c8e6b42..b27e94d 100644 --- a/src/core/checker/sort.cpp +++ b/src/core/checker/sort.cpp @@ -96,22 +96,20 @@ QHash buildConfigPriority(const sort::TierConfig& tierConfig) { struct SortKey { int priority; - QString name; PackageId pid; }; SortKey makeKey(const PackageId& pid, const QHash& configPriority) { return SortKey{ - configPriority.value(pid, static_cast(configPriority.size())), - pid.str(), pid}; + configPriority.value(pid, static_cast(configPriority.size())), pid}; } bool keyLess(const SortKey& a, const SortKey& b) { if (a.priority != b.priority) { return a.priority < b.priority; } - return a.name < b.name; + return a.pid.str() < b.pid.str(); } // Heap order that pops the smallest key first. diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp index ec35523..cd1b97d 100644 --- a/src/core/providers/base.cpp +++ b/src/core/providers/base.cpp @@ -79,9 +79,7 @@ QCoro::Task> BaseModProvider::loadMods( ModMap result; QList missing; - if (forceReparse) { - missing = candidates; - } else if (cache) { + if (cache && !forceReparse) { auto scope = timer("cache_lookup"); CacheLookup lookup = co_await cache->getMods(candidates, targetVersion); for (ListedModPtr& mod : lookup.cached) { diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp index 9b0e467..4188622 100644 --- a/src/core/providers/local.cpp +++ b/src/core/providers/local.cpp @@ -9,22 +9,20 @@ namespace px::modrim::providers { namespace { -struct ScannedDirectory { - ModDirectory dir; - bool hasPublishedFileId = false; -}; - // Runs off the UI thread: one directory listing plus a published-file-id probe // per mod. -QList scanWithPublishedIds(const QString& root) { - QList scanned; +QList scanWithPublishedIds(const QString& root, + bool hasPublishedFileId) { + QList dirs; for (const ModDirectory& dir : scanModDirectory(root)) { const bool hasId = QFileInfo::exists( QDir(dir.modPath) .filePath(QStringLiteral("About/PublishedFileId.txt"))); - scanned.append(ScannedDirectory{dir, hasId}); + if (hasId == hasPublishedFileId) { + dirs.append(dir); + } } - return scanned; + return dirs; } } // namespace @@ -47,17 +45,11 @@ QCoro::Task> LocalModProvider::discover( } spdlog::debug("LocalModProvider scanning: {}", path().toStdString()); - QList scanned; + QList dirs; { auto scope = tm("scan_dir"); - scanned = co_await runOnPool>( - nullptr, [root = path()] { return scanWithPublishedIds(root); }); - } - QList dirs; - for (const ScannedDirectory& item : scanned) { - if (!item.hasPublishedFileId) { - dirs.append(item.dir); - } + dirs = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root, false); }); } auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), @@ -93,17 +85,11 @@ QCoro::Task> DownloadedModProvider::discover( } spdlog::debug("DownloadedModProvider scanning: {}", path().toStdString()); - QList scanned; + QList dirs; { auto scope = tm("scan_dir"); - scanned = co_await runOnPool>( - nullptr, [root = path()] { return scanWithPublishedIds(root); }); - } - QList dirs; - for (const ScannedDirectory& item : scanned) { - if (item.hasPublishedFileId) { - dirs.append(item.dir); - } + dirs = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root, true); }); } auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), From 58aad02097613a9e021629a073468aa83cfa91eb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 29/50] refactor(core): avoid copies in config, context, mod service and mods config --- src/core/config.cpp | 4 ++-- src/core/context.cpp | 6 +++--- src/core/mod_service.cpp | 7 ++----- src/core/mods_config.cpp | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/core/config.cpp b/src/core/config.cpp index a823307..c2224bc 100644 --- a/src/core/config.cpp +++ b/src/core/config.cpp @@ -351,7 +351,7 @@ JsonObject ConfigService::loadRaw(const QString& filename) const { if (glz::read_json(document, *bytes) || !document.is_object()) { return {}; } - return document.get_object(); + return std::move(document.get_object()); } Result> ConfigService::loadVersioned( @@ -375,7 +375,7 @@ Result> ConfigService::loadVersioned( filename.toStdString()); return std::optional{}; } - JsonObject raw = document.get_object(); + JsonObject raw = std::move(document.get_object()); // A missing marker or an explicit null means schema version 0. std::int64_t version = 0; diff --git a/src/core/context.cpp b/src/core/context.cpp index f1bad12..8fdf9f5 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -73,11 +73,11 @@ void CoreContext::load(QHash mods, } void CoreContext::setActive(QStringList uuids) { - const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); - const QSet newSet(uuids.cbegin(), uuids.cend()); - if (oldSet == newSet && m_activeUuids == uuids) { + if (m_activeUuids == uuids) { return; } + const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QSet newSet(uuids.cbegin(), uuids.cend()); const bool setChanged = oldSet != newSet; m_activeUuids = std::move(uuids); if (setChanged) { diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp index f7097be..96c09b1 100644 --- a/src/core/mod_service.cpp +++ b/src/core/mod_service.cpp @@ -69,10 +69,9 @@ QCoro::Task> ModService::discover(Profiler* timer, bool forceReparse) { // Each provider profiles into its own tree, since their discoveries // interleave. - std::unique_ptr ownTimer; + std::optional ownTimer; if (!timer) { - ownTimer = std::make_unique(); - timer = ownTimer.get(); + timer = &ownTimer.emplace(); } auto scope = (*timer)(QStringLiteral("discover")); @@ -138,7 +137,6 @@ QCoro::Task> ModService::runRefreshPipeline(const QString& label, Profiler discoverTimer; Profiler diagnosticsTimer; - Profiler impactTimer; auto discovery = discover(&discoverTimer, forceReparse); auto diagnostics = m_ctx.diagnosticsService().initialize(&diagnosticsTimer); auto impact = m_startupImpact.load(); @@ -148,7 +146,6 @@ QCoro::Task> ModService::runRefreshPipeline(const QString& label, const auto loaded = co_await impact; timer.adopt(discoverTimer); timer.adopt(diagnosticsTimer); - timer.adopt(impactTimer); if (!discovered) { co_return std::unexpected(discovered.error()); diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp index 58c9605..5c1ffef 100644 --- a/src/core/mods_config.cpp +++ b/src/core/mods_config.cpp @@ -313,7 +313,7 @@ Result writeModsConfig(const QString& path, const ModsConfig& data, return std::unexpected(kept.error()); } } - if (auto written = writeFileAtomically(path, out.str()); !written) { + if (auto written = writeFileAtomically(path, out.view()); !written) { return std::unexpected(written.error()); } spdlog::info("ModsConfig.xml written to {}", path.toStdString()); From 80ca55e0e275b6ee147a576f6795f07392b442a2 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 30/50] refactor(ui): simplify plugin models, views and Steam bridge scripts --- src/ui/plugins/downloads/model.cpp | 6 ++---- src/ui/plugins/organizer/tree_model.cpp | 5 +---- src/ui/plugins/steam_workshop/ts/bridge.ts | 4 ---- src/ui/plugins/steam_workshop/ts/utils.ts | 3 +-- src/ui/plugins/workshop/view.cpp | 5 ++--- 5 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp index 8dd6e96..7728b3e 100644 --- a/src/ui/plugins/downloads/model.cpp +++ b/src/ui/plugins/downloads/model.cpp @@ -56,10 +56,8 @@ void DownloadsModel::apply(const DownloadItemStatus& status) { row.state = QStringLiteral("failed"); row.error = status.error; row.progress = 0; - } else if (status.status == QLatin1String("success")) { - row.state = finalState(status.bytesTotal); - row.progress = 1; - } else if (status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { + } else if (status.status != QLatin1String("success") && + status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { row.state = QStringLiteral("downloading"); row.progress = static_cast(status.bytesDone) / status.bytesTotal; } else { diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp index 83845bd..52b5233 100644 --- a/src/ui/plugins/organizer/tree_model.cpp +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -334,10 +334,7 @@ QModelIndex ModTreeModel::indexForKey(const QString& key) const { return find(find, m_root.get()); } QModelIndex ModTreeModel::indexForUuid(const QString& uuid) const { - for (const auto* item : m_root->walk()) - if (item->kind == "mod" && item->leaf && item->leaf->uuid == uuid) - return indexOf(item); - return {}; + return indexForKey("m:" + uuid); } QList ModTreeModel::expandedIndexes( const QModelIndex& parent) const { diff --git a/src/ui/plugins/steam_workshop/ts/bridge.ts b/src/ui/plugins/steam_workshop/ts/bridge.ts index c55c05a..6590498 100644 --- a/src/ui/plugins/steam_workshop/ts/bridge.ts +++ b/src/ui/plugins/steam_workshop/ts/bridge.ts @@ -1,7 +1,6 @@ import "./qwebchannel.js"; import { getModState, applyModState } from "./controls"; import { refreshAllDepsBadges, updateAllModBadges } from "./badges"; -import { log } from "./utils"; // Ensure __pxmodrim exists (modules may initialize before main.ts guard) if (!window.__pxmodrim) { @@ -15,7 +14,6 @@ if (!window.__pxmodrim) { let _bridgeDataReady = false; let _bridgeDataResolve: (() => void) | null = null; -let _bridgeDataReject: ((reason: Error) => void) | null = null; let _bridgeDataPromise: Promise | null = null; export function isBridgeDataReady(): boolean { @@ -31,11 +29,9 @@ export function waitForBridgeData(timeoutMs = 15000): Promise { if (!_bridgeDataPromise) { _bridgeDataPromise = new Promise((resolve, reject) => { _bridgeDataResolve = resolve; - _bridgeDataReject = reject; setTimeout(() => { _bridgeDataPromise = null; _bridgeDataResolve = null; - _bridgeDataReject = null; reject(new Error("Bridge data timeout")); }, timeoutMs); }); diff --git a/src/ui/plugins/steam_workshop/ts/utils.ts b/src/ui/plugins/steam_workshop/ts/utils.ts index 428dd3a..324077c 100644 --- a/src/ui/plugins/steam_workshop/ts/utils.ts +++ b/src/ui/plugins/steam_workshop/ts/utils.ts @@ -47,8 +47,7 @@ export function scrapeDepsFromContainer(containerEl: Element): ModToggle[] { 'a[href*="filedetails/?id="]', ); links.forEach((link) => { - const match = link.href.match(/[?&]id=(\d+)/); - const modId = match ? match[1] : null; + const modId = getModId(link); const titleEl = link.querySelector(".requiredItem"); const title = titleEl ? titleEl.textContent?.trim() diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index cc006c6..28cdb72 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -372,10 +372,9 @@ QCoro::Task WorkshopViewPanel::load(bool append) { void WorkshopViewPanel::showFavourites() { std::vector matches; + const auto query = m_criteria.query.toCaseFolded(); for (const auto& item : m_favourites.items()) - if (QString::fromStdString(item.title) - .toCaseFolded() - .contains(m_criteria.query.toCaseFolded())) + if (QString::fromStdString(item.title).toCaseFolded().contains(query)) matches.push_back(item); collectionsModel->setPage(rows(matches), static_cast(matches.size()), std::nullopt); From 604b1d4678681374cc4081720e5bce50c8fe7737 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 31/50] refactor(ui): simplify list models, info panel and window helpers --- src/ui/models/mod_list_model.cpp | 22 +++++++++--------- src/ui/models/mod_list_model.hpp | 3 ++- src/ui/models/mod_list_proxy_model.cpp | 17 +++++--------- src/ui/panels/mod_info_data.cpp | 31 ++++++++++++++------------ src/ui/window/main_window.cpp | 3 ++- 5 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp index 2dec95a..2a27546 100644 --- a/src/ui/models/mod_list_model.cpp +++ b/src/ui/models/mod_list_model.cpp @@ -175,31 +175,32 @@ QStringList ModListModel::activeUuids() const { if (item.checked) result.append(item.uuid); return result; } -QList ModListModel::updateLoadIndices() { - QList changed; +std::pair ModListModel::updateLoadIndices() { + int first = rowCount(), last = -1; int count = 0; for (int row = 0; row < m_items.size(); ++row) { auto& item = m_items[row]; const int index = item.checked ? ++count : -1; if (item.loadIndex != index) { item.loadIndex = index; - changed.append(row); + first = std::min(first, row); + last = row; } } if (m_activeCount != count) { m_activeCount = count; emit activeCountChanged(); } - return changed; + return {first, last}; } bool ModListModel::moveRow(int source, int target) { if (source == target || !getItem(source) || !getItem(target)) return false; beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); m_items.move(source, target); endMoveRows(); - const auto changed = updateLoadIndices(); - if (!changed.isEmpty()) - emit dataChanged(index(changed.first()), index(changed.last()), + const auto [first, last] = updateLoadIndices(); + if (last >= 0) + emit dataChanged(index(first), index(last), {kLoadIndexRole, kSectionNameRole}); return true; } @@ -211,8 +212,8 @@ void ModListModel::commitOrder(const QStringList& uuids) { order.reserve(m_items.size()); QSet ordered; for (const auto& uuid : uuids) - if (oldRows.contains(uuid)) { - order.append(oldRows.value(uuid)); + if (const auto found = oldRows.constFind(uuid); found != oldRows.cend()) { + order.append(found.value()); ordered.insert(uuid); } QList inactive; @@ -263,8 +264,7 @@ void ModListModel::setCheckStates(const QList& rows, bool checked) { first = std::min(first, row); } if (first == m_items.size()) return; - const auto loadChanged = updateLoadIndices(); - if (!loadChanged.isEmpty()) first = std::min(first, loadChanged.first()); + first = std::min(first, updateLoadIndices().first); emit dataChanged( index(first), index(rowCount() - 1), {kCheckStateRole, kLoadIndexRole, kIsActiveRole, kSectionNameRole}); diff --git a/src/ui/models/mod_list_model.hpp b/src/ui/models/mod_list_model.hpp index 99f1281..ade96e6 100644 --- a/src/ui/models/mod_list_model.hpp +++ b/src/ui/models/mod_list_model.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "core/models/metadata/structures.hpp" #include "core/models/view/diagnostics.hpp" @@ -77,7 +78,7 @@ class ModListModel : public QAbstractListModel { void activeCountChanged(); private: - QList updateLoadIndices(); + std::pair updateLoadIndices(); ModItem makeItem(const QString& uuid, ListedModPtr mod, bool checked) const; void diagnosticsChanged( const QHash& summaries); diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp index fe5e2f4..49c6db8 100644 --- a/src/ui/models/mod_list_proxy_model.cpp +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -7,23 +7,18 @@ ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) { connect(m_source, &QAbstractItemModel::dataChanged, this, &ModListProxyModel::sourceDataChanged); - connect(m_source, &QAbstractItemModel::layoutChanged, this, [this] { + const auto rebuildLayout = [this] { if (!m_inProxyMove) layoutRebuild(); - }); + }; + connect(m_source, &QAbstractItemModel::layoutChanged, this, rebuildLayout); connect(m_source, &QAbstractItemModel::modelReset, this, [this] { beginResetModel(); rebuild(); endResetModel(); }); - connect(m_source, &QAbstractItemModel::rowsMoved, this, [this] { - if (!m_inProxyMove) layoutRebuild(); - }); - connect(m_source, &QAbstractItemModel::rowsInserted, this, [this] { - if (!m_inProxyMove) layoutRebuild(); - }); - connect(m_source, &QAbstractItemModel::rowsRemoved, this, [this] { - if (!m_inProxyMove) layoutRebuild(); - }); + connect(m_source, &QAbstractItemModel::rowsMoved, this, rebuildLayout); + connect(m_source, &QAbstractItemModel::rowsInserted, this, rebuildLayout); + connect(m_source, &QAbstractItemModel::rowsRemoved, this, rebuildLayout); rebuild(); } int ModListProxyModel::rowCount(const QModelIndex& parent) const { diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp index 49d7321..913d1c1 100644 --- a/src/ui/panels/mod_info_data.cpp +++ b/src/ui/panels/mod_info_data.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -21,11 +22,11 @@ QString substitute(const QString& text, const QRegularExpression& re, auto matches = re.globalMatch(text); while (matches.hasNext()) { const auto match = matches.next(); - out += text.mid(end, match.capturedStart() - end); + out += QStringView(text).sliced(end, match.capturedStart() - end); out += replacement(match); end = match.capturedEnd(); } - out += text.mid(end); + out += QStringView(text).sliced(end); return out; } QRegularExpression insensitive(const QString& pattern) { @@ -192,16 +193,17 @@ QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, conflicts.begin(), conflicts.end(), [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); QList dependents; - for (const auto& entry : ctx.allMods()) - if (const auto* other = dynamic_cast(entry.get()); - other && other->uuid() != mod.uuid()) { - for (const auto& dep : other->aboutRules.dependencies) - if (dep.packageId == about->packageId || - dep.alternativePackageIds.contains(about->packageId)) { - dependents.append(other); - break; - } - } + for (const auto& entry : ctx.allMods()) { + const auto* other = dynamic_cast(entry.get()); + if (!other || other->uuid() == mod.uuid()) continue; + if (std::any_of( + other->aboutRules.dependencies.cbegin(), + other->aboutRules.dependencies.cend(), [about](const auto& dep) { + return dep.packageId == about->packageId || + dep.alternativePackageIds.contains(about->packageId); + })) + dependents.append(other); + } std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { return a->name.toLower() < b->name.toLower(); }); @@ -217,9 +219,10 @@ QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, if (copy) item.insert("copy", true); details.append(item); }; + const QString authors = about ? about->authors.join(", ") : QString{}; QString provider; if (about) { - if (!about->authors.isEmpty()) row("Author", about->authors.join(", ")); + if (!about->authors.isEmpty()) row("Author", authors); if (!about->modVersion.isEmpty()) row("Version", about->modVersion); provider = providerLabel(mod.providerId, about->packageId.str()); if (!provider.isEmpty()) row("Source", provider); @@ -232,7 +235,7 @@ QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, const QString target = ctx.targetVersion(); const bool known = !mod.supportedVersions.isEmpty() && target != "Unknown"; return {{"name", mod.name}, - {"author", about ? about->authors.join(", ") : QString{}}, + {"author", authors}, {"provider", provider}, {"status", QVariantMap{{"level", level}, {"title", title}, diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp index daec289..10c539a 100644 --- a/src/ui/window/main_window.cpp +++ b/src/ui/window/main_window.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -541,7 +542,7 @@ void MainWindow::onDownloadsFinished(const DownloadResult& result) { if (failed) { const auto names = m_ctx.downloads().namesById(m_ctx.allMods().values()); QStringList shown; - for (const auto& id : result.failed.mid(0, 3)) + for (const auto& id : result.failed | std::views::take(3)) shown.append(names.value(id, id)); m_toasts->warning(QStringLiteral("%1, %2 failed: %3%4") .arg(summary) From 379b9af7da5e4af2c08dc8a8a322055299f3e408 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 32/50] refactor(qml): reuse lookups in ModTree, RuleEditor and TagPicker --- src/ui/ModTree.qml | 20 ++++++++++---------- src/ui/RuleEditor.qml | 22 ++++------------------ src/ui/TagPicker.qml | 9 ++++++--- 3 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/ui/ModTree.qml b/src/ui/ModTree.qml index b4c5c9b..25f0adc 100644 --- a/src/ui/ModTree.qml +++ b/src/ui/ModTree.qml @@ -38,6 +38,7 @@ Rectangle { function clickRow(index, row, modifiers) { var range = [] + var key = modTreeModel.data(index, Qt.UserRole + 2) var anchorRow = tree.rowAtIndex(modTreeModel.indexForKey(root.anchorKey)) if ((modifiers & Qt.ShiftModifier) && anchorRow >= 0) { var from = Math.min(anchorRow, row) @@ -45,22 +46,22 @@ Rectangle { for (var r = from; r <= to; ++r) range.push(tree.index(r, 0)) } else { - root.anchorKey = modTreeModel.data(index, Qt.UserRole + 2) + root.anchorKey = key } - root.focusedKey = modTreeModel.data(index, Qt.UserRole + 2) + root.focusedKey = key organizerPanel.selectRow(index, modifiers, range) tree.forceActiveFocus() } function toggleRow(index, row) { - if (tree.isExpanded(row)) { + var collapsed = tree.isExpanded(row) + if (collapsed) tree.collapse(row) - organizerPanel.persistCollapsed(index, true) - } else { + else tree.expand(row) - organizerPanel.persistCollapsed(index, false) + organizerPanel.persistCollapsed(index, collapsed) + if (!collapsed) root.restoreExpansion() - } } function navigateRow(row, modifiers) { @@ -176,10 +177,9 @@ Rectangle { implicitWidth: tree.width implicitHeight: item.model.kind === "mod" ? 30 : 32 radius: Theme.radiusSm - color: root.dropRow === item.row + color: root.dropRow === item.row || item.model.selected ? Theme.primaryBg - : (item.model.selected ? Theme.primaryBg - : (rowMouse.containsMouse ? Theme.elevate3 : "transparent")) + : rowMouse.containsMouse ? Theme.elevate3 : "transparent" border.width: root.dropRow === item.row ? 1 : 0 border.color: Theme.primary Accessible.role: Accessible.TreeItem diff --git a/src/ui/RuleEditor.qml b/src/ui/RuleEditor.qml index c9ce362..3ab2b19 100644 --- a/src/ui/RuleEditor.qml +++ b/src/ui/RuleEditor.qml @@ -75,27 +75,15 @@ FocusScope { } function findFieldIndex(val) { - for (var i = 0; i < fieldOptions.length; ++i) { - if (fieldOptions[i].value === val) - return i - } - return 0 + return Math.max(0, fieldOptions.findIndex(option => option.value === val)) } function findOpIndex(val) { - for (var i = 0; i < opOptions.length; ++i) { - if (opOptions[i].value === val) - return i - } - return 0 + return Math.max(0, opOptions.findIndex(option => option.value === val)) } function findFolderIndex(fid) { - for (var i = 0; i < ruleFolders.length; ++i) { - if (ruleFolders[i].id === fid) - return i - } - return 0 + return Math.max(0, ruleFolders.findIndex(folder => folder.id === fid)) } ListModel { @@ -126,9 +114,7 @@ FocusScope { } onRuleRowsChanged: { - if (!opened && !draftMatches(ruleRows)) - loadDraft() - else if (opened && draftMatches(loadedRows) && !draftMatches(ruleRows)) + if ((!opened || draftMatches(loadedRows)) && !draftMatches(ruleRows)) loadDraft() refreshDirty() schedulePreview() diff --git a/src/ui/TagPicker.qml b/src/ui/TagPicker.qml index 89aa3b8..8715f79 100644 --- a/src/ui/TagPicker.qml +++ b/src/ui/TagPicker.qml @@ -8,9 +8,12 @@ PxComboBox { property var options: [] property string selectedTag: "" displayText: selectedTag || "All tags" - model: options.filter(function (tag) { - return tag === "All tags" || tag.toLowerCase().indexOf(tagSearch.text.toLowerCase()) >= 0 - }) + model: { + const query = tagSearch.text.toLowerCase() + return options.filter(function (tag) { + return tag === "All tags" || tag.toLowerCase().indexOf(query) >= 0 + }) + } Accessible.name: "Tag filter" onActivated: selectedTag = currentText === "All tags" ? "" : currentText delegate: ItemDelegate { From ecc75487da15d9a426702943b14cdf93430eaa4f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 17:03:03 +0300 Subject: [PATCH 33/50] refactor(backend): consolidate dependency traversal and queue acknowledgement --- cf-workers/picks.ts | 24 ++++++++++------------ cf-workers/worker.ts | 49 ++++++++++++++++++-------------------------- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index d62ad1e..5656cf8 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -65,20 +65,18 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } let previewUrl: string | null = null; - if (Object.prototype.hasOwnProperty.call(input, 'preview_url')) { - if (input.preview_url !== null) { - if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { - throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); - } - let url: URL; - try { - url = new URL(input.preview_url); - } catch { - throw new HttpError(400, 'preview_url must be a valid HTTPS URL'); - } - if (url.protocol !== 'https:') throw new HttpError(400, 'preview_url must use HTTPS'); - previewUrl = input.preview_url; + if (Object.prototype.hasOwnProperty.call(input, 'preview_url') && input.preview_url !== null) { + if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { + throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); + } + let url: URL; + try { + url = new URL(input.preview_url); + } catch { + throw new HttpError(400, 'preview_url must be a valid HTTPS URL'); } + if (url.protocol !== 'https:') throw new HttpError(400, 'preview_url must use HTTPS'); + previewUrl = input.preview_url; } const tags: string[] = []; if (Object.prototype.hasOwnProperty.call(input, 'tags')) { diff --git a/cf-workers/worker.ts b/cf-workers/worker.ts index b53fe49..e31ad51 100644 --- a/cf-workers/worker.ts +++ b/cf-workers/worker.ts @@ -90,7 +90,6 @@ class LRUMap { } has(key: string): boolean { return this._map.has(key); } delete(key: string): boolean { return this._map.delete(key); } - get size(): number { return this._map.size; } } const GLOBAL_RAM_CACHE = new LRUMap(10000); @@ -270,27 +269,22 @@ function buildTree(targetId: string): { itemsMap: Record visited.add(currentId); const cached = GLOBAL_RAM_CACHE.get(currentId); - if (cached) { - const ttl = cached.status === 'OK' - ? (cached.data.is_collection ? CACHE_TTL_OK_COLLECTION : CACHE_TTL_OK_MOD) - : CACHE_TTL_ERROR; - - if ((now - cached.timestamp) < ttl && cached.status !== 'PENDING') { - itemsMap[currentId] = cached.data; - if (Array.isArray(cached.data.deps)) { - queue.push(...cached.data.deps); - } - continue; - } - - if (Array.isArray(cached.data.deps)) { - queue.push(...cached.data.deps); - } - + if (!cached) { missingIds.add(currentId); + continue; + } + const ttl = cached.status === 'OK' + ? (cached.data.is_collection ? CACHE_TTL_OK_COLLECTION : CACHE_TTL_OK_MOD) + : CACHE_TTL_ERROR; + + if ((now - cached.timestamp) < ttl && cached.status !== 'PENDING') { + itemsMap[currentId] = cached.data; } else { missingIds.add(currentId); } + if (Array.isArray(cached.data.deps)) { + queue.push(...cached.data.deps); + } } return { itemsMap, missingIds }; @@ -335,7 +329,7 @@ async function fetchSteamApiDetails(apiKey: string | undefined, modIds: string[] } } -function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem[] } { +function processApiResults(details: SteamFile[]): ApiDependencyItem[] { const processed: ApiDependencyItem[] = []; for (const detail of details) { @@ -362,7 +356,7 @@ function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem }); } - return { processed }; + return processed; } // ============================================================================ @@ -651,7 +645,7 @@ async function scrapeInline(env: Env, missingIds: Set, rootId: string | continue; } - const { processed } = processApiResults(apiDetails); + const processed = processApiResults(apiDetails); const respondedIds = new Set(processed.map(m => m.id)); for (const id of batch) { @@ -690,12 +684,10 @@ async function scrapeInline(env: Env, missingIds: Set, rootId: string | scraped.add(mod.id); scrapedCount++; - if (depsToSave.length > 0) { - for (const dep of depsToSave) { - if (!scraped.has(dep) && !inQueue.has(dep)) { - toScrape.push(dep); - inQueue.add(dep); - } + for (const dep of depsToSave) { + if (!scraped.has(dep) && !inQueue.has(dep)) { + toScrape.push(dep); + inQueue.add(dep); } } } @@ -888,7 +880,6 @@ export default { const check = buildTree(rootId); if (check.missingIds.size === 0) { console.log(`[QUEUE COMPLETE] Root ${rootId} fully resolved`); - message.ack(); } else { console.log(`[QUEUE CONTINUE] Root ${rootId}: ${check.missingIds.size} still missing, re-scheduling`); if (!env.SCRAPE_QUEUE) throw new Error('SCRAPE_QUEUE not configured'); @@ -896,8 +887,8 @@ export default { { rootId }, { delaySeconds: 30 } ); - message.ack(); } + message.ack(); } catch (err) { console.error(`[QUEUE ERROR] Root ${rootId}:`, err instanceof Error ? err.message : err); message.retry(); From d895a4cf00ca95a924a08607315349438d45ba23 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 22:23:23 +0300 Subject: [PATCH 34/50] ci: pin Qt 6.10.3 and drop base-package modules from aqtinstall --- .github/workflows/build.yml | 8 +- .github/workflows/ci.yml | 4 +- AGENTS.md | 4 +- aqtinstall.log | 167 ++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 aqtinstall.log diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d49685..5a5abd6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ permissions: contents: write env: - QT_VERSION: '6.11.0' + QT_VERSION: '6.10.3' QCORO_REF: 'v0.13.0' jobs: @@ -30,7 +30,7 @@ jobs: with: version: ${{ env.QT_VERSION }} cache: true - modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup Node.js uses: actions/setup-node@v7 @@ -228,7 +228,7 @@ jobs: with: version: ${{ env.QT_VERSION }} cache: true - modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup MSVC environment uses: ilammy/msvc-dev-cmd@v1 @@ -354,7 +354,7 @@ jobs: with: version: ${{ env.QT_VERSION }} cache: true - modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d05b24..d45f591 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: permissions: {} env: - QT_VERSION: '6.11.0' + QT_VERSION: '6.10.3' QCORO_REF: 'v0.13.0' jobs: @@ -32,7 +32,7 @@ jobs: with: version: ${{ env.QT_VERSION }} cache: true - modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtdeclarative qtshadertools qtsvg qtpositioning qtwebchannel qtwebengine' || 'qtdeclarative qtshadertools qtsvg' }} + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup MSVC environment if: runner.os == 'Windows' diff --git a/AGENTS.md b/AGENTS.md index a184cd3..6952469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ CI runs configure, build and test on every OS (see CI matrix). Only the Linux St - Task runner: **just** (`just` lists the recipes). Node.js 22 for `build-js` (`npx esbuild`). - **C++23** compiler: GCC or Clang (CI also builds with MSVC on Windows). - **CMake >= 3.24** with **Ninja** (the presets use it). **ccache** is picked up automatically when it is on `PATH`. -- **Qt 6** (`find_package(Qt6 6.8)` is the minimum; CI and releases use **6.11.0**). Components: Core, Concurrent, Network, Sql, Gui, Quick, QuickControls2, QuickWidgets, Svg, Widgets, Test, Qml. `WebEngineQuick` is needed only by the SteamWorkshop variant. +- **Qt 6** (`find_package(Qt6 6.8)` is the minimum; CI and releases use **6.10.3**: aqtinstall 3.3.0 cannot read the Windows 6.11 repository layout). Components: Core, Concurrent, Network, Sql, Gui, Quick, QuickControls2, QuickWidgets, Svg, Widgets, Test, Qml. `WebEngineQuick` is needed only by the SteamWorkshop variant. - **QCoro 0.13** (`QCoro6`: Core, Network, Qml, Quick), built against the same Qt install. Enable coroutines with `qcoro_enable_coroutines()`, as the root CMakeLists already does. - **pugixml**, **libzip**, **spdlog** via `find_package`. - **CPM** (`cmake/CPM.cmake`) fetches glaze 5.5.4, Catch2 3.8.1 (tests) and PxSteamDL 0.3.0 (`cmake/Steam.cmake`, skipped by NoWorkshop). @@ -135,7 +135,7 @@ C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): - `cf-workers/` — Cloudflare Workers (Steam Workshop dependency resolver, separate deploy); checked by the `workshop-backend` CI job. ## CI matrix -- `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.11.0 (`jurplel/install-qt-action`, WebEngine modules only for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. +- `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.10.3 (`jurplel/install-qt-action`; `qtdeclarative` and `qtsvg` ship in the base package, so `modules` lists only `qtshadertools`, plus the WebEngine addons for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. - Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md`. - The Linux SteamWorkshop job alone runs `just format-check` and `just lint` after configure, installing `clang-format` and `clang-tidy` from apt first. - Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. diff --git a/aqtinstall.log b/aqtinstall.log new file mode 100644 index 0000000..79f6b61 --- /dev/null +++ b/aqtinstall.log @@ -0,0 +1,167 @@ +2026-10-16 22:18:40,516 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:43,058 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:18:45,386 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:18:48,852 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:50,047 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:51,208 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:52,616 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:59,079 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:00,297 - aqt.main - ERROR - installer 139951542945664 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:00,947 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:02,005 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:03,164 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:04,204 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:06,006 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:07,135 - aqt.main - ERROR - installer 139921843177344 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:07,754 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:08,822 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:09,910 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:10,930 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:11,998 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:13,053 - aqt.main - ERROR - installer 140087631488896 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:16,758 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:19,055 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:19:21,206 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:19:23,928 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:24,986 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:26,061 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:27,225 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:28,496 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:29,643 - aqt.main - ERROR - installer 140002283718528 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:30,027 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:31,062 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:32,373 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:33,437 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:34,614 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:35,748 - aqt.main - ERROR - installer 140367646533504 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:36,283 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:39,112 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtpdf/6110/clang_64/Updates.xml.sha256 +2026-10-16 22:19:41,116 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtwebengine/6110/clang_64/Updates.xml.sha256 +2026-10-16 22:19:49,691 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:50,749 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:51,749 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:52,828 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:53,873 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:54,888 - aqt.main - ERROR - installer 140553987299200 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:55,338 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:56,857 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:57,798 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:58,807 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:59,850 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:01,022 - aqt.main - ERROR - installer 139797143357312 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:20:07,161 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:08,274 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:09,297 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:10,283 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:11,231 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:12,625 - aqt.main - ERROR - installer 140696147408768 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:16,210 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:17,726 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:18,710 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:19,863 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:20,943 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:22,017 - aqt.main - ERROR - installer 140351640856448 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:25,583 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:27,034 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:28,141 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:29,133 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:30,150 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:31,146 - aqt.main - ERROR - installer 139895792011136 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:34,640 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:38,563 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:39,592 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:40,595 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:41,700 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:42,693 - aqt.main - ERROR - installer 140191232956288 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:21:55,531 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:56,749 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:57,848 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:58,926 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:00,000 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:01,341 - aqt.main - ERROR - installer 140108064188288 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:22:01,865 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:02,967 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:04,107 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:05,118 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:06,262 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:07,224 - aqt.main - ERROR - installer 140021660686208 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:22:46,358 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:22:48,719 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6103/x86_64/Updates.xml.sha256 +2026-10-16 22:22:50,870 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6103/x86_64/Updates.xml.sha256 +2026-10-16 22:22:58,472 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:23:00,592 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/extensions/qtpdf/6103/msvc2022_64/Updates.xml.sha256 +2026-10-16 22:23:02,738 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/extensions/qtwebengine/6103/msvc2022_64/Updates.xml.sha256 +2026-10-16 22:23:05,582 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:23:08,129 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtpdf/6103/clang_64/Updates.xml.sha256 +2026-10-16 22:23:10,089 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtwebengine/6103/clang_64/Updates.xml.sha256 From b9a765ff12313deebc0c2817df06ec1ba4c3ef25 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 22:35:01 +0300 Subject: [PATCH 35/50] ci: fix QCoro QmlPrivate lookup, ZLIB target clash on vcpkg and Ubuntu libzip tools --- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- CMakeLists.txt | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a5abd6..544b121 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libspdlog-dev + sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libzip-tools libspdlog-dev - name: Build QCoro against Qt shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d45f591..6ee87b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libspdlog-dev + sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libzip-tools libspdlog-dev - name: Install system dependencies (macOS) if: runner.os == 'macOS' diff --git a/CMakeLists.txt b/CMakeLists.txt index 5972800..bd8f3b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,7 +24,7 @@ set(CPM_USE_LOCAL_PACKAGES ON) set(CPM_SOURCE_CACHE "$ENV{HOME}/.cache/CPM" CACHE PATH "") include(cmake/CPM.cmake) -find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets Test) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets Test Qml QmlPrivate) qt_standard_project_setup(REQUIRES 6.8) set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") @@ -34,6 +34,11 @@ endif() find_package(QCoro6 REQUIRED COMPONENTS Core Network Qml Quick) qcoro_enable_coroutines() +# NoWorkshop ships without Steam downloads, so PxSteamDL is not fetched or linked. +if(NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") + include(cmake/Steam.cmake OPTIONAL) +endif() + find_package(pugixml REQUIRED) find_package(libzip REQUIRED) find_package(spdlog REQUIRED) @@ -43,11 +48,6 @@ if(PX_BUILD_TESTS) list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") endif() -# NoWorkshop ships without Steam downloads, so PxSteamDL is not fetched or linked. -if(NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") - include(cmake/Steam.cmake OPTIONAL) -endif() - add_compile_options(-Wall -Wextra -Wpedantic) add_subdirectory(src/core) From d783a4b875b957c0caa4609f0676bd102c4e4eff Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 22:38:37 +0300 Subject: [PATCH 36/50] ci: install Ubuntu zipcmp, zipmerge and ziptool for libzip-dev CMake config --- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 544b121..d899e4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libzip-tools libspdlog-dev + sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev - name: Build QCoro against Qt shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ee87b4..6081cdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev libzip-tools libspdlog-dev + sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev - name: Install system dependencies (macOS) if: runner.os == 'macOS' From 06b66b5901a9817edbf5759dc7a27d5e4204f7b9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:00:11 +0300 Subject: [PATCH 37/50] fix: build on libc++, clang, GCC 13 and MSVC; pin clang-format in CI --- .github/workflows/ci.yml | 4 +++- CMakeLists.txt | 6 +++++- src/core/workshop/client.cpp | 24 +++++++++++++++-------- src/core/workshop/types.hpp | 6 ++++-- src/ui/panels/mod_info_panel.cpp | 3 ++- src/ui/plugins/organizer/view.cpp | 9 +++++---- src/ui/plugins/workshop/view.cpp | 29 +++++++++++++++++----------- tests/core/test_config.cpp | 6 ++++-- tests/core/test_plugin_config.cpp | 6 ++++-- tests/core/workshop/test_catalog.cpp | 6 ++++-- tests/core/workshop/test_client.cpp | 6 ++++-- tests/ui/test_workshop_view.cpp | 6 ++++-- 12 files changed, 73 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6081cdf..c0455d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,9 @@ jobs: - name: Install clang tools (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' - run: sudo apt-get install -y clang-format clang-tidy + run: | + sudo apt-get install -y clang-tidy clang-tools + pipx install clang-format==22.1.8 - name: Check formatting (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' diff --git a/CMakeLists.txt b/CMakeLists.txt index bd8f3b8..a8fc1f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,7 +48,11 @@ if(PX_BUILD_TESTS) list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") endif() -add_compile_options(-Wall -Wextra -Wpedantic) +if(MSVC) + add_compile_options(/W4 /utf-8 /bigobj) +else() + add_compile_options(-Wall -Wextra -Wpedantic) +endif() add_subdirectory(src/core) if(PX_BUILD_UI) diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp index cc65729..400cdf7 100644 --- a/src/core/workshop/client.cpp +++ b/src/core/workshop/client.cpp @@ -17,14 +17,9 @@ namespace px::modrim::workshop { -namespace { - -constexpr int kRequestTimeoutMs = 30000; -// Per-request limits of the catalog service; larger inputs are split across -// requests. -constexpr qsizetype kIdsPerBatch = 100; -constexpr qsizetype kModIdsPerResolve = 50; -constexpr qsizetype kCollectionIdsPerResolve = 10; +// glaze reflection needs these types to have external linkage; clang rejects +// reflecting types from an anonymous namespace. +namespace wire { struct ErrorMessage { std::string message; @@ -52,6 +47,19 @@ struct ResolveBody { }; // NOLINTEND(readability-identifier-naming) +} // namespace wire + +using namespace wire; + +namespace { + +constexpr int kRequestTimeoutMs = 30000; +// Per-request limits of the catalog service; larger inputs are split across +// requests. +constexpr qsizetype kIdsPerBatch = 100; +constexpr qsizetype kModIdsPerResolve = 50; +constexpr qsizetype kCollectionIdsPerResolve = 10; + std::vector toUtf8(const QStringList& list) { std::vector out; out.reserve(static_cast(list.size())); diff --git a/src/core/workshop/types.hpp b/src/core/workshop/types.hpp index 9a41748..3fdc7fb 100644 --- a/src/core/workshop/types.hpp +++ b/src/core/workshop/types.hpp @@ -5,9 +5,9 @@ #include #include #include -#include #include #include +#include #include #include @@ -166,7 +166,9 @@ struct Discover { // on the wire struct ResolvedDownload { std::vector roots; - std::map items; + // Not std::map: libc++'s map operator<=> is not SFINAE-friendly, so glaze's + // ordered<> probe hard-errors on a variant of unordered alternatives. + std::unordered_map items; std::vector mod_ids; std::vector unavailable_ids; std::vector incomplete_collection_ids; diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp index f7e7849..545d261 100644 --- a/src/ui/panels/mod_info_panel.cpp +++ b/src/ui/panels/mod_info_panel.cpp @@ -252,7 +252,8 @@ void ModInfoPanel::updateMod() { if (auto id = m_ctx.downloads().updatableId(*m_mod)) performUpdate(*id); } QCoro::Task ModInfoPanel::performUpdate(QString id) { - const auto result = co_await m_ctx.downloads().downloadMods({id}); + const QStringList ids{id}; + const auto result = co_await m_ctx.downloads().downloadMods(ids); if (!result) qWarning().noquote() << "Mod update not started:" << result.error().message; } diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp index c27b8a1..40b6024 100644 --- a/src/ui/plugins/organizer/view.cpp +++ b/src/ui/plugins/organizer/view.cpp @@ -577,9 +577,10 @@ QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { QCoro::Task OrganizerViewPanel::toggle(QModelIndex index) { const auto* node = m_model->nodeAt(index); if (!node) co_return; - if (node->leaf) - co_await toggleMods(m_ctx, this, {node->leaf->uuid}); - else if (node->isContainer()) { + if (node->leaf) { + const QStringList uuids{node->leaf->uuid}; + co_await toggleMods(m_ctx, this, uuids); + } else if (node->isContainer()) { const auto change = m_service.folderToggle( node->folderId(), node->kind != QStringLiteral("ungrouped")); if (!change) { @@ -607,7 +608,7 @@ QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { else if (action == QStringLiteral("enable")) co_await applyActivation(m_ctx, this, uuids); else if (action == QStringLiteral("disable")) - co_await applyActivation(m_ctx, this, {}, uuids); + co_await applyActivation(m_ctx, this, QStringList{}, uuids); } QCoro::QmlTask OrganizerViewPanel::newFolder() { return editFolder(QStringLiteral("new")); diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index 28cdb72..7ab8a65 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -270,7 +270,8 @@ QCoro::Task WorkshopViewPanel::loadPack() { if (!m_detailData || m_tornDown) co_return; QPointer self(this); const int generation = m_generation; - auto result = co_await m_catalog.plan({}, {idOf(m_detailData->item)}); + const QStringList collectionIds{idOf(m_detailData->item)}; + auto result = co_await m_catalog.plan(QStringList{}, collectionIds); if (!self || self->m_tornDown || generation != self->m_generation) co_return; if (!result) { spdlog::warn("[workshop] cannot resolve collection mods: {}", @@ -522,10 +523,13 @@ QCoro::Task WorkshopViewPanel::toggleActivationAsync(QString itemId) { if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); } if (copies.isEmpty()) co_return; - if (active.isEmpty()) - co_await applyActivation(m_ctx, this, {copies.front()}, {}); - else - co_await applyActivation(m_ctx, this, {}, active); + const QStringList none; + if (active.isEmpty()) { + const QStringList only{copies.front()}; + co_await applyActivation(m_ctx, this, only, none); + } else { + co_await applyActivation(m_ctx, this, none, active); + } } QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { auto copies = packCopies(); @@ -542,24 +546,27 @@ QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { (!only && allActive && m_packIds.contains(*id)))) disable.append(uuid); } + const QStringList none; if (!only && allActive) - co_await applyActivation(m_ctx, this, {}, disable); + co_await applyActivation(m_ctx, this, none, disable); else co_await applyActivation(m_ctx, this, *copies, disable); } QCoro::Task WorkshopViewPanel::downloadItemAsync(QString itemId, QString kind) { - co_await download( - kind == QStringLiteral("mod") ? QStringList{itemId} : QStringList{}, - kind == QStringLiteral("collection") ? QStringList{itemId} - : QStringList{}); + QStringList mods; + QStringList collections; + if (kind == QStringLiteral("mod")) mods.append(itemId); + if (kind == QStringLiteral("collection")) collections.append(itemId); + co_await download(mods, collections); } QCoro::Task WorkshopViewPanel::updateAllAsync() { QStringList ids; for (const auto& mod : m_installed.mods()) if (m_catalog.installState(mod) == InstallState::kOutdated) ids.append(QString::fromStdString(mod.id)); - if (!ids.isEmpty()) co_await download(ids, {}); + const QStringList noCollections; + if (!ids.isEmpty()) co_await download(ids, noCollections); } void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { m_catalog.enqueue(plan); diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index b4b135d..e22dc2c 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -14,7 +14,7 @@ #include "core/file_io.hpp" #include "test_support.hpp" -namespace { +namespace config_test { using namespace px::modrim; using test_support::readBytes; @@ -67,7 +67,9 @@ AppConfig legacyExpectedConfig() { return cfg; } -} // namespace +} // namespace config_test + +using namespace config_test; TEST_CASE("read_game_version reads and trims Version.txt") { QTemporaryDir dir; diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp index 999982b..dfe951e 100644 --- a/tests/core/test_plugin_config.cpp +++ b/tests/core/test_plugin_config.cpp @@ -10,7 +10,7 @@ #include "core/plugin_config.hpp" #include "test_support.hpp" -namespace { +namespace plugin_config_test { using namespace px::modrim; using test_support::readBytes; @@ -25,7 +25,9 @@ struct DemoSettings { }; // NOLINTEND(readability-identifier-naming) -} // namespace +} // namespace plugin_config_test + +using namespace plugin_config_test; TEST_CASE( "plugin config seeds from legacy config.json keys and persists changes") { diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp index 6c63da3..606946c 100644 --- a/tests/core/workshop/test_catalog.cpp +++ b/tests/core/workshop/test_catalog.cpp @@ -43,7 +43,7 @@ using workshop_test::HttpResponse; using workshop_test::run; using workshop_test::wire; -namespace { +namespace catalog_test { // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct BatchWire { std::vector items; @@ -165,7 +165,9 @@ struct ResolvePayload { }; // NOLINTEND(readability-identifier-naming) -} // namespace +} // namespace catalog_test + +using namespace catalog_test; TEST_CASE("workshop install state uses sync timestamp, not directory mtime") { ContextFixture fixture; diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp index 66b9a1e..8660da8 100644 --- a/tests/core/workshop/test_client.cpp +++ b/tests/core/workshop/test_client.cpp @@ -23,7 +23,7 @@ using workshop_test::run; using workshop_test::unreachableUrl; using workshop_test::wire; -namespace { +namespace client_test { // NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire struct ResolveRequest { @@ -72,7 +72,9 @@ CatalogCollection steamCollection(std::string id, std::string steamId, return collection; } -} // namespace +} // namespace client_test + +using namespace client_test; TEST_CASE("workshop client decodes every endpoint and sends the query") { TestApplication app; diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp index fe8d159..7551b9f 100644 --- a/tests/ui/test_workshop_view.cpp +++ b/tests/ui/test_workshop_view.cpp @@ -20,7 +20,7 @@ #include "ui/plugins/workshop/view.hpp" #include "ui/theme/qml_theme.hpp" -namespace { +namespace workshop_view_test { using namespace px::modrim; using namespace px::modrim::workshop; using namespace px::modrim; @@ -192,7 +192,9 @@ struct PanelFixture { }); } }; -} // namespace +} // namespace workshop_view_test + +using namespace workshop_view_test; TEST_CASE( "workshop view loads discovery and paginates filtered catalog results") { From e6588638ab9c84e1b4bab321e490375273575cda Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:08:01 +0300 Subject: [PATCH 38/50] fix: link zlib directly and include windows.h before tlhelp32.h --- CMakeLists.txt | 3 +++ src/core/CMakeLists.txt | 2 +- src/core/services/game_process.cpp | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a8fc1f0..bac3ca3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,9 @@ if(NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") include(cmake/Steam.cmake OPTIONAL) endif() +if(NOT TARGET ZLIB::ZLIB) + find_package(ZLIB REQUIRED) +endif() find_package(pugixml REQUIRED) find_package(libzip REQUIRED) find_package(spdlog REQUIRED) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 0150751..6cdb67e 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -4,7 +4,7 @@ target_include_directories(pxcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) target_link_libraries(pxcore PUBLIC Qt6::Core Qt6::Concurrent Qt6::Network Qt6::Sql QCoro6::Core QCoro6::Network - libzip::zip pugixml::pugixml spdlog::spdlog glaze::glaze) + libzip::zip ZLIB::ZLIB pugixml::pugixml spdlog::spdlog glaze::glaze) if(PX_STEAM_LIBS) target_link_libraries(pxcore PUBLIC ${PX_STEAM_LIBS}) diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp index a4f5977..84e24fc 100644 --- a/src/core/services/game_process.cpp +++ b/src/core/services/game_process.cpp @@ -29,8 +29,11 @@ #ifndef NOMINMAX #define NOMINMAX #endif -#include +// clang-format off +// tlhelp32.h needs the types windows.h declares, so the order matters. #include +#include +// clang-format on #elif defined(Q_OS_MACOS) #include #include From f2e9a23f04291fcbd24019b67cd3f487e36ad666 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:28:40 +0300 Subject: [PATCH 39/50] ci: pin clang-tidy, filter GCC-only flags for lint; fix macOS test assumptions --- .github/workflows/ci.yml | 4 +++- justfile | 15 ++++++++++----- tests/core/test_utils.cpp | 5 ++++- tests/ui/main.cpp | 2 ++ tests/ui/test_main_window.cpp | 3 ++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0455d6..14e7946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,10 @@ jobs: - name: Install clang tools (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' run: | + # clang-tidy 18 from apt cannot parse libstdc++ 13's . sudo apt-get install -y clang-tidy clang-tools pipx install clang-format==22.1.8 + pipx install clang-tidy==22.1.8 - name: Check formatting (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' @@ -113,7 +115,7 @@ jobs: - name: Lint (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' - run: just lint + run: CLANG_TIDY="$HOME/.local/bin/clang-tidy" just lint - name: Build run: cmake --build --preset default diff --git a/justfile b/justfile index 6ba280d..44f8929 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,7 @@ set windows-shell := ["bash", "-cu"] python := if os() == "windows" { "python" } else { "python3" } app_exe := if os() == "macos" { "pxmodrim.app/Contents/MacOS/pxmodrim" } else { "pxmodrim" } +clang_tidy := env("CLANG_TIDY", "clang-tidy") @default: just --list @@ -44,13 +45,17 @@ format: format-check: find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format --dry-run --Werror -lint: +# Qt adds GCC-only flags that clang rejects, so clang-tidy reads a filtered copy. +lint-db: [ -f build/default/compile_commands.json ] || cmake --preset default - run-clang-tidy -p build/default -quiet -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + mkdir -p build/lint + sed 's/ -mno-direct-extern-access//g' build/default/compile_commands.json > build/lint/compile_commands.json -lint-fix: - [ -f build/default/compile_commands.json ] || cmake --preset default - run-clang-tidy -p build/default -quiet -fix -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' +lint: lint-db + run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + +lint-fix: lint-db + run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -fix -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' check: format-check lint build diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp index 89786d7..146997e 100644 --- a/tests/core/test_utils.cpp +++ b/tests/core/test_utils.cpp @@ -38,7 +38,10 @@ TEST_CASE( const auto found = px::modrim::findAboutXml(root.path()); REQUIRE(found.has_value()); - CHECK(found->endsWith(QStringLiteral("ABOUT/about.XML"))); + // Case-insensitive filesystems (macOS, Windows) resolve the canonical + // About/About.xml candidate first and keep its spelling. + CHECK( + found->endsWith(QStringLiteral("ABOUT/about.XML"), Qt::CaseInsensitive)); } TEST_CASE("findAboutXml returns nullopt when About has no About.xml") { diff --git a/tests/ui/main.cpp b/tests/ui/main.cpp index 651dabf..3d32e94 100644 --- a/tests/ui/main.cpp +++ b/tests/ui/main.cpp @@ -10,6 +10,8 @@ Q_IMPORT_QML_PLUGIN(PxModRimPlugin) // per process. int main(int argc, char* argv[]) { qputenv("QT_QPA_PLATFORM", "offscreen"); + if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) + qputenv("QT_QUICK_CONTROLS_STYLE", "Basic"); QApplication app(argc, argv); return Catch::Session().run(argc, argv); } diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp index cfdfa93..92a3f9d 100644 --- a/tests/ui/test_main_window.cpp +++ b/tests/ui/test_main_window.cpp @@ -105,8 +105,9 @@ QStackedWidget* stackOf(px::modrim::MainWindow& window) { } QMessageBox* closePrompt(px::modrim::MainWindow& window) { + // QMessageBox drops its window title on macOS, so match by type. for (auto* dialog : window.findChildren()) - if (dialog->windowTitle() == QStringLiteral("Unsaved Changes") && + if (dynamic_cast(dialog) && dialog->isVisible()) return dialog; return nullptr; From 8999a5e67d36dea20710389bb5981df3d34f2a0f Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:29:29 +0300 Subject: [PATCH 40/50] ci: build Windows in Release so it links against prebuilt QCoro --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14e7946..07f0b23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,8 @@ jobs: run: | extra=() if [ "$RUNNER_OS" = "Windows" ]; then - extra+=(-DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static-md) + # QCoro is built Release (/MD); a Debug build (/MDd) would not link. + extra+=(-DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static-md) fi cmake --preset default \ -DPX_WORKSHOP_VARIANT=${{ matrix.workshop }} \ From d896856a5b88aa690ea49cb8c610a7309ebb1cd7 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:47:13 +0300 Subject: [PATCH 41/50] fix(steam): complete the download future through the callback queue; ci: pipx clang-tidy path; docs --- .github/workflows/ci.yml | 2 +- AGENTS.md | 5 ++-- src/core/downloads/steam.cpp | 49 +++++++++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f0b23..e660448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: - name: Lint (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' - run: CLANG_TIDY="$HOME/.local/bin/clang-tidy" just lint + run: CLANG_TIDY="$(pipx environment --value PIPX_BIN_DIR)/clang-tidy" just lint - name: Build run: cmake --build --preset default diff --git a/AGENTS.md b/AGENTS.md index 6952469..ea83798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,8 +136,9 @@ C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): ## CI matrix - `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.10.3 (`jurplel/install-qt-action`; `qtdeclarative` and `qtsvg` ship in the base package, so `modules` lists only `qtshadertools`, plus the WebEngine addons for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. -- Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md`. -- The Linux SteamWorkshop job alone runs `just format-check` and `just lint` after configure, installing `clang-format` and `clang-tidy` from apt first. +- Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip (with `zipcmp zipmerge ziptool`, which its CMake config references) and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md` and builds Release, because the prebuilt QCoro is `/MD`. +- The Linux SteamWorkshop job alone runs `just format-check` and `just lint` after configure. Both tools are pinned to 22.1.8 from PyPI (`pipx`): the apt clang-tidy 18 cannot parse libstdc++ 13's ``, and apt clang-format 18 formats differently. `just lint` reads a copy of `compile_commands.json` without Qt's GCC-only `-mno-direct-extern-access`; `CLANG_TIDY` overrides the binary. +- The Ubuntu runner's GCC 13 ICEs on braced-list or ternary arguments inside a `co_await` operand (`co_await f({x})`); hoist them into locals. libc++ (macOS) hard-errors on glaze's `<=>` probe of a `std::map` holding a `std::variant`, and clang rejects glaze reflection of types in an anonymous namespace; keep reflected structs in a named namespace. - Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. ## Packaging diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index 2b3aa67..e3e22e7 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -8,10 +8,14 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -427,18 +431,39 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, }; downloadPhaseChanged.notify(QStringLiteral("query")); - QFuture call = QtConcurrent::run( - [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { - try { - return client->download(itemIds, rootPath, options); - } catch (const std::runtime_error& e) { - // Anything else propagates to the caller, as in the Python version. - const std::string what = e.what(); - return std::unexpected(what.empty() ? std::string("download failed") - : what); - } - }); - Outcome outcome = co_await QFuture(call); + // The future completes on the main thread, through the queue the item + // callbacks above use. A task that finishes before the co_await below would + // otherwise resume this coroutine ahead of its still-queued callbacks. + auto promise = std::make_shared>(); + promise->start(); + QFuture call = promise->future(); + QThreadPool::globalInstance()->start([client, itemIds = std::move(itemIds), + rootPath, options, promise, target] { + std::optional outcome; + std::exception_ptr failure; + try { + outcome = client->download(itemIds, rootPath, options); + } catch (const std::runtime_error& e) { + const std::string what = e.what(); + outcome = + std::unexpected(what.empty() ? std::string("download failed") : what); + } catch (...) { + // Anything else propagates to the caller, as in the Python version. + failure = std::current_exception(); + } + QMetaObject::invokeMethod( + target, + [promise, outcome = std::move(outcome), failure]() mutable { + if (failure) { + promise->setException(failure); + } else { + promise->addResult(std::move(*outcome)); + } + promise->finish(); + }, + Qt::QueuedConnection); + }); + Outcome outcome = co_await call; if (!outcome) { // A failed call leaves the Steam session suspect; log in again next time. From 9c454dc88990e5ce51fa520daef6b9ab3396f733 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Fri, 16 Oct 2026 23:59:49 +0300 Subject: [PATCH 42/50] fix(steam): order download completion behind queued callbacks; test: skip POSIX-only cases on Windows; ci: explicit tidy checks --- .github/workflows/ci.yml | 4 +- justfile | 4 +- src/core/downloads/steam.cpp | 56 ++++++++------------- tests/core/services/test_game_launcher.cpp | 6 +++ tests/core/services/test_metadata_cache.cpp | 3 ++ tests/core/test_config.cpp | 6 +++ tests/core/test_mods_config.cpp | 2 +- 7 files changed, 41 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e660448..d4e58ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,9 @@ jobs: - name: Lint (Linux SteamWorkshop) if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' - run: CLANG_TIDY="$(pipx environment --value PIPX_BIN_DIR)/clang-tidy" just lint + # run-clang-tidy 18 probes the checks with `-list-checks -`, where + # clang-tidy 22 finds no .clang-tidy, so the checks are passed explicitly. + run: CLANG_TIDY="$(pipx environment --value PIPX_BIN_DIR)/clang-tidy" just lint -checks=-*,readability-identifier-naming - name: Build run: cmake --build --preset default diff --git a/justfile b/justfile index 44f8929..9e8998f 100644 --- a/justfile +++ b/justfile @@ -51,8 +51,8 @@ lint-db: mkdir -p build/lint sed 's/ -mno-direct-extern-access//g' build/default/compile_commands.json > build/lint/compile_commands.json -lint: lint-db - run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' +lint *args: lint-db + run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -extra-arg=-Wno-unknown-warning-option {{args}} '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' lint-fix: lint-db run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -fix -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp index e3e22e7..c6fce11 100644 --- a/src/core/downloads/steam.cpp +++ b/src/core/downloads/steam.cpp @@ -8,14 +8,11 @@ #include #include #include -#include #include #include #include #include -#include #include -#include #include #include #include @@ -431,39 +428,26 @@ QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, }; downloadPhaseChanged.notify(QStringLiteral("query")); - // The future completes on the main thread, through the queue the item - // callbacks above use. A task that finishes before the co_await below would - // otherwise resume this coroutine ahead of its still-queued callbacks. - auto promise = std::make_shared>(); - promise->start(); - QFuture call = promise->future(); - QThreadPool::globalInstance()->start([client, itemIds = std::move(itemIds), - rootPath, options, promise, target] { - std::optional outcome; - std::exception_ptr failure; - try { - outcome = client->download(itemIds, rootPath, options); - } catch (const std::runtime_error& e) { - const std::string what = e.what(); - outcome = - std::unexpected(what.empty() ? std::string("download failed") : what); - } catch (...) { - // Anything else propagates to the caller, as in the Python version. - failure = std::current_exception(); - } - QMetaObject::invokeMethod( - target, - [promise, outcome = std::move(outcome), failure]() mutable { - if (failure) { - promise->setException(failure); - } else { - promise->addResult(std::move(*outcome)); - } - promise->finish(); - }, - Qt::QueuedConnection); - }); - Outcome outcome = co_await call; + QFuture call = QtConcurrent::run( + [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { + try { + return client->download(itemIds, rootPath, options); + } catch (const std::runtime_error& e) { + // Anything else propagates to the caller, as in the Python version. + const std::string what = e.what(); + return std::unexpected(what.empty() ? std::string("download failed") + : what); + } + }); + Outcome outcome = co_await QFuture(call); + + // A task that finished before the co_await above resumes this coroutine + // ahead of the callbacks it posted. This marker is queued behind them. + auto marker = std::make_shared>(); + marker->start(); + QMetaObject::invokeMethod( + target, [marker] { marker->finish(); }, Qt::QueuedConnection); + co_await marker->future(); if (!outcome) { // A failed call leaves the Steam session suspect; log in again next time. diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp index 3d7890d..d10de4d 100644 --- a/tests/core/services/test_game_launcher.cpp +++ b/tests/core/services/test_game_launcher.cpp @@ -161,6 +161,9 @@ TEST_CASE( } TEST_CASE("executable lookup prefers the current binary over the legacy one") { +#ifdef Q_OS_WIN + SKIP("Windows decides executability by extension, not permission bits"); +#endif QTemporaryDir dir; REQUIRE(dir.isValid()); const QString root = dir.path(); @@ -173,6 +176,9 @@ TEST_CASE("executable lookup prefers the current binary over the legacy one") { } TEST_CASE("executable lookup skips a non-executable binary") { +#ifdef Q_OS_WIN + SKIP("Windows decides executability by extension, not permission bits"); +#endif QTemporaryDir dir; REQUIRE(dir.isValid()); const QString root = dir.path(); diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp index 1198c65..b0b1a36 100644 --- a/tests/core/services/test_metadata_cache.cpp +++ b/tests/core/services/test_metadata_cache.cpp @@ -343,6 +343,9 @@ TEST_CASE("providers keep their identity when they share one cache") { } TEST_CASE("normalize_paths matches normalize_path, including symlinks") { +#ifdef Q_OS_WIN + SKIP("QFile::link makes .lnk shortcuts on Windows, not symlinks"); +#endif QTemporaryDir root; REQUIRE(root.isValid()); const QString real = QDir(root.path()).filePath(QStringLiteral("real")); diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp index e22dc2c..f1b6bf3 100644 --- a/tests/core/test_config.cpp +++ b/tests/core/test_config.cpp @@ -84,6 +84,9 @@ TEST_CASE("read_game_version reads and trims Version.txt") { TEST_CASE( "read_game_version returns nullopt when Version.txt is missing or " "unreadable") { +#ifdef Q_OS_WIN + SKIP("Windows has no POSIX read permission to remove"); +#endif QTemporaryDir dir; REQUIRE(dir.isValid()); REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); @@ -210,6 +213,9 @@ TEST_CASE("corrupt config returns safe defaults") { } TEST_CASE("unreadable config returns safe defaults") { +#ifdef Q_OS_WIN + SKIP("Windows has no POSIX read permission to remove"); +#endif QTemporaryDir dir; REQUIRE(dir.isValid()); const QString path = pathIn(dir, "config.json"); diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp index fa596b4..47672e7 100644 --- a/tests/core/test_mods_config.cpp +++ b/tests/core/test_mods_config.cpp @@ -329,7 +329,7 @@ TEST_CASE( QStringList backups; for (const auto& snapshot : getSnapshots(snapshotsDir)) { - if (snapshot != snapshotPath) { + if (QFileInfo(snapshot) != QFileInfo(snapshotPath)) { backups.append(snapshot); } } From 8d6450c7d93b6f7ec251a841866500a7ac2756d7 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 01:28:57 +0300 Subject: [PATCH 43/50] refactor(workshop): inline empty QStringList arguments --- src/ui/plugins/workshop/view.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp index 7ab8a65..c44a977 100644 --- a/src/ui/plugins/workshop/view.cpp +++ b/src/ui/plugins/workshop/view.cpp @@ -523,12 +523,11 @@ QCoro::Task WorkshopViewPanel::toggleActivationAsync(QString itemId) { if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); } if (copies.isEmpty()) co_return; - const QStringList none; if (active.isEmpty()) { const QStringList only{copies.front()}; - co_await applyActivation(m_ctx, this, only, none); + co_await applyActivation(m_ctx, this, only, QStringList{}); } else { - co_await applyActivation(m_ctx, this, none, active); + co_await applyActivation(m_ctx, this, QStringList{}, active); } } QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { @@ -546,9 +545,8 @@ QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { (!only && allActive && m_packIds.contains(*id)))) disable.append(uuid); } - const QStringList none; if (!only && allActive) - co_await applyActivation(m_ctx, this, none, disable); + co_await applyActivation(m_ctx, this, QStringList{}, disable); else co_await applyActivation(m_ctx, this, *copies, disable); } @@ -565,8 +563,7 @@ QCoro::Task WorkshopViewPanel::updateAllAsync() { for (const auto& mod : m_installed.mods()) if (m_catalog.installState(mod) == InstallState::kOutdated) ids.append(QString::fromStdString(mod.id)); - const QStringList noCollections; - if (!ids.isEmpty()) co_await download(ids, noCollections); + if (!ids.isEmpty()) co_await download(ids, QStringList{}); } void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { m_catalog.enqueue(plan); From 28bc14ae10962fadb4fd0e26ab96f5875621e91a Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 01:48:30 +0300 Subject: [PATCH 44/50] ci: pin third-party actions to immutable commits --- .github/workflows/build.yml | 24 ++++++++++++------------ .github/workflows/ci.yml | 8 ++++---- CHANGELOG.md | 1 + 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d899e4f..a54948f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Qt - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: version: ${{ env.QT_VERSION }} cache: true @@ -38,7 +38,7 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: just @@ -64,7 +64,7 @@ jobs: cmake --install "$RUNNER_TEMP/qcoro-build" - name: Setup ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 with: key: release-linux-${{ matrix.workshop }} @@ -204,7 +204,7 @@ jobs: tar -xzf dist-archive/PxModRim-Linux-${{ matrix.workshop }}.tar.gz -C dist - name: Build Flatpak bundle - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 + uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6 with: manifest-path: packaging/flatpak/com.github.PyXiion.PxModRim.yaml bundle: PxModRim-Linux-${{ matrix.workshop }}.flatpak @@ -224,14 +224,14 @@ jobs: - uses: actions/checkout@v7 - name: Install Qt - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: version: ${{ env.QT_VERSION }} cache: true modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup MSVC environment - uses: ilammy/msvc-dev-cmd@v1 + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 - name: Setup Node.js uses: actions/setup-node@v7 @@ -239,7 +239,7 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: just @@ -274,7 +274,7 @@ jobs: cmake --install "$RUNNER_TEMP/qcoro-build" - name: Setup ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 with: key: release-windows-${{ matrix.workshop }} @@ -350,7 +350,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Qt - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: version: ${{ env.QT_VERSION }} cache: true @@ -362,7 +362,7 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: just @@ -386,7 +386,7 @@ jobs: cmake --install "$RUNNER_TEMP/qcoro-build" - name: Setup ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 with: key: release-macos-${{ matrix.workshop }} @@ -514,7 +514,7 @@ jobs: done - name: Create Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: files: | artifacts/PxModRim-Linux-*/*.tar.gz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e58ba..9b65be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Qt - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: version: ${{ env.QT_VERSION }} cache: true @@ -36,7 +36,7 @@ jobs: - name: Setup MSVC environment if: runner.os == 'Windows' - uses: ilammy/msvc-dev-cmd@v1 + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 - name: Setup Node.js uses: actions/setup-node@v7 @@ -44,7 +44,7 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: just @@ -82,7 +82,7 @@ jobs: cmake --install "$RUNNER_TEMP/qcoro-build" - name: Setup ccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 with: key: ci-${{ matrix.os }}-${{ matrix.workshop }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d0af1..9dd0deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Project license changed from LGPL-3.0 to GPL-3.0-or-later. Parts of earlier releases were derived from RimSort (GPL-3.0); `NOTICE` previously described RimSort as MIT-licensed, which was incorrect and is fixed. - Re-implemented the RimSort-derived modules (XML conversion, game constants, About.xml metadata structures and parsing, startup-impact report parsing) independently of the original code, working only from the clean-room rewrite of their behaviour and the existing tests; their public interfaces are unchanged. - Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. +- Pinned third-party CI and release actions to immutable commits without changing their selected versions. ### Fixed - Windows and macOS builds no longer fail on the renamed `Platform` enumerators; the UI-off configuration no longer builds UI tests; presets work with CMake 3.24; `just run*` launches the macOS app bundle; CI/release QCoro builds no longer require Qt WebSockets. From 0badf2f4f6681d118186cc48225721496a91af17 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 01:49:00 +0300 Subject: [PATCH 45/50] fix(workshop): use ES2022 own-property checks --- CHANGELOG.md | 1 + cf-workers/picks.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd0deb..817d74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The embedded Steam browser exposes its native bridge only to `steamcommunity.com`; deactivating a Workshop mod from it disables every installed copy; Organizer keyboard focus and range selection survive folder expansion. - Mods with a parsed About.xml but no description no longer show the "invalid mod" text. - Workshop backend: queue continuations are acknowledged only after they are re-queued, emptied collections drop their old members, picked collections honour version tags in the version filter, and failed search-index writes are retried by the crawler. +- Workshop backend validates picked-collection fields with the ES2022 own-property API. ## [0.3.1] - 2026-10-13 diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index 5656cf8..62c7ec0 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -35,7 +35,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { if (!fields.has(field)) throw new HttpError(400, `Unexpected field: ${field}`); } for (const field of ['title', 'author', 'description', 'member_ids']) { - if (!Object.prototype.hasOwnProperty.call(input, field)) { + if (!Object.hasOwn(input, field)) { throw new HttpError(400, `Missing required field: ${field}`); } } @@ -65,7 +65,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } let previewUrl: string | null = null; - if (Object.prototype.hasOwnProperty.call(input, 'preview_url') && input.preview_url !== null) { + if (Object.hasOwn(input, 'preview_url') && input.preview_url !== null) { if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); } @@ -79,7 +79,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { previewUrl = input.preview_url; } const tags: string[] = []; - if (Object.prototype.hasOwnProperty.call(input, 'tags')) { + if (Object.hasOwn(input, 'tags')) { if (!Array.isArray(input.tags) || input.tags.length > 30) { throw new HttpError(400, 'tags must contain at most 30 strings of at most 100 characters'); } @@ -91,7 +91,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } } const versions: string[] = []; - if (Object.prototype.hasOwnProperty.call(input, 'supported_versions')) { + if (Object.hasOwn(input, 'supported_versions')) { if (!Array.isArray(input.supported_versions) || input.supported_versions.length > 20) { throw new HttpError(400, 'supported_versions must contain at most 20 versions in major.minor[.patch] form'); } @@ -104,7 +104,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } } let featuredRank = 0; - if (Object.prototype.hasOwnProperty.call(input, 'featured_rank')) { + if (Object.hasOwn(input, 'featured_rank')) { if (typeof input.featured_rank !== 'number' || !Number.isInteger(input.featured_rank) || input.featured_rank < 0 || input.featured_rank > 1000000) { throw new HttpError(400, 'featured_rank must be an integer from 0 to 1000000'); From 63845a0154417f61dae9a15dcf295079d2bf2dea Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 01:49:06 +0300 Subject: [PATCH 46/50] fix(workshop): resolve injection static-analysis warnings --- CHANGELOG.md | 1 + src/ui/plugins/steam_workshop/ts/api.ts | 2 +- src/ui/plugins/steam_workshop/ts/badges.ts | 1 - src/ui/plugins/steam_workshop/ts/bridge.ts | 8 ++++++-- src/ui/plugins/steam_workshop/ts/controls.ts | 2 +- src/ui/plugins/steam_workshop/ts/deps.ts | 10 +++++++--- src/ui/plugins/steam_workshop/ts/detail.ts | 4 ++-- 7 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 817d74e..d53bdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Re-implemented the RimSort-derived modules (XML conversion, game constants, About.xml metadata structures and parsing, startup-impact report parsing) independently of the original code, working only from the clean-room rewrite of their behaviour and the existing tests; their public interfaces are unchanged. - Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. - Pinned third-party CI and release actions to immutable commits without changing their selected versions. +- Removed unused Workshop injection imports and discarded callback results explicitly to satisfy static analysis. ### Fixed - Windows and macOS builds no longer fail on the renamed `Platform` enumerators; the UI-off configuration no longer builds UI tests; presets work with CMake 3.24; `just run*` launches the macOS app bundle; CI/release QCoro builds no longer require Qt WebSockets. diff --git a/src/ui/plugins/steam_workshop/ts/api.ts b/src/ui/plugins/steam_workshop/ts/api.ts index 7b04244..b314430 100644 --- a/src/ui/plugins/steam_workshop/ts/api.ts +++ b/src/ui/plugins/steam_workshop/ts/api.ts @@ -1,5 +1,5 @@ import type { DepItemsResult } from "./types"; -import { PxModRimRPC } from "./bridge"; +import type { PxModRimRPC } from "./bridge"; let _rpc: PxModRimRPC | null = null; diff --git a/src/ui/plugins/steam_workshop/ts/badges.ts b/src/ui/plugins/steam_workshop/ts/badges.ts index 50919c1..287140f 100644 --- a/src/ui/plugins/steam_workshop/ts/badges.ts +++ b/src/ui/plugins/steam_workshop/ts/badges.ts @@ -1,4 +1,3 @@ -import { ModState } from "./types"; import { log, getModId, getModTitle, findItemCard } from "./utils"; import { getModState, applyModState, handleClick } from "./controls"; import { isBridgeDataReady } from "./bridge"; diff --git a/src/ui/plugins/steam_workshop/ts/bridge.ts b/src/ui/plugins/steam_workshop/ts/bridge.ts index 6590498..9db2f43 100644 --- a/src/ui/plugins/steam_workshop/ts/bridge.ts +++ b/src/ui/plugins/steam_workshop/ts/bridge.ts @@ -91,7 +91,9 @@ export function initRPC(): Promise { export function __pxmSetInstalled(modIds: string[]): void { window.__pxmodrim.installedIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.installedIds.add(id)); + (modIds || []).forEach((id) => { + window.__pxmodrim.installedIds.add(id); + }); if (_bridgeDataReady) { updateAllModBadges(); refreshAllDepsBadges(); @@ -115,7 +117,9 @@ export function __pxmClearChecked(): void { export function __pxmSetActive(modIds: string[]): void { window.__pxmodrim.activeIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.activeIds.add(id)); + (modIds || []).forEach((id) => { + window.__pxmodrim.activeIds.add(id); + }); if (_bridgeDataReady) { updateAllModBadges(); refreshAllDepsBadges(); diff --git a/src/ui/plugins/steam_workshop/ts/controls.ts b/src/ui/plugins/steam_workshop/ts/controls.ts index d0a0a97..2c7a1ac 100644 --- a/src/ui/plugins/steam_workshop/ts/controls.ts +++ b/src/ui/plugins/steam_workshop/ts/controls.ts @@ -1,5 +1,5 @@ import { ModState, type ModStatus } from "./types"; -import { log, warn } from "./utils"; +import { warn } from "./utils"; import { getDepsFor, flattenDepTree } from "./deps"; import { PxModRimAPI } from "./api"; diff --git a/src/ui/plugins/steam_workshop/ts/deps.ts b/src/ui/plugins/steam_workshop/ts/deps.ts index 37a30e4..c12c425 100644 --- a/src/ui/plugins/steam_workshop/ts/deps.ts +++ b/src/ui/plugins/steam_workshop/ts/deps.ts @@ -1,4 +1,4 @@ -import type { DepNode, DepItemsResult } from "./types"; +import type { DepNode } from "./types"; import { Config } from "./types"; import { log, warn, parseHTML, scrapeDepsFromContainer } from "./utils"; import { PxModRimAPI } from "./api"; @@ -149,7 +149,9 @@ async function buildDomDepTree( const resolvers = _fetchResolvers.get(modId); if (resolvers) { _fetchResolvers.delete(modId); - resolvers.forEach((r) => r()); + resolvers.forEach((r) => { + r(); + }); } } } @@ -210,7 +212,9 @@ export function flattenDepTree( } export function cancelPendingDepFetches(): void { - DepState.aborters.forEach((controller) => controller.abort()); + DepState.aborters.forEach((controller) => { + controller.abort(); + }); DepState.aborters.clear(); DepState.fetching.clear(); } diff --git a/src/ui/plugins/steam_workshop/ts/detail.ts b/src/ui/plugins/steam_workshop/ts/detail.ts index b5e0766..fd09f59 100644 --- a/src/ui/plugins/steam_workshop/ts/detail.ts +++ b/src/ui/plugins/steam_workshop/ts/detail.ts @@ -1,4 +1,4 @@ -import { ModState, Config } from "./types"; +import { Config } from "./types"; import { log, warn } from "./utils"; import { getModState, applyModState, handleClick, performModAction } from "./controls"; import { getDepsFor } from "./deps"; @@ -121,7 +121,7 @@ export async function createDetailButton(): Promise { if ((createDetailButton as Record)._running) return; (createDetailButton as Record)._running = true; try { - log("createDetailButton ENTER url=" + window.location.href); + log(`createDetailButton ENTER url=${window.location.href}`); const currentUrl = window.location.href; if (DetailState.injectedUrl !== currentUrl) { DetailState.injectedUrl = currentUrl; From d106d507e7d3b37eaedb8189e254281d18af117b Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 02:54:43 +0300 Subject: [PATCH 47/50] ci(release): build with GCC 13, PowerShell NSIS, keep macOS frameworks, SQLite-only deploy --- .github/workflows/build.yml | 48 ++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a54948f..5f017d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,8 @@ jobs: workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] env: VARIANT: ${{ matrix.workshop }} + CC: gcc-13 + CXX: g++-13 steps: - uses: actions/checkout@v7 @@ -44,8 +46,10 @@ jobs: - name: Install system dependencies run: | + # GCC 13 provides C++23 while Jammy preserves glibc 2.35. + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test sudo apt-get update - sudo apt-get install -y ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev + sudo apt-get install -y gcc-13 g++-13 ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev - name: Build QCoro against Qt shell: bash @@ -104,6 +108,8 @@ jobs: curl -fsSL -o "$tools/linuxdeploy-plugin-qt" https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage chmod +x "$tools/linuxdeploy" "$tools/linuxdeploy-plugin-qt" cp src/ui/assets/logo.svg dist/pxmodrim.svg + # Only SQLite is used; other drivers require external database clients. + find "$QT_ROOT_DIR/plugins/sqldrivers" -type f ! -name libqsqlite.so -delete export PATH="$tools:$PATH" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 export QMAKE="$QT_ROOT_DIR/bin/qmake" QML_SOURCES_PATHS="$PWD/src/ui" export OUTPUT="$PWD/dist/PxModRim-Linux-x86_64-$VARIANT.AppImage" @@ -119,7 +125,11 @@ jobs: shell: bash run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" + if [ "$GITHUB_REF_TYPE" = tag ]; then + version="${GITHUB_REF_NAME#v}" + else + version=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' build/release/CMakeCache.txt) + fi pkg=dist/deb rm -rf "$pkg" @@ -311,19 +321,24 @@ jobs: fi rm -f dist/PxModRim/bin/qtwebengine_devtools_resources.pak - - name: Build NSIS installer - shell: bash + - name: Build NSIS installer and zip + shell: pwsh run: | - set -euo pipefail - version="${GITHUB_REF_NAME#v}" - makensis.exe "/DVERSION=$version" "/DDIST_DIR=$PWD/dist/PxModRim/bin" \ - "/DOUTPUT_FILE=$PWD/dist/PxModRim-Windows-$VARIANT-Setup.exe" packaging/windows/installer.nsi + if ($env:GITHUB_REF_TYPE -eq "tag") { + $version = $env:GITHUB_REF_NAME -replace '^v', '' + } else { + $version = (Select-String '^CMAKE_PROJECT_VERSION:STATIC=' build/release/CMakeCache.txt).Line.Split('=')[1] + } + makensis.exe "/DVERSION=$version" "/DDIST_DIR=$PWD/dist/PxModRim/bin" ` + "/DOUTPUT_FILE=$PWD/dist/PxModRim-Windows-$env:VARIANT-Setup.exe" packaging/windows/installer.nsi + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + 7z a "dist/PxModRim-Windows-$env:VARIANT.zip" "./dist/PxModRim/bin/*" - name: Upload artifact (dist) uses: actions/upload-artifact@v7 with: name: PxModRim-Windows-${{ matrix.workshop }} - path: dist/PxModRim/bin/* + path: dist/PxModRim-Windows-${{ matrix.workshop }}.zip - name: Upload artifact (installer) uses: actions/upload-artifact@v7 @@ -411,10 +426,15 @@ jobs: cmake --install build/release --prefix "$PWD/dist/stage" mv dist/stage/pxmodrim.app dist/PxModRim.app rm -rf dist/stage + # Only SQLite is used; other drivers require external database clients. + find "$QT_ROOT_DIR/plugins/sqldrivers" -type f ! -name libqsqlite.dylib -delete macdeployqt dist/PxModRim.app -qmldir=src/ui -always-overwrite mkdir -p dist/PxModRim.app/Contents/Frameworks - dylibbundler -od -b -x dist/PxModRim.app/Contents/MacOS/pxmodrim \ + # Preserve the Qt frameworks already copied by macdeployqt. + dylibbundler -of -b -x dist/PxModRim.app/Contents/MacOS/pxmodrim \ + -s "$PWD/dist/PxModRim.app/Contents/Frameworks" \ -d dist/PxModRim.app/Contents/Frameworks/ -p @executable_path/../Frameworks/ + test -f dist/PxModRim.app/Contents/Frameworks/QtCore.framework/QtCore - name: Sign and notarize macOS app if: env.MACOS_SIGNING_CONFIGURED == 'true' @@ -507,12 +527,6 @@ jobs: path: artifacts merge-multiple: false - - name: Package Windows release - run: | - for variant in NoWorkshop SteamWorkshop NativeWorkshop; do - 7z a "PxModRim-Windows-${variant}.zip" "./artifacts/PxModRim-Windows-${variant}/*" - done - - name: Create Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: @@ -522,7 +536,7 @@ jobs: artifacts/PxModRim-Linux-Deb-*/*.deb artifacts/PxModRim-Linux-RPM-*/*.rpm artifacts/PxModRim-Flatpak-*/*.flatpak - PxModRim-Windows-*.zip + artifacts/PxModRim-Windows-*/*.zip artifacts/PxModRim-Windows-Installer-*/*-Setup.exe artifacts/PxModRim-macOS-DMG-*/*.dmg artifacts/PxModRim-macOS-ZIP-*/*.zip From 81d46162c9b3eda15b202c157b7df899c1c18c95 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 02:54:43 +0300 Subject: [PATCH 48/50] docs: record release packaging workflow fixes --- AGENTS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea83798..88ad841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,11 +142,12 @@ C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): - Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. ## Packaging -Release builds run on `v*` tags (`build.yml`). Each variant runs `cmake --build --preset release` and `cmake --install`, then: -- **Linux**: install into `dist/AppDir/usr`; linuxdeploy + linuxdeploy-plugin-qt write the AppImage and deploy Qt into the same tree; `PxModRim-Linux-.tar.gz` is `usr/`; `.deb` (`/opt/pxmodrim`, `/usr/bin/pxmodrim` symlink) and `.rpm` (`packaging/linux/pxmodrim.spec`) are built from the same tree. The Flatpak manifest (`packaging/flatpak/`) consumes the tarball. -- **Windows**: install into `dist/PxModRim/bin`, rename to `PxModRim.exe`, `windeployqt --release --no-translations --qmldir src/ui`, then the NSIS installer (`packaging/windows/installer.nsi`) and the release zip. -- **macOS**: install as `pxmodrim.app`, rename to `PxModRim.app`, `macdeployqt -qmldir=src/ui`, `dylibbundler` for Homebrew dylibs. Developer ID signing and notarization run only when the secrets are set; otherwise the app is ad-hoc signed. -- The version comes from the tag for artifact names; keep `project(VERSION)` in the root CMakeLists in step with it. +Release builds run on `v*` tags or manual dispatch (`build.yml`); only tag runs publish a GitHub release. Each variant runs `cmake --build --preset release` and `cmake --install`, then: +- **Linux**: Ubuntu 22.04 with GCC 13 from `ubuntu-toolchain-r/test` supplies C++23 while retaining the glibc 2.35 floor. Install into `dist/AppDir/usr`; linuxdeploy + linuxdeploy-plugin-qt write the AppImage and deploy Qt into the same tree; `PxModRim-Linux-.tar.gz` is `usr/`; `.deb` (`/opt/pxmodrim`, `/usr/bin/pxmodrim` symlink) and `.rpm` (`packaging/linux/pxmodrim.spec`) are built from the same tree. The Flatpak manifest (`packaging/flatpak/`) consumes the tarball. +- **Windows**: install into `dist/PxModRim/bin`, rename to `PxModRim.exe`, `windeployqt --release --no-translations --qmldir src/ui`, then build the NSIS installer (`packaging/windows/installer.nsi`) and release zip in the Windows job, including on manual dispatch. Invoke NSIS from PowerShell so Git Bash does not convert its `/D` options into paths. +- **macOS**: install as `pxmodrim.app`, rename to `PxModRim.app`, `macdeployqt -qmldir=src/ui`, `dylibbundler -of` for Homebrew dylibs while preserving the deployed Qt frameworks (`-od` would erase them). Developer ID signing and notarization run only when the secrets are set; otherwise the app is ad-hoc signed. +- Package versions come from the tag on tag runs and `CMAKE_PROJECT_VERSION` on manual dispatch; keep `project(VERSION)` in the root CMakeLists in step with release tags. +- Linux and macOS deploy only the SQLite SQL driver; other Qt SQL drivers require external database clients that the application does not use. ## AI PRs PR description starts with: "Greetings, PyXiion! The silicon-based contributor is here." From 96510ef31ad0eda617ac5c1b4af0fdba7e746c08 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 03:17:54 +0300 Subject: [PATCH 49/50] fix(release): bundle libssl on Linux; prune unused Qt pieces and smoke-test packages --- .github/workflows/build.yml | 29 +++++++++++++++++++---- AGENTS.md | 2 ++ scripts/prune-qt-deploy.sh | 47 +++++++++++++++++++++++++++++++++++++ scripts/smoke-package.sh | 32 +++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100755 scripts/prune-qt-deploy.sh create mode 100755 scripts/smoke-package.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f017d9..341c6da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: # GCC 13 provides C++23 while Jammy preserves glibc 2.35. sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test sudo apt-get update - sudo apt-get install -y gcc-13 g++-13 ninja-build ccache dpkg patchelf rpm file libfuse2 libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev + sudo apt-get install -y gcc-13 g++-13 ninja-build ccache dpkg patchelf rpm file libfuse2 xvfb libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev - name: Build QCoro against Qt shell: bash @@ -87,7 +87,7 @@ jobs: run: cmake --build --preset release - name: Install into AppDir - run: cmake --install build/release --prefix "$PWD/dist/AppDir/usr" + run: cmake --install build/release --prefix "$PWD/dist/AppDir/usr" --strip - name: Verify glibc floor run: | @@ -113,12 +113,23 @@ jobs: export PATH="$tools:$PATH" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 export QMAKE="$QT_ROOT_DIR/bin/qmake" QML_SOURCES_PATHS="$PWD/src/ui" export OUTPUT="$PWD/dist/PxModRim-Linux-x86_64-$VARIANT.AppImage" + # Qt's OpenSSL backend dlopens libssl at run time and linuxdeploy only + # sees libcrypto, so a host libssl of another version would not pair + # with the bundled libcrypto and TLS would fail to initialize. + libdir=/lib/x86_64-linux-gnu linuxdeploy --appdir dist/AppDir \ --executable dist/AppDir/usr/bin/pxmodrim \ --desktop-file dist/AppDir/usr/share/applications/pxmodrim.desktop \ --icon-file dist/pxmodrim.svg \ - --plugin qt \ - --output appimage + --library "$libdir/libssl.so.3" \ + --library "$libdir/libcrypto.so.3" \ + --plugin qt + keep="" + if [ "$VARIANT" = SteamWorkshop ]; then keep=keep-dialogs; fi + scripts/prune-qt-deploy.sh dist/AppDir/usr $keep + test -f dist/AppDir/usr/lib/libssl.so.3 + xvfb-run -a scripts/smoke-package.sh dist/AppDir/usr/bin/pxmodrim + linuxdeploy --appdir dist/AppDir --output appimage tar -czf "dist/PxModRim-Linux-$VARIANT.tar.gz" -C dist/AppDir usr - name: Build deb and rpm @@ -320,6 +331,10 @@ jobs: find dist/PxModRim/bin/qtwebengine_locales -name '*.pak' ! -name 'en-US.pak' ! -name 'en-GB.pak' -delete fi rm -f dist/PxModRim/bin/qtwebengine_devtools_resources.pak + keep="" + if [ "$VARIANT" = SteamWorkshop ]; then keep=keep-dialogs; fi + scripts/prune-qt-deploy.sh dist/PxModRim/bin $keep + scripts/smoke-package.sh dist/PxModRim/bin/PxModRim.exe - name: Build NSIS installer and zip shell: pwsh @@ -429,6 +444,9 @@ jobs: # Only SQLite is used; other drivers require external database clients. find "$QT_ROOT_DIR/plugins/sqldrivers" -type f ! -name libqsqlite.dylib -delete macdeployqt dist/PxModRim.app -qmldir=src/ui -always-overwrite + keep="" + if [ "$VARIANT" = SteamWorkshop ]; then keep=keep-dialogs; fi + scripts/prune-qt-deploy.sh dist/PxModRim.app $keep mkdir -p dist/PxModRim.app/Contents/Frameworks # Preserve the Qt frameworks already copied by macdeployqt. dylibbundler -of -b -x dist/PxModRim.app/Contents/MacOS/pxmodrim \ @@ -497,6 +515,9 @@ jobs: -srcfolder dist/PxModRim.app \ -ov -format UDZO dist/PxModRim-macOS.dmg + - name: Smoke test the packaged app + run: scripts/smoke-package.sh dist/PxModRim.app/Contents/MacOS/pxmodrim + - name: Rename macOS packages run: | set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md index 88ad841..a0b2132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,8 @@ Release builds run on `v*` tags or manual dispatch (`build.yml`); only tag runs - **macOS**: install as `pxmodrim.app`, rename to `PxModRim.app`, `macdeployqt -qmldir=src/ui`, `dylibbundler -of` for Homebrew dylibs while preserving the deployed Qt frameworks (`-od` would erase them). Developer ID signing and notarization run only when the secrets are set; otherwise the app is ad-hoc signed. - Package versions come from the tag on tag runs and `CMAKE_PROJECT_VERSION` on manual dispatch; keep `project(VERSION)` in the root CMakeLists in step with release tags. - Linux and macOS deploy only the SQLite SQL driver; other Qt SQL drivers require external database clients that the application does not use. +- Every package goes through `scripts/prune-qt-deploy.sh` after deployment, which drops what the UI never loads (non-Basic Quick Controls styles, Particles, Effects, Qt `.qm` catalogs; Dialogs too unless the variant is SteamWorkshop, because QtWebEngine's QML delegates import `QtQuick.Dialogs`), then `scripts/smoke-package.sh` starts the packaged app and fails on missing QML modules or libraries (Linux runs it under `xvfb-run`). A new QML import of a pruned module needs its entry removed from the prune script. +- The Linux bundle must ship `libssl.so.3` next to `libcrypto.so.3`: Qt's OpenSSL backend `dlopen`s libssl, and a host libssl of another version cannot pair with the bundled libcrypto (TLS then fails to initialize). `cmake --install --strip` strips the executable. ## AI PRs PR description starts with: "Greetings, PyXiion! The silicon-based contributor is here." diff --git a/scripts/prune-qt-deploy.sh b/scripts/prune-qt-deploy.sh new file mode 100755 index 0000000..268881d --- /dev/null +++ b/scripts/prune-qt-deploy.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Removes Qt pieces PxModRim never loads from a deployed tree (linuxdeploy, +# windeployqt or macdeployqt output). The UI runs on the Basic Quick Controls +# style, imports no particles or effects and installs no Qt translators. +# +# usage: prune-qt-deploy.sh [keep-dialogs] +# +# QtWebEngine's QML delegates import QtQuick.Dialogs, so the web browser +# variant passes keep-dialogs. +set -euo pipefail + +root="${1:?usage: prune-qt-deploy.sh [keep-dialogs]}" +keep_dialogs="${2:-}" + +remove_dirs() { + find "$root" -type d \( "$@" \) -prune -exec rm -rf {} + +} +remove_files() { + find "$root" \( "$@" \) -prune -exec rm -rf {} + +} + +remove_dirs -path '*/QtQuick/Controls/FluentWinUI3' \ + -o -path '*/QtQuick/Controls/Fusion' \ + -o -path '*/QtQuick/Controls/Imagine' \ + -o -path '*/QtQuick/Controls/Material' \ + -o -path '*/QtQuick/Controls/Universal' \ + -o -path '*/QtQuick/Controls/designer' \ + -o -path '*/QtQuick/Particles' \ + -o -path '*/QtQuick/Effects' + +# Libraries, DLLs and frameworks, named per platform +# (libQt6X.so.6, Qt6X.dll, QtX.framework). +remove_files -name '*QuickControls2FluentWinUI3*' \ + -o -name '*QuickControls2Fusion*' \ + -o -name '*QuickControls2Imagine*' \ + -o -name '*QuickControls2Material*' \ + -o -name '*QuickControls2Universal*' \ + -o -name '*QuickParticles*' \ + -o -name '*QuickEffects*' + +if [ "$keep_dialogs" != keep-dialogs ]; then + remove_dirs -path '*/QtQuick/Dialogs' + remove_files -name '*QuickDialogs2*' +fi + +# Only the .qm catalogs; QtWebEngine keeps its locale .pak files. +find "$root" -path '*/translations/*.qm' -delete diff --git a/scripts/smoke-package.sh b/scripts/smoke-package.sh new file mode 100755 index 0000000..ebe26cf --- /dev/null +++ b/scripts/smoke-package.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Starts a deployed build headless and fails on missing Qt pieces: QML modules +# or libraries that deployment dropped. +# The bundle ships only the platform plugin of its OS, so Linux needs a display +# (xvfb-run -a scripts/smoke-package.sh ...). +# +# usage: smoke-package.sh [seconds] +set -uo pipefail + +exe="${1:?usage: smoke-package.sh [seconds]}" +seconds="${2:-10}" + +home="$(mktemp -d)" +log="$home/out.txt" +HOME="$home" XDG_CONFIG_HOME="$home/.config" \ + PX_LOG_LEVEL=info timeout "$seconds" "$exe" >"$log" 2>&1 +status=$? +cat "$log" + +# timeout exits 124 when the app was still running, which is the success case. +if [ "$status" -ne 124 ]; then + echo "smoke: app exited early with status $status" >&2 + exit 1 +fi +if grep -Eiq 'is not installed|cannot load library|failed to load' "$log"; then + echo "smoke: deployment is missing a Qt component" >&2 + exit 1 +fi +if ! grep -q 'Services initialized' "$log"; then + echo "smoke: startup did not finish" >&2 + exit 1 +fi From 809c6e790c1ad8ed42d4db34a843daad5fbcfc47 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Sat, 17 Oct 2026 03:30:53 +0300 Subject: [PATCH 50/50] fix(release): smoke test without GNU timeout, which macOS lacks --- scripts/smoke-package.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/smoke-package.sh b/scripts/smoke-package.sh index ebe26cf..d2dfd57 100755 --- a/scripts/smoke-package.sh +++ b/scripts/smoke-package.sh @@ -12,16 +12,21 @@ seconds="${2:-10}" home="$(mktemp -d)" log="$home/out.txt" -HOME="$home" XDG_CONFIG_HOME="$home/.config" \ - PX_LOG_LEVEL=info timeout "$seconds" "$exe" >"$log" 2>&1 -status=$? -cat "$log" - -# timeout exits 124 when the app was still running, which is the success case. -if [ "$status" -ne 124 ]; then +# No `timeout`: it does not exist on macOS runners. +HOME="$home" XDG_CONFIG_HOME="$home/.config" PX_LOG_LEVEL=info \ + "$exe" >"$log" 2>&1 & +pid=$! +sleep "$seconds" +if ! kill "$pid" 2>/dev/null; then + status=0 + wait "$pid" || status=$? + cat "$log" echo "smoke: app exited early with status $status" >&2 exit 1 fi +wait "$pid" 2>/dev/null || true +cat "$log" + if grep -Eiq 'is not installed|cannot load library|failed to load' "$log"; then echo "smoke: deployment is missing a Qt component" >&2 exit 1